0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

Merge pull request #1808 from logto-io/gao-refactor-session-routes

refactor(core): session routes
This commit is contained in:
Gao Sun 2022-08-23 18:32:43 +08:00 committed by GitHub
commit 9ead8aeef5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 295 additions and 217 deletions

View file

@ -14,9 +14,7 @@ import dashboardRoutes from '@/routes/dashboard';
import logRoutes from '@/routes/log';
import resourceRoutes from '@/routes/resource';
import roleRoutes from '@/routes/role';
import sessionPasswordlessRoutes from '@/routes/session/passwordless';
import sessionRoutes from '@/routes/session/session';
import sessionSocialRoutes from '@/routes/session/social';
import sessionRoutes from '@/routes/session';
import settingRoutes from '@/routes/setting';
import signInExperiencesRoutes from '@/routes/sign-in-experience';
import statusRoutes from '@/routes/status';
@ -29,8 +27,6 @@ const createRouters = (provider: Provider) => {
const sessionRouter: AnonymousRouter = new Router();
sessionRouter.use(koaLogSession(provider));
sessionRoutes(sessionRouter, provider);
sessionPasswordlessRoutes(sessionRouter, provider);
sessionSocialRoutes(sessionRouter, provider);
const managementRouter: AuthedRouter = new Router();
managementRouter.use(koaAuth(UserRole.Admin));

View file

@ -0,0 +1,195 @@
import { User } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockUser } from '@/__mocks__';
import { createRequester } from '@/utils/test-utils';
import sessionRoutes from '.';
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
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 () => ({}));
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
}));
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/consent', () => {
describe('should call grant.save() and assign interaction results', () => {
afterEach(() => {
updateUserById.mockClear();
});
it('with empty details and reusing old grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('with empty details and creating new grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
grantId: 'exists',
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('should save application id when the user first consented', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: mockUser.id },
params: { client_id: 'clientId' },
prompt: {
name: 'consent',
details: {},
reasons: ['consent_prompt', 'native_client_prompt'],
},
grantId: 'grantId',
});
findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null }));
const response = await sessionRequest.post('/session/consent');
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' });
expect(response.statusCode).toEqual(200);
});
it('missingOIDCScope and missingResourceScopes', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: {
details: {
missingOIDCScope: ['scope1', 'scope2'],
missingResourceScopes: {
resource1: ['scope1', 'scope2'],
resource2: ['scope3'],
},
},
},
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
});
it('throws if session is missing', async () => {
interactionDetails.mockResolvedValueOnce({ params: { client_id: 'clientId' } });
await expect(sessionRequest.post('/session/consent')).resolves.toHaveProperty(
'statusCode',
400
);
});
});
it('DELETE /session', async () => {
const response = await sessionRequest.delete('/session');
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ error: 'oidc.aborted' }),
expect.anything()
);
});
});

View file

@ -0,0 +1,85 @@
import path from 'path';
import { LogtoErrorCode } from '@logto/phrases';
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 assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import passwordlessRoutes from './passwordless';
import socialRoutes from './social';
import usernamePasswordRoutes from './username-password';
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/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.delete('/session', async (ctx, next) => {
await provider.interactionDetails(ctx.req, ctx.res);
const error: LogtoErrorCode = 'oidc.aborted';
await assignInteractionResults(ctx, provider, { error });
return next();
});
usernamePasswordRoutes(router, provider);
passwordlessRoutes(router, provider);
socialRoutes(router, provider);
}

View file

@ -5,7 +5,7 @@ import { mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import sessionPasswordlessRoutes from './passwordless';
import passwordlessRoutes from './passwordless';
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
@ -52,9 +52,9 @@ afterEach(() => {
interactionResult.mockClear();
});
describe('sessionPasswordlessRoutes', () => {
describe('session -> passwordlessRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: sessionPasswordlessRoutes,
anonymousRoutes: passwordlessRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {

View file

@ -18,7 +18,7 @@ import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
export default function sessionPasswordlessRoutes<T extends AnonymousRouter>(
export default function passwordlessRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {

View file

@ -7,7 +7,7 @@ import { ConnectorType } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import sessionSocialRoutes from './social';
import socialRoutes from './social';
jest.mock('@/lib/social', () => ({
...jest.requireActual('@/lib/social'),
@ -97,9 +97,9 @@ afterEach(() => {
interactionResult.mockClear();
});
describe('sessionSocialRoutes', () => {
describe('session -> socialRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: sessionSocialRoutes,
anonymousRoutes: socialRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {

View file

@ -25,10 +25,7 @@ import { maskUserInfo } from '@/utils/format';
import { AnonymousRouter } from '../types';
export default function sessionSocialRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
export default function socialRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
router.post(
'/session/sign-in/social',
koaGuard({

View file

@ -6,7 +6,7 @@ import { mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import sessionRoutes from './session';
import sessionRoutes from '.';
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
@ -99,28 +99,6 @@ describe('sessionRoutes', () => {
],
});
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 () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
@ -256,112 +234,4 @@ describe('sessionRoutes', () => {
expect(response.statusCode).toEqual(422);
});
});
describe('POST /session/consent', () => {
describe('should call grant.save() and assign interaction results', () => {
afterEach(() => {
updateUserById.mockClear();
});
it('with empty details and reusing old grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('with empty details and creating new grant', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: { details: {} },
grantId: 'exists',
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantSave).toHaveBeenCalled();
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
it('should save application id when the user first consented', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: mockUser.id },
params: { client_id: 'clientId' },
prompt: {
name: 'consent',
details: {},
reasons: ['consent_prompt', 'native_client_prompt'],
},
grantId: 'grantId',
});
findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null }));
const response = await sessionRequest.post('/session/consent');
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' });
expect(response.statusCode).toEqual(200);
});
it('missingOIDCScope and missingResourceScopes', async () => {
interactionDetails.mockResolvedValueOnce({
session: { accountId: 'accountId' },
params: { client_id: 'clientId' },
prompt: {
details: {
missingOIDCScope: ['scope1', 'scope2'],
missingResourceScopes: {
resource1: ['scope1', 'scope2'],
resource2: ['scope3'],
},
},
},
});
const response = await sessionRequest.post('/session/consent');
expect(response.statusCode).toEqual(200);
expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2');
expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
consent: { grantId: 'finalGrantId' },
}),
expect.anything()
);
});
});
it('throws if session is missing', async () => {
interactionDetails.mockResolvedValueOnce({ params: { client_id: 'clientId' } });
await expect(sessionRequest.post('/session/consent')).resolves.toHaveProperty(
'statusCode',
400
);
});
});
it('DELETE /session', async () => {
const response = await sessionRequest.delete('/session');
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ error: 'oidc.aborted' }),
expect.anything()
);
});
});

View file

@ -1,15 +1,11 @@
import path from 'path';
import { LogtoErrorCode } from '@logto/phrases';
import { UserRole } from '@logto/schemas';
import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
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 { assignInteractionResults } from '@/lib/session';
import {
encryptUserPassword,
generateUserId,
@ -23,21 +19,10 @@ 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');
});
export default function usernamePasswordRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.post(
'/session/sign-in/username-password',
koaGuard({
@ -72,48 +57,6 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
}
);
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({
@ -162,12 +105,4 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
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();
});
}