package identity import ( "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "math/big" "net/http" "net/http/httptest" "testing" "time" ) func TestVerifyIDTokenValidatesOIDCSecurityClaims(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } kid := "test-key" jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ "kid": kid, "kty": "RSA", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), }}}) })) defer jwks.Close() now := time.Unix(1_800_000_000, 0) service := &Service{ allowPrivateIdentityProvider: true, oidcClient: newOIDCHTTPClient(true), now: func() time.Time { return now }, } discovery := oidcDiscovery{Issuer: "https://issuer.example", JWKSURI: jwks.URL} baseClaims := map[string]any{ "iss": discovery.Issuer, "sub": "subject-1", "aud": "client-1", "exp": now.Add(time.Minute).Unix(), "iat": now.Unix(), "nonce": "nonce-1", } if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, baseClaims)); err != nil { t.Fatalf("valid ID token rejected: %v", err) } tests := []struct { name string change func(map[string]any) }{ {name: "wrong issuer", change: func(c map[string]any) { c["iss"] = "https://attacker.example" }}, {name: "wrong nonce", change: func(c map[string]any) { c["nonce"] = "other" }}, {name: "expired", change: func(c map[string]any) { c["exp"] = now.Add(-time.Minute).Unix() }}, {name: "issued in future", change: func(c map[string]any) { c["iat"] = now.Add(time.Minute).Unix() }}, {name: "missing subject", change: func(c map[string]any) { delete(c, "sub") }}, {name: "wrong audience", change: func(c map[string]any) { c["aud"] = "other-client" }}, {name: "multiple audiences without azp", change: func(c map[string]any) { c["aud"] = []string{"client-1", "other-client"} }}, {name: "wrong authorized party", change: func(c map[string]any) { c["aud"] = []string{"client-1", "other-client"}; c["azp"] = "other-client" }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { claims := cloneClaims(baseClaims) test.change(claims) if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, claims)); err == nil { t.Fatal("expected ID token to be rejected") } }) } multiple := cloneClaims(baseClaims) multiple["aud"] = []string{"client-1", "other-client"} multiple["azp"] = "client-1" if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, multiple)); err != nil { t.Fatalf("valid multi-audience ID token rejected: %v", err) } } func TestOIDCURLAndScopeValidation(t *testing.T) { got, err := validateOIDCURL(context.Background(), "http://127.0.0.1:9090/issuer/", true) if err != nil || got != "http://127.0.0.1:9090/issuer" { t.Fatalf("private development issuer rejected: %q %v", got, err) } for _, raw := range []string{ "http://127.0.0.1:9090/issuer?tenant=1", "http://127.0.0.1:9090/issuer#fragment", "http://8.8.8.8/issuer", } { allowPrivate := raw != "http://8.8.8.8/issuer" if _, err := validateOIDCURL(context.Background(), raw, allowPrivate); err == nil { t.Fatalf("unsafe issuer accepted: %s", raw) } } if _, err := validateAbsoluteURL("https://user:secret@example.com/callback"); err == nil { t.Fatal("URL containing credentials was accepted") } scopes, err := normalizeOIDCScopes([]string{"openid", " profile ", "openid", "email"}) if err != nil || len(scopes) != 3 || scopes[0] != "openid" || scopes[1] != "profile" || scopes[2] != "email" { t.Fatalf("unexpected normalized scopes: %#v %v", scopes, err) } for _, scopes := range [][]string{{"openid email"}, {"openid", ""}} { if _, err := normalizeOIDCScopes(scopes); err == nil { t.Fatalf("invalid scopes accepted: %#v", scopes) } } } func signTestIDToken(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string { t.Helper() header, err := json.Marshal(map[string]string{"alg": "RS256", "kid": kid, "typ": "JWT"}) if err != nil { t.Fatal(err) } payload, err := json.Marshal(claims) if err != nil { t.Fatal(err) } signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) digest := sha256.Sum256([]byte(signingInput)) signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) if err != nil { t.Fatal(err) } return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) } func cloneClaims(source map[string]any) map[string]any { result := make(map[string]any, len(source)) for key, value := range source { result[key] = value } return result }