Skip to content

Commit

Permalink
feat(core): actor token
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Jul 5, 2024
1 parent af44e87 commit b9f821a
Show file tree
Hide file tree
Showing 9 changed files with 326 additions and 10 deletions.
33 changes: 33 additions & 0 deletions packages/core/src/oidc/extra-token-claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
LogResult,
jwtCustomizer as jwtCustomizerLog,
type CustomJwtFetcher,
GrantType,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional, trySafe } from '@silverhand/essentials';
Expand All @@ -18,6 +19,8 @@ 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';

import { tokenExchangeActGuard } from './grants/token-exchange/types.js';

/**
* For organization API resource feature, add extra token claim `organization_id` to the
* access token.
Expand Down Expand Up @@ -46,6 +49,36 @@ export const getExtraTokenClaimsForOrganizationApiResource = async (
return { organization_id: organizationId };
};

/**
* The field `extra` in the access token will be overidden by the return value of `extraTokenClaims` function,
* previously in token exchange grant, this field is used to save `act` data temporarily,
* here we validate the data and return them again to prevent data loss.
*/
export const getExtraTokenClaimsForTokenExchange = async (
ctx: KoaContextWithOIDC,
token: unknown
): Promise<UnknownObject | undefined> => {
const isAccessToken = token instanceof ctx.oidc.provider.AccessToken;

// Only handle access tokens
if (!isAccessToken) {
return;
}

// Only handle token exchange grant type
if (token.gty !== GrantType.TokenExchange) {
return;
}

const result = tokenExchangeActGuard.safeParse(token.extra);

if (!result.success) {
return;
}

return result.data;
};

Check warning on line 80 in packages/core/src/oidc/extra-token-claims.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/extra-token-claims.ts#L58-L80

Added lines #L58 - L80 were not covered by tests

/* eslint-disable complexity */
export const getExtraTokenClaimsForJwtCustomization = async (
ctx: KoaContextWithOIDC,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/oidc/grants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type Queries from '#src/tenants/Queries.js';

import * as clientCredentials from './client-credentials.js';
import * as refreshToken from './refresh-token.js';
import * as tokenExchange from './token-exchange.js';
import * as tokenExchange from './token-exchange/index.js';

export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) => {
const {
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/oidc/grants/token-exchange/actor-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { errors, type KoaContextWithOIDC } from 'oidc-provider';
import Sinon from 'sinon';

import { createOidcContext } from '#src/test-utils/oidc-provider.js';

import { handleActorToken } from './actor-token.js';

const { InvalidGrant } = errors;

const accountId = 'some_account_id';

const validOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
params: {
actor_token: 'some_actor_token',
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
},
};

beforeAll(() => {
// `oidc-provider` will warn for dev interactions
Sinon.stub(console, 'warn');
});

afterAll(() => {
Sinon.restore();
});

describe('handleActorToken', () => {
it('should return accountId', async () => {
const ctx = createOidcContext(validOidcContext);
Sinon.stub(ctx.oidc.provider.AccessToken, 'find').resolves({ accountId, scope: 'openid' });

await expect(handleActorToken(ctx)).resolves.toStrictEqual({
accountId,
});
});

it('should return empty accountId when params are not present', async () => {
const ctx = createOidcContext({ params: {} });

await expect(handleActorToken(ctx)).resolves.toStrictEqual({
accountId: undefined,
});
});

it('should throw if actor_token_type is invalid', async () => {
const ctx = createOidcContext({
params: {
actor_token: 'some_actor_token',
actor_token_type: 'invalid',
},
});

await expect(handleActorToken(ctx)).rejects.toThrow(
new InvalidGrant('unsupported actor token type')
);
});

it('should throw if actor_token is invalid', async () => {
const ctx = createOidcContext(validOidcContext);
Sinon.stub(ctx.oidc.provider.AccessToken, 'find').rejects();

await expect(handleActorToken(ctx)).rejects.toThrow(new InvalidGrant('invalid actor token'));
});
});
39 changes: 39 additions & 0 deletions packages/core/src/oidc/grants/token-exchange/actor-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { trySafe } from '@silverhand/essentials';
import { type KoaContextWithOIDC, errors } from 'oidc-provider';

import assertThat from '#src/utils/assert-that.js';

const { InvalidGrant } = errors;

/**
* Handles the `actor_token` and `actor_token_type` parameters,
* if both are present and valid, the `accountId` of the actor token is returned.
*/
export const handleActorToken = async (
ctx: KoaContextWithOIDC
): Promise<{ accountId?: string }> => {
const { params, provider } = ctx.oidc;
const { AccessToken } = provider;

assertThat(params, new InvalidGrant('parameters must be available'));
assertThat(
!params.actor_token ||
params.actor_token_type === 'urn:ietf:params:oauth:token-type:access_token',
new InvalidGrant('unsupported actor token type')
);

if (!params.actor_token) {
return { accountId: undefined };
}

// The actor token should have `openid` scope (RFC 0005), and a token with this scope is an opaque token.
// We can use `AccessToken.find` to handle the token, no need to handle JWT tokens.
const actorToken = await trySafe(async () => AccessToken.find(String(params.actor_token)));
assertThat(actorToken?.accountId, new InvalidGrant('invalid actor token'));
assertThat(
actorToken.scope?.includes('openid'),
new InvalidGrant('actor token must have openid scope')
);

return { accountId: actorToken.accountId };
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { type SubjectToken } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { type KoaContextWithOIDC, errors } from 'oidc-provider';
import Sinon from 'sinon';

import { mockApplication } from '#src/__mocks__/index.js';
import { createOidcContext } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js';

import { buildHandler } from './token-exchange.js';

const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);

const { handleActorToken } = mockEsm('./actor-token.js', () => ({
handleActorToken: jest.fn().mockResolvedValue({ accountId: undefined }),
}));

const { buildHandler } = await import('./index.js');

// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = async () => {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
isThirdPartyApplication,
getSharedResourceServerData,
reversedResourceAccessTokenTtl,
} from '../resource.js';
} from '../../resource.js';

import { handleActorToken } from './actor-token.js';
import { type TokenExchangeAct } from './types.js';

const { InvalidClient, InvalidGrant, AccessDenied } = errors;

Expand All @@ -32,6 +35,8 @@ const { InvalidClient, InvalidGrant, AccessDenied } = errors;
export const parameters = Object.freeze([
'subject_token',
'subject_token_type',
'actor_token',
'actor_token_type',
'organization_id',
'scope',
] as const);
Expand All @@ -46,6 +51,7 @@ const requiredParameters = Object.freeze([
'subject_token_type',
] as const) satisfies ReadonlyArray<(typeof parameters)[number]>;

/* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */
export const buildHandler: (
envSet: EnvSet,
queries: Queries
Expand Down Expand Up @@ -90,8 +96,6 @@ export const buildHandler: (
// TODO: (LOG-9501) Implement general security checks like dPop

Check warning on line 96 in packages/core/src/oidc/grants/token-exchange/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/grants/token-exchange/index.ts#L96

[no-warning-comments] Unexpected 'todo' comment: 'TODO: (LOG-9501) Implement general...'.
ctx.oidc.entity('Account', account);

/* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */

/* === RFC 0001 === */
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
// to `Boolean` first.
Expand Down Expand Up @@ -197,7 +201,17 @@ export const buildHandler: (
.filter((name) => new Set(oidcScopes).has(name))
.join(' ');
}
/* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */

// Handle the actor token
const { accountId: actorId } = await handleActorToken(ctx);
if (actorId) {
// The JWT generator in node-oidc-provider only recognizes a fixed list of claims,
// to add other claims to JWT, the only way is to return them in `extraTokenClaims` function.
// @see https://github.com/panva/node-oidc-provider/blob/main/lib/models/formats/jwt.js#L118
// We save the `act` data in the `extra` field temporarily,
// so that we can get this context it in the `extraTokenClaims` function and add it to the JWT.
accessToken.extra = { act: { sub: actorId } } satisfies TokenExchangeAct;
}

Check warning on line 214 in packages/core/src/oidc/grants/token-exchange/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/grants/token-exchange/index.ts#L208-L214

Added lines #L208 - L214 were not covered by tests

ctx.oidc.entity('AccessToken', accessToken);
const accessTokenString = await accessToken.save();
Expand All @@ -216,3 +230,4 @@ export const buildHandler: (

await next();
};
/* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */
9 changes: 9 additions & 0 deletions packages/core/src/oidc/grants/token-exchange/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from 'zod';

export const tokenExchangeActGuard = z.object({
act: z.object({
sub: z.string(),
}),
});

export type TokenExchangeAct = z.infer<typeof tokenExchangeActGuard>;
6 changes: 5 additions & 1 deletion packages/core/src/oidc/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import defaults from './defaults.js';
import {
getExtraTokenClaimsForJwtCustomization,
getExtraTokenClaimsForOrganizationApiResource,
getExtraTokenClaimsForTokenExchange,
} from './extra-token-claims.js';
import { registerGrants } from './grants/index.js';
import {
Expand Down Expand Up @@ -224,6 +225,8 @@ export default function initOidc(
},
extraParams: Object.values(ExtraParamsKey),
extraTokenClaims: async (ctx, token) => {
const tokenExchangeClaims = await getExtraTokenClaimsForTokenExchange(ctx, token);

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

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/init.ts#L228-L229

Added lines #L228 - L229 were not covered by tests
const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource(
ctx,
token
Expand All @@ -237,11 +240,12 @@ export default function initOidc(
cloudConnection,
});

if (!organizationApiResourceClaims && !jwtCustomizedClaims) {
if (!organizationApiResourceClaims && !jwtCustomizedClaims && !tokenExchangeClaims) {

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L243 was not covered by tests
return;
}

return {
...tokenExchangeClaims,

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L248 was not covered by tests
...organizationApiResourceClaims,
...jwtCustomizedClaims,
};
Expand Down
Loading

0 comments on commit b9f821a

Please sign in to comment.