0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(core): add the support on custom JWT feature

This commit is contained in:
Darcy Ye 2024-03-11 15:36:13 +08:00
parent a98bc3da54
commit 084ced1bd6
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
4 changed files with 93 additions and 6 deletions

View file

@ -27,17 +27,27 @@ 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;
/** The library for connecting to Logto Cloud service. */
export class CloudConnectionLibrary {
private _isAuthenticated = false;
private client?: Client<typeof router>;
private accessTokenCache?: { expiresAt: number; accessToken: string };
constructor(private readonly logtoConfigs: LogtoConfigLibrary) {}
get isAuthenticated() {
return this._isAuthenticated;
}
private set isAuthenticated(value: boolean) {
this._isAuthenticated = value;
}
public getCloudConnectionData = async (): Promise<CloudConnection> => {
const { getCloudConnectionData: getCloudServiceM2mCredentials } = this.logtoConfigs;
const credentials = await getCloudServiceM2mCredentials();
@ -66,6 +76,8 @@ export class CloudConnectionLibrary {
if (expiresAt > Date.now() / 1000 + accessTokenExpirationMargin) {
return accessToken;
}
// Set the cloud connection to not authenticated if the access token is expired.
this.isAuthenticated = false;
}
const { tokenEndpoint, appId, appSecret, resource } = await this.getCloudConnectionData();
@ -93,6 +105,8 @@ export class CloudConnectionLibrary {
expiresAt: Date.now() / 1000 + result.data.expires_in,
accessToken: result.data.access_token,
};
// Set the cloud connection to `authenticated` if the access token is valid.
this.isAuthenticated = true;
return result.data.access_token;
};

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,3 +1,4 @@
/* eslint-disable max-lines */
/* istanbul ignore file */
import assert from 'node:assert';
@ -12,16 +13,21 @@ import {
inSeconds,
logtoCookieKey,
type LogtoUiCookie,
LogtoJwtTokenKey,
type JsonObject,
} from '@logto/schemas';
import { conditional, tryThat } from '@silverhand/essentials';
import { conditional, trySafe, tryThat } from '@silverhand/essentials';
import { got } from 'got';
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 +51,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 +210,64 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
},
},
extraParams: [OIDCExtraParametersKey.InteractionMode],
extraTokenClaims: async (ctx, token) => {
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}
/**
* The execution on this function relies on the existence of authenticated cloud connection client.
*
* The process that cloud connection get access token also includes this function (`extraTokenClaims`
* is a function that will always be executed during the process of generating an access token), it
* could trigger infinite loop if we do not terminal the process early.
*/
if (!cloudConnection.isAuthenticated) {
return;
}
const isTokenClientCredentials = token instanceof ctx.oidc.provider.ClientCredentials;
const {
value: { script, envVars },
} = (await trySafe(
logtoConfigs.getJwtCustomizer(
isTokenClientCredentials
? LogtoJwtTokenKey.ClientCredentials
: LogtoJwtTokenKey.AccessToken
)
)) ?? { value: {} };
if (script) {
// Wait for cloud API to be ready and we can use cloud connection client to request the API.
const accessToken = await cloudConnection.getAccessToken();
const { endpoint: cloudApiEndpoint } = await cloudConnection.getCloudConnectionData();
// 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))
);
const result =
(await trySafe(
got
.post(`${cloudApiEndpoint}/services/custom-jwt`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
json: {
script,
envVars,
token,
...conditional(logtoUserInfo && { context: { user: logtoUserInfo } }),
},
})
.json<JsonObject>()
)) ?? {};
return result;
}
},
extraClientMetadata: {
properties: Object.values(CustomClientMetadataKey),
validator: (_, key, value) => {
@ -351,3 +421,4 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
return oidc;
}
/* eslint-enable max-lines */

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