Skip to content

Commit

Permalink
feat(core): issue organization token via client credentials (#6098)
Browse files Browse the repository at this point in the history
* feat(core): issue organization token via client credentials

* refactor: fix tests
  • Loading branch information
gao-sun authored Jun 26, 2024
1 parent 75c0468 commit b590e64
Show file tree
Hide file tree
Showing 11 changed files with 624 additions and 31 deletions.
157 changes: 157 additions & 0 deletions packages/core/src/oidc/grants/client-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
80 changes: 71 additions & 9 deletions packages/core/src/oidc/grants/client-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,38 @@
* 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';
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.
/* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-non-null-assertion */
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'));
Expand All @@ -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(' '))]
Expand All @@ -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(
Expand All @@ -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);

Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/oidc/grants/refresh-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/oidc/grants/refresh-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ export const buildHandler: (
}

/* === RFC 0001 === */

if (organizationId) {
// Check membership
if (
Expand Down Expand Up @@ -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 === */
Expand Down
12 changes: 3 additions & 9 deletions packages/core/src/oidc/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -228,7 +223,6 @@ export default function initOidc(
},
},
extraParams: Object.values(ExtraParamsKey),

extraTokenClaims: async (ctx, token) => {
const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource(
ctx,
Expand Down
Loading

0 comments on commit b590e64

Please sign in to comment.