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:
parent
1d6254e3ca
commit
4c6fb767f0
9 changed files with 346 additions and 23 deletions
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
38
packages/core/src/oidc/grants/token-exchange/actor-token.ts
Normal file
38
packages/core/src/oidc/grants/token-exchange/actor-token.ts
Normal 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 };
|
||||
};
|
|
@ -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,
|
|
@ -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 */
|
13
packages/core/src/oidc/grants/token-exchange/types.ts
Normal file
13
packages/core/src/oidc/grants/token-exchange/types.ts
Normal 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',
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue