0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

Merge pull request #5508 from logto-io/yemq-log-8338-update-extra-token-claims-logic

feat(core): add the support on custom JWT feature
This commit is contained in:
Darcy Ye 2024-03-25 14:28:14 +08:00 committed by GitHub
commit 618c38f134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 99 additions and 16 deletions

View file

@ -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;

View file

@ -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<CloudConnectionData> => {
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 };

View file

@ -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();
});
});

View file

@ -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 */

View file

@ -147,6 +147,9 @@
},
"400": {
"description": "The request body is invalid."
},
"403": {
"description": "Permission denied."
}
}
},

View file

@ -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);

View file

@ -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<T extends AuthedRouter>(
...[router, { queries, logtoConfigs, invalidateCache, cloudConnection }]: RouterInitArgs<T>
...[
router,
{ id: tenantId, queries, logtoConfigs, invalidateCache, cloudConnection },
]: RouterInitArgs<T>
) {
const {
getAdminConsoleConfig,
@ -222,9 +226,16 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
*/
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<T extends AuthedRouter>(
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();
}
);

View file

@ -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 = {

View file

@ -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);

3
pnpm-lock.yaml generated
View file

@ -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