0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): actor token (#6171)

This commit is contained in:
wangsijie 2024-07-08 13:23:38 +08:00 committed by GitHub
parent 1d6254e3ca
commit 4c6fb767f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 346 additions and 23 deletions

View file

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

View file

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

View file

@ -0,0 +1,69 @@
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({
accountId: 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'));
});
});

View file

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

View file

@ -1,4 +1,5 @@
import { type SubjectToken } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { type KoaContextWithOIDC, errors } from 'oidc-provider';
import Sinon from 'sinon';
@ -6,9 +7,16 @@ 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 () => {};
@ -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,

View file

@ -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;
@ -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);
@ -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
@ -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')
);
@ -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.
@ -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();
@ -216,3 +230,4 @@ export const buildHandler: (
await next();
};
/* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */

View file

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

View file

@ -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 {
@ -224,24 +225,25 @@ export default function initOidc(
},
extraParams: Object.values(ExtraParamsKey),
extraTokenClaims: async (ctx, token) => {
const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource(
ctx,
token
);
const [tokenExchangeClaims, organizationApiResourceClaims, jwtCustomizedClaims] =
await Promise.all([
getExtraTokenClaimsForTokenExchange(ctx, token),
getExtraTokenClaimsForOrganizationApiResource(ctx, token),
getExtraTokenClaimsForJwtCustomization(ctx, token, {
envSet,
queries,
libraries,
logtoConfigs,
cloudConnection,
}),
]);
const jwtCustomizedClaims = await getExtraTokenClaimsForJwtCustomization(ctx, token, {
envSet,
queries,
libraries,
logtoConfigs,
cloudConnection,
});
if (!organizationApiResourceClaims && !jwtCustomizedClaims) {
if (!organizationApiResourceClaims && !jwtCustomizedClaims && !tokenExchangeClaims) {
return;
}
return {
...tokenExchangeClaims,
...organizationApiResourceClaims,
...jwtCustomizedClaims,
};

View file

@ -1,15 +1,33 @@
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
import { decodeAccessToken } from '@logto/js';
import { ApplicationType, GrantType, MfaFactor } from '@logto/schemas';
import {
ApplicationType,
GrantType,
InteractionEvent,
MfaFactor,
type Resource,
} from '@logto/schemas';
import { formUrlEncodedHeaders } from '@logto/shared';
import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js';
import { oidcApi } from '#src/api/api.js';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { putInteraction } from '#src/api/interaction.js';
import { createResource, deleteResource } from '#src/api/resource.js';
import { createSubjectToken } from '#src/api/subject-token.js';
import type MockClient from '#src/client/index.js';
import { initClient, processSession } from '#src/helpers/client.js';
import { createUserByAdmin } from '#src/helpers/index.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import { devFeatureTest, getAccessTokenPayload, randomString, generateName } from '#src/utils.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import {
devFeatureTest,
getAccessTokenPayload,
randomString,
generateName,
generatePassword,
generateUsername,
} from '#src/utils.js';
const { describe, it } = devFeatureTest;
@ -252,4 +270,131 @@ describe('Token Exchange', () => {
});
});
});
describe('with actor token', () => {
const username = generateUsername();
const password = generatePassword();
// Add test resource to ensure that the access token is JWT,
// make it easy to check claims.
const testApiResourceInfo: Pick<Resource, 'name' | 'indicator'> = {
name: 'test-api-resource',
indicator: 'https://foo.logto.io/api',
};
/* eslint-disable @silverhand/fp/no-let */
let testApiResourceId: string;
let testUserId: string;
let testAccessToken: string;
let client: MockClient;
/* eslint-enable @silverhand/fp/no-let */
beforeAll(async () => {
await enableAllPasswordSignInMethods();
/* eslint-disable @silverhand/fp/no-mutation */
const resource = await createResource(
testApiResourceInfo.name,
testApiResourceInfo.indicator
);
testApiResourceId = resource.id;
const { id } = await createUserByAdmin({ username, password });
testUserId = id;
client = await initClient({
resources: [testApiResourceInfo.indicator],
});
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: { username, password },
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
testAccessToken = await client.getAccessToken();
/* eslint-enable @silverhand/fp/no-mutation */
});
afterAll(async () => {
await deleteUser(testUserId);
await deleteResource(testApiResourceId);
});
it('should exchange an access token with `act` claim', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { access_token } = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
actor_token: testAccessToken,
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
resource: testApiResourceInfo.indicator,
}),
})
.json<{ access_token: string }>();
expect(getAccessTokenPayload(access_token)).toHaveProperty('act', { sub: testUserId });
});
it('should fail with invalid actor_token_type', async () => {
const { subjectToken } = await createSubjectToken(userId);
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
actor_token: testAccessToken,
actor_token_type: 'invalid_actor_token_type',
resource: testApiResourceInfo.indicator,
}),
})
).rejects.toThrow();
});
it('should fail with invalid actor_token', async () => {
const { subjectToken } = await createSubjectToken(userId);
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
actor_token: 'invalid_actor_token',
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
resource: testApiResourceInfo.indicator,
}),
})
).rejects.toThrow();
});
it('should fail when the actor token do not have `openid` scope', async () => {
const { subjectToken } = await createSubjectToken(userId);
// Set `resource` to ensure that the access token is JWT, and then it won't have `openid` scope.
const accessToken = await client.getAccessToken(testApiResourceInfo.indicator);
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
actor_token: accessToken,
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
resource: testApiResourceInfo.indicator,
}),
})
).rejects.toThrow();
});
});
});