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:
parent
efa9491749
commit
985fe7203a
9 changed files with 1594 additions and 1458 deletions
|
@ -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));
|
||||
|
|
|
@ -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 */
|
|
@ -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 */
|
317
packages/core/src/routes/session/passwordless.test.ts
Normal file
317
packages/core/src/routes/session/passwordless.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
219
packages/core/src/routes/session/passwordless.ts
Normal file
219
packages/core/src/routes/session/passwordless.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
295
packages/core/src/routes/session/session.test.ts
Normal file
295
packages/core/src/routes/session/session.test.ts
Normal 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()
|
||||
);
|
||||
});
|
||||
});
|
148
packages/core/src/routes/session/session.ts
Normal file
148
packages/core/src/routes/session/session.ts
Normal 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();
|
||||
});
|
||||
}
|
400
packages/core/src/routes/session/social.test.ts
Normal file
400
packages/core/src/routes/session/social.test.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
210
packages/core/src/routes/session/social.ts
Normal file
210
packages/core/src/routes/session/social.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
Loading…
Add table
Reference in a new issue