Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core,console): enable custom JWT for OSS and can run script in local vm #5794

Merged
merged 24 commits into from
May 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f02ce52
feat(core): support run custom JWT script in OSS
darcyYe Apr 25, 2024
19e8235
feat(core,console): enable OSS custom JWT and run script in local vm
darcyYe Apr 25, 2024
dbd0d82
chore: update the deploy/undeploy method calls and API docs
darcyYe Apr 26, 2024
2d0103a
chore: rebase master
darcyYe May 7, 2024
1446d00
refactor: add azure-functions kit and reuse code that run custom scri…
darcyYe May 7, 2024
dbd6812
fix: remove OSS version early return in token issurance flow
darcyYe May 7, 2024
de9c0c2
fix: azure-functions kit dependencies
darcyYe May 7, 2024
a6f0363
chore: renaming
darcyYe May 7, 2024
1da473d
refactor: put custom JWT reused logic in core-kit
darcyYe May 9, 2024
a326557
chore: upgrade pnpm and rerun pnpm i
darcyYe May 9, 2024
eee22d6
chore: rename file
darcyYe May 10, 2024
8773030
chore: add integration test for test API
darcyYe May 10, 2024
258af39
chore: add integration test to check user sign-in access token
darcyYe May 10, 2024
a0066ef
chore: add integration test to check m2m access token
darcyYe May 10, 2024
03b3179
chore: add changeset
darcyYe May 10, 2024
657528a
chore: separate custom-jwt as a subpath from core-kit
darcyYe May 10, 2024
1b109d3
refactor: code
darcyYe May 10, 2024
1d0cd92
chore: fix lint error and remove unnecessary dependencies
darcyYe May 10, 2024
02bec24
chore: rename
darcyYe May 10, 2024
a4f78a6
chore: adopt suggestion
darcyYe May 10, 2024
9b6b6c6
chore: update
darcyYe May 10, 2024
e56bf2b
refactor: local vm error handling
darcyYe May 11, 2024
65a0823
chore: update changeset
darcyYe May 11, 2024
6e15784
chore: update
darcyYe May 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/itchy-eels-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@logto/console": minor
"@logto/core": minor
---

enable custom JWT feature for OSS version

OSS version users can now use custom JWT feature to add custom claims to JWT access tokens payload (previously, this feature was only available to Logto Cloud).
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ export const useSidebarMenuItems = (): {
{
Icon: JwtClaims,
title: 'customize_jwt',
isHidden: !isCloud,
},
{
Icon: Hook,
Expand Down
2 changes: 1 addition & 1 deletion packages/console/src/hooks/use-console-routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const useConsoleRoutes = () => {
},
{ path: 'signing-keys', element: <SigningKeys /> },
isCloud && tenantSettings,
isCloud && customizeJwt
customizeJwt
),
[tenantSettings]
);
Expand Down
127 changes: 83 additions & 44 deletions packages/core/src/libraries/jwt-customizer.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { runScriptFunctionInLocalVm, buildErrorResponse } from '@logto/core-kit/custom-jwt';
import {
userInfoSelectFields,
jwtCustomizerUserContextGuard,
type LogtoJwtTokenKey,
type JwtCustomizerType,
type JwtCustomizerUserContext,
type CustomJwtFetcher,
LogtoJwtTokenKeyType,
} from '@logto/schemas';
import { type ConsoleLog } from '@logto/shared';
import { deduplicate, pick, pickState, assert } from '@silverhand/essentials';
import deepmerge from 'deepmerge';
import { z, ZodError } from 'zod';

import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type { LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { type ScopeLibrary } from '#src/libraries/scope.js';
Expand All @@ -18,42 +23,70 @@
getJwtCustomizerScripts,
type CustomJwtDeployRequestBody,
} from '#src/utils/custom-jwt/index.js';
import { LocalVmError } from '#src/utils/custom-jwt/index.js';

import { type CloudConnectionLibrary } from './cloud-connection.js';

export const createJwtCustomizerLibrary = (
queries: Queries,
logtoConfigs: LogtoConfigLibrary,
cloudConnection: CloudConnectionLibrary,
userLibrary: UserLibrary,
scopeLibrary: ScopeLibrary
) => {
const {
users: { findUserById },
rolesScopes: { findRolesScopesByRoleIds },
scopes: { findScopesByIds },
userSsoIdentities,
organizations: { relations },
} = queries;
const { findUserRoles } = userLibrary;
const { attachResourceToScopes } = scopeLibrary;
const { getJwtCustomizers } = logtoConfigs;
export class JwtCustomizerLibrary {
// Convert errors to WithTyped client response error to share the error handling logic.
static async runScriptInLocalVm(data: CustomJwtFetcher) {
try {
const payload =
data.tokenType === LogtoJwtTokenKeyType.AccessToken
? pick(data, 'token', 'context', 'environmentVariables')
: pick(data, 'token', 'environmentVariables');
const result = await runScriptFunctionInLocalVm(data.script, 'getCustomJwtClaims', payload);

// If the `result` is not a record, we cannot merge it to the existing token payload.
return z.record(z.unknown()).parse(result);

Check warning on line 41 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L40-L41

Added lines #L40 - L41 were not covered by tests
} catch (error: unknown) {
// Assuming we only use zod for request body validation
if (error instanceof ZodError) {
const { errors } = error;
throw new LocalVmError(
{
message: 'Invalid input',
errors,
},
400
);
}

Check warning on line 53 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L45-L53

Added lines #L45 - L53 were not covered by tests

throw new LocalVmError(
buildErrorResponse(error),
error instanceof SyntaxError || error instanceof TypeError ? 422 : 500
);
}
}

constructor(

Check warning on line 62 in packages/core/src/libraries/jwt-customizer.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/libraries/jwt-customizer.ts#L62

[max-params] Constructor has too many parameters (5). Maximum allowed is 4.
private readonly queries: Queries,
private readonly logtoConfigs: LogtoConfigLibrary,
private readonly cloudConnection: CloudConnectionLibrary,
private readonly userLibrary: UserLibrary,
private readonly scopeLibrary: ScopeLibrary
) {}

/**
* We does not include org roles' scopes for the following reason:
* 1. The org scopes query method requires `limit` and `offset` parameters. Other management API get
* these APIs from console setup while this library method is a backend used method.
* 2. Logto developers can get the org roles' id from this user context and hence query the org roles' scopes via management API.
*/
const getUserContext = async (userId: string): Promise<JwtCustomizerUserContext> => {
const user = await findUserById(userId);
const fullSsoIdentities = await userSsoIdentities.findUserSsoIdentitiesByUserId(userId);
const roles = await findUserRoles(userId);
const rolesScopes = await findRolesScopesByRoleIds(roles.map(({ id }) => id));
async getUserContext(userId: string): Promise<JwtCustomizerUserContext> {
const user = await this.queries.users.findUserById(userId);
const fullSsoIdentities = await this.queries.userSsoIdentities.findUserSsoIdentitiesByUserId(
userId
);
const roles = await this.userLibrary.findUserRoles(userId);
const rolesScopes = await this.queries.rolesScopes.findRolesScopesByRoleIds(
roles.map(({ id }) => id)
);

Check warning on line 84 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L77-L84

Added lines #L77 - L84 were not covered by tests
const scopeIds = rolesScopes.map(({ scopeId }) => scopeId);
const scopes = await findScopesByIds(scopeIds);
const scopesWithResources = await attachResourceToScopes(scopes);
const organizationsWithRoles = await relations.users.getOrganizationsByUserId(userId);
const scopes = await this.queries.scopes.findScopesByIds(scopeIds);
const scopesWithResources = await this.scopeLibrary.attachResourceToScopes(scopes);
const organizationsWithRoles =
await this.queries.organizations.relations.users.getOrganizationsByUserId(userId);

Check warning on line 89 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L86-L89

Added lines #L86 - L89 were not covered by tests
const userContext = {
...pick(user, ...userInfoSelectFields),
ssoIdentities: fullSsoIdentities.map(pickState('issuer', 'identityId', 'detail')),
Expand Down Expand Up @@ -81,7 +114,7 @@
};

return jwtCustomizerUserContextGuard.parse(userContext);
};
}

Check warning on line 117 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L117

Added line #L117 was not covered by tests

/**
* This method is used to deploy the give JWT customizer scripts to the cloud worker service.
Expand All @@ -95,17 +128,24 @@
* @params payload.value - JWT customizer value
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`.
*/
const deployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
async deployJwtCustomizerScript<T extends LogtoJwtTokenKey>(
consoleLog: ConsoleLog,
payload: {
key: T;
value: JwtCustomizerType[T];
useCase: 'test' | 'production';
}
) => {
) {
if (!EnvSet.values.isCloud) {
consoleLog.warn(
'Early terminate `deployJwtCustomizerScript` since we do not provide dedicated computing resource for OSS version.'
);
return;
}

Check warning on line 145 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L138-L145

Added lines #L138 - L145 were not covered by tests
const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(),
getJwtCustomizers(consoleLog),
this.cloudConnection.getClient(),
this.logtoConfigs.getJwtCustomizers(consoleLog),

Check warning on line 148 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L147-L148

Added lines #L147 - L148 were not covered by tests
]);

const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
Expand All @@ -129,15 +169,19 @@
await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
});
};
}

Check warning on line 172 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L172

Added line #L172 was not covered by tests

async undeployJwtCustomizerScript<T extends LogtoJwtTokenKey>(consoleLog: ConsoleLog, key: T) {
if (!EnvSet.values.isCloud) {
consoleLog.warn(
'Early terminate `undeployJwtCustomizerScript` since we do not deploy the script to dedicated computing resource for OSS version.'
);
return;
}

Check warning on line 180 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L175-L180

Added lines #L175 - L180 were not covered by tests

const undeployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
consoleLog: ConsoleLog,
key: T
) => {
const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(),
getJwtCustomizers(consoleLog),
this.cloudConnection.getClient(),
this.logtoConfigs.getJwtCustomizers(consoleLog),

Check warning on line 184 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L183-L184

Added lines #L183 - L184 were not covered by tests
]);

assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key }));
Expand All @@ -160,10 +204,5 @@
await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
});
};
return {
getUserContext,
deployJwtCustomizerScript,
undeployJwtCustomizerScript,
};
};
}

Check warning on line 207 in packages/core/src/libraries/jwt-customizer.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/jwt-customizer.ts#L207

Added line #L207 was not covered by tests
}
45 changes: 20 additions & 25 deletions packages/core/src/oidc/extra-token-claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
LogtoJwtTokenKeyType,
LogResult,
jwtCustomizer as jwtCustomizerLog,
type CustomJwtFetcher,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional, trySafe } from '@silverhand/essentials';
import { type KoaContextWithOIDC, type UnknownObject } from 'oidc-provider';

import { EnvSet } from '#src/env-set/index.js';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js';
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { LogEntry } from '#src/middleware/koa-audit-log.js';
import type Libraries from '#src/tenants/Libraries.js';
Expand Down Expand Up @@ -66,12 +68,6 @@
cloudConnection: CloudConnectionLibrary;
}
): Promise<UnknownObject | undefined> => {
const { isCloud } = EnvSet.values;
// No cloud connection for OSS version, skip.
if (!isCloud) {
return;
}

// Narrow down the token type to `AccessToken` and `ClientCredentials`.
if (
!(token instanceof ctx.oidc.provider.AccessToken) &&
Expand Down Expand Up @@ -110,37 +106,36 @@
.map((field) => [field, Reflect.get(token, field)])
);

const client = await cloudConnection.getClient();

const commonPayload = {
script,
environmentVariables,
token: readOnlyToken,
};

// 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: isTokenClientCredentials
? {
...commonPayload,
tokenType: LogtoJwtTokenKeyType.ClientCredentials,
}
const payload: CustomJwtFetcher = {
script,
environmentVariables,
token: readOnlyToken,
...(isTokenClientCredentials
? { tokenType: LogtoJwtTokenKeyType.ClientCredentials }

Check warning on line 121 in packages/core/src/oidc/extra-token-claims.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/extra-token-claims.ts#L116-L121

Added lines #L116 - L121 were not covered by tests
: {
...commonPayload,
tokenType: LogtoJwtTokenKeyType.AccessToken,
// TODO (LOG-8555): the newly added `UserProfile` type includes undefined fields and can not be directly assigned to `Json` type. And the `undefined` fields should be removed by zod guard.

Check warning on line 124 in packages/core/src/oidc/extra-token-claims.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/extra-token-claims.ts#L124

[no-warning-comments] Unexpected 'todo' comment: 'TODO (LOG-8555): the newly added...'.
// `context` parameter is only eligible for user's access token for now.

Check warning on line 125 in packages/core/src/oidc/extra-token-claims.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/extra-token-claims.ts#L125

Added line #L125 was not covered by tests
// eslint-disable-next-line no-restricted-syntax
context: { user: logtoUserInfo as Record<string, Json> },
darcyYe marked this conversation as resolved.
Show resolved Hide resolved
},
search: {},
});
}),
};

if (EnvSet.values.isCloud) {
const client = await cloudConnection.getClient();
return await client.post(`/api/services/custom-jwt`, {
body: payload,
search: {},
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
});
}
return await JwtCustomizerLibrary.runScriptInLocalVm(payload);

Check warning on line 138 in packages/core/src/oidc/extra-token-claims.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/extra-token-claims.ts#L128-L138

Added lines #L128 - L138 were not covered by tests
} catch (error: unknown) {
const entry = new LogEntry(
`${jwtCustomizerLog.prefix}.${
Expand Down
16 changes: 5 additions & 11 deletions packages/core/src/routes/logto-config/jwt-customizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,9 @@
expect(response.status).toEqual(204);
});

it('POST /configs/jwt-customizer/test should return 200', async () => {
const cloudConnectionResponse = { success: true };
it('POST /configs/jwt-customizer/test should not call cloud connection client post', async () => {
jest.spyOn(tenantContext.cloudConnection, 'getClient').mockResolvedValue(mockCloudClient);
jest.spyOn(mockCloudClient, 'post').mockResolvedValue(cloudConnectionResponse);
const clientPostSpy = jest.spyOn(mockCloudClient, 'post');

const payload: JwtCustomizerTestRequestBody = {
tokenType: LogtoJwtTokenKeyType.ClientCredentials,
Expand All @@ -169,7 +168,7 @@
token: {},
};

const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload);
await routeRequester.post('/configs/jwt-customizer/test').send(payload);

expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
expect.any(ConsoleLog),
Expand All @@ -180,13 +179,8 @@
}
);

expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', {
body: payload,
search: {
isTest: 'true',
},
});
expect(clientPostSpy).toHaveBeenCalledTimes(0);

expect(response.status).toEqual(200);
// TODO: Add the test on nested class static method.

Check warning on line 184 in packages/core/src/routes/logto-config/jwt-customizer.test.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/logto-config/jwt-customizer.test.ts#L184

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Add the test on nested class...'.
});
});
Loading
Loading