0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): issue organization token via client credentials (#6098)

* feat(core): issue organization token via client credentials

* refactor: fix tests
This commit is contained in:
Gao Sun 2024-06-26 16:18:12 +08:00 committed by GitHub
parent 75c0468abe
commit b590e64f59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 624 additions and 31 deletions

View file

@ -0,0 +1,157 @@
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<KoaContextWithOIDC['oidc']['provider']['ClientCredentials']>;
type Client = InstanceType<KoaContextWithOIDC['oidc']['provider']['Client']>;
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<KoaContextWithOIDC['oidc']> = {
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(
new MockTenant(undefined, {
organizations: {
relations: {
// @ts-expect-error
apps: {
exists: jest.fn().mockResolvedValue(true),
},
},
},
})
)(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');
});
});

View file

@ -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<Provider['registerGrantType']>[1] = (_envSet, _queries) => async (ctx, next) => {
const { client } = ctx.oidc;
// eslint-disable-next-line complexity
) => Parameters<Provider['registerGrantType']>[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,40 @@ 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 { 0: resourceServer, length } = Object.values(ctx.oidc.resourceServers ?? {});
if (!organizationId && length === 0) {
throw new InvalidTarget('both `resource` and `organization_id` are not provided');
}
const scopes = ctx.oidc.params?.scope
? [...new Set(String(ctx.oidc.params.scope).split(' '))]
@ -83,7 +119,6 @@ export const buildHandler: (
scope: scopes.join(' ') || undefined!,
});
const { 0: resourceServer, length } = Object.values(ctx.oidc.resourceServers ?? {});
if (resourceServer) {
if (length !== 1) {
throw new InvalidTarget(
@ -96,6 +131,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);

View file

@ -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();

View file

@ -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 === */

View file

@ -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,

View file

@ -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);
}

View file

@ -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,61 @@ export class ApplicationRoleRelationQueries extends RelationQueries<
);
}
/**
* Get all the organization scopes of an application in an organization. Scopes are unique by
* their IDs.
*/
async getApplicationScopes(
organizationId: string,
applicationId: string
): Promise<readonly OrganizationScope[]> {
const { fields } = convertToIdentifiers(OrganizationRoleApplicationRelations, true);
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
const scopes = convertToIdentifiers(OrganizationScopes, true);
return this.pool.any<OrganizationScope>(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}
`);
}
/**
* Get all the resource scopes of an application in an organization. Scopes are unique by their
* IDs.
*/
async getApplicationResourceScopes(
organizationId: string,
applicationId: string,
resourceIndicator: string
): Promise<readonly Scope[]> {
const { fields } = convertToIdentifiers(OrganizationRoleApplicationRelations, true);
const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true);
const resources = convertToIdentifiers(Resources, true);
const scopes = convertToIdentifiers(Scopes, true);
return this.pool.any<Scope>(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);

View file

@ -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'),
});

View file

@ -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,

View file

@ -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,

View file

@ -0,0 +1,300 @@
import assert from 'node:assert';
import { buildOrganizationUrn } from '@logto/core-kit';
import {
type Application,
ApplicationType,
RoleType,
type Resource,
type Role,
type Scope,
} 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 as createResourceApi,
deleteResource,
setDefaultResource,
} from '#src/api/resource.js';
import { assignScopesToRole, createRole as createRoleApi, deleteRole } from '#src/api/role.js';
import { createScope as createScopeApi } 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;
};
const createApi = <R, Args extends unknown[]>(
run: (...args: Args) => Promise<R>,
storage: R[]
): ((...args: Args) => Promise<R>) => {
return async (...args) => {
const result = await run(...args);
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
storage.push(result);
return result;
};
};
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 currentResources: Resource[] = [];
const currentRoles: Role[] = [];
const currentScopes: Scope[] = [];
const createResource = createApi(createResourceApi, currentResources);
const createRole = createApi(createRoleApi, currentRoles);
const createScope = createApi(createScopeApi, currentScopes);
const post = async (additionalParams: Record<string, string> = {}) =>
oidcApi
.post('token', {
body: new URLSearchParams({
client_id: client.id,
client_secret: client.secret,
grant_type: 'client_credentials',
...additionalParams,
}),
})
.json<TokenResponse>();
const expectError = async (
additionalParams: Record<string, string>,
status: number,
json?: Record<string, unknown>
) => {
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 deleteApplication(client.id);
});
afterEach(async () => {
await Promise.all([
organizationApi.cleanUp(),
// eslint-disable-next-line @typescript-eslint/no-empty-function
...currentResources.map(async ({ id }) => deleteResource(id).catch(() => {})),
// eslint-disable-next-line @typescript-eslint/no-empty-function
...currentRoles.map(async ({ id }) => deleteRole(`roles/${id}`).catch(() => {})),
]);
});
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',
});
});
it('should fail if parameters are missing', async () => {
await expectError({}, 400, {
error: 'invalid_target',
error_description: 'both `resource` and `organization_id` are not provided',
});
});
});
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);
});
});
});