diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 027999ff5933..8057e7135a4f 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -88,6 +88,7 @@ export const createUserLibrary = (queries: Queries) => { usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId }, rolesScopes: { findRolesScopesByRoleIds }, scopes: { findScopesByIdsAndResourceIndicator }, + organizations, } = queries; const generateUserId = async (retries = 500) => @@ -173,9 +174,13 @@ export const createUserLibrary = (queries: Queries) => { ): Promise => { const usersRoles = await findUsersRolesByUserId(userId); const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId)); + const organizationScopes = await organizations.relations.rolesUsers.getUserResourceScopes( + userId, + resourceIndicator + ); const scopes = await findScopesByIdsAndResourceIndicator( - rolesScopes.map(({ scopeId }) => scopeId), + [...rolesScopes.map(({ scopeId }) => scopeId), ...organizationScopes.map(({ id }) => id)], resourceIndicator ); diff --git a/packages/core/src/oidc/extra-token-claims.ts b/packages/core/src/oidc/extra-token-claims.ts new file mode 100644 index 000000000000..8a20f3afd029 --- /dev/null +++ b/packages/core/src/oidc/extra-token-claims.ts @@ -0,0 +1,159 @@ +import { + type Json, + LogtoJwtTokenKey, + LogtoJwtTokenKeyType, + LogResult, + jwtCustomizer as jwtCustomizerLog, +} 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 { 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'; +import type Queries from '#src/tenants/Queries.js'; + +/** + * For organization API resource feature, + * add extra token claim `organization_id` to the access token. + * notice that this is avaiable only when `resource` and `organization_id` are both present. + */ +export const getExtraTokenClaimsForOrganizationApiResource = async ( + ctx: KoaContextWithOIDC, + token: unknown +): Promise => { + const { isDevFeaturesEnabled } = EnvSet.values; + + if (!isDevFeaturesEnabled) { + return; + } + + const organizationId = ctx.oidc.params?.organization_id; + const resource = ctx.oidc.params?.resource; + + if (!organizationId || !resource) { + return; + } + + const isAccessToken = token instanceof ctx.oidc.provider.AccessToken; + + // Only handle access tokens + if (!isAccessToken) { + return; + } + + return { organization_id: organizationId }; +}; + +/* eslint-disable complexity */ +export const getExtraTokenClaimsForJwtCustomization = async ( + ctx: KoaContextWithOIDC, + token: unknown, + envSet: EnvSet, + queries: Queries, + libraries: Libraries, + logtoConfigs: LogtoConfigLibrary, + cloudConnection: CloudConnectionLibrary +): Promise => { + const { isDevFeaturesEnabled, isCloud } = EnvSet.values; + // No cloud connection for OSS version, skip. + if (!isDevFeaturesEnabled || !isCloud) { + return; + } + + // Narrow down the token type to `AccessToken` and `ClientCredentials`. + if ( + !(token instanceof ctx.oidc.provider.AccessToken) && + !(token instanceof ctx.oidc.provider.ClientCredentials) + ) { + return; + } + + const isTokenClientCredentials = token instanceof ctx.oidc.provider.ClientCredentials; + + try { + /** + * It is by design to use `trySafe` here to catch the error but not log it since we do not + * want to insert an error log every time the OIDC provider issues a token when the JWT + * customizer is not configured. + */ + const { script, environmentVariables } = + (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 + .filter((field) => Reflect.get(token, field) !== undefined) + .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, + } + : { + ...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. + // eslint-disable-next-line no-restricted-syntax + context: { user: logtoUserInfo as Record }, + }, + }); + } catch (error: unknown) { + const entry = new LogEntry( + `${jwtCustomizerLog.prefix}.${ + isTokenClientCredentials + ? jwtCustomizerLog.Type.ClientCredentials + : jwtCustomizerLog.Type.AccessToken + }` + ); + entry.append({ + result: LogResult.Error, + error: { message: String(error) }, + }); + const { payload } = entry; + await queries.logs.insertLog({ + id: generateStandardId(), + key: payload.key, + payload: { + ...payload, + tenantId: envSet.tenantId, + token, + }, + }); + } +}; +/* eslint-enable complexity */ diff --git a/packages/core/src/oidc/grants/refresh-token.test.ts b/packages/core/src/oidc/grants/refresh-token.test.ts index cda582c1a82d..64ee8f7c47bf 100644 --- a/packages/core/src/oidc/grants/refresh-token.test.ts +++ b/packages/core/src/oidc/grants/refresh-token.test.ts @@ -238,21 +238,6 @@ describe('organization token grant', () => { await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidScope); }); - it('should throw when both `resource` and `organization_id` are present in request', async () => { - const ctx = createOidcContext({ - ...validOidcContext, - params: { - ...validOidcContext.params, - resource: 'some_resource', - }, - }); - stubRefreshToken(ctx); - stubGrant(ctx); - await expect(mockHandler()(ctx, noop)).rejects.toMatchError( - new errors.InvalidRequest('resource is not allowed when requesting organization token') - ); - }); - it('should throw when account cannot be found or account id mismatch', async () => { const ctx = createOidcContext(validOidcContext); stubRefreshToken(ctx); @@ -329,4 +314,29 @@ describe('organization token grant', () => { aud: 'urn:logto:organization:some_org_id', }); }); + + it('should go through normal process when both `organization_id` and `resource` are present', async () => { + const ctx = createOidcContext({ + ...validOidcContext, + params: { + ...validOidcContext.params, + resource: 'https://some_resource/api', + }, + }); + stubRefreshToken(ctx); + stubGrant(ctx); + const tenant = new MockTenant(); + + Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true); + Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication); + Sinon.stub(tenant.queries.organizations.relations.rolesUsers, 'getUserScopes').resolves([ + { id: 'foo', name: 'foo' }, + { id: 'bar', name: 'bar' }, + { id: 'baz', name: 'baz' }, + ]); + + // The resource is not existent, so it should throw invalid target error if the handler + // goes through the normal process. + await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(errors.InvalidTarget); + }); }); diff --git a/packages/core/src/oidc/grants/refresh-token.ts b/packages/core/src/oidc/grants/refresh-token.ts index 57ef72513e34..3246939f2a2e 100644 --- a/packages/core/src/oidc/grants/refresh-token.ts +++ b/packages/core/src/oidc/grants/refresh-token.ts @@ -138,15 +138,11 @@ export const buildHandler: ( // The value type is `unknown`, which will swallow other type inferences. So we have to cast it // to `Boolean` first. const organizationId = cond(Boolean(params.organization_id) && String(params.organization_id)); - if (organizationId) { - // Validate if the refresh token has the required scope from RFC 0001. - if (!refreshToken.scopes.has(UserScope.Organizations)) { - throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations); - } - // Does not allow requesting resource token when requesting organization token (yet). - if (params.resource) { - throw new InvalidRequest('resource is not allowed when requesting organization token'); - } + if ( + organizationId && + !refreshToken.scopes.has(UserScope.Organizations) // Validate if the refresh token has the required scope from RFC 0001. + ) { + throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations); } /* === End RFC 0001 === */ @@ -313,8 +309,11 @@ export const buildHandler: ( /** The scopes requested by the client. If not provided, use the scopes from the refresh token. */ const scope = params.scope ? requestParamScopes : refreshToken.scopes; - /* === RFC 0001 === */ - if (organizationId) { + // Note, issur organization token only if `params.resource` is not present. + // If resource is set, will issue normal access token with extra claim "organization_id", + // the logic is handled in `getResourceServerInfo` and `extraTokenClaims`, see the init file of oidc-provider. + if (organizationId && !params.resource) { + /* === RFC 0001 === */ const audience = buildOrganizationUrn(organizationId); /** All available scopes for the user in the organization. */ const availableScopes = await queries.organizations.relations.rolesUsers diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 9c768775328b..9bedcc1a759d 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ /* istanbul ignore file */ import assert from 'node:assert'; import { readFileSync } from 'node:fs'; @@ -13,26 +12,20 @@ import { inSeconds, logtoCookieKey, type LogtoUiCookie, - LogtoJwtTokenKey, ExtraParamsKey, - type Json, - jwtCustomizer as jwtCustomizerLog, - LogResult, - LogtoJwtTokenKeyType, } from '@logto/schemas'; -import { generateStandardId } from '@logto/shared'; 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 { EnvSet } from '#src/env-set/index.js'; +import { type 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, { LogEntry } from '#src/middleware/koa-audit-log.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'; import { @@ -44,6 +37,10 @@ import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import defaults from './defaults.js'; +import { + getExtraTokenClaimsForJwtCustomization, + getExtraTokenClaimsForOrganizationApiResource, +} from './extra-token-claims.js'; import { registerGrants } from './grants/index.js'; import { findResource, @@ -68,7 +65,6 @@ export default function initOidc( resources: { findDefaultResource }, users: { findUserById }, organizations, - logs: { insertLog }, } = queries; const logoutSource = readFileSync('static/html/logout.html', 'utf8'); const logoutSuccessSource = readFileSync('static/html/post-logout/index.html', 'utf8'); @@ -210,98 +206,31 @@ export default function initOidc( }, }, extraParams: Object.values(ExtraParamsKey), - // eslint-disable-next-line complexity + extraTokenClaims: async (ctx, token) => { - const { isDevFeaturesEnabled, isCloud } = EnvSet.values; + const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource( + ctx, + token + ); + + const jwtCustomizedClaims = await getExtraTokenClaimsForJwtCustomization( + ctx, + token, + envSet, + queries, + libraries, + logtoConfigs, + cloudConnection + ); - // No cloud connection for OSS version, skip. - if (!isDevFeaturesEnabled || !isCloud) { + if (!organizationApiResourceClaims && !jwtCustomizedClaims) { return; } - const isTokenClientCredentials = token instanceof ctx.oidc.provider.ClientCredentials; - - try { - /** - * It is by design to use `trySafe` here to catch the error but not log it since we do not - * want to insert an error log every time the OIDC provider issues a token when the JWT - * customizer is not configured. - */ - const { script, environmentVariables } = - (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 - .filter((field) => Reflect.get(token, field) !== undefined) - .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, - } - : { - ...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. - // eslint-disable-next-line no-restricted-syntax - context: { user: logtoUserInfo as Record }, - }, - }); - } catch (error: unknown) { - const entry = new LogEntry( - `${jwtCustomizerLog.prefix}.${ - isTokenClientCredentials - ? jwtCustomizerLog.Type.ClientCredentials - : jwtCustomizerLog.Type.AccessToken - }` - ); - entry.append({ - result: LogResult.Error, - error: { message: String(error) }, - }); - const { payload } = entry; - await insertLog({ - id: generateStandardId(), - key: payload.key, - payload: { - ...payload, - tenantId: envSet.tenantId, - token, - }, - }); - } + return { + ...organizationApiResourceClaims, + ...jwtCustomizedClaims, + }; }, extraClientMetadata: { properties: Object.values(CustomClientMetadataKey), @@ -456,4 +385,3 @@ export default function initOidc( return oidc; } -/* eslint-enable max-lines */ diff --git a/packages/core/src/queries/organization/relations.ts b/packages/core/src/queries/organization/relations.ts index 9d84c2a052a0..ce8e9846689c 100644 --- a/packages/core/src/queries/organization/relations.ts +++ b/packages/core/src/queries/organization/relations.ts @@ -10,6 +10,10 @@ import { type UserWithOrganizationRoles, type FeaturedUser, type OrganizationScopeEntity, + type ResourceScopeEntity, + Scopes, + OrganizationRoleResourceScopeRelations, + Resources, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -196,6 +200,34 @@ export class RoleUserRelationQueries extends RelationQueries< `); } + /** + * Get the available resource scopes of a user in all organizations. + * Note that the resource scopes are decided on authorization time, in which the orginazation id is unknown yet. + */ + async getUserResourceScopes( + userId: string, + resourceIndicator: string + ): Promise { + const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true); + const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true); + const scopes = convertToIdentifiers(Scopes, true); + const resources = convertToIdentifiers(Resources, true); + + return this.pool.any(sql` + select distinct on (${scopes.fields.id}) + ${scopes.fields.id}, ${scopes.fields.name} + from ${this.table} + join ${roleScopeRelations.table} + on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId} + join ${scopes.table} + on ${scopes.fields.id} = ${roleScopeRelations.fields.scopeId} + join ${resources.table} + on ${resources.fields.id} = ${scopes.fields.resourceId} + where ${fields.userId} = ${userId} + and ${resources.fields.indicator} = ${resourceIndicator} + `); + } + /** Replace the roles of a user in an organization. */ async replace(organizationId: string, userId: string, roleIds: string[]) { const users = convertToIdentifiers(Users); diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 4aab200be5ed..a7c6a556c40e 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -205,7 +205,7 @@ export const createUserQueries = (pool: CommonQueryMethods) => { ` ); - const findUsersByIds = async (userIds: string[]) => + const findUsersByIds = async (userIds: string[]): Promise => userIds.length > 0 ? pool.any(sql` select ${sql.join(Object.values(fields), sql`, `)}