diff --git a/packages/core/src/libraries/cloud-connection.ts b/packages/core/src/libraries/cloud-connection.ts index 1a3072a22..e97e87d3d 100644 --- a/packages/core/src/libraries/cloud-connection.ts +++ b/packages/core/src/libraries/cloud-connection.ts @@ -27,6 +27,7 @@ const accessTokenResponseGuard = z.object({ /** * The scope here can be empty and still work, because the cloud API requests made using this client do not rely on scope verification. * The `CloudScope.SendEmail` is added for now because it needs to call the cloud email service API. + * The `CloudScope.FetchCustomJwt` is added for now because it needs to call the cloud custom JWT service API. */ const scopes: string[] = [CloudScope.SendEmail, CloudScope.FetchCustomJwt]; const accessTokenExpirationMargin = 60; diff --git a/packages/core/src/libraries/logto-config.ts b/packages/core/src/libraries/logto-config.ts index b633f3418..0e4f4ce4b 100644 --- a/packages/core/src/libraries/logto-config.ts +++ b/packages/core/src/libraries/logto-config.ts @@ -6,7 +6,7 @@ import { LogtoOidcConfigKey, jwtCustomizerConfigGuard, } from '@logto/schemas'; -import type { LogtoOidcConfigType, LogtoJwtTokenKey } from '@logto/schemas'; +import type { LogtoOidcConfigType, LogtoJwtTokenKey, CloudConnectionData } from '@logto/schemas'; import chalk from 'chalk'; import { z, ZodError } from 'zod'; @@ -53,7 +53,7 @@ export const createLogtoConfigLibrary = ({ } }; - const getCloudConnectionData = async () => { + const getCloudConnectionData = async (): Promise => { const { value } = await queryCloudConnectionData(); const result = cloudConnectionDataGuard.safeParse(value); @@ -94,7 +94,7 @@ export const createLogtoConfigLibrary = ({ }); } - return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]); + return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value; }; return { getOidcConfigs, getCloudConnectionData, upsertJwtCustomizer, getJwtCustomizer }; diff --git a/packages/core/src/oidc/init.test.ts b/packages/core/src/oidc/init.test.ts index 212f63204..384e1508e 100644 --- a/packages/core/src/oidc/init.test.ts +++ b/packages/core/src/oidc/init.test.ts @@ -5,8 +5,10 @@ import initOidc from './init.js'; describe('oidc provider init', () => { it('init should not throw', async () => { - const { queries, libraries } = new MockTenant(); + const { queries, libraries, logtoConfigs, cloudConnection } = new MockTenant(); - expect(() => initOidc(mockEnvSet, queries, libraries)).not.toThrow(); + expect(() => + initOidc(mockEnvSet, queries, libraries, logtoConfigs, cloudConnection) + ).not.toThrow(); }); }); diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index d0a06d283..1515abb96 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -1,5 +1,5 @@ +/* eslint-disable max-lines */ /* istanbul ignore file */ - import assert from 'node:assert'; import { readFileSync } from 'node:fs'; @@ -12,16 +12,19 @@ import { inSeconds, logtoCookieKey, type LogtoUiCookie, + LogtoJwtTokenKey, } from '@logto/schemas'; -import { conditional, tryThat } from '@silverhand/essentials'; +import { conditional, trySafe, tryThat } from '@silverhand/essentials'; import i18next from 'i18next'; import koaBody from 'koa-body'; import Provider, { errors } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; -import { type EnvSet } from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { addOidcEventListeners } from '#src/event-listeners/index.js'; +import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; +import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js'; import koaAuditLog from '#src/middleware/koa-audit-log.js'; import koaBodyEtag from '#src/middleware/koa-body-etag.js'; import postgresAdapter from '#src/oidc/adapter.js'; @@ -45,7 +48,13 @@ import { OIDCExtraParametersKey, InteractionMode } from './type.js'; // Temporarily removed 'EdDSA' since it's not supported by browser yet const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const); -export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Libraries): Provider { +export default function initOidc( + envSet: EnvSet, + queries: Queries, + libraries: Libraries, + logtoConfigs: LogtoConfigLibrary, + cloudConnection: CloudConnectionLibrary +): Provider { const { resources: { findDefaultResource }, users: { findUserById }, @@ -198,6 +207,59 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li }, }, extraParams: [OIDCExtraParametersKey.InteractionMode], + extraTokenClaims: async (ctx, token) => { + const { isDevFeaturesEnabled, isCloud } = EnvSet.values; + + // No cloud connection for OSS version, skip. + if (!isDevFeaturesEnabled || !isCloud) { + return; + } + + try { + const isTokenClientCredentials = token instanceof ctx.oidc.provider.ClientCredentials; + + const { script, envVars } = + (await trySafe( + logtoConfigs.getJwtCustomizer( + isTokenClientCredentials + ? LogtoJwtTokenKey.ClientCredentials + : LogtoJwtTokenKey.AccessToken + ) + )) ?? {}; + + if (!script) { + return; + } + + const pickedFields = isTokenClientCredentials + ? ctx.oidc.provider.ClientCredentials.IN_PAYLOAD + : ctx.oidc.provider.AccessToken.IN_PAYLOAD; + const readOnlyToken = Object.fromEntries( + pickedFields.map((field) => [field, Reflect.get(token, field)]) + ); + + const client = await cloudConnection.getClient(); + + // We pass context to the cloud API only when it is a user's access token. + const logtoUserInfo = conditional( + !isTokenClientCredentials && + token.accountId && + (await libraries.jwtCustomizers.getUserContext(token.accountId)) + ); + + // `context` parameter is only eligible for user's access token for now. + return await client.post(`/api/services/custom-jwt`, { + body: { + script, + envVars, + token: readOnlyToken, + ...conditional(logtoUserInfo && { context: { user: logtoUserInfo } }), + }, + }); + } catch { + // TODO: Log the error + } + }, extraClientMetadata: { properties: Object.values(CustomClientMetadataKey), validator: (_, key, value) => { @@ -351,3 +413,4 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li return oidc; } +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/logto-config.openapi.json b/packages/core/src/routes/logto-config.openapi.json index 481db41cd..fea0b1cdf 100644 --- a/packages/core/src/routes/logto-config.openapi.json +++ b/packages/core/src/routes/logto-config.openapi.json @@ -147,6 +147,9 @@ }, "400": { "description": "The request body is invalid." + }, + "403": { + "description": "Permission denied." } } }, diff --git a/packages/core/src/routes/logto-config.test.ts b/packages/core/src/routes/logto-config.test.ts index 4f4eebbf6..35b84f091 100644 --- a/packages/core/src/routes/logto-config.test.ts +++ b/packages/core/src/routes/logto-config.test.ts @@ -270,7 +270,7 @@ describe('configs routes', () => { it('GET /configs/jwt-customizer/:tokenType should return the record', async () => { logtoConfigLibraries.getJwtCustomizer.mockResolvedValueOnce( - mockJwtCustomizerConfigForAccessToken + mockJwtCustomizerConfigForAccessToken.value ); const response = await routeRequester.get('/configs/jwt-customizer/access-token'); expect(response.status).toEqual(200); diff --git a/packages/core/src/routes/logto-config.ts b/packages/core/src/routes/logto-config.ts index 917a47c20..5590d7876 100644 --- a/packages/core/src/routes/logto-config.ts +++ b/packages/core/src/routes/logto-config.ts @@ -18,6 +18,7 @@ import { LogtoJwtTokenPath, jsonObjectGuard, } from '@logto/schemas'; +import { adminTenantId } from '@logto/schemas'; import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; @@ -77,7 +78,10 @@ const getRedactedOidcKeyResponse = async ( ); export default function logtoConfigRoutes( - ...[router, { queries, logtoConfigs, invalidateCache, cloudConnection }]: RouterInitArgs + ...[ + router, + { id: tenantId, queries, logtoConfigs, invalidateCache, cloudConnection }, + ]: RouterInitArgs ) { const { getAdminConsoleConfig, @@ -222,9 +226,16 @@ export default function logtoConfigRoutes( */ body: z.unknown(), response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard), - status: [200, 201, 400], + status: [200, 201, 400, 403], }), async (ctx, next) => { + if ( + tenantId !== adminTenantId && + !(EnvSet.values.isUnitTest || EnvSet.values.isIntegrationTest) + ) { + throw new RequestError({ code: 'auth.forbidden', status: 403 }); + } + const { params: { tokenTypePath }, body: rawBody, @@ -257,12 +268,11 @@ export default function logtoConfigRoutes( const { params: { tokenTypePath }, } = ctx.guard; - const { value } = await getJwtCustomizer( + ctx.body = await getJwtCustomizer( tokenTypePath === LogtoJwtTokenPath.AccessToken ? LogtoJwtTokenKey.AccessToken : LogtoJwtTokenKey.ClientCredentials ); - ctx.body = value; return next(); } ); diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index 3d24be56d..557cea219 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -87,7 +87,7 @@ export default class Tenant implements TenantContext { app.use(koaSecurityHeaders(mountedApps, id)); // Mount OIDC - const provider = initOidc(envSet, queries, libraries); + const provider = initOidc(envSet, queries, libraries, logtoConfigs, cloudConnection); app.use(mount('/oidc', provider.app)); const tenantContext: TenantContext = { diff --git a/packages/schemas/src/foundations/jsonb-types/users.ts b/packages/schemas/src/foundations/jsonb-types/users.ts index 7f237c7d7..78c8065fa 100644 --- a/packages/schemas/src/foundations/jsonb-types/users.ts +++ b/packages/schemas/src/foundations/jsonb-types/users.ts @@ -1,3 +1,4 @@ +import { jsonObjectGuard } from '@logto/connector-kit'; import { z } from 'zod'; import { MfaFactor } from './sign-in-experience.js'; @@ -56,7 +57,7 @@ export const roleNamesGuard = z.string().array(); export const identityGuard = z.object({ userId: z.string(), - details: z.record(z.unknown()).optional(), // Connector's userinfo details, schemaless + details: jsonObjectGuard.optional(), // Connector's userinfo details, schemaless }); export const identitiesGuard = z.record(identityGuard); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e741117b0..854071d37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18362,6 +18362,9 @@ packages: resolution: {integrity: sha512-2GTVocFkwblV/TIg9AmT7TI2fO4xdWkyN8aFUEVtiVNWt96GTR3FgQyHFValfCbcj1k9Xf962Ws2hYXYUr9k1Q==} engines: {node: '>= 12.0.0'} hasBin: true + peerDependenciesMeta: + '@parcel/core': + optional: true dependencies: '@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.31) '@parcel/core': 2.9.3