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:
commit
618c38f134
10 changed files with 99 additions and 16 deletions
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -147,6 +147,9 @@
|
|||
},
|
||||
"400": {
|
||||
"description": "The request body is invalid."
|
||||
},
|
||||
"403": {
|
||||
"description": "Permission denied."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
3
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue