Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ interface OpenidResponse extends Response {
oidc: ResponseContext;
}

/**
* The `act` (actor) claim from an RFC 8693 delegation token exchange.
* Present on {@link TokenExchangeResponse} when `actor_token` was provided and
* the authorization server included the claim in the returned token.
*
* @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-4.1 RFC 8693 §4.1}
*/
interface ActClaim {
/** Subject identifier of the acting party. */
sub: string;
[key: string]: unknown;
}

/**
* Options for {@link RequestContext.customTokenExchange}.
* All fields are optional — unset fields fall back to `authorizationParams` config or
Expand All @@ -127,16 +140,37 @@ interface CustomTokenExchangeOptions {
* Defaults to `urn:ietf:params:oauth:token-type:access_token`.
*/
subject_token_type?: string;
/**
* The actor token for delegation / impersonation exchanges (RFC 8693).
* When provided, `actor_token_type` is required.
* The resulting token will carry an `act` claim identifying the acting party.
*/
actor_token?: string;
/**
* URI identifying the type of `actor_token` (RFC 8693).
* Required when `actor_token` is provided.
*/
actor_token_type?: string;
/**
* URI specifying the type of token the caller wants the AS to issue (RFC 8693).
* e.g. `urn:ietf:params:oauth:token-type:jwt`
*/
requested_token_type?: string;
/**
* Organization ID or name to scope the exchange to.
* When provided, the issued token will be bound to that organization.
*/
organization?: string;
/** Requested audience. Defaults to `authorizationParams.audience`. */
audience?: string;
/** Requested scope(s). Defaults to `authorizationParams.scope`. */
scope?: string;
/**
* Additional parameters forwarded to the token endpoint and can be used
* for vendor-specific or RFC 8693 extension parameters.
* Parameters in the security denylist are silently stripped.
* Additional parameters forwarded to the token endpoint.
* Useful for vendor-specific or RFC 8693 extension parameters.
* Parameters in the denylist are silently stripped.
*/
extra?: Record<string, string | number | boolean>;
extra?: Record<string, string | string[] | number | boolean>;
}

/**
Expand All @@ -163,6 +197,12 @@ interface TokenExchangeResponse {
id_token?: string;
/** Refresh token, if the AS returned one. */
refresh_token?: string;
/**
* Actor claim from an RFC 8693 delegation exchange.
* Populated when `actor_token` was provided and the AS included the claim
* in the returned id_token or JWT access_token.
*/
act?: ActClaim;
/** Vendor-specific or extension fields returned by the authorization server. */
[key: string]: unknown;
}
Expand Down
101 changes: 87 additions & 14 deletions lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const {
jwtVerify,
createRemoteJWKSet,
customFetch: joseCustomFetch,
decodeJwt,
} = require('jose');
const TokenSet = require('./tokenset');
const clone = require('clone');
Expand Down Expand Up @@ -91,31 +92,34 @@ const ACCESS_TOKEN_EXCHANGE_IDENTIFIER =
'urn:ietf:params:oauth:token-type:access_token';

/**
* OAuth parameter denylist — parameters that cannot be overridden via extras.
* Parameters that cannot be supplied via the `extra` option.
*
* Two categories:
*
* 1. SDK-controlled: params the SDK always sets internally, overriding would break
* 1. SDK-controlled — always set by the SDK; allowing overrides would break
* OAuth protocol integrity or client authentication.
* - grant_type, client_id, client_secret, client_assertion, client_assertion_type
*
* 2. First-class params: params explicitly managed by the customTokenExchange API
* - subject_token, subject_token_type, audience, scope
* 2. First-class params — have dedicated, named fields in the options object.
* Accepting them again via `extra` would create ambiguity about precedence
* and hide intent (e.g. actor_token/actor_token_type must be paired and
* validated together; organization affects tenant context and must be explicit).
*
* All other parameters — including RFC 8693 optional params (requested_token_type,
* actor_token, actor_token_type), RFC 8707 resource indicators, and IdP-specific
* params (connection, organization, login_hint) — are intentionally NOT blocked so they can be
* passed freely via the `extra` option.
* Anything not listed here (e.g. `connection`, `resource`, `login_hint`) can be
* passed freely via `extra`.
*/
const PARAM_DENYLIST = Object.freeze(
new Set([
// SDK-controlled
'grant_type',
'client_id',
'client_secret',
'client_assertion',
'client_assertion_type',
// First-class params
'subject_token',
'subject_token_type',
'actor_token',
'actor_token_type',
'requested_token_type',
'organization',
'audience',
'aud',
'scope',
Expand All @@ -135,6 +139,51 @@ function validateTokenExchangeExtras(extra) {
return result;
}

/**
* Fails fast on common subject_token mistakes before hitting the network.
*/
function validateSubjectToken(token) {
if (typeof token !== 'string' || token.trim().length === 0) {
throw createError(400, 'subject_token is required for token exchange');
}
if (token !== token.trim()) {
throw createError(
400,
'subject_token must not include leading or trailing whitespace',
);
}
if (/^bearer\s+/i.test(token)) {
throw createError(
400,
"subject_token must not include the 'Bearer ' prefix",
);
}
}

/**
* Extracts the `act` claim for delegation flows.
* Prefers the id_token claims (via the helper openid-client attaches to every
* TokenEndpointResponse), then falls back to decoding the access_token JWT
* directly for M2M flows where no id_token is returned.
* Returns undefined silently when the access token is opaque or neither
* source carries the claim.
*/
function extractActClaim(exchanged) {
const idTokenClaims = exchanged.claims(); // undefined when no id_token
if (idTokenClaims && idTokenClaims.act) {
return idTokenClaims.act;
}
if (exchanged.access_token) {
try {
const decoded = decodeJwt(exchanged.access_token);
if (decoded.act) return decoded.act;
} catch {
// opaque access token — act claim not available
}
}
return undefined;
}

function isExpired() {
return tokenSet.call(this).expired();
}
Expand Down Expand Up @@ -328,13 +377,26 @@ class RequestContext {
const {
subject_token = this.accessToken && this.accessToken.access_token,
subject_token_type = ACCESS_TOKEN_EXCHANGE_IDENTIFIER,
actor_token,
actor_token_type,
requested_token_type,
organization,
audience = defaultAudience,
scope = defaultScope,
extra,
} = options;

if (!subject_token) {
throw createError(400, 'subject_token is required for token exchange');
validateSubjectToken(subject_token);

if (organization !== undefined && !organization.trim()) {
throw createError(400, 'organization must not be blank');
}

if (actor_token !== undefined && actor_token_type === undefined) {
throw createError(
400,
'actor_token_type is required when actor_token is provided',
);
}

debug('customTokenExchange() audience=%s scope=%s', audience, scope);
Expand All @@ -344,6 +406,10 @@ class RequestContext {
const parameters = {
subject_token,
subject_token_type,
...(actor_token !== undefined && { actor_token }),
...(actor_token_type !== undefined && { actor_token_type }),
...(requested_token_type !== undefined && { requested_token_type }),
...(organization !== undefined && { organization }),
...(audience !== undefined && { audience }),
...(scope !== undefined && { scope }),
...validateTokenExchangeExtras(extra),
Expand All @@ -355,7 +421,14 @@ class RequestContext {
TOKEN_EXCHANGE_GRANT_TYPE,
parameters,
);
return Object.assign({}, exchanged);
const result = Object.assign({}, exchanged);
if (actor_token !== undefined) {
const act = extractActClaim(exchanged);
if (act !== undefined) {
result.act = act;
}
}
return result;
} catch (error) {
debug(
'customTokenExchange() failed: %s - %s',
Expand Down
113 changes: 101 additions & 12 deletions test/customTokenExchange.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,36 @@ describe('customTokenExchange', () => {
);
});

it('throws 400 when subject_token has leading or trailing whitespace', async () => {
const { response } = await setup({
exchangeOptions: { subject_token: ' token_with_spaces ' },
});
assert.equal(response.statusCode, 400);
assert.equal(
response.body.err.message,
'subject_token must not include leading or trailing whitespace',
);
});

it("throws 400 when subject_token includes a 'Bearer ' prefix", async () => {
const { response } = await setup({
exchangeOptions: { subject_token: 'Bearer __test_token__' },
});
assert.equal(response.statusCode, 400);
assert.equal(
response.body.err.message,
"subject_token must not include the 'Bearer ' prefix",
);
});

it('throws 400 when organization is blank', async () => {
const { response } = await setup({
exchangeOptions: { organization: ' ' },
});
assert.equal(response.statusCode, 400);
assert.equal(response.body.err.message, 'organization must not be blank');
});

it('applies authorizationParams defaults for audience and scope', async () => {
const { capturedBody } = await setup({
authConfig: {
Expand All @@ -122,9 +152,9 @@ describe('customTokenExchange', () => {
assert.equal(capturedBody.scope, 'openid read:data');
});

it('sends organization when explicitly provided via extra', async () => {
it('sends organization when provided as first-class option', async () => {
const { capturedBody } = await setup({
exchangeOptions: { extra: { organization: 'org_abc123' } },
exchangeOptions: { organization: 'org_abc123' },
});
assert.equal(capturedBody.organization, 'org_abc123');
});
Expand Down Expand Up @@ -181,29 +211,88 @@ describe('customTokenExchange', () => {
assert.notEqual(capturedBody.scope, 'bad');
});

it('allows RFC 8693 optional params and IdP-specific params via extra', async () => {
it('allows IdP-specific params (e.g. connection) via extra', async () => {
const { capturedBody } = await setup({
exchangeOptions: {
extra: {
connection: 'google-oauth2',
requested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
actor_token: '__test_actor_token__',
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
},
extra: { connection: 'google-oauth2' },
},
});
assert.equal(capturedBody.connection, 'google-oauth2');
});

it('sends actor_token and actor_token_type as first-class options', async () => {
const { capturedBody } = await setup({
exchangeOptions: {
actor_token: '__test_actor_token__',
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
},
});
assert.equal(capturedBody.actor_token, '__test_actor_token__');
assert.equal(
capturedBody.actor_token_type,
'urn:ietf:params:oauth:token-type:access_token',
);
});

it('sends requested_token_type as first-class option', async () => {
const { capturedBody } = await setup({
exchangeOptions: {
requested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
},
});
assert.equal(
capturedBody.requested_token_type,
'urn:ietf:params:oauth:token-type:jwt',
);
assert.equal(capturedBody.actor_token, '__test_actor_token__');
});

it('throws 400 when actor_token is provided without actor_token_type', async () => {
const { response } = await setup({
exchangeOptions: { actor_token: '__test_actor_token__' },
});
assert.equal(response.statusCode, 400);
assert.equal(
capturedBody.actor_token_type,
'urn:ietf:params:oauth:token-type:access_token',
response.body.err.message,
'actor_token_type is required when actor_token is provided',
);
});

it('extracts act claim from JWT access_token when actor_token is provided', async () => {
const actClaim = { sub: '__test_actor_sub__' };
const accessTokenWithAct = makeIdToken({ act: actClaim });
const { response } = await setup({
exchangeOptions: {
actor_token: '__test_actor_token__',
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
},
mockTokenResponse: {
status: 200,
body: {
access_token: accessTokenWithAct,
token_type: 'Bearer',
},
},
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body.act, actClaim);
});

it('does not extract act claim when actor_token is not provided', async () => {
const actClaim = { sub: '__test_actor_sub__' };
const accessTokenWithAct = makeIdToken({ act: actClaim });
const { response } = await setup({
mockTokenResponse: {
status: 200,
body: {
access_token: accessTokenWithAct,
token_type: 'Bearer',
},
},
});
assert.equal(response.statusCode, 200);
assert.isUndefined(response.body.act);
});

it('returns the TokenSet from client.grant()', async () => {
const { response } = await setup();
assert.equal(response.statusCode, 200);
Expand Down
Loading