0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor(core): separate session routes and UTs (#1104)

* refactor(core): separate session routes and UTs

* refactor(core): reorganize and rename files
This commit is contained in:
Darcy Ye 2022-06-14 21:48:05 +08:00 committed by GitHub
parent efa9491749
commit 985fe7203a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1594 additions and 1458 deletions

View file

@ -10,7 +10,9 @@ import applicationRoutes from '@/routes/application';
import connectorRoutes from '@/routes/connector';
import dashboardRoutes from '@/routes/dashboard';
import resourceRoutes from '@/routes/resource';
import sessionRoutes from '@/routes/session';
import sessionPasswordlessRoutes from '@/routes/session/passwordless';
import sessionRoutes from '@/routes/session/session';
import sessionSocialRoutes from '@/routes/session/social';
import settingRoutes from '@/routes/setting';
import signInExperiencesRoutes from '@/routes/sign-in-experience';
import signInSettingsRoutes from '@/routes/sign-in-settings';
@ -27,6 +29,8 @@ const createRouters = (provider: Provider) => {
const sessionRouter: AnonymousRouter = new Router();
sessionRouter.use(koaLogSession(provider));
sessionRoutes(sessionRouter, provider);
sessionPasswordlessRoutes(sessionRouter, provider);
sessionSocialRoutes(sessionRouter, provider);
const managementRouter: AuthedRouter = new Router();
managementRouter.use(koaAuth(UserRole.Admin));

View file

@ -1,908 +0,0 @@
/* eslint-disable max-lines */
import { User } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import {
mockUser,
mockAliyunDmConnectorInstance,
mockAliyunSmsConnectorInstance,
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockGoogleConnectorInstance,
mockWechatConnectorInstance,
mockWechatNativeConnectorInstance,
} from '@/__mocks__';
import { ConnectorType } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import sessionRoutes from './session';
jest.mock('@/lib/user', () => ({
async findUserByUsernameAndPassword(username: string, password: string) {
if (username !== 'username') {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
if (password !== 'password') {
throw new RequestError('session.invalid_credentials');
}
return { id: 'user1' };
},
generateUserId: () => 'user1',
encryptUserPassword: (password: string) => ({
passwordEncrypted: password + '_user1',
passwordEncryptionMethod: 'Argon2i',
}),
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
}));
jest.mock('@/lib/social', () => ({
...jest.requireActual('@/lib/social'),
async findSocialRelatedUser() {
return ['phone', { id: 'user1', identities: {} }];
},
async getUserInfoByAuthCode(connectorId: string, data: { code: string }) {
if (connectorId === '_connectorId') {
throw new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
});
}
if (data.code === '123456') {
return { id: 'id' };
}
// This mocks the case that can not get userInfo with access token and auth code
// (most likely third-party social connectors' problem).
throw new Error(' ');
},
}));
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByIdentity: async () => ({ id: 'id', identities: {} }),
findUserByPhone: async () => ({ id: 'id' }),
findUserByEmail: async () => ({ id: 'id' }),
insertUser: async (...args: unknown[]) => insertUser(...args),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUser: async (username: string) => username === 'username1',
hasUserWithIdentity: async (connectorId: string, userId: string) =>
connectorId === 'connectorId' && userId === 'id',
hasUserWithPhone: async (phone: string) => phone === '13000000000',
hasUserWithEmail: async (email: string) => email === 'a@a.com',
}));
const sendPasscode = jest.fn(async () => ({ connector: { id: 'connectorIdValue' } }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
sendPasscode: async () => sendPasscode(),
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
if (code !== '1234') {
throw new RequestError('passcode.code_mismatch');
}
},
}));
const getAuthorizationUri = jest.fn(async (_state: string, _redirectUri: string) => {
return '';
});
const getConnectorInstanceById = jest.fn(async (connectorId: string) => {
const connector = {
enabled: connectorId === 'social_enabled',
};
const metadata = {
id:
connectorId === 'social_enabled'
? 'social_enabled'
: connectorId === 'social_disabled'
? 'social_disabled'
: 'others',
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.SMS,
};
return { connector, metadata, getAuthorizationUri };
});
const getConnectorInstances = jest.fn(async () => [
mockAliyunDmConnectorInstance,
mockAliyunSmsConnectorInstance,
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockGoogleConnectorInstance,
mockWechatConnectorInstance,
mockWechatNativeConnectorInstance,
]);
jest.mock('@/connectors', () => ({
getSocialConnectorInstanceById: async (connectorId: string) => {
const connectorInstance = await getConnectorInstanceById(connectorId);
if (connectorInstance.metadata.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
return connectorInstance;
},
getConnectorInstances: async () => getConnectorInstances(),
}));
const grantSave = jest.fn(async () => 'finalGrantId');
const grantAddOIDCScope = jest.fn();
const grantAddResourceScope = jest.fn();
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
class Grant {
static async find(id: string) {
return id === 'exists' ? new Grant() : undefined;
}
save: typeof grantSave;
addOIDCScope: typeof grantAddOIDCScope;
addResourceScope: typeof grantAddResourceScope;
constructor() {
this.save = grantSave;
this.addOIDCScope = grantAddOIDCScope;
this.addResourceScope = grantAddResourceScope;
}
}
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
Grant,
interactionDetails,
interactionResult,
})),
}));
afterEach(() => {
grantSave.mockClear();
interactionResult.mockClear();
});
describe('sessionRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: sessionRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
describe('POST /session', () => {
it('should redirect to /session/consent with consent prompt name', async () => {
interactionDetails.mockResolvedValueOnce({
prompt: { name: 'consent' },
});
const response = await sessionRequest.post('/session');
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty(
'redirectTo',
expect.stringContaining('/session/consent')
);
});
it('throw error with other prompt name', async () => {
interactionDetails.mockResolvedValueOnce({
prompt: { name: 'invalid' },
});
await expect(sessionRequest.post('/session').send({})).resolves.toHaveProperty('status', 400);
});
});
describe('POST /session/sign-in/username-password', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest.post('/session/sign-in/username-password').send({
username: 'username',
password: 'password',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw if user not found', async () => {
const response = await sessionRequest.post('/session/sign-in/username-password').send({
username: 'notexistuser',
password: 'password',
});
expect(response.statusCode).toEqual(404);
});
it('throw if user found but wrong password', async () => {
const response = await sessionRequest.post('/session/sign-in/username-password').send({
username: 'username',
password: '_password',
});
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/passwordless/sms/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('it should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/send-passcode')
.send({ phone: '13000000000' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if phone does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/send-passcode')
.send({ phone: '13000000001' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/sign-in/passwordless/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/verify-passcode')
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw error if phone does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/verify-passcode')
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/verify-passcode')
.send({ phone: '13000000000', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/passwordless/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValue({
jti: 'jti',
});
});
it('it should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'a@a.com' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if email does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/sign-in/passwordless/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/verify-passcode')
.send({ email: 'a@a.com', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw error if email does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/verify-passcode')
.send({ email: 'a@a.com', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/social', () => {
it('should throw when redirectURI is invalid', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('sign-in with social and redirect', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.body).toHaveProperty('redirectTo', '');
});
it('throw error when sign-in with social but miss state', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when sign-in with social but miss redirectUri', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
state: 'state',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when connector is disabled', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_disabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'others',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(404);
});
});
describe('POST /session/sign-in/social/auth', () => {
it('throw error when auth code is wrong', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: 'connectorId',
state: 'state',
redirectUri: 'https://logto.dev',
code: '123455',
});
expect(response.statusCode).toEqual(500);
});
it('throw error when code is provided but connector can not be found', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: '_connectorId',
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
});
expect(response.statusCode).toEqual(422);
});
it('get and add user info with auth code, as well as assign result and redirect', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: 'connectorId',
data: {
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
},
});
expect(updateUserById).toHaveBeenCalledWith(
'id',
expect.objectContaining({
identities: { connectorId: { userId: 'id', details: { id: 'id' } } },
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw error when identity exists', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: '_connectorId_',
data: {
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
},
});
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
socialUserInfo: { connectorId: '_connectorId_', userInfo: { id: 'id' } },
}),
expect.anything()
);
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/sign-in/bind-social-related-user', () => {
it('throw if session is not authorized', async () => {
await expect(
sessionRequest
.post('/session/sign-in/bind-social-related-user')
.send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('throw if no social info in session', async () => {
interactionDetails.mockResolvedValueOnce({
result: { login: { accountId: 'user1' } },
});
await expect(
sessionRequest
.post('/session/sign-in/bind-social-related-user')
.send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('updates user identities and sign in', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'user1' },
socialUserInfo: {
connectorId: 'connectorId',
userInfo: { id: 'connectorUser', phone: 'phone' },
},
},
});
const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({
connectorId: 'connectorId',
});
expect(response.statusCode).toEqual(200);
expect(updateUserById).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
identities: {
connectorId: {
details: { id: 'connectorUser', phone: 'phone' },
userId: 'connectorUser',
},
},
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
});
describe('POST /session/register/username-password', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/register/username-password')
.send({ username: 'username', password: 'password' });
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user1',
username: 'username',
passwordEncrypted: 'password_user1',
passwordEncryptionMethod: 'Argon2i',
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if username not valid', async () => {
const usernameStartedWithNumber = '1username';
const response = await sessionRequest
.post('/session/register/username-password')
.send({ username: usernameStartedWithNumber, password: 'password' });
expect(response.statusCode).toEqual(400);
});
it('throw error if username exists', async () => {
const response = await sessionRequest
.post('/session/register/username-password')
.send({ username: 'username1', password: 'password' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/register/passwordless/sms/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('it should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: '13000000001' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if phone not valid (charactors other than digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: '1300000000a' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone not valid (without digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: 'abcdefg' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone exists', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: '13000000000' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/register/passwordless/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user1', primaryPhone: '13000000001' })
);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if phone not valid (characters other than digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '1300000000a', code: '1234' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone not valid (without digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: 'abcdefg', code: '1234' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone exists', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '13000000001', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/register/passwordless/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('it should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if email not valid', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/send-passcode')
.send({ email: 'aaa.com' });
expect(response.statusCode).toEqual(400);
});
it('throw error if email exists', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/send-passcode')
.send({ email: 'a@a.com' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/register/passwordless/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/verify-passcode')
.send({ email: 'b@a.com', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user1', primaryEmail: 'b@a.com' })
);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if email not valid', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'aaa.com' });
expect(response.statusCode).toEqual(400);
});
it('throw error if email exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/verify-passcode')
.send({ email: 'a@a.com', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/register/social', () => {
it('register with social, assign result and redirect', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'user1' } },
},
});
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user1',
identities: { connectorId: { userId: 'user1', details: { id: 'user1' } } },
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if no result can be found in interactionResults', async () => {
interactionDetails.mockResolvedValueOnce({});
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
it('throw error if result parsing fails', async () => {
interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: 'id' } } });
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
it('throw error when user with identity exists', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'user1' },
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'id' } },
},
});
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/bind-social', () => {
it('throw if session is not authorized', async () => {
interactionDetails.mockResolvedValueOnce({});
await expect(
sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('throw if no social info in session', async () => {
interactionDetails.mockResolvedValueOnce({
result: { login: { accountId: 'user1' } },
});
await expect(
sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('updates user identities', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
login: { accountId: 'user1' },
socialUserInfo: {
connectorId: 'connectorId',
userInfo: { id: 'connectorUser', phone: 'phone' },
},
},
});
const response = await sessionRequest.post('/session/bind-social').send({
connectorId: 'connectorId',
});
expect(response.statusCode).toEqual(200);
expect(updateUserById).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
identities: {
connectorId: {
details: { id: 'connectorUser', phone: 'phone' },
userId: 'connectorUser',
},
},
})
);
});
});
describe('POST /session/consent', () => {
describe('should call grant.save() and assign interaction results', () => {
afterEach(() => {
updateUserById.mockClear();
});
it('with empty details and reusing old grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('with empty details and creating new grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
grantId: 'exists',
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('should save application id when the user first consented', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: mockUser.id },
params: { client_id: 'clientId' },
prompt: {
name: 'consent',
details: {},
reasons: ['consent_prompt', 'native_client_prompt'],
},
grantId: 'grantId',
});
findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null }));
const response = await sessionRequest.post('/session/consent');
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' });
expect(response.statusCode).toEqual(200);
});
it('missingOIDCScope and missingResourceScopes', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: {
details: {
missingOIDCScope: ['scope1', 'scope2'],
missingResourceScopes: {
resource1: ['scope1', 'scope2'],
resource2: ['scope3'],
},
},
},
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
});
it('throws if session is missing', async () => {
interactionDetails.mockResolvedValueOnce({ params: { client_id: 'clientId' } });
await expect(sessionRequest.post('/session/consent')).resolves.toHaveProperty(
'statusCode',
400
);
});
});
it('DELETE /session', async () => {
const response = await sessionRequest.delete('/session');
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ error: 'oidc.aborted' }),
expect.anything()
);
});
});
/* eslint-enable max-lines */

View file

@ -1,549 +0,0 @@
/* eslint-disable max-lines */
import path from 'path';
import { LogtoErrorCode } from '@logto/phrases';
import { PasscodeType, userInfoSelectFields } from '@logto/schemas';
import {
redirectUriRegEx,
emailRegEx,
passwordRegEx,
phoneRegEx,
usernameRegEx,
} from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import pick from 'lodash.pick';
import { Provider } from 'oidc-provider';
import { object, string, unknown } from 'zod';
import { getSocialConnectorInstanceById } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import { assignInteractionResults, saveUserFirstConsentedAppId } from '@/lib/session';
import {
findSocialRelatedUser,
getUserInfoByAuthCode,
getUserInfoFromInteractionResult,
} from '@/lib/social';
import {
encryptUserPassword,
generateUserId,
findUserByUsernameAndPassword,
updateLastSignInAt,
} from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
hasUserWithEmail,
hasUserWithPhone,
hasUser,
hasUserWithIdentity,
insertUser,
findUserById,
updateUserById,
findUserByEmail,
findUserByPhone,
findUserByIdentity,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { maskUserInfo } from '@/utils/format';
import { AnonymousRouter } from './types';
export default function sessionRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
router.post('/session', async (ctx, next) => {
const {
prompt: { name },
} = await provider.interactionDetails(ctx.req, ctx.res);
if (name === 'consent') {
ctx.body = { redirectTo: path.join(ctx.request.origin, '/session/consent') };
return next();
}
throw new RequestError('session.unsupported_prompt_name');
});
router.post(
'/session/sign-in/username-password',
koaGuard({
body: object({
username: string().nonempty(),
password: string().nonempty(),
}),
}),
async (ctx, next) => {
const { username, password } = ctx.guard.body;
const type = 'SignInUsernamePassword';
ctx.log(type, { username });
const { id } = await findUserByUsernameAndPassword(username, password);
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post(
'/session/sign-in/passwordless/sms/send-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'SignInSmsSendPasscode';
ctx.log(type, { phone });
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/sign-in/passwordless/sms/verify-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'SignInSms';
ctx.log(type, { phone, code });
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post(
'/session/sign-in/passwordless/email/send-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'SignInEmailSendPasscode';
ctx.log(type, { email });
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/sign-in/passwordless/email/verify-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'SignInEmail';
ctx.log(type, { email, code });
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
const { id } = await findUserByEmail(email);
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post(
'/session/sign-in/social',
koaGuard({
body: object({
connectorId: string(),
state: string(),
redirectUri: string().regex(redirectUriRegEx),
}),
}),
async (ctx, next) => {
const { connectorId, state, redirectUri } = ctx.guard.body;
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getSocialConnectorInstanceById(connectorId);
assertThat(connector.connector.enabled, 'connector.not_enabled');
const redirectTo = await connector.getAuthorizationUri({ state, redirectUri });
ctx.body = { redirectTo };
return next();
}
);
router.post(
'/session/sign-in/social/auth',
koaGuard({
body: object({
connectorId: string(),
data: unknown(),
}),
}),
async (ctx, next) => {
const { connectorId, data } = ctx.guard.body;
const type = 'SignInSocial';
ctx.log(type, { connectorId, data });
const userInfo = await getUserInfoByAuthCode(connectorId, data);
ctx.log(type, { userInfo });
if (!(await hasUserWithIdentity(connectorId, userInfo.id))) {
await assignInteractionResults(
ctx,
provider,
{ socialUserInfo: { connectorId, userInfo } },
true
);
const relatedInfo = await findSocialRelatedUser(userInfo);
throw new RequestError(
{
code: 'user.identity_not_exists',
status: 422,
},
relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) }
);
}
const { id, identities } = await findUserByIdentity(connectorId, userInfo.id);
ctx.log(type, { userId: id });
// Update social connector's user info
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/sign-in/bind-social-related-user',
koaGuard({
body: object({ connectorId: string() }),
}),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
assertThat(result, 'session.connector_session_not_found');
const { connectorId } = ctx.guard.body;
const type = 'SignInSocialBind';
ctx.log(type, { connectorId });
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
ctx.log(type, { userInfo });
const relatedInfo = await findSocialRelatedUser(userInfo);
assertThat(relatedInfo, 'session.connector_session_not_found');
const { id, identities } = relatedInfo[1];
ctx.log(type, { userId: id });
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post('/session/consent', async (ctx, next) => {
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
const {
session,
grantId,
params: { client_id },
prompt,
} = interaction;
assertThat(session, 'session.not_found');
const { accountId } = session;
const grant =
conditional(grantId && (await provider.Grant.find(grantId))) ??
new provider.Grant({ accountId, clientId: String(client_id) });
await saveUserFirstConsentedAppId(accountId, String(client_id));
// V2: fulfill missing claims / resources
const PromptDetailsBody = object({
missingOIDCScope: string().array().optional(),
missingResourceScopes: object({}).catchall(string().array()).optional(),
});
const { missingOIDCScope, missingResourceScopes } = PromptDetailsBody.parse(prompt.details);
if (missingOIDCScope) {
grant.addOIDCScope(missingOIDCScope.join(' '));
}
if (missingResourceScopes) {
for (const [indicator, scope] of Object.entries(missingResourceScopes)) {
grant.addResourceScope(indicator, scope.join(' '));
}
}
const finalGrantId = await grant.save();
// V2: configure consent
await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true);
return next();
});
router.post(
'/session/register/username-password',
koaGuard({
body: object({
username: string().regex(usernameRegEx),
password: string().regex(passwordRegEx),
}),
}),
async (ctx, next) => {
const { username, password } = ctx.guard.body;
const type = 'RegisterUsernamePassword';
ctx.log(type, { username });
assertThat(
!(await hasUser(username)),
new RequestError({
code: 'user.username_exists_register',
status: 422,
})
);
const id = await generateUserId();
ctx.log(type, { userId: id });
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await insertUser({
id,
username,
passwordEncrypted,
passwordEncryptionMethod,
});
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/register/passwordless/sms/send-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'RegisterSmsSendPasscode';
ctx.log(type, { phone });
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/register/passwordless/sms/verify-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'RegisterSms';
ctx.log(type, { phone, code });
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
await verifyPasscode(jti, PasscodeType.Register, code, { phone });
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryPhone: phone });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/register/passwordless/email/send-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'RegisterEmailSendPasscode';
ctx.log(type, { email });
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/register/passwordless/email/verify-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'RegisterEmail';
ctx.log(type, { email, code });
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
await verifyPasscode(jti, PasscodeType.Register, code, { email });
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryEmail: email });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/register/social',
koaGuard({
body: object({
connectorId: string(),
}),
}),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
// User can not register with social directly,
// need to try to sign in with social first, then confirm to register and continue,
// so the result is expected to be exists.
assertThat(result, 'session.connector_session_not_found');
const { connectorId } = ctx.guard.body;
const type = 'RegisterSocial';
ctx.log(type, { connectorId });
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
ctx.log(type, { userInfo });
assertThat(!(await hasUserWithIdentity(connectorId, userInfo.id)), 'user.identity_exists');
const id = await generateUserId();
await insertUser({
id,
name: userInfo.name ?? null,
avatar: userInfo.avatar ?? null,
identities: {
[connectorId]: {
userId: userInfo.id,
details: userInfo,
},
},
});
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/bind-social',
koaGuard({
body: object({
connectorId: string(),
}),
}),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
assertThat(result, 'session.connector_session_not_found');
const userId = result.login?.accountId;
assertThat(userId, 'session.unauthorized');
const { connectorId } = ctx.guard.body;
const type = 'RegisterSocialBind';
ctx.log(type, { connectorId, userId });
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
ctx.log(type, { userInfo });
const user = await findUserById(userId);
const updatedUser = await updateUserById(userId, {
identities: {
...user.identities,
[connectorId]: { userId: userInfo.id, details: userInfo },
},
});
ctx.body = pick(updatedUser, ...userInfoSelectFields);
return next();
}
);
router.delete('/session', async (ctx, next) => {
await provider.interactionDetails(ctx.req, ctx.res);
const error: LogtoErrorCode = 'oidc.aborted';
await assignInteractionResults(ctx, provider, { error });
return next();
});
}
/* eslint-enable max-lines */

View file

@ -0,0 +1,317 @@
import { User } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import sessionPasswordlessRoutes from './passwordless';
jest.mock('@/lib/user', () => ({
generateUserId: () => 'user1',
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
}));
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByPhone: async () => ({ id: 'id' }),
findUserByEmail: async () => ({ id: 'id' }),
insertUser: async (...args: unknown[]) => insertUser(...args),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUser: async (username: string) => username === 'username1',
hasUserWithPhone: async (phone: string) => phone === '13000000000',
hasUserWithEmail: async (email: string) => email === 'a@a.com',
}));
const sendPasscode = jest.fn(async () => ({ connector: { id: 'connectorIdValue' } }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
sendPasscode: async () => sendPasscode(),
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
if (code !== '1234') {
throw new RequestError('passcode.code_mismatch');
}
},
}));
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
interactionResult,
})),
}));
afterEach(() => {
interactionResult.mockClear();
});
describe('sessionPasswordlessRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: sessionPasswordlessRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
describe('POST /session/sign-in/passwordless/sms/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/send-passcode')
.send({ phone: '13000000000' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if phone does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/send-passcode')
.send({ phone: '13000000001' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/sign-in/passwordless/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/verify-passcode')
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw error if phone does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/verify-passcode')
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/sms/verify-passcode')
.send({ phone: '13000000000', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/passwordless/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValue({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'a@a.com' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if email does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/sign-in/passwordless/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/verify-passcode')
.send({ email: 'a@a.com', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw error if email does not exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/verify-passcode')
.send({ email: 'a@a.com', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/register/passwordless/sms/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: '13000000001' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if phone not valid (charactors other than digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: '1300000000a' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone not valid (without digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: 'abcdefg' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone exists', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/send-passcode')
.send({ phone: '13000000000' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/register/passwordless/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user1', primaryPhone: '13000000001' })
);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if phone is invalid (characters other than digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '1300000000a', code: '1234' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone not valid (without digits)', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: 'abcdefg', code: '1234' });
expect(response.statusCode).toEqual(400);
});
it('throw error if phone exists', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/sms/verify-passcode')
.send({ phone: '13000000001', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/register/passwordless/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
it('throw error if email not valid', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/send-passcode')
.send({ email: 'aaa.com' });
expect(response.statusCode).toEqual(400);
});
it('throw error if email exists', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/send-passcode')
.send({ email: 'a@a.com' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/register/passwordless/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/register/passwordless/email/verify-passcode')
.send({ email: 'b@a.com', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user1', primaryEmail: 'b@a.com' })
);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if email not valid', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'aaa.com' });
expect(response.statusCode).toEqual(400);
});
it('throw error if email exist', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/send-passcode')
.send({ email: 'b@a.com' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post('/session/sign-in/passwordless/email/verify-passcode')
.send({ email: 'a@a.com', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
});

View file

@ -0,0 +1,219 @@
import { PasscodeType } from '@logto/schemas';
import { emailRegEx, phoneRegEx } from '@logto/shared';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import { assignInteractionResults } from '@/lib/session';
import { generateUserId, updateLastSignInAt } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
hasUserWithEmail,
hasUserWithPhone,
insertUser,
findUserByEmail,
findUserByPhone,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
export default function sessionPasswordlessRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.post(
'/session/sign-in/passwordless/sms/send-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'SignInSmsSendPasscode';
ctx.log(type, { phone });
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/sign-in/passwordless/sms/verify-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'SignInSms';
ctx.log(type, { phone, code });
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post(
'/session/sign-in/passwordless/email/send-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'SignInEmailSendPasscode';
ctx.log(type, { email });
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/sign-in/passwordless/email/verify-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'SignInEmail';
ctx.log(type, { email, code });
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
const { id } = await findUserByEmail(email);
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post(
'/session/register/passwordless/sms/send-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'RegisterSmsSendPasscode';
ctx.log(type, { phone });
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/register/passwordless/sms/verify-passcode',
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'RegisterSms';
ctx.log(type, { phone, code });
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
await verifyPasscode(jti, PasscodeType.Register, code, { phone });
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryPhone: phone });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/register/passwordless/email/send-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'RegisterEmailSendPasscode';
ctx.log(type, { email });
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/register/passwordless/email/verify-passcode',
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'RegisterEmail';
ctx.log(type, { email, code });
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
await verifyPasscode(jti, PasscodeType.Register, code, { email });
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryEmail: email });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
}

View file

@ -0,0 +1,295 @@
import { User } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import sessionRoutes from './session';
jest.mock('@/lib/user', () => ({
async findUserByUsernameAndPassword(username: string, password: string) {
if (username !== 'username') {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
if (password !== 'password') {
throw new RequestError('session.invalid_credentials');
}
return { id: 'user1' };
},
generateUserId: () => 'user1',
encryptUserPassword: (password: string) => ({
passwordEncrypted: password + '_user1',
passwordEncryptionMethod: 'Argon2i',
}),
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
}));
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByIdentity: async () => ({ id: 'id', identities: {} }),
findUserByPhone: async () => ({ id: 'id' }),
findUserByEmail: async () => ({ id: 'id' }),
insertUser: async (...args: unknown[]) => insertUser(...args),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUser: async (username: string) => username === 'username1',
hasUserWithIdentity: async (connectorId: string, userId: string) =>
connectorId === 'connectorId' && userId === 'id',
hasUserWithPhone: async (phone: string) => phone === '13000000000',
hasUserWithEmail: async (email: string) => email === 'a@a.com',
}));
const grantSave = jest.fn(async () => 'finalGrantId');
const grantAddOIDCScope = jest.fn();
const grantAddResourceScope = jest.fn();
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
class Grant {
static async find(id: string) {
return id === 'exists' ? new Grant() : undefined;
}
save: typeof grantSave;
addOIDCScope: typeof grantAddOIDCScope;
addResourceScope: typeof grantAddResourceScope;
constructor() {
this.save = grantSave;
this.addOIDCScope = grantAddOIDCScope;
this.addResourceScope = grantAddResourceScope;
}
}
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
Grant,
interactionDetails,
interactionResult,
})),
}));
afterEach(() => {
grantSave.mockClear();
interactionResult.mockClear();
});
describe('sessionRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: sessionRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
describe('POST /session', () => {
it('should redirect to /session/consent with consent prompt name', async () => {
interactionDetails.mockResolvedValueOnce({
prompt: { name: 'consent' },
});
const response = await sessionRequest.post('/session');
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty(
'redirectTo',
expect.stringContaining('/session/consent')
);
});
it('throw error with other prompt name', async () => {
interactionDetails.mockResolvedValueOnce({
prompt: { name: 'invalid' },
});
await expect(sessionRequest.post('/session').send({})).resolves.toHaveProperty('status', 400);
});
});
describe('POST /session/sign-in/username-password', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest.post('/session/sign-in/username-password').send({
username: 'username',
password: 'password',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw if user not found', async () => {
const response = await sessionRequest.post('/session/sign-in/username-password').send({
username: 'notexistuser',
password: 'password',
});
expect(response.statusCode).toEqual(404);
});
it('throw if user found but wrong password', async () => {
const response = await sessionRequest.post('/session/sign-in/username-password').send({
username: 'username',
password: '_password',
});
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/register/username-password', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post('/session/register/username-password')
.send({ username: 'username', password: 'password' });
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user1',
username: 'username',
passwordEncrypted: 'password_user1',
passwordEncryptionMethod: 'Argon2i',
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if username not valid', async () => {
const usernameStartedWithNumber = '1username';
const response = await sessionRequest
.post('/session/register/username-password')
.send({ username: usernameStartedWithNumber, password: 'password' });
expect(response.statusCode).toEqual(400);
});
it('throw error if username exists', async () => {
const response = await sessionRequest
.post('/session/register/username-password')
.send({ username: 'username1', password: 'password' });
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/consent', () => {
describe('should call grant.save() and assign interaction results', () => {
afterEach(() => {
updateUserById.mockClear();
});
it('with empty details and reusing old grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('with empty details and creating new grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
grantId: 'exists',
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('should save application id when the user first consented', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: mockUser.id },
params: { client_id: 'clientId' },
prompt: {
name: 'consent',
details: {},
reasons: ['consent_prompt', 'native_client_prompt'],
},
grantId: 'grantId',
});
findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null }));
const response = await sessionRequest.post('/session/consent');
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' });
expect(response.statusCode).toEqual(200);
});
it('missingOIDCScope and missingResourceScopes', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: {
details: {
missingOIDCScope: ['scope1', 'scope2'],
missingResourceScopes: {
resource1: ['scope1', 'scope2'],
resource2: ['scope3'],
},
},
},
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
});
it('throws if session is missing', async () => {
interactionDetails.mockResolvedValueOnce({ params: { client_id: 'clientId' } });
await expect(sessionRequest.post('/session/consent')).resolves.toHaveProperty(
'statusCode',
400
);
});
});
it('DELETE /session', async () => {
const response = await sessionRequest.delete('/session');
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ error: 'oidc.aborted' }),
expect.anything()
);
});
});

View file

@ -0,0 +1,148 @@
import path from 'path';
import { LogtoErrorCode } from '@logto/phrases';
import { passwordRegEx, usernameRegEx } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults, saveUserFirstConsentedAppId } from '@/lib/session';
import {
encryptUserPassword,
generateUserId,
findUserByUsernameAndPassword,
updateLastSignInAt,
} from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import { hasUser, insertUser } from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
export default function sessionRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
router.post('/session', async (ctx, next) => {
const {
prompt: { name },
} = await provider.interactionDetails(ctx.req, ctx.res);
if (name === 'consent') {
ctx.body = { redirectTo: path.join(ctx.request.origin, '/session/consent') };
return next();
}
throw new RequestError('session.unsupported_prompt_name');
});
router.post(
'/session/sign-in/username-password',
koaGuard({
body: object({
username: string().nonempty(),
password: string().nonempty(),
}),
}),
async (ctx, next) => {
const { username, password } = ctx.guard.body;
const type = 'SignInUsernamePassword';
ctx.log(type, { username });
const { id } = await findUserByUsernameAndPassword(username, password);
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
router.post('/session/consent', async (ctx, next) => {
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
const {
session,
grantId,
params: { client_id },
prompt,
} = interaction;
assertThat(session, 'session.not_found');
const { accountId } = session;
const grant =
conditional(grantId && (await provider.Grant.find(grantId))) ??
new provider.Grant({ accountId, clientId: String(client_id) });
await saveUserFirstConsentedAppId(accountId, String(client_id));
// V2: fulfill missing claims / resources
const PromptDetailsBody = object({
missingOIDCScope: string().array().optional(),
missingResourceScopes: object({}).catchall(string().array()).optional(),
});
const { missingOIDCScope, missingResourceScopes } = PromptDetailsBody.parse(prompt.details);
if (missingOIDCScope) {
grant.addOIDCScope(missingOIDCScope.join(' '));
}
if (missingResourceScopes) {
for (const [indicator, scope] of Object.entries(missingResourceScopes)) {
grant.addResourceScope(indicator, scope.join(' '));
}
}
const finalGrantId = await grant.save();
// V2: configure consent
await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true);
return next();
});
router.post(
'/session/register/username-password',
koaGuard({
body: object({
username: string().regex(usernameRegEx),
password: string().regex(passwordRegEx),
}),
}),
async (ctx, next) => {
const { username, password } = ctx.guard.body;
const type = 'RegisterUsernamePassword';
ctx.log(type, { username });
assertThat(
!(await hasUser(username)),
new RequestError({
code: 'user.username_exists_register',
status: 422,
})
);
const id = await generateUserId();
ctx.log(type, { userId: id });
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await insertUser({
id,
username,
passwordEncrypted,
passwordEncryptionMethod,
});
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.delete('/session', async (ctx, next) => {
await provider.interactionDetails(ctx.req, ctx.res);
const error: LogtoErrorCode = 'oidc.aborted';
await assignInteractionResults(ctx, provider, { error });
return next();
});
}

View file

@ -0,0 +1,400 @@
import { User } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import {
mockUser,
mockAliyunDmConnectorInstance,
mockAliyunSmsConnectorInstance,
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockGoogleConnectorInstance,
mockWechatConnectorInstance,
mockWechatNativeConnectorInstance,
} from '@/__mocks__';
import { ConnectorType } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import sessionSocialRoutes from './social';
jest.mock('@/lib/user', () => ({
generateUserId: () => 'user1',
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
}));
jest.mock('@/lib/social', () => ({
...jest.requireActual('@/lib/social'),
async findSocialRelatedUser() {
return ['phone', { id: 'user1', identities: {} }];
},
async getUserInfoByAuthCode(connectorId: string, data: { code: string }) {
if (connectorId === '_connectorId') {
throw new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
});
}
if (data.code === '123456') {
return { id: 'id' };
}
// This mocks the case that can not get userInfo with access token and auth code
// (most likely third-party social connectors' problem).
throw new Error(' ');
},
}));
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByIdentity: async () => ({ id: 'id', identities: {} }),
insertUser: async (...args: unknown[]) => insertUser(...args),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUserWithIdentity: async (connectorId: string, userId: string) =>
connectorId === 'connectorId' && userId === 'id',
}));
const getAuthorizationUri = jest.fn(async () => '');
const getConnectorInstanceById = jest.fn(async (connectorId: string) => {
const connector = {
enabled: connectorId === 'social_enabled',
};
const metadata = {
id:
connectorId === 'social_enabled'
? 'social_enabled'
: connectorId === 'social_disabled'
? 'social_disabled'
: 'others',
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.SMS,
};
return { connector, metadata, getAuthorizationUri };
});
jest.mock('@/connectors', () => ({
getSocialConnectorInstanceById: async (connectorId: string) => {
const connectorInstance = await getConnectorInstanceById(connectorId);
if (connectorInstance.metadata.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
return connectorInstance;
},
getConnectorInstances: async () =>
jest.fn(async () => [
mockAliyunDmConnectorInstance,
mockAliyunSmsConnectorInstance,
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockGoogleConnectorInstance,
mockWechatConnectorInstance,
mockWechatNativeConnectorInstance,
]),
}));
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
interactionResult,
})),
}));
afterEach(() => {
interactionResult.mockClear();
});
describe('sessionSocialRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: sessionSocialRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
describe('POST /session/sign-in/social', () => {
it('should throw when redirectURI is invalid', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('sign-in with social and redirect', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.body).toHaveProperty('redirectTo', '');
});
it('throw error when sign-in with social but miss state', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when sign-in with social but miss redirectUri', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_enabled',
state: 'state',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when connector is disabled', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'social_disabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post('/session/sign-in/social').send({
connectorId: 'others',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(404);
});
});
describe('POST /session/sign-in/social/auth', () => {
it('throw error when auth code is wrong', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: 'connectorId',
state: 'state',
redirectUri: 'https://logto.dev',
code: '123455',
});
expect(response.statusCode).toEqual(500);
});
it('throw error when code is provided but connector can not be found', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: '_connectorId',
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
});
expect(response.statusCode).toEqual(422);
});
it('get and add user info with auth code, as well as assign result and redirect', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: 'connectorId',
data: {
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
},
});
expect(updateUserById).toHaveBeenCalledWith(
'id',
expect.objectContaining({
identities: { connectorId: { userId: 'id', details: { id: 'id' } } },
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw error when identity exists', async () => {
const response = await sessionRequest.post('/session/sign-in/social/auth').send({
connectorId: '_connectorId_',
data: {
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
},
});
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
socialUserInfo: { connectorId: '_connectorId_', userInfo: { id: 'id' } },
}),
expect.anything()
);
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/sign-in/bind-social-related-user', () => {
it('throw if session is not authorized', async () => {
await expect(
sessionRequest
.post('/session/sign-in/bind-social-related-user')
.send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('throw if no social info in session', async () => {
interactionDetails.mockResolvedValueOnce({
result: { login: { accountId: 'user1' } },
});
await expect(
sessionRequest
.post('/session/sign-in/bind-social-related-user')
.send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('updates user identities and sign in', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'user1' },
socialUserInfo: {
connectorId: 'connectorId',
userInfo: { id: 'connectorUser', phone: 'phone' },
},
},
});
const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({
connectorId: 'connectorId',
});
expect(response.statusCode).toEqual(200);
expect(updateUserById).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
identities: {
connectorId: {
details: { id: 'connectorUser', phone: 'phone' },
userId: 'connectorUser',
},
},
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
});
describe('POST /session/register/social', () => {
it('register with social, assign result and redirect', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'user1' } },
},
});
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user1',
identities: { connectorId: { userId: 'user1', details: { id: 'user1' } } },
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
});
it('throw error if no result can be found in interactionResults', async () => {
interactionDetails.mockResolvedValueOnce({});
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
it('throw error if result parsing fails', async () => {
interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: 'id' } } });
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
it('throw error when user with identity exists', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'user1' },
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'id' } },
},
});
const response = await sessionRequest
.post('/session/register/social')
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/bind-social', () => {
it('throw if session is not authorized', async () => {
interactionDetails.mockResolvedValueOnce({});
await expect(
sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('throw if no social info in session', async () => {
interactionDetails.mockResolvedValueOnce({
result: { login: { accountId: 'user1' } },
});
await expect(
sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('updates user identities', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
login: { accountId: 'user1' },
socialUserInfo: {
connectorId: 'connectorId',
userInfo: { id: 'connectorUser', phone: 'phone' },
},
},
});
const response = await sessionRequest.post('/session/bind-social').send({
connectorId: 'connectorId',
});
expect(response.statusCode).toEqual(200);
expect(updateUserById).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
identities: {
connectorId: {
details: { id: 'connectorUser', phone: 'phone' },
userId: 'connectorUser',
},
},
})
);
});
});
});

View file

@ -0,0 +1,210 @@
import { userInfoSelectFields } from '@logto/schemas';
import { redirectUriRegEx } from '@logto/shared';
import pick from 'lodash.pick';
import { Provider } from 'oidc-provider';
import { object, string, unknown } from 'zod';
import { getSocialConnectorInstanceById } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import {
findSocialRelatedUser,
getUserInfoByAuthCode,
getUserInfoFromInteractionResult,
} from '@/lib/social';
import { generateUserId, updateLastSignInAt } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
hasUserWithIdentity,
insertUser,
findUserById,
updateUserById,
findUserByIdentity,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { maskUserInfo } from '@/utils/format';
import { AnonymousRouter } from '../types';
export default function sessionSocialRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.post(
'/session/sign-in/social',
koaGuard({
body: object({
connectorId: string(),
state: string(),
redirectUri: string().regex(redirectUriRegEx),
}),
}),
async (ctx, next) => {
const { connectorId, state, redirectUri } = ctx.guard.body;
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getSocialConnectorInstanceById(connectorId);
assertThat(connector.connector.enabled, 'connector.not_enabled');
const redirectTo = await connector.getAuthorizationUri({ state, redirectUri });
ctx.body = { redirectTo };
return next();
}
);
router.post(
'/session/sign-in/social/auth',
koaGuard({
body: object({
connectorId: string(),
data: unknown(),
}),
}),
async (ctx, next) => {
const { connectorId, data } = ctx.guard.body;
const type = 'SignInSocial';
ctx.log(type, { connectorId, data });
const userInfo = await getUserInfoByAuthCode(connectorId, data);
ctx.log(type, { userInfo });
if (!(await hasUserWithIdentity(connectorId, userInfo.id))) {
await assignInteractionResults(
ctx,
provider,
{ socialUserInfo: { connectorId, userInfo } },
true
);
const relatedInfo = await findSocialRelatedUser(userInfo);
throw new RequestError(
{
code: 'user.identity_not_exists',
status: 422,
},
relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) }
);
}
const { id, identities } = await findUserByIdentity(connectorId, userInfo.id);
ctx.log(type, { userId: id });
// Update social connector's user info
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/sign-in/bind-social-related-user',
koaGuard({
body: object({ connectorId: string() }),
}),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
assertThat(result, 'session.connector_session_not_found');
const { connectorId } = ctx.guard.body;
const type = 'SignInSocialBind';
ctx.log(type, { connectorId });
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
ctx.log(type, { userInfo });
const relatedInfo = await findSocialRelatedUser(userInfo);
assertThat(relatedInfo, 'session.connector_session_not_found');
const { id, identities } = relatedInfo[1];
ctx.log(type, { userId: id });
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/register/social',
koaGuard({
body: object({
connectorId: string(),
}),
}),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
// User can not register with social directly,
// need to try to sign in with social first, then confirm to register and continue,
// so the result is expected to be exists.
assertThat(result, 'session.connector_session_not_found');
const { connectorId } = ctx.guard.body;
const type = 'RegisterSocial';
ctx.log(type, { connectorId });
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
ctx.log(type, { userInfo });
assertThat(!(await hasUserWithIdentity(connectorId, userInfo.id)), 'user.identity_exists');
const id = await generateUserId();
await insertUser({
id,
name: userInfo.name ?? null,
avatar: userInfo.avatar ?? null,
identities: {
[connectorId]: {
userId: userInfo.id,
details: userInfo,
},
},
});
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
);
router.post(
'/session/bind-social',
koaGuard({
body: object({
connectorId: string(),
}),
}),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
assertThat(result, 'session.connector_session_not_found');
const userId = result.login?.accountId;
assertThat(userId, 'session.unauthorized');
const { connectorId } = ctx.guard.body;
const type = 'RegisterSocialBind';
ctx.log(type, { connectorId, userId });
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
ctx.log(type, { userInfo });
const user = await findUserById(userId);
const updatedUser = await updateUserById(userId, {
identities: {
...user.identities,
[connectorId]: { userId: userInfo.id, details: userInfo },
},
});
ctx.body = pick(updatedUser, ...userInfoSelectFields);
return next();
}
);
}