OAuth 2.0 Integration Design for ContextForge¶
Version: 1.2 Status: Design + implementation notes Date: February 2026 Related: OAuth 2.0 Authorization Code Flow UI Implementation Design
Executive Summary¶
This document describes the design for the Admin UI initiated OAuth 2.0 Authorization Code flow for MCP gateways and how the backend stores and uses user-delegated tokens.
Scope of This Document
This document covers gateway OAuth token delegation - how ContextForge obtains and uses OAuth tokens to authenticate with upstream MCP servers on behalf of users.
For information about user authentication to ContextForge (SSO, JWT tokens, RBAC), see:
- RBAC Configuration - Token scoping, permissions, and access control
- Multi-Tenancy Architecture - User authentication flows and team management
- RFC 9728 Compliance - OAuth Protected Resource Metadata for MCP client discovery
Current Implementation Snapshot¶
Implemented Capabilities¶
- Admin UI exposes OAuth configuration fields for gateways and an "Authorize" action.
- Authorization Code flow uses PKCE (S256) and an HMAC-signed state value with a 300-second TTL.
- OAuth state is stored in Redis when configured, in the database when configured, and in memory otherwise.
- Tokens are stored per gateway and app user (email) in the database, encrypted with a dedicated encryption secret.
- Refresh tokens are used when access tokens are near expiry; invalid refresh tokens are cleared.
- Dynamic Client Registration (DCR) auto-registration can run during authorization when an issuer is set but a client ID is missing.
Known Gaps and Constraints¶
- Some UI options (like storing tokens and auto-refresh toggles) are not yet persisted or enforced by the backend.
- PKCE method is currently fixed to S256.
- No admin UI exists to list or revoke stored OAuth tokens per user.
- Token cleanup is currently a helper method only; there is no automated scheduler invoking it.
Architecture Overview¶
The system involves interactions between the Admin UI, the Backend services (OAuth Router, OAuth Manager, Token Storage Service), a State Store (Redis/Database/Memory), the Database, and External entities (User Browser, OAuth Provider).
The "Authorize" action in the UI redirects the user through the gateway's authorization endpoint. The OAuth Manager handles the PKCE generation and state management.
Data Model¶
Gateway OAuth Configuration¶
Stored as JSON within the gateway record and assembled from Admin UI fields or API payloads. It includes:
- Grant Type: Authorization code, client credentials, or password.
- Issuer: OAuth Authorization Server issuer URL (required for DCR).
- Endpoints: Authorization URL and Token URL.
- Redirect URI: Must match the OAuth client registration.
- Client Credentials: Client ID and encrypted Client Secret.
- User Credentials: Username and password (for password grant only).
- Scopes: Array of requested scopes.
- Resource: Optional resource parameter; derived from the gateway URL if omitted.
OAuth Tokens¶
One token record is stored per gateway and app user (email). It contains:
- Identifiers: Gateway ID and App User Email (unique pair), plus the User ID from the OAuth provider.
- Tokens: Encrypted Access Token and Refresh Token.
- Metadata: Token type, expiration time, granted scopes, and creation/update timestamps.
OAuth States¶
Used for state storage when a database backend is configured. It tracks:
- Identifiers: Gateway ID and State (unique pair).
- PKCE: Code verifier.
- Lifecycle: Expiration time, used status, and creation timestamp. TTL is enforced in logic (300 seconds).
Registered OAuth Clients¶
Stored when Dynamic Client Registration succeeds. It includes:
- Configuration: Issuer, Client ID, encrypted Client Secret.
- Metadata: Redirect URIs, Grant Types, Response Types, Scopes, Token Endpoint Auth Method, and Registration Client URI.
- Lifecycle: Creation time, expiration time, and active status.
UI and Flow¶
Admin UI Touchpoints¶
The gateway configuration form maps user inputs to the OAuth configuration structure. The gateway list provides an Authorize button for OAuth gateways, which initiates the flow.
Authorization Code Flow¶
- Configuration: Admin configures gateway OAuth settings via the UI, which are saved to the database.
- Initiation: Admin clicks "Authorize". The UI requests authorization from the gateway.
- Setup: The Gateway initiates the auth code flow via the OAuth Manager, storing state and PKCE verifier in the State Store.
- Redirection: The Gateway redirects the Admin to the OAuth Provider.
- Consent: Admin logs in and grants consent at the Provider.
- Callback: Provider redirects back to the Gateway with a code and state.
- Exchange: Gateway validates state via OAuth Manager and exchanges the code for tokens.
- Storage: OAuth Manager stores access and refresh tokens via Token Store into the Database.
- Completion: Gateway shows a success page to the Admin.
Audience handling and RFC 8707¶
The gateway implements RFC 8707 (Resource Indicators for OAuth 2.0) to ensure that access tokens are correctly scoped for the target MCP server.
The resource parameter¶
RFC 8707 introduces the resource parameter, which allows the client to specify the target resource (the MCP server) during the authorization request. This ensures the Identity Provider (IdP) mints a token with the correct aud (audience) claim.
Non-compliant IdPs¶
Some IdPs (ServiceNow, Authentik, Salesforce, Azure AD in multi-tenant configurations) do not strictly honor RFC 8707. They may map the requested resource to a different aud claim (typically the client_id or another abstract identifier). RFC 8707 §2 explicitly permits this mapping.
Three-stage audience handling (per-user)¶
The gateway resolves the expected audience via a three-level precedence, with per-user isolation on stage 2 to prevent cross-tenant DoS and RBAC bypass:
- Explicit admin configuration — the admin sets
oauth_config.resourceexplicitly via the Admin UI or API. Authoritative and blocking. Global to the gateway. - Per-user auto-learning — on each user's OAuth callback, the gateway decodes the access token's
audandissclaims (best-effort, unverified) and persists them on the user's ownOAuthTokenrow (columnslearned_aud,learned_iss). Subsequent validation for THAT USER checks their token against their own learned value. Authoritative and blocking for that user only. Not shared across users. - Auto-derived origin fallback — if no resource is configured and no learned value exists for this user (e.g. first authentication), the gateway derives the origin (
scheme://netloc, not the full path) from the gateway's URL. Real-world providers (Salesforce, Azure AD, Okta) typically issue origin-level audiences.
Why per-user (and not per-gateway)?¶
The earlier design persisted learned audience on shared gateway.oauth_config.resource, which created two problems:
- Cross-tenant DoS: a single gateway serving multiple IdP tenants (each returning different
audvalues) would have itsresourcepinned by whichever user authenticated first; other tenants' tokens then failed validation until an admin manually cleared the field. - RBAC bypass: the OAuth callback path only enforces gateway access (visibility/team membership), not
gateways.update. Allowing every authenticated callback to mutate shared config let users without the update permission alter global state on behalf of all users.
Per-user storage eliminates both: each user's callback only writes to their own row (permission-scoped by construction), and each user's validation only reads their own learned value (no cross-tenant interference).
Authoritative vs. advisory validation¶
The gateway performs local validation of the token's audience before forwarding it to the upstream MCP server:
- Authoritative (blocking) — mismatch raises
GatewayConnectionErrorand the token is not forwarded. This is the case when either (a)oauth_config.resourceis admin-configured, or (b) the user has alearned_audfrom a prior successful callback. - Advisory (non-blocking, default) — mismatch is logged as a warning and the token is still forwarded so the upstream MCP server can act as the authority. Only applies when the user has neither of the above and the validator falls back to the gateway URL origin. Set
OAUTH_REQUIRE_CONFIGURED_RESOURCE=trueto make this case authoritative too.
Implementation references¶
- Origin extraction (outbound
resourceparameter fallback):_derive_resource_origininmcpgateway/routers/oauth_router.py. - Single-decode claim extraction (aud + iss):
_extract_aud_and_issinmcpgateway/services/oauth_manager.py. - Per-user learned-audience storage:
OAuthToken.learned_aud/learned_issinmcpgateway/db.py; write viaTokenStorageService.store_tokens(called fromOAuthManager.complete_authorization_code_flow); read viaTokenStorageService.get_user_learned_audience. - Precedence + advisory / authoritative split:
_validate_audienceinmcpgateway/services/token_validation_service.py. - Trust model for the unverified JWT decode used at learning time:
_decode_token_claims_unverifiedinmcpgateway/services/oauth_manager.py.
Security: unverified JWT decode trust boundary
Audience extraction during auto-learning uses _decode_token_claims_unverified, which decodes the JWT without cryptographic verification. The extracted audience is used only for routing and learning — it is not a security gate. The immediate trust boundary is the TLS connection to the admin-configured token endpoint in response to a callback the gateway itself initiated. Authorization-relevant validation remains the upstream MCP server's responsibility (or, when configured, the gateway's own authoritative audience check).
Tool Invocation using Stored Tokens¶
- Invocation: A Client (authenticated user) invokes a tool on the Gateway.
- Retrieval: Gateway requests a token from the Token Store for the gateway and user.
- Validation: Token Store checks expiration.
- Valid: Decrypted access token is returned.
- Expired: Token Store requests a refresh from the Provider. New tokens are stored and returned.
- Execution: Gateway forwards the tool request with the Bearer token to the MCP Server.
- Response: MCP Server responds, and the Gateway returns the result to the Client.
Security and Operational Notes¶
- Encryption: Tokens are encrypted at rest using a configured encryption secret.
- State Security: State is an opaque random token (
secrets.token_urlsafe), stored server-side with associated metadata, single-use, and has a short expiration (300 seconds). - Scoping: Tokens are scoped per gateway and app user (email) to prevent cross-user reuse.
- Resource Indicator: The gateway derives a resource value from the gateway URL if not explicitly configured.
- Transport: HTTPS is recommended in production.
Token Exchange (RFC 8693 / On-Behalf-Of)¶
In addition to the Authorization Code, Client Credentials, and Password grants described above, a gateway's oauth_config can use grant_type: "token-exchange" to implement RFC 8693 OAuth 2.0 Token Exchange — also known as an On-Behalf-Of (OBO) flow.
When to Use It¶
Use token exchange when a downstream MCP server needs to act as the calling user, not as a shared service identity. Unlike Client Credentials (which authenticates the gateway itself) or a stored per-user OAuth token (which requires the user to complete an interactive authorization flow against the upstream provider), token exchange lets the gateway present the user's already-authenticated ContextForge identity to an Authorization Server and receive back a token scoped for the downstream audience — without any extra user interaction.
Typical use case: the user authenticates to ContextForge once (JWT/SSO), and every downstream MCP server they reach through federated tools receives a token that identifies them, enabling per-user authorization and audit trails at the upstream service.
Configuration Keys¶
| Key | Required | Default | Description |
|---|---|---|---|
grant_type | yes | — | Must be "token-exchange" to enable this flow. |
token_url | yes | — | The Authorization Server's token endpoint. Validated at config time (SSRF guard — see below). |
target_audience | yes | — | The audience parameter sent to the AS, identifying the downstream resource the exchanged token is for. |
subject_token_source | no | inbound_user_jwt | Where the subject_token for the exchange comes from. See below. |
subject_token_type | no | urn:ietf:params:oauth:token-type:jwt | The subject_token_type parameter sent to the AS, describing the type of subject_token (RFC 8693 §3). |
requested_token_type | no | urn:ietf:params:oauth:token-type:access_token | The requested_token_type parameter sent to the AS. |
client_id / client_secret | yes | — | Client credentials used to authenticate the exchange request itself. client_secret is stored encrypted. |
scopes | no | — | Optional scopes requested for the exchanged token. |
Subject Token Sources¶
inbound_user_jwt(default): The ContextForge JWT presented by the calling user on the current request is used as thesubject_tokenin the exchange. This requires the inbound request to carry a verifiable JWT (not an opaque API key).user_oauth_token: The user's previously stored per-gateway OAuth access token (obtained via the Authorization Code flow described above) is used as thesubject_tokeninstead. This is supported on the tool-invocation path; gateway connection/health-check paths fail closed fortoken-exchangebecause they have no per-request user context.
subject_token_type¶
Per RFC 8693 §3, subject_token_type tells the Authorization Server how to interpret subject_token:
urn:ietf:params:oauth:token-type:jwt(default): thesubject_tokenis a generic JWT — ContextForge's own inbound JWT, not a token previously issued by this AS. This is correct for the defaultsubject_token_source: inbound_user_jwt.urn:ietf:params:oauth:token-type:access_token: signals that thesubject_tokenis an access token the AS itself previously issued and can recognize as one of its own. Set this only if the configuredsubject_token_sourceactually returns an AS-issued access token (e.g.user_oauth_tokenagainst the same AS).
Some Authorization Servers (e.g. Keycloak) enforce this distinction and reject the exchange if subject_token_type doesn't match the actual token shape.
Response token_type Validation¶
RFC 8693 §2.2.1 requires the AS to return a token_type field. OAuthManager.token_exchange() validates this field (case-insensitively) and only accepts Bearer — if the AS returns anything else (e.g. N_A for a non-access-token issued_token_type), the exchange fails with an OAuthError rather than silently forwarding a token under the wrong scheme. If token_type is absent from the response, it defaults to Bearer for compatibility with ASes that omit this REQUIRED field.
Security Boundary: The Inbound JWT Is Never Forwarded¶
The user's ContextForge JWT never reaches the upstream MCP server
With subject_token_source: inbound_user_jwt, the user's inbound ContextForge JWT is POSTed to token_url as the subject_token. Therefore token_url MUST be a trusted Identity Provider — it is validated at config time (SSRF guard), but operators must treat the ability to create or modify token-exchange gateways as a privileged action.
Only the exchanged token returned by the Authorization Server is ever sent to the downstream MCP server as the Bearer credential. The gateway's own JWT is used solely as the subject of the exchange request to the trusted IdP and is discarded afterward.
Caching¶
Exchanged tokens are cached (TokenExchangeCache, Redis-backed with an in-memory fallback) under a key derived from the gateway, user, and target_audience. Cache behavior:
- TTL: Taken from the Authorization Server's
expires_inresponse field. - Single-flight: Concurrent requests for the same cache key share one in-flight exchange instead of issuing duplicate calls to the AS.
- Negative caching: A failed exchange is cached briefly so repeated failures don't hammer the AS; callers see a "token exchange unavailable" degraded-mode error until the cooldown expires.
- 401 invalidation: For REST-integration tool calls, if the downstream server rejects the exchanged token with
401, the cache entry is evicted and exactly one re-exchange is attempted before failing. (MCP-protocol tool calls over SSE/streamable HTTP do not expose a retryable HTTP status and are out of scope for this retry.)
Shared-Issuer Trust Requirement¶
The Authorization Server at token_url must trust the ContextForge JWT issuer — i.e., it must be configured (directly, or via federated SSO) to accept ContextForge-issued JWTs as valid subject_token values for RFC 8693 exchange. Without this trust relationship, every exchange will fail with a 4xx from the AS. See Identity Propagation for migration guidance and the shared-issuer setup.
Example Configuration¶
The following oauth_config exchanges the caller's inbound ContextForge JWT (inbound_user_jwt, the default subject_token_source):
{
"name": "downstream-mcp",
"url": "https://downstream.example.com/mcp",
"auth_type": "oauth",
"oauth_config": {
"grant_type": "token-exchange",
"token_url": "https://idp.example.com/realms/cf/protocol/openid-connect/token",
"client_id": "contextforge",
"client_secret": "<encrypted-at-rest>",
"target_audience": "https://downstream.example.com",
"subject_token_source": "inbound_user_jwt",
"scopes": ["mcp.invoke"]
}
}
client_secret is stored encrypted at rest (same mechanism as other gateway OAuth credentials). target_audience is required — gateway creation/update fails validation without it. If subject_token_source is omitted, it defaults to inbound_user_jwt.
Troubleshooting¶
| Symptom (caller) | Likely cause | Where to look |
|---|---|---|
| "User authentication required…" | No inbound bearer, or an opaque (non-JWT) bearer was presented with subject_token_source: inbound_user_jwt | Confirm the request carried a verifiable JWT; check client auth configuration |
| "Token exchange failed… Contact your administrator." | The Authorization Server returned a 4xx/5xx for the exchange request | Server WARNING log (with stack trace) and audit entry with error status, searchable by correlation_id |
| "Token exchange unavailable…" | Negative cache is open after a recent failure (degraded mode) | Wait for the cooldown to expire; investigate the original failure that triggered the negative cache entry |
ValueError: target_audience is required / token_url rejected at config time | Invalid or incomplete oauth_config for a token-exchange gateway | Review the gateway create/update response; check for an SSRF-validation WARNING in logs |
OAuthError: Unsupported token_type '...' | The Authorization Server returned a token_type other than Bearer (e.g. N_A) for the exchanged token | Check requested_token_type/subject_token_type against what the AS expects; the exchanged token cannot be forwarded as a Bearer credential |
Token Verification¶
Gateway OAuth Tokens¶
OAuth tokens obtained through the Authorization Code flow are used to authenticate requests to upstream MCP servers. These tokens are:
- Stored encrypted: Using
AUTH_ENCRYPTION_SECRET - Scoped per user: Each user's token is stored separately per gateway
- Automatically refreshed: When access tokens expire and refresh tokens are available
- Forwarded with pre-validation: Before forwarding, the gateway validates the token's
aud,scope/scp, andissclaims againstoauth_config(see Audience handling and RFC 8707 above). Authoritative mismatches block forwarding withGatewayConnectionError; advisory mismatches (auto-derived audience only) are logged and the token is still forwarded so the upstream MCP server remains the final authority.
Relationship to Gateway Authentication¶
This OAuth flow is separate from user authentication to ContextForge itself:
| Aspect | Gateway OAuth (this doc) | User Auth to Gateway |
|---|---|---|
| Purpose | Authenticate to upstream MCP servers | Authenticate users to the gateway |
| Token storage | oauth_tokens table | JWT in client, session in browser |
| Verification | By upstream MCP server | By gateway (verify_jwt_token_cached) |
| Scope | Per gateway + user pair | Gateway-wide |
For user authentication details, see RBAC Configuration.
Inbound External-Token Validation (M2M API Auth)¶
The flows above describe ContextForge as an OAuth client delegating to upstream MCP servers. ContextForge can also act as a resource server for its own API/MCP endpoints, accepting access tokens minted by a trusted external SSO provider directly as Bearer credentials — see SSO: Machine-to-machine API auth with external IdP tokens for operator-facing setup.
This path is gated by SSO_API_TOKEN_AUTH_ENABLED (global) and SSOProvider.trusted_for_api_auth + SSOProvider.api_audience (per provider), and is dispatched from mcpgateway/utils/verify_credentials.py:
- Issuer discrimination (
_maybe_verify_external): the inbound bearer token is unsigned-decoded to read itsissclaim. If no enabled provider hastrusted_for_api_auth=True, this path is skipped entirely and the token is evaluated only as an internal JWT. Otherwiseresolve_trusted_provider_by_issuer(iss, db)looks up the matchingSSOProvider. - Token validation (
verify_external_idp_token): the token is fully verified against the matched provider's JWKS — signature, expiry, issuer, andaud == provider.api_audience. ID tokens are rejected; only access tokens are accepted. - JIT provisioning (
build_external_identity): the validated token is used to provision/look up a localEmailUservia the same SSO service used for browser logins.client_credentialstokens with noemailclaim are detected (_is_clientless_token) and provisioned as synthetic service principals (svc-<client_id>@<provider-id>.service.local); both human and service principals receive teams via the provider's existing role/group → team mapping. - Session-semantics payload: the resulting identity is returned with
token_use="session"andsource="external_idp".is_adminis read from the persisted local user record (db_user.is_admin), andteamsare resolved viaresolve_session_teams()— both DB-authoritative, never derived directly from the external token's claims. This identity then flows through the normal Layer 1 (token scoping) / Layer 2 (RBAC) pipeline exactly like any other session token. - Caching: successful resolutions are cached per-token (SHA-256 of the raw token) for
EXTERNAL_IDENTITY_CACHE_TTLseconds (clamped to the token'sexp), shared via Redis whenCACHE_TYPE=redis, to avoid re-provisioning on every M2M call.
Revocation and role-sync caveats
ContextForge cannot revoke an externally-issued token before its own expiry — only local user-deactivation/team-membership changes take effect immediately. If role-sync is enabled for the provider, teams/admin status are re-derived from token claims into the local DB on each provisioning pass. See the SSO documentation for details.
Future Enhancements¶
- Wire UI toggles for token storage and auto-refresh to backend logic.
- Make PKCE method configurable.
- Add Admin UI for managing token status and revocation.
- Implement scheduled cleanup of expired OAuth tokens.