diff --git a/packages/core/src/libraries/quota.ts b/packages/core/src/libraries/quota.ts index 9812c7e24..ee2858837 100644 --- a/packages/core/src/libraries/quota.ts +++ b/packages/core/src/libraries/quota.ts @@ -14,13 +14,13 @@ import { type ConnectorLibrary } from './connector.js'; export type QuotaLibrary = ReturnType; const notNumber = (): never => { - throw new Error('Only support usage query for numberic quota'); + throw new Error('Only support usage query for numeric quota'); }; export const createQuotaLibrary = ( queries: Queries, cloudConnection: CloudConnectionLibrary, - connectorLibraray: ConnectorLibrary + connectorLibrary: ConnectorLibrary ) => { const { applications: { countNonM2mApplications, countM2mApplications }, @@ -31,7 +31,7 @@ export const createQuotaLibrary = ( rolesScopes: { countRolesScopesByRoleId }, } = queries; - const { getLogtoConnectors } = connectorLibraray; + const { getLogtoConnectors } = connectorLibrary; const tenantUsageQueries: Record< keyof FeatureQuota, diff --git a/packages/core/src/libraries/session.test.ts b/packages/core/src/libraries/session.test.ts new file mode 100644 index 000000000..94bf68b60 --- /dev/null +++ b/packages/core/src/libraries/session.test.ts @@ -0,0 +1,143 @@ +import { type User } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import type Provider from 'oidc-provider'; + +import { mockUser } from '#src/__mocks__/user.js'; +import type Queries from '#src/tenants/Queries.js'; +import { GrantMock, createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import { consent } from './session.js'; + +const { jest } = import.meta; + +const grantSave = jest.fn(async (id: string) => id); +const grantAddOIDCScope = jest.fn(); +const grantAddResourceScope = jest.fn(); + +class Grant extends GrantMock { + static async find(id: string) { + return id === 'exists' ? existGrant : undefined; + } + + id: string; + + accountId?: string; + + constructor() { + super(); + this.id = generateStandardId(); + this.save = async () => grantSave(this.id); + this.addOIDCScope = grantAddOIDCScope; + this.addResourceScope = grantAddResourceScope; + } +} + +const existGrant = new Grant(); + +const userQueries = { + findUserById: jest.fn(async (): Promise => mockUser), + updateUserById: jest.fn(async (..._args: unknown[]) => ({ id: 'id' })), +}; + +// @ts-expect-error +const queries: Queries = { users: userQueries }; +const context = createContextWithRouteParameters(); + +type Interaction = Awaited>; + +describe('consent', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const baseInteractionDetails = { + session: { accountId: mockUser.id }, + params: { client_id: 'clientId' }, + prompt: { details: {} }, + } as unknown as Interaction; + + it('should update with new grantId if not exist', async () => { + const provider = createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant); + await consent(context, provider, queries, baseInteractionDetails); + + expect(grantSave).toHaveBeenCalled(); + + expect(provider.interactionResult).toHaveBeenCalledWith( + context.req, + context.res, + { + ...baseInteractionDetails.result, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + consent: { grantId: expect.any(String) }, + }, + { + mergeWithLastSubmission: true, + } + ); + }); + + it('should update with existing grantId if exist', async () => { + const interactionDetails = { + ...baseInteractionDetails, + grantId: 'exists', + } as unknown as Interaction; + + const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails), Grant); + + await consent(context, provider, queries, interactionDetails); + + expect(grantSave).toHaveBeenCalled(); + + expect(provider.interactionResult).toHaveBeenCalledWith( + context.req, + context.res, + { + ...baseInteractionDetails.result, + consent: { grantId: existGrant.id }, + }, + { + mergeWithLastSubmission: true, + } + ); + }); + + it('should save first consented app id', async () => { + userQueries.findUserById.mockImplementationOnce(async () => ({ + ...mockUser, + applicationId: null, + })); + + const provider = createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant); + await consent(context, provider, queries, baseInteractionDetails); + + expect(userQueries.updateUserById).toHaveBeenCalledWith(mockUser.id, { + applicationId: baseInteractionDetails.params.client_id, + }); + }); + + it('should grant missing scopes', async () => { + const interactionDetails = { + ...baseInteractionDetails, + prompt: { + details: { + missingOIDCScope: ['openid', 'profile'], + missingResourceScopes: { + resource1: ['resource1_scope1', 'resource1_scope2'], + resource2: ['resource2_scope1'], + }, + }, + }, + } as unknown as Interaction; + + const provider = createMockProvider(jest.fn().mockResolvedValue(interactionDetails), Grant); + await consent(context, provider, queries, interactionDetails); + + expect(grantAddOIDCScope).toHaveBeenCalledWith('openid profile'); + expect(grantAddResourceScope).toHaveBeenCalledWith( + 'resource1', + 'resource1_scope1 resource1_scope2' + ); + expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'resource2_scope1'); + }); +}); diff --git a/packages/core/src/libraries/session.ts b/packages/core/src/libraries/session.ts index a1262c868..dc37a6e29 100644 --- a/packages/core/src/libraries/session.ts +++ b/packages/core/src/libraries/session.ts @@ -1,10 +1,13 @@ +import { conditional } from '@silverhand/essentials'; import type { Context } from 'koa'; import type { InteractionResults } from 'oidc-provider'; import type Provider from 'oidc-provider'; +import { z } from 'zod'; import type Queries from '#src/tenants/Queries.js'; +import assertThat from '#src/utils/assert-that.js'; -export const assignInteractionResults = async ( +const updateInteractionResult = async ( ctx: Context, provider: Provider, result: InteractionResults, @@ -16,7 +19,7 @@ export const assignInteractionResults = async ( // refer to: https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106 const details = merge ? await provider.interactionDetails(ctx.req, ctx.res) : undefined; - const redirectTo = await provider.interactionResult( + return provider.interactionResult( ctx.req, ctx.res, { @@ -28,10 +31,20 @@ export const assignInteractionResults = async ( mergeWithLastSubmission: merge, } ); +}; + +export const assignInteractionResults = async ( + ctx: Context, + provider: Provider, + result: InteractionResults, + merge = false +) => { + const redirectTo = await updateInteractionResult(ctx, provider, result, merge); + ctx.body = { redirectTo }; }; -export const saveUserFirstConsentedAppId = async ( +const saveUserFirstConsentedAppId = async ( queries: Queries, userId: string, applicationId: string @@ -44,3 +57,49 @@ export const saveUserFirstConsentedAppId = async ( await updateUserById(userId, { applicationId }); } }; + +export const consent = async ( + ctx: Context, + provider: Provider, + queries: Queries, + interactionDetails: Awaited> +) => { + const { + session, + grantId, + params: { client_id }, + prompt, + } = interactionDetails; + + 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(queries, accountId, String(client_id)); + + // Fulfill missing claims / resources + const PromptDetailsBody = z.object({ + missingOIDCScope: z.string().array().optional(), + missingResourceScopes: z.object({}).catchall(z.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(); + + // Configure consent + return updateInteractionResult(ctx, provider, { consent: { grantId: finalGrantId } }, true); +}; diff --git a/packages/core/src/middleware/koa-auto-consent.ts b/packages/core/src/middleware/koa-auto-consent.ts new file mode 100644 index 000000000..41042b929 --- /dev/null +++ b/packages/core/src/middleware/koa-auto-consent.ts @@ -0,0 +1,28 @@ +import { type MiddlewareType } from 'koa'; +import { type IRouterParamContext } from 'koa-router'; +import type Provider from 'oidc-provider'; + +import { consent } from '#src/libraries/session.js'; +import type Queries from '#src/tenants/Queries.js'; + +/** + * Automatically consent for the first party apps. + */ +export default function koaAutoConsent( + provider: Provider, + query: Queries +): MiddlewareType { + return async (ctx, next) => { + const shouldAutoConsent = true; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Update later. Third party app should not auto consent + if (!shouldAutoConsent) { + return next(); + } + + const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res); + const redirectTo = await consent(ctx, provider, query, interactionDetails); + + ctx.redirect(redirectTo); + }; +} diff --git a/packages/core/src/routes/interaction/consent.test.ts b/packages/core/src/routes/interaction/consent.test.ts deleted file mode 100644 index 1bff86fd0..000000000 --- a/packages/core/src/routes/interaction/consent.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { User } from '@logto/schemas'; -import { createMockUtils } from '@logto/shared/esm'; - -import { mockUser } from '#src/__mocks__/index.js'; -import type Queries from '#src/tenants/Queries.js'; -import { createMockProvider, GrantMock } from '#src/test-utils/oidc-provider.js'; -import type { Partial2 } from '#src/test-utils/tenant.js'; -import { MockTenant } from '#src/test-utils/tenant.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import { interactionPrefix } from './const.js'; - -const { jest } = import.meta; -const { mockEsmWithActual } = createMockUtils(jest); - -const grantSave = jest.fn(async () => 'finalGrantId'); -const grantAddOIDCScope = jest.fn(); -const grantAddResourceScope = jest.fn(); - -class Grant extends GrantMock { - static async find(id: string) { - return id === 'exists' ? new Grant() : undefined; - } - - constructor() { - super(); - this.save = grantSave; - this.addOIDCScope = grantAddOIDCScope; - this.addResourceScope = grantAddResourceScope; - } -} - -const userQueries = { - findUserById: jest.fn(async (): Promise => mockUser), - updateUserById: jest.fn(async (..._args: unknown[]) => ({ id: 'id' })), -}; -const { findUserById, updateUserById } = userQueries; - -// @ts-expect-error -const queries: Partial2 = { users: userQueries }; - -const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({ - assignInteractionResults: jest.fn(), -})); - -const { default: interactionRoutes } = await import('./index.js'); - -describe('interaction -> consent', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - const baseInteractionDetails = { - session: { accountId: mockUser.id }, - params: { client_id: 'clientId' }, - prompt: { details: {} }, - }; - - it('with empty details and reusing old grant', async () => { - const sessionRequest = createRequester({ - anonymousRoutes: interactionRoutes, - tenantContext: new MockTenant( - createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant), - queries - ), - }); - - await sessionRequest.post(`${interactionPrefix}/consent`); - expect(grantSave).toHaveBeenCalled(); - expect(assignInteractionResults).toBeCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - consent: { grantId: 'finalGrantId' }, - }), - true - ); - }); - - it('with empty details and creating new grant', async () => { - const sessionRequest = createRequester({ - anonymousRoutes: interactionRoutes, - tenantContext: new MockTenant( - createMockProvider( - jest.fn().mockResolvedValue({ - ...baseInteractionDetails, - grantId: 'exists', - }), - Grant - ), - queries - ), - }); - - await sessionRequest.post(`${interactionPrefix}/consent`); - - expect(grantSave).toHaveBeenCalled(); - expect(assignInteractionResults).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - consent: { grantId: 'finalGrantId' }, - }), - expect.anything() - ); - }); - - it('should save application id when the user first consented', async () => { - const sessionRequest = createRequester({ - anonymousRoutes: interactionRoutes, - tenantContext: new MockTenant( - createMockProvider( - jest.fn().mockResolvedValue({ - ...baseInteractionDetails, - prompt: { - name: 'consent', - details: {}, - reasons: ['consent_prompt', 'native_client_prompt'], - }, - }), - Grant - ), - queries - ), - }); - - findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null })); - - await sessionRequest.post(`${interactionPrefix}/consent`); - expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' }); - }); - - it('missingOIDCScope and missingResourceScopes', async () => { - const sessionRequest = createRequester({ - anonymousRoutes: interactionRoutes, - tenantContext: new MockTenant( - createMockProvider( - jest.fn().mockResolvedValue({ - ...baseInteractionDetails, - prompt: { - details: { - missingOIDCScope: ['scope1', 'scope2'], - missingResourceScopes: { - resource1: ['scope1', 'scope2'], - resource2: ['scope3'], - }, - }, - }, - }), - Grant - ), - queries - ), - }); - - await sessionRequest.post(`${interactionPrefix}/consent`); - expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2'); - expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2'); - expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3'); - expect(assignInteractionResults).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - consent: { grantId: 'finalGrantId' }, - }), - expect.anything() - ); - }); -}); diff --git a/packages/core/src/routes/interaction/consent.ts b/packages/core/src/routes/interaction/consent.ts index baafe77a6..a053a227e 100644 --- a/packages/core/src/routes/interaction/consent.ts +++ b/packages/core/src/routes/interaction/consent.ts @@ -1,11 +1,8 @@ -import { conditional } from '@silverhand/essentials'; import type Router from 'koa-router'; import { type IRouterParamContext } from 'koa-router'; -import { z } from 'zod'; -import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libraries/session.js'; +import { consent } from '#src/libraries/session.js'; import type TenantContext from '#src/tenants/TenantContext.js'; -import assertThat from '#src/utils/assert-that.js'; import { interactionPrefix } from './const.js'; import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; @@ -17,44 +14,9 @@ export default function consentRoutes( router.post(`${interactionPrefix}/consent`, async (ctx, next) => { const { interactionDetails } = ctx; - const { - session, - grantId, - params: { client_id }, - prompt, - } = interactionDetails; + const redirectTo = await consent(ctx, provider, queries, interactionDetails); - 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(queries, accountId, String(client_id)); - - // V2: fulfill missing claims / resources - const PromptDetailsBody = z.object({ - missingOIDCScope: z.string().array().optional(), - missingResourceScopes: z.object({}).catchall(z.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); + ctx.body = { redirectTo }; return next(); }); diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index 1cf584bf2..bf84fd446 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -13,6 +13,7 @@ import { AdminApps, EnvSet, UserApps } from '#src/env-set/index.js'; import { createCloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; import { createConnectorLibrary } from '#src/libraries/connector.js'; import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js'; +import koaAutoConsent from '#src/middleware/koa-auto-consent.js'; import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js'; import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js'; import koaErrorHandler from '#src/middleware/koa-error-handler.js'; @@ -23,6 +24,7 @@ import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js'; import koaSpaProxy from '#src/middleware/koa-spa-proxy.js'; import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js'; import initOidc from '#src/oidc/init.js'; +import { routes } from '#src/routes/consts.js'; import initApis from '#src/routes/init.js'; import initMeApis from '#src/routes-me/init.js'; import BasicSentinel from '#src/sentinel/basic-sentinel.js'; @@ -137,7 +139,13 @@ export default class Tenant implements TenantContext { } // Mount UI - app.use(compose([koaSpaSessionGuard(provider, queries), koaSpaProxy(mountedApps)])); + app.use( + compose([ + koaSpaSessionGuard(provider, queries), + mount(`${routes.consent}`, koaAutoConsent(provider, queries)), + koaSpaProxy(mountedApps), + ]) + ); this.app = app; this.provider = provider; diff --git a/packages/core/src/test-utils/oidc-provider.ts b/packages/core/src/test-utils/oidc-provider.ts index 3d3bd75e0..879c37fe2 100644 --- a/packages/core/src/test-utils/oidc-provider.ts +++ b/packages/core/src/test-utils/oidc-provider.ts @@ -42,6 +42,8 @@ export const createMockProvider = ( interactionDetails ?? (async () => ({ params: {}, jti: 'jti', client_id: 'mockApplicationId' })) ); + jest.spyOn(provider, 'interactionResult').mockImplementation(async () => 'redirectTo'); + if (Grant) { Sinon.stub(provider, 'Grant').value(Grant); } diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 38f21b68b..4d71a2c47 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -6,7 +6,7 @@ import { assert } from '@silverhand/essentials'; import type { Got } from 'got'; import { got } from 'got'; -import { consent, submitInteraction } from '#src/api/index.js'; +import { submitInteraction } from '#src/api/index.js'; import { demoAppRedirectUri, logtoUrl } from '#src/constants.js'; import { MemoryStorage } from './storage.js'; @@ -162,10 +162,19 @@ export default class MockClient { assert(this.interactionCookie, new Error('Session not found')); assert(this.interactionCookie.includes('_session.sig'), new Error('Session not found')); - const { redirectTo } = await consent(this.api, this.interactionCookie); + const consentResponse = await got.get(`${this.config.endpoint}/consent`, { + headers: { + cookie: this.interactionCookie, + }, + followRedirect: false, + }); - // Note: should redirect to oidc auth endpoint - assert(redirectTo.startsWith(`${this.config.endpoint}/oidc/auth`), new Error('Consent failed')); + // Consent page should auto consent and redirect to auth endpoint + const redirectTo = consentResponse.headers.location; + + if (!redirectTo?.startsWith(`${this.config.endpoint}/oidc/auth`)) { + throw new Error('Consent failed'); + } const authCodeResponse = await got.get(redirectTo, { headers: {