5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
141 lines
5.0 KiB
Go
141 lines
5.0 KiB
Go
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
|
|
}
|