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,
|
LogResult,
|
||||||
jwtCustomizer as jwtCustomizerLog,
|
jwtCustomizer as jwtCustomizerLog,
|
||||||
type CustomJwtFetcher,
|
type CustomJwtFetcher,
|
||||||
|
GrantType,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { conditional, trySafe } from '@silverhand/essentials';
|
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 Libraries from '#src/tenants/Libraries.js';
|
||||||
import type Queries from '#src/tenants/Queries.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
|
* For organization API resource feature, add extra token claim `organization_id` to the
|
||||||
* access token.
|
* access token.
|
||||||
|
@ -46,6 +49,36 @@ export const getExtraTokenClaimsForOrganizationApiResource = async (
|
||||||
return { organization_id: organizationId };
|
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 */
|
/* eslint-disable complexity */
|
||||||
export const getExtraTokenClaimsForJwtCustomization = async (
|
export const getExtraTokenClaimsForJwtCustomization = async (
|
||||||
ctx: KoaContextWithOIDC,
|
ctx: KoaContextWithOIDC,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import type Queries from '#src/tenants/Queries.js';
|
||||||
|
|
||||||
import * as clientCredentials from './client-credentials.js';
|
import * as clientCredentials from './client-credentials.js';
|
||||||
import * as refreshToken from './refresh-token.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) => {
|
export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) => {
|
||||||
const {
|
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 { type SubjectToken } from '@logto/schemas';
|
||||||
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
import { type KoaContextWithOIDC, errors } from 'oidc-provider';
|
import { type KoaContextWithOIDC, errors } from 'oidc-provider';
|
||||||
import Sinon from 'sinon';
|
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 { createOidcContext } from '#src/test-utils/oidc-provider.js';
|
||||||
import { MockTenant } from '#src/test-utils/tenant.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 { 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
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
const noop = async () => {};
|
const noop = async () => {};
|
||||||
|
@ -57,7 +65,7 @@ const validSubjectToken: SubjectToken = {
|
||||||
const validOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
|
const validOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
|
||||||
params: {
|
params: {
|
||||||
subject_token: 'some_subject_token',
|
subject_token: 'some_subject_token',
|
||||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
subject_token_type: TokenExchangeTokenType.AccessToken,
|
||||||
},
|
},
|
||||||
entities: {
|
entities: {
|
||||||
Client: validClient,
|
Client: validClient,
|
|
@ -21,7 +21,10 @@ import {
|
||||||
isThirdPartyApplication,
|
isThirdPartyApplication,
|
||||||
getSharedResourceServerData,
|
getSharedResourceServerData,
|
||||||
reversedResourceAccessTokenTtl,
|
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;
|
const { InvalidClient, InvalidGrant, AccessDenied } = errors;
|
||||||
|
|
||||||
|
@ -32,6 +35,8 @@ const { InvalidClient, InvalidGrant, AccessDenied } = errors;
|
||||||
export const parameters = Object.freeze([
|
export const parameters = Object.freeze([
|
||||||
'subject_token',
|
'subject_token',
|
||||||
'subject_token_type',
|
'subject_token_type',
|
||||||
|
'actor_token',
|
||||||
|
'actor_token_type',
|
||||||
'organization_id',
|
'organization_id',
|
||||||
'scope',
|
'scope',
|
||||||
] as const);
|
] as const);
|
||||||
|
@ -46,6 +51,7 @@ const requiredParameters = Object.freeze([
|
||||||
'subject_token_type',
|
'subject_token_type',
|
||||||
] as const) satisfies ReadonlyArray<(typeof parameters)[number]>;
|
] as const) satisfies ReadonlyArray<(typeof parameters)[number]>;
|
||||||
|
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */
|
||||||
export const buildHandler: (
|
export const buildHandler: (
|
||||||
envSet: EnvSet,
|
envSet: EnvSet,
|
||||||
queries: Queries
|
queries: Queries
|
||||||
|
@ -64,7 +70,7 @@ export const buildHandler: (
|
||||||
new InvalidClient('third-party applications are not allowed for this grant type')
|
new InvalidClient('third-party applications are not allowed for this grant type')
|
||||||
);
|
);
|
||||||
assertThat(
|
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')
|
new InvalidGrant('unsupported subject token type')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -90,8 +96,6 @@ export const buildHandler: (
|
||||||
// TODO: (LOG-9501) Implement general security checks like dPop
|
// TODO: (LOG-9501) Implement general security checks like dPop
|
||||||
ctx.oidc.entity('Account', account);
|
ctx.oidc.entity('Account', account);
|
||||||
|
|
||||||
/* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */
|
|
||||||
|
|
||||||
/* === RFC 0001 === */
|
/* === RFC 0001 === */
|
||||||
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
|
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
|
||||||
// to `Boolean` first.
|
// to `Boolean` first.
|
||||||
|
@ -197,7 +201,17 @@ export const buildHandler: (
|
||||||
.filter((name) => new Set(oidcScopes).has(name))
|
.filter((name) => new Set(oidcScopes).has(name))
|
||||||
.join(' ');
|
.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);
|
ctx.oidc.entity('AccessToken', accessToken);
|
||||||
const accessTokenString = await accessToken.save();
|
const accessTokenString = await accessToken.save();
|
||||||
|
@ -216,3 +230,4 @@ export const buildHandler: (
|
||||||
|
|
||||||
await next();
|
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 {
|
import {
|
||||||
getExtraTokenClaimsForJwtCustomization,
|
getExtraTokenClaimsForJwtCustomization,
|
||||||
getExtraTokenClaimsForOrganizationApiResource,
|
getExtraTokenClaimsForOrganizationApiResource,
|
||||||
|
getExtraTokenClaimsForTokenExchange,
|
||||||
} from './extra-token-claims.js';
|
} from './extra-token-claims.js';
|
||||||
import { registerGrants } from './grants/index.js';
|
import { registerGrants } from './grants/index.js';
|
||||||
import {
|
import {
|
||||||
|
@ -224,24 +225,25 @@ export default function initOidc(
|
||||||
},
|
},
|
||||||
extraParams: Object.values(ExtraParamsKey),
|
extraParams: Object.values(ExtraParamsKey),
|
||||||
extraTokenClaims: async (ctx, token) => {
|
extraTokenClaims: async (ctx, token) => {
|
||||||
const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource(
|
const [tokenExchangeClaims, organizationApiResourceClaims, jwtCustomizedClaims] =
|
||||||
ctx,
|
await Promise.all([
|
||||||
token
|
getExtraTokenClaimsForTokenExchange(ctx, token),
|
||||||
);
|
getExtraTokenClaimsForOrganizationApiResource(ctx, token),
|
||||||
|
getExtraTokenClaimsForJwtCustomization(ctx, token, {
|
||||||
|
envSet,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
logtoConfigs,
|
||||||
|
cloudConnection,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const jwtCustomizedClaims = await getExtraTokenClaimsForJwtCustomization(ctx, token, {
|
if (!organizationApiResourceClaims && !jwtCustomizedClaims && !tokenExchangeClaims) {
|
||||||
envSet,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
logtoConfigs,
|
|
||||||
cloudConnection,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organizationApiResourceClaims && !jwtCustomizedClaims) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...tokenExchangeClaims,
|
||||||
...organizationApiResourceClaims,
|
...organizationApiResourceClaims,
|
||||||
...jwtCustomizedClaims,
|
...jwtCustomizedClaims,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,15 +1,33 @@
|
||||||
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
|
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
|
||||||
import { decodeAccessToken } from '@logto/js';
|
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 { formUrlEncodedHeaders } from '@logto/shared';
|
||||||
|
|
||||||
import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js';
|
import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js';
|
||||||
import { oidcApi } from '#src/api/api.js';
|
import { oidcApi } from '#src/api/api.js';
|
||||||
import { createApplication, deleteApplication } from '#src/api/application.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 { 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 { createUserByAdmin } from '#src/helpers/index.js';
|
||||||
import { OrganizationApiTest } from '#src/helpers/organization.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;
|
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