diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index 087b4b5..b401837 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -73,16 +73,19 @@ func NewRequestAuthError(status int, code, message string) error { } type Authenticator struct { - JWTSecret string - ServerMainBaseURL string - ServerMainInternalToken string - ServerMainInternalKey string - ServerMainInternalSecret string - HTTPClient *http.Client - LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error) - OIDCVerifier *OIDCVerifier - OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error) - LegacyJWTEnabled bool + JWTSecret string + ServerMainBaseURL string + ServerMainInternalToken string + ServerMainInternalKey string + ServerMainInternalSecret string + HTTPClient *http.Client + LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error) + OIDCVerifier *OIDCVerifier + OIDCVerifierProvider func() *OIDCVerifier + OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error) + OIDCSessionResolverProvider func(ctx context.Context, sessionID string) (*User, error) + LegacyJWTEnabled bool + LegacyJWTEnabledProvider func() bool } func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator { @@ -152,10 +155,14 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { if token == "" { if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil { sessionID := strings.TrimSpace(cookie.Value) - if sessionID == "" || a.OIDCSessionResolver == nil { + resolver := a.OIDCSessionResolver + if a.OIDCSessionResolverProvider != nil { + resolver = a.OIDCSessionResolverProvider + } + if sessionID == "" || resolver == nil { return nil, ErrUnauthorized } - return a.OIDCSessionResolver(r.Context(), sessionID) + return resolver(r.Context(), sessionID) } } if token == "" { @@ -168,7 +175,7 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { if algorithm == "RS256" || algorithm == "ES256" { return a.AuthenticateOIDCAccessToken(r.Context(), token) } - if !a.LegacyJWTEnabled { + if !a.legacyJWTEnabled() { return nil, ErrUnauthorized } return a.verifyJWT(token) @@ -176,10 +183,21 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) { algorithm := jwtAlgorithm(token) - if a.OIDCVerifier == nil || algorithm != "RS256" && algorithm != "ES256" { + verifier := a.OIDCVerifier + if a.OIDCVerifierProvider != nil { + verifier = a.OIDCVerifierProvider() + } + if verifier == nil || algorithm != "RS256" && algorithm != "ES256" { return nil, ErrUnauthorized } - return a.OIDCVerifier.Verify(ctx, token) + return verifier.Verify(ctx, token) +} + +func (a *Authenticator) legacyJWTEnabled() bool { + if a.LegacyJWTEnabledProvider != nil { + return a.LegacyJWTEnabledProvider() + } + return a.LegacyJWTEnabled } func (a *Authenticator) verifyJWT(tokenString string) (*User, error) { diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 9d3b02f..d86cd9c 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -112,6 +112,20 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil } +// ValidateConfiguration eagerly validates Discovery, JWKS and, when enabled, +// the RFC 7662 endpoint and machine credential before a runtime is activated. +func (v *OIDCVerifier) ValidateConfiguration(ctx context.Context) error { + if err := v.refresh(ctx, true); err != nil { + return err + } + if v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil { + if _, err := v.introspect(ctx, "identity-configuration-probe"); err != nil { + return err + } + } + return nil +} + func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"})) unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{}) diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index a2f484b..edd9128 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -70,6 +70,12 @@ func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, erro return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil } +// ValidateConfiguration eagerly checks public-client Discovery metadata. +func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error { + _, _, err := c.configuration(ctx) + return err +} + func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) { if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) { return "", errors.New("state, nonce and PKCE verifier are required") diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index e01e426..19f16df 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -63,6 +63,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T if err != nil { t.Fatal(err) } + if err := client.ValidateConfiguration(context.Background()); err != nil { + t.Fatalf("ValidateConfiguration() error = %v", err) + } authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/auth/oidc_session_cookie_test.go b/apps/api/internal/auth/oidc_session_cookie_test.go index 3167f95..8c596ac 100644 --- a/apps/api/internal/auth/oidc_session_cookie_test.go +++ b/apps/api/internal/auth/oidc_session_cookie_test.go @@ -58,6 +58,29 @@ func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) { } } +func TestAuthenticatorReadsOIDCSessionResolverAndLegacyPolicyDynamically(t *testing.T) { + authenticator := New("local-jwt-secret", "", "") + currentUser := &User{ID: "first", Roles: []string{"manager"}} + authenticator.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*User, error) { + return currentUser, nil + } + authenticator.LegacyJWTEnabledProvider = func() bool { return false } + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"}) + user, err := authenticator.Authenticate(request) + if err != nil || user.ID != "first" { + t.Fatalf("first runtime session resolution failed: user=%#v err=%v", user, err) + } + currentUser = &User{ID: "second", Roles: []string{"manager"}} + user, err = authenticator.Authenticate(request) + if err != nil || user.ID != "second" { + t.Fatalf("swapped runtime session resolution failed: user=%#v err=%v", user, err) + } + if authenticator.legacyJWTEnabled() { + t.Fatal("dynamic legacy JWT policy was ignored") + } +} + func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) { authenticator := New("local-jwt-secret", "", "") authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) { diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index da6952d..5a0f637 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -49,6 +49,9 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) { if err != nil { t.Fatal(err) } + if err := verifier.ValidateConfiguration(context.Background()); err != nil { + t.Fatalf("ValidateConfiguration() error = %v", err) + } for _, test := range []struct { name string kid string diff --git a/apps/api/internal/httpapi/identity_runtime.go b/apps/api/internal/httpapi/identity_runtime.go new file mode 100644 index 0000000..cea5c6c --- /dev/null +++ b/apps/api/internal/httpapi/identity_runtime.go @@ -0,0 +1,96 @@ +package httpapi + +import ( + "context" + "net/url" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" + ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" +) + +type oidcTokenVerifier interface { + Verify(context.Context, string) (*auth.User, 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. +type identityRequestRuntime struct { + Revision identity.Revision + Verifier oidcTokenVerifier + PublicClient oidcPublicClient + Sessions oidcSessionManager + SessionCipher *oidcsession.Cipher + SecurityEvents *ssfreceiver.ConnectionManager + CookieSecure bool + BrowserEnabled bool +} + +func (s *Server) currentIdentityRuntime() *identityRequestRuntime { + if s.identityRuntime != nil { + runtime := s.identityRuntime.Current() + if runtime == nil { + return nil + } + 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, + } + } + + // Compatibility path for focused HTTP tests. NewServer never uses these + // static fields after identity revisions are enabled. + if !s.cfg.OIDCEnabled && s.cfg.OIDCIssuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil { + return nil + } + webBaseURL := s.cfg.WebBaseURL + if webBaseURL == "" { + webBaseURL = s.cfg.CORSAllowedOrigin + } + var verifier oidcTokenVerifier + if s.auth != nil { + verifier = s.auth.OIDCVerifier + } + return &identityRequestRuntime{ + Revision: identity.Revision{ + State: identity.RevisionActive, Issuer: s.cfg.OIDCIssuer, LocalTenantKey: s.cfg.OIDCGatewayTenantKey, + WebBaseURL: webBaseURL, PublicBaseURL: s.cfg.PublicBaseURL, + JITEnabled: s.cfg.OIDCJITProvisioningEnabled, LegacyJWTEnabled: s.cfg.OIDCAcceptLegacyHS256, + SessionAbsoluteSeconds: s.cfg.OIDCSessionAbsoluteTTLSeconds, + }, + Verifier: verifier, PublicClient: s.oidcClient, Sessions: s.oidcSessions, + SessionCipher: s.oidcSessionCipher, SecurityEvents: s.securityEventManager, + CookieSecure: s.cfg.OIDCSessionCookieSecure, + BrowserEnabled: s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled, + } +} + +func (s *Server) currentSecurityEventManager() *ssfreceiver.ConnectionManager { + if runtime := s.currentIdentityRuntime(); runtime != nil { + return runtime.SecurityEvents + } + return nil +} + +func (s *Server) oidcCookieSecure() bool { + if runtime := s.currentIdentityRuntime(); runtime != nil { + return runtime.CookieSecure + } + return false +} + +func originMatchesBaseURL(origin, baseURL string) bool { + originURL, err := url.Parse(strings.TrimSpace(origin)) + if err != nil || originURL.User != nil || originURL.Path != "" || originURL.RawQuery != "" || originURL.Fragment != "" { + return false + } + base, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return false + } + return strings.EqualFold(originURL.Scheme, base.Scheme) && strings.EqualFold(originURL.Host, base.Host) +} diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index 0b21a2d..d442980 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -40,7 +40,8 @@ const ( // @Failure 404 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/login [get] func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { - if !s.oidcBrowserSessionReady() { + runtime := s.currentIdentityRuntime() + if !oidcRuntimeReady(runtime) { writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } @@ -53,13 +54,13 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid) return } - encoded, err := s.oidcSessionCipher.EncodeLoginTransaction(transaction) + encoded, err := runtime.SessionCipher.EncodeLoginTransaction(transaction) if err != nil { s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err) writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable) return } - authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier) + authorizationURL, err := runtime.PublicClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier) if err != nil { s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err) writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE") @@ -68,7 +69,7 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: oidcsession.LoginTransactionCookieName, Value: encoded, Path: s.oidcCallbackCookiePath(), MaxAge: 600, Expires: time.Now().Add(10 * time.Minute), - HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode, + HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteLaxMode, }) w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, authorizationURL, http.StatusSeeOther) @@ -84,7 +85,8 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { // @Failure 503 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/callback [get] func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { - if !s.oidcBrowserSessionReady() { + runtime := s.currentIdentityRuntime() + if !oidcRuntimeReady(runtime) { writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } @@ -94,7 +96,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing) return } - transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now()) + transaction, err := runtime.SessionCipher.DecodeLoginTransaction(cookie.Value, time.Now()) if err != nil { s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid) return @@ -107,22 +109,22 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing) return } - tokens, err := s.oidcClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier) + tokens, err := runtime.PublicClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier) if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed) return } - identity, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), tokens.AccessToken) + identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken) if err != nil || identity == nil { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed) return } - idSubject, err := s.oidcClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce) + idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce) if err != nil || idSubject != identity.ID { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed) return } - projection, err := s.resolveOIDCUserProjection(r.Context(), r, identity) + projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision) if err != nil { s.writeOIDCCallbackProjectionError(w, r, err) return @@ -131,7 +133,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user")) return } - rawSession, err := s.oidcSessions.Create(r.Context(), oidcsession.TokenBundle{ + rawSession, err := runtime.Sessions.Create(r.Context(), oidcsession.TokenBundle{ AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken, }, projection.User) if err != nil { @@ -141,12 +143,12 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { now := time.Now() http.SetCookie(w, &http.Cookie{ Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/", - MaxAge: s.cfg.OIDCSessionAbsoluteTTLSeconds, Expires: now.Add(time.Duration(s.cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second), - HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode, + MaxAge: runtime.Revision.SessionAbsoluteSeconds, Expires: now.Add(time.Duration(runtime.Revision.SessionAbsoluteSeconds) * time.Second), + HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteStrictMode, }) w.Header().Set("Cache-Control", "no-store") s.recordOIDCSessionAudit(r, projection.User) - http.Redirect(w, r, s.oidcReturnLocation(transaction.ReturnTo), http.StatusSeeOther) + http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther) } // logoutOIDCSession godoc @@ -158,13 +160,14 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { // @Failure 503 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/logout [post] func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) { - if !s.oidcBrowserSessionReady() { + runtime := s.currentIdentityRuntime() + if !oidcRuntimeReady(runtime) { writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } var bundle oidcsession.TokenBundle if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil { - bundle, err = s.oidcSessions.Delete(r.Context(), cookie.Value) + bundle, err = runtime.Sessions.Delete(r.Context(), cookie.Value) if err != nil { writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable) return @@ -172,14 +175,14 @@ func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) { } s.clearOIDCSessionCookie(w) if bundle.RefreshToken != "" { - if err := s.oidcClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil { + if err := runtime.PublicClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil { s.logger.WarnContext(r.Context(), "revoke OIDC refresh token failed", "error", err) } } // Do not put the encrypted-at-rest ID Token into a browser-visible redirect URL. - location, err := s.oidcClient.EndSessionURL(r.Context(), "") + location, err := runtime.PublicClient.EndSessionURL(r.Context(), "") if err != nil { - location = s.cfg.OIDCPostLogoutRedirectURI + location = runtime.Revision.WebBaseURL + "/" } w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, location, http.StatusSeeOther) @@ -192,9 +195,10 @@ func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) { // @Failure 503 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/session [delete] func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request) { - if s.oidcSessions != nil { + runtime := s.currentIdentityRuntime() + if runtime != nil && runtime.Sessions != nil { if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil { - if _, err := s.oidcSessions.Delete(r.Context(), cookie.Value); err != nil { + if _, err := runtime.Sessions.Delete(r.Context(), cookie.Value); err != nil { writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable) return } @@ -206,33 +210,40 @@ func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request } func (s *Server) oidcBrowserSessionReady() bool { - return s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled && s.auth != nil && s.auth.OIDCVerifier != nil && - s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil + return oidcRuntimeReady(s.currentIdentityRuntime()) +} + +func oidcRuntimeReady(runtime *identityRequestRuntime) bool { + return runtime != nil && runtime.BrowserEnabled && runtime.Verifier != nil && runtime.PublicClient != nil && runtime.Sessions != nil && runtime.SessionCipher != nil } func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(), - Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode, + Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteLaxMode, }) } func (s *Server) oidcCallbackCookiePath() string { - if parsed, err := url.Parse(strings.TrimSpace(s.cfg.OIDCRedirectURI)); err == nil && strings.HasPrefix(parsed.Path, "/") { - return parsed.Path - } return "/api/v1/auth/oidc/callback" } func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: auth.OIDCSessionCookieName, Value: "", Path: "/", - Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode, + Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteStrictMode, }) } func (s *Server) oidcReturnLocation(returnTo string) string { - if base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/"); base != "" { + if runtime := s.currentIdentityRuntime(); runtime != nil { + return oidcReturnLocation(runtime.Revision.WebBaseURL, returnTo) + } + return returnTo +} + +func oidcReturnLocation(webBaseURL, returnTo string) string { + if base := strings.TrimRight(strings.TrimSpace(webBaseURL), "/"); base != "" { return base + returnTo } return returnTo @@ -255,7 +266,10 @@ func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.R } func (s *Server) writeOIDCCallbackErrorWithDiagnostics(w http.ResponseWriter, r *http.Request, status int, message, code, reason, diagnosticID string) { - base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/") + base := "" + if runtime := s.currentIdentityRuntime(); runtime != nil { + base = strings.TrimRight(strings.TrimSpace(runtime.Revision.WebBaseURL), "/") + } if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) { query := parsed.Query() query.Set("oidcError", code) @@ -320,9 +334,6 @@ func (s *Server) recordOIDCSessionAudit(r *http.Request, user *auth.User) { } func (s *Server) startOIDCSessionCleanup(ctx context.Context) { - if s.oidcSessions == nil { - return - } go func() { ticker := time.NewTicker(15 * time.Minute) defer ticker.Stop() @@ -331,7 +342,11 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if _, err := s.oidcSessions.Cleanup(ctx); err != nil { + runtime := s.currentIdentityRuntime() + if runtime == nil || runtime.Sessions == nil { + continue + } + if _, err := runtime.Sessions.Cleanup(ctx); err != nil { s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err) } } @@ -341,7 +356,8 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) { func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) { + runtime := s.currentIdentityRuntime() + if runtime == nil || !runtime.BrowserEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) { next.ServeHTTP(w, r) return } @@ -350,7 +366,7 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { return } origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin == "" || !originAllowed(origin, s.cfg.CORSAllowedOrigin) { + if origin == "" || !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) { writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF) return } diff --git a/apps/api/internal/httpapi/oidc_user_middleware.go b/apps/api/internal/httpapi/oidc_user_middleware.go index 9203921..2ffa390 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware.go +++ b/apps/api/internal/httpapi/oidc_user_middleware.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) @@ -52,17 +53,25 @@ func (s *Server) resolveGatewayUser(next http.Handler) http.Handler { } func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request, user *auth.User) (store.ResolveOrProvisionOIDCUserResult, error) { + runtime := s.currentIdentityRuntime() + if runtime == nil { + return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable") + } + return s.resolveOIDCUserProjectionForRevision(ctx, r, user, runtime.Revision) +} + +func (s *Server) resolveOIDCUserProjectionForRevision(ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision) (store.ResolveOrProvisionOIDCUserResult, error) { if s.oidcUserResolver == nil { return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable") } return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{ - Issuer: s.cfg.OIDCIssuer, + Issuer: revision.Issuer, Subject: user.ID, Username: user.Username, Roles: user.Roles, TenantID: user.TenantID, - GatewayTenantKey: s.cfg.OIDCGatewayTenantKey, - ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled, + GatewayTenantKey: revision.LocalTenantKey, + ProvisioningEnabled: revision.JITEnabled, RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512), }) diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go index 4e75115..b82b890 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -31,11 +31,12 @@ type securityEventConnectionResponse struct { func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) { ensureSecurityEventTraceID(w, r) w.Header().Set("Cache-Control", "no-store") - if s.securityEventManager == nil { + manager := s.currentSecurityEventManager() + if manager == nil { writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) return } - connection, err := s.securityEventManager.Get(r.Context()) + connection, err := manager.Get(r.Context()) if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) return @@ -50,7 +51,8 @@ func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Reque func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - if s.securityEventManager == nil { + manager := s.currentSecurityEventManager() + if manager == nil { writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing") return } @@ -79,12 +81,12 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) { return } - if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) { + if current, err := manager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) { return } - connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey) + connection, err := manager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey) if err != nil { - s.writeSecurityEventConnectionError(w, r, "connect", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err) return } s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection) @@ -92,13 +94,14 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify", traceID) + manager := s.currentSecurityEventManager() + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID) if !ok { return } - connection, err := s.securityEventManager.Verify(r.Context()) + connection, err := manager.Verify(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, "verify", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err) return } s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection) @@ -106,13 +109,14 @@ func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Re func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate", traceID) + manager := s.currentSecurityEventManager() + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID) if !ok { return } - connection, err := s.securityEventManager.RotateCredential(r.Context()) + connection, err := manager.RotateCredential(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, "rotate", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err) return } s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection) @@ -120,25 +124,34 @@ func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect", traceID) + manager := s.currentSecurityEventManager() + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID) if !ok { return } - connection, err := s.securityEventManager.Disconnect(r.Context()) + connection, err := manager.Disconnect(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, "disconnect", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err) return } s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection) } func (s *Server) securityEventPrerequisites() map[string]any { + runtime := s.currentIdentityRuntime() + if runtime == nil { + return map[string]any{ + "oidcConfigured": false, "introspectionClientConfigured": false, + "publicBaseUrlConfigured": false, "managementClientId": "", "credentialInputSupported": false, + } + } + revision := runtime.Revision return map[string]any{ - "oidcConfigured": s.cfg.OIDCEnabled && s.cfg.OIDCIssuer != "" && s.cfg.OIDCTenantID != "", - "introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "", - "publicBaseUrlConfigured": s.cfg.PublicBaseURL != "", - "managementClientId": s.cfg.OIDCIntrospectionClientID, - "credentialInputSupported": true, + "oidcConfigured": revision.Issuer != "" && revision.TenantID != "", + "introspectionClientConfigured": revision.MachineClientID != "" && revision.MachineCredentialRef != "", + "publicBaseUrlConfigured": revision.PublicBaseURL != "", + "managementClientId": revision.MachineClientID, + "credentialInputSupported": false, } } @@ -151,8 +164,8 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s return value, true } -func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, traceID string) (string, string, bool) { - if s.securityEventManager == nil { +func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, bool) { + if manager == nil { writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") return "", "", false } @@ -164,9 +177,9 @@ func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Requ if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) { return "", "", false } - connection, err := s.securityEventManager.Get(r.Context()) + connection, err := manager.Get(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, operation, traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err) return "", "", false } return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version) @@ -247,11 +260,11 @@ func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int return true } -func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, operation, traceID string, err error) { +func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string, err error) { status, message, code := securityEventConnectionErrorProjection(err) connection := ssfreceiver.ConnectionView{} - if s.securityEventManager != nil { - connection, _ = s.securityEventManager.Get(r.Context()) + if manager != nil { + connection, _ = manager.Get(r.Context()) } errorCategory := code if connection.LastErrorCategory != nil && *connection.LastErrorCategory != "" { diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 26e37df..fd4d7e5 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -11,6 +11,8 @@ import ( "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/oidcsession" "github.com/easyai/easyai-ai-gateway/apps/api/internal/runner" ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" @@ -31,6 +33,8 @@ type Server struct { geminiUploadSessions sync.Map securityEventReceiver http.Handler securityEventManager *ssfreceiver.ConnectionManager + identityRuntime *identityruntime.Manager + identityPairing *identity.PairingService } type oidcPublicClient interface { @@ -63,83 +67,43 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor runner: runner.New(cfg, db, logger), logger: logger, } - server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256 server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret securityEventMetrics := &ssfreceiver.Metrics{} - if cfg.OIDCEnabled { - secretStore, err := securityEventSecretStore(cfg) - if err != nil { - panic("invalid OIDC security event secret store: " + err.Error()) - } - manager, err := ssfreceiver.NewConnectionManager(ctx, db, secretStore, ssfreceiver.ConnectionManagerConfig{ - AppEnv: cfg.AppEnv, OIDCEnabled: cfg.OIDCEnabled, OIDCIssuer: cfg.OIDCIssuer, OIDCTenantID: cfg.OIDCTenantID, - ManagementClientID: cfg.OIDCIntrospectionClientID, ManagementClientSecret: cfg.OIDCIntrospectionClientSecret, - PublicBaseURL: cfg.PublicBaseURL, - HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, - StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, - ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, - }, securityEventMetrics) - if err != nil { - panic("initialize OIDC security event connection manager: " + err.Error()) - } - server.securityEventManager = manager - server.securityEventReceiver = manager + secretStore, err := securityEventSecretStore(cfg) + if err != nil { + panic("invalid identity SecretStore: " + err.Error()) } - if cfg.OIDCEnabled { - var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) - if server.securityEventManager != nil { - evaluator = server.securityEventManager.Evaluate + runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{ + AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute, + HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, + StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, + ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, + }, securityEventMetrics) + server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder) + if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil { + logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed") + } + server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) { + return identity.NewOnboardingClient(baseURL, nil) + }, server.identityRuntime) + server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier { + if runtime := server.identityRuntime.Current(); runtime != nil { + return runtime.Verifier } - verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ - Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, - RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes, - JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, - IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, - IntrospectionClientID: cfg.OIDCIntrospectionClientID, - IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, - IntrospectionCredentialProvider: server.securityEventManager.IntrospectionCredential, - SecurityEventEvaluator: evaluator, - IntrospectionObserver: securityEventMetrics.ObserveIntrospection, - JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") }, - }) - if err != nil { - panic("invalid OIDC configuration: " + err.Error()) - } - server.auth.OIDCVerifier = verifier - if cfg.OIDCBrowserSessionEnabled { - key, err := cfg.OIDCSessionEncryptionKeyBytes() - if err != nil { - panic("invalid OIDC session configuration: " + err.Error()) - } - cipher, err := oidcsession.NewCipher(key) - if err != nil { - panic("invalid OIDC session configuration: " + err.Error()) - } - client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{ - Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, RedirectURI: cfg.OIDCRedirectURI, - PostLogoutRedirectURI: cfg.OIDCPostLogoutRedirectURI, - Scopes: append([]string{"openid", "profile"}, cfg.OIDCRequiredScopes...), - }) - if err != nil { - panic("invalid OIDC public client configuration: " + err.Error()) - } - sessions, err := oidcsession.NewService(db, cipher, verifier, client, oidcsession.Config{ - IdleTTL: time.Duration(cfg.OIDCSessionIdleTTLSeconds) * time.Second, - AbsoluteTTL: time.Duration(cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second, - RefreshBefore: time.Duration(cfg.OIDCSessionRefreshBeforeSeconds) * time.Second, - }) - if err != nil { - panic("invalid OIDC session configuration: " + err.Error()) - } - server.oidcClient = client - server.oidcSessions = sessions - server.oidcSessionCipher = cipher - server.auth.OIDCSessionResolver = func(ctx context.Context, sessionID string) (*auth.User, error) { - user, resolveErr := sessions.Resolve(ctx, sessionID) - return user, oidcSessionRequestError(resolveErr) - } + return nil + } + server.auth.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*auth.User, error) { + runtime := server.identityRuntime.Current() + if runtime == nil || runtime.Sessions == nil { + return nil, auth.ErrUnauthorized } + user, resolveErr := runtime.Sessions.Resolve(ctx, sessionID) + return user, oidcSessionRequestError(resolveErr) + } + server.auth.LegacyJWTEnabledProvider = func() bool { + runtime := server.identityRuntime.Current() + return runtime == nil || runtime.Revision.LegacyJWTEnabled } server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey server.runner.StartAsyncQueueWorker(ctx) diff --git a/apps/api/internal/identityruntime/builder.go b/apps/api/internal/identityruntime/builder.go new file mode 100644 index 0000000..71374d9 --- /dev/null +++ b/apps/api/internal/identityruntime/builder.go @@ -0,0 +1,209 @@ +package identityruntime + +import ( + "context" + "errors" + "net/url" + "slices" + "sync" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type RuntimeBuilderConfig struct { + AppEnv string + JWKSCacheTTL time.Duration + HeartbeatInterval time.Duration + StaleAfter time.Duration + ClockSkew time.Duration +} + +type preparedSecurityRuntime struct { + manager *securityevents.ConnectionManager + cancel context.CancelFunc +} + +type RuntimeBuilder struct { + ctx context.Context + store *store.Store + secrets PairingSecretStore + config RuntimeBuilderConfig + metrics *securityevents.Metrics + mutex sync.Mutex + prepared map[string]preparedSecurityRuntime +} + +type PairingSecretStore interface { + Put(context.Context, string, []byte) error + Get(context.Context, string) ([]byte, error) + Delete(context.Context, string) error +} + +func NewRuntimeBuilder(ctx context.Context, data *store.Store, secrets PairingSecretStore, config RuntimeBuilderConfig, metrics *securityevents.Metrics) *RuntimeBuilder { + if metrics == nil { + metrics = &securityevents.Metrics{} + } + if config.JWKSCacheTTL <= 0 { + config.JWKSCacheTTL = 5 * time.Minute + } + return &RuntimeBuilder{ctx: ctx, store: data, secrets: secrets, config: config, metrics: metrics, prepared: map[string]preparedSecurityRuntime{}} +} + +func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revision) (*Runtime, error) { + if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" || + revision.Audience == "" || revision.RolePrefix == "" { + 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 + } + runtimeCtx, cancel := context.WithCancel(builder.ctx) + runtime := &Runtime{Revision: revision, CookieSecure: secureCookieFor(revision.PublicBaseURL), close: cancel} + + var securityManager *securityevents.ConnectionManager + if revision.SessionRevocation { + prepared := builder.takePreparedSecurityRuntime(revision.ID) + securityManager = prepared.manager + if prepared.cancel != nil { + runtime.close = func() { + cancel() + prepared.cancel() + } + } + if securityManager == nil { + var err error + securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision) + if err != nil { + cancel() + return nil, err + } + } + runtime.SecurityEvents = securityManager + } + + var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) + if securityManager != nil { + evaluator = securityManager.Evaluate + } + credentialProvider := func(ctx context.Context) (string, []byte, error) { + if revision.MachineClientID == "" || revision.MachineCredentialRef == "" { + return "", nil, errors.New("managed machine credential is unavailable") + } + secret, err := builder.secrets.Get(ctx, revision.MachineCredentialRef) + return revision.MachineClientID, secret, err + } + verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ + Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID, + RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...), + JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection, + IntrospectionCredentialProvider: credentialProvider, SecurityEventEvaluator: evaluator, + IntrospectionObserver: builder.metrics.ObserveIntrospection, + JWKSRefreshFailureObserver: func() { builder.metrics.ObserveJWKSRefreshFailure("oidc") }, + }) + if err != nil { + cancel() + return nil, err + } + if err := verifier.ValidateConfiguration(ctx); err != nil { + cancel() + return nil, err + } + runtime.Verifier = verifier + + if slices.Contains(revision.Capabilities, "oidc_login") { + if revision.BrowserClientID == "" || revision.SessionEncryptionKeyRef == "" { + cancel() + return nil, errors.New("OIDC browser session configuration is incomplete") + } + key, err := builder.secrets.Get(ctx, revision.SessionEncryptionKeyRef) + if err != nil { + cancel() + return nil, err + } + cipher, err := oidcsession.NewCipher(key) + clear(key) + if err != nil { + cancel() + return nil, err + } + client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{ + Issuer: revision.Issuer, ClientID: revision.BrowserClientID, + RedirectURI: revision.PublicBaseURL + "/api/v1/auth/oidc/callback", + PostLogoutRedirectURI: revision.WebBaseURL + "/", Scopes: append([]string{"openid", "profile"}, revision.Scopes...), + }) + if err != nil { + cancel() + return nil, err + } + if err := client.ValidateConfiguration(ctx); err != nil { + cancel() + return nil, err + } + sessions, err := oidcsession.NewService(builder.store, cipher, verifier, client, oidcsession.Config{ + IdleTTL: time.Duration(revision.SessionIdleSeconds) * time.Second, + AbsoluteTTL: time.Duration(revision.SessionAbsoluteSeconds) * time.Second, + RefreshBefore: time.Duration(revision.SessionRefreshSeconds) * time.Second, + }) + if err != nil { + cancel() + return nil, err + } + runtime.PublicClient, runtime.Sessions, runtime.SessionCipher = client, sessions, cipher + } + return runtime, nil +} + +func (builder *RuntimeBuilder) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, managementSecret []byte) error { + if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.MachineClientID == "" { + return errors.New("security event configuration is incomplete") + } + runtimeCtx, cancel := context.WithCancel(builder.ctx) + manager, err := builder.newSecurityEventManager(runtimeCtx, revision) + if err != nil { + cancel() + return err + } + secretCopy := append([]byte(nil), managementSecret...) + _, err = manager.Connect(ctx, revision.SecurityEventIssuer, revision.MachineClientID, secretCopy, "identity-pairing-ssf-"+revision.ID) + clear(secretCopy) + if err != nil { + cancel() + return err + } + builder.mutex.Lock() + previous := builder.prepared[revision.ID] + builder.prepared[revision.ID] = preparedSecurityRuntime{manager: manager, cancel: cancel} + builder.mutex.Unlock() + if previous.cancel != nil { + previous.cancel() + } + return nil +} + +func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision) (*securityevents.ConnectionManager, error) { + return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, securityevents.ConnectionManagerConfig{ + AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID, + ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL, + HeartbeatInterval: builder.config.HeartbeatInterval, StaleAfter: builder.config.StaleAfter, ClockSkew: builder.config.ClockSkew, + }, builder.metrics) +} + +func (builder *RuntimeBuilder) takePreparedSecurityRuntime(revisionID string) preparedSecurityRuntime { + builder.mutex.Lock() + defer builder.mutex.Unlock() + prepared := builder.prepared[revisionID] + delete(builder.prepared, revisionID) + return prepared +} + +func secureCookieFor(baseURL string) bool { + parsed, err := url.Parse(baseURL) + return err == nil && parsed.Scheme == "https" +} diff --git a/apps/api/internal/identityruntime/manager.go b/apps/api/internal/identityruntime/manager.go new file mode 100644 index 0000000..0d1d2af --- /dev/null +++ b/apps/api/internal/identityruntime/manager.go @@ -0,0 +1,151 @@ +package identityruntime + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" +) + +type Repository interface { + IdentityConfigurationRevision(context.Context, string) (identity.Revision, error) + ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) + MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error) + MarkIdentityRevisionFailed(context.Context, string, int64, string, string, string) (identity.Revision, error) + ActivateIdentityRevision(context.Context, string, int64) (identity.Revision, bool, error) + DisableActiveIdentityRevision(context.Context, int64) (identity.Revision, error) +} + +type Builder interface { + Build(context.Context, identity.Revision) (*Runtime, error) +} + +type Runtime struct { + Revision identity.Revision + Verifier *auth.OIDCVerifier + PublicClient *auth.OIDCPublicClient + Sessions *oidcsession.Service + SessionCipher *oidcsession.Cipher + SecurityEvents *securityevents.ConnectionManager + CookieSecure bool + close func() +} + +func (runtime *Runtime) Close() { + if runtime != nil && runtime.close != nil { + runtime.close() + } +} + +type Manager struct { + repository Repository + builder Builder + operation sync.Mutex + current atomic.Pointer[Runtime] +} + +func NewManager(repository Repository, builder Builder) *Manager { + return &Manager{repository: repository, builder: builder} +} + +func (manager *Manager) Current() *Runtime { + return manager.current.Load() +} + +func (manager *Manager) LoadActive(ctx context.Context) error { + manager.operation.Lock() + defer manager.operation.Unlock() + revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx) + if errors.Is(err, identity.ErrRevisionNotFound) { + return nil + } + if err != nil { + return err + } + runtime, err := manager.builder.Build(ctx, revision) + if err != nil { + return err + } + runtime.Revision = revision + manager.current.Store(runtime) + return nil +} + +func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { + manager.operation.Lock() + defer manager.operation.Unlock() + revision, err := manager.repository.IdentityConfigurationRevision(ctx, id) + if err != nil { + return identity.Revision{}, err + } + if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded { + return identity.Revision{}, identity.ErrRevisionConflict + } + candidate, buildErr := manager.builder.Build(ctx, revision) + if buildErr != nil { + _, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID) + return identity.Revision{}, buildErr + } + candidate.Close() + return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID) +} + +func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64) (identity.Revision, error) { + manager.operation.Lock() + defer manager.operation.Unlock() + revision, err := manager.repository.IdentityConfigurationRevision(ctx, id) + if err != nil { + return identity.Revision{}, err + } + if revision.Version != expectedVersion || revision.State != identity.RevisionValidated { + return identity.Revision{}, identity.ErrRevisionConflict + } + candidate, err := manager.builder.Build(ctx, revision) + if err != nil { + return identity.Revision{}, err + } + activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion) + if err != nil { + candidate.Close() + return identity.Revision{}, err + } + candidate.Revision = activated + old := manager.current.Swap(candidate) + retireRuntime(old) + return activated, nil +} + +func (manager *Manager) Disable(ctx context.Context, expectedVersion int64) (identity.Revision, error) { + manager.operation.Lock() + defer manager.operation.Unlock() + disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion) + if err != nil { + return identity.Revision{}, err + } + old := manager.current.Swap(nil) + retireRuntime(old) + return disabled, nil +} + +func (manager *Manager) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, secret []byte) error { + preparer, ok := manager.builder.(interface { + PrepareSecurityEvents(context.Context, identity.Revision, []byte) error + }) + if !ok { + return errors.New("security event runtime preparation is unavailable") + } + return preparer.PrepareSecurityEvents(ctx, revision, secret) +} + +func retireRuntime(runtime *Runtime) { + if runtime == nil || runtime.close == nil { + return + } + time.AfterFunc(30*time.Second, runtime.Close) +} diff --git a/apps/api/internal/identityruntime/manager_test.go b/apps/api/internal/identityruntime/manager_test.go new file mode 100644 index 0000000..3494d70 --- /dev/null +++ b/apps/api/internal/identityruntime/manager_test.go @@ -0,0 +1,120 @@ +package identityruntime + +import ( + "context" + "errors" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" +) + +type runtimeRepositoryFake struct { + revisions map[string]identity.Revision + active identity.Revision + activateCalled bool +} + +func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context, id string) (identity.Revision, error) { + revision, ok := f.revisions[id] + if !ok { + return identity.Revision{}, identity.ErrRevisionNotFound + } + return revision, nil +} +func (f *runtimeRepositoryFake) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) { + if f.active.ID == "" { + return identity.Revision{}, identity.ErrRevisionNotFound + } + return f.active, nil +} +func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, id string, expected int64, _, _ string) (identity.Revision, error) { + revision := f.revisions[id] + if revision.Version != expected { + return identity.Revision{}, identity.ErrRevisionConflict + } + revision.State, revision.Version = identity.RevisionValidated, revision.Version+1 + f.revisions[id] = revision + return revision, nil +} +func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id string, expected int64, category, _, _ string) (identity.Revision, error) { + revision := f.revisions[id] + if revision.Version != expected { + return identity.Revision{}, identity.ErrRevisionConflict + } + revision.State, revision.Version, revision.LastErrorCategory = identity.RevisionFailed, revision.Version+1, category + f.revisions[id] = revision + return revision, nil +} +func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64) (identity.Revision, bool, error) { + f.activateCalled = true + revision := f.revisions[id] + if revision.Version != expected || revision.State != identity.RevisionValidated { + return identity.Revision{}, false, identity.ErrRevisionConflict + } + if f.active.ID != "" { + old := f.active + old.State = identity.RevisionSuperseded + f.revisions[old.ID] = old + } + revision.State, revision.Version = identity.RevisionActive, revision.Version+1 + f.active, f.revisions[id] = revision, revision + return revision, true, nil +} +func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64) (identity.Revision, error) { + if f.active.Version != expected { + return identity.Revision{}, identity.ErrRevisionConflict + } + disabled := f.active + disabled.State, disabled.Version = identity.RevisionSuperseded, disabled.Version+1 + f.revisions[disabled.ID] = disabled + f.active = identity.Revision{} + return disabled, nil +} + +type runtimeBuilderFake struct { + err error + builtIDs []string +} + +func (f *runtimeBuilderFake) Build(_ context.Context, revision identity.Revision) (*Runtime, error) { + f.builtIDs = append(f.builtIDs, revision.ID) + if f.err != nil { + return nil, f.err + } + return &Runtime{Revision: revision}, nil +} + +func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} + draft := identity.Revision{ID: "draft", State: identity.RevisionDraft, Version: 1} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active, "draft": draft}, active: active} + builder := &runtimeBuilderFake{err: errors.New("discovery failed")} + manager := NewManager(repository, builder) + manager.current.Store(&Runtime{Revision: active}) + + if _, err := manager.Validate(context.Background(), draft.ID, draft.Version, "trace", "audit"); err == nil { + t.Fatal("validation failure was ignored") + } + if manager.Current().Revision.ID != active.ID || repository.activateCalled { + t.Fatal("validation failure changed current runtime or activated the draft") + } + if repository.revisions[draft.ID].State != identity.RevisionFailed { + t.Fatal("failed draft was not marked failed") + } +} + +func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) { + active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2} + candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"old": active, "new": candidate}, active: active} + manager := NewManager(repository, &runtimeBuilderFake{}) + manager.current.Store(&Runtime{Revision: active}) + + activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version) + if err != nil { + t.Fatal(err) + } + if !repository.activateCalled || activated.State != identity.RevisionActive || manager.Current().Revision.ID != candidate.ID { + t.Fatalf("activation order failed: activated=%#v current=%#v", activated, manager.Current()) + } +}