diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index cba4453..a2f484b 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -2,13 +2,11 @@ package auth import ( "context" - "encoding/json" "errors" "fmt" "io" "net/http" "net/url" - "strconv" "strings" "sync" @@ -38,12 +36,12 @@ type OIDCTokenResponse struct { } type OIDCPublicClient struct { - config OIDCPublicClientConfig - client *http.Client - mu sync.Mutex - oauth2Config *oauth2.Config - metadata oidcClientDiscovery - idVerifier *oidc.IDTokenVerifier + config OIDCPublicClientConfig + client *http.Client + mu sync.Mutex + oauth2Config *oauth2.Config + metadata oidcClientDiscovery + idTokenVerifier *oidc.IDTokenVerifier } type oidcClientDiscovery struct { @@ -122,7 +120,7 @@ func (c *OIDCPublicClient) VerifyIDToken(ctx context.Context, raw, expectedNonce return "", oidcUnauthorized("ID token provider discovery failed", err) } c.mu.Lock() - verifier := c.idVerifier + verifier := c.idTokenVerifier c.mu.Unlock() if verifier == nil { return "", oidcUnauthorized("ID token verifier is unavailable", nil) @@ -230,7 +228,7 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o Endpoint: endpoint, Scopes: append([]string(nil), c.config.Scopes...), } verifierContext := oidc.ClientContext(context.Background(), c.client) - idVerifier := provider.VerifierContext(verifierContext, &oidc.Config{ + idTokenVerifier := provider.VerifierContext(verifierContext, &oidc.Config{ ClientID: c.config.ClientID, SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256}, }) c.mu.Lock() @@ -238,7 +236,7 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o if c.oauth2Config == nil { c.oauth2Config = config c.metadata = metadata - c.idVerifier = idVerifier + c.idTokenVerifier = idTokenVerifier } return c.oauth2Config, c.metadata, nil } @@ -267,29 +265,10 @@ func oidcTokenResponse(token *oauth2.Token) (OIDCTokenResponse, error) { idToken, _ := token.Extra("id_token").(string) return OIDCTokenResponse{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: idToken, - TokenType: token.TokenType, ExpiresIn: integerTokenExtra(token.Extra("expires_in")), + TokenType: token.TokenType, ExpiresIn: int(token.ExpiresIn), }, nil } -func integerTokenExtra(value any) int { - switch typed := value.(type) { - case int: - return typed - case int64: - return int(typed) - case float64: - return int(typed) - case json.Number: - result, _ := strconv.Atoi(typed.String()) - return result - case string: - result, _ := strconv.Atoi(typed) - return result - default: - return 0 - } -} - func validPKCEVerifier(value string) bool { if len(value) < 43 || len(value) > 128 { return false diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index d5393be..e01e426 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -132,6 +133,24 @@ func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) { if _, err := client.VerifyIDToken(context.Background(), idToken, "wrong-nonce"); err == nil { t.Fatal("ID token with mismatched nonce was accepted") } + for _, test := range []struct { + name string + mutate func(jwt.MapClaims) + }{ + {name: "wrong audience", mutate: func(claims jwt.MapClaims) { claims["aud"] = "other-client" }}, + {name: "missing nbf", mutate: func(claims jwt.MapClaims) { delete(claims, "nbf") }}, + } { + t.Run(test.name, func(t *testing.T) { + invalid := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { + claims["aud"] = "gateway-public-client" + claims["nonce"] = "expected-nonce" + test.mutate(claims) + }) + if _, err := client.VerifyIDToken(context.Background(), invalid, "expected-nonce"); err == nil { + t.Fatal("invalid ID token was accepted") + } + }) + } } func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) { @@ -189,3 +208,37 @@ func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) { t.Fatalf("requests = %d, want 2", requests) } } + +func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testing.T) { + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": issuer, "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", + }) + case "/token": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"sensitive-provider-detail"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + issuer = server.URL + + client, err := NewOIDCPublicClient(OIDCPublicClientConfig{ + Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback", + PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + _, err = client.Refresh(context.Background(), "redacted-refresh-token") + if !errors.Is(err, ErrOIDCInvalidGrant) || strings.Contains(err.Error(), "sensitive-provider-detail") { + t.Fatalf("invalid_grant mapping was not stable and redacted: %v", err) + } +}