diff --git a/cmd/altinity-mcp/main.go b/cmd/altinity-mcp/main.go index ff4f8ed..1852e70 100644 --- a/cmd/altinity-mcp/main.go +++ b/cmd/altinity-mcp/main.go @@ -1108,6 +1108,36 @@ func validateOAuthRuntimeConfig(cfg config.Config) error { "without it, any IdP-issued token with email_verified=false can impersonate the named CH user via initial_user") } + // #109: gating mode is now a pure OAuth resource server (Auth0-fronted). + // The fields below belong to the gating-AS role that is being removed. + // Refuse at startup so operators notice and clean up helm values. + if cfg.Server.OAuth.IsGatingMode() { + if cfg.Server.OAuth.ClientID != "" { + return fmt.Errorf("oauth: gating mode forbids oauth.client_id — remove from helm values; client_id is now Auth0's responsibility under #109") + } + if cfg.Server.OAuth.ClientSecret != "" { + return fmt.Errorf("oauth: gating mode forbids oauth.client_secret — remove from helm values; client_secret is now Auth0's responsibility under #109") + } + if cfg.Server.OAuth.TokenURL != "" { + return fmt.Errorf("oauth: gating mode forbids oauth.token_url — remove from helm values; token_url is now Auth0's responsibility under #109") + } + if cfg.Server.OAuth.AuthURL != "" { + return fmt.Errorf("oauth: gating mode forbids oauth.auth_url — remove from helm values; auth_url is now Auth0's responsibility under #109") + } + if cfg.Server.OAuth.UserInfoURL != "" { + return fmt.Errorf("oauth: gating mode forbids oauth.userinfo_url — remove from helm values; userinfo_url is now Auth0's responsibility under #109") + } + if cfg.Server.OAuth.PublicAuthServerURL != "" { + return fmt.Errorf("oauth: gating mode forbids oauth.public_auth_server_url — remove from helm values; public_auth_server_url is now Auth0's responsibility under #109") + } + if strings.TrimSpace(cfg.Server.OAuth.Issuer) == "" { + return fmt.Errorf("oauth: gating mode requires oauth.issuer (the upstream AS, e.g. https://altinity.auth0.com/) to be set") + } + if strings.TrimSpace(cfg.Server.OAuth.Audience) == "" { + return fmt.Errorf("oauth: gating mode requires oauth.audience to byte-equal the MCP public URL (RFC 8707)") + } + } + return nil } diff --git a/cmd/altinity-mcp/main_test.go b/cmd/altinity-mcp/main_test.go index c9b53f2..08ce0de 100644 --- a/cmd/altinity-mcp/main_test.go +++ b/cmd/altinity-mcp/main_test.go @@ -3288,9 +3288,11 @@ func TestValidateOAuthRuntimeConfig(t *testing.T) { t.Run("valid_gating_config", func(t *testing.T) { t.Parallel() cfg := config.Config{Server: config.ServerConfig{OAuth: config.OAuthConfig{ - Enabled: true, - Mode: "gating", + Enabled: true, + Mode: "gating", SigningSecret: "test-signing-secret-32-byte-key!!", + Issuer: "https://example.auth0.com/", + Audience: "https://example-mcp.test/", }}} require.NoError(t, validateOAuthRuntimeConfig(cfg)) }) @@ -3317,6 +3319,7 @@ func TestValidateOAuthRuntimeConfig(t *testing.T) { Enabled: true, Mode: "gating", SigningSecret: "test-signing-secret-32-byte-key!!", + Issuer: "https://example.auth0.com/", RequireEmailVerified: false, }}, ClickHouse: config.ClickHouseConfig{ @@ -3337,6 +3340,8 @@ func TestValidateOAuthRuntimeConfig(t *testing.T) { Enabled: true, Mode: "gating", SigningSecret: "test-signing-secret-32-byte-key!!", + Issuer: "https://example.auth0.com/", + Audience: "https://example-mcp.test/", RequireEmailVerified: true, }}, ClickHouse: config.ClickHouseConfig{ @@ -3357,6 +3362,8 @@ func TestValidateOAuthRuntimeConfig(t *testing.T) { Enabled: true, Mode: "gating", SigningSecret: "test-signing-secret-32-byte-key!!", + Issuer: "https://example.auth0.com/", + Audience: "https://example-mcp.test/", RequireEmailVerified: false, }}, ClickHouse: config.ClickHouseConfig{Protocol: config.TCPProtocol}, diff --git a/cmd/altinity-mcp/oauth_server.go b/cmd/altinity-mcp/oauth_server.go index f8ab1da..028d045 100644 --- a/cmd/altinity-mcp/oauth_server.go +++ b/cmd/altinity-mcp/oauth_server.go @@ -231,7 +231,7 @@ func (a *application) oauthJWESecret() []byte { func (a *application) mustJWESecret() ([]byte, error) { secret := a.oauthJWESecret() if len(secret) == 0 { - return nil, fmt.Errorf("oauth signing_secret is required for OAuth client registration and gating-mode token minting") + return nil, fmt.Errorf("oauth signing_secret is required for OAuth client registration and forward-mode token wrapping") } return secret, nil } @@ -241,16 +241,13 @@ func (a *application) mustJWESecret() ([]byte, error) { // decryption; absence (kid="") selects the legacy SHA256(secret) key for // backwards compat with artifacts minted before the rotation cutover. After // the longest legacy artifact lifetime expires (refresh tokens, default 30 -// days), the legacy fallback below can be removed. Self-issued access-token -// JWS artifacts use altinitymcp.SelfIssuedAccessTokenKid instead — pkg/server -// is the verifier and owns that contract. +// days), the legacy fallback below can be removed. const oauthKidV1 = "v1" // HKDF info labels for cmd-internal OAuth key derivation. Each label produces // an independent 32-byte key from the shared signing_secret (RFC 5869 §3.2). // Bumping the /vN suffix in any single label rotates that one key without -// disturbing the others. The access-token label lives in pkg/server as -// altinitymcp.SelfIssuedAccessTokenHKDFInfo because the verifier owns it. +// disturbing the others. const ( hkdfInfoOAuthClientID = "altinity-mcp/oauth/client-id/v1" hkdfInfoOAuthRefresh = "altinity-mcp/oauth/refresh-token/v1" @@ -324,7 +321,6 @@ func decodeOAuthJWE(secret []byte, info string, token string) (map[string]interf return jwe_auth.ParseAndDecryptJWE(token, secret, secret) } - func normalizeURL(raw string) string { return strings.TrimRight(strings.TrimSpace(raw), "/") } @@ -652,34 +648,6 @@ func randomToken(prefix string) string { return prefix + base64.RawURLEncoding.EncodeToString(buf) } -func encodeSelfIssuedAccessToken(secret []byte, claims map[string]interface{}) (string, error) { - // Signing key is HKDF-derived per the access-token info label, separate - // from the JWE-encryption keys used for client_id and refresh_token. The - // kid header lets parseAndVerifySelfIssuedOAuthToken (pkg/server) select - // this derivation; legacy tokens (no kid) verify against the old - // SHA256(secret). Both the kid value and the info label are imported - // from pkg/server — that package is the verifier and owns the contract. - signingKey := jwe_auth.DeriveKey(secret, altinitymcp.SelfIssuedAccessTokenHKDFInfo) - signer, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.HS256, Key: signingKey}, - (&jose.SignerOptions{}). - WithType("JWT"). - WithHeader(jose.HeaderKey("kid"), altinitymcp.SelfIssuedAccessTokenKid), - ) - if err != nil { - return "", err - } - payload, err := json.Marshal(claims) - if err != nil { - return "", err - } - object, err := signer.Sign(payload) - if err != nil { - return "", err - } - return object.CompactSerialize() -} - func pkceChallenge(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) @@ -888,19 +856,28 @@ func (a *application) handleOAuthProtectedResource(w http.ResponseWriter, r *htt http.NotFound(w, r) return } + cfg := a.GetCurrentConfig().Server.OAuth baseURL := a.resourceBaseURL(r) - authServerBaseURL := a.oauthAuthorizationServerBaseURL(r) + // Under gating, MCP is a pure resource server and the upstream IdP + // (configured via `oauth.issuer`) is the AS — advertise it byte-equal to + // what tokens carry in their `iss` claim. Under forward, MCP fronts the + // upstream IdP and is itself the AS, so we advertise our own auth-server + // base URL with no trailing slash (RFC 8414 §2 issuer convention). + var authorizationServers []string + if cfg.IsGatingMode() { + authorizationServers = []string{strings.TrimSpace(cfg.Issuer)} + } else { + authorizationServers = []string{strings.TrimRight(a.oauthAuthorizationServerBaseURL(r), "/")} + } resp := map[string]interface{}{ // `resource` is the canonical RFC 9728 protected-resource identifier // (with trailing slash, per canonicalResourceURL); claude.ai's artifact // proxy compares the metadata field literally and round-trips it to the // `aud` claim. Inbound `aud` validation tolerates either form via - // audienceMatchesResource. `authorization_servers` follows the RFC 8414 - // issuer convention (no trailing slash) so as[0] == issuer holds byte- - // for-byte. + // audienceMatchesResource. "resource": canonicalResourceURL(baseURL), - "authorization_servers": []string{strings.TrimRight(authServerBaseURL, "/")}, - "scopes_supported": a.GetCurrentConfig().Server.OAuth.Scopes, + "authorization_servers": authorizationServers, + "scopes_supported": cfg.Scopes, "bearer_methods_supported": []string{"header"}, } w.Header().Set("Content-Type", "application/json") @@ -1319,7 +1296,11 @@ func (a *application) handleOAuthCallback(w http.ResponseWriter, r *http.Request } else { accessTokenExpiry = time.Now().Add(time.Hour).Unix() } - gatingCode := randomToken("oac_") + // /callback only runs in forward mode now (#109): under gating, /callback + // is not registered and clients redirect directly to the upstream IdP. + // Wrap the upstream tokens in our short-lived issued code; /token unwraps + // them in handleOAuthTokenAuthCode. + authCode := randomToken("oac_") issuedCode := oauthIssuedCode{ ClientID: pending.ClientID, RedirectURI: pending.RedirectURI, @@ -1328,27 +1309,19 @@ func (a *application) handleOAuthCallback(w http.ResponseWriter, r *http.Request CodeChallengeMethod: pending.CodeChallengeMethod, Resource: pending.Resource, ExpiresAt: time.Now().Add(time.Duration(defaultAuthCodeTTLSeconds) * time.Second), - } - if a.oauthForwardMode() { - issuedCode.UpstreamBearerToken = bearerToken - issuedCode.UpstreamTokenType = tokenType - issuedCode.AccessTokenExpiry = time.Unix(accessTokenExpiry, 0) - if cfg.Server.OAuth.UpstreamOfflineAccess { - issuedCode.UpstreamRefreshToken = tokenResp.RefreshToken - if tokenResp.RefreshToken == "" { - log.Warn(). - Str("scope", tokenResp.Scope). - Msg("upstream_offline_access=true but upstream did not return a refresh_token; check IdP application config (offline_access scope, refresh_token grant, audience)") - } + UpstreamBearerToken: bearerToken, + UpstreamTokenType: tokenType, + AccessTokenExpiry: time.Unix(accessTokenExpiry, 0), + } + if cfg.Server.OAuth.UpstreamOfflineAccess { + issuedCode.UpstreamRefreshToken = tokenResp.RefreshToken + if tokenResp.RefreshToken == "" { + log.Warn(). + Str("scope", tokenResp.Scope). + Msg("upstream_offline_access=true but upstream did not return a refresh_token; check IdP application config (offline_access scope, refresh_token grant, audience)") } - } else { - issuedCode.Subject = identityClaims.Subject - issuedCode.Email = identityClaims.Email - issuedCode.Name = identityClaims.Name - issuedCode.HostedDomain = identityClaims.HostedDomain - issuedCode.EmailVerified = identityClaims.EmailVerified } - a.getOAuthStateStore().putAuthCode(gatingCode, issuedCode) + a.getOAuthStateStore().putAuthCode(authCode, issuedCode) redirect, err := url.Parse(pending.RedirectURI) if err != nil { @@ -1356,7 +1329,7 @@ func (a *application) handleOAuthCallback(w http.ResponseWriter, r *http.Request return } params := redirect.Query() - params.Set("code", gatingCode) + params.Set("code", authCode) if pending.ClientState != "" { params.Set("state", pending.ClientState) } @@ -1364,100 +1337,6 @@ func (a *application) handleOAuthCallback(w http.ResponseWriter, r *http.Request http.Redirect(w, r, redirect.String(), http.StatusFound) } -// gatingIdentity holds the identity fields needed to mint gating-mode tokens. -type gatingIdentity struct { - ClientID string - Subject string - Email string - Name string - HostedDomain string - EmailVerified bool - Scope string - // Resource is the RFC 8707 resource indicator the client requested. - // Empty when the client did not pass one. When set, it is used verbatim - // as the `aud` claim — preserving trailing-slash form for byte-equality - // with what the client sent. - Resource string -} - -// mintGatingTokenResponse mints an access token and a stateless refresh token -// for gating mode, then writes the JSON response. -func (a *application) mintGatingTokenResponse(w http.ResponseWriter, r *http.Request, secret []byte, id gatingIdentity) { - cfg := a.GetCurrentConfig() - // Match the no-trailing-slash form advertised in /.well-known/oauth-authorization-server - // (RFC 8414 §2 requires byte-identical issuer between metadata and iss claim). - issuer := strings.TrimRight(a.oauthAuthorizationServerBaseURL(r), "/") - // RFC 8707 §2.2 / MCP authorization spec: when the client requested a - // resource indicator, the `aud` claim MUST identify that resource. Echo - // the requested string verbatim so byte-equality with what the client sent - // holds — this is what claude.ai's artifact proxy enforces. - // - // When the client did NOT send a resource indicator, fall back to the - // canonical no-trailing-slash form (matches the advertised `resource` - // field per MCP 2025-11-25 §Canonical Server URI). - var audience string - switch { - case id.Resource != "": - audience = id.Resource - case cfg.Server.OAuth.Audience != "": - audience = strings.TrimSuffix(cfg.Server.OAuth.Audience, "/") - default: - audience = strings.TrimRight(a.resourceBaseURL(r), "/") - } - scope := id.Scope - if scope == "" { - scope = strings.Join(cfg.Server.OAuth.Scopes, " ") - } - - now := time.Now() - accessToken, err := encodeSelfIssuedAccessToken(secret, map[string]interface{}{ - "sub": id.Subject, - "iss": issuer, - "aud": audience, - "exp": now.Add(time.Duration(ttlSeconds(cfg.Server.OAuth.AccessTokenTTLSeconds, defaultAccessTokenTTLSeconds)) * time.Second).Unix(), - "iat": now.Unix(), - "scope": scope, - "email": id.Email, - "name": id.Name, - "hd": id.HostedDomain, - "email_verified": id.EmailVerified, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to mint self-issued access token") - writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) - return - } - - refreshToken, err := encodeOAuthJWE(secret, hkdfInfoOAuthRefresh, map[string]interface{}{ - "sub": id.Subject, - "iss": issuer, - "aud": audience, - "exp": now.Add(time.Duration(ttlSeconds(cfg.Server.OAuth.RefreshTokenTTLSeconds, defaultRefreshTokenTTLSeconds)) * time.Second).Unix(), - "iat": now.Unix(), - "scope": scope, - "email": id.Email, - "name": id.Name, - "hd": id.HostedDomain, - "email_verified": id.EmailVerified, - "client_id": id.ClientID, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to mint refresh token") - writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) - return - } - - accessTokenTTL := ttlSeconds(cfg.Server.OAuth.AccessTokenTTLSeconds, defaultAccessTokenTTLSeconds) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "access_token": accessToken, - "refresh_token": refreshToken, - "token_type": "Bearer", - "expires_in": accessTokenTTL, - "scope": scope, - }) -} - // mintForwardRefreshToken wraps an upstream IdP refresh token in a stateless JWE. func (a *application) mintForwardRefreshToken(secret []byte, upstreamRefresh, upstreamTokenType, scope, clientID, issuer string) (string, error) { cfg := a.GetCurrentConfig() @@ -1500,12 +1379,73 @@ func (a *application) handleOAuthToken(w http.ResponseWriter, r *http.Request) { case "authorization_code": a.handleOAuthTokenAuthCode(w, r) case "refresh_token": - a.handleOAuthTokenRefresh(w, r) + a.handleOAuthTokenRefreshDispatch(w, r) default: writeOAuthTokenError(w, http.StatusBadRequest, "unsupported_grant_type", "unsupported grant type") } } +// handleOAuthTokenRefreshDispatch validates the refresh request's client +// authentication and refresh-token JWE, then delegates to the forward-mode +// upstream-refresh path. Under #109, gating mode no longer mints refresh +// tokens — clients refresh directly against the upstream IdP — so this +// dispatcher only ever runs in forward mode. +func (a *application) handleOAuthTokenRefreshDispatch(w http.ResponseWriter, r *http.Request) { + log.Info(). + Bool("forward_mode", a.oauthForwardMode()). + Msg("OAuth refresh_token grant: handler entered") + secret, err := a.mustJWESecret() + if err != nil { + writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) + return + } + + clientID := r.Form.Get("client_id") + clientClaims, err := decodeOAuthJWE(secret, hkdfInfoOAuthClientID, clientID) + if err != nil { + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "unknown OAuth client") + return + } + client, err := parseStatelessRegisteredClient(clientClaims) + if err != nil || time.Now().Unix() > client.ExpiresAt { + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "unknown OAuth client") + return + } + if err := authenticateClientSecret(client, r); err != nil { + log.Debug().Err(err).Msg("OAuth refresh request rejected: client_secret authentication failed") + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") + return + } + + refreshTokenStr := r.Form.Get("refresh_token") + if refreshTokenStr == "" { + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "missing refresh token") + return + } + claims, err := decodeOAuthJWE(secret, hkdfInfoOAuthRefresh, refreshTokenStr) + if err != nil { + log.Warn().Err(err).Msg("OAuth refresh_token grant: JWE decode failed") + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "invalid refresh token") + return + } + jweUpstreamRefresh, _ := claims["upstream_refresh_token"].(string) + log.Info(). + Bool("has_upstream_refresh_token", jweUpstreamRefresh != ""). + Msg("OAuth refresh_token grant: JWE decoded successfully") + + tokenClientID, _ := claims["client_id"].(string) + if tokenClientID != clientID { + log.Debug(). + Str("token_client_id", tokenClientID). + Str("request_client_id", clientID). + Msg("OAuth refresh rejected: client_id mismatch") + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token was not issued to this client") + return + } + + a.handleOAuthTokenRefreshForward(w, r, secret, clientID, claims) +} + func (a *application) handleOAuthTokenAuthCode(w http.ResponseWriter, r *http.Request) { secret, err := a.mustJWESecret() if err != nil { @@ -1574,179 +1514,49 @@ func (a *application) handleOAuthTokenAuthCode(w http.ResponseWriter, r *http.Re } } - if a.oauthForwardMode() { - bearerToken := issued.UpstreamBearerToken - if bearerToken == "" { - writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "invalid authorization code") - return - } - expiresIn := int64(0) - if !issued.AccessTokenExpiry.IsZero() { - expiresIn = int64(time.Until(issued.AccessTokenExpiry).Seconds()) - if expiresIn < 0 { - expiresIn = 0 - } - } - response := map[string]interface{}{ - "access_token": bearerToken, - "token_type": issued.UpstreamTokenType, - "expires_in": expiresIn, - "scope": issued.Scope, - } - if issued.UpstreamRefreshToken != "" { - refreshToken, err := a.mintForwardRefreshToken(secret, issued.UpstreamRefreshToken, issued.UpstreamTokenType, issued.Scope, clientID, a.oauthAuthorizationServerBaseURL(r)) - if err != nil { - log.Error().Err(err).Msg("Failed to mint forward-mode refresh token") - writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) - return - } - response["refresh_token"] = refreshToken - log.Info(). - Str("client_id", clientID). - Int("jwe_len", len(refreshToken)). - Msg("Forward-mode auth-code response includes refresh_token (JWE wrapping upstream refresh)") - } else { - log.Info(). - Str("client_id", clientID). - Msg("Forward-mode auth-code response WITHOUT refresh_token (no upstream refresh captured)") - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - return - } - - a.mintGatingTokenResponse(w, r, secret, gatingIdentity{ - ClientID: issued.ClientID, - Subject: issued.Subject, - Email: issued.Email, - Name: issued.Name, - HostedDomain: issued.HostedDomain, - EmailVerified: issued.EmailVerified, - Scope: issued.Scope, - Resource: resource, - }) -} - -// handleOAuthTokenRefresh exchanges a refresh token for a new access + rotated -// refresh token pair. Refresh tokens are stateless JWE-encrypted blobs validated -// by decrypt + expiry check only. -// -// In gating mode the JWE wraps the user's identity claims and a fresh self-issued -// access token is minted from them. In forward mode the JWE wraps the upstream -// IdP's refresh token; this handler decrypts it, calls the upstream token -// endpoint with grant_type=refresh_token, re-validates the new ID token via the -// configured JWKS, and returns a new pair (access_token = upstream ID token, -// refresh_token = new JWE around the rotated upstream refresh). -// -// Limitations of the stateless design (apply to both modes): -// - No revocation: a stolen MCP refresh token is valid until its JWE exp. -// - No reuse detection: a rotated-out MCP refresh token remains valid alongside -// the new one until it naturally expires. -// - No server-side state: there is no token store to revoke against. -// -// In forward mode, IdP-side refresh-token rotation + reuse detection (e.g. Auth0) -// provides a second line of defense outside MCP. -func (a *application) handleOAuthTokenRefresh(w http.ResponseWriter, r *http.Request) { - log.Info(). - Bool("forward_mode", a.oauthForwardMode()). - Msg("OAuth refresh_token grant: handler entered") - secret, err := a.mustJWESecret() - if err != nil { - writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) - return - } - - // Validate client_id - clientID := r.Form.Get("client_id") - clientClaims, err := decodeOAuthJWE(secret, hkdfInfoOAuthClientID, clientID) - if err != nil { - writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "unknown OAuth client") - return - } - client, err := parseStatelessRegisteredClient(clientClaims) - if err != nil || time.Now().Unix() > client.ExpiresAt { - writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "unknown OAuth client") - return - } - if err := authenticateClientSecret(client, r); err != nil { - log.Debug().Err(err).Msg("OAuth refresh request rejected: client_secret authentication failed") - writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") - return - } - - // Decrypt and validate refresh token - refreshTokenStr := r.Form.Get("refresh_token") - if refreshTokenStr == "" { - writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "missing refresh token") - return - } - claims, err := decodeOAuthJWE(secret, hkdfInfoOAuthRefresh, refreshTokenStr) - if err != nil { - log.Warn().Err(err).Msg("OAuth refresh_token grant: JWE decode failed") - writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "invalid refresh token") + // /oauth/token only runs in forward mode now (#109): under gating, /token + // is not registered and clients hit the upstream IdP directly. The issued + // authorization_code wraps an upstream bearer token captured in /callback; + // forward it back to the client unchanged, mint a forward-mode refresh + // JWE around the upstream refresh if offline_access is on. + _ = resource + bearerToken := issued.UpstreamBearerToken + if bearerToken == "" { + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "invalid authorization code") return } - jweUpstreamRefresh, _ := claims["upstream_refresh_token"].(string) - log.Info(). - Bool("has_upstream_refresh_token", jweUpstreamRefresh != ""). - Msg("OAuth refresh_token grant: JWE decoded successfully") - - // Verify client_id in refresh token matches the requesting client - tokenClientID, _ := claims["client_id"].(string) - if tokenClientID != clientID { - log.Debug(). - Str("token_client_id", tokenClientID). - Str("request_client_id", clientID). - Msg("OAuth refresh rejected: client_id mismatch") - writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token was not issued to this client") - return + expiresIn := int64(0) + if !issued.AccessTokenExpiry.IsZero() { + expiresIn = int64(time.Until(issued.AccessTokenExpiry).Seconds()) + if expiresIn < 0 { + expiresIn = 0 + } } - - if a.oauthForwardMode() { - a.handleOAuthTokenRefreshForward(w, r, secret, clientID, claims) - return + response := map[string]interface{}{ + "access_token": bearerToken, + "token_type": issued.UpstreamTokenType, + "expires_in": expiresIn, + "scope": issued.Scope, } - - sub, _ := claims["sub"].(string) - email, _ := claims["email"].(string) - name, _ := claims["name"].(string) - hd, _ := claims["hd"].(string) - emailVerified, _ := claims["email_verified"].(bool) - scope, _ := claims["scope"].(string) - resource, _ := claims["aud"].(string) - - // RFC 8707 §2.2: a /token refresh request MAY narrow the resource to a - // subset of those originally granted. We don't track multi-resource grants, - // so the only supported case is "same as original" — reject any mismatch. - if formResource := r.Form.Get("resource"); formResource != "" { - if resource == "" { - resource = formResource - } else if strings.TrimRight(formResource, "/") != strings.TrimRight(resource, "/") { - writeOAuthTokenError(w, http.StatusBadRequest, "invalid_target", "resource indicator does not match the original grant") + if issued.UpstreamRefreshToken != "" { + refreshToken, err := a.mintForwardRefreshToken(secret, issued.UpstreamRefreshToken, issued.UpstreamTokenType, issued.Scope, clientID, a.oauthAuthorizationServerBaseURL(r)) + if err != nil { + log.Error().Err(err).Msg("Failed to mint forward-mode refresh token") + writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) return } + response["refresh_token"] = refreshToken + log.Info(). + Str("client_id", clientID). + Int("jwe_len", len(refreshToken)). + Msg("Forward-mode auth-code response includes refresh_token (JWE wrapping upstream refresh)") + } else { + log.Info(). + Str("client_id", clientID). + Msg("Forward-mode auth-code response WITHOUT refresh_token (no upstream refresh captured)") } - - policyClaims := &altinitymcp.OAuthClaims{ - Email: email, - EmailVerified: emailVerified, - HostedDomain: hd, - } - if err := a.mcpServer.ValidateOAuthIdentityPolicyClaims(policyClaims); err != nil { - writeOAuthTokenError(w, http.StatusForbidden, "access_denied", err.Error()) - return - } - - a.mintGatingTokenResponse(w, r, secret, gatingIdentity{ - ClientID: clientID, - Subject: sub, - Email: email, - Name: name, - HostedDomain: hd, - EmailVerified: emailVerified, - Scope: scope, - Resource: resource, - }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) } // handleOAuthTokenRefreshForward implements the forward-mode refresh flow. @@ -1905,8 +1715,18 @@ func truncateForLog(value string, max int) string { } func (a *application) registerOAuthHTTPRoutes(mux *http.ServeMux) { + // RFC 9728 protected-resource metadata is the only OAuth endpoint MCP + // itself owns under gating mode (#109): MCP is a pure resource server + // and points clients at the upstream IdP for everything else. Under + // forward mode MCP also fronts the upstream IdP as a proxying AS — + // /oauth/register, /authorize, /callback, /token, and the + // AS-discovery metadata endpoints stay registered on that path. mux.HandleFunc(defaultProtectedResourceMetadataPath, a.handleOAuthProtectedResource) + if a.GetCurrentConfig().Server.OAuth.IsGatingMode() { + return + } + for _, path := range uniquePaths( defaultAuthorizationServerMetadataPath, "/.well-known/oauth-authorization-server/oauth", diff --git a/cmd/altinity-mcp/oauth_server_test.go b/cmd/altinity-mcp/oauth_server_test.go index 76fdf7c..2446fcb 100644 --- a/cmd/altinity-mcp/oauth_server_test.go +++ b/cmd/altinity-mcp/oauth_server_test.go @@ -123,23 +123,6 @@ func TestOAuthJWEHKDFRoundtripAndLegacyFallback(t *testing.T) { require.Equal(t, "user-legacy", decoded["sub"]) }) - t.Run("self_issued_access_token_v1_carries_kid", func(t *testing.T) { - t.Parallel() - token, err := encodeSelfIssuedAccessToken(secret, map[string]interface{}{ - "sub": "user-1", - "iss": "https://mcp.example.com", - "aud": "https://mcp.example.com", - "exp": time.Now().Add(time.Hour).Unix(), - }) - require.NoError(t, err) - parts := strings.Split(token, ".") - require.Len(t, parts, 3) - header, err := decodeJWTSegment(parts[0]) - require.NoError(t, err) - var hdr map[string]interface{} - require.NoError(t, json.Unmarshal(header, &hdr)) - require.Equal(t, oauthKidV1, hdr["kid"], "self-issued access token must carry kid=v1") - }) } func TestOAuthHTTPDiscoveryAndRegistration(t *testing.T) { @@ -280,75 +263,6 @@ func TestOAuthHTTPDiscoveryAndRegistration(t *testing.T) { require.Equal(t, http.StatusFound, authRR.Code, "missing resource indicator must still authorize (legacy clients)") }) - t.Run("mint_gating_token_aud_mirrors_requested_resource", func(t *testing.T) { - // `aud` claim must byte-match what the client passed in `resource`. - // Anthropic's artifact-side proxy enforces this byte-equality; if we - // strip a trailing slash that the client included, the proxy silently - // drops the connector — see docs/artifact-mcp-known-issues.md. - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "https://mcp.example.com/oauth/token", nil) - // Audience field is set on app.config (= "https://mcp.example.com"), - // but Resource on the gatingIdentity must win. - app.mintGatingTokenResponse(w, req, []byte(app.config.Server.OAuth.SigningSecret), gatingIdentity{ - ClientID: "test-client", - Subject: "user-123", - Email: "u@example.com", - EmailVerified: true, - Scope: "openid email", - Resource: "https://mcp.example.com/", - }) - require.Equal(t, http.StatusOK, w.Code) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - accessToken, _ := resp["access_token"].(string) - require.NotEmpty(t, accessToken) - - // Decode the JWS (3 parts) without verification; we only check the - // audience claim shape. - parts := strings.Split(accessToken, ".") - require.Len(t, parts, 3) - payload, err := decodeJWTSegment(parts[1]) - require.NoError(t, err) - var claims map[string]interface{} - require.NoError(t, json.Unmarshal(payload, &claims)) - require.Equal(t, "https://mcp.example.com/", claims["aud"], "aud must be the exact string the client passed in `resource` (trailing slash preserved)") - }) - - t.Run("mint_gating_token_aud_defaults_to_canonical_no_slash", func(t *testing.T) { - // When the client did NOT send a resource indicator (e.g., legacy - // codex / older mcp clients) the fallback `aud` matches the - // canonical advertised `resource` (no trailing slash) per - // MCP 2025-11-25 §Canonical Server URI. - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "https://mcp.example.com/oauth/token", nil) - // Clear the operator-configured Audience for this subtest so the - // fallback path runs (with Audience set, that wins). - savedAud := app.config.Server.OAuth.Audience - app.config.Server.OAuth.Audience = "" - t.Cleanup(func() { app.config.Server.OAuth.Audience = savedAud }) - - app.mintGatingTokenResponse(w, req, []byte(app.config.Server.OAuth.SigningSecret), gatingIdentity{ - ClientID: "test-client", - Subject: "user-123", - Email: "u@example.com", - EmailVerified: true, - Scope: "openid email", - // Resource intentionally empty. - }) - require.Equal(t, http.StatusOK, w.Code) - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - accessToken, _ := resp["access_token"].(string) - parts := strings.Split(accessToken, ".") - require.Len(t, parts, 3) - payload, err := decodeJWTSegment(parts[1]) - require.NoError(t, err) - var claims map[string]interface{} - require.NoError(t, json.Unmarshal(payload, &claims)) - require.Equal(t, "https://mcp.example.com", claims["aud"]) - }) - t.Run("dynamic_client_registration_default_is_confidential", func(t *testing.T) { // When the client doesn't ask for a specific auth method, we now // register it as confidential (client_secret_post). This unblocks @@ -422,21 +336,14 @@ func TestOAuthHTTPDiscoveryAndRegistration(t *testing.T) { app.handleOAuthProtectedResource(rr, req) require.Equal(t, http.StatusOK, rr.Code) require.Contains(t, rr.Body.String(), "\"resource\":\"https://public.example.com/\"") - require.Contains(t, rr.Body.String(), "\"authorization_servers\":[\"https://public.example.com/oauth\"]") + // In gating mode the protected-resource metadata advertises the upstream + // IdP (cfg.Issuer) as the AS, not MCP's own PublicAuthServerURL. + require.Contains(t, rr.Body.String(), "\"authorization_servers\":[\"https://mcp.example.com/oauth\"]") }) } func TestOAuthMCPAuthInjector(t *testing.T) { t.Parallel() - token, err := generateOAuthTokenForApp(map[string]interface{}{ - "sub": "user123", - "iss": "https://mcp.example.com", - "aud": "https://mcp.example.com", - "exp": time.Now().Add(time.Hour).Unix(), - "scope": "openid email", - "email": "user@example.com", - }) - require.NoError(t, err) app := &application{ config: config.Config{ @@ -491,24 +398,6 @@ func TestOAuthMCPAuthInjector(t *testing.T) { require.Contains(t, rr.Header().Get("WWW-Authenticate"), "error=\"invalid_token\"") }) - t.Run("valid_oauth_sets_context", func(t *testing.T) { - t.Parallel() - req := httptest.NewRequest(http.MethodPost, "https://mcp.example.com/"+jweToken, nil) - req.SetPathValue("token", jweToken) - req.Header.Set("Authorization", "Bearer "+token) - rr := httptest.NewRecorder() - called := false - handler := app.createMCPAuthInjector(app.config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - require.Equal(t, jweToken, r.Context().Value(altinitymcp.JWETokenKey)) - require.Equal(t, token, r.Context().Value(altinitymcp.OAuthTokenKey)) - w.WriteHeader(http.StatusOK) - })) - handler.ServeHTTP(rr, req) - require.True(t, called) - require.Equal(t, http.StatusOK, rr.Code) - }) - t.Run("jwe_with_credentials_skips_oauth", func(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodPost, "https://mcp.example.com/"+jweTokenWithCredentials, nil) @@ -687,6 +576,7 @@ func TestRegisterOAuthHTTPRoutesAliases(t *testing.T) { Server: config.ServerConfig{ OAuth: config.OAuthConfig{ Enabled: true, + Mode: "forward", Issuer: "https://mcp.example.com/oauth", Audience: "https://mcp.example.com", Scopes: []string{"openid", "email"}, @@ -1209,18 +1099,6 @@ func TestCanonicalResourceURL(t *testing.T) { } } -func TestEncodeSelfIssuedAccessTokenShortSecret(t *testing.T) { - t.Parallel() - token, err := encodeSelfIssuedAccessToken([]byte("short-secret"), map[string]interface{}{ - "sub": "user-1", - "iss": "https://issuer.example.com", - "aud": "https://resource.example.com", - "exp": time.Now().Add(time.Hour).Unix(), - }) - require.NoError(t, err) - require.NotEmpty(t, token) -} - func TestOAuthStateStoreSizeCap(t *testing.T) { t.Parallel() t.Run("pending_auth_evicts_oldest_at_cap", func(t *testing.T) { @@ -1370,143 +1248,6 @@ func exchangeRefreshToken(t *testing.T, app *application, clientID, refreshToken return rr } -func TestOAuthRefreshTokenGatingMode(t *testing.T) { - t.Parallel() - const ( - redirectURI = "http://127.0.0.1:3334/callback" - codeVerifier = "test-code-verifier" - ) - - provider := newTestForwardModeOIDCProvider(t, map[string]interface{}{ - "access_token": "upstream-access-token", - "token_type": "Bearer", - "expires_in": 1800, - "scope": "openid email", - }, nil) - provider.tokenResponse["id_token"] = provider.issueIDToken(t, map[string]interface{}{ - "sub": "user-1", - "iss": provider.server.URL, - "aud": "upstream-client-id", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - "email": "user@example.com", - "email_verified": true, - }) - - app := newGatingModeTestApp(provider) - resp := doGatingAuthCodeFlow(t, app, provider, redirectURI, codeVerifier) - clientID := resp["_client_id"].(string) - - t.Run("auth_code_response_includes_refresh_token", func(t *testing.T) { - t.Parallel() - require.NotEmpty(t, resp["access_token"]) - require.NotEmpty(t, resp["refresh_token"], "gating mode should return a refresh_token") - require.Equal(t, "Bearer", resp["token_type"]) - require.Greater(t, resp["expires_in"].(float64), float64(0)) - }) - - t.Run("refresh_grants_new_tokens", func(t *testing.T) { - t.Parallel() - rr := exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusOK, rr.Code) - - var refreshResp map[string]interface{} - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &refreshResp)) - require.NotEmpty(t, refreshResp["access_token"]) - require.NotEmpty(t, refreshResp["refresh_token"], "refresh response should include rotated refresh_token") - require.Equal(t, "Bearer", refreshResp["token_type"]) - require.Greater(t, refreshResp["expires_in"].(float64), float64(0)) - - // Refresh token is JWE (random IV) so always differs; access token is - // deterministic HS256 JWT so may match within the same second — only - // check the refresh token is rotated. - require.NotEqual(t, resp["refresh_token"], refreshResp["refresh_token"]) - }) - - t.Run("chained_refresh_works", func(t *testing.T) { - t.Parallel() - // First refresh - rr1 := exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusOK, rr1.Code) - var resp1 map[string]interface{} - require.NoError(t, json.Unmarshal(rr1.Body.Bytes(), &resp1)) - - // Second refresh using rotated token - rr2 := exchangeRefreshToken(t, app, clientID, resp1["refresh_token"].(string)) - require.Equal(t, http.StatusOK, rr2.Code) - var resp2 map[string]interface{} - require.NoError(t, json.Unmarshal(rr2.Body.Bytes(), &resp2)) - require.NotEmpty(t, resp2["access_token"]) - require.NotEmpty(t, resp2["refresh_token"]) - }) -} - -func TestOAuthRefreshTokenInvalidGrant(t *testing.T) { - t.Parallel() - const redirectURI = "http://127.0.0.1:3334/callback" - - provider := newTestForwardModeOIDCProvider(t, map[string]interface{}{ - "access_token": "upstream-access-token", - "token_type": "Bearer", - "expires_in": 1800, - "scope": "openid email", - }, nil) - provider.tokenResponse["id_token"] = provider.issueIDToken(t, map[string]interface{}{ - "sub": "user-1", - "iss": provider.server.URL, - "aud": "upstream-client-id", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - "email": "user@example.com", - "email_verified": true, - }) - - app := newGatingModeTestApp(provider) - resp := doGatingAuthCodeFlow(t, app, provider, redirectURI, "verifier1") - clientID := resp["_client_id"].(string) - - t.Run("wrong_client_id", func(t *testing.T) { - t.Parallel() - otherClientID := registerOAuthBrowserClient(t, app, redirectURI) - rr := exchangeRefreshToken(t, app, otherClientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusBadRequest, rr.Code) - require.Contains(t, rr.Body.String(), "not issued to this client") - }) - - t.Run("malformed_refresh_token", func(t *testing.T) { - t.Parallel() - rr := exchangeRefreshToken(t, app, clientID, "garbage-token") - require.Equal(t, http.StatusBadRequest, rr.Code) - require.Contains(t, rr.Body.String(), "invalid refresh token") - }) - - t.Run("missing_refresh_token", func(t *testing.T) { - t.Parallel() - form := url.Values{} - form.Set("grant_type", "refresh_token") - form.Set("client_id", clientID) - req := httptest.NewRequest(http.MethodPost, "https://mcp.example.com/oauth/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() - app.handleOAuthToken(rr, req) - require.Equal(t, http.StatusBadRequest, rr.Code) - require.Contains(t, rr.Body.String(), "missing refresh token") - }) - - t.Run("forward_mode_rejects_gating_refresh_token", func(t *testing.T) { - t.Parallel() - // Forward mode now supports refresh when UpstreamOfflineAccess is on, - // but a refresh token minted by gating mode is not transferable: the - // client_id encoded in the JWE belongs to the gating-mode app, not the - // forward-mode app's freshly registered client. - fwdApp := newForwardModeBrowserLoginTestApp(provider) - fwdClientID := registerOAuthBrowserClient(t, fwdApp, redirectURI) - rr := exchangeRefreshToken(t, fwdApp, fwdClientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusBadRequest, rr.Code) - require.Contains(t, rr.Body.String(), "not issued to this client") - }) -} - func TestOAuthForwardModeNoRefreshToken(t *testing.T) { t.Parallel() const ( @@ -1812,128 +1553,6 @@ func TestOAuthAuthorizeOfflineAccessScope(t *testing.T) { require.NotContains(t, scopes, "offline_access", "default forward mode must not request offline_access") }) - t.Run("gating_mode_ignores_flag", func(t *testing.T) { - t.Parallel() - provider := newTestForwardModeOIDCProvider(t, map[string]interface{}{ - "access_token": "irrelevant", - "token_type": "Bearer", - }, nil) - app := newGatingModeTestApp(provider) - // Even if the flag were set in gating mode, offline_access is forward-only. - app.config.Server.OAuth.UpstreamOfflineAccess = true - app.mcpServer.Config.Server.OAuth.UpstreamOfflineAccess = true - scopes := scopeFromRedirect(t, app) - require.NotContains(t, scopes, "offline_access", "gating mode must not request offline_access regardless of flag") - }) -} - -func TestOAuthRefreshTokenPolicyRevalidation(t *testing.T) { - t.Parallel() - const ( - redirectURI = "http://127.0.0.1:3334/callback" - codeVerifier = "test-code-verifier-policy" - ) - - setupProviderAndApp := func(t *testing.T, email string, emailVerified bool, hd string) (*testForwardModeOIDCProvider, *application, map[string]interface{}) { - t.Helper() - idTokenClaims := map[string]interface{}{ - "sub": "user-1", - "aud": "upstream-client-id", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - "email": email, - "email_verified": emailVerified, - } - if hd != "" { - idTokenClaims["hd"] = hd - } - - provider := newTestForwardModeOIDCProvider(t, map[string]interface{}{ - "access_token": "upstream-access-token", - "token_type": "Bearer", - "expires_in": 1800, - "scope": "openid email", - }, nil) - idTokenClaims["iss"] = provider.server.URL - provider.tokenResponse["id_token"] = provider.issueIDToken(t, idTokenClaims) - - app := newGatingModeTestApp(provider) - resp := doGatingAuthCodeFlow(t, app, provider, redirectURI, codeVerifier) - return provider, app, resp - } - - t.Run("refresh_rejected_when_email_domain_removed", func(t *testing.T) { - t.Parallel() - _, app, resp := setupProviderAndApp(t, "user@allowed.com", true, "") - app.config.Server.OAuth.AllowedEmailDomains = []string{"allowed.com"} - app.mcpServer.Config.Server.OAuth.AllowedEmailDomains = []string{"allowed.com"} - - // Verify refresh works before policy change - clientID := resp["_client_id"].(string) - rr := exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusOK, rr.Code) - - // Change policy to remove the allowed domain - app.config.Server.OAuth.AllowedEmailDomains = []string{"other.com"} - app.mcpServer.Config.Server.OAuth.AllowedEmailDomains = []string{"other.com"} - - rr = exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusForbidden, rr.Code) - require.Contains(t, rr.Body.String(), "access_denied") - }) - - t.Run("refresh_rejected_when_email_verification_required", func(t *testing.T) { - t.Parallel() - _, app, resp := setupProviderAndApp(t, "user@example.com", false, "") - clientID := resp["_client_id"].(string) - - // Works when not required - rr := exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusOK, rr.Code) - - // Now require email verification - app.config.Server.OAuth.RequireEmailVerified = true - app.mcpServer.Config.Server.OAuth.RequireEmailVerified = true - - rr = exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusForbidden, rr.Code) - require.Contains(t, rr.Body.String(), "access_denied") - }) - - t.Run("refresh_rejected_when_hosted_domain_removed", func(t *testing.T) { - t.Parallel() - _, app, resp := setupProviderAndApp(t, "user@corp.com", true, "corp.com") - app.config.Server.OAuth.AllowedHostedDomains = []string{"corp.com"} - app.mcpServer.Config.Server.OAuth.AllowedHostedDomains = []string{"corp.com"} - - clientID := resp["_client_id"].(string) - rr := exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusOK, rr.Code) - - // Change policy - app.config.Server.OAuth.AllowedHostedDomains = []string{"other.com"} - app.mcpServer.Config.Server.OAuth.AllowedHostedDomains = []string{"other.com"} - - rr = exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusForbidden, rr.Code) - require.Contains(t, rr.Body.String(), "access_denied") - }) - - t.Run("refresh_succeeds_when_policy_still_satisfied", func(t *testing.T) { - t.Parallel() - _, app, resp := setupProviderAndApp(t, "user@allowed.com", true, "") - app.config.Server.OAuth.AllowedEmailDomains = []string{"allowed.com"} - app.mcpServer.Config.Server.OAuth.AllowedEmailDomains = []string{"allowed.com"} - - clientID := resp["_client_id"].(string) - rr := exchangeRefreshToken(t, app, clientID, resp["refresh_token"].(string)) - require.Equal(t, http.StatusOK, rr.Code) - - var refreshResp map[string]interface{} - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &refreshResp)) - require.NotEmpty(t, refreshResp["access_token"]) - require.NotEmpty(t, refreshResp["refresh_token"]) - }) } func TestOAuthRegistrationNegative(t *testing.T) { @@ -2230,101 +1849,6 @@ func TestOAuthTokenExchangeNegative(t *testing.T) { }) } -func TestOAuthGatingFlowE2E(t *testing.T) { - t.Parallel() - provider := newTestForwardModeOIDCProvider(t, map[string]interface{}{ - "access_token": "upstream-access-token", - "token_type": "Bearer", - "expires_in": 1800, - "scope": "openid email", - }, nil) - provider.tokenResponse["id_token"] = provider.issueIDToken(t, map[string]interface{}{ - "sub": "user-1", - "iss": provider.server.URL, - "aud": "upstream-client-id", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - "email": "user@example.com", - "email_verified": true, - "name": "Test User", - }) - - app := newGatingModeTestApp(provider) - // Set PublicAuthServerURL so self-issued tokens use a stable issuer - app.config.Server.OAuth.PublicAuthServerURL = "https://mcp.example.com" - app.mcpServer.Config.Server.OAuth.PublicAuthServerURL = "https://mcp.example.com" - const redirectURI = "http://127.0.0.1:3334/callback" - const codeVerifier = "e2e-code-verifier-for-pkce-test" - - // Step 1: Discovery document - t.Run("discovery_document", func(t *testing.T) { - t.Parallel() - req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/.well-known/oauth-authorization-server", nil) - rr := httptest.NewRecorder() - app.handleOAuthAuthorizationServerMetadata(rr, req) - require.Equal(t, http.StatusOK, rr.Code) - - var meta map[string]interface{} - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &meta)) - require.NotEmpty(t, meta["token_endpoint"]) - require.NotEmpty(t, meta["authorization_endpoint"]) - require.NotEmpty(t, meta["registration_endpoint"]) - }) - - // Step 2: Register client - clientID := registerOAuthBrowserClient(t, app, redirectURI) - - // Step 3: Authorize with PKCE S256 - state := startOAuthBrowserLogin(t, app, clientID, redirectURI, "client-state-123", codeVerifier) - - // Step 4: Callback with upstream code + state - callbackReq := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/oauth/callback?code=upstream-auth-code&state="+url.QueryEscape(state), nil) - callbackRR := httptest.NewRecorder() - app.handleOAuthCallback(callbackRR, callbackReq) - require.Equal(t, http.StatusFound, callbackRR.Code) - - loc, err := url.Parse(callbackRR.Header().Get("Location")) - require.NoError(t, err) - authCode := loc.Query().Get("code") - require.NotEmpty(t, authCode) - require.Equal(t, "client-state-123", loc.Query().Get("state")) - - // Step 5: Token exchange with PKCE verifier - tokenRR := exchangeOAuthBrowserCode(t, app, clientID, authCode, redirectURI, codeVerifier) - require.Equal(t, http.StatusOK, tokenRR.Code) - - var tokenResp map[string]interface{} - require.NoError(t, json.Unmarshal(tokenRR.Body.Bytes(), &tokenResp)) - accessToken := tokenResp["access_token"].(string) - refreshToken := tokenResp["refresh_token"].(string) - require.NotEmpty(t, accessToken) - require.NotEmpty(t, refreshToken) - require.Equal(t, "Bearer", tokenResp["token_type"]) - - // Step 6: Verify access token is valid HS256 JWT - t.Run("access_token_is_valid_jwt", func(t *testing.T) { - t.Parallel() - claims, err := app.mcpServer.ValidateOAuthToken(accessToken) - require.NoError(t, err) - require.Equal(t, "user-1", claims.Subject) - require.Equal(t, "user@example.com", claims.Email) - }) - - // Step 7: Refresh token exchange - t.Run("refresh_token_exchange", func(t *testing.T) { - t.Parallel() - rr := exchangeRefreshToken(t, app, clientID, refreshToken) - require.Equal(t, http.StatusOK, rr.Code) - - var refreshResp map[string]interface{} - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &refreshResp)) - require.NotEmpty(t, refreshResp["access_token"]) - require.NotEmpty(t, refreshResp["refresh_token"]) - require.Equal(t, "Bearer", refreshResp["token_type"]) - require.NotEqual(t, refreshToken, refreshResp["refresh_token"]) - }) -} - func TestOAuthMetadataAdvertisesRefreshToken(t *testing.T) { t.Parallel() provider := newTestForwardModeOIDCProvider(t, nil, nil) @@ -2808,18 +2332,3 @@ func TestWriteOAuthTokenError(t *testing.T) { require.Equal(t, "bad thing happened", body["error_description"]) } -func TestEncodeSelfIssuedAccessToken(t *testing.T) { - t.Parallel() - secret := []byte("test-secret-key") - claims := map[string]interface{}{ - "sub": "user-123", - "iss": "test-issuer", - "exp": float64(time.Now().Add(time.Hour).Unix()), - } - token, err := encodeSelfIssuedAccessToken(secret, claims) - require.NoError(t, err) - require.NotEmpty(t, token) - // Token should have 3 parts separated by dots (JWT compact format) - parts := strings.Split(token, ".") - require.Equal(t, 3, len(parts)) -} diff --git a/docs/oauth_authorization.md b/docs/oauth_authorization.md index 3fc6a5f..5d44bde 100644 --- a/docs/oauth_authorization.md +++ b/docs/oauth_authorization.md @@ -6,36 +6,115 @@ This document explains how to configure OAuth 2.0 / OpenID Connect (OIDC) authen OAuth 2.0 authorization supports two modes: -- **`mode: forward`** — works only against ClickHouse builds with native JWT auth (Altinity Antalya 25.8+). The bearer claude.ai sends to MCP is the upstream IdP's id_token; MCP validates locally then forwards to ClickHouse, which re-validates via `token_processors`. -- **`mode: gating`** — works against any ClickHouse. The MCP server brokers OAuth with the upstream IdP and mints its own HS256 access tokens for the MCP client; ClickHouse is reached via static credentials or `cluster_secret` impersonation. +- **`mode: gating`** *(default for v1+, redefined in #109)* — MCP is a pure OAuth resource server. The upstream IdP (Auth0 Enterprise, Authentik, Keycloak, Okta) handles DCR, `/authorize`, `/token`, and refresh-token rotation natively. MCP validates AS-issued JWTs and authorizes per-tool scopes. Use when the IdP supports DCR + RFC 8707 resource indicators. +- **`mode: forward`** *(unchanged)* — MCP proxies DCR + `/authorize` + `/token` to upstream and relays upstream tokens to clients. Use when the IdP does NOT support DCR (Google direct, basic-tier Auth0). Detailed flows are in [Forward mode](#forward-mode) and [Gating mode](#gating-mode). The decision-rationale and trust-model differences are in [Choosing a mode](#choosing-a-mode) below. -## Choosing a mode +## Mode taxonomy + +| Mode | What MCP does | When to use | +|---|---|---| +| **`gating`** *(redefined in #109)* | Validates AS-issued JWTs via JWKS (RS256/ES256/EdDSA); enforces `aud` byte-equality (RFC 8707); enforces per-tool scopes (`mcp:read` / `mcp:write`); impersonates the user to ClickHouse via `cluster_secret` + `Auth.Username`. Does **not** run `/register`, `/authorize`, `/token`, `/callback`, `/consent`. | Default for v1+. Use when the upstream IdP supports DCR (Auth0 Enterprise, Authentik, Keycloak ≥ 18, Okta). | +| **`forward`** *(unchanged)* | Proxies DCR + `/authorize` + `/token` to upstream; relays upstream tokens to clients. MCP appears to be the AS to clients but isn't really. JWE-wraps the upstream refresh token for stateless rotation. | Use when the upstream IdP does NOT support DCR (Google direct, basic-tier Auth0). IdP-agnostic. | + +### Required helm values per mode + +**Gating mode** (live example: `$MCP_DEPLOY_DIR/otel/mcp-values.yaml`): + +```yaml +config: + server: + oauth: + enabled: true + mode: gating + issuer: "https://altinity.auth0.com/" + jwks_url: "https://altinity.auth0.com/.well-known/jwks.json" + audience: "https://otel-mcp.demo.altinity.cloud" # RFC 8707 byte-equal with Auth0 API identifier + required_scopes: + - mcp:read + - mcp:write + require_email_verified: true + public_urls: + - "https://otel-mcp.demo.altinity.cloud" + # signing_secret injected via MCP_OAUTH_SIGNING_SECRET env var +``` + +Fields that **must not be present** under gating (startup refuses with a clear error naming the field): `client_id`, `client_secret` / `MCP_OAUTH_CLIENT_SECRET`, `token_url`, `auth_url`, `userinfo_url`, `public_auth_server_url`, `refresh_revokes_tracking`, `callback_path`. + +**Forward mode** (live example: `$MCP_DEPLOY_DIR/antalya/mcp-values.yaml`): + +```yaml +config: + server: + oauth: + enabled: true + mode: forward + issuer: "https://altinity.auth0.com/" + auth_url: "https://altinity.auth0.com/authorize" + token_url: "https://altinity.auth0.com/oauth/token" + callback_path: "/callback" + client_id: "" + # client_secret injected via MCP_OAUTH_CLIENT_SECRET env var + public_auth_server_url: "https://antalya-mcp.demo.altinity.cloud" + public_resource_url: "https://antalya-mcp.demo.altinity.cloud" + scopes: [openid, email, profile, offline_access] + upstream_offline_access: true +``` + +### Auth0 setup checklist for gating mode + +1. **Tenant-level DCR enabled** — already done at `altinity.auth0.com`. +2. **RFC 8707 resource indicators configured** — already done at tenant level. +3. **Per-cluster Auth0 API resource** (create one per cluster, otel example already exists): + - Identifier (audience) = MCP public URL byte-equal (e.g. `https://otel-mcp.demo.altinity.cloud`) + - Signing algorithm: `RS256` + - RBAC enabled; "Add Permissions in the Access Token" enabled; token dialect: `access_token_authz` + - Scopes: `mcp:read`, `mcp:write` + - Token Expiration: 600 s (10 min — revocation-latency mitigation) + - Allow Offline Access: on + - Refresh Token Rotation: Rotating; Reuse Interval: 0 s; Absolute expiry: 30 d; Inactivity: 7 d +4. **`otel` cluster** — API resource already created (W2): resource-server id `69ff99639b974225b2bab5cd`, identifier `https://otel-mcp.demo.altinity.cloud`. + +> **Known security gap (action required before relying on gating-mode security posture):** Refresh-token rotation policy is set per-Application in Auth0. The existing per-cluster Application (`altinity-mcp-otel`, client_id `fAkf9qpOo0HBI2lA8Nc2R1fOqXdJEshx`) is currently non-rotating and non-expiring. DCR-registered clients (claude.ai, ChatGPT) inherit tenant-level DCR-template defaults at registration time, not the per-cluster Application's settings. Tenant-level DCR-template defaults must be set to enable rotation before OAuth 2.1 §4.13.2 reuse detection will actually take effect for dynamically registered clients. + +### Migration from old gating (pre-#109) + +An operator moving from the old gating (MCP-as-AS) to new gating (pure resource server) must **remove** the following helm values fields; if any are present at startup the server exits with an error naming the offending field: -If your ClickHouse can't natively validate JWTs, forward mode saves no work and adds a load-bearing dependency on the upstream JWKS — pick gating. If you specifically want CH to do per-request identity validation (stronger trust isolation) and to materialise users from JWT claims, pick forward. +- `client_id` +- `client_secret` env injection (`MCP_OAUTH_CLIENT_SECRET`) +- `token_url` +- `auth_url` +- `userinfo_url` +- `public_auth_server_url` +- `refresh_revokes_tracking` +- `callback_path` -### What both modes do identically +The canonical diff pattern is the `otel` values change committed on `feature/dcr-via-auth0` (`$MCP_DEPLOY_DIR/otel/mcp-values.yaml`). -The MCP server is, in either mode: +Fields that must be **added**: +- `audience` (RFC 8707 byte-equal with the Auth0 API identifier) +- `jwks_url` (or rely on OIDC discovery from `issuer`) +- `required_scopes: [mcp:read, mcp:write]` -1. **An OAuth Authorization Server** to the MCP client. Claude.ai, Codex, MCP Inspector and friends hit the MCP server's `/oauth/register` (DCR), `/oauth/authorize`, `/oauth/callback`, `/oauth/token`, and the well-known discovery docs. They never talk to the upstream IdP directly. -2. **An OAuth Relying Party** to the upstream IdP. The MCP server holds the IdP `client_secret`, runs the auth-code-with-PKCE dance, and handles refresh-token grants. +## Choosing a mode -Neither role can be delegated to ClickHouse. CH doesn't speak DCR, doesn't talk to a user's browser, doesn't refresh tokens. **Both modes need the upstream IdP URLs, the per-deployment IdP `client_id` / `client_secret`, and a `signing_secret`** (HKDF root for the DCR client_id JWE, refresh-token JWE wrapping the upstream refresh, and the self-issued auth-code state). +Use **gating** when the IdP supports DCR + RFC 8707 (Auth0 Enterprise, Authentik, Keycloak ≥ 18, Okta) and you don't need ClickHouse to independently validate the JWT. Use **forward** when the IdP lacks DCR support (Google direct, basic-tier Auth0) or when you specifically need ClickHouse to do per-request identity validation for stronger trust isolation. ### What's actually different -| | Gating | Forward | +| | Gating (#109+) | Forward | |---|---|---| -| Bearer the MCP client receives | MCP-minted HS256 JWT | Upstream IdP id_token (raw passthrough) | -| MCP→ClickHouse credential | Static creds, OR `cluster_secret` + `initial_user=email` | `Authorization: Bearer ` over HTTP | -| Who validates the bearer on every query | MCP server only | **ClickHouse** via `token_processors` (MCP also validates locally — see C-1 below) | +| Who runs DCR / authorize / token | Upstream IdP | MCP (proxied to upstream) | +| Bearer the MCP client receives | AS-issued JWT (RS256, 10-min TTL) | Upstream IdP id_token (raw passthrough) | +| MCP→ClickHouse credential | `cluster_secret` + `Auth.Username` = email | `Authorization: Bearer ` over HTTP | +| Who validates the bearer on every query | MCP (JWKS + RFC 8707 aud + scopes) | **ClickHouse** via `token_processors` (MCP also validates locally — see [C-1](#c-1-defense-in-depth-validation-in-forward-mode)) | | User provisioning in ClickHouse | Pre-create users (`CREATE USER alice@example.com …`) | Dynamic — `token_processors` materialises ephemeral users from JWT claims | | ClickHouse build requirement | Any | Altinity Antalya 25.8+ (or any CH with native JWT auth) | -| ClickHouse protocol | TCP or HTTP | HTTP only | -| Identity in `system.query_log` | The cluster-secret-impersonated user (or static service user) | The JWT subject directly | -| Token lifetime | MCP-controlled (independent of IdP session) | IdP-controlled (revoking the upstream session breaks the next query) | +| ClickHouse protocol | TCP (cluster_secret) or HTTP (static creds) | HTTP only | +| Identity in `system.query_log` | The cluster-secret-impersonated user | The JWT subject directly | +| Refresh-token rotation + reuse detection | Auth0 native (when DCR-template defaults are set — see security gap above) | Upstream IdP (when `upstream_offline_access: true`) | ### The trust-boundary argument for forward mode @@ -47,13 +126,13 @@ The honest summary: **forward mode is a stronger trust-isolation story when Clic ### Dynamic user provisioning -Antalya's `token_processors` reads JWT claims (`email`, `roles`, custom claims) and materialises an ephemeral CH user with the right grants on the fly. This is forward-mode-exclusive — there is no plumbing in gating mode to give ClickHouse the JWT, so ClickHouse can't react to its claims. +Antalya's `token_processors` reads JWT claims (`email`, `roles`, custom claims) and materialises an ephemeral CH user with the right grants on the fly. This is forward-mode-exclusive — gating mode never hands the JWT to ClickHouse, so ClickHouse can't react to its claims. For a multi-tenant or per-customer deployment where you don't want to manually `CREATE USER` for every new identity, this is a real operational gain. For a fixed roster of internal users, it doesn't matter. ### Token lifecycle -In gating mode the MCP-issued access token is independent of the IdP session. The IdP revokes a session → the MCP-issued token keeps working until its own `exp`. The MCP-issued refresh token also keeps working until *its* `exp`, and on refresh the MCP server re-validates the upstream identity, so revocation is detected — but at refresh boundaries, not on every query. +In gating mode the AS-issued access token has a 10-minute TTL (operator-controlled via `access_token_ttl_seconds`). The IdP revokes a session → tokens expire within 10 min. Refresh-token rotation and reuse detection are handled by Auth0, not MCP. In forward mode every query carries the upstream id_token, so revocation lands at the next query (subject to JWKS cache TTL and ClickHouse's own caching). This is a stronger "log the user out and they're out" guarantee. @@ -61,8 +140,8 @@ In forward mode every query carries the upstream id_token, so revocation lands a - ClickHouse build is anything other than Altinity Antalya 25.8+ or another build with native JWT auth. Forward sends the bearer; CH 403s every query. - You need TCP protocol to ClickHouse (forward only supports HTTP). -- You need MCP-issued tokens with custom claims that the IdP doesn't emit. Gating mints its own JWT and can shape claims; forward passes whatever the IdP gave you. -- You don't want CH fetching the upstream JWKS on every cold cache. Forward mode adds that load to CH. +- The IdP supports DCR and RFC 8707 — gating is simpler in that case. +- You don't want CH fetching the upstream JWKS on every cold cache. ### When gating mode is the wrong choice @@ -141,52 +220,58 @@ In forward mode, the bearer token is automatically forwarded to ClickHouse and s ## Gating mode -Use this when ClickHouse has no OAuth support. The MCP server itself authenticates users via the upstream IdP, mints its own tokens, and connects to ClickHouse with static credentials. +Use this when ClickHouse has no native OAuth support and the upstream IdP supports DCR + RFC 8707 resource indicators (Auth0 Enterprise, Authentik, Keycloak ≥ 18, Okta). -1. An MCP client authenticates with an Identity Provider (IdP) via browser login. -2. The MCP server validates the upstream identity (email domain, hosted domain, email verification). -3. The MCP server mints its own signed access and refresh tokens for the MCP client. -4. The MCP server connects to ClickHouse with its statically configured credentials (or `cluster_secret` impersonation — see [Cluster-secret authentication](#cluster-secret-authentication-optional)). +Under #109, MCP is a **pure OAuth resource server** in gating mode. The upstream IdP owns the entire OAuth AS surface (DCR, `/authorize`, `/token`, refresh-token rotation, reuse detection). MCP only validates and authorizes: -This mode works even when ClickHouse has no native OAuth support. +1. An MCP client (claude.ai, ChatGPT, Codex) discovers the IdP via `/.well-known/oauth-protected-resource` and completes the auth-code-with-PKCE flow directly against the IdP. MCP plays no role in that dance. +2. The client presents the AS-issued access token (RS256 JWT) on every MCP request. +3. MCP validates: JWKS signature, `iss` match, `aud` byte-equality (RFC 8707), `exp`, and per-tool required scopes (`mcp:read` / `mcp:write`). Rejects invalid tokens at 401. +4. MCP connects to ClickHouse via `cluster_secret` + `Auth.Username = email` (per-user attribution) or static credentials. ClickHouse never sees the JWT. ``` -┌────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ -│ MCP │─────>│ IdP │ │ MCP │ │ ClickHouse │ -│ Client │<─────│(Keycloak,│ │ Server │ │ │ -│ │ │ Azure AD,│ │ │ │ │ -│ │ │ Google) │ │ │ │ │ -│ │ └──────────┘ │ │ │ │ -│ │ │ │ │ │ -│ │──Browser login────────>│──Verify──>│ │ │ -│ │<─────────MCP token─────│ identity │ │ │ -│ │ │ │ │ │ -│ │──MCP token────────────>│ │ │ │ -│ │ │─Static──>│ │ │ -│ │ │ creds │─────>│ Authn via │ -│ │<───────────────────────│<─────────│<─────│ config user│ -│ │ query results │ │ │ │ -└────────┘ └──────────┘ └────────────┘ +┌────────┐ ┌────────────┐ ┌──────────┐ ┌────────────┐ +│ MCP │─────>│ Auth0 / │ │ MCP │ │ ClickHouse │ +│ Client │<─────│ Keycloak │ │ Server │ │ │ +│ │ │ (IdP/AS) │ │ │ │ │ +│ │ └────────────┘ │ │ │ │ +│ │ │ │ │ │ +│ │──AS-issued JWT──────────>│ │ │ │ +│ │ │ validate │ │ │ +│ │ │ JWKS+aud │ │ │ +│ │ │ +scopes │ │ │ +│ │ │─cluster─>│ │ verifies │ +│ │ │ secret + │─────>│ HMAC, runs│ +│ │ │ initial │ │ as email │ +│ │ │ _user │ │ │ +│ │<─────────────────────────│<─────────│<─────│ │ +│ │ query results │ │ │ │ +└────────┘ └──────────┘ └────────────┘ ``` ```yaml -clickhouse: - host: "clickhouse.example.com" - port: 9000 - protocol: tcp - username: "default" - password: "" -server: - oauth: - enabled: true - mode: "gating" - signing_secret: "CHANGE_ME_TO_A_RANDOM_SECRET" - issuer: "https://accounts.google.com" - public_auth_server_url: "https://mcp.example.com" - client_id: "" - client_secret: "" - scopes: ["openid", "email"] - allowed_email_domains: ["example.com"] +config: + clickhouse: + host: "clickhouse.example.com" + port: 9000 + protocol: tcp + cluster_name: "mcp_cluster" + cluster_secret: "CHANGE_ME_SHARED_SECRET" + username: default # fallback; real queries run as OAuth email + server: + oauth: + enabled: true + mode: gating + issuer: "https://altinity.auth0.com/" + jwks_url: "https://altinity.auth0.com/.well-known/jwks.json" + audience: "https://mcp.example.com" # RFC 8707 byte-equal with Auth0 API identifier + required_scopes: [mcp:read, mcp:write] + require_email_verified: true + public_urls: + - "https://mcp.example.com" + # signing_secret via MCP_OAUTH_SIGNING_SECRET env var + # DO NOT set: client_id, client_secret, token_url, auth_url, + # userinfo_url, public_auth_server_url, refresh_revokes_tracking ``` ### Cluster-secret authentication (optional) @@ -197,22 +282,22 @@ The **cluster-secret path** removes both limitations. altinity-mcp handshakes wi ``` ┌────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ -│ MCP │ │ IdP │ │ MCP │ │ ClickHouse │ -│ Client │ │ │ │ Server │ │ │ -│ │──login──>│ │ │ │ │ │ -│ │<─MCP tok─│ │ │ │ │ │ +│ MCP │ │ IdP/AS │ │ MCP │ │ ClickHouse │ +│ Client │ │ (Auth0) │ │ Server │ │ │ +│ │──DCR+login──────>│ │ │ │ │ +│ │<─AS JWT──────────│ │ │ │ │ │ │ │ │ │ │ -│ │──query + MCP token────>│ │ │ │ +│ │──query + AS JWT────────>│ │ │ │ │ │ │─cluster─>│ │ verifies │ │ │ │ secret + │ │ HMAC, runs│ -│ │ │ initial │─────>│ as claim. │ -│ │ │ _user = │ │ subject │ -│ │ │ claim.sub│ │ │ +│ │ │ initial │─────>│ as email │ +│ │ │ _user = │ │ claim │ +│ │ │ email │ │ │ │ │<───────────────────────│<─────────│<─────│ │ └────────┘ └──────────┘ └────────────┘ ``` -**altinity-mcp config:** +**altinity-mcp config** (abbreviated — see [Required helm values per mode](#required-helm-values-per-mode) for the full gating snippet): ```yaml clickhouse: @@ -229,9 +314,12 @@ server: oauth: enabled: true mode: gating - issuer: https://accounts.google.com - signing_secret: "CHANGE_ME_TO_A_RANDOM_SECRET" - # ... standard gating config ... + issuer: "https://altinity.auth0.com/" + jwks_url: "https://altinity.auth0.com/.well-known/jwks.json" + audience: "https://mcp.example.com" + required_scopes: [mcp:read, mcp:write] + require_email_verified: true + # signing_secret via MCP_OAUTH_SIGNING_SECRET env var ``` Or via env: `CLICKHOUSE_CLUSTER_NAME`, `CLICKHOUSE_CLUSTER_SECRET`, `CLICKHOUSE_PROTOCOL=tcp`. @@ -288,20 +376,19 @@ The literal value used for the ClickHouse username is the OAuth `email` claim wh - **ClickHouse protocol**: Forward mode requires `http`. Gating mode with static credentials works with both `http` and native `tcp`. Gating mode with cluster-secret authentication requires `tcp`. - **ClickHouse version**: Forward mode requires Altinity Antalya build 25.8+ (or any build that supports `token_processors`). Gating mode works with any ClickHouse version. - **Identity Provider**: Any OAuth 2.0 / OIDC-compliant provider (Keycloak, Azure AD, Google, AWS Cognito, etc.) -- **`signing_secret`**: Required in both modes. Protects stateless client registration, authorization codes, and (in gating mode) refresh tokens. -- **Frontend / reverse proxy**: If published behind a proxy, configure explicit `public_resource_url` and `public_auth_server_url`. See [Frontend / Reverse Proxy Requirements](#frontend--reverse-proxy-requirements). +- **`signing_secret`**: Required in both modes. Protects stateless OAuth artifacts: DCR client-id JWE, forward-mode refresh-token JWE, and HKDF-derived key material. +- **Frontend / reverse proxy**: If published behind a proxy, configure `public_resource_url` (both modes) and `public_auth_server_url` (forward mode only). See [Frontend / Reverse Proxy Requirements](#frontend--reverse-proxy-requirements). ## MCP Client Discovery Flow OAuth-capable MCP clients (e.g., Claude Desktop, Codex) discover authentication automatically: -1. Client fetches `/.well-known/oauth-protected-resource` from the MCP endpoint -2. Response points to the authorization server URL -3. Client fetches `/.well-known/oauth-authorization-server` for endpoint metadata -4. Client dynamically registers via the registration endpoint (PKCE, public client) -5. Client initiates authorization code flow with S256 PKCE -6. After login, client exchanges the code for access + refresh tokens -7. Client uses the access token for MCP requests and refreshes silently when it expires +1. Client fetches `/.well-known/oauth-protected-resource` from the MCP endpoint. +2. **Gating**: response `authorization_servers` points to the upstream IdP (e.g. `https://altinity.auth0.com/`). Client fetches `/.well-known/oauth-authorization-server` **from the IdP**. MCP's `/.well-known/oauth-authorization-server` returns 404 (route removed under #109). **Forward**: response points to MCP itself; client fetches MCP's `/.well-known/oauth-authorization-server`. +3. Client dynamically registers (DCR) with the authorization server — the IdP in gating mode, MCP in forward mode. +4. Client initiates authorization code flow with S256 PKCE. +5. After login, client exchanges the code for access + refresh tokens. +6. Client presents the access token on every MCP request and refreshes silently via the AS when it expires. ## Refresh Tokens @@ -309,11 +396,11 @@ Both modes can issue refresh tokens. The MCP refresh token is always a stateless ### Gating mode -The token endpoint returns a `refresh_token` alongside the `access_token`. Clients exchange it via `grant_type=refresh_token` to get a new access token without re-authorizing through the browser. +Refresh tokens are issued and rotated entirely by the upstream IdP (Auth0, Keycloak, etc.). MCP does not issue, rotate, or validate gating-mode refresh tokens — it never sees them. The client exchanges refresh tokens directly against the IdP's `/token` endpoint. -- **TTL**: Controlled by `refresh_token_ttl_seconds` (default: 30 days) -- **Rotation**: Each refresh returns a new refresh token (the old one remains valid until expiry) -- **Stateless**: Refresh tokens are encrypted JWE blobs with no server-side state. There is no revocation or reuse detection. +- **TTL**: Set on the Auth0 API resource (absolute: 30 d; inactivity: 7 d per the otel setup). +- **Rotation**: Rotating with reuse interval = 0 s (when the Auth0 DCR-template defaults are configured — see the security gap in the Auth0 setup checklist above). +- **Reuse detection**: RFC 6749 §10.4 / OAuth 2.1 §4.13.2 — handled by Auth0, not MCP. ### Forward mode (opt-in) @@ -331,22 +418,22 @@ Operator setup: - Configure refresh-token rotation + reuse detection at the IdP if available. This provides revocation outside MCP, since the JWE itself is stateless. - The default is `false` so existing forward-mode deployments are unaffected unless an operator opts in. Three reasons for the default: (1) turning on refresh widens the stolen-token blast radius from the upstream ID-token TTL (~1 h) to `refresh_token_ttl_seconds` (default 30 d) — operators must consciously accept that envelope; (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). -Limitations (apply to both modes): +Limitations: -- No server-side revocation of individual MCP tokens. Rotate `signing_secret` to invalidate all outstanding JWEs. -- No reuse detection for the MCP-side refresh token: a rotated-out JWE remains valid until its `exp`. In forward mode, the upstream IdP's reuse detection (if enabled) provides defense-in-depth. +- **Gating**: no MCP-side revocation; token validity is bounded by Auth0's access-token TTL (600 s). Grant revocations take effect within one TTL window. +- **Forward**: no server-side revocation of the JWE-wrapped refresh token. Rotate `signing_secret` to invalidate all outstanding JWEs. The upstream IdP's reuse detection (if enabled) provides defense-in-depth when `upstream_offline_access: true`. -## Identity Policy (Gating Mode) +## Identity Policy -Gating mode can restrict access based on verified identity claims from the upstream IdP: +MCP can restrict access based on identity claims extracted from the validated JWT on every request. These checks apply to both modes; in gating mode they are MCP's primary admission gate (the IdP is still the authority for authentication, but MCP enforces the domain/email policy at authorization time). | Option | Description | |--------|-------------| -| `allowed_email_domains` | Only allow users with email addresses in these domains (e.g., `["example.com"]`) | -| `allowed_hosted_domains` | Only allow users from these Google Workspace / hosted domains (checks the `hd` claim) | -| `require_email_verified` | Reject users whose `email_verified` claim is false | +| `allowed_email_domains` | Only allow principals with an `email` claim in these domains (e.g., `["example.com"]`) | +| `allowed_hosted_domains` | Only allow principals with an `hd` (hosted/workspace domain) claim in this set — Google Workspace / Auth0 organization | +| `require_email_verified` | Reject principals whose `email_verified` claim is `false` or absent. **Required when using `cluster_secret` impersonation** (H-1 safety rule: gating + cluster_secret refuses to start without this set). | -These checks run on every token mint and refresh. Identity claims come from the upstream IdP's signed id_token or userinfo response and cannot be forged by the client. +Claims come from the AS-issued JWT (gating) or the upstream id_token (forward) and cannot be forged by the client. ```yaml server: @@ -365,9 +452,13 @@ server: enabled: false # OAuth operating mode: - # - "forward": pass bearer tokens through to ClickHouse for validation - # - "gating": validate upstream identity and mint local MCP tokens - mode: "forward" + # - "gating": pure resource server — validate AS-issued JWTs (JWKS + RFC 8707 aud + scopes). + # Upstream IdP handles DCR/authorize/token. Requires issuer + audience. + # Forbidden fields: client_id, client_secret, token_url, auth_url, + # userinfo_url, public_auth_server_url, refresh_revokes_tracking. + # - "forward": MCP proxies DCR + authorize + token to upstream; relays upstream tokens. + # Requires client_id, client_secret, auth_url, token_url. + mode: "gating" # Symmetric secret for stateless OAuth artifacts (client registration, # authorization codes, refresh tokens). Required whenever OAuth is enabled. @@ -382,16 +473,16 @@ server: # Expected audience claim in incoming tokens audience: "" - # Upstream OAuth client credentials (for browser-login facade) + # Forward mode only: upstream OAuth client credentials and endpoint URLs. + # FORBIDDEN in gating mode — startup refuses if any of these are set. client_id: "" client_secret: "" - - # Upstream OAuth endpoint URLs (discovered from issuer if empty) token_url: "" auth_url: "" userinfo_url: "" + public_auth_server_url: "" - # OAuth scopes to request from upstream IdP + # Forward mode only: OAuth scopes to request from upstream IdP scopes: ["openid", "profile", "email"] # Forward mode: opt into requesting offline_access upstream and issuing @@ -399,35 +490,34 @@ server: # Tokens / Forward mode (opt-in)" for trust model and operator setup. upstream_offline_access: false - # Scopes required in incoming tokens (gating mode only) + # Gating mode: scopes required in every incoming AS-issued JWT required_scopes: [] - # Allowed upstream IdP issuers for identity tokens during callback exchange + # Forward mode: allowed upstream IdP issuers for identity tokens upstream_issuer_allowlist: [] - # Identity policy (gating mode) + # Identity policy — applies to both modes (claims from JWT) allowed_email_domains: [] allowed_hosted_domains: [] require_email_verified: false - # Token lifetimes (auth code TTL is hardcoded to 300s per RFC 6749) - access_token_ttl_seconds: 3600 # 1 hour - refresh_token_ttl_seconds: 2592000 # 30 days (gating mode only) + # Token lifetimes + access_token_ttl_seconds: 3600 # 1 hour (gating: reduce to 600 for revocation latency) + refresh_token_ttl_seconds: 2592000 # 30 days (forward mode only — gating refresh tokens are IdP-managed) - # Header name for forwarding. Default "Authorization" sends "Bearer {token}". + # Header name for forwarding (forward mode). Default "Authorization" sends "Bearer {token}". # Set to a custom name to send the raw token without "Bearer " prefix. clickhouse_header_name: "" - # Map token claims to ClickHouse HTTP headers (gating mode with claims) + # Map token claims to ClickHouse HTTP headers (forward mode with claims) claims_to_headers: sub: "X-ClickHouse-User" email: "X-ClickHouse-Email" - # Externally visible URLs (required behind a reverse proxy) + # Externally visible MCP endpoint URL. Required behind a reverse proxy (both modes). public_resource_url: "" - public_auth_server_url: "" - # Endpoint paths (defaults shown; override for custom proxy layouts). + # Forward mode only: endpoint paths (defaults shown; override for custom proxy layouts). # The .well-known metadata paths are spec-fixed and not configurable. registration_path: "/register" authorization_path: "/authorize" @@ -439,23 +529,25 @@ server: | Option | Description | |--------|-------------| -| `mode` | `forward` passes tokens to ClickHouse for validation; `gating` validates upstream identity and mints local tokens | +| `mode` | `gating` validates AS-issued JWTs (JWKS + RFC 8707 aud + scopes); `forward` proxies DCR/authorize/token to upstream and relays tokens to ClickHouse | | `signing_secret` | Symmetric secret for all stateless OAuth artifacts. **Required** whenever OAuth is enabled | | `issuer` | Upstream IdP issuer URL for OIDC discovery and token validation | | `public_resource_url` | Externally visible MCP endpoint URL. **Required** behind a reverse proxy | -| `public_auth_server_url` | Externally visible OAuth authorization server URL. **Required** behind a reverse proxy | -| `refresh_token_ttl_seconds` | Lifetime of stateless refresh tokens (default 30 days). Applies to gating mode and to forward mode when `upstream_offline_access` is on | +| `public_auth_server_url` | Externally visible OAuth authorization server URL. **Forward mode only** — required behind a reverse proxy. Forbidden in gating mode. | +| `refresh_token_ttl_seconds` | Lifetime of JWE-wrapped refresh tokens (default 30 days). Applies to forward mode when `upstream_offline_access` is on. Not applicable to gating mode (refresh tokens are IdP-managed). | | `upstream_offline_access` | Forward mode only: request `offline_access` upstream and issue JWE-wrapped refresh tokens to MCP clients. Default `false` | ## Frontend / Reverse Proxy Requirements For direct bearer-token use, a plain reverse proxy is usually enough. -For browser-based MCP login, the frontend must expose two public URL spaces: +For browser-based MCP login in **forward mode**, the frontend must expose two public URL spaces: - the protected resource, for example `https://PUBLIC_HOST.example.com/` - the OAuth authorization server, for example `https://PUBLIC_HOST.example.com/oauth` +In **gating mode**, only the protected resource URL needs to be proxied. The authorization server is the upstream IdP and is not proxied through MCP. + The proxy must: - Forward `Host` and `Authorization` headers unchanged @@ -829,7 +921,7 @@ Example values files are provided for each provider: - **`signing_secret`** protects all stateless OAuth artifacts (client registrations, authorization codes, refresh tokens). Treat it like a signing key. Rotate it to invalidate all outstanding registrations and tokens. - **Forward mode does not validate tokens locally.** It checks only that a bearer token is present, then forwards it to ClickHouse. Token validation is ClickHouse's responsibility via `token_processors`. -- **Gating-mode refresh tokens are stateless.** There is no server-side state, so individual tokens cannot be revoked. The only way to invalidate all tokens is to rotate `signing_secret`. Use `refresh_token_ttl_seconds` to limit exposure. +- **Gating-mode tokens are AS-issued JWTs.** MCP does not mint or revoke them. Revocation propagates to MCP within one access-token TTL window (default 600 s). Refresh-token revocation is handled entirely by the upstream IdP. - **Opaque bearer tokens are not supported.** Inbound OAuth validation on MCP/OpenAPI endpoints requires a signed JWT that can be validated via JWKS. The `userinfo` endpoint is used only during browser-login identity lookup, not for runtime token validation. - **Token preference during browser login.** When both `id_token` and `access_token` are returned by the upstream provider, `altinity-mcp` prefers `id_token` as the MCP bearer token and falls back to `access_token` only when no `id_token` is available. @@ -845,7 +937,7 @@ Ensure the `issuer` in your MCP config matches exactly what your IdP puts in the - Trailing slash mismatch (`https://accounts.google.com` vs `https://accounts.google.com/`) - Missing `/v2.0` suffix for Azure AD -In gating mode, also ensure `public_auth_server_url` is set when `issuer` is configured. The server mints tokens with `public_auth_server_url` as the issuer but validates against `issuer` if `public_auth_server_url` is empty. +In gating mode, `issuer` must exactly match the `iss` claim in the AS-issued JWT. `public_auth_server_url` is a **forward-mode-only** field and must not be set under gating (startup refuses). ### ClickHouse authenticates but user has no permissions diff --git a/pkg/server/server_auth_oauth.go b/pkg/server/server_auth_oauth.go index 38eea22..64aac88 100644 --- a/pkg/server/server_auth_oauth.go +++ b/pkg/server/server_auth_oauth.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/altinity/altinity-mcp/pkg/jwe_auth" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" "github.com/rs/zerolog/log" @@ -103,14 +102,14 @@ func (s *ClickHouseJWEServer) oauthRequiresLocalValidation() bool { // ValidateOAuthToken validates an OAuth bearer and returns claims. // -// Gating mode: the bearer is the self-issued HS256 JWT we minted on /token — -// verify the HMAC and our claims policy. +// Both modes route through the JWKS-based external-JWT validator: under +// gating, MCP is a pure resource server and the bearer is an upstream IdP +// (Auth0) access token; under forward, MCP proxies the upstream IdP token to +// the client unchanged. In both cases local validation is signature + iss + +// aud + exp against the configured JWKS. // -// Forward mode: the bearer is the upstream IdP token. When it looks like a -// JWT and the operator has configured a JWKS source (Issuer or JWKSURL), -// validate signature + iss + aud + exp via the upstream JWKS. Two cases -// soft-pass (return nil claims, nil error) — the auth layer accepts the -// request and forwards to ClickHouse, which is then the sole validator: +// Two cases soft-pass (return nil claims, nil error) — the auth layer accepts +// the request and forwards to ClickHouse, which is then the sole validator: // // 1. Opaque (non-JWT) bearers — RFC 7662 introspection is not implemented; // local validation isn't possible. @@ -131,28 +130,15 @@ func (s *ClickHouseJWEServer) ValidateOAuthToken(token string) (*OAuthClaims, er } mode := s.Config.Server.OAuth.NormalizedMode() - var ( - claims *OAuthClaims - err error - ) - if mode == "forward" { - if !looksLikeJWT(token) { - // Opaque bearer — defer to ClickHouse for validation. Logged at - // debug only because this is the steady-state path for IdPs that - // issue opaque tokens. - log.Debug().Msg("Forward-mode bearer is opaque (not a JWT); skipping local validation, deferring to ClickHouse") - return nil, nil - } - if strings.TrimSpace(s.Config.Server.OAuth.JWKSURL) == "" && strings.TrimSpace(s.Config.Server.OAuth.Issuer) == "" { - // JWT but no JWKS source configured. Hot path; debug-only here — - // the per-startup warning surfaces this once via warnOAuthMisconfiguration. - log.Debug().Msg("Forward-mode JWT received but neither oauth_issuer nor jwks_url is configured; skipping local validation") - return nil, nil - } - claims, err = s.parseAndVerifyOAuthToken(token, s.Config.Server.OAuth.Audience) - } else { - claims, err = s.parseAndVerifySelfIssuedOAuthToken(token) + if !looksLikeJWT(token) { + log.Debug().Str("mode", mode).Msg("Bearer is opaque (not a JWT); skipping local validation, deferring to ClickHouse") + return nil, nil + } + if strings.TrimSpace(s.Config.Server.OAuth.JWKSURL) == "" && strings.TrimSpace(s.Config.Server.OAuth.Issuer) == "" { + log.Debug().Str("mode", mode).Msg("JWT received but neither oauth_issuer nor jwks_url is configured; skipping local validation") + return nil, nil } + claims, err := s.parseAndVerifyOAuthToken(token, s.Config.Server.OAuth.Audience) if err != nil { log.Error().Err(err).Str("mode", mode).Msg("Failed to validate OAuth token") return nil, err @@ -162,29 +148,12 @@ func (s *ClickHouseJWEServer) ValidateOAuthToken(token string) (*OAuthClaims, er } func (s *ClickHouseJWEServer) validateOAuthClaims(claims *OAuthClaims) (*OAuthClaims, error) { - // Issuer enforcement is mode-specific: - // - // - Gating mode: the issuer MUST be us. Prefer PublicAuthServerURL when - // configured (deployment-specific public URL), otherwise the operator's - // `Issuer` field. Compare slash-normalised — operator config may or - // may not include the slash; advertised issuer uses no-slash form. - // - // - Forward mode: parseAndVerifyExternalJWT (the only path that reaches - // here in forward mode) already enforced issuerAllowed against - // UpstreamIssuerAllowlist (preferred) or the singular `Issuer`. We - // do not re-validate here, because the singular `Issuer` may not be - // authoritative when an allowlist is configured. - if s.Config.Server.OAuth.IsGatingMode() { - expectedIssuer := strings.TrimSpace(s.Config.Server.OAuth.PublicAuthServerURL) - if expectedIssuer == "" { - expectedIssuer = strings.TrimSpace(s.Config.Server.OAuth.Issuer) - } - if expectedIssuer != "" && - strings.TrimRight(claims.Issuer, "/") != strings.TrimRight(expectedIssuer, "/") { - log.Error().Str("expected", expectedIssuer).Str("got", claims.Issuer).Msg("OAuth token issuer mismatch") - return nil, ErrInvalidOAuthToken - } - } + // Issuer enforcement happens upstream in parseAndVerifyExternalJWT, which + // is the only path that reaches here. It already validates `iss` against + // UpstreamIssuerAllowlist (preferred) or the singular `Issuer` config — + // re-validating here would duplicate the check and incorrectly reject + // tokens issued under a multi-issuer allowlist (where the singular + // `Issuer` field is not authoritative). // Validate audience if configured. Compare slash-normalised — the token's // `aud` claim is whatever string the client passed in `resource` at @@ -412,50 +381,6 @@ func issuerAllowed(got string, allowlist []string, singleIssuer string) bool { return true } -// SelfIssuedAccessTokenKid is the `kid` header value that selects the -// HKDF-derived HS256 signing key for self-issued OAuth access tokens. Tokens -// without a kid header are accepted with the legacy SHA256(secret) key for -// the duration of the rotation window. -const SelfIssuedAccessTokenKid = "v1" - -// SelfIssuedAccessTokenHKDFInfo is the HKDF info label that mints the HS256 -// signing key for self-issued access tokens. Imported by the cmd-side minter -// (cmd/altinity-mcp encodeSelfIssuedAccessToken) so both sides share one -// source of truth. -const SelfIssuedAccessTokenHKDFInfo = "altinity-mcp/oauth/access-token/v1" - -func (s *ClickHouseJWEServer) parseAndVerifySelfIssuedOAuthToken(token string) (*OAuthClaims, error) { - secret := strings.TrimSpace(s.Config.Server.OAuth.SigningSecret) - if secret == "" { - return nil, fmt.Errorf("oauth signing_secret is required in gating mode") - } - - parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) - if err != nil { - return nil, fmt.Errorf("failed to parse self-issued JWT: %w", err) - } - if len(parsed.Headers) == 0 { - return nil, fmt.Errorf("missing JWT header") - } - - // kid="v1" → HKDF-derived key (current). Absent kid → SHA256(secret) - // (legacy, accepted during the post-rotation window for tokens minted - // before the kid cutover; remove the fallback once all in-flight refresh - // tokens have expired). - var key []byte - if parsed.Headers[0].KeyID == SelfIssuedAccessTokenKid { - key = jwe_auth.DeriveKey([]byte(secret), SelfIssuedAccessTokenHKDFInfo) - } else { - key = jwe_auth.HashSHA256([]byte(secret)) - } - - var rawClaims map[string]interface{} - if err := parsed.Claims(key, &rawClaims); err != nil { - return nil, fmt.Errorf("failed to verify self-issued JWT: %w", err) - } - return oauthClaimsFromRawClaims(rawClaims), nil -} - func (s *ClickHouseJWEServer) ValidateUpstreamIdentityToken(token string, expectedAudience string) (*OAuthClaims, error) { claims, err := s.parseAndVerifyExternalJWT(token, expectedAudience) if err != nil { diff --git a/pkg/server/server_auth_oauth_test.go b/pkg/server/server_auth_oauth_test.go index baa59a8..c9eb14b 100644 --- a/pkg/server/server_auth_oauth_test.go +++ b/pkg/server/server_auth_oauth_test.go @@ -2088,106 +2088,6 @@ func TestParseAndVerifyExternalJWTUnknownKid(t *testing.T) { require.Contains(t, err.Error(), "no JWK found for kid") } -func TestValidateOAuthClaimsTemporalEdgeCases(t *testing.T) { - t.Parallel() - const gatingSecret = "test-gating-secret-32-byte-key!!" - now := time.Now().Unix() - - baseClaims := func() map[string]interface{} { - return map[string]interface{}{ - "sub": "user-1", - "iss": "https://mcp.example.com", - "aud": "https://mcp.example.com", - "email": "user@example.com", - } - } - - newSrv := func() *ClickHouseJWEServer { - return NewClickHouseMCPServer(config.Config{ - Server: config.ServerConfig{ - OAuth: config.OAuthConfig{ - Enabled: true, - Mode: "gating", - SigningSecret: gatingSecret, - }, - }, - }, "test") - } - - // NOTE: subtests are NOT parallel — they share a `now` timestamp and are timing-sensitive - t.Run("expired_token", func(t *testing.T) { - c := baseClaims() - c["exp"] = now - 120 - c["iat"] = now - 300 - token := mintSelfIssuedToken(t, gatingSecret, c) - srv := newSrv() - _, err := srv.ValidateOAuthToken(token) - require.ErrorIs(t, err, ErrOAuthTokenExpired) - }) - - t.Run("expired_within_clock_skew", func(t *testing.T) { - c := baseClaims() - c["exp"] = now - 30 - c["iat"] = now - 300 - token := mintSelfIssuedToken(t, gatingSecret, c) - srv := newSrv() - _, err := srv.ValidateOAuthToken(token) - require.NoError(t, err) - }) - - t.Run("expired_beyond_clock_skew", func(t *testing.T) { - c := baseClaims() - c["exp"] = now - 61 - c["iat"] = now - 300 - token := mintSelfIssuedToken(t, gatingSecret, c) - srv := newSrv() - _, err := srv.ValidateOAuthToken(token) - require.ErrorIs(t, err, ErrOAuthTokenExpired) - }) - - t.Run("future_nbf_within_skew", func(t *testing.T) { - c := baseClaims() - c["exp"] = now + 3600 - c["iat"] = now - c["nbf"] = now + 30 - token := mintSelfIssuedToken(t, gatingSecret, c) - srv := newSrv() - _, err := srv.ValidateOAuthToken(token) - require.NoError(t, err) - }) - - t.Run("future_nbf_beyond_skew", func(t *testing.T) { - c := baseClaims() - c["exp"] = now + 3600 - c["iat"] = now - c["nbf"] = now + 120 - token := mintSelfIssuedToken(t, gatingSecret, c) - srv := newSrv() - _, err := srv.ValidateOAuthToken(token) - require.ErrorIs(t, err, ErrInvalidOAuthToken) - }) - - t.Run("future_iat_within_skew", func(t *testing.T) { - c := baseClaims() - c["exp"] = now + 3600 - c["iat"] = now + 30 - token := mintSelfIssuedToken(t, gatingSecret, c) - srv := newSrv() - _, err := srv.ValidateOAuthToken(token) - require.NoError(t, err) - }) - - t.Run("future_iat_beyond_skew", func(t *testing.T) { - c := baseClaims() - c["exp"] = now + 3600 - c["iat"] = now + 120 - token := mintSelfIssuedToken(t, gatingSecret, c) - srv := newSrv() - _, err := srv.ValidateOAuthToken(token) - require.ErrorIs(t, err, ErrInvalidOAuthToken) - }) -} - func TestGatingModeIdentityPolicy(t *testing.T) { t.Parallel() const gatingSecret = "test-gating-secret-32-byte-key!!" @@ -2496,15 +2396,6 @@ func TestLooksLikeJWT(t *testing.T) { func TestValidateOAuthClaims(t *testing.T) { t.Parallel() - t.Run("issuer_mismatch", func(t *testing.T) { - t.Parallel() - s := &ClickHouseJWEServer{Config: config.Config{Server: config.ServerConfig{OAuth: config.OAuthConfig{ - Issuer: "https://expected.example.com", - }}}} - _, err := s.validateOAuthClaims(&OAuthClaims{Issuer: "https://wrong.example.com"}) - require.ErrorIs(t, err, ErrInvalidOAuthToken) - }) - t.Run("audience_missing_when_required", func(t *testing.T) { t.Parallel() s := &ClickHouseJWEServer{Config: config.Config{Server: config.ServerConfig{OAuth: config.OAuthConfig{ @@ -2594,40 +2485,6 @@ func TestValidateOAuthClaims(t *testing.T) { require.Equal(t, "https://issuer.example.com", claims.Issuer) }) - t.Run("gating_mode_uses_public_auth_server_url_as_issuer", func(t *testing.T) { - t.Parallel() - s := &ClickHouseJWEServer{Config: config.Config{Server: config.ServerConfig{OAuth: config.OAuthConfig{ - Mode: "gating", - Issuer: "https://original-issuer.com", - PublicAuthServerURL: "https://public-auth.com", - }}}} - _, err := s.validateOAuthClaims(&OAuthClaims{Issuer: "https://public-auth.com"}) - require.NoError(t, err) - }) -} - -func TestParseAndVerifySelfIssuedOAuthToken(t *testing.T) { - t.Parallel() - - t.Run("missing_secret", func(t *testing.T) { - t.Parallel() - s := &ClickHouseJWEServer{Config: config.Config{Server: config.ServerConfig{OAuth: config.OAuthConfig{ - SigningSecret: "", - }}}} - _, err := s.parseAndVerifySelfIssuedOAuthToken("some.jwt.token") - require.Error(t, err) - require.Contains(t, err.Error(), "signing_secret is required") - }) - - t.Run("invalid_jwt_format", func(t *testing.T) { - t.Parallel() - s := &ClickHouseJWEServer{Config: config.Config{Server: config.ServerConfig{OAuth: config.OAuthConfig{ - SigningSecret: "my-secret", - }}}} - _, err := s.parseAndVerifySelfIssuedOAuthToken("not-a-jwt") - require.Error(t, err) - require.Contains(t, err.Error(), "failed to parse self-issued JWT") - }) } func TestHasRequiredScopes(t *testing.T) { diff --git a/pkg/server/server_client.go b/pkg/server/server_client.go index d993188..faf6c96 100644 --- a/pkg/server/server_client.go +++ b/pkg/server/server_client.go @@ -299,13 +299,26 @@ func (s *ClickHouseJWEServer) GetClickHouseClientWithOAuth(ctx context.Context, // impersonate. When OAuth is enabled, prefer the authenticated user's // email so `system.query_log` attributes the query to a human-readable // identity that matches how operators typically provision ClickHouse - // users. Fall back to `sub` for IdPs that don't emit an email claim. + // users. + // + // Auth0 enhanced-security third-party (DCR) tokens strip the OIDC `email` + // claim from access tokens. Operators work around this with a post-login + // Action that re-adds email under a namespaced URL claim (Auth0 only + // allows non-standard claims when they're URL-prefixed for third-party + // clients). We accept either the standard `email` claim or any namespaced + // `*/email` claim from the Extra map. Fall back to `sub` for IdPs that + // don't emit any email claim. if chConfig.ClusterSecret != "" && oauthClaims != nil { - switch { - case strings.TrimSpace(oauthClaims.Email) != "": - chConfig.Username = strings.TrimSpace(oauthClaims.Email) - case strings.TrimSpace(oauthClaims.Subject) != "": - chConfig.Username = strings.TrimSpace(oauthClaims.Subject) + var impersonateAs string + if e := strings.TrimSpace(oauthClaims.Email); e != "" { + impersonateAs = e + } else if e := emailFromNamespacedExtra(oauthClaims.Extra); e != "" { + impersonateAs = e + } else if s := strings.TrimSpace(oauthClaims.Subject); s != "" { + impersonateAs = s + } + if impersonateAs != "" { + chConfig.Username = impersonateAs } chConfig.Password = "" } @@ -323,3 +336,23 @@ func (s *ClickHouseJWEServer) GetClickHouseClientWithOAuth(ctx context.Context, return client, nil } + +// emailFromNamespacedExtra returns the first string-valued claim whose key +// ends with `/email` from the JWT's non-standard claim map. Auth0 third-party +// (DCR) tokens in enhanced security mode silently drop non-namespaced custom +// claims, forcing operators to set email under a URL-prefixed key (e.g. +// `https://mcp.altinity.cloud/email`). Looking up by suffix lets MCP accept +// any namespace the operator chose. +func emailFromNamespacedExtra(extra map[string]interface{}) string { + for k, v := range extra { + if !strings.HasSuffix(k, "/email") { + continue + } + if s, ok := v.(string); ok { + if t := strings.TrimSpace(s); t != "" { + return t + } + } + } + return "" +}