fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
This commit is contained in:
@@ -22,7 +22,9 @@ import (
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
OIDCSessionCookieName = "easyai_gateway_oidc_session"
|
||||
OIDCSessionCookieName = "easyai_gateway_oidc_session"
|
||||
localBreakGlassTokenPurpose = "local_break_glass_manager"
|
||||
legacyAccessTokenPurpose = "legacy_access"
|
||||
|
||||
PermissionPublic Permission = "public"
|
||||
PermissionBasic Permission = "basic"
|
||||
@@ -52,6 +54,7 @@ type User struct {
|
||||
TokenExpiresAt time.Time `json:"-"`
|
||||
TokenIssuedAt time.Time `json:"-"`
|
||||
Issuer string `json:"-"`
|
||||
TokenPurpose string `json:"-"`
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
@@ -142,15 +145,24 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
|
||||
token := extractBearer(r.Header.Get("Authorization"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(r.Header.Get("x-comfy-api-key"))
|
||||
}
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(r.Header.Get("x-goog-api-key"))
|
||||
}
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(r.URL.Query().Get("key"))
|
||||
var token string
|
||||
if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" {
|
||||
token = extractBearer(authorization)
|
||||
if token == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
} else if value := strings.TrimSpace(r.Header.Get("x-comfy-api-key")); value != "" {
|
||||
token = value
|
||||
} else if value := strings.TrimSpace(r.Header.Get("x-goog-api-key")); value != "" {
|
||||
token = value
|
||||
} else if value := strings.TrimSpace(r.URL.Query().Get("key")); value != "" {
|
||||
// Query credentials are retained only for API compatibility with
|
||||
// providers that use `?key=sk-*`. Bearer/OIDC tokens in URLs would leak
|
||||
// through browser history, reverse-proxy logs, and diagnostics.
|
||||
if !strings.HasPrefix(value, "sk-") {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
token = value
|
||||
}
|
||||
if token == "" {
|
||||
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
|
||||
@@ -175,10 +187,14 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
|
||||
if algorithm == "RS256" || algorithm == "ES256" {
|
||||
return a.AuthenticateOIDCAccessToken(r.Context(), token)
|
||||
}
|
||||
if !a.legacyJWTEnabled() {
|
||||
return nil, ErrUnauthorized
|
||||
user, err := a.verifyJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.verifyJWT(token)
|
||||
if a.legacyJWTEnabled() || isLocalBreakGlassManager(user) {
|
||||
return user, nil
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) {
|
||||
@@ -233,6 +249,7 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
||||
APIKeyName: stringClaim(claims, "apiKeyName"),
|
||||
APIKeyPrefix: stringClaim(claims, "apiKeyPrefix"),
|
||||
APIKeyScopes: stringSliceClaim(claims, "apiKeyScopes"),
|
||||
TokenPurpose: stringClaim(claims, "tokenPurpose"),
|
||||
}
|
||||
if user.Source == "" {
|
||||
user.Source = "gateway"
|
||||
@@ -248,6 +265,10 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
||||
ttl = time.Hour
|
||||
}
|
||||
now := time.Now()
|
||||
tokenPurpose := legacyAccessTokenPurpose
|
||||
if user.Source == "gateway" && hasManagerRole(user.Roles) {
|
||||
tokenPurpose = localBreakGlassTokenPurpose
|
||||
}
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"username": user.Username,
|
||||
@@ -264,6 +285,7 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
||||
"apiKeyName": user.APIKeyName,
|
||||
"apiKeyPrefix": user.APIKeyPrefix,
|
||||
"apiKeyScopes": user.APIKeyScopes,
|
||||
"tokenPurpose": tokenPurpose,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
}
|
||||
@@ -271,6 +293,19 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
||||
return token.SignedString([]byte(a.JWTSecret))
|
||||
}
|
||||
|
||||
func isLocalBreakGlassManager(user *User) bool {
|
||||
return user != nil && user.Source == "gateway" && user.TokenPurpose == localBreakGlassTokenPurpose && hasManagerRole(user.Roles)
|
||||
}
|
||||
|
||||
func hasManagerRole(roles []string) bool {
|
||||
for _, role := range roles {
|
||||
if role == "manager" || role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
|
||||
if a.LocalAPIKeyVerifier != nil {
|
||||
user, err := a.LocalAPIKeyVerifier(ctx, apiKey)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -25,6 +26,7 @@ const maxOIDCResponseBytes = 1 << 20
|
||||
const defaultOIDCHTTPTimeout = 10 * time.Second
|
||||
|
||||
type OIDCConfig struct {
|
||||
AppEnv string
|
||||
Issuer string
|
||||
Audience string
|
||||
TenantID string
|
||||
@@ -90,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
|
||||
config.Audience = strings.TrimSpace(config.Audience)
|
||||
config.TenantID = strings.TrimSpace(config.TenantID)
|
||||
config.RolePrefix = strings.TrimSpace(config.RolePrefix)
|
||||
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
|
||||
if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
|
||||
return nil, errors.New("issuer, audience, tenant and role prefix are required")
|
||||
}
|
||||
if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil &&
|
||||
@@ -262,10 +264,10 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
|
||||
if err := v.fetchJSON(ctx, discoveryURL, &discovery); err != nil {
|
||||
return err
|
||||
}
|
||||
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
|
||||
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI, v.config.AppEnv) != nil {
|
||||
return errors.New("OIDC discovery metadata is invalid")
|
||||
}
|
||||
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
|
||||
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint, v.config.AppEnv) != nil {
|
||||
return errors.New("OIDC introspection metadata is invalid")
|
||||
}
|
||||
var set jsonWebKeySet
|
||||
@@ -405,7 +407,7 @@ func decodeBigInt(value string) (*big.Int, error) {
|
||||
return new(big.Int).SetBytes(payload), nil
|
||||
}
|
||||
|
||||
func validatePublicURL(raw string) error {
|
||||
func validatePublicURL(raw, appEnv string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return errors.New("invalid URL")
|
||||
@@ -413,12 +415,30 @@ func validatePublicURL(raw string) error {
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme == "http" && (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "localhost") {
|
||||
if parsed.Scheme == "http" && isLocalOIDCEnvironment(appEnv) && isLoopbackOIDCHost(parsed.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("URL must use HTTPS")
|
||||
}
|
||||
|
||||
func isLocalOIDCEnvironment(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "development", "dev", "local", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopbackOIDCHost(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(value)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func numericDateClaim(value any) (time.Time, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -19,6 +19,7 @@ var ErrOIDCInvalidGrant = errors.New("OIDC refresh token is invalid")
|
||||
var errOIDCResponseTooLarge = errors.New("OIDC response exceeds size limit")
|
||||
|
||||
type OIDCPublicClientConfig struct {
|
||||
AppEnv string
|
||||
Issuer string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
@@ -64,7 +65,7 @@ func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, erro
|
||||
return nil, errors.New("offline_access is not allowed for Gateway browser sessions")
|
||||
}
|
||||
}
|
||||
if validatePublicURL(config.Issuer) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI) != nil || validatePublicURL(config.PostLogoutRedirectURI) != nil {
|
||||
if validatePublicURL(config.Issuer, config.AppEnv) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI, config.AppEnv) != nil || validatePublicURL(config.PostLogoutRedirectURI, config.AppEnv) != nil {
|
||||
return nil, errors.New("issuer, public client id and exact redirect URLs are required")
|
||||
}
|
||||
return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil
|
||||
@@ -220,9 +221,9 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o
|
||||
}
|
||||
var metadata oidcClientDiscovery
|
||||
if err := provider.Claims(&metadata); err != nil || metadata.Issuer != c.config.Issuer ||
|
||||
validatePublicURL(metadata.AuthorizationEndpoint) != nil || validatePublicURL(metadata.TokenEndpoint) != nil || validatePublicURL(metadata.JWKSURI) != nil ||
|
||||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint) != nil ||
|
||||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint) != nil {
|
||||
validatePublicURL(metadata.AuthorizationEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.TokenEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.JWKSURI, c.config.AppEnv) != nil ||
|
||||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint, c.config.AppEnv) != nil ||
|
||||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint, c.config.AppEnv) != nil {
|
||||
return nil, oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid")
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
|
||||
@@ -57,6 +57,7 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
|
||||
issuer = server.URL
|
||||
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
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(),
|
||||
})
|
||||
@@ -84,6 +85,7 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
|
||||
|
||||
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
|
||||
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "production",
|
||||
Issuer: "https://auth.example.com", ClientID: "gateway-public",
|
||||
RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/",
|
||||
@@ -119,6 +121,7 @@ func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) {
|
||||
issuer = server.URL
|
||||
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, ClientID: "gateway-public-client", RedirectURI: "https://gateway.example.com/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
|
||||
})
|
||||
@@ -195,6 +198,7 @@ func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
|
||||
})
|
||||
@@ -234,6 +238,7 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi
|
||||
issuer = server.URL
|
||||
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
|
||||
})
|
||||
@@ -245,3 +250,43 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi
|
||||
t.Fatalf("invalid_grant mapping was not stable and redacted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCPublicClientRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
|
||||
var issuer, insecureField string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path != "/.well-known/openid-configuration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
|
||||
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
|
||||
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "ES256"},
|
||||
}
|
||||
metadata[insecureField] = "http://127.0.0.1:1/oidc-endpoint"
|
||||
_ = json.NewEncoder(w).Encode(metadata)
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
|
||||
for _, field := range []string{
|
||||
"authorization_endpoint", "token_endpoint", "jwks_uri", "revocation_endpoint", "end_session_endpoint",
|
||||
} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
insecureField = field
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "production", 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.ValidateConfiguration(context.Background()); err == nil {
|
||||
t.Fatalf("production accepted loopback HTTP %s", field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestAuthenticateResolvesOpaqueOIDCSessionCookie(t *testing.T) {
|
||||
@@ -49,6 +52,47 @@ func TestAuthenticateBearerTakesPrecedenceOverOIDCSessionCookie(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsMalformedExplicitCredentialInsteadOfFallingBackToCookie(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
var resolved bool
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
||||
resolved = true
|
||||
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
request.Header.Set("Authorization", "not-a-bearer-credential")
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
|
||||
|
||||
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("malformed explicit credential error=%v, want unauthorized", err)
|
||||
}
|
||||
if resolved {
|
||||
t.Fatal("malformed Authorization header fell back to the OIDC session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsManagerJWTInQueryWithoutCookieFallback(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resolved bool
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
||||
resolved = true
|
||||
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
|
||||
|
||||
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("manager query JWT error=%v, want unauthorized", err)
|
||||
}
|
||||
if resolved {
|
||||
t.Fatal("rejected query credential fell back to the OIDC session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
@@ -81,6 +125,46 @@ func TestAuthenticatorReadsOIDCSessionResolverAndLegacyPolicyDynamically(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateKeepsOnlySignedBreakGlassManagerWhenLegacyJWTDisabled(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
authenticator.LegacyJWTEnabledProvider = func() bool { return false }
|
||||
|
||||
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managerRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
|
||||
managerRequest.Header.Set("Authorization", "Bearer "+managerToken)
|
||||
manager, err := authenticator.Authenticate(managerRequest)
|
||||
if err != nil || manager.ID != "manager" {
|
||||
t.Fatalf("signed break-glass manager was rejected: user=%#v err=%v", manager, err)
|
||||
}
|
||||
|
||||
userToken, err := authenticator.SignJWT(&User{ID: "user", Source: "gateway", Roles: []string{"user"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userRequest := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
userRequest.Header.Set("Authorization", "Bearer "+userToken)
|
||||
if _, err := authenticator.Authenticate(userRequest); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("ordinary local JWT error = %v, want unauthorized", err)
|
||||
}
|
||||
|
||||
legacyManager := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": "legacy-manager", "source": "gateway", "role": []string{"manager"},
|
||||
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
legacyManagerToken, err := legacyManager.SignedString([]byte(authenticator.JWTSecret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
|
||||
legacyRequest.Header.Set("Authorization", "Bearer "+legacyManagerToken)
|
||||
if _, err := authenticator.Authenticate(legacyRequest); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("legacy manager without token purpose error = %v, want unauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
||||
|
||||
@@ -43,6 +43,7 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1",
|
||||
RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
})
|
||||
@@ -85,6 +86,7 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
verifier, _ := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
})
|
||||
@@ -139,6 +141,7 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
IntrospectionEnabled: true,
|
||||
@@ -182,6 +185,7 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
|
||||
issuer = server.URL
|
||||
evaluation := OIDCSecurityEventEvaluation{RequireIntrospection: true}
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
IntrospectionEnabled: true,
|
||||
@@ -227,6 +231,72 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCVerifierRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var issuer, jwksURI, introspectionEndpoint string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch request.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": issuer, "jwks_uri": jwksURI, "introspection_endpoint": introspectionEndpoint,
|
||||
})
|
||||
case "/jwks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{rsaJWK("rsa-key", &key.PublicKey)}})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
jwks string
|
||||
introspection string
|
||||
}{
|
||||
{name: "JWKS", jwks: "http://127.0.0.1:1/jwks"},
|
||||
{name: "introspection", jwks: issuer + "/jwks", introspection: "http://localhost:1/introspect"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
jwksURI, introspectionEndpoint = test.jwks, test.introspection
|
||||
config := OIDCConfig{
|
||||
AppEnv: "production", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
HTTPClient: server.Client(),
|
||||
}
|
||||
if test.introspection != "" {
|
||||
config.IntrospectionEnabled = true
|
||||
config.IntrospectionCredentialProvider = func(context.Context) (string, []byte, error) {
|
||||
return "gateway-machine", []byte("machine-secret-long-enough"), nil
|
||||
}
|
||||
}
|
||||
verifier, createErr := NewOIDCVerifier(config)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
if validateErr := verifier.ValidateConfiguration(context.Background()); validateErr == nil {
|
||||
t.Fatalf("production accepted loopback HTTP %s endpoint", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCURLPolicyAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
||||
for _, appEnv := range []string{"", "production", "staging"} {
|
||||
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err == nil {
|
||||
t.Fatalf("%s accepted loopback HTTP OIDC URL", appEnv)
|
||||
}
|
||||
}
|
||||
for _, appEnv := range []string{"local", "development", "dev", "test"} {
|
||||
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err != nil {
|
||||
t.Fatalf("%s rejected loopback HTTP OIDC URL: %v", appEnv, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
|
||||
Reference in New Issue
Block a user