diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index 72640b5..eec61f7 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -34,27 +34,31 @@ const ( ) type User struct { - ID string `json:"sub"` - Username string `json:"username"` - Roles []string `json:"role,omitempty"` - TenantID string `json:"tenantId,omitempty"` - GatewayTenantID string `json:"gatewayTenantId,omitempty"` - TenantKey string `json:"tenantKey,omitempty"` - SSOID string `json:"sso_id,omitempty"` - Source string `json:"source,omitempty"` - GatewayUserID string `json:"gatewayUserId,omitempty"` - UserGroupID string `json:"userGroupId,omitempty"` - UserGroupKey string `json:"userGroupKey,omitempty"` - UserGroupKeys []string `json:"userGroupKeys,omitempty"` - APIKeyID string `json:"apiKeyId,omitempty"` - APIKeySecret string `json:"apiKeySecret,omitempty"` - APIKeyName string `json:"apiKeyName,omitempty"` - APIKeyPrefix string `json:"apiKeyPrefix,omitempty"` - APIKeyScopes []string `json:"apiKeyScopes,omitempty"` - TokenExpiresAt time.Time `json:"-"` - TokenIssuedAt time.Time `json:"-"` - Issuer string `json:"-"` - TokenPurpose string `json:"-"` + ID string `json:"sub"` + Username string `json:"username"` + Roles []string `json:"role,omitempty"` + TenantID string `json:"tenantId,omitempty"` + TenantName string `json:"tenantName,omitempty"` + GatewayTenantID string `json:"gatewayTenantId,omitempty"` + TenantKey string `json:"tenantKey,omitempty"` + SSOID string `json:"sso_id,omitempty"` + Source string `json:"source,omitempty"` + GatewayUserID string `json:"gatewayUserId,omitempty"` + UserGroupID string `json:"userGroupId,omitempty"` + UserGroupKey string `json:"userGroupKey,omitempty"` + UserGroupKeys []string `json:"userGroupKeys,omitempty"` + APIKeyID string `json:"apiKeyId,omitempty"` + APIKeySecret string `json:"apiKeySecret,omitempty"` + APIKeyName string `json:"apiKeyName,omitempty"` + APIKeyPrefix string `json:"apiKeyPrefix,omitempty"` + APIKeyScopes []string `json:"apiKeyScopes,omitempty"` + TokenExpiresAt time.Time `json:"-"` + TokenIssuedAt time.Time `json:"-"` + Issuer string `json:"-"` + ApplicationID string `json:"-"` + OIDCClientID string `json:"-"` + OIDCUserBindingID string `json:"-"` + TokenPurpose string `json:"-"` } type contextKey string diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index fa8c539..3b6af3d 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -20,6 +20,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) const maxOIDCResponseBytes = 1 << 20 @@ -30,6 +31,9 @@ type OIDCConfig struct { Issuer string Audience string TenantID string + TenantMode string + ApplicationID string + ClientID string RolePrefix string RequiredScopes []string JWKSCacheTTL time.Duration @@ -44,10 +48,11 @@ type OIDCConfig struct { } type OIDCSecurityEventIdentity struct { - Issuer string - TenantID string - Subject string - IssuedAt time.Time + Issuer string + ApplicationID string + TenantID string + Subject string + IssuedAt time.Time } type OIDCSecurityEventEvaluation struct { @@ -91,8 +96,16 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { config.Issuer = strings.TrimRight(strings.TrimSpace(config.Issuer), "/") config.Audience = strings.TrimSpace(config.Audience) config.TenantID = strings.TrimSpace(config.TenantID) + config.TenantMode = strings.TrimSpace(config.TenantMode) + config.ApplicationID = strings.TrimSpace(config.ApplicationID) + config.ClientID = strings.TrimSpace(config.ClientID) config.RolePrefix = strings.TrimSpace(config.RolePrefix) - if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" { + if config.TenantMode == "" { + config.TenantMode = "single_tenant" + } + validTenantMode := config.TenantMode == "single_tenant" && config.TenantID != "" || + config.TenantMode == "multi_tenant" && config.TenantID == "" && config.ApplicationID != "" + if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || !validTenantMode || config.RolePrefix == "" { return nil, errors.New("issuer, audience, tenant and role prefix are required") } if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil && @@ -166,7 +179,14 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { return nil, oidcUnauthorized(registeredClaimsValidationCategory(err), "signature or registered claims are invalid", err) } claims, ok := token.Claims.(jwt.MapClaims) - if !ok || stringClaim(claims, "sub") == "" || stringClaim(claims, "tid") != v.config.TenantID { + tenantID := stringClaim(claims, "tid") + validTenant := tenantID == v.config.TenantID + if v.config.TenantMode == "multi_tenant" { + validTenant = uuid.Validate(tenantID) == nil + } + clientID := stringClaim(claims, "client_id") + if !ok || stringClaim(claims, "sub") == "" || !validTenant || + v.config.ClientID != "" && clientID != v.config.ClientID { return nil, oidcUnauthorized("STABLE_IDENTITY_CLAIMS_INVALID", "stable identity claims are invalid", nil) } expiresAt, ok := numericDateClaim(claims["exp"]) @@ -190,7 +210,8 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { } if v.config.SecurityEventEvaluator != nil { evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{ - Issuer: v.config.Issuer, TenantID: v.config.TenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt, + Issuer: v.config.Issuer, ApplicationID: v.config.ApplicationID, + TenantID: tenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt, }) if evaluateErr != nil { return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用") @@ -227,7 +248,8 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { } return &User{ ID: stringClaim(claims, "sub"), Username: username, Roles: roles, - TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer, + TenantID: tenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer, + ApplicationID: v.config.ApplicationID, OIDCClientID: clientID, }, nil } diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index 608380e..c570ec1 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" "golang.org/x/oauth2" ) @@ -77,15 +78,22 @@ func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error { return err } -func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) { +func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier, tenantHint string) (string, error) { if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) { return "", errors.New("state, nonce and PKCE verifier are required") } + if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil { + return "", errors.New("tenant hint must be a UUID") + } config, _, err := c.configuration(ctx) if err != nil { return "", err } - return config.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)), nil + options := []oauth2.AuthCodeOption{oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)} + if strings.TrimSpace(tenantHint) != "" { + options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint))) + } + return config.AuthCodeURL(state, options...), nil } func (c *OIDCPublicClient) ExchangeCode(ctx context.Context, code, verifier string) (OIDCTokenResponse, error) { diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index 01faf9f..44ed0c2 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -67,7 +67,8 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T if err := client.ValidateConfiguration(context.Background()); err != nil { t.Fatalf("ValidateConfiguration() error = %v", err) } - authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier) + tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier, tenantHint) if err != nil { t.Fatal(err) } @@ -78,6 +79,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T 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 query.Get("tenant_hint") != tenantHint { + t.Fatalf("tenant_hint = %q, want %q", query.Get("tenant_hint"), tenantHint) + } if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil { t.Fatal(err) } diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 14fd893..60baf23 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -12,6 +12,7 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy" "github.com/easyai/easyai-ai-gateway/apps/api/internal/runner" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" @@ -90,8 +91,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { // @Failure 500 {object} ErrorEnvelope // @Router /api/v1/auth/register [post] func (s *Server) register(w http.ResponseWriter, r *http.Request) { - if !s.localIdentityEnabled() || !s.ordinaryLocalJWTEnabled() { - writeError(w, http.StatusForbidden, "local registration is disabled") + if !s.localRegistrationEnabled() { + writeError(w, http.StatusForbidden, "统一认证启用后,本地注册已关闭", "LOCAL_REGISTRATION_DISABLED") return } var input store.LocalRegisterInput @@ -185,6 +186,12 @@ func (s *Server) localIdentityEnabled() bool { return mode == "" || mode == "standalone" || mode == "hybrid" } +func (s *Server) localRegistrationEnabled() bool { + runtime := s.currentIdentityRuntime() + return (runtime == nil || runtime.Revision.State != identity.RevisionActive) && + s.localIdentityEnabled() && s.ordinaryLocalJWTEnabled() +} + func (s *Server) localLoginAllowed(user store.GatewayUser) bool { for _, role := range user.Roles { if role == "manager" || role == "admin" { diff --git a/apps/api/internal/httpapi/handlers_identity_test.go b/apps/api/internal/httpapi/handlers_identity_test.go new file mode 100644 index 0000000..c3c1a6f --- /dev/null +++ b/apps/api/internal/httpapi/handlers_identity_test.go @@ -0,0 +1,41 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" +) + +func TestRegisterIsStructurallyForbiddenWhenIdentityRevisionIsActive(t *testing.T) { + server := &Server{ + cfg: config.Config{IdentityMode: "hybrid"}, + identityTestRevision: identity.Revision{ + State: identity.RevisionActive, Issuer: "https://auth.example.test", + LegacyJWTEnabled: true, + }, + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(`{}`)) + recorder := httptest.NewRecorder() + + server.register(recorder, request) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status=%d, want 403", recorder.Code) + } + var envelope struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Error.Code != "LOCAL_REGISTRATION_DISABLED" { + t.Fatalf("code=%q", envelope.Error.Code) + } +} diff --git a/apps/api/internal/httpapi/identity_configuration_handlers.go b/apps/api/internal/httpapi/identity_configuration_handlers.go index 8adea1f..83f6063 100644 --- a/apps/api/internal/httpapi/identity_configuration_handlers.go +++ b/apps/api/internal/httpapi/identity_configuration_handlers.go @@ -331,7 +331,7 @@ func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Reques return } policy := identity.RevisionPolicy{ - LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled, + TenantMode: revision.TenantMode, LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled, LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds, SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds, } diff --git a/apps/api/internal/httpapi/identity_runtime.go b/apps/api/internal/httpapi/identity_runtime.go index 51fffb4..01711eb 100644 --- a/apps/api/internal/httpapi/identity_runtime.go +++ b/apps/api/internal/httpapi/identity_runtime.go @@ -15,6 +15,10 @@ type oidcTokenVerifier interface { Verify(context.Context, string) (*auth.User, error) } +type tenantContextReader interface { + Get(context.Context, string, string) (identity.TenantContext, bool, error) +} + // identityRequestRuntime is an immutable request-level snapshot. A handler that // starts with one runtime keeps using it even when an administrator activates a // new revision while that request is in flight. @@ -25,6 +29,7 @@ type identityRequestRuntime struct { Sessions oidcSessionManager SessionCipher *oidcsession.Cipher SecurityEvents *ssfreceiver.ConnectionManager + TenantContext tenantContextReader CookieSecure bool BrowserEnabled bool } @@ -38,7 +43,8 @@ func (s *Server) currentIdentityRuntime() *identityRequestRuntime { return &identityRequestRuntime{ Revision: runtime.Revision, Verifier: runtime.Verifier, PublicClient: runtime.PublicClient, Sessions: runtime.Sessions, SessionCipher: runtime.SessionCipher, SecurityEvents: runtime.SecurityEvents, - CookieSecure: runtime.CookieSecure, BrowserEnabled: runtime.PublicClient != nil, + TenantContext: runtime.TenantContext, + CookieSecure: runtime.CookieSecure, BrowserEnabled: runtime.PublicClient != nil, } } diff --git a/apps/api/internal/httpapi/oidc_multi_tenant_local_e2e_test.go b/apps/api/internal/httpapi/oidc_multi_tenant_local_e2e_test.go new file mode 100644 index 0000000..a5393bf --- /dev/null +++ b/apps/api/internal/httpapi/oidc_multi_tenant_local_e2e_test.go @@ -0,0 +1,548 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func TestLocalCrossRepositoryMultiTenantGatewayFlow(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + onboardingContract := strings.TrimSpace(os.Getenv("AUTH_CENTER_ONBOARDING_CONTRACT")) + tenantRuntimeContract := strings.TrimSpace(os.Getenv("AUTH_CENTER_TENANT_RUNTIME_CONTRACT")) + if databaseURL == "" || onboardingContract == "" || tenantRuntimeContract == "" { + t.Skip("set the local Gateway database and Auth Center contract paths to run the cross-repository E2E") + } + assertAuthCenterMultiTenantContracts(t, onboardingContract, tenantRuntimeContract) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + applyMigration(t, ctx, databaseURL) + db, err := store.Connect(ctx, databaseURL) + if err != nil { + t.Fatalf("connect Gateway store: %v", err) + } + t.Cleanup(db.Close) + breakGlassCreated := false + var breakGlassUserID string + hasBreakGlass, err := db.HasBreakGlassManager(ctx) + if err != nil { + t.Fatal(err) + } + if !hasBreakGlass { + manager, registerErr := db.RegisterLocalUser(ctx, store.LocalRegisterInput{ + Username: "multi-tenant-e2e-manager-" + uuid.NewString(), + Password: uuid.NewString() + "-local-only", + }) + if registerErr != nil { + t.Fatalf("create local Break-glass Manager fixture: %v", registerErr) + } + breakGlassCreated, breakGlassUserID = true, manager.ID + } + + applicationID := uuid.NewString() + tenantA, tenantB := uuid.NewString(), uuid.NewString() + subject := "shared-cross-repository-subject-" + uuid.NewString() + browserClientID := "gateway-browser-" + uuid.NewString() + machineClientID := "gateway-machine-" + uuid.NewString() + machineSecret := "local-cross-repository-machine-secret" + audience := "urn:easyai:gateway:" + applicationID + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + var issuer, ssfIssuer, gatewayBaseURL string + ssfAudience := "urn:easyai:ssf:receiver:" + applicationID + var revokedRefreshTokens atomic.Int64 + type authorization struct { + TenantID string + Nonce string + } + var authorizationMu sync.Mutex + authorizations := map[string]authorization{} + var ssfStreamMu sync.Mutex + var ssfStream map[string]any + tenantNames := map[string]string{tenantA: "本地租户 A", tenantB: "本地租户 B"} + tenantSlugs := map[string]string{tenantA: "local-tenant-a", tenantB: "local-tenant-b"} + + authCenter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/.well-known/openid-configuration": + writeTestJSON(w, map[string]any{ + "issuer": issuer, "jwks_uri": issuer + "/jwks", + "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token", + "revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout", + "introspection_endpoint": issuer + "/introspect", + }) + case r.URL.Path == "/.well-known/ssf-configuration/ssf": + writeTestJSON(w, map[string]any{ + "spec_version": "1_0", "issuer": ssfIssuer, "jwks_uri": issuer + "/jwks", + "configuration_endpoint": issuer + "/ssf/streams", + "status_endpoint": issuer + "/ssf/status", + "verification_endpoint": issuer + "/ssf/v1/verify", + "delivery_methods_supported": []string{"urn:ietf:rfc:8935"}, + }) + case r.URL.Path == "/jwks": + writeTestJSON(w, map[string]any{"keys": []any{oidcJITECJWK("multi-tenant-key", &key.PublicKey)}}) + case r.URL.Path == "/authorize": + tenantID := r.URL.Query().Get("tenant_hint") + if tenantID != tenantA && tenantID != tenantB || + r.URL.Query().Get("client_id") != browserClientID || + r.URL.Query().Get("code_challenge_method") != "S256" { + http.Error(w, "authorization request rejected", http.StatusBadRequest) + return + } + code := uuid.NewString() + authorizationMu.Lock() + authorizations[code] = authorization{TenantID: tenantID, Nonce: r.URL.Query().Get("nonce")} + authorizationMu.Unlock() + callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=" + url.QueryEscape(code) + + "&state=" + url.QueryEscape(r.URL.Query().Get("state")) + http.Redirect(w, r, callback, http.StatusSeeOther) + case r.URL.Path == "/token": + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid token request", http.StatusBadRequest) + return + } + if r.Form.Get("grant_type") == "client_credentials" { + clientID, secret, ok := r.BasicAuth() + if !ok || clientID != machineClientID || secret != machineSecret || r.Form.Get("scope") == "" { + http.Error(w, "machine token rejected", http.StatusUnauthorized) + return + } + writeTestJSON(w, map[string]any{ + "access_token": "opaque-local-machine-token", "token_type": "Bearer", "expires_in": 300, + }) + return + } + code := r.Form.Get("code") + authorizationMu.Lock() + requested, ok := authorizations[code] + delete(authorizations, code) + authorizationMu.Unlock() + if !ok || r.Form.Get("grant_type") != "authorization_code" || + r.Form.Get("client_id") != browserClientID || r.Form.Get("client_secret") != "" { + http.Error(w, "authorization code rejected", http.StatusBadRequest) + return + } + accessToken := signedMultiTenantE2EToken( + t, key, issuer, audience, browserClientID, applicationID, requested.TenantID, subject, "", + ) + idToken := signedMultiTenantE2EToken( + t, key, issuer, browserClientID, browserClientID, applicationID, requested.TenantID, subject, requested.Nonce, + ) + writeTestJSON(w, map[string]any{ + "access_token": accessToken, "refresh_token": "opaque-refresh-" + requested.TenantID, + "id_token": idToken, "token_type": "Bearer", "expires_in": 300, + }) + case r.URL.Path == "/introspect": + clientID, secret, ok := r.BasicAuth() + if !ok || clientID != machineClientID || secret != machineSecret { + http.Error(w, "introspection rejected", http.StatusUnauthorized) + return + } + writeTestJSON(w, map[string]any{"active": true}) + case r.URL.Path == "/revoke": + revokedRefreshTokens.Add(1) + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/logout": + http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther) + case strings.HasPrefix(r.URL.Path, "/api/v1/runtime/tenants/"): + tenantID := strings.TrimPrefix(r.URL.Path, "/api/v1/runtime/tenants/") + if r.Header.Get("Authorization") != "Bearer opaque-local-machine-token" { + http.Error(w, "tenant context unauthorized", http.StatusUnauthorized) + return + } + displayName, ok := tenantNames[tenantID] + if !ok { + http.NotFound(w, r) + return + } + etag := `"` + tenantID + `-v1"` + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", etag) + writeTestJSON(w, identity.TenantContext{ + ApplicationID: applicationID, TenantID: tenantID, DisplayName: displayName, Slug: tenantSlugs[tenantID], + TenantStatus: "active", TenantApplicationStatus: "active", Version: "1", UpdatedAt: time.Now().UTC(), + }) + case r.URL.Path == "/ssf/streams": + if r.Header.Get("Authorization") != "Bearer opaque-local-machine-token" { + http.Error(w, "SSF management unauthorized", http.StatusUnauthorized) + return + } + ssfStreamMu.Lock() + defer ssfStreamMu.Unlock() + if r.Method == http.MethodGet { + if ssfStream == nil { + writeTestJSON(w, []any{}) + } else { + writeTestJSON(w, []any{ssfStream}) + } + return + } + if r.Method != http.MethodPost { + http.Error(w, "SSF method rejected", http.StatusMethodNotAllowed) + return + } + var input struct { + EventsRequested []string `json:"events_requested"` + Description string `json:"description"` + Delivery struct { + Method string `json:"method"` + EndpointURL string `json:"endpoint_url"` + } `json:"delivery"` + } + if json.NewDecoder(r.Body).Decode(&input) != nil { + http.Error(w, "SSF stream rejected", http.StatusBadRequest) + return + } + ssfStream = map[string]any{ + "stream_id": uuid.NewString(), "iss": ssfIssuer, "aud": ssfAudience, + "events_requested": input.EventsRequested, "description": input.Description, + "delivery": map[string]any{"method": input.Delivery.Method, "endpoint_url": input.Delivery.EndpointURL}, + } + writeTestJSON(w, ssfStream) + case r.URL.Path == "/ssf/status": + writeTestJSON(w, map[string]any{}) + case r.URL.Path == "/ssf/v1/verify": + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + defer authCenter.Close() + issuer = authCenter.URL + ssfIssuer = issuer + "/ssf" + + cfg := config.Config{ + AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "hybrid", + JWTSecret: "local-cross-repository-jwt-secret", IdentitySecretStore: "file", + IdentitySecretDir: t.TempDir(), LocalGeneratedStorageDir: t.TempDir(), LocalUploadedStorageDir: t.TempDir(), + LocalTempAssetTTLHours: 1, CORSAllowedOrigin: "http://localhost:5178", TaskProgressCallbackEnabled: false, + } + previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx) + if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) { + t.Fatal(previousErr) + } + revision := prepareMultiTenantE2ERevision( + t, ctx, db, cfg, issuer, ssfIssuer, ssfAudience, + applicationID, audience, browserClientID, machineClientID, machineSecret, + ) + revisionID := revision.ID + t.Cleanup(func() { + _, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE transmitter_issuer=$1`, ssfIssuer) + _, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, ssfIssuer) + restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, []string{revisionID}) + if breakGlassCreated { + if err := db.DeleteGatewayUser(context.Background(), breakGlassUserID); err != nil { + t.Errorf("delete local Break-glass Manager fixture: %v", err) + } + } + }) + activeRevision, _, err := db.ActivateIdentityRevision(ctx, revision.ID, revision.Version, "multi-tenant-e2e", "multi-tenant-e2e") + if err != nil { + t.Fatalf("activate multi-tenant revision: %v", err) + } + revision = activeRevision + gateway := httptest.NewServer(NewServerWithContext(ctx, cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer gateway.Close() + gatewayBaseURL = gateway.URL + + tokenA := signedMultiTenantE2EToken(t, key, issuer, audience, browserClientID, applicationID, tenantA, subject, "") + tokenB := signedMultiTenantE2EToken(t, key, issuer, audience, browserClientID, applicationID, tenantB, subject, "") + var meA, meB auth.User + doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenA, nil, http.StatusOK, &meA) + doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenB, nil, http.StatusOK, &meB) + if meA.ID != subject || meB.ID != subject || meA.GatewayTenantID == meB.GatewayTenantID || + meA.GatewayUserID == meB.GatewayUserID || meA.TenantName != tenantNames[tenantA] || meB.TenantName != tenantNames[tenantB] { + t.Fatalf("cross-tenant projection mismatch: A=%+v B=%+v", meA, meB) + } + + var createdKey struct { + APIKey struct { + ID string `json:"id"` + } `json:"apiKey"` + } + doOIDCJITJSON(t, gateway.URL, http.MethodPost, "/api/v1/api-keys", tokenA, + map[string]any{"name": "multi-tenant-local-e2e"}, http.StatusCreated, &createdKey) + var keysB struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/api-keys", tokenB, nil, http.StatusOK, &keysB) + for _, item := range keysB.Items { + if item.ID == createdKey.APIKey.ID { + t.Fatal("Tenant A API Key crossed into Tenant B") + } + } + + switchJar := newMultiTenantE2ECookieJar(t) + loginMultiTenantE2E(t, switchJar, gateway.URL, tenantA) + loginMultiTenantE2E(t, switchJar, gateway.URL, tenantB) + var switched auth.User + doMultiTenantE2ECookieJSON(t, switchJar, gateway.URL+"/api/v1/me", http.StatusOK, &switched) + if switched.TenantID != tenantB || revokedRefreshTokens.Load() != 1 { + t.Fatalf("tenant switch did not replace A session: me=%+v revoked=%d", switched, revokedRefreshTokens.Load()) + } + var switchedASessions int + if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$1::uuid`, meA.GatewayTenantID).Scan(&switchedASessions); err != nil { + t.Fatal(err) + } + if switchedASessions != 0 { + t.Fatalf("tenant switch retained %d old Tenant A sessions", switchedASessions) + } + + jarA, jarB := newMultiTenantE2ECookieJar(t), newMultiTenantE2ECookieJar(t) + loginMultiTenantE2E(t, jarA, gateway.URL, tenantA) + loginMultiTenantE2E(t, jarB, gateway.URL, tenantB) + principalResult, err := db.ApplySessionRevoked(ctx, store.ApplySessionRevokedInput{ + Issuer: issuer + "/ssf", Audience: "urn:easyai:ssf:" + applicationID, JTI: uuid.NewString(), + SubjectIssuer: issuer, ApplicationID: applicationID, SubjectType: "principal", + TenantID: tenantA, Subject: subject, EventTimestamp: time.Now().UTC().Add(-2 * time.Second), InitiatingEntity: "local-e2e", + }) + if err != nil || principalResult.SessionsDeleted != 1 { + t.Fatalf("principal revocation result=%+v err=%v", principalResult, err) + } + doMultiTenantE2ECookieJSON(t, jarA, gateway.URL+"/api/v1/me", http.StatusUnauthorized, nil) + doMultiTenantE2ECookieJSON(t, jarB, gateway.URL+"/api/v1/me", http.StatusOK, nil) + + loginMultiTenantE2E(t, jarA, gateway.URL, tenantA) + tenantResult, err := db.ApplySessionRevoked(ctx, store.ApplySessionRevokedInput{ + Issuer: issuer + "/ssf", Audience: "urn:easyai:ssf:" + applicationID, JTI: uuid.NewString(), + SubjectIssuer: issuer, ApplicationID: applicationID, SubjectType: "tenant", + TenantID: tenantA, Subject: tenantA, EventTimestamp: time.Now().UTC().Add(-2 * time.Second), InitiatingEntity: "local-e2e", + }) + if err != nil || tenantResult.SessionsDeleted != 1 { + t.Fatalf("tenant revocation result=%+v err=%v", tenantResult, err) + } + doMultiTenantE2ECookieJSON(t, jarA, gateway.URL+"/api/v1/me", http.StatusUnauthorized, nil) + doMultiTenantE2ECookieJSON(t, jarB, gateway.URL+"/api/v1/me", http.StatusOK, nil) + + var restored auth.User + doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenA, nil, http.StatusOK, &restored) + if restored.GatewayTenantID != meA.GatewayTenantID || restored.GatewayUserID != meA.GatewayUserID { + t.Fatalf("reassignment did not reuse projection: before=%+v after=%+v", meA, restored) + } + var survivingAPIKey int + if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_api_keys WHERE id=$1::uuid`, createdKey.APIKey.ID).Scan(&survivingAPIKey); err != nil { + t.Fatal(err) + } + if survivingAPIKey != 1 { + t.Fatal("OIDC tenant revocation unexpectedly removed a Gateway API Key") + } + + doOIDCJITJSON(t, gateway.URL, http.MethodPost, "/api/v1/auth/register", "", + map[string]any{"username": "must-not-register", "password": "not-a-real-password"}, http.StatusForbidden, nil) +} + +func assertAuthCenterMultiTenantContracts(t *testing.T, onboardingPath, runtimePath string) { + t.Helper() + for path, required := range map[string][]string{ + onboardingPath: {"schema_version", "multi_tenant", "tenant_context", "machine_to_machine"}, + runtimePath: {"/api/v1/runtime/tenants/{tenantId}", "tenant.context.read", "If-None-Match", "ETag"}, + } { + raw, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + t.Fatalf("read Auth Center contract %s: %v", filepath.Base(path), err) + } + for _, marker := range required { + if !bytes.Contains(raw, []byte(marker)) { + t.Fatalf("Auth Center contract %s is missing %q", filepath.Base(path), marker) + } + } + } +} + +func prepareMultiTenantE2ERevision( + t *testing.T, + ctx context.Context, + db *store.Store, + cfg config.Config, + issuer, ssfIssuer, ssfAudience, applicationID, audience, browserClientID, machineClientID, machineSecret string, +) identity.Revision { + t.Helper() + draft, err := identity.NewDraft(identity.PairingInput{ + AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178", + LegacyJWTEnabled: true, + }, "test") + if err != nil { + t.Fatal(err) + } + draft, err = db.CreateIdentityConfigurationRevision(ctx, draft) + if err != nil { + t.Fatal(err) + } + secrets, err := identitySecretStore(cfg) + if err != nil { + t.Fatal(err) + } + machineRef, sessionRef := "multi-tenant-machine-"+draft.ID, "multi-tenant-session-"+draft.ID + if err := secrets.Put(ctx, machineRef, []byte(machineSecret)); err != nil { + t.Fatal(err) + } + if err := secrets.Put(ctx, sessionRef, bytes.Repeat([]byte{9}, 32)); err != nil { + t.Fatal(err) + } + for _, ref := range []string{machineRef, sessionRef} { + if err := db.QueueIdentitySecretCleanup(ctx, ref, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{ + Manifest: identity.ManifestV2{ + SchemaVersion: 2, TenantMode: "multi_tenant", Issuer: issuer, ApplicationID: applicationID, + Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, + Audience: audience, Scopes: []string{"gateway.access"}, + Clients: identity.ManifestClients{ + BrowserLogin: &identity.ManifestClient{ClientID: browserClientID}, + MachineToMachine: &identity.ManifestClient{ClientID: machineClientID}, + }, + TenantContext: &identity.ManifestTenantContext{ + Endpoint: issuer + "/api/v1/runtime/tenants/{tenantId}", + Audience: "urn:easyai:auth-center:tenant-context", Scope: "tenant.context.read", + }, + SecurityEvents: &identity.ManifestSecurityEvents{ + TransmitterIssuer: ssfIssuer, ConfigurationEndpoint: issuer + "/ssf/streams", Audience: ssfAudience, + }, + }, + MachineCredentialRef: machineRef, SessionEncryptionKeyRef: sessionRef, + TraceID: "multi-tenant-local-e2e", AuditID: "multi-tenant-local-e2e", AppEnv: "test", + }) + if err != nil { + t.Fatal(err) + } + draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "multi-tenant-local-e2e", "multi-tenant-local-e2e") + if err != nil { + t.Fatal(err) + } + builder := identityruntime.NewRuntimeBuilder(ctx, db, secrets, identityruntime.RuntimeBuilderConfig{ + AppEnv: "test", HeartbeatInterval: time.Hour, StaleAfter: 3 * time.Hour, ClockSkew: time.Minute, + }, nil) + if err := builder.PrepareSecurityEvents(ctx, draft, []byte(machineSecret)); err != nil { + t.Fatalf("prepare application-scoped SSF receiver: %v", err) + } + return draft +} + +func signedMultiTenantE2EToken( + t *testing.T, + key *ecdsa.PrivateKey, + issuer, audience, clientID, applicationID, tenantID, subject, nonce string, +) string { + t.Helper() + now := time.Now().UTC() + claims := jwt.MapClaims{ + "iss": issuer, "aud": audience, "sub": subject, "tid": tenantID, "client_id": clientID, + "application_id": applicationID, "preferred_username": "shared-local-user", + "roles": []string{"gateway.user"}, "scope": "openid gateway.access", + "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": now.Add(time.Hour).Unix(), + } + if nonce != "" { + claims["nonce"] = nonce + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = "multi-tenant-key" + raw, err := token.SignedString(key) + if err != nil { + t.Fatal(err) + } + return raw +} + +func newMultiTenantE2ECookieJar(t *testing.T) *cookiejar.Jar { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return jar +} + +func loginMultiTenantE2E(t *testing.T, jar *cookiejar.Jar, gatewayURL, tenantID string) { + t.Helper() + gateway, err := url.Parse(gatewayURL) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) > 10 { + return errors.New("too many redirects") + } + if request.URL.Host != gateway.Host && request.URL.Path != "/authorize" { + return http.ErrUseLastResponse + } + return nil + }} + response, err := client.Get(gatewayURL + "/api/v1/auth/oidc/login?returnTo=%2F&tenantHint=" + url.QueryEscape(tenantID)) + if err != nil { + t.Fatalf("complete tenant %s login: %v", tenantID[:8], err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusSeeOther && response.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + t.Fatalf("tenant %s login status=%d body=%s", tenantID[:8], response.StatusCode, raw) + } + for _, cookie := range jar.Cookies(gateway) { + if cookie.Name == auth.OIDCSessionCookieName && cookie.Value != "" { + return + } + } + t.Fatalf("tenant %s login returned no opaque session", tenantID[:8]) +} + +func doMultiTenantE2ECookieJSON(t *testing.T, jar *cookiejar.Jar, requestURL string, expectedStatus int, output any) { + t.Helper() + client := &http.Client{Jar: jar} + response, err := client.Get(requestURL) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != expectedStatus { + t.Fatalf("GET %s status=%d want=%d body=%s", requestURL, response.StatusCode, expectedStatus, raw) + } + if output != nil { + if err := json.Unmarshal(raw, output); err != nil { + t.Fatal(err) + } + } +} + +func writeTestJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(value) +} diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index 1e6898d..f31ef02 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -13,6 +13,7 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/google/uuid" ) const ( @@ -49,7 +50,12 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { if returnTo == "" { returnTo = "/" } - transaction, err := oidcsession.NewLoginTransaction(returnTo, time.Now()) + tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint")) + if tenantHint != "" && (runtime.Revision.TenantMode != "multi_tenant" || uuid.Validate(tenantHint) != nil) { + writeError(w, http.StatusBadRequest, "租户提示无效", errorCodeOIDCLoginInvalid) + return + } + transaction, err := oidcsession.NewLoginTransactionWithTenantHint(returnTo, tenantHint, time.Now()) if err != nil { writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid) return @@ -60,7 +66,9 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable) return } - authorizationURL, err := runtime.PublicClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier) + authorizationURL, err := runtime.PublicClient.AuthorizationURL( + r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier, transaction.TenantHint, + ) if err != nil { s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err) writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE") @@ -124,7 +132,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCTokenFailure(w, r, "ID_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心身份令牌校验失败") return } - projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision) + projection, err := s.resolveOIDCUserProjectionForRuntime(r.Context(), r, identity, runtime) if err != nil { s.writeOIDCCallbackProjectionError(w, r, err) return @@ -140,6 +148,13 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "登录会话保存失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable) return } + if err := s.replaceOIDCBrowserSession(r.Context(), r, runtime, rawSession); err != nil { + if _, cleanupErr := runtime.Sessions.Delete(r.Context(), rawSession); cleanupErr != nil { + s.logger.WarnContext(r.Context(), "cleanup replacement OIDC session failed", "error", cleanupErr) + } + s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "旧登录会话清理失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable) + return + } now := time.Now() http.SetCookie(w, &http.Cookie{ Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/", @@ -151,6 +166,34 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther) } +func (s *Server) replaceOIDCBrowserSession( + ctx context.Context, + r *http.Request, + runtime *identityRequestRuntime, + newRawSession string, +) error { + previous, err := r.Cookie(auth.OIDCSessionCookieName) + if errors.Is(err, http.ErrNoCookie) { + return nil + } + if err != nil { + return err + } + if previous.Value == "" || previous.Value == newRawSession { + return nil + } + bundle, err := runtime.Sessions.Delete(ctx, previous.Value) + if err != nil { + return err + } + if bundle.RefreshToken != "" { + if err := runtime.PublicClient.RevokeRefreshToken(ctx, bundle.RefreshToken); err != nil { + s.logger.WarnContext(ctx, "revoke replaced OIDC refresh token failed", "error", err) + } + } + return nil +} + // logoutOIDCSession godoc // @Summary 注销 OIDC 登录会话 // @Description 删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。 diff --git a/apps/api/internal/httpapi/oidc_session_test.go b/apps/api/internal/httpapi/oidc_session_test.go index f4b1c97..f123e21 100644 --- a/apps/api/internal/httpapi/oidc_session_test.go +++ b/apps/api/internal/httpapi/oidc_session_test.go @@ -60,6 +60,57 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) { } } +func TestStartOIDCLoginEncryptsAndForwardsMultiTenantHint(t *testing.T) { + cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) + client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize"} + server := &Server{ + auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client, + oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, + identityTestRevision: identity.Revision{TenantMode: "multi_tenant"}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + recorder := httptest.NewRecorder() + server.startOIDCLogin(recorder, httptest.NewRequest( + http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+tenantHint, nil, + )) + if recorder.Code != http.StatusSeeOther || client.tenantHint != tenantHint { + t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint) + } + cookies := recorder.Result().Cookies() + transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, time.Now()) + if err != nil || transaction.TenantHint != tenantHint { + t.Fatalf("transaction=%+v err=%v", transaction, err) + } +} + +func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) { + for _, test := range []struct { + name string + revision identity.Revision + hint string + }{ + {name: "invalid UUID", revision: identity.Revision{TenantMode: "multi_tenant"}, hint: "not-a-uuid"}, + {name: "single tenant", revision: identity.Revision{TenantMode: "single_tenant"}, hint: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}, + } { + t.Run(test.name, func(t *testing.T) { + cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) + server := &Server{ + auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{}, + oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, + identityTestRevision: test.revision, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + recorder := httptest.NewRecorder() + server.startOIDCLogin(recorder, httptest.NewRequest( + http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+test.hint, nil, + )) + if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" { + t.Fatalf("status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie")) + } + }) + } +} + func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) { cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) if err != nil { @@ -191,6 +242,42 @@ func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) { } } +func TestReplaceOIDCBrowserSessionDeletesPreviousSessionAndRevokesRefreshToken(t *testing.T) { + sessions := &fakeOIDCSessions{ + deleteBundle: oidcsession.TokenBundle{RefreshToken: "previous-refresh-token"}, + } + client := &fakeOIDCClient{} + server := &Server{} + runtime := &identityRequestRuntime{Sessions: sessions, PublicClient: client} + request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback", nil) + request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "previous-session"}) + + if err := server.replaceOIDCBrowserSession(request.Context(), request, runtime, "new-session"); err != nil { + t.Fatal(err) + } + if sessions.deleted != "previous-session" { + t.Fatalf("deleted session=%q", sessions.deleted) + } + if client.revokedRefreshToken != "previous-refresh-token" { + t.Fatalf("revoked refresh token=%q", client.revokedRefreshToken) + } +} + +func TestReplaceOIDCBrowserSessionDoesNotDeleteNewSession(t *testing.T) { + sessions := &fakeOIDCSessions{} + server := &Server{} + runtime := &identityRequestRuntime{Sessions: sessions, PublicClient: &fakeOIDCClient{}} + request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback", nil) + request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "same-session"}) + + if err := server.replaceOIDCBrowserSession(request.Context(), request, runtime, "same-session"); err != nil { + t.Fatal(err) + } + if sessions.deleted != "" { + t.Fatalf("unexpected deleted session=%q", sessions.deleted) + } +} + func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { server := &Server{ cfg: config.Config{CORSAllowedOrigin: "https://gateway.example.com"}, @@ -329,10 +416,13 @@ func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) { type fakeOIDCClient struct { authorizationURL string state, nonce, challenge string + tenantHint string + revokedRefreshToken string } -func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge string) (string, error) { +func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, tenantHint string) (string, error) { f.state, f.nonce, f.challenge = state, nonce, challenge + f.tenantHint = tenantHint return f.authorizationURL, nil } func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) { @@ -344,12 +434,18 @@ func (f *fakeOIDCClient) VerifyIDToken(context.Context, string, string) (string, func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) { return auth.OIDCTokenResponse{}, nil } -func (f *fakeOIDCClient) RevokeRefreshToken(context.Context, string) error { return nil } +func (f *fakeOIDCClient) RevokeRefreshToken(_ context.Context, refreshToken string) error { + f.revokedRefreshToken = refreshToken + return nil +} func (f *fakeOIDCClient) EndSessionURL(context.Context, string) (string, error) { return "https://gateway.example.com/", nil } -type fakeOIDCSessions struct{ deleted string } +type fakeOIDCSessions struct { + deleted string + deleteBundle oidcsession.TokenBundle +} func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) { return "opaque-session", nil @@ -357,6 +453,6 @@ func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *aut func (f *fakeOIDCSessions) Resolve(context.Context, string) (*auth.User, error) { return nil, nil } func (f *fakeOIDCSessions) Delete(_ context.Context, raw string) (oidcsession.TokenBundle, error) { f.deleted = raw - return oidcsession.TokenBundle{}, nil + return f.deleteBundle, nil } func (f *fakeOIDCSessions) Cleanup(context.Context) (int64, error) { return 0, nil } diff --git a/apps/api/internal/httpapi/oidc_user_middleware.go b/apps/api/internal/httpapi/oidc_user_middleware.go index 2ffa390..18471cd 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware.go +++ b/apps/api/internal/httpapi/oidc_user_middleware.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strings" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" @@ -22,6 +23,10 @@ type oidcUserResolver interface { ResolveOrProvisionOIDCUser(context.Context, store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) } +type oidcTenantBindingReader interface { + OIDCTenantBindingContext(context.Context, string, string, string) (store.OIDCTenantBindingContext, error) +} + func (s *Server) requireUser(permission auth.Permission, next http.Handler) http.Handler { return s.auth.Require(permission, s.resolveGatewayUser(next)) } @@ -57,26 +62,119 @@ func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request, if runtime == nil { return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable") } - return s.resolveOIDCUserProjectionForRevision(ctx, r, user, runtime.Revision) + return s.resolveOIDCUserProjectionForRuntime(ctx, r, user, runtime) } -func (s *Server) resolveOIDCUserProjectionForRevision(ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision) (store.ResolveOrProvisionOIDCUserResult, error) { +func (s *Server) resolveOIDCUserProjectionForRuntime(ctx context.Context, r *http.Request, user *auth.User, runtime *identityRequestRuntime) (store.ResolveOrProvisionOIDCUserResult, error) { if s.oidcUserResolver == nil { return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable") } + revision := runtime.Revision + tenantName := "" + tenantSlug := "" + tenantMetadataStatus := "" + tenantMetadataVersion := "" + tenantMetadataETag := "" + var tenantMetadataUpdatedAt time.Time + if revision.TenantMode == "multi_tenant" { + if runtime.TenantContext == nil { + return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable") + } + var cached store.OIDCTenantBindingContext + bindingDisabled := false + if reader, ok := s.oidcUserResolver.(oidcTenantBindingReader); ok { + cached, _ = reader.OIDCTenantBindingContext(ctx, revision.Issuer, revision.ApplicationID, user.TenantID) + bindingDisabled = cached.ID != "" && cached.AccessStatus != "active" + if !bindingDisabled && cached.MetadataStatus == "synced" && cached.NextSyncAt.After(time.Now()) { + tenantName, tenantSlug = cached.DisplayName, cached.Slug + tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version + tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt + } + } + if tenantMetadataStatus == "synced" { + return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision, + tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt) + } + etag := cached.ETag + if bindingDisabled { + // A revoked binding can only be reactivated after a fresh positive + // Tenant Context response. Do not let a stale 304 or cached profile + // reopen access after reassignment. + etag = "" + } + tenant, unchanged, err := runtime.TenantContext.Get(ctx, user.TenantID, etag) + switch { + case err == nil && unchanged && !bindingDisabled && cached.MetadataStatus == "synced": + tenantName, tenantSlug = cached.DisplayName, cached.Slug + tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version + tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt + case err == nil && tenant.Active(): + tenantName = tenant.DisplayName + tenantSlug = tenant.Slug + tenantMetadataStatus = "synced" + tenantMetadataVersion = tenant.Version + tenantMetadataETag = tenant.ETag + tenantMetadataUpdatedAt = tenant.UpdatedAt + case err == nil, errors.Is(err, identity.ErrTenantContextNotFound): + return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable + case errors.Is(err, identity.ErrTenantContextUnavailable): + if bindingDisabled { + return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable + } + if cached.MetadataStatus == "synced" { + tenantName, tenantSlug = cached.DisplayName, cached.Slug + tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version + tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt + } else { + tenantName = oidcTenantPlaceholderName(user.TenantID) + tenantMetadataStatus = "metadata_pending" + } + default: + return store.ResolveOrProvisionOIDCUserResult{}, err + } + } + return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision, + tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt) +} + +func (s *Server) resolveOIDCUserProjectionWithTenantContext( + ctx context.Context, + r *http.Request, + user *auth.User, + revision identity.Revision, + tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag string, + tenantMetadataUpdatedAt time.Time, +) (store.ResolveOrProvisionOIDCUserResult, error) { return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{ - Issuer: revision.Issuer, - Subject: user.ID, - Username: user.Username, - Roles: user.Roles, - TenantID: user.TenantID, - GatewayTenantKey: revision.LocalTenantKey, - ProvisioningEnabled: revision.JITEnabled, - RequestIP: limitAuditText(requestIP(r), 128), - UserAgent: limitAuditText(r.UserAgent(), 512), + Issuer: revision.Issuer, + ApplicationID: revision.ApplicationID, + Subject: user.ID, + Username: user.Username, + Roles: user.Roles, + TenantID: user.TenantID, + TenantMode: revision.TenantMode, + TenantName: tenantName, + TenantSlug: tenantSlug, + TenantMetadataStatus: tenantMetadataStatus, + TenantMetadataVersion: tenantMetadataVersion, + TenantMetadataETag: tenantMetadataETag, + TenantMetadataUpdatedAt: tenantMetadataUpdatedAt, + OIDCClientID: user.OIDCClientID, + GatewayTenantKey: revision.LocalTenantKey, + ProvisioningEnabled: revision.JITEnabled, + RequestIP: limitAuditText(requestIP(r), 128), + UserAgent: limitAuditText(r.UserAgent(), 512), }) } +func oidcTenantPlaceholderName(tenantID string) string { + compact := strings.ReplaceAll(strings.TrimSpace(tenantID), "-", "") + if len(compact) > 8 { + compact = compact[:8] + } + return "认证中心租户 " + compact +} + func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, store.ErrOIDCUserNotProvisioned): diff --git a/apps/api/internal/httpapi/oidc_user_middleware_test.go b/apps/api/internal/httpapi/oidc_user_middleware_test.go index 35ad1a0..5381fc0 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware_test.go +++ b/apps/api/internal/httpapi/oidc_user_middleware_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" @@ -16,10 +17,30 @@ import ( ) type fakeOIDCUserResolver struct { - result store.ResolveOrProvisionOIDCUserResult + result store.ResolveOrProvisionOIDCUserResult + err error + calls int + input store.ResolveOrProvisionOIDCUserInput + binding store.OIDCTenantBindingContext + bindingErr error + bindingCalls int +} + +type fakeTenantContextReader struct { + tenant identity.TenantContext err error - calls int - input store.ResolveOrProvisionOIDCUserInput + calls *int + etag *string +} + +func (reader fakeTenantContextReader) Get(_ context.Context, _, etag string) (identity.TenantContext, bool, error) { + if reader.calls != nil { + (*reader.calls)++ + } + if reader.etag != nil { + *reader.etag = etag + } + return reader.tenant, false, reader.err } func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, input store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) { @@ -28,6 +49,14 @@ func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, inp return f.result, f.err } +func (f *fakeOIDCUserResolver) OIDCTenantBindingContext(context.Context, string, string, string) (store.OIDCTenantBindingContext, error) { + f.bindingCalls++ + if f.binding.ID == "" && f.bindingErr == nil { + return store.OIDCTenantBindingContext{}, store.ErrOIDCTenantUnavailable + } + return f.binding, f.bindingErr +} + func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) { resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{ ID: "platform-user", @@ -99,6 +128,203 @@ func TestResolveGatewayUserLeavesNonOIDCIdentityChainsUnchanged(t *testing.T) { } } +func TestResolveOIDCMultiTenantProjectionUsesRuntimeTenantContext(t *testing.T) { + tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0" + applicationID := "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8" + resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}} + server := &Server{oidcUserResolver: resolver} + runtime := &identityRequestRuntime{ + Revision: identity.Revision{ + Issuer: "https://auth.test.example", ApplicationID: applicationID, + TenantMode: "multi_tenant", JITEnabled: true, + }, + TenantContext: fakeTenantContextReader{tenant: identity.TenantContext{ + ApplicationID: applicationID, TenantID: tenantID, DisplayName: "租户 A", Slug: "tenant-a", + TenantStatus: "active", TenantApplicationStatus: "active", Version: "v7", + UpdatedAt: time.Date(2026, 7, 28, 9, 0, 0, 0, time.UTC), ETag: `"tenant-v7"`, + }}, + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ + ID: "shared-subject", TenantID: tenantID, Username: "alice", Roles: []string{"basic"}, + }, runtime) + if err != nil { + t.Fatalf("resolve multi-tenant projection: %v", err) + } + if resolver.input.TenantMode != "multi_tenant" || resolver.input.ApplicationID != applicationID || + resolver.input.TenantName != "租户 A" || resolver.input.TenantSlug != "tenant-a" || + resolver.input.TenantMetadataStatus != "synced" || resolver.input.TenantMetadataVersion != "v7" || + resolver.input.TenantMetadataETag != `"tenant-v7"` { + t.Fatalf("unexpected tenant context projection input: %+v", resolver.input) + } +} + +func TestResolveOIDCMultiTenantProjectionTreatsTemporaryContextFailureAsPending(t *testing.T) { + tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0" + resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}} + server := &Server{oidcUserResolver: resolver} + runtime := &identityRequestRuntime{ + Revision: identity.Revision{ + Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8", + TenantMode: "multi_tenant", JITEnabled: true, + }, + TenantContext: fakeTenantContextReader{err: identity.ErrTenantContextUnavailable}, + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ + ID: "subject", TenantID: tenantID, + }, runtime); err != nil { + t.Fatalf("temporary tenant context failure should reach fail-closed store projection: %v", err) + } + if resolver.input.TenantMetadataStatus != "metadata_pending" || + resolver.input.TenantName != "认证中心租户 d9dcb4e7" { + t.Fatalf("unexpected pending projection input: %+v", resolver.input) + } +} + +func TestResolveOIDCMultiTenantProjectionUsesFreshLocalTenantCache(t *testing.T) { + tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0" + remoteCalls := 0 + resolver := &fakeOIDCUserResolver{ + result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}, + binding: store.OIDCTenantBindingContext{ + ID: "binding-a", AccessStatus: "active", MetadataStatus: "synced", DisplayName: "缓存租户 A", + Slug: "tenant-a", Version: "v8", ETag: `"tenant-v8"`, + MetadataUpdatedAt: time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC), + NextSyncAt: time.Now().Add(time.Minute), + }, + } + server := &Server{oidcUserResolver: resolver} + runtime := &identityRequestRuntime{ + Revision: identity.Revision{ + Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8", + TenantMode: "multi_tenant", JITEnabled: true, + }, + TenantContext: fakeTenantContextReader{ + err: errors.New("fresh cache must avoid a remote request"), calls: &remoteCalls, + }, + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ + ID: "subject", TenantID: tenantID, + }, runtime); err != nil { + t.Fatal(err) + } + if remoteCalls != 0 || resolver.input.TenantName != "缓存租户 A" || + resolver.input.TenantMetadataVersion != "v8" { + t.Fatalf("remote calls=%d input=%+v", remoteCalls, resolver.input) + } +} + +func TestResolveOIDCMultiTenantProjectionFallsBackToSyncedCacheOnTemporaryFailure(t *testing.T) { + tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0" + resolver := &fakeOIDCUserResolver{ + result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}, + binding: store.OIDCTenantBindingContext{ + ID: "binding-a", AccessStatus: "active", MetadataStatus: "synced", DisplayName: "缓存租户 A", + Slug: "tenant-a", Version: "v7", ETag: `"tenant-v7"`, NextSyncAt: time.Now().Add(-time.Minute), + }, + } + server := &Server{oidcUserResolver: resolver} + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ + ID: "subject", TenantID: tenantID, + }, &identityRequestRuntime{ + Revision: identity.Revision{ + Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8", + TenantMode: "multi_tenant", JITEnabled: true, + }, + TenantContext: fakeTenantContextReader{err: identity.ErrTenantContextUnavailable}, + }); err != nil { + t.Fatal(err) + } + if resolver.input.TenantName != "缓存租户 A" || resolver.input.TenantMetadataStatus != "synced" { + t.Fatalf("cached fallback input=%+v", resolver.input) + } +} + +func TestResolveOIDCMultiTenantProjectionRevalidatesDisabledBindingBeforeReassignment(t *testing.T) { + tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0" + applicationID := "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8" + remoteETag := "not-called" + resolver := &fakeOIDCUserResolver{ + result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}, + binding: store.OIDCTenantBindingContext{ + ID: "binding-a", AccessStatus: "disabled", MetadataStatus: "rejected", + ETag: `"revoked-v7"`, + }, + } + server := &Server{oidcUserResolver: resolver} + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ + ID: "subject", TenantID: tenantID, + }, &identityRequestRuntime{ + Revision: identity.Revision{ + Issuer: "https://auth.test.example", ApplicationID: applicationID, + TenantMode: "multi_tenant", JITEnabled: true, + }, + TenantContext: fakeTenantContextReader{tenant: identity.TenantContext{ + ApplicationID: applicationID, TenantID: tenantID, DisplayName: "恢复后的租户 A", Slug: "tenant-a", + TenantStatus: "active", TenantApplicationStatus: "active", Version: "v8", UpdatedAt: time.Now(), + }, etag: &remoteETag}, + }) + if err != nil { + t.Fatalf("revalidate reassigned tenant: %v", err) + } + if remoteETag != "" || resolver.calls != 1 || resolver.input.TenantMetadataStatus != "synced" { + t.Fatalf("etag=%q calls=%d input=%+v", remoteETag, resolver.calls, resolver.input) + } +} + +func TestResolveOIDCMultiTenantProjectionKeepsDisabledBindingClosedDuringContextFailure(t *testing.T) { + resolver := &fakeOIDCUserResolver{binding: store.OIDCTenantBindingContext{ + ID: "binding-a", AccessStatus: "disabled", MetadataStatus: "rejected", + }} + server := &Server{oidcUserResolver: resolver} + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ + ID: "subject", TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0", + }, &identityRequestRuntime{ + Revision: identity.Revision{ + Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8", + TenantMode: "multi_tenant", JITEnabled: true, + }, + TenantContext: fakeTenantContextReader{err: identity.ErrTenantContextUnavailable}, + }) + if !errors.Is(err, store.ErrOIDCTenantUnavailable) || resolver.calls != 0 { + t.Fatalf("err=%v resolver calls=%d", err, resolver.calls) + } +} + +func TestResolveOIDCMultiTenantProjectionRejectsMissingOrInactiveTenant(t *testing.T) { + tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0" + tests := []struct { + name string + reader fakeTenantContextReader + }{ + {name: "not found", reader: fakeTenantContextReader{err: identity.ErrTenantContextNotFound}}, + {name: "inactive", reader: fakeTenantContextReader{tenant: identity.TenantContext{ + TenantStatus: "suspended", TenantApplicationStatus: "active", + }}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver := &fakeOIDCUserResolver{} + server := &Server{oidcUserResolver: resolver} + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ + ID: "subject", TenantID: tenantID, + }, &identityRequestRuntime{ + Revision: identity.Revision{TenantMode: "multi_tenant"}, + TenantContext: test.reader, + }) + if !errors.Is(err, store.ErrOIDCTenantUnavailable) || resolver.calls != 0 { + t.Fatalf("err=%v resolver calls=%d", err, resolver.calls) + } + }) + } +} + func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) { tests := []struct { name string diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index e0682ee..ab886c3 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -48,7 +48,7 @@ type Server struct { } type oidcPublicClient interface { - AuthorizationURL(context.Context, string, string, string) (string, error) + AuthorizationURL(context.Context, string, string, string, string) (string, error) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) VerifyIDToken(context.Context, string, string) (string, error) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go index 8fc0666..959cde1 100644 --- a/apps/api/internal/identity/configuration.go +++ b/apps/api/internal/identity/configuration.go @@ -22,6 +22,7 @@ var ( ) type RevisionPolicy struct { + TenantMode string `json:"tenantMode"` LocalTenantKey string `json:"localTenantKey"` RolePrefix string `json:"rolePrefix"` JITEnabled bool `json:"jitEnabled"` @@ -32,7 +33,8 @@ type RevisionPolicy struct { } func (policy RevisionPolicy) Validate() error { - if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" { + if strings.TrimSpace(policy.RolePrefix) == "" || + policy.TenantMode != "multi_tenant" && strings.TrimSpace(policy.LocalTenantKey) == "" { return errors.New("local tenant mapping and role prefix are required") } if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds || @@ -56,6 +58,7 @@ type Revision struct { ID string `json:"id"` State RevisionState `json:"state"` SchemaVersion int `json:"schemaVersion"` + TenantMode string `json:"tenantMode,omitempty"` AuthCenterURL string `json:"authCenterUrl"` Issuer string `json:"issuer,omitempty"` TenantID string `json:"tenantId,omitempty"` @@ -76,6 +79,9 @@ type Revision struct { SecurityEventIssuer string `json:"securityEventIssuer,omitempty"` SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"` SecurityEventAudience string `json:"securityEventAudience,omitempty"` + TenantContextEndpoint string `json:"tenantContextEndpoint,omitempty"` + TenantContextAudience string `json:"tenantContextAudience,omitempty"` + TenantContextScope string `json:"tenantContextScope,omitempty"` MachineCredentialRef string `json:"-"` SessionEncryptionKeyRef string `json:"-"` SessionIdleSeconds int `json:"sessionIdleSeconds"` @@ -126,7 +132,7 @@ func NewDraft(input PairingInput, appEnv string) (Revision, error) { publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv) webBase, _ := exactBaseURL(input.WebBaseURL, appEnv) return Revision{ - ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1, + ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1, TenantMode: "single_tenant", AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey), PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled, Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, @@ -151,8 +157,13 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" { return Revision{}, errors.New("session encryption key reference is required") } + if input.Manifest.SchemaVersion == 1 && strings.TrimSpace(revision.LocalTenantKey) == "" { + return Revision{}, ErrLocalTenantInvalid + } + revision.SchemaVersion = input.Manifest.SchemaVersion revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/") revision.TenantID = input.Manifest.TenantID + revision.TenantMode = input.Manifest.TenantMode revision.ApplicationID = input.Manifest.ApplicationID revision.Audience = input.Manifest.Audience revision.Scopes = append([]string(nil), input.Manifest.Scopes...) @@ -165,6 +176,16 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro } revision.TokenIntrospection = capabilities["token_introspection"] revision.SessionRevocation = capabilities["session_revocation"] + if input.Manifest.SchemaVersion == 2 { + revision.LocalTenantKey = "" + revision.TenantContextEndpoint = input.Manifest.TenantContext.Endpoint + revision.TenantContextAudience = input.Manifest.TenantContext.Audience + revision.TenantContextScope = input.Manifest.TenantContext.Scope + } else { + revision.TenantContextEndpoint = "" + revision.TenantContextAudience = "" + revision.TenantContextScope = "" + } if input.Manifest.SecurityEvents != nil { revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint @@ -209,9 +230,6 @@ func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string if !sameSiteBaseURLs(publicBase, webBase) { return ConsumerMetadata{}, errors.New("public base URL and web base URL must be same-site") } - if strings.TrimSpace(input.LocalTenantKey) == "" { - return ConsumerMetadata{}, errors.New("local tenant mapping is required") - } metadata := ConsumerMetadata{ PublicBaseURL: publicBase, WebBaseURL: webBase, RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"}, diff --git a/apps/api/internal/identity/configuration_test.go b/apps/api/internal/identity/configuration_test.go index 18bb07d..ad2bbb4 100644 --- a/apps/api/internal/identity/configuration_test.go +++ b/apps/api/internal/identity/configuration_test.go @@ -176,3 +176,53 @@ func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { } } } + +func TestApplyManifestV2RemovesFixedTenantMappingWhileV1StillRequiresIt(t *testing.T) { + draft, err := NewDraft(PairingInput{ + AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com", + WebBaseURL: "https://gateway.example.com", + }, "production") + if err != nil { + t.Fatalf("multi-tenant pairing draft rejected: %v", err) + } + applied, err := ApplyManifest(draft, ManifestApplication{ + AppEnv: "production", MachineCredentialRef: "identity-machine-example", + SessionEncryptionKeyRef: "identity-session-example", + Manifest: ManifestV2{ + SchemaVersion: 2, TenantMode: "multi_tenant", Issuer: "https://auth.example.com/issuer/shared", + ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, + Scopes: []string{"openid", "gateway.access"}, + Clients: ManifestClients{ + BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}, + }, + TenantContext: &ManifestTenantContext{ + Endpoint: "https://auth.example.com/api/v1/runtime/tenants/{tenantId}", + Audience: "urn:easyai:auth-center:tenant-context", Scope: "tenant.context.read", + }, + SecurityEvents: &ManifestSecurityEvents{ + TransmitterIssuer: "https://auth.example.com/ssf", + ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", + Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if applied.SchemaVersion != 2 || applied.TenantMode != "multi_tenant" || applied.TenantID != "" || + applied.LocalTenantKey != "" || applied.TenantContextEndpoint == "" { + t.Fatalf("unexpected multi-tenant revision: %#v", applied) + } + + v1Draft := draft + v1Draft.ID = "v1-draft" + if _, err := ApplyManifest(v1Draft, ManifestApplication{AppEnv: "production", Manifest: ManifestV1{ + SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", + TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + Capabilities: []string{}, Scopes: []string{}, Clients: ManifestClients{}, + }}); err == nil { + t.Fatal("Manifest V1 without a fixed local tenant mapping was accepted") + } +} diff --git a/apps/api/internal/identity/onboarding_client.go b/apps/api/internal/identity/onboarding_client.go index ec343f7..eb8d0e5 100644 --- a/apps/api/internal/identity/onboarding_client.go +++ b/apps/api/internal/identity/onboarding_client.go @@ -61,18 +61,31 @@ type ManifestSecurityEvents struct { Audience string `json:"audience"` } +type ManifestTenantContext struct { + Endpoint string `json:"endpoint"` + Audience string `json:"audience"` + Scope string `json:"scope"` +} + type ManifestV1 struct { SchemaVersion int `json:"schema_version"` + TenantMode string `json:"tenant_mode,omitempty"` Issuer string `json:"issuer"` - TenantID string `json:"tenant_id"` + TenantID string `json:"tenant_id,omitempty"` ApplicationID string `json:"application_id"` Capabilities []string `json:"capabilities"` Audience string `json:"audience,omitempty"` Scopes []string `json:"scopes"` Clients ManifestClients `json:"clients"` + TenantContext *ManifestTenantContext `json:"tenant_context,omitempty"` SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"` } +// ManifestV2 intentionally shares the wire structure with ManifestV1 so the +// onboarding delivery can be decoded without guessing a union variant. Validate +// always branches on schema_version and rejects fields from the other mode. +type ManifestV2 = ManifestV1 + type MachineCredential struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` @@ -86,11 +99,30 @@ type CredentialDelivery struct { } func (manifest ManifestV1) Validate(appEnv string) error { - if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer, appEnv) != nil { + if validatePublicIdentityURL(manifest.Issuer, appEnv) != nil { return errors.New("application manifest identity metadata is invalid") } - if _, err := uuid.Parse(manifest.TenantID); err != nil { - return errors.New("application manifest tenant is invalid") + switch manifest.SchemaVersion { + case 1: + if strings.TrimSpace(manifest.TenantMode) != "" || manifest.TenantContext != nil { + return errors.New("application manifest tenant mode is invalid") + } + if _, err := uuid.Parse(manifest.TenantID); err != nil { + return errors.New("application manifest tenant is invalid") + } + case 2: + if manifest.TenantMode != "multi_tenant" || strings.TrimSpace(manifest.TenantID) != "" { + return errors.New("application manifest tenant mode is invalid") + } + if manifest.TenantContext == nil || + validatePublicIdentityURL(strings.ReplaceAll(manifest.TenantContext.Endpoint, "{tenantId}", uuid.Nil.String()), appEnv) != nil || + !strings.Contains(manifest.TenantContext.Endpoint, "{tenantId}") || + manifest.TenantContext.Audience != "urn:easyai:auth-center:tenant-context" || + manifest.TenantContext.Scope != "tenant.context.read" { + return errors.New("application manifest tenant context is invalid") + } + default: + return errors.New("application manifest identity metadata is invalid") } if _, err := uuid.Parse(manifest.ApplicationID); err != nil { return errors.New("application manifest application is invalid") @@ -107,6 +139,11 @@ func (manifest ManifestV1) Validate(appEnv string) error { capabilities["token_introspection"] && !capabilities["machine_to_machine"] { return errors.New("application manifest capability dependencies are invalid") } + if manifest.SchemaVersion == 2 && + (!capabilities["oidc_login"] || !capabilities["machine_to_machine"] || + !capabilities["token_introspection"] || !capabilities["session_revocation"]) { + return errors.New("application manifest multi-tenant capabilities are invalid") + } if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") { return errors.New("application manifest browser client is missing") } diff --git a/apps/api/internal/identity/onboarding_client_test.go b/apps/api/internal/identity/onboarding_client_test.go index 56aecf4..3738a8d 100644 --- a/apps/api/internal/identity/onboarding_client_test.go +++ b/apps/api/internal/identity/onboarding_client_test.go @@ -78,6 +78,47 @@ func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *te } } +func TestManifestV2ValidationRequiresExplicitMultiTenantRuntimeContract(t *testing.T) { + valid := ManifestV2{ + SchemaVersion: 2, TenantMode: "multi_tenant", + Issuer: "https://auth.example.com/issuer/shared", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, + Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Scopes: []string{"openid", "gateway.access"}, + Clients: ManifestClients{ + BrowserLogin: &ManifestClient{ClientID: "browser"}, + MachineToMachine: &ManifestClient{ClientID: "service"}, + }, + TenantContext: &ManifestTenantContext{ + Endpoint: "https://auth.example.com/api/v1/runtime/tenants/{tenantId}", + Audience: "urn:easyai:auth-center:tenant-context", + Scope: "tenant.context.read", + }, + SecurityEvents: &ManifestSecurityEvents{ + TransmitterIssuer: "https://auth.example.com/ssf", + ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", + Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + }, + } + if err := valid.Validate("production"); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*ManifestV2){ + func(value *ManifestV2) { value.TenantMode = "" }, + func(value *ManifestV2) { value.TenantID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, + func(value *ManifestV2) { value.TenantContext = nil }, + func(value *ManifestV2) { value.TenantContext.Scope = "" }, + func(value *ManifestV2) { value.TenantContext.Audience = "urn:wrong" }, + } { + candidate := valid + contextCopy := *valid.TenantContext + candidate.TenantContext = &contextCopy + mutate(&candidate) + if err := candidate.Validate("production"); err == nil { + t.Fatalf("invalid Manifest V2 was accepted: %#v", candidate) + } + } +} + func TestOnboardingClientRejectsRedirects(t *testing.T) { target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "should not be reached", http.StatusTeapot) diff --git a/apps/api/internal/identity/tenant_context_client.go b/apps/api/internal/identity/tenant_context_client.go new file mode 100644 index 0000000..51522b4 --- /dev/null +++ b/apps/api/internal/identity/tenant_context_client.go @@ -0,0 +1,188 @@ +package identity + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +var ( + ErrTenantContextNotFound = errors.New("tenant context was not found") + ErrTenantContextUnavailable = errors.New("tenant context is unavailable") +) + +type TenantContext struct { + ApplicationID string `json:"application_id"` + TenantID string `json:"tenant_id"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` + TenantStatus string `json:"tenant_status"` + TenantApplicationStatus string `json:"tenant_application_status"` + Version string `json:"version"` + UpdatedAt time.Time `json:"updated_at"` + ETag string `json:"-"` +} + +func (context TenantContext) Active() bool { + return context.TenantStatus == "active" && context.TenantApplicationStatus == "active" +} + +type TenantContextCredentialProvider func(context.Context) (string, []byte, error) + +type TenantContextClient struct { + issuer string + appEnv string + application string + endpoint string + audience string + scope string + credentials TenantContextCredentialProvider + client *http.Client + mutex sync.Mutex + token string + tokenExpiry time.Time + tokenURL string +} + +func NewTenantContextClient(appEnv, issuer, applicationID, endpoint, audience, scope string, credentials TenantContextCredentialProvider, base *http.Client) (*TenantContextClient, error) { + issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + endpoint = strings.TrimSpace(endpoint) + applicationID = strings.TrimSpace(applicationID) + if validatePublicIdentityURL(issuer, appEnv) != nil || + validatePublicIdentityURL(strings.ReplaceAll(endpoint, "{tenantId}", uuid.Nil.String()), appEnv) != nil || + !strings.Contains(endpoint, "{tenantId}") || uuid.Validate(applicationID) != nil || + audience != "urn:easyai:auth-center:tenant-context" || scope != "tenant.context.read" || credentials == nil { + return nil, errors.New("tenant context configuration is invalid") + } + if base == nil { + base = &http.Client{Timeout: 10 * time.Second} + } + client := *base + if client.Timeout <= 0 { + client.Timeout = 10 * time.Second + } + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &TenantContextClient{ + issuer: issuer, appEnv: appEnv, application: applicationID, endpoint: endpoint, + audience: audience, scope: scope, credentials: credentials, client: &client, + }, nil +} + +func (client *TenantContextClient) Get(ctx context.Context, tenantID, etag string) (TenantContext, bool, error) { + if uuid.Validate(strings.TrimSpace(tenantID)) != nil { + return TenantContext{}, false, ErrTenantContextNotFound + } + token, err := client.accessToken(ctx) + if err != nil { + return TenantContext{}, false, ErrTenantContextUnavailable + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.ReplaceAll(client.endpoint, "{tenantId}", tenantID), nil) + if err != nil { + return TenantContext{}, false, ErrTenantContextUnavailable + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+token) + if strings.TrimSpace(etag) != "" { + request.Header.Set("If-None-Match", strings.TrimSpace(etag)) + } + response, err := client.client.Do(request) + if err != nil { + return TenantContext{}, false, ErrTenantContextUnavailable + } + defer response.Body.Close() + if response.StatusCode == http.StatusNotModified { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes)) + return TenantContext{}, true, nil + } + if response.StatusCode == http.StatusNotFound { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes)) + return TenantContext{}, false, ErrTenantContextNotFound + } + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes)) + return TenantContext{}, false, ErrTenantContextUnavailable + } + var output TenantContext + decoder := json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)) + if decoder.Decode(&output) != nil || output.ApplicationID != client.application || output.TenantID != tenantID || + strings.TrimSpace(output.DisplayName) == "" || strings.TrimSpace(output.Slug) == "" || strings.TrimSpace(output.Version) == "" || + output.UpdatedAt.IsZero() { + return TenantContext{}, false, ErrTenantContextUnavailable + } + output.ETag = strings.TrimSpace(response.Header.Get("ETag")) + return output, false, nil +} + +func (client *TenantContextClient) accessToken(ctx context.Context) (string, error) { + client.mutex.Lock() + defer client.mutex.Unlock() + if client.token != "" && time.Now().Add(30*time.Second).Before(client.tokenExpiry) { + return client.token, nil + } + if client.tokenURL == "" { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.issuer+"/.well-known/openid-configuration", nil) + if err != nil { + return "", err + } + request.Header.Set("Accept", "application/json") + response, err := client.client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + var discovery struct { + Issuer string `json:"issuer"` + TokenEndpoint string `json:"token_endpoint"` + } + if response.StatusCode != http.StatusOK || + json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&discovery) != nil || + strings.TrimRight(discovery.Issuer, "/") != client.issuer || + validatePublicIdentityURL(discovery.TokenEndpoint, client.appEnv) != nil { + return "", errors.New("tenant context discovery is invalid") + } + client.tokenURL = discovery.TokenEndpoint + } + clientID, secret, err := client.credentials(ctx) + if err != nil { + return "", err + } + defer clear(secret) + if strings.TrimSpace(clientID) == "" || len(secret) < 16 { + return "", errors.New("tenant context credential is unavailable") + } + form := url.Values{"grant_type": {"client_credentials"}, "scope": {client.scope}} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Accept", "application/json") + request.SetBasicAuth(clientID, string(secret)) + response, err := client.client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + var tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + if response.StatusCode != http.StatusOK || + json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&tokenResponse) != nil || + tokenResponse.AccessToken == "" || !strings.EqualFold(tokenResponse.TokenType, "Bearer") || + tokenResponse.ExpiresIn <= 0 { + return "", errors.New("tenant context token response is invalid") + } + client.token = tokenResponse.AccessToken + client.tokenExpiry = time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second) + return client.token, nil +} diff --git a/apps/api/internal/identity/tenant_context_client_test.go b/apps/api/internal/identity/tenant_context_client_test.go new file mode 100644 index 0000000..fa38bd2 --- /dev/null +++ b/apps/api/internal/identity/tenant_context_client_test.go @@ -0,0 +1,118 @@ +package identity + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestTenantContextClientUsesApplicationMachineTokenAndETag(t *testing.T) { + tenantID, applicationID := uuid.NewString(), uuid.NewString() + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/issuer/shared/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, "token_endpoint": serverURL(r) + "/issuer/shared/token", + }) + case "/issuer/shared/token": + clientID, secret, ok := r.BasicAuth() + if !ok || clientID != "gateway-machine" || secret != "machine-secret-material" || + r.FormValue("grant_type") != "client_credentials" || r.FormValue("scope") != "tenant.context.read" { + t.Fatal("tenant context token request was not least-privilege client credentials") + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "opaque-machine-token", "token_type": "Bearer", "expires_in": 300, + }) + case "/api/v1/runtime/tenants/" + tenantID: + if r.Header.Get("Authorization") != "Bearer opaque-machine-token" { + t.Fatal("tenant context request omitted the machine token") + } + if r.Header.Get("If-None-Match") == `"tenant-v7"` { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", `"tenant-v7"`) + _ = json.NewEncoder(w).Encode(TenantContext{ + ApplicationID: applicationID, TenantID: tenantID, DisplayName: "Tenant A", Slug: "tenant-a", + TenantStatus: "active", TenantApplicationStatus: "active", Version: "7", + UpdatedAt: time.Unix(1_780_000_000, 0).UTC(), + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + issuer = server.URL + "/issuer/shared" + client, err := NewTenantContextClient( + "test", issuer, applicationID, server.URL+"/api/v1/runtime/tenants/{tenantId}", + "urn:easyai:auth-center:tenant-context", "tenant.context.read", + func(context.Context) (string, []byte, error) { + return "gateway-machine", []byte("machine-secret-material"), nil + }, server.Client(), + ) + if err != nil { + t.Fatal(err) + } + tenant, unchanged, err := client.Get(context.Background(), tenantID, "") + if err != nil || unchanged || tenant.ETag != `"tenant-v7"` || !tenant.Active() { + t.Fatalf("tenant=%#v unchanged=%t error=%v", tenant, unchanged, err) + } + _, unchanged, err = client.Get(context.Background(), tenantID, tenant.ETag) + if err != nil || !unchanged { + t.Fatalf("etag request unchanged=%t error=%v", unchanged, err) + } +} + +func TestTenantContextClientDistinguishesNotFoundFromTemporaryFailure(t *testing.T) { + tenantID, applicationID := uuid.NewString(), uuid.NewString() + for _, test := range []struct { + status int + want error + }{ + {status: http.StatusNotFound, want: ErrTenantContextNotFound}, + {status: http.StatusServiceUnavailable, want: ErrTenantContextUnavailable}, + } { + t.Run(http.StatusText(test.status), func(t *testing.T) { + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/issuer/shared/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, "token_endpoint": serverURL(r) + "/issuer/shared/token", + }) + case r.URL.Path == "/issuer/shared/token": + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "opaque-machine-token", "token_type": "Bearer", "expires_in": 300, + }) + default: + w.WriteHeader(test.status) + } + })) + defer server.Close() + issuer = server.URL + "/issuer/shared" + client, err := NewTenantContextClient( + "test", issuer, applicationID, server.URL+"/api/v1/runtime/tenants/{tenantId}", + "urn:easyai:auth-center:tenant-context", "tenant.context.read", + func(context.Context) (string, []byte, error) { + return "gateway-machine", []byte("machine-secret-material"), nil + }, server.Client(), + ) + if err != nil { + t.Fatal(err) + } + if _, _, err := client.Get(context.Background(), tenantID, ""); err != test.want { + t.Fatalf("error=%v want=%v", err, test.want) + } + }) + } +} + +func serverURL(request *http.Request) string { + return "http://" + request.Host +} diff --git a/apps/api/internal/identityruntime/builder.go b/apps/api/internal/identityruntime/builder.go index 5c3b569..734be4f 100644 --- a/apps/api/internal/identityruntime/builder.go +++ b/apps/api/internal/identityruntime/builder.go @@ -99,14 +99,18 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil { return nil, err } - if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" || - revision.Audience == "" || revision.RolePrefix == "" { + if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || + revision.Audience == "" || revision.RolePrefix == "" || + revision.TenantMode != "multi_tenant" && revision.TenantID == "" || + revision.TenantMode == "multi_tenant" && (revision.ApplicationID == "" || revision.TenantContextEndpoint == "") { return nil, errors.New("identity runtime configuration is incomplete") } - if exists, err := builder.store.HasActiveTenantKey(ctx, revision.LocalTenantKey); err != nil { - return nil, err - } else if !exists { - return nil, identity.ErrLocalTenantInvalid + if revision.TenantMode != "multi_tenant" { + if exists, err := builder.store.HasActiveTenantKey(ctx, revision.LocalTenantKey); err != nil { + return nil, err + } else if !exists { + return nil, identity.ErrLocalTenantInvalid + } } runtimeCtx, cancel := context.WithCancel(builder.ctx) runtime := &Runtime{Revision: revision, CookieSecure: secureCookieFor(revision.PublicBaseURL), close: cancel} @@ -151,6 +155,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ AppEnv: builder.config.AppEnv, Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID, + TenantMode: revision.TenantMode, ApplicationID: revision.ApplicationID, ClientID: revision.BrowserClientID, RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...), JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection, IntrospectionCredentialProvider: credentialProvider, SecurityEventEvaluator: evaluator, @@ -166,6 +171,23 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi return nil, err } runtime.Verifier = verifier + if revision.TenantMode == "multi_tenant" { + tenantContext, err := identity.NewTenantContextClient( + builder.config.AppEnv, revision.Issuer, revision.ApplicationID, + revision.TenantContextEndpoint, revision.TenantContextAudience, revision.TenantContextScope, + credentialProvider, nil, + ) + if err != nil { + cancel() + return nil, err + } + runtime.TenantContext = tenantContext + runtime.start = func() { + go runTenantContextSynchronizer( + runtimeCtx, builder.store, tenantContext, revision.Issuer, revision.ApplicationID, + ) + } + } if slices.Contains(revision.Capabilities, "oidc_login") { if revision.BrowserClientID == "" || revision.SessionEncryptionKeyRef == "" { @@ -396,6 +418,7 @@ func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revi func (builder *RuntimeBuilder) securityEventManagerConfig(revision identity.Revision, strictRevisionBinding bool) securityevents.ConnectionManagerConfig { return securityevents.ConnectionManagerConfig{ AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID, + OIDCApplicationID: revision.ApplicationID, OIDCTenantMode: revision.TenantMode, ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL, ExpectedTransmitterIssuer: strings.TrimRight(strings.TrimSpace(revision.SecurityEventIssuer), "/"), ExpectedAudience: revision.SecurityEventAudience, diff --git a/apps/api/internal/identityruntime/manager.go b/apps/api/internal/identityruntime/manager.go index 2460b5d..470e7ff 100644 --- a/apps/api/internal/identityruntime/manager.go +++ b/apps/api/internal/identityruntime/manager.go @@ -40,11 +40,20 @@ type Runtime struct { Sessions *oidcsession.Service SessionCipher *oidcsession.Cipher SecurityEvents *securityevents.ConnectionManager + TenantContext *identity.TenantContextClient CookieSecure bool close func() + start func() + startOnce sync.Once securityEventDisconnector SecurityEventDisconnector } +func (runtime *Runtime) Start() { + if runtime != nil && runtime.start != nil { + runtime.startOnce.Do(runtime.start) + } +} + func (runtime *Runtime) Close() { if runtime != nil && runtime.close != nil { runtime.close() @@ -378,6 +387,7 @@ func (manager *Manager) publishRuntime(candidate *Runtime, revision identity.Rev manager.rememberTrustedWebBaseURL(revision.WebBaseURL) manager.legacyJWTAllowed.Store(revision.LegacyJWTEnabled) old := manager.current.Swap(candidate) + candidate.Start() manager.adoptPreparedSecurityEvents(candidate, revision.ID) manager.reconciliationRequired.Store(false) retireRuntime(old) diff --git a/apps/api/internal/identityruntime/tenant_context_sync.go b/apps/api/internal/identityruntime/tenant_context_sync.go new file mode 100644 index 0000000..e6a227b --- /dev/null +++ b/apps/api/internal/identityruntime/tenant_context_sync.go @@ -0,0 +1,76 @@ +package identityruntime + +import ( + "context" + "errors" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type tenantContextSyncRepository interface { + DueOIDCTenantBindingSyncs(context.Context, string, string, int) ([]store.OIDCTenantBindingSyncTarget, error) + ApplyOIDCTenantBindingSync(context.Context, string, identity.TenantContext, bool, time.Time) error + RejectOIDCTenantBinding(context.Context, string, string, time.Time) error + FailOIDCTenantBindingSync(context.Context, string, string, time.Time) error +} + +type tenantContextSyncReader interface { + Get(context.Context, string, string) (identity.TenantContext, bool, error) +} + +func runTenantContextSynchronizer(ctx context.Context, repository tenantContextSyncRepository, reader tenantContextSyncReader, issuer, applicationID string) { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + _ = synchronizeDueTenantContexts(ctx, repository, reader, issuer, applicationID, now.UTC()) + } + } +} + +func synchronizeDueTenantContexts(ctx context.Context, repository tenantContextSyncRepository, reader tenantContextSyncReader, issuer, applicationID string, now time.Time) error { + targets, err := repository.DueOIDCTenantBindingSyncs(ctx, issuer, applicationID, 50) + if err != nil { + return err + } + for _, target := range targets { + tenant, unchanged, readErr := reader.Get(ctx, target.ExternalTenantID, target.ETag) + switch { + case readErr == nil && unchanged: + err = repository.ApplyOIDCTenantBindingSync(ctx, target.ID, identity.TenantContext{}, true, now) + case readErr == nil && tenant.Active(): + err = repository.ApplyOIDCTenantBindingSync(ctx, target.ID, tenant, false, now) + case readErr == nil: + err = repository.RejectOIDCTenantBinding(ctx, target.ID, "tenant_inactive", now) + case errors.Is(readErr, identity.ErrTenantContextNotFound): + err = repository.RejectOIDCTenantBinding(ctx, target.ID, "tenant_not_found", now) + default: + err = repository.FailOIDCTenantBindingSync( + ctx, target.ID, "tenant_context_unavailable", now.Add(tenantContextRetryDelay(target.FailureCount)), + ) + } + if err != nil && !errors.Is(err, store.ErrOIDCTenantUnavailable) { + return err + } + } + return nil +} + +func tenantContextRetryDelay(failureCount int) time.Duration { + if failureCount < 0 { + failureCount = 0 + } + delay := 30 * time.Second + for index := 0; index < failureCount && delay < 5*time.Minute; index++ { + delay *= 2 + } + if delay > 5*time.Minute { + return 5 * time.Minute + } + return delay +} diff --git a/apps/api/internal/identityruntime/tenant_context_sync_test.go b/apps/api/internal/identityruntime/tenant_context_sync_test.go new file mode 100644 index 0000000..ba3053f --- /dev/null +++ b/apps/api/internal/identityruntime/tenant_context_sync_test.go @@ -0,0 +1,113 @@ +package identityruntime + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type tenantSyncRepository struct { + targets []store.OIDCTenantBindingSyncTarget + synced []string + unchanged []string + rejected []string + failed []string + failureNext time.Time +} + +func (repository *tenantSyncRepository) DueOIDCTenantBindingSyncs(context.Context, string, string, int) ([]store.OIDCTenantBindingSyncTarget, error) { + return repository.targets, nil +} +func (repository *tenantSyncRepository) ApplyOIDCTenantBindingSync(_ context.Context, id string, _ identity.TenantContext, unchanged bool, _ time.Time) error { + if unchanged { + repository.unchanged = append(repository.unchanged, id) + } else { + repository.synced = append(repository.synced, id) + } + return nil +} +func (repository *tenantSyncRepository) RejectOIDCTenantBinding(_ context.Context, id, _ string, _ time.Time) error { + repository.rejected = append(repository.rejected, id) + return nil +} +func (repository *tenantSyncRepository) FailOIDCTenantBindingSync(_ context.Context, id, _ string, next time.Time) error { + repository.failed = append(repository.failed, id) + repository.failureNext = next + return nil +} + +type tenantSyncReader struct { + tenants map[string]identity.TenantContext + unchanged map[string]bool + errors map[string]error +} + +func (reader tenantSyncReader) Get(_ context.Context, tenantID, _ string) (identity.TenantContext, bool, error) { + return reader.tenants[tenantID], reader.unchanged[tenantID], reader.errors[tenantID] +} + +func TestSynchronizeDueTenantContextsAppliesETagRejectsAndBacksOff(t *testing.T) { + now := time.Unix(1_780_000_000, 0).UTC() + repository := &tenantSyncRepository{targets: []store.OIDCTenantBindingSyncTarget{ + {ID: "binding-a", ExternalTenantID: "tenant-a", ETag: `"a"`, FailureCount: 0}, + {ID: "binding-b", ExternalTenantID: "tenant-b", ETag: `"b"`, FailureCount: 0}, + {ID: "binding-c", ExternalTenantID: "tenant-c", FailureCount: 3}, + {ID: "binding-d", ExternalTenantID: "tenant-d", FailureCount: 0}, + }} + reader := tenantSyncReader{ + tenants: map[string]identity.TenantContext{ + "tenant-a": {TenantStatus: "active", TenantApplicationStatus: "active"}, + "tenant-d": {TenantStatus: "suspended", TenantApplicationStatus: "active"}, + }, + unchanged: map[string]bool{"tenant-b": true}, + errors: map[string]error{ + "tenant-c": identity.ErrTenantContextUnavailable, + }, + } + if err := synchronizeDueTenantContexts(context.Background(), repository, reader, "issuer", "application", now); err != nil { + t.Fatal(err) + } + if len(repository.synced) != 1 || repository.synced[0] != "binding-a" || + len(repository.unchanged) != 1 || repository.unchanged[0] != "binding-b" || + len(repository.rejected) != 1 || repository.rejected[0] != "binding-d" || + len(repository.failed) != 1 || repository.failed[0] != "binding-c" { + t.Fatalf("sync outcomes=%#v", repository) + } + if repository.failureNext.Sub(now) != 4*time.Minute { + t.Fatalf("retry delay=%s", repository.failureNext.Sub(now)) + } +} + +func TestSynchronizeDueTenantContextsTreatsNotFoundAsRevocation(t *testing.T) { + repository := &tenantSyncRepository{targets: []store.OIDCTenantBindingSyncTarget{ + {ID: "binding-a", ExternalTenantID: "tenant-a"}, + }} + reader := tenantSyncReader{ + tenants: map[string]identity.TenantContext{}, unchanged: map[string]bool{}, + errors: map[string]error{"tenant-a": identity.ErrTenantContextNotFound}, + } + if err := synchronizeDueTenantContexts( + context.Background(), repository, reader, "issuer", "application", time.Now(), + ); err != nil { + t.Fatal(err) + } + if len(repository.rejected) != 1 || len(repository.failed) != 0 { + t.Fatalf("not-found outcome=%#v", repository) + } +} + +func TestTenantContextRetryDelayIsBounded(t *testing.T) { + if got := tenantContextRetryDelay(0); got != 30*time.Second { + t.Fatalf("first retry=%s", got) + } + if got := tenantContextRetryDelay(100); got != 5*time.Minute { + t.Fatalf("bounded retry=%s", got) + } + if !errors.Is(identity.ErrTenantContextUnavailable, identity.ErrTenantContextUnavailable) { + t.Fatal("sentinel error changed unexpectedly") + } +} diff --git a/apps/api/internal/oidcsession/login_transaction.go b/apps/api/internal/oidcsession/login_transaction.go index ac5359f..551f8f1 100644 --- a/apps/api/internal/oidcsession/login_transaction.go +++ b/apps/api/internal/oidcsession/login_transaction.go @@ -7,6 +7,8 @@ import ( "net/url" "strings" "time" + + "github.com/google/uuid" ) const LoginTransactionCookieName = "easyai_gateway_oidc_login" @@ -18,10 +20,15 @@ type LoginTransaction struct { Nonce string `json:"nonce"` PKCEVerifier string `json:"pkceVerifier"` ReturnTo string `json:"returnTo"` + TenantHint string `json:"tenantHint,omitempty"` CreatedAt time.Time `json:"createdAt"` } func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, error) { + return NewLoginTransactionWithTenantHint(returnTo, "", now) +} + +func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Time) (LoginTransaction, error) { if !ValidReturnTo(returnTo) { return LoginTransaction{}, errors.New("returnTo must be a same-origin relative path") } @@ -37,7 +44,10 @@ func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, erro if err != nil { return LoginTransaction{}, err } - return LoginTransaction{State: state, Nonce: nonce, PKCEVerifier: verifier, ReturnTo: returnTo, CreatedAt: now.UTC()}, nil + return LoginTransaction{ + State: state, Nonce: nonce, PKCEVerifier: verifier, + ReturnTo: returnTo, TenantHint: strings.TrimSpace(tenantHint), CreatedAt: now.UTC(), + }, nil } func (c *Cipher) EncodeLoginTransaction(transaction LoginTransaction) (string, error) { @@ -58,6 +68,7 @@ func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTra return LoginTransaction{}, err } if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) || + transaction.TenantHint != "" && uuid.Validate(transaction.TenantHint) != nil || transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) { return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid") } diff --git a/apps/api/internal/oidcsession/login_transaction_test.go b/apps/api/internal/oidcsession/login_transaction_test.go index bdc8d73..e48bbc1 100644 --- a/apps/api/internal/oidcsession/login_transaction_test.go +++ b/apps/api/internal/oidcsession/login_transaction_test.go @@ -33,6 +33,27 @@ func TestLoginTransactionIsEncryptedAndBounded(t *testing.T) { } } +func TestLoginTransactionEncryptsTenantHint(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + cipher, _ := NewCipher(bytes.Repeat([]byte{9}, 32)) + tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + transaction, err := NewLoginTransactionWithTenantHint("/", tenantHint, now) + if err != nil { + t.Fatal(err) + } + encoded, err := cipher.EncodeLoginTransaction(transaction) + if err != nil { + t.Fatal(err) + } + if strings.Contains(encoded, tenantHint) { + t.Fatal("login transaction cookie contains plaintext tenant hint") + } + decoded, err := cipher.DecodeLoginTransaction(encoded, now) + if err != nil || decoded.TenantHint != tenantHint { + t.Fatalf("decoded transaction=%+v err=%v", decoded, err) + } +} + func TestValidReturnToRejectsOpenRedirects(t *testing.T) { for _, value := range []string{"https://evil.example", "//evil.example", "/\\evil", "", "workspace"} { if ValidReturnTo(value) { diff --git a/apps/api/internal/oidcsession/service.go b/apps/api/internal/oidcsession/service.go index bc33f14..4975fb4 100644 --- a/apps/api/internal/oidcsession/service.go +++ b/apps/api/internal/oidcsession/service.go @@ -85,6 +85,12 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut if err != nil || verified == nil || verified.Source != "oidc" || verified.ID == "" || verified.ID != localUser.ID { return "", ErrSessionInvalid } + if localUser.OIDCUserBindingID != "" && + (localUser.Issuer == "" || localUser.ApplicationID == "" || localUser.TenantID == "" || localUser.OIDCClientID == "" || + verified.Issuer != localUser.Issuer || verified.ApplicationID != localUser.ApplicationID || + verified.TenantID != localUser.TenantID || verified.OIDCClientID != localUser.OIDCClientID) { + return "", ErrSessionInvalid + } now := s.now() if !verified.TokenExpiresAt.After(now) { return "", ErrSessionExpired @@ -100,6 +106,8 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut } _, err = s.repository.CreateOIDCSession(ctx, store.CreateOIDCSessionInput{ SessionTokenHash: hash, GatewayUserID: localUser.GatewayUserID, GatewayTenantID: localUser.GatewayTenantID, + OIDCUserBindingID: localUser.OIDCUserBindingID, OIDCClientID: verified.OIDCClientID, + Issuer: verified.Issuer, ApplicationID: verified.ApplicationID, TenantID: verified.TenantID, TokenCiphertext: ciphertext, AccessTokenExpiresAt: verified.TokenExpiresAt, LastSeenAt: now, IdleExpiresAt: now.Add(s.config.IdleTTL), AbsoluteExpiresAt: now.Add(s.config.AbsoluteTTL), }) @@ -142,6 +150,9 @@ func (s *Service) resolveRecord(ctx context.Context, hash []byte, record store.O } user, err := s.verifySessionUser(ctx, record, bundle.AccessToken) if err != nil { + if errors.Is(err, ErrSessionInvalid) { + _ = s.repository.DeleteOIDCSessionByID(ctx, record.ID) + } return nil, err } if err := s.touch(ctx, record, now); err != nil { @@ -264,6 +275,11 @@ func (s *Service) verifySessionUser(ctx context.Context, record store.OIDCSessio if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID { return nil, ErrSessionInvalid } + if record.OIDCUserBindingID != "" && + (user.Issuer != record.Issuer || user.ApplicationID != record.ApplicationID || + user.TenantID != record.TenantID || user.OIDCClientID != record.OIDCClientID) { + return nil, ErrSessionInvalid + } return user, nil } diff --git a/apps/api/internal/oidcsession/service_test.go b/apps/api/internal/oidcsession/service_test.go index dcd0e9a..93bd981 100644 --- a/apps/api/internal/oidcsession/service_test.go +++ b/apps/api/internal/oidcsession/service_test.go @@ -255,6 +255,42 @@ func TestServiceDeletesSessionEvenWhenCiphertextCannotBeDecryptedDuringLogout(t } } +func TestServiceBindsMultiTenantSessionToIssuerApplicationTenantAndClient(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + tenantA := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + tenantB := "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + applicationID := "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + verified := &auth.User{ + ID: "shared-subject", Source: "oidc", Issuer: "https://auth.example.test", + ApplicationID: applicationID, TenantID: tenantA, OIDCClientID: "gateway-browser", + TokenExpiresAt: now.Add(5 * time.Minute), + } + verifier := fakeVerifier{users: map[string]*auth.User{"access": verified}} + repository := newFakeRepository("shared-subject") + service := newTestService(t, repository, verifier, &fakePublicClient{}) + service.now = func() time.Time { return now } + local := &auth.User{ + ID: "shared-subject", GatewayUserID: "11111111-1111-4111-8111-111111111111", + GatewayTenantID: "22222222-2222-4222-8222-222222222222", + OIDCUserBindingID: "44444444-4444-4444-8444-444444444444", + Issuer: "https://auth.example.test", ApplicationID: applicationID, + TenantID: tenantA, OIDCClientID: "gateway-browser", + } + raw, err := service.Create(context.Background(), TokenBundle{ + AccessToken: "access", RefreshToken: "refresh", + }, local) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + verified.TenantID = tenantB + if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionInvalid) { + t.Fatalf("Resolve() error = %v, want ErrSessionInvalid", err) + } + if !repository.isDeleted() { + t.Fatal("identity-binding mismatch did not destroy the session") + } +} + func testLocalUser() *auth.User { return &auth.User{ ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111", @@ -323,6 +359,8 @@ func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.Create f.record = store.OIDCSession{ ID: "33333333-3333-4333-8333-333333333333", SessionTokenHash: append([]byte(nil), input.SessionTokenHash...), GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: f.external, + OIDCUserBindingID: input.OIDCUserBindingID, OIDCClientID: input.OIDCClientID, + Issuer: input.Issuer, ApplicationID: input.ApplicationID, TenantID: input.TenantID, UserStatus: "active", TokenCiphertext: append([]byte(nil), input.TokenCiphertext...), AccessTokenExpiresAt: input.AccessTokenExpiresAt, LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1, } diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go index cb101f9..79f4491 100644 --- a/apps/api/internal/securityevents/connection_manager.go +++ b/apps/api/internal/securityevents/connection_manager.go @@ -50,6 +50,8 @@ type ConnectionManagerConfig struct { OIDCEnabled bool OIDCIssuer string OIDCTenantID string + OIDCApplicationID string + OIDCTenantMode string ManagementClientID string ManagementClientSecret string ExpectedTransmitterIssuer string @@ -145,7 +147,9 @@ func (err safeConnectionError) Unwrap() error { return err.cause } func (err safeConnectionError) SafeErrorCategory() string { return err.category } func NewConnectionManager(ctx context.Context, repository ConnectionRepository, secrets SecretStore, config ConnectionManagerConfig, metrics *Metrics) (*ConnectionManager, error) { - if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || config.OIDCTenantID == "" { + validIdentityBinding := config.OIDCTenantMode == "multi_tenant" && uuid.Validate(config.OIDCApplicationID) == nil || + config.OIDCTenantMode != "multi_tenant" && uuid.Validate(config.OIDCTenantID) == nil + if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || !validIdentityBinding { return nil, errors.New("security event connection prerequisites are incomplete") } if config.HeartbeatInterval <= 0 { @@ -773,7 +777,8 @@ func (m *ConnectionManager) Evaluate(ctx context.Context, identity auth.OIDCSecu return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, nil } result, evaluateErr := m.repository.EvaluateOIDCSecurityEvent( - ctx, connection.TransmitterIssuer, *connection.Audience, identity.Issuer, identity.TenantID, identity.Subject, + ctx, connection.TransmitterIssuer, *connection.Audience, identity.Issuer, identity.ApplicationID, + identity.TenantID, identity.Subject, identity.IssuedAt, time.Now().UTC(), m.config.StaleAfter, ) if result.Revoked && m.metrics != nil { @@ -821,7 +826,9 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur } verifier, err := NewVerifier(VerifierConfig{ TransmitterIssuer: connection.TransmitterIssuer, Audience: *connection.Audience, - SubjectIssuer: m.config.OIDCIssuer, TenantID: m.config.OIDCTenantID, StreamID: *connection.StreamID, + SubjectIssuer: m.config.OIDCIssuer, TenantID: m.config.OIDCTenantID, + ApplicationID: m.config.OIDCApplicationID, TenantMode: m.config.OIDCTenantMode, + StreamID: *connection.StreamID, ClockSkew: m.config.ClockSkew, HTTPClient: safeClient, }) if err != nil { @@ -1038,7 +1045,11 @@ func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID str if !m.config.OIDCEnabled || strings.TrimSpace(managementClientID) == "" || len(managementSecret) < 16 { return errors.New("OIDC and RFC 7662 machine client must be configured") } - if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil { + if m.config.OIDCTenantMode == "multi_tenant" { + if uuid.Validate(m.config.OIDCApplicationID) != nil { + return errors.New("OIDC application id is invalid") + } + } else if uuid.Validate(m.config.OIDCTenantID) != nil { return errors.New("OIDC tenant id is invalid") } if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil { diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go index 16baae7..8933b21 100644 --- a/apps/api/internal/securityevents/connection_manager_test.go +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -123,7 +123,7 @@ func (*memoryConnectionRepository) RecordSecurityEventHeartbeatFailure(context.C func (*memoryConnectionRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error { return nil } -func (r *memoryConnectionRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) { +func (r *memoryConnectionRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) { if r.evaluation.Mode == "" { return store.SecurityEventEvaluation{Mode: "bootstrap", RequireIntrospection: true}, nil } diff --git a/apps/api/internal/securityevents/handler.go b/apps/api/internal/securityevents/handler.go index 8c9f7b0..aa7a1f4 100644 --- a/apps/api/internal/securityevents/handler.go +++ b/apps/api/internal/securityevents/handler.go @@ -88,7 +88,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var result store.ApplySecurityEventResult result, err = h.repository.ApplySessionRevoked(r.Context(), store.ApplySessionRevokedInput{ Issuer: event.Issuer, Audience: event.Audience, JTI: event.JTI, TransactionID: event.TransactionID, - SubjectIssuer: event.SubjectIssuer, TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp, + SubjectIssuer: event.SubjectIssuer, ApplicationID: event.ApplicationID, SubjectType: event.SubjectType, + TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp, InitiatingEntity: event.InitiatingEntity, }) if err == nil { diff --git a/apps/api/internal/securityevents/handler_test.go b/apps/api/internal/securityevents/handler_test.go index b8dd894..fb992a6 100644 --- a/apps/api/internal/securityevents/handler_test.go +++ b/apps/api/internal/securityevents/handler_test.go @@ -109,6 +109,54 @@ func TestReceiverAcceptsValidSessionRevokedAndDuplicate(t *testing.T) { } } +func TestVerifierAcceptsApplicationScopedPrincipalAndTenantEvents(t *testing.T) { + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + applicationID, tenantID, subjectID := uuid.NewString(), uuid.NewString(), uuid.NewString() + verifier := &Verifier{config: VerifierConfig{ + TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:" + applicationID, + SubjectIssuer: "https://auth.example/issuer/shared", ApplicationID: applicationID, + TenantMode: "multi_tenant", StreamID: uuid.NewString(), ClockSkew: time.Minute, + }, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient} + now := time.Now().UTC().Truncate(time.Second) + claims := func(subjectType, subject, eventApplicationID string) jwt.MapClaims { + return jwt.MapClaims{ + "iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": now.Unix(), + "jti": uuid.NewString(), "txn": uuid.NewString(), + "sub_id": map[string]any{ + "format": "complex", + "user": map[string]any{"format": "iss_sub", "iss": verifier.config.SubjectIssuer, "sub": subject}, + "tenant": map[string]any{"format": "opaque", "id": tenantID}, + }, + "events": map[string]any{SessionRevokedEventType: map[string]any{ + "event_timestamp": now.Unix(), "initiating_entity": "admin", + "application_id": eventApplicationID, "subject_type": subjectType, + }}, + } + } + for _, test := range []struct { + name, subjectType, subject string + }{ + {name: "principal", subjectType: "principal", subject: subjectID}, + {name: "tenant", subjectType: "tenant", subject: tenantID}, + } { + t.Run(test.name, func(t *testing.T) { + event, err := verifier.Verify(context.Background(), signSET(t, privateKey, claims(test.subjectType, test.subject, applicationID))) + if err != nil || event.ApplicationID != applicationID || event.SubjectType != test.subjectType || + event.Subject != test.subject || event.TenantID != tenantID { + t.Fatalf("event=%#v error=%v", event, err) + } + }) + } + for _, invalid := range []jwt.MapClaims{ + claims("principal", subjectID, uuid.NewString()), + claims("tenant", subjectID, applicationID), + } { + if _, err := verifier.Verify(context.Background(), signSET(t, privateKey, invalid)); err == nil { + t.Fatal("invalid application-scoped event was accepted") + } + } +} + func TestReceiverRejectsAuthenticationMediaTypeAndForbiddenClaims(t *testing.T) { privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) verifier := &Verifier{config: VerifierConfig{ diff --git a/apps/api/internal/securityevents/service.go b/apps/api/internal/securityevents/service.go index 053d60f..e3436d0 100644 --- a/apps/api/internal/securityevents/service.go +++ b/apps/api/internal/securityevents/service.go @@ -26,7 +26,7 @@ type StateRepository interface { BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error - EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) + EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) } type ServiceConfig struct { @@ -95,7 +95,7 @@ func (s *Service) RequestVerification(ctx context.Context) { s.sendVerification( func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) { result, err := s.repository.EvaluateOIDCSecurityEvent( - ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject, + ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.ApplicationID, identity.TenantID, identity.Subject, identity.IssuedAt, s.clock().UTC(), s.config.StaleAfter, ) if err != nil { diff --git a/apps/api/internal/securityevents/service_test.go b/apps/api/internal/securityevents/service_test.go index d7512c5..36b5089 100644 --- a/apps/api/internal/securityevents/service_test.go +++ b/apps/api/internal/securityevents/service_test.go @@ -34,7 +34,7 @@ func (*fakeStateRepository) RecordSecurityEventHeartbeatFailure(context.Context, func (*fakeStateRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error { return nil } -func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) { +func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) { return f.evaluation, nil } diff --git a/apps/api/internal/securityevents/verifier.go b/apps/api/internal/securityevents/verifier.go index 415c5ac..617da2a 100644 --- a/apps/api/internal/securityevents/verifier.go +++ b/apps/api/internal/securityevents/verifier.go @@ -36,6 +36,8 @@ type Event struct { EventType string EventTimestamp time.Time SubjectIssuer string + ApplicationID string + SubjectType string Subject string TenantID string InitiatingEntity string @@ -50,6 +52,8 @@ type VerifierConfig struct { Audience string SubjectIssuer string TenantID string + ApplicationID string + TenantMode string StreamID string ClockSkew time.Duration HTTPClient *http.Client @@ -93,8 +97,11 @@ func (e *protocolError) Error() string { return e.Code } func NewVerifier(config VerifierConfig) (*Verifier, error) { config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/") config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/") - if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" { - return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required") + multiTenant := config.TenantMode == "multi_tenant" + validBinding := multiTenant && uuid.Validate(config.ApplicationID) == nil || + !multiTenant && uuid.Validate(config.TenantID) == nil + if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || !validBinding { + return nil, errors.New("SSF transmitter, audience, subject issuer, and identity binding are required") } if _, err := uuid.Parse(config.StreamID); err != nil { return nil, errors.New("SSF stream id must be a UUID") @@ -179,6 +186,7 @@ func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) { return Event{}, &protocolError{Code: "invalid_request"} } event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"]) + event.ApplicationID, event.SubjectType = stringValue(value["application_id"]), stringValue(value["subject_type"]) if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) || !validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) { return Event{}, &protocolError{Code: "invalid_request"} @@ -259,11 +267,19 @@ func (v *Verifier) readSessionSubject(raw any, event *Event) bool { return false } event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"]) - if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID { + if event.SubjectIssuer != v.config.SubjectIssuer || uuid.Validate(event.TenantID) != nil { return false } - _, err := uuid.Parse(event.Subject) - return err == nil + if v.config.TenantMode == "multi_tenant" { + if event.ApplicationID != v.config.ApplicationID || + event.SubjectType != "principal" && event.SubjectType != "tenant" || + event.SubjectType == "tenant" && event.Subject != event.TenantID { + return false + } + } else if event.ApplicationID != "" || event.SubjectType != "" || event.TenantID != v.config.TenantID { + return false + } + return uuid.Validate(event.Subject) == nil } func (v *Verifier) readStreamSubject(raw any, event *Event) bool { diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go index 2ef31ad..f2d0407 100644 --- a/apps/api/internal/store/identity_configurations.go +++ b/apps/api/internal/store/identity_configurations.go @@ -21,10 +21,11 @@ type IdentityManagementRequest struct { } const identityRevisionColumns = ` -id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''), +id::text,state,schema_version,tenant_mode,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''), COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix, local_tenant_key,public_base_url,web_base_url,jit_enabled,legacy_jwt_enabled,token_introspection,session_revocation, COALESCE(security_event_issuer,''),COALESCE(security_event_configuration_url,''),COALESCE(security_event_audience,''), +COALESCE(tenant_context_endpoint,''),COALESCE(tenant_context_audience,''),COALESCE(tenant_context_scope,''), COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),session_idle_seconds,session_absolute_seconds, session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''), validated_at,activated_at,superseded_at,created_at,updated_at` @@ -83,11 +84,11 @@ func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revisio capabilities, _ := json.Marshal(revision.Capabilities) return scanIdentityRevision(s.pool.QueryRow(ctx, ` INSERT INTO gateway_identity_configuration_revisions ( - id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url, + id,state,schema_version,tenant_mode,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url, jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds -) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15) +) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13::jsonb,$14,$15,$16) RETURNING `+identityRevisionColumns, - revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix, + revision.ID, revision.State, revision.SchemaVersion, revision.TenantMode, revision.AuthCenterURL, revision.RolePrefix, revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled, string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, revision.SessionRefreshSeconds, )) @@ -198,16 +199,19 @@ FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, id) capabilities, _ := json.Marshal(updated.Capabilities) revision, err := scanIdentityRevision(tx.QueryRow(ctx, ` UPDATE gateway_identity_configuration_revisions SET - issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''), - scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12, - security_event_issuer=NULLIF($13,''),security_event_configuration_url=NULLIF($14,''),security_event_audience=NULLIF($15,''), - machine_credential_ref=NULLIF($16,''),session_encryption_key_ref=NULLIF($17,''),last_trace_id=NULLIF($18,''), - last_audit_id=NULLIF($19,''),last_error_category=NULL,version=version+1,updated_at=now() + schema_version=$3,tenant_mode=$4,issuer=$5,tenant_id=NULLIF($6,''),application_id=$7,audience=NULLIF($8,''), + browser_client_id=NULLIF($9,''),machine_client_id=NULLIF($10,''),local_tenant_key=$11, + scopes=$12::jsonb,capabilities=$13::jsonb,token_introspection=$14,session_revocation=$15, + security_event_issuer=NULLIF($16,''),security_event_configuration_url=NULLIF($17,''),security_event_audience=NULLIF($18,''), + tenant_context_endpoint=NULLIF($19,''),tenant_context_audience=NULLIF($20,''),tenant_context_scope=NULLIF($21,''), + machine_credential_ref=NULLIF($22,''),session_encryption_key_ref=NULLIF($23,''),last_trace_id=NULLIF($24,''), + last_audit_id=NULLIF($25,''),last_error_category=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='draft' RETURNING `+identityRevisionColumns, - id, expectedVersion, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience, - updated.BrowserClientID, updated.MachineClientID, string(scopes), string(capabilities), updated.TokenIntrospection, + id, expectedVersion, updated.SchemaVersion, updated.TenantMode, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience, + updated.BrowserClientID, updated.MachineClientID, updated.LocalTenantKey, string(scopes), string(capabilities), updated.TokenIntrospection, updated.SessionRevocation, updated.SecurityEventIssuer, updated.SecurityEventConfigURL, updated.SecurityEventAudience, + updated.TenantContextEndpoint, updated.TenantContextAudience, updated.TenantContextScope, updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID, )) if errors.Is(err, pgx.ErrNoRows) { @@ -296,8 +300,12 @@ func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expecte return identity.Revision{}, false, identity.ErrBreakGlassRequired } var tenantExists bool - if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=( -SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$1::uuid) AND status='active')`, id).Scan(&tenantExists); err != nil { + if err := tx.QueryRow(ctx, `SELECT revision.tenant_mode='multi_tenant' OR EXISTS( + SELECT 1 FROM gateway_tenants tenant + WHERE tenant.tenant_key=revision.local_tenant_key AND tenant.status='active' +) +FROM gateway_identity_configuration_revisions revision +WHERE revision.id=$1::uuid`, id).Scan(&tenantExists); err != nil { return identity.Revision{}, false, err } if !tenantExists { @@ -428,11 +436,12 @@ func scanIdentityRevision(row scanner) (identity.Revision, error) { var state string var scopes, capabilities []byte if err := row.Scan( - &revision.ID, &state, &revision.SchemaVersion, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID, + &revision.ID, &state, &revision.SchemaVersion, &revision.TenantMode, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID, &revision.ApplicationID, &revision.Audience, &revision.BrowserClientID, &revision.MachineClientID, &scopes, &capabilities, &revision.RolePrefix, &revision.LocalTenantKey, &revision.PublicBaseURL, &revision.WebBaseURL, &revision.JITEnabled, &revision.LegacyJWTEnabled, &revision.TokenIntrospection, &revision.SessionRevocation, &revision.SecurityEventIssuer, &revision.SecurityEventConfigURL, &revision.SecurityEventAudience, + &revision.TenantContextEndpoint, &revision.TenantContextAudience, &revision.TenantContextScope, &revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds, &revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory, &revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt, diff --git a/apps/api/internal/store/oidc_multi_tenant_users.go b/apps/api/internal/store/oidc_multi_tenant_users.go new file mode 100644 index 0000000..a2b59f8 --- /dev/null +++ b/apps/api/internal/store/oidc_multi_tenant_users.go @@ -0,0 +1,261 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) { + if input.Issuer == "" || uuid.Validate(input.ApplicationID) != nil || uuid.Validate(input.TenantID) != nil || + input.Subject == "" || input.TenantMetadataStatus != "synced" && input.TenantMetadataStatus != "metadata_pending" { + return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid multi-tenant OIDC user projection input") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + + bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err := + loadOIDCTenantBinding(ctx, tx, input) + if errors.Is(err, pgx.ErrNoRows) { + if !input.ProvisioningEnabled { + return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned + } + bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err = + createOIDCTenantBinding(ctx, tx, input) + accessStatus = "active" + } + if err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + if accessStatus != "active" { + if input.TenantMetadataStatus != "synced" { + return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable + } + tag, reactivateErr := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings + SET access_status='active',metadata_status='synced',last_error_category=NULL,updated_at=now() + WHERE id=$1::uuid AND access_status='disabled'`, bindingID) + if reactivateErr != nil { + return ResolveOrProvisionOIDCUserResult{}, reactivateErr + } + if tag.RowsAffected() != 1 { + return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable + } + accessStatus = "active" + } + if input.TenantMetadataStatus == "synced" { + if _, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings SET + metadata_status='synced',display_name=$2,slug=$3,metadata_version=NULLIF($4,''), + metadata_etag=NULLIF($5,''),metadata_updated_at=$6, + last_sync_at=now(),next_sync_at=now()+interval '15 minutes',sync_failure_count=0,last_error_category=NULL,updated_at=now() + WHERE id=$1::uuid AND access_status='active'`, + bindingID, input.TenantName, input.TenantSlug, input.TenantMetadataVersion, + input.TenantMetadataETag, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt), + ); err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE gateway_tenants SET name=$2,synced_at=now(),source_updated_at=$3,updated_at=now() + WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`, + gatewayTenantID, input.TenantName, input.TenantMetadataUpdatedAt, + ); err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + } + + user, userBindingID, err := loadOIDCMultiTenantUser(ctx, tx, bindingID, input.Subject) + created := false + if errors.Is(err, pgx.ErrNoRows) { + if !input.ProvisioningEnabled { + return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned + } + user, userBindingID, created, err = s.createOIDCMultiTenantUser( + ctx, tx, bindingID, gatewayTenantID, tenantKey, groupID, input, + ) + } + if err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + if user.Status != "active" { + return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled + } + rolesJSON, _ := json.Marshal(input.Roles) + user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET + username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now() + WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL + RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON))) + if err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + var auditID string + if created { + subjectHash := sha256.Sum256([]byte(input.Subject)) + audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{ + Category: "identity", Action: "identity.oidc_user.provisioned", + ActorGatewayUserID: user.ID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles, + TargetType: "gateway_user", TargetID: user.ID, TargetGatewayUserID: user.ID, + TargetGatewayTenantID: user.GatewayTenantID, RequestIP: input.RequestIP, UserAgent: input.UserAgent, + AfterState: map[string]any{"source": "oidc_v2", "tenantKey": user.TenantKey, "userGroupId": user.DefaultUserGroupID}, + Metadata: map[string]any{ + "provisioningMode": "oidc-multi-tenant-jit", + "externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16], + }, + }) + if err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + auditID = audit.ID + } + if err := tx.Commit(ctx); err != nil { + return ResolveOrProvisionOIDCUserResult{}, err + } + return ResolveOrProvisionOIDCUserResult{ + User: multiTenantAuthUser(user, groupKey, input, userBindingID), + Created: created, AuditID: auditID, + }, nil +} + +func loadOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, string, error) { + var bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus string + err := tx.QueryRow(ctx, `SELECT binding.id::text,tenant.id::text,tenant.tenant_key, + tenant.default_user_group_id::text,group_record.group_key,binding.access_status + FROM gateway_oidc_tenant_bindings binding + JOIN gateway_tenants tenant ON tenant.id=binding.gateway_tenant_id + JOIN gateway_user_groups group_record ON group_record.id=tenant.default_user_group_id + WHERE binding.issuer=$1 AND binding.application_id=$2 AND binding.external_tenant_id=$3 + AND tenant.status='active' AND tenant.deleted_at IS NULL AND group_record.status='active' + FOR UPDATE OF binding,tenant`, + input.Issuer, input.ApplicationID, input.TenantID, + ).Scan(&bindingID, &gatewayTenantID, &tenantKey, &groupID, &groupKey, &accessStatus) + return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err +} + +func createOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, error) { + var groupID, groupKey string + if err := tx.QueryRow(ctx, `SELECT id::text,group_key FROM gateway_user_groups + WHERE group_key='default' AND status='active'`).Scan(&groupID, &groupKey); err != nil { + return "", "", "", "", "", ErrOIDCTenantUnavailable + } + tenantKey := deriveOIDCMultiTenantKey(input.Issuer, input.ApplicationID, input.TenantID) + name := input.TenantName + if name == "" { + name = "认证中心租户 " + strings.ReplaceAll(input.TenantID, "-", "")[:8] + } + metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit", "metadataStatus": input.TenantMetadataStatus}) + var gatewayTenantID string + err := tx.QueryRow(ctx, `INSERT INTO gateway_tenants( + tenant_key,source,external_tenant_id,name,default_user_group_id,metadata,status,synced_at,source_updated_at + ) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4::jsonb,'active', + CASE WHEN $5='synced' THEN now() ELSE NULL END,$6) + ON CONFLICT(tenant_key) DO UPDATE SET updated_at=gateway_tenants.updated_at + RETURNING id::text`, tenantKey, name, groupID, string(metadata), input.TenantMetadataStatus, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt)).Scan(&gatewayTenantID) + if err != nil { + return "", "", "", "", "", err + } + var bindingID string + err = tx.QueryRow(ctx, `INSERT INTO gateway_oidc_tenant_bindings( + issuer,application_id,external_tenant_id,gateway_tenant_id,access_status,metadata_status, + display_name,slug,metadata_version,metadata_etag,metadata_updated_at,last_sync_at,next_sync_at + ) VALUES($1,$2,$3,$4::uuid,'active',$5,NULLIF($6,''),NULLIF($7,''),NULLIF($8,''),NULLIF($9,''), + $10, + CASE WHEN $5='synced' THEN now() ELSE NULL END, + CASE WHEN $5='synced' THEN now()+interval '15 minutes' ELSE now()+interval '30 seconds' END) + ON CONFLICT(issuer,application_id,external_tenant_id) DO UPDATE SET updated_at=gateway_oidc_tenant_bindings.updated_at + RETURNING id::text`, + input.Issuer, input.ApplicationID, input.TenantID, gatewayTenantID, input.TenantMetadataStatus, + input.TenantName, input.TenantSlug, input.TenantMetadataVersion, input.TenantMetadataETag, + nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt), + ).Scan(&bindingID) + return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err +} + +func loadOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, subject string) (GatewayUser, string, error) { + var userBindingID, gatewayUserID string + if err := tx.QueryRow(ctx, `SELECT binding.id::text,binding.gateway_user_id::text + FROM gateway_oidc_user_bindings binding + JOIN gateway_users ON gateway_users.id=binding.gateway_user_id + WHERE binding.tenant_binding_id=$1::uuid AND binding.subject=$2 + AND gateway_users.deleted_at IS NULL + FOR UPDATE OF binding,gateway_users`, bindingID, subject).Scan(&userBindingID, &gatewayUserID); err != nil { + return GatewayUser{}, "", err + } + user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+` + FROM gateway_users WHERE id=$1::uuid`, gatewayUserID)) + return user, userBindingID, err +} + +func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, gatewayTenantID, tenantKey, groupID string, input ResolveOrProvisionOIDCUserInput) (GatewayUser, string, bool, error) { + userKey := deriveOIDCMultiTenantUserKey(input.Issuer, input.ApplicationID, input.TenantID, input.Subject) + username := input.Username + if username == "" { + username = "oidc-" + strings.TrimPrefix(userKey, "oidc2:")[:12] + } + rolesJSON, _ := json.Marshal(input.Roles) + metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"}) + user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users( + user_key,source,external_user_id,username,gateway_tenant_id,tenant_id,tenant_key, + default_user_group_id,roles,auth_profile,metadata,status,last_login_at,synced_at,source_updated_at + ) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb, + 'active',now(),now(),now()) + ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at + RETURNING `+userColumns, + userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata), + )) + if err != nil { + return GatewayUser{}, "", false, err + } + var userBindingID string + tag, err := tx.Exec(ctx, `INSERT INTO gateway_oidc_user_bindings(tenant_binding_id,subject,gateway_user_id) + VALUES($1::uuid,$2,$3::uuid) ON CONFLICT(tenant_binding_id,subject) DO NOTHING`, + bindingID, input.Subject, user.ID) + if err != nil { + return GatewayUser{}, "", false, err + } + created := tag.RowsAffected() == 1 + if err := tx.QueryRow(ctx, `SELECT id::text FROM gateway_oidc_user_bindings + WHERE tenant_binding_id=$1::uuid AND subject=$2`, bindingID, input.Subject).Scan(&userBindingID); err != nil { + return GatewayUser{}, "", false, err + } + if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil { + return GatewayUser{}, "", false, err + } + return user, userBindingID, created, nil +} + +func deriveOIDCMultiTenantKey(issuer, applicationID, tenantID string) string { + sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID)) + return fmt.Sprintf("oidc2-tenant-%x", sum[:16]) +} + +func nullableOIDCTenantMetadataTime(value time.Time) any { + if value.IsZero() { + return nil + } + return value +} + +func deriveOIDCMultiTenantUserKey(issuer, applicationID, tenantID, subject string) string { + sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID + "\x00" + subject)) + return fmt.Sprintf("oidc2:%x", sum) +} + +func multiTenantAuthUser(user GatewayUser, groupKey string, input ResolveOrProvisionOIDCUserInput, userBindingID string) *auth.User { + result := authUserFromOIDCProjection(user, groupKey) + result.ID = input.Subject + result.TenantName = input.TenantName + result.Issuer = input.Issuer + result.ApplicationID = input.ApplicationID + result.OIDCClientID = input.OIDCClientID + result.OIDCUserBindingID = userBindingID + return result +} diff --git a/apps/api/internal/store/oidc_sessions.go b/apps/api/internal/store/oidc_sessions.go index 73ac579..dafc7e3 100644 --- a/apps/api/internal/store/oidc_sessions.go +++ b/apps/api/internal/store/oidc_sessions.go @@ -15,7 +15,12 @@ type OIDCSession struct { SessionTokenHash []byte GatewayUserID string GatewayTenantID string + OIDCUserBindingID string ExternalUserID string + Issuer string + ApplicationID string + TenantID string + OIDCClientID string UserStatus string UserDeleted bool TokenCiphertext []byte @@ -34,6 +39,11 @@ type CreateOIDCSessionInput struct { SessionTokenHash []byte GatewayUserID string GatewayTenantID string + OIDCUserBindingID string + OIDCClientID string + Issuer string + ApplicationID string + TenantID string TokenCiphertext []byte AccessTokenExpiresAt time.Time LastSeenAt time.Time @@ -46,12 +56,27 @@ func (s *Store) CreateOIDCSession(ctx context.Context, input CreateOIDCSessionIn err := s.pool.QueryRow(ctx, ` INSERT INTO gateway_oidc_sessions ( session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext, - access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at + access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at, + oidc_user_binding_id, oidc_client_id ) -VALUES ($1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8) +SELECT $1, u.id, $3::uuid, $4, $5, $6, $7, $8, NULLIF($9, '')::uuid, NULLIF($10, '') +FROM gateway_users u +LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = NULLIF($9, '')::uuid +LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id +WHERE u.id = $2::uuid + AND u.gateway_tenant_id = $3::uuid + AND ($9 = '' OR ( + ub.gateway_user_id = u.id + AND tb.gateway_tenant_id = u.gateway_tenant_id + AND tb.issuer = $11 + AND tb.application_id = $12 + AND tb.external_tenant_id = $13 + )) RETURNING id::text`, input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext, input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt, + input.OIDCUserBindingID, input.OIDCClientID, + input.Issuer, input.ApplicationID, input.TenantID, ).Scan(&id) if err != nil { return OIDCSession{}, err @@ -64,6 +89,8 @@ func (s *Store) FindOIDCSessionByHash(ctx context.Context, hash []byte) (OIDCSes SELECT `+oidcSessionColumns+` FROM gateway_oidc_sessions s JOIN gateway_users u ON u.id = s.gateway_user_id +LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = s.oidc_user_binding_id +LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id WHERE s.session_token_hash = $1`, hash)) if errors.Is(err, pgx.ErrNoRows) { return OIDCSession{}, ErrOIDCSessionNotFound @@ -138,7 +165,9 @@ WHERE idle_expires_at <= $1 OR absolute_expires_at <= $1`, now) const oidcSessionColumns = ` s.id::text, s.session_token_hash, s.gateway_user_id::text, s.gateway_tenant_id::text, - COALESCE(u.external_user_id, ''), u.status, u.deleted_at IS NOT NULL, + COALESCE(s.oidc_user_binding_id::text, ''), COALESCE(ub.subject, u.external_user_id, ''), + COALESCE(tb.issuer, ''), COALESCE(tb.application_id, ''), COALESCE(tb.external_tenant_id, ''), + COALESCE(s.oidc_client_id, ''), u.status, u.deleted_at IS NOT NULL, s.token_ciphertext, s.access_token_expires_at, s.last_seen_at, s.idle_expires_at, s.absolute_expires_at, s.refresh_version, COALESCE(s.refresh_lock_id::text, ''), COALESCE(s.refresh_lock_until, 'epoch'::timestamptz), s.created_at, s.updated_at` @@ -147,7 +176,8 @@ func scanOIDCSession(row pgx.Row) (OIDCSession, error) { var item OIDCSession err := row.Scan( &item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID, - &item.ExternalUserID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext, + &item.OIDCUserBindingID, &item.ExternalUserID, &item.Issuer, &item.ApplicationID, + &item.TenantID, &item.OIDCClientID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext, &item.AccessTokenExpiresAt, &item.LastSeenAt, &item.IdleExpiresAt, &item.AbsoluteExpiresAt, &item.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt, ) diff --git a/apps/api/internal/store/oidc_tenant_context_sync.go b/apps/api/internal/store/oidc_tenant_context_sync.go new file mode 100644 index 0000000..05b79e7 --- /dev/null +++ b/apps/api/internal/store/oidc_tenant_context_sync.go @@ -0,0 +1,176 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type OIDCTenantBindingSyncTarget struct { + ID string + ExternalTenantID string + ETag string + FailureCount int +} + +type OIDCTenantBindingContext struct { + ID string + AccessStatus string + MetadataStatus string + DisplayName string + Slug string + Version string + ETag string + MetadataUpdatedAt time.Time + NextSyncAt time.Time +} + +func (s *Store) DueOIDCTenantBindingSyncs(ctx context.Context, issuer, applicationID string, limit int) ([]OIDCTenantBindingSyncTarget, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.pool.Query(ctx, `SELECT id::text,external_tenant_id,COALESCE(metadata_etag,''),sync_failure_count + FROM gateway_oidc_tenant_bindings + WHERE issuer=$1 AND application_id=$2 AND access_status='active' AND next_sync_at <= now() + ORDER BY next_sync_at,id + LIMIT $3`, strings.TrimRight(strings.TrimSpace(issuer), "/"), strings.TrimSpace(applicationID), limit) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]OIDCTenantBindingSyncTarget, 0) + for rows.Next() { + var item OIDCTenantBindingSyncTarget + if err := rows.Scan(&item.ID, &item.ExternalTenantID, &item.ETag, &item.FailureCount); err != nil { + return nil, err + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) OIDCTenantBindingContext(ctx context.Context, issuer, applicationID, tenantID string) (OIDCTenantBindingContext, error) { + var item OIDCTenantBindingContext + err := s.pool.QueryRow(ctx, `SELECT id::text,access_status,metadata_status,COALESCE(display_name,''), + COALESCE(slug,''),COALESCE(metadata_version,''),COALESCE(metadata_etag,''), + COALESCE(metadata_updated_at,'epoch'::timestamptz),next_sync_at + FROM gateway_oidc_tenant_bindings + WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`, + strings.TrimRight(strings.TrimSpace(issuer), "/"), strings.TrimSpace(applicationID), strings.TrimSpace(tenantID), + ).Scan(&item.ID, &item.AccessStatus, &item.MetadataStatus, &item.DisplayName, &item.Slug, + &item.Version, &item.ETag, &item.MetadataUpdatedAt, &item.NextSyncAt) + if errors.Is(err, pgx.ErrNoRows) { + return OIDCTenantBindingContext{}, ErrOIDCTenantUnavailable + } + return item, err +} + +func (s *Store) ApplyOIDCTenantBindingSync(ctx context.Context, bindingID string, tenant identity.TenantContext, unchanged bool, now time.Time) error { + if uuid.Validate(bindingID) != nil { + return errors.New("OIDC tenant binding id is invalid") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if unchanged { + tag, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings + SET last_sync_at=$2::timestamptz,next_sync_at=$2::timestamptz+interval '15 minutes',sync_failure_count=0, + last_error_category=NULL,updated_at=now() + WHERE id=$1::uuid AND access_status='active'`, bindingID, now) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrOIDCTenantUnavailable + } + return tx.Commit(ctx) + } + if !tenant.Active() || strings.TrimSpace(tenant.DisplayName) == "" || strings.TrimSpace(tenant.Slug) == "" { + return errors.New("OIDC tenant context is inactive or incomplete") + } + var gatewayTenantID string + err = tx.QueryRow(ctx, `UPDATE gateway_oidc_tenant_bindings + SET metadata_status='synced',display_name=$2,slug=$3,metadata_version=$4,metadata_etag=NULLIF($5,''), + metadata_updated_at=$6,last_sync_at=$7::timestamptz, + next_sync_at=$7::timestamptz+interval '15 minutes', + sync_failure_count=0,last_error_category=NULL,updated_at=now() + WHERE id=$1::uuid AND access_status='active' + RETURNING gateway_tenant_id::text`, + bindingID, tenant.DisplayName, tenant.Slug, tenant.Version, tenant.ETag, tenant.UpdatedAt, now, + ).Scan(&gatewayTenantID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrOIDCTenantUnavailable + } + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE gateway_tenants + SET name=$2,synced_at=$3,source_updated_at=$4,updated_at=now() + WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`, + gatewayTenantID, tenant.DisplayName, now, tenant.UpdatedAt, + ); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) RejectOIDCTenantBinding(ctx context.Context, bindingID, category string, now time.Time) error { + if uuid.Validate(bindingID) != nil { + return errors.New("OIDC tenant binding id is invalid") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings + SET access_status='disabled',metadata_status='rejected',last_sync_at=$2::timestamptz, + next_sync_at=$2::timestamptz+interval '15 minutes', + last_error_category=$3,updated_at=now() + WHERE id=$1::uuid`, bindingID, now, limitOIDCTenantContextCategory(category)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrOIDCTenantUnavailable + } + if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions session + USING gateway_oidc_user_bindings user_binding + WHERE session.oidc_user_binding_id=user_binding.id AND user_binding.tenant_binding_id=$1::uuid`, + bindingID, + ); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) FailOIDCTenantBindingSync(ctx context.Context, bindingID, category string, next time.Time) error { + if uuid.Validate(bindingID) != nil { + return errors.New("OIDC tenant binding id is invalid") + } + tag, err := s.pool.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings + SET sync_failure_count=sync_failure_count+1,next_sync_at=$2,last_error_category=$3,updated_at=now() + WHERE id=$1::uuid AND access_status='active'`, bindingID, next, limitOIDCTenantContextCategory(category)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrOIDCTenantUnavailable + } + return nil +} + +func limitOIDCTenantContextCategory(value string) string { + value = strings.TrimSpace(value) + if len(value) > 64 { + return value[:64] + } + return value +} diff --git a/apps/api/internal/store/oidc_users.go b/apps/api/internal/store/oidc_users.go index 1a4ad3a..daba193 100644 --- a/apps/api/internal/store/oidc_users.go +++ b/apps/api/internal/store/oidc_users.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/jackc/pgx/v5" @@ -20,15 +21,24 @@ var ( ) type ResolveOrProvisionOIDCUserInput struct { - Issuer string - Subject string - Username string - Roles []string - TenantID string - GatewayTenantKey string - ProvisioningEnabled bool - RequestIP string - UserAgent string + Issuer string + ApplicationID string + Subject string + Username string + Roles []string + TenantID string + TenantMode string + TenantName string + TenantSlug string + TenantMetadataStatus string + TenantMetadataVersion string + TenantMetadataETag string + TenantMetadataUpdatedAt time.Time + OIDCClientID string + GatewayTenantKey string + ProvisioningEnabled bool + RequestIP string + UserAgent string } type ResolveOrProvisionOIDCUserResult struct { @@ -48,6 +58,9 @@ type oidcUserProjection struct { func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) { input = normalizeOIDCUserInput(input) + if input.TenantMode == "multi_tenant" { + return s.resolveOrProvisionOIDCMultiTenantUser(ctx, input) + } if input.Issuer == "" || input.Subject == "" || input.TenantID == "" { return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input") } @@ -296,6 +309,13 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv input.Subject = strings.TrimSpace(input.Subject) input.Username = strings.TrimSpace(input.Username) input.TenantID = strings.TrimSpace(input.TenantID) + input.ApplicationID = strings.TrimSpace(input.ApplicationID) + input.TenantMode = strings.TrimSpace(input.TenantMode) + input.TenantName = strings.TrimSpace(input.TenantName) + input.TenantSlug = strings.TrimSpace(input.TenantSlug) + input.TenantMetadataStatus = strings.TrimSpace(input.TenantMetadataStatus) + input.TenantMetadataVersion = strings.TrimSpace(input.TenantMetadataVersion) + input.TenantMetadataETag = strings.TrimSpace(input.TenantMetadataETag) input.GatewayTenantKey = strings.TrimSpace(input.GatewayTenantKey) input.RequestIP = strings.TrimSpace(input.RequestIP) input.UserAgent = strings.TrimSpace(input.UserAgent) diff --git a/apps/api/internal/store/oidc_users_integration_test.go b/apps/api/internal/store/oidc_users_integration_test.go index 9fd0e09..a06c830 100644 --- a/apps/api/internal/store/oidc_users_integration_test.go +++ b/apps/api/internal/store/oidc_users_integration_test.go @@ -11,9 +11,147 @@ import ( "testing" "time" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) +func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC multi-tenant PostgreSQL integration tests") + } + ctx := context.Background() + applyOIDCJITTestMigrations(t, ctx, databaseURL) + db, err := Connect(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + issuer := "https://auth.test.example/issuer/shared" + applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput { + return ResolveOrProvisionOIDCUserInput{ + Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject", + Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant", + TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced", + TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`, + TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(), + OIDCClientID: "gateway-browser", ProvisioningEnabled: true, + } + } + + const concurrentLogins = 8 + results := make([]ResolveOrProvisionOIDCUserResult, concurrentLogins) + errs := make([]error, concurrentLogins) + var wait sync.WaitGroup + for index := range concurrentLogins { + wait.Add(1) + go func(index int) { + defer wait.Done() + results[index], errs[index] = db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A", "tenant-a")) + }(index) + } + wait.Wait() + var userA *auth.User + created := 0 + for index, result := range results { + if errs[index] != nil || result.User == nil { + t.Fatalf("tenant A login %d result=%#v error=%v", index, result, errs[index]) + } + if userA == nil { + userA = result.User + } + if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID { + t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User) + } + if result.Created { + created++ + } + } + if created != 1 { + t.Fatalf("tenant A created projections=%d", created) + } + + resultB, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantB, "Tenant B", "tenant-b")) + if err != nil || resultB.User == nil { + t.Fatalf("tenant B projection=%#v error=%v", resultB, err) + } + userB := resultB.User + if userA.GatewayUserID == userB.GatewayUserID || userA.GatewayTenantID == userB.GatewayTenantID || + userA.ID != userB.ID { + t.Fatalf("same subject was not isolated by tenant: A=%#v B=%#v", userA, userB) + } + + var tenants, users, wallets, audits int + if err := db.pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM gateway_oidc_tenant_bindings WHERE application_id=$1 AND external_tenant_id IN ($2,$3)), + (SELECT count(*) FROM gateway_oidc_user_bindings binding + JOIN gateway_oidc_tenant_bindings tenant ON tenant.id=binding.tenant_binding_id + WHERE tenant.application_id=$1 AND binding.subject=$4), + (SELECT count(*) FROM gateway_wallet_accounts WHERE gateway_user_id IN ($5::uuid,$6::uuid) AND currency='resource'), + (SELECT count(*) FROM gateway_audit_logs WHERE action='identity.oidc_user.provisioned' + AND target_gateway_user_id IN ($5::uuid,$6::uuid))`, + applicationID, tenantA, tenantB, subject, userA.GatewayUserID, userB.GatewayUserID, + ).Scan(&tenants, &users, &wallets, &audits); err != nil { + t.Fatal(err) + } + if tenants != 2 || users != 2 || wallets != 2 || audits != 2 { + t.Fatalf("tenants=%d users=%d wallets=%d audits=%d", tenants, users, wallets, audits) + } + + if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "Tenant A only"}, userA); err != nil { + t.Fatal(err) + } + keysB, err := db.ListAPIKeys(ctx, userB) + if err != nil || len(keysB) != 0 { + t.Fatalf("tenant B observed tenant A API keys: keys=%#v error=%v", keysB, err) + } + if _, err := db.CreateTask(ctx, CreateTaskInput{ + Kind: "multi-tenant-isolation", Model: "local-fixture", RunMode: "async", Async: true, + Request: map[string]any{"prompt": "tenant-a"}, + }, userA); err != nil { + t.Fatal(err) + } + tasksB, err := db.ListTasks(ctx, userB, TaskListFilter{}) + if err != nil || len(tasksB.Items) != 0 { + t.Fatalf("tenant B observed tenant A tasks: tasks=%#v error=%v", tasksB.Items, err) + } + + bindingA, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA) + if err != nil { + t.Fatal(err) + } + if err := db.RejectOIDCTenantBinding(ctx, bindingA.ID, "tenant_application_revoked", time.Now().UTC()); err != nil { + t.Fatal(err) + } + pending := input(tenantA, "", "") + pending.TenantMetadataStatus = "metadata_pending" + if _, err := db.ResolveOrProvisionOIDCUser(ctx, pending); !errorsIs(err, ErrOIDCTenantUnavailable) { + t.Fatalf("disabled binding accepted pending context: %v", err) + } + reactivated, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A restored", "tenant-a")) + if err != nil { + t.Fatal(err) + } + if reactivated.User.GatewayTenantID != userA.GatewayTenantID || reactivated.User.GatewayUserID != userA.GatewayUserID { + t.Fatalf("reassignment did not reuse local projection: before=%#v after=%#v", userA, reactivated.User) + } + if err := db.ApplyOIDCTenantBindingSync(ctx, bindingA.ID, identity.TenantContext{ + ApplicationID: applicationID, TenantID: tenantA, DisplayName: "Tenant A renamed", Slug: "tenant-a", + TenantStatus: "active", TenantApplicationStatus: "active", Version: "v2", ETag: `"v2"`, + UpdatedAt: time.Unix(1_780_000_100, 0).UTC(), + }, false, time.Now().UTC()); err != nil { + t.Fatal(err) + } + synced, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA) + if err != nil || synced.DisplayName != "Tenant A renamed" || synced.Version != "v2" { + t.Fatalf("synced tenant context=%#v error=%v", synced, err) + } +} + func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) { databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) if databaseURL == "" { diff --git a/apps/api/internal/store/security_events.go b/apps/api/internal/store/security_events.go index df04a03..7f00a28 100644 --- a/apps/api/internal/store/security_events.go +++ b/apps/api/internal/store/security_events.go @@ -16,6 +16,8 @@ type ApplySessionRevokedInput struct { JTI string TransactionID string SubjectIssuer string + ApplicationID string + SubjectType string TenantID string Subject string EventTimestamp time.Time @@ -36,6 +38,9 @@ type SecurityEventEvaluation struct { } func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevokedInput) (ApplySecurityEventResult, error) { + if input.SubjectType == "" { + input.SubjectType = "principal" + } tx, err := s.pool.Begin(ctx) if err != nil { return ApplySecurityEventResult{}, err @@ -44,13 +49,15 @@ func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevok subjectHash := shortSecurityEventHash(input.Subject) tag, err := tx.Exec(ctx, ` INSERT INTO gateway_security_event_receipts ( - issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp + issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp, + application_id, subject_type ) -VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8) +VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8, NULLIF($9, ''), $10) ON CONFLICT (issuer, jti) DO NOTHING`, input.Issuer, input.JTI, input.Audience, "https://schemas.openid.net/secevent/caep/event-type/session-revoked", input.TransactionID, input.TenantID, subjectHash, input.EventTimestamp, + input.ApplicationID, input.SubjectType, ) if err != nil { return ApplySecurityEventResult{}, err @@ -64,25 +71,56 @@ ON CONFLICT (issuer, jti) DO NOTHING`, var revokedAt time.Time err = tx.QueryRow(ctx, ` -INSERT INTO gateway_oidc_revocation_watermarks (issuer, tenant_id, subject, revoked_at, source_jti) -VALUES ($1, $2, $3, $4, $5::uuid) -ON CONFLICT (issuer, tenant_id, subject) DO UPDATE +INSERT INTO gateway_oidc_revocation_watermarks ( + issuer, application_id, tenant_id, subject_type, subject, revoked_at, source_jti +) +VALUES ($1, $2, $3, $4, $5, $6, $7::uuid) +ON CONFLICT (issuer, application_id, tenant_id, subject_type, subject) DO UPDATE SET revoked_at = EXCLUDED.revoked_at, source_jti = EXCLUDED.source_jti, updated_at = now() WHERE EXCLUDED.revoked_at > gateway_oidc_revocation_watermarks.revoked_at -RETURNING revoked_at`, input.SubjectIssuer, input.TenantID, input.Subject, input.EventTimestamp, input.JTI).Scan(&revokedAt) +RETURNING revoked_at`, input.SubjectIssuer, input.ApplicationID, input.TenantID, input.SubjectType, + input.Subject, input.EventTimestamp, input.JTI).Scan(&revokedAt) watermarkMoved := err == nil if err != nil && !errors.Is(err, pgx.ErrNoRows) { return ApplySecurityEventResult{}, err } var sessionsDeleted int64 if watermarkMoved { - tag, err = tx.Exec(ctx, ` + switch { + case input.ApplicationID != "" && input.SubjectType == "principal": + tag, err = tx.Exec(ctx, ` +DELETE FROM gateway_oidc_sessions session +USING gateway_oidc_user_bindings user_binding,gateway_oidc_tenant_bindings tenant_binding +WHERE session.oidc_user_binding_id=user_binding.id + AND user_binding.tenant_binding_id=tenant_binding.id + AND tenant_binding.issuer=$1 + AND tenant_binding.application_id=$2 + AND tenant_binding.external_tenant_id=$3 + AND user_binding.subject=$4`, input.SubjectIssuer, input.ApplicationID, input.TenantID, input.Subject) + case input.ApplicationID != "" && input.SubjectType == "tenant": + tag, err = tx.Exec(ctx, ` +DELETE FROM gateway_oidc_sessions session +USING gateway_oidc_user_bindings user_binding,gateway_oidc_tenant_bindings tenant_binding +WHERE session.oidc_user_binding_id=user_binding.id + AND user_binding.tenant_binding_id=tenant_binding.id + AND tenant_binding.issuer=$1 + AND tenant_binding.application_id=$2 + AND tenant_binding.external_tenant_id=$3`, input.SubjectIssuer, input.ApplicationID, input.TenantID) + if err == nil { + _, err = tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings + SET access_status='disabled',updated_at=now() + WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`, + input.SubjectIssuer, input.ApplicationID, input.TenantID) + } + default: + tag, err = tx.Exec(ctx, ` DELETE FROM gateway_oidc_sessions session USING gateway_users gateway_user WHERE session.gateway_user_id = gateway_user.id AND gateway_user.source = 'oidc' AND gateway_user.external_user_id = $1 AND gateway_user.tenant_id = $2`, input.Subject, input.TenantID) + } if err != nil { return ApplySecurityEventResult{}, err } @@ -95,7 +133,7 @@ WHERE session.gateway_user_id = gateway_user.id AfterState: map[string]any{"watermarkMoved": watermarkMoved, "sessionsDeleted": sessionsDeleted}, Metadata: map[string]any{ "issuer": input.SubjectIssuer, "transmitterIssuer": input.Issuer, "tenantId": input.TenantID, "jtiHash": jtiHash, - "initiatingEntity": input.InitiatingEntity, + "applicationId": input.ApplicationID, "subjectType": input.SubjectType, "initiatingEntity": input.InitiatingEntity, }, }) if err != nil { @@ -314,7 +352,7 @@ WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback' return err } -func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) { +func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, applicationID, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) { var mode, streamStatus string var lastVerification, bootstrapUntil *time.Time var createdAt time.Time @@ -322,9 +360,17 @@ func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer err := s.pool.QueryRow(ctx, ` SELECT state.mode,state.stream_status,state.last_verification_at,state.bootstrap_until,state.created_at,watermark.revoked_at FROM gateway_security_event_stream_state state -LEFT JOIN gateway_oidc_revocation_watermarks watermark - ON watermark.issuer=$3 AND watermark.tenant_id=$4 AND watermark.subject=$5 -WHERE state.issuer=$1 AND state.audience=$2`, transmitterIssuer, audience, subjectIssuer, tenantID, subject).Scan( +LEFT JOIN LATERAL ( + SELECT max(revoked_at) AS revoked_at + FROM gateway_oidc_revocation_watermarks + WHERE issuer=$3 AND application_id=$4 AND tenant_id=$5 + AND ( + (subject_type='principal' AND subject=$6) + OR (subject_type='tenant' AND subject=$5) + ) +) watermark ON true +WHERE state.issuer=$1 AND state.audience=$2`, + transmitterIssuer, audience, subjectIssuer, applicationID, tenantID, subject).Scan( &mode, &streamStatus, &lastVerification, &bootstrapUntil, &createdAt, &revokedAt, ) if err != nil { diff --git a/apps/api/internal/store/security_events_integration_test.go b/apps/api/internal/store/security_events_integration_test.go index 8131e50..96d50d1 100644 --- a/apps/api/internal/store/security_events_integration_test.go +++ b/apps/api/internal/store/security_events_integration_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) @@ -55,7 +56,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { if err := db.AdvanceSecurityEventStreamState(ctx, issuer, audience, now, 180*time.Second); err != nil { t.Fatalf("advance fresh stream state: %v", err) } - evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second) + evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second) if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" { t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err) } @@ -77,12 +78,12 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { } confirmAt("first-verification-state", now) confirmAt("second-verification-state", now.Add(time.Minute)) - evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(time.Minute), 180*time.Second) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(time.Minute), 180*time.Second) if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" { t.Fatalf("bootstrap overlap evaluation=%#v error=%v", evaluation, err) } confirmAt("post-bootstrap-verification-state", now.Add(361*time.Second)) - evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(361*time.Second), 180*time.Second) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(361*time.Second), 180*time.Second) if err != nil || evaluation.RequireIntrospection || evaluation.Mode != "push_healthy" { t.Fatalf("healthy evaluation=%#v error=%v", evaluation, err) } @@ -97,7 +98,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { } confirmAt("paused-verification-one", now.Add(363*time.Second)) confirmAt("paused-verification-two", now.Add(364*time.Second)) - evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(364*time.Second), 180*time.Second) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(364*time.Second), 180*time.Second) if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" { t.Fatalf("stream update fallback=%#v error=%v", evaluation, err) } @@ -114,22 +115,22 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { if err != nil || !duplicate.Duplicate { t.Fatalf("duplicate result=%#v error=%v", duplicate, err) } - evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, revokedAt, now, 180*time.Second) + evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, revokedAt, now, 180*time.Second) if !evaluation.Revoked { t.Fatal("token at watermark was accepted") } - evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", tenantID, subject, revokedAt, now, 180*time.Second) + evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", "", tenantID, subject, revokedAt, now, 180*time.Second) if evaluation.Revoked { t.Fatal("watermark crossed OIDC issuer boundary") } - evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second) + evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second) if evaluation.Revoked { t.Fatal("token after watermark was rejected") } _, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET last_verification_at=$3 WHERE issuer=$1 AND audience=$2`, issuer, audience, now.Add(-181*time.Second)) - evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second) if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" { t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err) } @@ -183,3 +184,136 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { t.Fatalf("rebind discarded security history receipts=%d watermarks=%d", retainedReceipts, retainedWatermarks) } } + +func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run application-scoped security event integration tests") + } + ctx := context.Background() + applyOIDCJITTestMigrations(t, ctx, databaseURL) + db, err := Connect(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + transmitterIssuer := "https://auth.test.example/ssf/" + uuid.NewString() + subjectIssuer := "https://auth.test.example/issuer/shared" + applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + projection := func(tenantID string) *auth.User { + result, err := db.ResolveOrProvisionOIDCUser(ctx, ResolveOrProvisionOIDCUserInput{ + Issuer: subjectIssuer, ApplicationID: applicationID, Subject: subject, Username: "shared-user", + Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant", + TenantName: "Tenant " + tenantID[:8], TenantSlug: "tenant-" + tenantID[:8], + TenantMetadataStatus: "synced", TenantMetadataVersion: "1", + TenantMetadataUpdatedAt: time.Now().UTC(), OIDCClientID: "gateway-browser", ProvisioningEnabled: true, + }) + if err != nil { + t.Fatal(err) + } + return result.User + } + userA, userB := projection(tenantA), projection(tenantB) + if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "must survive OIDC revocation"}, userA); err != nil { + t.Fatal(err) + } + createSession := func(user *auth.User, marker byte) { + now := time.Now().UTC() + sessionHash := sha256.Sum256([]byte(uuid.NewString())) + if _, err := db.CreateOIDCSession(ctx, CreateOIDCSessionInput{ + SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID, + GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID, + OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID, + TenantID: user.TenantID, TokenCiphertext: []byte{marker}, + AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now, + IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour), + }); err != nil { + t.Fatal(err) + } + } + createSession(userA, 0xa1) + createSession(userB, 0xb1) + + revokedAt := time.Now().UTC().Truncate(time.Second) + principalEvent := ApplySessionRevokedInput{ + Issuer: transmitterIssuer, Audience: "urn:easyai:ssf:receiver:" + applicationID, + JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer, + ApplicationID: applicationID, SubjectType: "principal", TenantID: tenantA, Subject: subject, + EventTimestamp: revokedAt, InitiatingEntity: "admin", + } + result, err := db.ApplySessionRevoked(ctx, principalEvent) + if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved { + t.Fatalf("principal result=%#v error=%v", result, err) + } + var tenantASessions, tenantBSessions int + countSessions := func() { + t.Helper() + if err := db.pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$1::uuid), + (SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$2::uuid)`, + userA.GatewayTenantID, userB.GatewayTenantID, + ).Scan(&tenantASessions, &tenantBSessions); err != nil { + t.Fatal(err) + } + } + countSessions() + if tenantASessions != 0 || tenantBSessions != 1 { + t.Fatalf("principal revocation sessions A=%d B=%d", tenantASessions, tenantBSessions) + } + older := principalEvent + older.JTI = uuid.NewString() + older.EventTimestamp = revokedAt.Add(-time.Minute) + result, err = db.ApplySessionRevoked(ctx, older) + if err != nil || result.WatermarkMoved || result.SessionsDeleted != 0 { + t.Fatalf("older result=%#v error=%v", result, err) + } + + createSession(userA, 0xa2) + tenantEvent := principalEvent + tenantEvent.JTI, tenantEvent.TransactionID = uuid.NewString(), uuid.NewString() + tenantEvent.SubjectType, tenantEvent.Subject = "tenant", tenantA + tenantEvent.EventTimestamp = revokedAt.Add(time.Minute) + result, err = db.ApplySessionRevoked(ctx, tenantEvent) + if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved { + t.Fatalf("tenant result=%#v error=%v", result, err) + } + countSessions() + if tenantASessions != 0 || tenantBSessions != 1 { + t.Fatalf("tenant revocation sessions A=%d B=%d", tenantASessions, tenantBSessions) + } + keysA, err := db.ListAPIKeys(ctx, userA) + if err != nil || len(keysA) != 1 { + t.Fatalf("tenant revocation changed API keys: keys=%#v error=%v", keysA, err) + } + var accessStatus string + if err := db.pool.QueryRow(ctx, `SELECT access_status FROM gateway_oidc_tenant_bindings + WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`, + subjectIssuer, applicationID, tenantA, + ).Scan(&accessStatus); err != nil || accessStatus != "disabled" { + t.Fatalf("tenant A access status=%q error=%v", accessStatus, err) + } + + streamID := uuid.NewString() + audience := tenantEvent.Audience + if err := db.EnsureSecurityEventStreamState(ctx, transmitterIssuer, audience, streamID); err != nil { + t.Fatal(err) + } + _, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state + SET stream_status='enabled',mode='push_healthy',last_verification_at=$3,bootstrap_until=$3 + WHERE issuer=$1 AND audience=$2`, transmitterIssuer, audience, revokedAt.Add(2*time.Minute)) + evaluationA, err := db.EvaluateOIDCSecurityEvent( + ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantA, subject, + tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute, + ) + if err != nil || !evaluationA.Revoked { + t.Fatalf("tenant A evaluation=%#v error=%v", evaluationA, err) + } + evaluationB, err := db.EvaluateOIDCSecurityEvent( + ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantB, subject, + tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute, + ) + if err != nil || evaluationB.Revoked { + t.Fatalf("tenant B evaluation=%#v error=%v", evaluationB, err) + } +} diff --git a/apps/api/migrations/0090_oidc_multi_tenant_identity.sql b/apps/api/migrations/0090_oidc_multi_tenant_identity.sql new file mode 100644 index 0000000..dafa2ee --- /dev/null +++ b/apps/api/migrations/0090_oidc_multi_tenant_identity.sql @@ -0,0 +1,92 @@ +ALTER TABLE gateway_identity_configuration_revisions + DROP CONSTRAINT IF EXISTS gateway_identity_configuration_revisions_schema_version_check; + +ALTER TABLE gateway_identity_configuration_revisions + ADD COLUMN IF NOT EXISTS tenant_mode text NOT NULL DEFAULT 'single_tenant', + ADD COLUMN IF NOT EXISTS tenant_context_endpoint text, + ADD COLUMN IF NOT EXISTS tenant_context_audience text, + ADD COLUMN IF NOT EXISTS tenant_context_scope text, + ADD CONSTRAINT gateway_identity_configuration_revisions_schema_version_check + CHECK (schema_version IN (1,2)), + ADD CONSTRAINT gateway_identity_configuration_revisions_tenant_mode_check + CHECK (tenant_mode IN ('single_tenant','multi_tenant')), + ADD CONSTRAINT gateway_identity_configuration_revisions_manifest_mode_check + CHECK ( + (state='draft' AND issuer IS NULL) + OR + (schema_version=1 AND tenant_mode='single_tenant' AND tenant_id IS NOT NULL AND local_tenant_key <> '' + AND tenant_context_endpoint IS NULL AND tenant_context_audience IS NULL AND tenant_context_scope IS NULL) + OR + (schema_version=2 AND tenant_mode='multi_tenant' AND tenant_id IS NULL AND local_tenant_key='' + AND tenant_context_endpoint IS NOT NULL AND tenant_context_audience='urn:easyai:auth-center:tenant-context' + AND tenant_context_scope='tenant.context.read') + ) NOT VALID; + +CREATE TABLE IF NOT EXISTS gateway_oidc_tenant_bindings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + issuer text NOT NULL, + application_id text NOT NULL, + external_tenant_id text NOT NULL, + gateway_tenant_id uuid NOT NULL REFERENCES gateway_tenants(id) ON DELETE CASCADE, + access_status text NOT NULL DEFAULT 'active' + CHECK (access_status IN ('active','disabled')), + metadata_status text NOT NULL DEFAULT 'metadata_pending' + CHECK (metadata_status IN ('metadata_pending','synced','rejected')), + display_name text, + slug text, + metadata_version text, + metadata_etag text, + metadata_updated_at timestamptz, + last_sync_at timestamptz, + next_sync_at timestamptz NOT NULL DEFAULT now(), + sync_failure_count integer NOT NULL DEFAULT 0 CHECK (sync_failure_count >= 0), + last_error_category text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(issuer,application_id,external_tenant_id), + UNIQUE(id,gateway_tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_oidc_tenant_bindings_sync + ON gateway_oidc_tenant_bindings(next_sync_at) + WHERE access_status='active'; + +CREATE TABLE IF NOT EXISTS gateway_oidc_user_bindings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_binding_id uuid NOT NULL REFERENCES gateway_oidc_tenant_bindings(id) ON DELETE CASCADE, + subject text NOT NULL, + gateway_user_id uuid NOT NULL REFERENCES gateway_users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(tenant_binding_id,subject), + UNIQUE(id,gateway_user_id) +); + +ALTER TABLE gateway_oidc_sessions + ADD COLUMN IF NOT EXISTS oidc_user_binding_id uuid + REFERENCES gateway_oidc_user_bindings(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS oidc_client_id text; + +CREATE INDEX IF NOT EXISTS idx_gateway_oidc_sessions_user_binding + ON gateway_oidc_sessions(oidc_user_binding_id,created_at DESC) + WHERE oidc_user_binding_id IS NOT NULL; + +ALTER TABLE gateway_security_event_receipts + ADD COLUMN IF NOT EXISTS application_id text, + ADD COLUMN IF NOT EXISTS subject_type text + CHECK (subject_type IS NULL OR subject_type IN ('principal','tenant')); + +ALTER TABLE gateway_oidc_revocation_watermarks + ADD COLUMN IF NOT EXISTS application_id text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS subject_type text NOT NULL DEFAULT 'principal' + CHECK (subject_type IN ('principal','tenant')); + +ALTER TABLE gateway_oidc_revocation_watermarks + DROP CONSTRAINT IF EXISTS gateway_oidc_revocation_watermarks_pkey; + +ALTER TABLE gateway_oidc_revocation_watermarks + ADD PRIMARY KEY (issuer,application_id,tenant_id,subject_type,subject); + +DROP INDEX IF EXISTS idx_gateway_oidc_revocation_watermarks_lookup; +CREATE INDEX idx_gateway_oidc_revocation_watermarks_lookup + ON gateway_oidc_revocation_watermarks(issuer,application_id,tenant_id,subject_type,subject,revoked_at); diff --git a/apps/web/src/components/AuthPanel.tsx b/apps/web/src/components/AuthPanel.tsx index 34bc373..dececf9 100644 --- a/apps/web/src/components/AuthPanel.tsx +++ b/apps/web/src/components/AuthPanel.tsx @@ -25,6 +25,8 @@ export function AuthPanel(props: { oidcEnabled: boolean; onOIDCLogin: () => void; }) { + const visibleTabs = props.oidcEnabled ? tabs.filter((tab) => tab.value !== 'register') : tabs; + const visibleMode = props.oidcEnabled && props.authMode === 'register' ? 'login' : props.authMode; return (
@@ -41,10 +43,10 @@ export function AuthPanel(props: { 使用统一认证中心登录 )} - - {props.authMode === 'login' && } - {props.authMode === 'register' && } - {props.authMode === 'external' && } + + {visibleMode === 'login' && } + {visibleMode === 'register' && } + {visibleMode === 'external' && }