Merged
Size
L
Change Breakdown
Feature60%
Refactor15%
Testing15%
Security10%
#28293feat(core): Wire TokenExchangeService.exchange() end-to-end (no-changelog)

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

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.ts at module root) — the real implementation in services/token-exchange.service.ts now 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 existing embed-auth.controller.ts.
  • Error classification in controller: AuthErrorinvalid_grant (token verification failures), BadRequestErrorinvalid_request (malformed input), ZodErrorinvalid_request (claims validation), all others → server_error (only unexpected errors reported to ErrorReporter).
  • JWT payload changes: scope is now a string (not array), resource is now a string array (space-delimited input split), and sub/act.sub now 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) on scope/audience and max(2048) on resource; added optional nbf to ExternalTokenClaimsSchema.
  • IssuedTokenResult extended with subjectUserId and actorUserId to carry internal user IDs alongside external identifiers.

How to test manually

  1. Set N8N_ENV_FEAT_TOKEN_EXCHANGE=true and configure a trusted key (RSA key pair)
  2. Generate a signed JWT with sub, iss, aud, exp, jti, and email claims using the matching private key
  3. POST /auth/oauth/token with grant_type=urn:ietf:params:oauth:token-type:access_token&subject_token=<jwt>
  4. Verify: 200 response with access_token, token_type: Bearer, expires_in, and issued_token_type
  5. Decode the issued token — sub should be an n8n user ID, iss should be n8n
  6. Check the database: user was JIT-provisioned with correct email/name, AuthIdentity linked, personal project created
  7. Replay the same token → should get 400 invalid_grant (JTI replay protection)
  8. Send an expired token → should get 400 invalid_grant
  9. Send a token nearly expired (< 5s remaining) → should get 400 invalid_grant
  10. Test with actor_token → issued token should have act.sub set 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, or Backport 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.
© 2026 · via Gitpulse