feat(identity): 实现统一认证运行时热切换

从 Active Revision 构造并验证 OIDC、BFF Session、Introspection 与 SSF Runtime,在数据库激活成功后原子替换内存引用。请求链路使用不可变快照,失败保留当前运行时,本地管理登录不受影响。\n\n验证:go test ./apps/api/...;go vet ./apps/api/...
This commit is contained in:
chengcheng 2026-07-17 12:05:00 +08:00
parent 96bbd3a2f6
commit a9e23cb237
14 changed files with 797 additions and 152 deletions

View File

@ -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) {

View File

@ -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{})

View File

@ -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")

View File

@ -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)

View File

@ -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) {

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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),
})

View File

@ -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 != "" {

View File

@ -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)

View File

@ -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"
}

View File

@ -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)
}

View File

@ -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())
}
}