OAuth forward-mode refresh tokens + DCR refresh_token grant advertisement#88
Open
BorisTyshkevich wants to merge 3 commits intomainfrom
Open
OAuth forward-mode refresh tokens + DCR refresh_token grant advertisement#88BorisTyshkevich wants to merge 3 commits intomainfrom
BorisTyshkevich wants to merge 3 commits intomainfrom
Conversation
The /oauth/register endpoint returned only `authorization_code` in `grant_types`, even though: - `/.well-known/oauth-authorization-server` already advertised `refresh_token` via `grant_types_supported` - the `/oauth/token` endpoint already accepted `grant_type=refresh_token` - the auth_code exchange already minted a `refresh_token` in the response body Per RFC 7591 §3.2.1, the `grant_types` list in the registration response is authoritative for the client. Strict clients (observed with Claude.ai) treat an omitted grant as forbidden and silently skip the refresh flow — forcing end users to re-authorize through the browser on every new chat session. Include `refresh_token` alongside `authorization_code` so the registration response matches server capabilities. Add a regression assertion to the existing `dynamic_client_registration` subtest that pins both grants in the response body. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
BorisTyshkevich
added a commit
that referenced
this pull request
Apr 22, 2026
Brings the refresh_token grant advertisement fix into the interserver-auth test build so Claude.ai can exercise the full refresh flow against gated cluster-secret deployments. Will be dropped automatically once #88 merges to main and this branch rebases onto main.
Forward mode previously rejected grant_type=refresh_token outright, so MCP-client sessions died when the upstream ID token expired (~1 h with Auth0 defaults). Lengthening the upstream lifetime would weaken the very property that motivates forward mode (CH validates the bearer end-to-end via token_processors). When the new upstream_offline_access flag is set in forward mode: - offline_access is appended to the upstream authorize redirect - the upstream refresh token is captured and wrapped in a stateless JWE keyed by gating_secret_key; the JWE is returned to the MCP client as refresh_token (the cleartext upstream refresh never leaves MCP) - on grant_type=refresh_token, MCP decrypts the JWE, calls the upstream /oauth/token with the cleartext, re-validates the new ID token via the existing JWKS path (or upstream userinfo when no id_token is returned), mints a new JWE around the rotated upstream refresh, and returns the new pair. The access_token reaching ClickHouse remains the upstream IdP's signed JWT verbatim. The flag defaults to false so existing forward-mode deployments are unaffected. Three reasons for the default: (1) refresh widens the stolen- token blast radius from upstream ID-token TTL to refresh_token_ttl_seconds (default 30 d), which operators must consciously accept; (2) offline_access requires upstream IdP configuration that may not yet be in place; (3) refresh-rotation policy is a separate operator decision often owned by the identity team. Also renames encodeGatingArtifact/decodeGatingArtifact/mustGatingSecret /oauthGatingSecret to encodeJWEArtifact/decodeJWEArtifact/mustJWESecret /oauthJWESecret since the primitives are now shared by both modes. The config field name (gating_secret_key) is preserved for YAML backward compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When forwarding the upstream id_token as the bearer, the old code reported upstream tokenResp.ExpiresIn (the access_token's TTL) as expires_in. Auth0 returns expires_in=86400 for the access_token while the id_token's own exp is iat+3600, so MCP advertised a 24h lifetime to clients that were in fact getting a 1h bearer. Claude.ai (and other MCP clients) schedule proactive refresh from expires_in, so the session broke at the real bearer expiry with `jwt::error::token_verification_exception, e.what() = token expired` on the ClickHouse side. Use the id_token's JWT exp (already extracted via ValidateUpstreamIdentity- Token's OAuthClaims.ExpiresAt) when the bearer is the id_token; fall back to tokenResp.ExpiresIn for the access_token-only path; 1h as last-resort default. Applied in both handleOAuthCallback and handleOAuthTokenRefresh- Forward. Also adds INFO/WARN logging across the forward-mode auth-code + refresh flow (dispatcher entry, upstream token exchange success/failure, JWE mint, JWE decode, upstream refresh call) since the previous code was silent on whether Auth0 returned a refresh_token, whether MCP minted one, and whether refresh hits ever reached the handler — making diagnostics like the one above significantly faster. Tests: updated TestOAuthForwardModeBrowserLoginUsesUpstreamBearerToken (prior assertion `expires_in <= 1800` codified the bug; now asserts the id_token's real exp ~3600); added matching expires_in assertions in TestOAuthForwardModeRefresh for both the auth-code and refresh response paths to prevent regression. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In forward mode
altinity-mcppreviously rejectedgrant_type=refresh_tokenoutright, so MCP-client sessions died when the upstream ID token expired (~1 h with Auth0 defaults), forcing a full browser re-auth. Lengthening the upstream lifetime would weaken the very property forward mode exists for: ClickHouse validates the bearer end-to-end viatoken_processors.This PR ships forward-mode refresh, opt-in behind a new
upstream_offline_accessflag. The MCP refresh token is a stateless JWE wrapping the upstream IdP's refresh token (mirroring the gating-mode pattern that already exists, keyed bygating_secret_key). The forward-mode invariant is preserved: the bearer that reaches ClickHouse is still the upstream-IdP-signed JWT.The PR also includes a small companion fix to dynamic client registration — without it, strict OAuth clients (Claude.ai) never attempt
grant_type=refresh_tokenregardless of what the server supports, because RFC 7591 §3.2.1 makes the registration response'sgrant_typesauthoritative for the client.What changed
1. Forward-mode refresh tokens (new feature, opt-in)
When
upstream_offline_access: trueand forward mode:offline_accessis appended to the upstream authorize redirect.refresh_tokenis captured and wrapped in a JWE keyed bygating_secret_key. The cleartext upstream refresh never leaves MCP. The MCP client receives only the opaque JWE.grant_type=refresh_token, MCP decrypts the JWE, calls upstream/oauth/tokenwithgrant_type=refresh_token, re-validates the new ID token via the existing JWKS path (or upstreamuserinfowhen noid_tokenis returned, mirroring the callback's behavior), mints a new JWE around the rotated upstream refresh, and returns the new pair.access_tokenreaching ClickHouse remains the upstream JWT verbatim. No CH-side change.Default
false. Three reasons:refresh_token_ttl_seconds(default 30 d). Operators who chose forward mode for the short ceiling must consciously accept the new envelope.offline_accessmay not be enabled upstream. Auth0 requires it on the tenant API; Okta on the app grant types; Azure AD as exposed scope. Sendingoffline_accessto a non-configured IdP can hard-fail or silently strip it. Default-off lets operators stage IdP first.2. DCR
grant_typesincludesrefresh_token(one-line fix)/.well-known/oauth-authorization-serveralready advertisesrefresh_tokeningrant_types_supported, and/oauth/tokenalready accepts the grant. But/oauth/registerreturned\"grant_types\": [\"authorization_code\"], which is authoritative per RFC 7591. Strict clients (Claude.ai) honor the registration response and never attempt refresh.Without this, the new forward-mode refresh path is server-supported but client-unreachable.
3. Rename: gating primitives → JWE primitives
The encoder/decoder/secret helpers are no longer gating-mode-specific:
Pure mechanical search-and-replace; no behavior change. Internal call sites only — the `OAuthConfig.GatingSecretKey` config field and YAML key are preserved for backward compatibility with existing deployments.
Files changed
Test plan
Backward compatibility
Security envelope
🤖 Generated with Claude Code