Skip to content

Commit

Permalink
1366 add entraToken to logto custom claims (#660)
Browse files Browse the repository at this point in the history
  • Loading branch information
suwarnoong authored Feb 11, 2025
1 parent d75f1ee commit e1eaa75
Show file tree
Hide file tree
Showing 8 changed files with 302 additions and 4 deletions.
2 changes: 1 addition & 1 deletion docker-compose-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ services:
alp-logto:
build:
context: ./services/alp-logto
dockerfile: Dockerfile
dockerfile: Dockerfile.local

alp-minerva-pg-mgmt-init:
build:
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
Expand Down
12 changes: 12 additions & 0 deletions services/alp-logto/Dockerfile.local
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ ENV APPLICATIONINSIGHTS_CONNECTION_STRING=${applicationinsights_connection_strin

## Copy modified files as needed
COPY ./to-replace/SignIn/Main.tsx /etc/logto/packages/experience/src/pages/SignIn/Main.tsx
COPY ./to-replace/core/src/libraries/jwt-customizer.ts /etc/logto/packages/core/src/libraries/jwt-customizer.ts

RUN pnpm -r build

Expand All @@ -47,6 +48,17 @@ FROM node:20-alpine as app
WORKDIR /etc/logto
COPY --from=builder /etc/logto .

COPY ./connector-alp-azuread /etc/logto/packages/connectors/connector-alp-azuread

WORKDIR /etc/logto/packages/connectors/connector-alp-azuread

RUN yarn
RUN yarn build

WORKDIR /etc/logto/

RUN npx @logto/cli connector link

EXPOSE 3001
ENTRYPOINT ["npm", "run"]
CMD ["start"]
1 change: 1 addition & 0 deletions services/alp-logto/connector-alp-azuread/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- IDP__RELYING_PARTY: `azure`
- LOGTO_ROLES_AZ_GROUPS_MAPPING - Update `Object ID from Groups` from target Azure
- LOGTO__SCOPE: `openid offline_access profile email role.systemadmin role.useradmin role.tenantviewer role.dashboardviewer`
- Uncomment `LOGTO__CUSTOM_JWT`
- Restart services

# Microsoft Azure AD connector
Expand Down
11 changes: 9 additions & 2 deletions services/alp-logto/connector-alp-azuread/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const getAccessToken = async (
});

const authResult = await clientApplication.acquireTokenByCode(codeRequest);
const idToken = authResult.idToken

await assignLogtoRolesByAzureGroups(
authResult.idToken,
Expand All @@ -123,7 +124,7 @@ const getAccessToken = async (
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);

return { accessToken };
return { accessToken, idToken };
};

const assignLogtoRolesByAzureGroups = async (
Expand Down Expand Up @@ -404,7 +405,7 @@ const getUserInfo =
const config = await getConfig(defaultMetadata.id);
validateConfig(config, azureADConfigGuard);

const { accessToken } = await getAccessToken(config, code, redirectUri);
const { accessToken, idToken } = await getAccessToken(config, code, redirectUri);

// throw new Error("asdfasdfasdfasdfsd")

Expand All @@ -429,6 +430,12 @@ const getUserInfo =

const { id, mail, displayName } = result.data;

// @ts-ignore
globalThis.tokenMap = globalThis.tokenMap || {}
const mapId = mail
// @ts-ignore
globalThis.tokenMap[mapId] = idToken

return {
id,
email: conditional(mail),
Expand Down
44 changes: 43 additions & 1 deletion services/alp-logto/post-init/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,34 @@ async function update(
}
}

async function upsert(
path: string,
headers: object,
data: object,
hasResponseBody = true
) {
try {
console.log(`Request create/update ${path}`);
console.log(JSON.stringify(data));
const resp = await logto.put(path, headers, data);
console.log(`Responded with ${resp.status}`);

if (resp.ok) {
if (hasResponseBody) {
let json = await resp.json();
console.log(JSON.stringify(json));
return json;
}
} else {
console.error("Request failed");
console.error(resp.statusText, " ", path, " ", JSON.stringify(data));
return -1;
}
} catch (error) {
throw error;
}
}

async function fetchExisting(path: string, headers: object, showLog = true) {
try {
showLog && console.log(`Request existing ${path}`);
Expand Down Expand Up @@ -341,6 +369,21 @@ async function main() {
"*********************************************************************************\n"
);

if (process.env.LOGTO__CUSTOM_JWT) {
// Create custom JWT
console.log(
"*********************************** CONFIGS **********************************************"
);

const payload = JSON.parse(process.env.LOGTO__CUSTOM_JWT);
console.log("payload", payload);
await upsert("configs/jwt-customizer/access-token", headers, payload);

console.log(
"*********************************************************************************\n"
);
}

console.log(
"*********************************** SUMMARY **********************************\n"
);
Expand Down Expand Up @@ -428,7 +471,6 @@ async function main() {
createdUserRoles.length == userRoles.map((x) => x.roleIds).flat().length
}`
);

}

async function getDBClient() {
Expand Down
15 changes: 15 additions & 0 deletions services/alp-logto/post-init/src/middleware/logto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ export async function patch(
return resp;
}

export async function put(
path: string,
headers: any,
data: object
): Promise<Response> {
const resp = await fetch(`${LOGTO__ADMIN_SERVER__FQDN_URL}/api/${path}`, {
method: "PUT",
headers: Object.assign({}, headers, {
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
});
return resp;
}

export async function get(path: string, headers: any): Promise<Response> {
const resp = await fetch(`${LOGTO__ADMIN_SERVER__FQDN_URL}/api/${path}`, {
method: "GET",
Expand Down
220 changes: 220 additions & 0 deletions services/alp-logto/to-replace/core/src/libraries/jwt-customizer.ts
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),
});
}
}

0 comments on commit e1eaa75

Please sign in to comment.