package auth import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/golang-jwt/jwt/v5" ) func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T) { const pkceVerifier = "test-pkce-verifier-with-at-least-43-characters-1234" var issuer string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/.well-known/openid-configuration": _ = json.NewEncoder(w).Encode(map[string]string{ "issuer": issuer, "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", "revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout", }) case "/token": body, _ := io.ReadAll(r.Body) values, _ := url.ParseQuery(string(body)) if values.Get("client_secret") != "" || strings.Contains(r.Header.Get("Authorization"), "Basic") { t.Error("public client token request must not contain client credentials") http.Error(w, "invalid client authentication", http.StatusBadRequest) return } if values.Get("grant_type") != "authorization_code" || values.Get("client_id") != "gateway-public" || values.Get("code_verifier") != pkceVerifier { t.Error("token request omitted authorization code, public client id, or PKCE verifier") http.Error(w, "invalid token request", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "access-token", "refresh_token": "refresh-token", "id_token": "id-token", "expires_in": 300, "token_type": "Bearer", }) 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/gateway-api/api/v1/auth/oidc/callback", PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "profile", "gateway.access"}, HTTPClient: server.Client(), }) if err != nil { t.Fatal(err) } authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier) if err != nil { t.Fatal(err) } parsed, _ := url.Parse(authorizationURL) query := parsed.Query() digest := sha256.Sum256([]byte(pkceVerifier)) expectedChallenge := base64.RawURLEncoding.EncodeToString(digest[:]) if query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != expectedChallenge { t.Fatalf("authorization request is not PKCE S256: %v", query) } if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil { t.Fatal(err) } } func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) { _, err := NewOIDCPublicClient(OIDCPublicClientConfig{ Issuer: "https://auth.example.com", ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback", PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "gateway.access", "offline_access"}, }) if err == nil || !strings.Contains(err.Error(), "offline_access") { t.Fatalf("NewOIDCPublicClient() error = %v, want offline_access rejection", err) } } func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } var issuer string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/.well-known/openid-configuration": _ = json.NewEncoder(w).Encode(map[string]any{ "issuer": issuer, "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", "id_token_signing_alg_values_supported": []string{"RS256", "ES256"}, }) case "/jwks": _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}}) default: http.NotFound(w, r) } })) defer server.Close() issuer = server.URL client, err := NewOIDCPublicClient(OIDCPublicClientConfig{ Issuer: issuer, ClientID: "gateway-public-client", RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(), }) if err != nil { t.Fatal(err) } idToken := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { claims["aud"] = "gateway-public-client" claims["nonce"] = "expected-nonce" }) subject, err := client.VerifyIDToken(context.Background(), idToken, "expected-nonce") if err != nil || subject != "platform-subject" { t.Fatalf("VerifyIDToken() subject=%q err=%v", subject, err) } 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) { var issuer string requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/.well-known/openid-configuration": _ = json.NewEncoder(w).Encode(map[string]string{ "issuer": issuer, "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", "revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout", }) case "/token", "/revoke": requests++ body, _ := io.ReadAll(r.Body) values, _ := url.ParseQuery(string(body)) if values.Get("client_secret") != "" || r.Header.Get("Authorization") != "" { t.Error("public client request contained client authentication") http.Error(w, "invalid client authentication", http.StatusBadRequest) return } if r.URL.Path == "/token" { if values.Get("grant_type") != "refresh_token" || values.Get("refresh_token") != "old-refresh" { t.Error("refresh request omitted grant type or refresh token") http.Error(w, "invalid refresh request", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 300}) return } w.WriteHeader(http.StatusOK) 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) } if _, err := client.Refresh(context.Background(), "old-refresh"); err != nil { t.Fatal(err) } if err := client.RevokeRefreshToken(context.Background(), "new-refresh"); err != nil { t.Fatal(err) } if requests != 2 { 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) } }