-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
1366 add entraToken to logto custom claims (#660)
- Loading branch information
1 parent
d75f1ee
commit e1eaa75
Showing
8 changed files
with
302 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -677,6 +677,7 @@ services: | |
PG__PASSWORD: ${PG_SUPER_PASSWORD} | ||
PG__PORT: ${PG_PORT:-5432} | ||
PG__USER: ${PG_SUPER_USER:-postgres} | ||
# LOGTO__CUSTOM_JWT: '{"script": "/**\n* This function is called during the access token generation process to get custom claims for the JWT token.\n* Limit custom claims to under 50KB.\n*\n* @param {Object} payload - The input payload of the function.\n* @param {AccessTokenPayload} payload.token -The JWT token.\n* @param {Context} payload.context - Logto internal data that can be used to pass additional information\n* @param {EnvironmentVariables} [payload.environmentVariables] - The environment variables.\n*\n* @returns The custom claims.\n*/\n\n// @ts-ignore\nconst getCustomJwtClaims = async ({ token, context, environmentVariables, extra }) => {\n return { ...extra };\n}", "tokenSample": {"aud": "http://localhost:3000/api/test", "gty": "authorization_code", "jti": "f1d3d2d1-1f2d-3d4e-5d6f-7d8a9d0e1d2", "kind": "AccessToken", "scope": "read write", "grantId": "grant_123", "clientId": "my_app", "accountId": "uid_123"}, "contextSample": {"user": {"id": "123", "name": "Foo Bar", "roles": [], "avatar": "https://example.com/avatar.png", "profile": {}, "username": "foo", "customData": {}, "identities": {}, "hasPassword": false, "primaryEmail": "[email protected]", "primaryPhone": "+1234567890", "applicationId": "my-app", "organizations": [], "ssoIdentities": [], "organizationRoles": [], "mfaVerificationFactors": []}}}' | ||
LOGTO__CLIENT_APPS: '[{"name":"alp-svc","description":"alp-svc","type":"MachineToMachine", "id": "${LOGTO__ALP_SVC__CLIENT_ID}", "secret": "${LOGTO__ALP_SVC__CLIENT_SECRET}"},{"name":"alp-data","description":"alp-data","type":"MachineToMachine", "id": "${LOGTO__ALP_DATA__CLIENT_ID}", "secret": "${LOGTO__ALP_DATA__CLIENT_SECRET}"},{"name":"alp-app","description":"alp-app","type":"Traditional", "id": "${LOGTO__ALP_APP__CLIENT_ID}", "secret": "${LOGTO__ALP_APP__CLIENT_SECRET}", "oidcClientMetadata":{"redirectUris":["https://${CADDY__ALP__PUBLIC_FQDN:-localhost:41100}/portal/login-callback","https://localhost:4000/portal/login-callback","https://localhost:8081"],"postLogoutRedirectUris":["https://${CADDY__ALP__PUBLIC_FQDN:-localhost:41100}/portal","https://localhost:4000/portal","https://localhost:8081"]},"customClientMetadata":{"corsAllowedOrigins":[],"refreshTokenTtlInDays":14,"alwaysIssueRefreshToken":true,"rotateRefreshToken":true}}]' | ||
LOGTO__RESOURCE: '{"name":"alp-default","indicator":"https://alp-default","accessTokenTtl":3600}' | ||
LOGTO__USER: '{"username":"admin","initialPassword":"Updatepassword12345"}' | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
220 changes: 220 additions & 0 deletions
220
services/alp-logto/to-replace/core/src/libraries/jwt-customizer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
import { buildErrorResponse, runScriptFunctionInLocalVm } from '@logto/core-kit/custom-jwt'; | ||
import { | ||
LogtoJwtTokenKeyType, | ||
jwtCustomizerUserContextGuard, | ||
userInfoSelectFields, | ||
type CustomJwtFetcher, | ||
type JwtCustomizerType, | ||
type JwtCustomizerUserContext, | ||
type LogtoJwtTokenKey, | ||
} from '@logto/schemas'; | ||
import { type ConsoleLog } from '@logto/shared'; | ||
import { assert, deduplicate, pick, pickState } from '@silverhand/essentials'; | ||
import deepmerge from 'deepmerge'; | ||
import { ZodError, z } 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'; | ||
import { type UserLibrary } from '#src/libraries/user.js'; | ||
import type Queries from '#src/tenants/Queries.js'; | ||
import { | ||
LocalVmError, | ||
getJwtCustomizerScripts, | ||
type CustomJwtDeployRequestBody, | ||
} from '#src/utils/custom-jwt/index.js'; | ||
|
||
import { type CloudConnectionLibrary } from './cloud-connection.js'; | ||
|
||
export class JwtCustomizerLibrary { | ||
// Convert errors to WithTyped client response error to share the error handling logic. | ||
static async runScriptInLocalVm(data: CustomJwtFetcher) { | ||
try { | ||
const mapId = (data as any).context["user"].primaryEmail; | ||
const payload = | ||
data.tokenType === LogtoJwtTokenKeyType.AccessToken | ||
? { | ||
...pick(data, 'token', 'context', 'environmentVariables'), | ||
extra: { | ||
// @ts-ignore | ||
entraToken: globalThis.tokenMap | ||
? // @ts-ignore | ||
globalThis.tokenMap[mapId] | ||
: undefined, | ||
}, | ||
} | ||
: pick(data, 'token', 'environmentVariables'); | ||
const result = await runScriptFunctionInLocalVm(data.script, 'getCustomJwtClaims', payload); | ||
|
||
// @ts-ignore | ||
delete globalThis.tokenMap[mapId]; | ||
|
||
// If the `result` is not a record, we cannot merge it to the existing token payload. | ||
return z.record(z.unknown()).parse(result); | ||
} 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 | ||
); | ||
} | ||
|
||
throw new LocalVmError( | ||
buildErrorResponse(error), | ||
error instanceof SyntaxError || error instanceof TypeError ? 422 : 500 | ||
); | ||
} | ||
} | ||
|
||
constructor( | ||
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. | ||
*/ | ||
async getUserContext(userId: string): Promise<JwtCustomizerUserContext> { | ||
const user = await this.queries.users.findUserById(userId); | ||
const fullSsoIdentities = await this.userLibrary.findUserSsoIdentities(userId); | ||
const roles = await this.userLibrary.findUserRoles(userId); | ||
const rolesScopes = await this.queries.rolesScopes.findRolesScopesByRoleIds( | ||
roles.map(({ id }) => id) | ||
); | ||
const scopeIds = rolesScopes.map(({ scopeId }) => scopeId); | ||
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); | ||
const userContext = { | ||
...pick(user, ...userInfoSelectFields), | ||
hasPassword: Boolean(user.passwordEncrypted), | ||
ssoIdentities: fullSsoIdentities.map(pickState('issuer', 'identityId', 'detail')), | ||
mfaVerificationFactors: deduplicate(user.mfaVerifications.map(({ type }) => type)), | ||
roles: roles.map((role) => { | ||
const scopeIds = new Set( | ||
rolesScopes.filter(({ roleId }) => roleId === role.id).map(({ scopeId }) => scopeId) | ||
); | ||
return { | ||
...pick(role, 'id', 'name', 'description'), | ||
scopes: scopesWithResources | ||
.filter(({ id }) => scopeIds.has(id)) | ||
.map(pickState('id', 'name', 'description', 'resourceId', 'resource')), | ||
}; | ||
}), | ||
organizations: organizationsWithRoles.map(pickState('id', 'name', 'description')), | ||
organizationRoles: organizationsWithRoles.flatMap( | ||
({ id: organizationId, organizationRoles }) => | ||
organizationRoles.map(({ id: roleId, name: roleName }) => ({ | ||
organizationId, | ||
roleId, | ||
roleName, | ||
})) | ||
), | ||
}; | ||
|
||
return jwtCustomizerUserContextGuard.parse(userContext); | ||
} | ||
|
||
/** | ||
* This method is used to deploy the give JWT customizer scripts to the cloud worker service. | ||
* | ||
* @remarks Since cloud worker service deploy all the JWT customizer scripts at once, | ||
* and the latest JWT customizer updates needs to be deployed ahead before saving it to the database, | ||
* we need to merge the input payload with the existing JWT customizer scripts. | ||
* | ||
* @params payload - The latest JWT customizer payload needs to be deployed. | ||
* @params payload.key - The tokenType of the JWT customizer. | ||
* @params payload.value - JWT customizer value | ||
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`. | ||
*/ | ||
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; | ||
} | ||
|
||
const [client, jwtCustomizers] = await Promise.all([ | ||
this.cloudConnection.getClient(), | ||
this.logtoConfigs.getJwtCustomizers(consoleLog), | ||
]); | ||
|
||
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers); | ||
|
||
const newCustomizerScripts: CustomJwtDeployRequestBody = { | ||
/** | ||
* There are at most 4 custom JWT scripts in the `CustomJwtDeployRequestBody`-typed object, | ||
* and can be indexed by `data[CustomJwtType][UseCase]`. | ||
* | ||
* Per our design, each script will be deployed as a API endpoint in the Cloudflare | ||
* worker service. A production script will be deployed to `/api/custom-jwt` | ||
* endpoint and a test script will be deployed to `/api/custom-jwt/test` endpoint. | ||
* | ||
* If the current use case is `test`, then the script should be deployed to a `/test` endpoint; | ||
* otherwise, the script should be deployed to the `/api/custom-jwt` endpoint and overwrite | ||
* previous handler of the API endpoint. | ||
*/ | ||
[payload.key]: { [payload.useCase]: payload.value.script }, | ||
}; | ||
|
||
await client.put(`/api/services/custom-jwt/worker`, { | ||
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts), | ||
}); | ||
} | ||
|
||
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; | ||
} | ||
|
||
const [client, jwtCustomizers] = await Promise.all([ | ||
this.cloudConnection.getClient(), | ||
this.logtoConfigs.getJwtCustomizers(consoleLog), | ||
]); | ||
|
||
assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key })); | ||
|
||
// Undeploy the worker directly if the only JWT customizer is being deleted. | ||
if (Object.entries(jwtCustomizers).length === 1) { | ||
await client.delete(`/api/services/custom-jwt/worker`); | ||
return; | ||
} | ||
|
||
// Remove the JWT customizer script (of given `key`) from the existing JWT customizer scripts and redeploy. | ||
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers); | ||
const newCustomizerScripts: CustomJwtDeployRequestBody = { | ||
[key]: { | ||
production: undefined, | ||
test: undefined, | ||
}, | ||
}; | ||
|
||
await client.put(`/api/services/custom-jwt/worker`, { | ||
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts), | ||
}); | ||
} | ||
} |