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 8, 2024
1 parent a5fd7ff commit cf6e6ee
Show file tree
Hide file tree
Showing 9 changed files with 343 additions and 23 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;
};

/* 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
66 changes: 66 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,66 @@
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';
import { TokenExchangeTokenType } from './types.js';

const { InvalidGrant } = errors;

const actorId = 'some_account_id';

const validOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
params: {
actor_token: 'some_actor_token',
actor_token_type: TokenExchangeTokenType.AccessToken,
},
};

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

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

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

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

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

await expect(handleActorToken(ctx)).resolves.toStrictEqual({
actorId: 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'));
});
});
38 changes: 38 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,38 @@
import { trySafe } from '@silverhand/essentials';
import { type KoaContextWithOIDC, errors } from 'oidc-provider';

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

import { TokenExchangeTokenType } from './types.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<{ actorId?: 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 === TokenExchangeTokenType.AccessToken,
new InvalidGrant('unsupported actor token type')
);

if (!params.actor_token) {
return { actorId: 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 { actorId: actorToken.accountId };
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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';
import { TokenExchangeTokenType } from './types.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 Expand Up @@ -57,7 +65,7 @@ const validSubjectToken: SubjectToken = {
const validOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
params: {
subject_token: 'some_subject_token',
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
subject_token_type: TokenExchangeTokenType.AccessToken,
},
entities: {
Client: validClient,
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 { TokenExchangeTokenType, 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 All @@ -64,7 +70,7 @@ export const buildHandler: (
new InvalidClient('third-party applications are not allowed for this grant type')
);
assertThat(
params.subject_token_type === 'urn:ietf:params:oauth:token-type:access_token',
params.subject_token_type === TokenExchangeTokenType.AccessToken,
new InvalidGrant('unsupported subject token type')
);

Expand All @@ -90,8 +96,6 @@ export const buildHandler: (
// TODO: (LOG-9501) Implement general security checks like dPop
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 { 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;
}

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 */
13 changes: 13 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,13 @@
import { z } from 'zod';

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

export type TokenExchangeAct = z.infer<typeof tokenExchangeActGuard>;

export enum TokenExchangeTokenType {
AccessToken = 'urn:ietf:params:oauth:token-type:access_token',
}
28 changes: 15 additions & 13 deletions 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,24 +225,25 @@ export default function initOidc(
},
extraParams: Object.values(ExtraParamsKey),
extraTokenClaims: async (ctx, token) => {
const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource(
ctx,
token
);

const jwtCustomizedClaims = await getExtraTokenClaimsForJwtCustomization(ctx, token, {
envSet,
queries,
libraries,
logtoConfigs,
cloudConnection,
});
const [tokenExchangeClaims, organizationApiResourceClaims, jwtCustomizedClaims] =
await Promise.all([
getExtraTokenClaimsForTokenExchange(ctx, token),
getExtraTokenClaimsForOrganizationApiResource(ctx, token),
getExtraTokenClaimsForJwtCustomization(ctx, token, {
envSet,
queries,
libraries,
logtoConfigs,
cloudConnection,
}),
]);

if (!organizationApiResourceClaims && !jwtCustomizedClaims) {
if (!organizationApiResourceClaims && !jwtCustomizedClaims && !tokenExchangeClaims) {
return;
}

return {
...tokenExchangeClaims,
...organizationApiResourceClaims,
...jwtCustomizedClaims,
};
Expand Down
Loading

0 comments on commit cf6e6ee

Please sign in to comment.