diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 54c83db5d..997755810 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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)); diff --git a/packages/core/src/routes/session/index.test.ts b/packages/core/src/routes/session/index.test.ts new file mode 100644 index 000000000..419895675 --- /dev/null +++ b/packages/core/src/routes/session/index.test.ts @@ -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 => 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> = 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() + ); + }); +}); diff --git a/packages/core/src/routes/session/index.ts b/packages/core/src/routes/session/index.ts new file mode 100644 index 000000000..3c6202fa4 --- /dev/null +++ b/packages/core/src/routes/session/index.ts @@ -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(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); +} diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index d210f0b46..4890b0293 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -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 => 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) => { diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index def941c07..abebb3259 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -18,7 +18,7 @@ import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from '../types'; -export default function sessionPasswordlessRoutes( +export default function passwordlessRoutes( router: T, provider: Provider ) { diff --git a/packages/core/src/routes/session/social.test.ts b/packages/core/src/routes/session/social.test.ts index d470a9b6d..9a4f2d692 100644 --- a/packages/core/src/routes/session/social.test.ts +++ b/packages/core/src/routes/session/social.test.ts @@ -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) => { diff --git a/packages/core/src/routes/session/social.ts b/packages/core/src/routes/session/social.ts index f0da63ed8..cbf87a065 100644 --- a/packages/core/src/routes/session/social.ts +++ b/packages/core/src/routes/session/social.ts @@ -25,10 +25,7 @@ import { maskUserInfo } from '@/utils/format'; import { AnonymousRouter } from '../types'; -export default function sessionSocialRoutes( - router: T, - provider: Provider -) { +export default function socialRoutes(router: T, provider: Provider) { router.post( '/session/sign-in/social', koaGuard({ diff --git a/packages/core/src/routes/session/session.test.ts b/packages/core/src/routes/session/username-password.test.ts similarity index 62% rename from packages/core/src/routes/session/session.test.ts rename to packages/core/src/routes/session/username-password.test.ts index ba6da5418..cf5e5ddb2 100644 --- a/packages/core/src/routes/session/session.test.ts +++ b/packages/core/src/routes/session/username-password.test.ts @@ -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 => 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() - ); - }); }); diff --git a/packages/core/src/routes/session/session.ts b/packages/core/src/routes/session/username-password.ts similarity index 57% rename from packages/core/src/routes/session/session.ts rename to packages/core/src/routes/session/username-password.ts index d38bbf823..b4d531895 100644 --- a/packages/core/src/routes/session/session.ts +++ b/packages/core/src/routes/session/username-password.ts @@ -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(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( + router: T, + provider: Provider +) { router.post( '/session/sign-in/username-password', koaGuard({ @@ -72,48 +57,6 @@ export default function sessionRoutes(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(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(); - }); }