Skip to content

[TT-17580] Add JWT bearer grant (RFC 7523) to OAuth2 token exchange#8466

Open
lghiur wants to merge 3 commits into
TT-17186-token-exchange-observabilityfrom
TT-17580-jwt-bearer-grant
Open

[TT-17580] Add JWT bearer grant (RFC 7523) to OAuth2 token exchange#8466
lghiur wants to merge 3 commits into
TT-17186-token-exchange-observabilityfrom
TT-17580-jwt-bearer-grant

Conversation

@lghiur

@lghiur lghiur commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

PR description:

Adds the RFC 7523 JWT bearer grant as a second delegation grant for the OAuth2 token exchange feature, selected per provider via a new optional grantType field (token-exchange default | jwt-bearer).

  • JWT bearer requests: the inbound user token is sent as assertion; the resolved target (audience + scopes) is rendered into the scope parameter by one mechanical rule — unqualified scopes are prefixed with audience/, scopes containing / pass verbatim, nothing is ever invented. No audience wire parameter is emitted under this grant.
  • Per-grant reserved customParams: a key is reserved only when the gateway sets it under that grant — assertion is rejected under jwt-bearer; audience/resource become legal there while staying rejected under token-exchange.
  • Built-in step-up relay: when a jwt-bearer exchange returns interaction_required, the gateway replies 401 with WWW-Authenticate: Bearer error="insufficient_claims" relaying the IdP's claims challenge and authorization_uri; upstream is not called, nothing is cached, and a new step_up_required outcome is recorded (metric + structured log). The RFC 8693 path is untouched.
  • Multi-tenant IdPs: issuers entries may opt into regex matching with an explicit regex: prefix (compile + ^…$ anchor validated at load; exact entries match first, then regex in provider order), and tokenEndpoint may embed a {claim.<name>} placeholder substituted per request from the inbound token (path-only, strict charset guard, rejected before any IdP call if the claim is missing).
  • Client authentication (client_secret_basic/client_secret_post/private_key_jwt), target resolution, and caching are shared unchanged across grants.

Ticket Details

TT-17580
Status In Code Review
Summary Microsoft Entra (OBO)

Generated at: 2026-07-09 08:43:38

@probelabs

probelabs Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces support for the JWT Bearer Grant (RFC 7523) for OAuth2 token exchange, enhancing Tyk's capabilities for implementing On-Behalf-Of (OBO) delegation flows, particularly with identity providers like Microsoft Entra ID. The changes also include significant improvements for multi-tenant configurations and add support for private_key_jwt client authentication.

Key changes include:

  • A new grantType field for token exchange providers, allowing selection between the existing token-exchange (RFC 8693) and the new jwt-bearer (RFC 7523) grant.
  • Support for private_key_jwt client authentication, enabling the gateway to authenticate to the IdP by signing a JWT with a certificate from the gateway's certificate store, thus avoiding the need for a client secret.
  • Enhanced multi-tenancy support, allowing issuers to be specified as regular expressions (using a regex: prefix) and tokenEndpoint URLs to contain dynamic placeholders that are resolved from inbound token claims at request time.
  • A built-in relay for step-up authentication challenges. If an IdP responds with interaction_required, the gateway will return a 401 Unauthorized with a WWW-Authenticate: Bearer error="insufficient_claims" challenge to the client.

Files Changed Analysis

The changes are spread across 30 files, with approximately 1745 lines of code added, reflecting a significant feature implementation. The modifications are logically structured:

  • API Definitions (apidef/): The configuration schema and Go structs in apidef/oas/oauth2.go are updated to include the new grantType and certId fields, and private_key_jwt is added to the list of supported client authentication methods.
  • Middleware Logic (ee/middleware/oauth2tokenexchange/): This is the core of the implementation. exchange.go is updated to handle the new grant type, dynamic endpoints, and step-up responses. New files like clientassertion.go are added to implement the JWT signing for private_key_jwt authentication. The feature is accompanied by a comprehensive suite of new tests.
  • Shared Utilities (internal/oauth2common/): New shared logic has been added for multi-tenant provider selection (multitenant.go), JWT bearer scope rendering (jwt_bearer_scope.go), and new error types for the step-up flow (errors.go). Critically, the cache key generation in cache_key.go is now tenant-aware.
  • Gateway Integration (gateway/): Minor but crucial changes in gateway/mw_oauth2.go and gateway/mw_oauth2_exchange_ee.go integrate the new functionality with the gateway's variable replacement engine and certificate manager.

Architecture & Impact Assessment

  • What this PR accomplishes: It enables the gateway to use the RFC 7523 JWT bearer grant for token delegation, a key requirement for modern OBO flows. It also simplifies the management of multi-tenant IdPs and improves security by adding a secret-less client authentication method.

  • Key technical changes introduced:

    1. JWT Bearer Grant: A new grantType: "jwt-bearer" option changes the request to the IdP, sending the inbound token as an assertion.
    2. private_key_jwt Client Authentication: A new client authentication method where the gateway signs a JWT with a configured certificate.
    3. Multi-Tenant IdP Support: Provider issuers can use regex, and the tokenEndpoint can be resolved dynamically from JWT claims.
    4. Step-Up Challenge Relay: If an exchange returns interaction_required, the gateway relays this as a 401 insufficient_claims challenge to the client.
  • Affected system components:

    • OAuth2 Token Exchange Middleware: The core logic is extended with new paths for request building, client authentication, and response handling.
    • API Definition & Validation: The configuration model is expanded with new validation rules.
    • Gateway Certificate Manager: Now used to fetch private keys for signing private_key_jwt assertions.
    • Caching: The token exchange cache key is modified to include the resolved tenant endpoint, ensuring cache isolation between tenants.

JWT Bearer Flow with Step-Up Challenge

sequenceDiagram
    participant Client
    participant Tyk Gateway
    participant "Identity Provider (IdP)"

    Client->>+Tyk Gateway: API Request with Inbound JWT
    Tyk Gateway->>Tyk Gateway: "Select Provider (iss match, regex support)"
    Tyk Gateway->>Tyk Gateway: Resolve dynamic Token Endpoint from JWT
    Tyk Gateway->>Tyk Gateway: "Build JWT Bearer request (token as `assertion`)"
    alt private_key_jwt auth
        Tyk Gateway->>Tyk Gateway: Sign client assertion with private key
    end
    Tyk Gateway->>+"Identity Provider (IdP)": "POST /token (grant_type=jwt-bearer, assertion=...)"
    alt Step-Up Required
        "Identity Provider (IdP)"-->>-Tyk Gateway: "400 Bad Request (error=interaction_required, claims=...)"
        Tyk Gateway->>Tyk Gateway: "Classify as StepUpRequiredError"
        Tyk Gateway-->>-Client: "401 Unauthorized (WWW-Authenticate: Bearer error=\"insufficient_claims\")"
    else Success
        "Identity Provider (IdP)"-->>-Tyk Gateway: "200 OK (Exchanged Token)"
        Tyk Gateway->>Tyk Gateway: "Cache exchanged token (key includes tenant endpoint)"
        Tyk Gateway-->>Client: Proxy request to upstream with new token
    end
Loading

Scope Discovery & Context Expansion

  • The changes are well-contained within the OAuth2 token exchange feature but have notable architectural impacts. The private_key_jwt feature creates a new dependency from the middleware to the gateway's central certificate store, which is a positive step towards centralized secret management.
  • The modification to the cache key in internal/oauth2common/cache_key.go is a critical security and correctness fix for multi-tenancy. By including the resolved TenantEndpoint in the key, it prevents a token cached for one tenant from being served to another.
  • The new step_up_required outcome establishes a new client-facing contract. API consumers will need to be prepared to handle the 401 insufficient_claims challenge to implement interactive flows, which should be clearly documented.
  • The provider selection logic in internal/oauth2common/target.go now performs two passes over the provider list (one for exact matches, one for regex). While correct, this could have performance implications for configurations with a very large number of providers.
Metadata
  • Review Effort: 5 / 5
  • Primary Label: feature

Powered by Visor from Probelabs

Last updated: 2026-07-09T08:46:01.565Z | Triggered by: pr_updated | Commit: 8539397

💡 TIP: You can chat with Visor using /visor ask <your question>

@probelabs

probelabs Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

✅ Security Check Passed

No security issues found – changes LGTM.

Architecture Issues (1)

Severity Location Issue
🟡 Warning ee/middleware/oauth2tokenexchange/clientassertion.go:41-44
The implementation for `private_key_jwt` client assertion is hardcoded to use RSA keys and the RS256 signing algorithm. The `private_key_jwt` standard also supports other asymmetric algorithms like ECDSA. This implementation limits the feature's applicability to identity providers that might require other algorithm types, reducing its extensibility.
💡 SuggestionRefactor `buildClientAssertion` to be algorithm-agnostic. Inspect the type of the private key from the `tls.Certificate` (e.g., `*rsa.PrivateKey`, `*ecdsa.PrivateKey`) and dynamically select the appropriate JWT signing method (e.g., `jwt.SigningMethodRS256`, `jwt.SigningMethodES256`). This will make the feature more robust and compatible with a wider range of identity providers.

✅ Security Check Passed

No security issues found – changes LGTM.

\n\n

Architecture Issues (1)

Severity Location Issue
🟡 Warning ee/middleware/oauth2tokenexchange/clientassertion.go:41-44
The implementation for `private_key_jwt` client assertion is hardcoded to use RSA keys and the RS256 signing algorithm. The `private_key_jwt` standard also supports other asymmetric algorithms like ECDSA. This implementation limits the feature's applicability to identity providers that might require other algorithm types, reducing its extensibility.
💡 SuggestionRefactor `buildClientAssertion` to be algorithm-agnostic. Inspect the type of the private key from the `tls.Certificate` (e.g., `*rsa.PrivateKey`, `*ecdsa.PrivateKey`) and dynamically select the appropriate JWT signing method (e.g., `jwt.SigningMethodRS256`, `jwt.SigningMethodES256`). This will make the feature more robust and compatible with a wider range of identity providers.
\n\n ### Performance Issues (1)
Severity Location Issue
🟡 Warning internal/oauth2common/target.go:31-71
The `SelectExchangeProvider` function iterates through the list of providers twice in the worst-case scenario: once to find an exact issuer match, and a second time to find a regex-based match. For configurations with a large number of providers, this could introduce latency as it's on a per-request hot path.
💡 SuggestionTo optimize, consider pre-processing the provider configuration upon loading. Exact issuers can be stored in a map for O(1) lookup. Regex-based providers would only need to be iterated if no exact match is found. This would significantly improve performance for the common case of exact matches and avoid iterating the full provider list multiple times per request.

Quality Issues (2)

Severity Location Issue
🟠 Error apidef/oas/oauth2.go:484-486
The validation for `tokenEndpoint` is inconsistent with its documented behavior regarding dynamic variables. The code uses `url.Parse` and then checks `u.Host == ""`, which will fail for a URL with a variable in the hostname (e.g., `https://$var.example.com`). This contradicts the comment on line 480 which states that `$tyk_context.*` variables are not validated. This will prevent valid multi-tenant configurations from being loaded.
💡 SuggestionThe validation logic should be relaxed to accommodate URLs with variables in the host. One approach is to skip the `u.Host == ""` check if the `tokenEndpoint` string contains `$tyk_context`.
🟡 Warning internal/oauth2common/multitenant.go:18-25
The caching logic for compiled regular expressions in `compiledIssuerRegex` contains a check-then-act race condition. If multiple goroutines access a new issuer regex for the first time concurrently, the regex may be compiled multiple times unnecessarily. The `Load` and `Store` operations on `sync.Map` are not atomic as a pair.
💡 SuggestionUse `sync.Map.LoadOrStore` to ensure the regex is compiled and stored atomically, preventing redundant compilation work under concurrent load. This will make the caching more robust and efficient.

Powered by Visor from Probelabs

Last updated: 2026-07-09T08:45:29.490Z | Triggered by: pr_updated | Commit: 8539397

💡 TIP: You can chat with Visor using /visor ask <your question>

endpoint = st.ReplaceVariables(endpoint)
}
tenantEndpoint := ""
if endpoint != provider.TokenEndpoint {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the below concern flagged by Visor should be handled

Comment thread apidef/oas/oauth2.go Outdated
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("oauth2 scheme %q: tokenExchange.provider %q issuers regex %q does not compile: %v", schemeName, providerName, pattern, err)
}
if !strings.HasPrefix(pattern, "^") || !strings.HasSuffix(pattern, "$") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be moved above the compile call

Comment thread apidef/oas/oauth2.go Outdated
// validateExchangeCustomParams rejects customParams that would override a wire
// key the gateway sets under the provider's grant.
func validateExchangeCustomParams(schemeName string, p *OAuth2TokenExchangeProvider) error {
reservedKeys, grantLabel := oauth2ReservedExchangeFormKeys, "RFC 8693"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

magic strings used as values should be replaced for constants

@sentinelone-cnapp-eu1

Copy link
Copy Markdown

SentinelOne CNS Hardcoded Secret Detector
✅ Congratulations, your code is safe

SentinelOne CNS is a cloud-agnostic, agentless CSPM & CWPP solution that continuously detects and prevents vulnerabilities that have the highest probability of being exploited in Azure, AWS, Google Cloud, and Kubernetes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants