Token exchange wired end-to-end for OAuth 2.0 delegation

External systems can now exchange trusted JWTs for n8n access tokens with JIT user provisioning, replay protection, and scoped token issuance.
The OAuth 2.0 Token Exchange implementation is now complete. External systems can POST to /auth/oauth/token with a signed JWT and receive an n8n access token in return — the entire flow from token verification to user provisioning to token issuance works end-to-end.
When a request arrives, n8n verifies the external JWT against configured trusted keys, JIT-provisions the user if they don't exist (creating their AuthIdentity and personal project), enforces JTI replay protection to prevent token reuse, and issues a scoped n8n access token with the appropriate subject and actor claims. Actor delegation is also supported — if an actor_token is provided, the issued token includes an act.sub claim identifying the delegating user.
The controller now maps errors to the correct RFC 8693 codes: invalid_grant for token verification failures, invalid_request for malformed input or validation errors, and server_error for unexpected issues. Schema validation has been hardened with maximum length constraints to prevent oversized claims.
This work completes Phase 3 of the OAuth 2.0 Token Exchange initiative in the n8n CLI package.
View Original GitHub Description
Summary
What: Connects the delegation-specific logic (token verification, scope check, JWT issuance) with the shared services (TrustedKeyService, JtiStoreService, IdentityResolutionService) to make TokenExchangeService.exchange() work end-to-end. The controller is wired to the real service, replacing the stub, and now handles RFC 8693 error codes properly (invalid_grant, invalid_request, server_error).
Why: This is Phase 3 (Integration) of the OAuth 2.0 Token Exchange initiative. Phase 2a built shared services and Phase 2c built delegation-specific logic — this ticket wires them together so POST /auth/oauth/token performs real token exchange: verifying external JWTs against trusted keys, JIT-provisioning users via IdentityResolutionService, enforcing JTI replay protection, and issuing scoped n8n access tokens.
Key implementation decisions:
- Deleted the old stub
TokenExchangeService(token-exchange.service.tsat module root) — the real implementation inservices/token-exchange.service.tsnow handles everything. The stub's unit tests were also removed since the integration tests provide stronger coverage. - Controller moved to
controllers/subdirectory for consistency with the existingembed-auth.controller.ts. - Error classification in controller:
AuthError→invalid_grant(token verification failures),BadRequestError→invalid_request(malformed input),ZodError→invalid_request(claims validation), all others →server_error(only unexpected errors reported to ErrorReporter). - JWT payload changes:
scopeis now a string (not array),resourceis now a string array (space-delimited input split), andsub/act.subnow use internal n8n user IDs (not external subject identifiers). - Minimum remaining lifetime check (5s) prevents issuing tokens that would expire almost immediately.
- Schema hardening: Added
max(1024)onscope/audienceandmax(2048)onresource; added optionalnbftoExternalTokenClaimsSchema. IssuedTokenResultextended withsubjectUserIdandactorUserIdto carry internal user IDs alongside external identifiers.
How to test manually
- Set
N8N_ENV_FEAT_TOKEN_EXCHANGE=trueand configure a trusted key (RSA key pair) - Generate a signed JWT with
sub,iss,aud,exp,jti, andemailclaims using the matching private key POST /auth/oauth/tokenwithgrant_type=urn:ietf:params:oauth:token-type:access_token&subject_token=<jwt>- Verify: 200 response with
access_token,token_type: Bearer,expires_in, andissued_token_type - Decode the issued token —
subshould be an n8n user ID,issshould ben8n - Check the database: user was JIT-provisioned with correct email/name,
AuthIdentitylinked, personal project created - Replay the same token → should get 400
invalid_grant(JTI replay protection) - Send an expired token → should get 400
invalid_grant - Send a token nearly expired (< 5s remaining) → should get 400
invalid_grant - Test with
actor_token→ issued token should haveact.subset to actor's n8n user ID
See https://n8nio.slack.com/archives/C0ACFN1G7NK/p1775812851354889 for test scripts to help set this up.
Related tickets
Review checklist
- PR title and summary are descriptive.
- Docs updated or follow-up ticket created.
- Tests included.
- Unit tests updated for new constructor dependencies and error handling
- Integration tests cover: happy path with JIT provisioning, subject+actor delegation, scope/resource passthrough, expiry enforcement, JTI replay, validation errors, near-expiry rejection, schema limits, and disabled feature
- PR Labeled with
Backport to Beta,Backport to Stable, orBackport to v1(if the PR is an urgent fix that needs to be backported) - I have seen this code, I have run this code, and I take responsibility for this code.