From 5bc0048747cc978487efd378acdb3bf98e109905 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 24 Jun 2024 17:13:09 +0800 Subject: [PATCH] feat(core): issue organization token via client credentials --- .../oidc/grants/client-credentials.test.ts | 144 ++++++++++ .../src/oidc/grants/client-credentials.ts | 74 ++++- .../src/oidc/grants/refresh-token.test.ts | 10 +- .../core/src/oidc/grants/refresh-token.ts | 3 +- packages/core/src/oidc/init.ts | 12 +- packages/core/src/oidc/resource.ts | 24 +- .../application-role-relations.ts | 54 ++++ packages/integration-tests/src/api/api.ts | 2 + .../integration-tests/src/api/application.ts | 2 - .../api/application/application.roles.test.ts | 3 +- .../api/oidc/client-credentials-grant.test.ts | 253 ++++++++++++++++++ 11 files changed, 551 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/oidc/grants/client-credentials.test.ts create mode 100644 packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts diff --git a/packages/core/src/oidc/grants/client-credentials.test.ts b/packages/core/src/oidc/grants/client-credentials.test.ts new file mode 100644 index 00000000000..194e0cbc8e0 --- /dev/null +++ b/packages/core/src/oidc/grants/client-credentials.test.ts @@ -0,0 +1,144 @@ +import { isKeyInObject } from '@silverhand/essentials'; +import { type KoaContextWithOIDC, errors, type Adapter } from 'oidc-provider'; + +import { createOidcContext } from '#src/test-utils/oidc-provider.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; + +const { jest } = import.meta; + +jest.unstable_mockModule('oidc-provider/lib/shared/check_resource.js', () => ({ + default: jest.fn(), +})); + +jest.unstable_mockModule('oidc-provider/lib/helpers/weak_cache.js', () => ({ + default: jest.fn().mockReturnValue({ + configuration: jest.fn().mockReturnValue({ + features: { + mTLS: { getCertificate: jest.fn() }, + }, + scopes: new Set(['foo', 'bar']), + }), + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = async () => {}; + +const clientId = 'some_client_id'; +const requestScopes = ['foo', 'bar']; + +const mockAdapter: Adapter = { + upsert: jest.fn(), + find: jest.fn(), + findByUserCode: jest.fn(), + findByUid: jest.fn(), + consume: jest.fn(), + destroy: jest.fn(), + revokeByGrantId: jest.fn(), +}; + +type ClientCredentials = InstanceType; +type Client = InstanceType; + +const validClientCredentials: ClientCredentials = { + kind: 'ClientCredentials', + clientId, + aud: '', + tokenType: '', + isSenderConstrained: jest.fn().mockReturnValue(false), + iat: 0, + jti: '', + scope: requestScopes.join(' '), + scopes: new Set(requestScopes), + ttlPercentagePassed: jest.fn(), + isValid: false, + isExpired: false, + remainingTTL: 0, + expiration: 0, + save: jest.fn(), + adapter: mockAdapter, + destroy: jest.fn(), + emit: jest.fn(), +}; + +// @ts-expect-error +const createValidClient = ({ scope }: { scope?: string } = {}): Client => ({ + clientId, + grantTypeAllowed: jest.fn().mockResolvedValue(true), + clientAuthMethod: 'none', + scope, +}); + +const validOidcContext: Partial = { + params: { + refresh_token: 'some_refresh_token', + organization_id: 'some_org_id', + scope: requestScopes.join(' '), + }, + client: createValidClient(), +}; + +const { buildHandler } = await import('./client-credentials.js'); + +const mockHandler = (tenant = new MockTenant()) => { + return buildHandler(tenant.envSet, tenant.queries); +}; + +// The handler returns void so we cannot check the return value, and it's also not +// straightforward to assert the token is issued correctly. Here we just do the sanity +// check and basic token validation. Comprehensive token validation should be done in +// integration tests. +describe('client credentials grant', () => { + it('should throw an error if the client is not available', async () => { + const ctx = createOidcContext({ ...validOidcContext, client: undefined }); + await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidClient); + }); + + it('should throw an error if the requested scope is not allowed', async () => { + const ctx = createOidcContext({ + ...validOidcContext, + client: createValidClient({ scope: 'baz' }), + }); + await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidScope); + }); + + it('should throw an error if the app has not associated with the organization', async () => { + const ctx = createOidcContext(validOidcContext); + await expect( + mockHandler( + new MockTenant(undefined, { + organizations: { + relations: { + // @ts-expect-error + apps: { + exists: jest.fn().mockResolvedValue(false), + }, + }, + }, + }) + )(ctx, noop) + ).rejects.toThrow(errors.AccessDenied); + }); + + it('should be ok', async () => { + const ctx = createOidcContext(validOidcContext); + await expect( + mockHandler( + new MockTenant(undefined, { + organizations: { + relations: { + // @ts-expect-error + apps: { + exists: jest.fn().mockResolvedValue(true), + }, + // @ts-expect-error + appsRoles: { getApplicationScopes: jest.fn().mockResolvedValue([{ name: 'foo' }]) }, + }, + }, + }) + )(ctx, noop) + ).resolves.toBeUndefined(); + + expect(isKeyInObject(ctx.body, 'scope') && ctx.body.scope).toBe('foo'); + }); +}); diff --git a/packages/core/src/oidc/grants/client-credentials.ts b/packages/core/src/oidc/grants/client-credentials.ts index 8e6346d8624..96151bbf8e7 100644 --- a/packages/core/src/oidc/grants/client-credentials.ts +++ b/packages/core/src/oidc/grants/client-credentials.ts @@ -20,6 +20,8 @@ * The commit hash of the original file is `0c52469f08b0a4a1854d90a96546a3f7aa090e5e`. */ +import { buildOrganizationUrn } from '@logto/core-kit'; +import { cond } from '@silverhand/essentials'; import type Provider from 'oidc-provider'; import { errors } from 'oidc-provider'; import epochTime from 'oidc-provider/lib/helpers/epoch_time.js'; @@ -27,17 +29,19 @@ import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js'; import instance from 'oidc-provider/lib/helpers/weak_cache.js'; import checkResource from 'oidc-provider/lib/shared/check_resource.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'; -const { InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors; +import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js'; + +const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors; /** * The valid parameters for the `client_credentials` grant type. Note the `resource` parameter is * not included here since it should be handled per configuration when registering the grant type. */ -export const parameters = Object.freeze(['scope']); +export const parameters = Object.freeze(['scope', 'organization_id']); // We have to disable the rules because the original implementation is written in JavaScript and // uses mutable variables. @@ -45,9 +49,9 @@ export const parameters = Object.freeze(['scope']); export const buildHandler: ( envSet: EnvSet, queries: Queries - // eslint-disable-next-line complexity, unicorn/consistent-function-scoping -) => Parameters[1] = (_envSet, _queries) => async (ctx, next) => { - const { client } = ctx.oidc; + // eslint-disable-next-line complexity +) => Parameters[1] = (envSet, queries) => async (ctx, next) => { + const { client, params } = ctx.oidc; const { ClientCredentials, ReplayDetection } = ctx.oidc.provider; assertThat(client, new InvalidClient('client must be available')); @@ -61,8 +65,34 @@ export const buildHandler: ( const dPoP = await dpopValidate(ctx); - // eslint-disable-next-line @typescript-eslint/no-empty-function - await checkResource(ctx, async () => {}); + /* === RFC 0001 === */ + // 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)); + // TODO: Remove + if (!EnvSet.values.isDevFeaturesEnabled && organizationId) { + throw new InvalidTarget('organization tokens are not supported yet'); + } + + if ( + organizationId && + !(await queries.organizations.relations.apps.exists({ + organizationId, + applicationId: client.clientId, + })) + ) { + const error = new AccessDenied('app has not associated with the organization'); + error.statusCode = 403; + throw error; + } + /* === End RFC 0001 === */ + + // Do not check the resource if the organization ID is provided and the resource is not. In this + // case, the default resource server will be ignored, and an organization token will be issued. + if (!(organizationId && !params?.resource)) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + await checkResource(ctx, async () => {}); + } const scopes = ctx.oidc.params?.scope ? [...new Set(String(ctx.oidc.params.scope).split(' '))] @@ -84,6 +114,7 @@ export const buildHandler: ( }); const { 0: resourceServer, length } = Object.values(ctx.oidc.resourceServers ?? {}); + if (resourceServer) { if (length !== 1) { throw new InvalidTarget( @@ -96,6 +127,33 @@ export const buildHandler: ( undefined; } + // Issue organization token only if resource server is not present. + // If it's present, the flow falls into the `checkResource` and `if (resourceServer)` block above. + if (organizationId && !resourceServer) { + /* === RFC 0001 === */ + const audience = buildOrganizationUrn(organizationId); + const availableScopes = await queries.organizations.relations.appsRoles + .getApplicationScopes(organizationId, client.clientId) + .then((scope) => scope.map(({ name }) => name)); + + /** The intersection of the available scopes and the requested scopes. */ + const issuedScopes = availableScopes.filter((scope) => scopes.includes(scope)).join(' '); + + token.aud = audience; + // Note: the original implementation uses `new provider.ResourceServer` to create the resource + // server. But it's not available in the typings. The class is actually very simple and holds + // no provider-specific context. So we just create the object manually. + // See https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/resource_server.js + token.resourceServer = { + ...getSharedResourceServerData(envSet), + accessTokenTTL: reversedResourceAccessTokenTtl, + audience, + scope: availableScopes.join(' '), + }; + token.scope = issuedScopes; + /* === End RFC 0001 === */ + } + if (client.tlsClientCertificateBoundAccessTokens) { const cert = getCertificate(ctx); diff --git a/packages/core/src/oidc/grants/refresh-token.test.ts b/packages/core/src/oidc/grants/refresh-token.test.ts index 55110e6b58d..69d8e21b77f 100644 --- a/packages/core/src/oidc/grants/refresh-token.test.ts +++ b/packages/core/src/oidc/grants/refresh-token.test.ts @@ -154,7 +154,11 @@ afterAll(() => { Sinon.restore(); }); -describe('organization token grant', () => { +// The handler returns void so we cannot check the return value, and it's also not +// straightforward to assert the token is issued correctly. Here we just do the sanity +// check and basic token validation. Comprehensive token validation should be done in +// integration tests. +describe('refresh token grant', () => { it('should throw when client is not available', async () => { const ctx = createOidcContext({ ...validOidcContext, client: undefined }); await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidClient); @@ -307,10 +311,6 @@ describe('organization token grant', () => { ); }); - // The handler returns void so we cannot check the return value, and it's also not - // straightforward to assert the token is issued correctly. Here we just do the sanity - // check and basic token validation. Comprehensive token validation should be done in - // integration tests. it('should not explode when everything looks fine', async () => { const ctx = createPreparedContext(); const tenant = new MockTenant(); diff --git a/packages/core/src/oidc/grants/refresh-token.ts b/packages/core/src/oidc/grants/refresh-token.ts index 479e10da5a3..87677f4f034 100644 --- a/packages/core/src/oidc/grants/refresh-token.ts +++ b/packages/core/src/oidc/grants/refresh-token.ts @@ -216,7 +216,6 @@ export const buildHandler: ( } /* === RFC 0001 === */ - if (organizationId) { // Check membership if ( @@ -325,7 +324,7 @@ export const buildHandler: ( const scope = params.scope ? requestParamScopes : refreshToken.scopes; // 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", + // If resource is set, we 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 === */ diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 22ce03e6337..9e6a12f6c94 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -131,7 +131,9 @@ export default function initOidc( enabled: true, defaultResource: async () => { const resource = await findDefaultResource(); - return resource?.indicator ?? ''; + // The default implementation returns `undefined` - https://github.com/panva/node-oidc-provider/blob/0c52469f08b0a4a1854d90a96546a3f7aa090e5e/lib/helpers/defaults.js#L195 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return resource?.indicator ?? undefined!; }, // Disable the auto use of authorization_code granted resource feature useGrantedResource: () => false, @@ -147,13 +149,6 @@ export default function initOidc( const { client, params, session, entities } = ctx.oidc; const userId = session?.accountId ?? entities.Account?.accountId; - /** - * In consent or code exchange 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, @@ -228,7 +223,6 @@ export default function initOidc( }, }, extraParams: Object.values(ExtraParamsKey), - extraTokenClaims: async (ctx, token) => { const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource( ctx, diff --git a/packages/core/src/oidc/resource.ts b/packages/core/src/oidc/resource.ts index ab51a0a181f..d6f7bea8ea3 100644 --- a/packages/core/src/oidc/resource.ts +++ b/packages/core/src/oidc/resource.ts @@ -20,9 +20,12 @@ export const getSharedResourceServerData = ( }, }); +// TODO: Refactor me. This function is too complex. /** - * Find the scopes for a given resource indicator according to the subject in the - * context. The subject can be either a user or an application. + * Find the scopes for a given resource indicator according to the subject in the context. The + * subject can be either a user or an application. + * + * When both `userId` and `applicationId` are provided, the function will prioritize the user. * * This function also handles the reserved resources. * @@ -40,6 +43,15 @@ export const findResourceScopes = async ({ queries: Queries; libraries: Libraries; indicator: string; + /** + * In consent or code exchange flow, the `organizationId` is `undefined`, and all the scopes + * inherited from the all organization roles should be granted. + * + * In the flow of granting token for a specific organization with API resource, `organizationId` + * is provided, and only the scopes inherited from that organization should be granted. + * + * Note: This value does not affect the reserved resources and application subjects. + */ findFromOrganizations: boolean; userId?: string; applicationId?: string; @@ -68,6 +80,14 @@ export const findResourceScopes = async ({ ); } + if (applicationId && organizationId) { + return queries.organizations.relations.appsRoles.getApplicationResourceScopes( + organizationId, + applicationId, + indicator + ); + } + if (applicationId) { return findApplicationScopesForResourceIndicator(applicationId, indicator); } diff --git a/packages/core/src/queries/organization/application-role-relations.ts b/packages/core/src/queries/organization/application-role-relations.ts index f576732c1fa..47492e4f9cf 100644 --- a/packages/core/src/queries/organization/application-role-relations.ts +++ b/packages/core/src/queries/organization/application-role-relations.ts @@ -3,6 +3,13 @@ import { OrganizationRoles, Applications, OrganizationRoleApplicationRelations, + type OrganizationScope, + OrganizationRoleScopeRelations, + OrganizationScopes, + OrganizationRoleResourceScopeRelations, + Scopes, + Resources, + type Scope, } from '@logto/schemas'; import { type CommonQueryMethods, sql } from '@silverhand/slonik'; @@ -22,6 +29,53 @@ export class ApplicationRoleRelationQueries extends RelationQueries< ); } + async getApplicationScopes( + organizationId: string, + applicationId: string + ): Promise { + const { fields } = convertToIdentifiers(OrganizationRoleApplicationRelations, true); + const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true); + const scopes = convertToIdentifiers(OrganizationScopes, true); + + return this.pool.any(sql` + select distinct on (${scopes.fields.id}) + ${sql.join(Object.values(scopes.fields), sql`, `)} + from ${this.table} + join ${roleScopeRelations.table} + on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId} + join ${scopes.table} + on ${scopes.fields.id} = ${roleScopeRelations.fields.organizationScopeId} + where ${fields.organizationId} = ${organizationId} + and ${fields.applicationId} = ${applicationId} + `); + } + + async getApplicationResourceScopes( + organizationId: string, + applicationId: string, + resourceIndicator: string + ): Promise { + const { fields } = convertToIdentifiers(OrganizationRoleApplicationRelations, true); + const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true); + const resources = convertToIdentifiers(Resources, true); + const scopes = convertToIdentifiers(Scopes, true); + + return this.pool.any(sql` + select distinct on (${scopes.fields.id}) + ${sql.join(Object.values(scopes.fields), sql`, `)} + 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.organizationId} = ${organizationId} + and ${fields.applicationId} = ${applicationId} + and ${resources.fields.indicator} = ${resourceIndicator} + `); + } + /** Replace the roles of an application in an organization. */ async replace(organizationId: string, applicationId: string, roleIds: readonly string[]) { const applications = convertToIdentifiers(Applications); diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index e3993997681..5fcd0882e7c 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -1,3 +1,4 @@ +import { formUrlEncodedHeaders } from '@logto/shared'; import { appendPath } from '@silverhand/essentials'; import ky from 'ky'; @@ -31,5 +32,6 @@ export const cloudApi = ky.extend({ }); export const oidcApi = ky.extend({ + headers: formUrlEncodedHeaders, prefixUrl: appendPath(new URL(logtoUrl), 'oidc'), }); diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index d0a7037b4ff..88d75d55160 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -7,7 +7,6 @@ import { type ProtectedAppMetadata, type OrganizationWithRoles, } from '@logto/schemas'; -import { formUrlEncodedHeaders } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; import { authedAdminApi, oidcApi } from './api.js'; @@ -101,7 +100,6 @@ export const generateM2mLog = async (applicationId: string) => { // This is a token request with insufficient parameters and should fail. We make the request to generate a log for the current machine to machine app. return oidcApi.post('token', { - headers: formUrlEncodedHeaders, body: new URLSearchParams({ client_id: id, client_secret: secret, diff --git a/packages/integration-tests/src/tests/api/application/application.roles.test.ts b/packages/integration-tests/src/tests/api/application/application.roles.test.ts index 0e012e47b12..5516339e065 100644 --- a/packages/integration-tests/src/tests/api/application/application.roles.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.roles.test.ts @@ -1,5 +1,5 @@ import { ApplicationType, RoleType } from '@logto/schemas'; -import { generateStandardId, formUrlEncodedHeaders } from '@logto/shared'; +import { generateStandardId } from '@logto/shared'; import { HTTPError } from 'ky'; import { @@ -175,7 +175,6 @@ describe('admin console application management (roles)', () => { const { access_token: accessToken } = await oidcApi .post('token', { - headers: formUrlEncodedHeaders, body: new URLSearchParams({ client_id: m2mApp.id, client_secret: m2mApp.secret, diff --git a/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts b/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts new file mode 100644 index 00000000000..76765527940 --- /dev/null +++ b/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert'; + +import { buildOrganizationUrn } from '@logto/core-kit'; +import { type Application, ApplicationType, RoleType } from '@logto/schemas'; +import { appendPath } from '@silverhand/essentials'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { HTTPError } from 'ky'; + +import { oidcApi } from '#src/api/api.js'; +import { + assignRolesToApplication, + createApplication, + deleteApplication, +} from '#src/api/application.js'; +import { createResource, setDefaultResource } from '#src/api/resource.js'; +import { assignScopesToRole, createRole } from '#src/api/role.js'; +import { createScope } from '#src/api/scope.js'; +import { isDevFeaturesEnabled, logtoUrl } from '#src/constants.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { devFeatureTest, randomString } from '#src/utils.js'; + +type TokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + scope: string; +}; + +describe('client credentials grant', () => { + const jwkSet = createRemoteJWKSet(appendPath(new URL(logtoUrl), 'oidc/jwks')); + const organizationApi = new OrganizationApiTest(); + // eslint-disable-next-line @silverhand/fp/no-let + let client: Application; + + const post = async (additionalParams: Record = {}) => + oidcApi + .post('token', { + body: new URLSearchParams({ + client_id: client.id, + client_secret: client.secret, + grant_type: 'client_credentials', + ...additionalParams, + }), + }) + .json(); + + const expectError = async ( + additionalParams: Record, + status: number, + json?: Record + ) => { + const error = await post(additionalParams).catch((error: unknown) => error); + assert(error instanceof HTTPError); + expect(error.response.status).toBe(status); + + if (json) { + expect(await error.response.json()).toMatchObject(json); + } + }; + + beforeAll(async () => { + // eslint-disable-next-line @silverhand/fp/no-mutation + client = await createApplication('client credentials test', ApplicationType.MachineToMachine); + }); + + afterAll(async () => { + await Promise.all([deleteApplication(client.id), organizationApi.cleanUp()]); + }); + + describe('general failed cases', () => { + it('should fail if the client is not found', async () => { + await expectError({ client_id: 'not-found', client_secret: 'not-found' }, 400, { + error: 'invalid_client', + error_description: 'invalid client not-found', + }); + }); + }); + + describe('resource server', () => { + it('should fail if the resource server is not found', async () => { + await expectError({ resource: 'https://not-found' }, 400, { + error: 'invalid_target', + error_description: 'resource indicator is missing, or unknown', + }); + }); + + it('should be able to get a token for a resource server', async () => { + const resource = await createResource(); + const { access_token: accessToken, scope } = await post({ resource: resource.indicator }); + + expect(scope).toBe(undefined); + + const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator }); + expect(verified.payload.scope).toBe(undefined); + }); + + it('should be able to get a token for a resource server with valid scope', async () => { + const resource = await createResource(); + const scope = await createScope(resource.id, 'test-scope'); + const role = await createRole({ + name: `cc-${randomString()}`, + type: RoleType.MachineToMachine, + }); + await assignScopesToRole([scope.id], role.id); + await assignRolesToApplication(client.id, [role.id]); + + const { access_token: accessToken, scope: returnedScope } = await post({ + resource: resource.indicator, + scope: `${scope.name} ${randomString()}`, + }); + + expect(returnedScope).toBe(scope.name); + + const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator }); + expect(verified.payload.scope).toBe(scope.name); + }); + + it('should fall back to the default resource server if no `resource` parameter is provided', async () => { + const resource = await createResource(); + await setDefaultResource(resource.id); + const { access_token: accessToken, scope } = await post(); + + expect(scope).toBe(undefined); + + const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator }); + expect(verified.payload.scope).toBe(undefined); + }); + }); + + describe('organization token', () => { + it('should fail if dev feature is not enabled', async () => { + if (isDevFeaturesEnabled) { + return; + } + + await expectError({ organization_id: 'not-found' }, 400, { + error: 'invalid_target', + error_description: 'organization tokens are not supported yet', + }); + }); + }); + + devFeatureTest.describe('organization token', () => { + it('should fail if the application is not associated with the organization', async () => { + await expectError({ organization_id: 'not-found' }, 403, { + error: 'access_denied', + error_description: 'app has not associated with the organization', + }); + }); + + it('should be able to get an organization token', async () => { + const organization = await organizationApi.create({ name: 'test-organization' }); + await organizationApi.applications.add(organization.id, [client.id]); + const { access_token: accessToken, scope } = await post({ organization_id: organization.id }); + + expect(scope).toBe(''); + + const verified = await jwtVerify(accessToken, jwkSet, { + audience: buildOrganizationUrn(organization.id), + }); + expect(verified.payload.scope).toBe(''); + }); + + it('should be able to get an organization token with valid scope', async () => { + const organization = await organizationApi.create({ name: 'test-organization' }); + await organizationApi.applications.add(organization.id, [client.id]); + const scope = await organizationApi.scopeApi.create({ name: `test-scope-${randomString()}` }); + const role = await organizationApi.roleApi.create({ + name: `cc-${randomString()}`, + type: RoleType.MachineToMachine, + organizationScopeIds: [scope.id], + }); + await organizationApi.addApplicationRoles(organization.id, client.id, [role.id]); + + const { access_token: accessToken, scope: returnedScope } = await post({ + organization_id: organization.id, + scope: `${scope.name} ${randomString()}`, + }); + + expect(returnedScope).toBe(scope.name); + + const verified = await jwtVerify(accessToken, jwkSet, { + audience: buildOrganizationUrn(organization.id), + }); + expect(verified.payload.scope).toBe(scope.name); + }); + + it('should be able to get an organization token with valid scope and resource', async () => { + const organization = await organizationApi.create({ name: 'test-organization' }); + await organizationApi.applications.add(organization.id, [client.id]); + + const resource = await createResource(); + const [scope1, scope2, scope3] = await Promise.all([ + createScope(resource.id, `test-scope-${randomString()}`), + createScope(resource.id, `test-scope-${randomString()}`), + createScope(resource.id, `test-scope-${randomString()}`), + ]); + const role = await organizationApi.roleApi.create({ + name: `cc-${randomString()}`, + type: RoleType.MachineToMachine, + resourceScopeIds: [scope1.id, scope2.id], + }); + await organizationApi.addApplicationRoles(organization.id, client.id, [role.id]); + + const { access_token: accessToken, scope: returnedScope } = await post({ + organization_id: organization.id, + resource: resource.indicator, + scope: `${scope1.name} ${scope2.name} ${scope3.name} ${randomString()}`, + }); + expect(returnedScope).toBe(`${scope1.name} ${scope2.name}`); + + const verified = await jwtVerify(accessToken, jwkSet, { audience: resource.indicator }); + expect(verified.payload.scope).toBe(`${scope1.name} ${scope2.name}`); + }); + + it('should only issue requested scopes', async () => { + const organization = await organizationApi.create({ name: 'test-organization' }); + await organizationApi.applications.add(organization.id, [client.id]); + + const resource = await createResource(); + const [scope1, scope2] = await Promise.all([ + createScope(resource.id, `test-scope-${randomString()}`), + createScope(resource.id, `test-scope-${randomString()}`), + ]); + const role = await organizationApi.roleApi.create({ + name: `cc-${randomString()}`, + type: RoleType.MachineToMachine, + resourceScopeIds: [scope1.id, scope2.id], + }); + await organizationApi.addApplicationRoles(organization.id, client.id, [role.id]); + + const { access_token: accessToken1, scope: returnedScope1 } = await post({ + organization_id: organization.id, + resource: resource.indicator, + scope: `${scope1.name}`, + }); + expect(returnedScope1).toBe(scope1.name); + + const verified1 = await jwtVerify(accessToken1, jwkSet, { audience: resource.indicator }); + expect(verified1.payload.scope).toBe(scope1.name); + + const { access_token: accessToken2, scope: returnedScope2 } = await post({ + organization_id: organization.id, + resource: resource.indicator, + scope: '', + }); + expect(returnedScope2).toBe(undefined); + + const verified2 = await jwtVerify(accessToken2, jwkSet, { audience: resource.indicator }); + expect(verified2.payload.scope).toBe(undefined); + }); + }); +});