diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 027999ff5..7473a02cb 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) => @@ -167,15 +168,25 @@ export const createUserLibrary = (queries: Queries) => { return findUsersByIds(usersRoles.map(({ userId }) => userId)); }; + /** + * Find user scopes for a resource indicator, from roles and organization roles + * set organizationId to narrow down the search to the specific organization, otherwise it will search all organizations + */ const findUserScopesForResourceIndicator = async ( userId: string, - resourceIndicator: string + resourceIndicator: string, + organizationId?: string ): Promise => { const usersRoles = await findUsersRolesByUserId(userId); const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId)); + const organizationScopes = await organizations.relations.rolesUsers.getUserResourceScopes( + userId, + resourceIndicator, + organizationId + ); 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 000000000..f257b05e2 --- /dev/null +++ b/packages/core/src/oidc/extra-token-claims.ts @@ -0,0 +1,167 @@ +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, + queries, + libraries, + logtoConfigs, + cloudConnection, + }: { + 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 b4b6b09b9..00819c83e 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); diff --git a/packages/core/src/oidc/grants/refresh-token.ts b/packages/core/src/oidc/grants/refresh-token.ts index 57ef72513..6a55ca21a 100644 --- a/packages/core/src/oidc/grants/refresh-token.ts +++ b/packages/core/src/oidc/grants/refresh-token.ts @@ -33,7 +33,7 @@ import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js'; import validatePresence from 'oidc-provider/lib/helpers/validate_presence.js'; import instance from 'oidc-provider/lib/helpers/weak_cache.js'; -import { type EnvSet } from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; @@ -144,7 +144,7 @@ export const buildHandler: ( throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations); } // Does not allow requesting resource token when requesting organization token (yet). - if (params.resource) { + if (!EnvSet.values.isDevFeaturesEnabled && params.resource) { throw new InvalidRequest('resource is not allowed when requesting organization token'); } } @@ -313,8 +313,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, issue 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 9c7687753..51e0eaa72 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -13,26 +13,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 +38,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 +66,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'); @@ -145,8 +142,22 @@ export default function initOidc( const { accessTokenTtl: accessTokenTTL } = resourceServer; - const scopes = await findResourceScopes(queries, libraries, ctx, indicator); - const { client } = ctx.oidc; + const { client, params } = ctx.oidc; + /** + * In consent or code excange flow, the organization_id is undefined, + * and all the scopes inherited from the all organization roles will be granted. + * In the flow of granting token for organization with api resource, + * this value is set to the organization id, + * and will then narrow down the scopes to the specific organization. + */ + const organizationId = params?.organization_id; + const scopes = await findResourceScopes( + queries, + libraries, + ctx, + indicator, + typeof organizationId === 'string' ? organizationId : undefined + ); // Need to filter out the unsupported scopes for the third-party application. if (client && (await isThirdPartyApplication(queries, client.clientId))) { @@ -210,98 +221,29 @@ export default function initOidc( }, }, extraParams: Object.values(ExtraParamsKey), - // eslint-disable-next-line complexity - extraTokenClaims: async (ctx, token) => { - const { isDevFeaturesEnabled, isCloud } = EnvSet.values; - // No cloud connection for OSS version, skip. - if (!isDevFeaturesEnabled || !isCloud) { + extraTokenClaims: async (ctx, token) => { + const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource( + ctx, + token + ); + + const jwtCustomizedClaims = await getExtraTokenClaimsForJwtCustomization(ctx, token, { + envSet, + queries, + libraries, + logtoConfigs, + cloudConnection, + }); + + 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), diff --git a/packages/core/src/oidc/resource.ts b/packages/core/src/oidc/resource.ts index 4e57393ad..af1f0e62d 100644 --- a/packages/core/src/oidc/resource.ts +++ b/packages/core/src/oidc/resource.ts @@ -33,7 +33,8 @@ export const findResourceScopes = async ( queries: Queries, libraries: Libraries, ctx: KoaContextWithOIDC, - indicator: string + indicator: string, + organizationId?: string ): Promise> => { if (isReservedResource(indicator)) { switch (indicator) { @@ -56,7 +57,7 @@ export const findResourceScopes = async ( const userId = oidc.session?.accountId ?? oidc.entities.Account?.accountId; if (userId) { - return findUserScopesForResourceIndicator(userId, indicator); + return findUserScopesForResourceIndicator(userId, indicator, organizationId); } const clientId = oidc.entities.Client?.clientId; diff --git a/packages/core/src/queries/organization/relations.ts b/packages/core/src/queries/organization/relations.ts index 98070ecae..e536fe070 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 OrganizationScope, + type ResourceScopeEntity, + Scopes, + OrganizationRoleResourceScopeRelations, + Resources, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -18,7 +22,7 @@ import RelationQueries, { type GetEntitiesOptions, TwoRelationsQueries, } from '#src/utils/RelationQueries.js'; -import { convertToIdentifiers } from '#src/utils/sql.js'; +import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; import { type userSearchKeys } from '../user.js'; @@ -196,6 +200,36 @@ export class RoleUserRelationQueries extends RelationQueries< `); } + /** + * Get the available resource scopes of a user in all organizations. + * if organizationId is provided, it will only search in that organization + */ + async getUserResourceScopes( + userId: string, + resourceIndicator: string, + organizationId?: 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} + ${conditionalSql(organizationId, (value) => sql`and ${fields.organizationId} = ${value}`)} + `); + } + /** 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 4aab200be..a7c6a556c 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`, `)} diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 16d8ce5dd..48552523c 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -127,8 +127,8 @@ export default class MockClient { await this.logto.handleSignInCallback(signInCallbackUri); } - public async getAccessToken(resource?: string) { - return this.logto.getAccessToken(resource); + public async getAccessToken(resource?: string, organizationId?: string) { + return this.logto.getAccessToken(resource, organizationId); } public async getRefreshToken(): Promise> { diff --git a/packages/integration-tests/src/tests/api/oidc/organization-api-resource.test.ts b/packages/integration-tests/src/tests/api/oidc/organization-api-resource.test.ts new file mode 100644 index 000000000..67768f62d --- /dev/null +++ b/packages/integration-tests/src/tests/api/oidc/organization-api-resource.test.ts @@ -0,0 +1,178 @@ +import { UserScope } from '@logto/core-kit'; +import { InteractionEvent, type Resource } from '@logto/schemas'; + +import { createResource, deleteResource, deleteUser, putInteraction } from '#src/api/index.js'; +import { createScope, deleteScope } from '#src/api/scope.js'; +import MockClient from '#src/client/index.js'; +import { processSession } from '#src/helpers/client.js'; +import { createUserByAdmin } from '#src/helpers/index.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateUsername, generatePassword, getAccessTokenPayload } from '#src/utils.js'; + +describe('get access token for organization API resource', () => { + const username = generateUsername(); + const password = generatePassword(); + const testApiResourceInfo: Pick = { + name: 'test-api-resource', + indicator: 'https://foo.logto.io/api', + }; + const scopeName = 'read'; + const scopeName2 = 'read:other'; + + /* eslint-disable @silverhand/fp/no-let */ + let testApiResourceId: string; + let testApiScopeId: string; + let testApiScopeId2: string; + let testUserId: string; + let testOrganizationId: string; + let testOrganizationId2: string; + /* eslint-enable @silverhand/fp/no-let */ + + const organizationApi = new OrganizationApiTest(); + + /* eslint-disable @silverhand/fp/no-mutation */ + beforeAll(async () => { + const user = await createUserByAdmin({ username, password }); + testUserId = user.id; + const testApiResource = await createResource( + testApiResourceInfo.name, + testApiResourceInfo.indicator + ); + testApiResourceId = testApiResource.id; + const scope = await createScope(testApiResource.id, scopeName); + testApiScopeId = scope.id; + const scope2 = await createScope(testApiResource.id, scopeName2); + testApiScopeId2 = scope2.id; + + const organization = await organizationApi.create({ name: 'org1' }); + testOrganizationId = organization.id; + await organizationApi.addUsers(testOrganizationId, [user.id]); + const role = await organizationApi.roleApi.create({ name: 'role1' }); + await organizationApi.roleApi.addResourceScopes(role.id, [scope.id]); + await organizationApi.addUserRoles(testOrganizationId, user.id, [role.id]); + + const organization2 = await organizationApi.create({ name: 'org2' }); + testOrganizationId2 = organization2.id; + await organizationApi.addUsers(testOrganizationId2, [user.id]); + const role2 = await organizationApi.roleApi.create({ name: 'role2' }); + await organizationApi.roleApi.addResourceScopes(role2.id, [scope2.id]); + await organizationApi.addUserRoles(testOrganizationId2, user.id, [role2.id]); + + await enableAllPasswordSignInMethods(); + }); + /* eslint-enable @silverhand/fp/no-mutation */ + + afterAll(async () => { + if (testApiResourceId && testApiScopeId && testApiScopeId2) { + await deleteScope(testApiResourceId, testApiScopeId); + await deleteScope(testApiResourceId, testApiScopeId2); + await deleteResource(testApiResourceId); + } + if (testUserId) { + await deleteUser(testUserId); + } + await organizationApi.cleanUp(); + await organizationApi.roleApi.cleanUp(); + }); + + it('can sign in and get access token with resource and organization_id', async () => { + const client = new MockClient({ + resources: [testApiResourceInfo.indicator], + scopes: [scopeName, scopeName2, UserScope.Organizations], + }); + await client.initSession(); + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { username, password }, + }); + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + const accessToken = await client.getAccessToken( + testApiResourceInfo.indicator, + testOrganizationId + ); + + // No scopeName2, because we narrow down to only organization1 + expect(getAccessTokenPayload(accessToken)).toHaveProperty('scope', scopeName); + expect(getAccessTokenPayload(accessToken)).toHaveProperty( + 'organization_id', + testOrganizationId + ); + expect(getAccessTokenPayload(accessToken)).toHaveProperty('aud', testApiResourceInfo.indicator); + }); + + it('can sign in and get normal access token with all scopes', async () => { + const client = new MockClient({ + resources: [testApiResourceInfo.indicator], + scopes: [scopeName, scopeName2], + }); + await client.initSession(); + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { username, password }, + }); + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + const accessToken = await client.getAccessToken(testApiResourceInfo.indicator); + + expect(getAccessTokenPayload(accessToken)).toHaveProperty( + 'scope', + [scopeName, scopeName2].join(' ') + ); + expect(getAccessTokenPayload(accessToken)).not.toHaveProperty( + 'organization_id', + testOrganizationId + ); + expect(getAccessTokenPayload(accessToken)).toHaveProperty('aud', testApiResourceInfo.indicator); + }); + + it('should throw if the user is not in the organization', async () => { + const username = generateUsername(); + const password = generatePassword(); + const guestUser = await createUserByAdmin({ username, password }); + const client = new MockClient({ + resources: [testApiResourceInfo.indicator], + scopes: [scopeName, UserScope.Organizations], + }); + await client.initSession(); + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { username, password }, + }); + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + await expect( + client.getAccessToken(testApiResourceInfo.indicator, testOrganizationId) + ).rejects.toThrow(); + await deleteUser(guestUser.id); + }); + + it('should not get the scope if the user organization role does not have the scope', async () => { + const username = generateUsername(); + const password = generatePassword(); + const guestUser = await createUserByAdmin({ username, password }); + await organizationApi.addUsers(testOrganizationId, [guestUser.id]); + const role = await organizationApi.roleApi.create({ name: 'role3' }); + // Noted that we do not add the scope to the role. + await organizationApi.addUserRoles(testOrganizationId, guestUser.id, [role.id]); + const client = new MockClient({ + resources: [testApiResourceInfo.indicator], + scopes: [scopeName, UserScope.Organizations], + }); + await client.initSession(); + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { username, password }, + }); + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + const accessToken = await client.getAccessToken( + testApiResourceInfo.indicator, + testOrganizationId + ); + + expect(getAccessTokenPayload(accessToken)).not.toHaveProperty('scope', scopeName); + await deleteUser(guestUser.id); + }); +});