@o3co/auth-provider-oauth-token-exchange
v0.7.0
Published
RFC 8693 Token Exchange grant for auth.provider
Readme
@o3co/auth-provider-oauth-token-exchange
RFC 8693 Token Exchange grant for auth.provider.
Supports on-behalf-of, delegation (act claim), and scope / audience narrowing.
Install
pnpm add @o3co/auth-provider-oauth-token-exchangeRegister the grant
import { createApp } from "@o3co/auth-provider-core";
import { tokenExchangeModule } from "@o3co/auth-provider-oauth-token-exchange";
const handle = await createApp({
modules: [
tokenExchangeModule,
clientRepositoryModule,
keyStoreModule,
refreshTokenStoreModule,
],
bootstrapComponents: {
config,
pathResolver,
},
});The grant type URI is urn:ietf:params:oauth:grant-type:token-exchange (IETF registered).
The built-in access_token validator is contributed by tokenExchangeModule itself. Consumers do not create or mutate a validator registry.
Disabling the module
There is no config-driven disable switch. To disable Token Exchange, do not import tokenExchangeModule.
Rationale: the RFC 8693 grant type URI (used for HTTP dispatch) differs from the HOCON-friendly config key, which makes a config-driven enabled flag structurally awkward to implement cleanly. Consumer-level opt-in via module import is both simpler and consistent with the rest of the v0.5.0 package-split philosophy (@o3co/auth-provider-oauth-federation-*).
Client configuration
Add allowedAudiences to the client record to permit audience narrowing to specific API identifiers:
clients:
billing-gateway:
clientSecret: "..."
allowedScopes: ["read", "write"]
allowedAudiences: ["billing-service", "inventory-service"]When allowedAudiences is empty or omitted, the only accepted audience parameter value is the client's own clientId. This allowlist applies to the request's audience parameter only — a GrantPolicyHook can override grantedAudience in its decision, which is not re-validated against the allowlist (see Security note 4 for the rationale). If you want hard-boundary enforcement across both paths, write your policy hook defensively.
External JWT subject_token
The package ships a built-in validator only for the access_token token type (tokens issued by this same auth.provider instance). To accept external JWTs as subject_token, implement ExchangeTokenValidator yourself and contribute it from a sibling module for urn:ietf:params:oauth:token-type:jwt:
import { createApp, defineModule } from "@o3co/auth-provider-core";
import type { ExchangeTokenValidator, ValidatedToken } from "@o3co/auth-provider-oauth-token-exchange";
import { tokenExchangeModule } from "@o3co/auth-provider-oauth-token-exchange";
class ExternalJwtValidator implements ExchangeTokenValidator {
constructor(private readonly options: { keyStore: unknown }) {}
async validate(
token: string,
ctx: { role: "subject" | "actor" },
): Promise<ValidatedToken | null> {
// Fetch jwks, verify signature, check issuer allowlist, consult remote
// introspection for revocation — all are YOUR responsibility.
// Return null on validation failure; throw on infrastructure failure (→ 503).
}
}
const externalJwtTokenExchangeValidatorModule = defineModule({
name: "external-jwt-token-exchange-validator",
requires: ["keyStore"],
contributes: {
tokenExchangeValidators: {
"urn:ietf:params:oauth:token-type:jwt": (deps) =>
new ExternalJwtValidator({ keyStore: deps.keyStore }),
},
},
});
const handle = await createApp({
modules: [
tokenExchangeModule,
externalJwtTokenExchangeValidatorModule,
clientRepositoryModule,
keyStoreModule,
],
bootstrapComponents: { config, pathResolver },
});Security notes
refreshTokenStore must be wired via a module that provides
refreshTokenStoreso the boot planner injects it intotokenExchangeModule's typed deps and the handler can surface the specificfamily_revokederrorDescription (spec §5.3). WithoutrefreshTokenStorein the module graph, self-issued access_tokens carryingfamily_idcannot be revocation-checked; the handler returnsinvalid_grant(fail-closed).tokenExchangeModuledeclares bothrefreshTokenStoreandgrantPolicyinoptional. See the "Register the grant" example above.Scope is always narrowed by the core handler.
requested scope ⊆ subject_token.scopeis enforced unconditionally — aGrantPolicyHookcannot bypass this subset check for narrowing through the request parameter. However, see point 5 below about policy-level override.Audience allowlist (client-scoped). The
audiencerequest parameter must be inclient.allowedAudiences ∪ { client.clientId }. This is enforced by the core handler before the policy hook runs. EmptyallowedAudiencesmeans only the client's ownclientIdis a valid exchange audience.Cross-client audience confusion defense. When the
audiencerequest parameter is omitted, the handler inheritssubject_token.audonly if it is inclient.allowedAudiences ∪ { client.clientId }. Otherwise it falls back toclientId. This prevents a malicious client from exchanging a stolen token outside its intended audience just by omitting the audience parameter.Policy hook widening is always rejected. The
GrantPolicyHook.evaluate()result'sgrantedScopeandgrantedAudienceare still allowed to override the request-derived values, but the handler always re-checks them against the validatedsubject_tokenboundary before minting. Scope or audience widening returnsinvalid_targetwithscope_widening_not_allowedoraudience_widening_not_allowed. There is no opt-in to bypass this check.Resource indicators must equal the issued-token audience. When the request includes RFC 8707
resource, every requested resource must equalaudienceForToken— the single value that will be minted into the issued token'saudclaim (typicallygrantedAudience[0]). Multi-resource requests whose resources cannot all be represented in the single-valuedaudare rejected withinvalid_target/requested_resources_not_in_audience. This avoids issuing a token whoseaudsilently disagrees with the requested resource (RFC 8707 §3).Impersonation vs delegation. An exchange without
actor_tokenissues an impersonation token (noactclaim). Deployments that require audit trails should add aGrantPolicyHookthat rejects requests lackingactor_token:async evaluate(req) { if (req.grantType === "urn:ietf:params:oauth:grant-type:token-exchange" && !req.actorTokenType) { return { outcome: "deny", error: "invalid_request", errorDescription: "actor_token required for delegation" }; } return { outcome: "allow" }; }may_actis enforced when present. If a subject token carries amay_actclaim, the suppliedactor_tokenmust match one of its{ sub?, iss? }constraints. Malformed or non-matchingmay_actvalues fail closed withmay_act_violation; subject tokens withoutmay_actcontinue to use the existing policy-hook boundary.Actor chains are bounded.
oauth.tokenExchange.maxActorChainDepthdefaults to3and can be overridden withOAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH. When anactor_tokenwould add to an already-full nestedactchain, the handler rejects the request withactor_chain_too_deep.Family cascade. Issued access_tokens inherit the subject's
family_idclaim. Revoking the subject's family (e.g. on logout) automatically invalidates every token exchanged from it. This is the same mechanism auth.provider's introspect and userinfo endpoints use.Refresh / ID tokens are never issued. Per RFC 8693 §4.2.2 the handler only returns an access_token. The response always carries
issued_token_type: "urn:ietf:params:oauth:token-type:access_token".Missing subject claim rejection. Self-issued access_tokens without a
subclaim (or with an empty-stringsub) are rejected withinvalid_grant. This prevents a silently-anonymous token from reaching downstream services.Validator contributions are immutable after boot. The boot planner aggregates
tokenExchangeValidatorscontributions, freezes the world during activation, and exposes only a read-only resolver to the grant handler. Post-boot mutation cannot replace the built-in validator at runtime.Confidential clients only (v0.5.0). The handler requires
client_secretand authenticates viaclientRepository.authenticate(). Requests without a secret are rejected withinvalid_client(401). The coreClienttype carriesclientSecret: stringas a required field andPublicClient = Omit<Client, "clientSecret">, sofindById()alone cannot tell a "no secret configured" client from "secret omitted by caller" — accepting the unauthenticated path would let an attacker exchange a stolensubject_tokenunder any client's allowlist. Public-client support is deferred until aClient.publicflag (or equivalent) lands in core.
Registration pattern summary
- Import
tokenExchangeModuleand any sibling modules that contribute additional validators - Boot the app with
await createApp({ modules: [...], bootstrapComponents: { config, pathResolver } }) - Call
handle.dispose()during shutdown
Unsupported RFC 8693 features (v0.5.0 scope-out)
saml1/saml2subject token types- Token type conversion (access ↔ id, access → refresh): returns
unsupported_token_type - DPoP-bound token minting (not implemented; release timing pending feature inventory)
- Built-in external JWT validator (consumer-implement; planned as a separate package post-0.5)
Breaking changes (v0.5.0)
createSelfIssuedAccessTokenValidator({ issuer })requiresissuer. Theissuerfield onCreateSelfIssuedAccessTokenValidatorOptionsis no longer optional and must be a non-empty string. Constructing the validator without it throws synchronously. Without an issuer, anyaccess_token-typed JWT signed by the same KeyStore could pass validation — a token-type confusion vector. Most consumers do not invoke this factory directly and pick upissuerautomatically fromconfig.oauth.jwt.issuerviatokenExchangeModule; only direct callers of the factory function need to update their call sites.tokenExchangeModuledeclaresconfigSchemarequiringconfig.oauth.jwt.issuer: string().min(1). Boot fails withBootError(reason: "config-validation-failed")when the issuer is missing or empty. The schema is intersected over the core schema's optional issuer viacomposeConfigSchema, so the more-restrictive module schema wins.
