Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@

const dPoP = await dpopValidate(ctx);

// eslint-disable-next-line @typescript-eslint/no-empty-function
await checkResource(ctx, async () => {});
/* === RFC 0001 === */
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
// 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

Check warning on line 72 in packages/core/src/oidc/grants/client-credentials.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/grants/client-credentials.ts#L72

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Remove'.
if (!EnvSet.values.isDevFeaturesEnabled && organizationId) {
throw new InvalidTarget('organization tokens are not supported yet');
}

Check warning on line 75 in packages/core/src/oidc/grants/client-credentials.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/grants/client-credentials.ts#L74-L75

Added lines #L74 - L75 were not covered by tests

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 () => {});
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
}

Check warning on line 95 in packages/core/src/oidc/grants/client-credentials.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/grants/client-credentials.ts#L93-L95

Added lines #L93 - L95 were not covered by tests

const { 0: resourceServer, length } = Object.values(ctx.oidc.resourceServers ?? {});
gao-sun marked this conversation as resolved.
Show resolved Hide resolved

if (!organizationId && length === 0) {
throw new InvalidTarget('both `resource` and `organization_id` are not provided');
}

Check warning on line 101 in packages/core/src/oidc/grants/client-credentials.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/grants/client-credentials.ts#L100-L101

Added lines #L100 - L101 were not covered by tests

const scopes = ctx.oidc.params?.scope
? [...new Set(String(ctx.oidc.params.scope).split(' '))]
Expand All @@ -83,7 +119,6 @@
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 @@
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 @@ -56,7 +56,7 @@
// Temporarily removed 'EdDSA' since it's not supported by browser yet
const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const);

export default function initOidc(

Check warning on line 59 in packages/core/src/oidc/init.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/init.ts#L59

[max-params] Function 'initOidc' has too many parameters (5). Maximum allowed is 4.
envSet: EnvSet,
queries: Queries,
libraries: Libraries,
Expand Down Expand Up @@ -131,7 +131,9 @@
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!;

Check warning on line 136 in packages/core/src/oidc/init.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/init.ts#L134-L136

Added lines #L134 - L136 were not covered by tests
},
// Disable the auto use of authorization_code granted resource feature
useGrantedResource: () => false,
Expand All @@ -147,13 +149,6 @@
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 @@
},
},
extraParams: Object.values(ExtraParamsKey),

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