feat: 接入 OIDC 公共客户端登录链路

由 Gateway 服务端完成 PKCE 换码、ID Token 校验、JIT 用户解析和 Session 建立。新增登录、回调、退出与本地会话删除接口,并移除旧的 Access Token Cookie 桥接接口。
This commit is contained in:
2026-07-13 19:09:38 +08:00
parent d345c070ae
commit c5c82bb528
7 changed files with 658 additions and 281 deletions
+74 -1
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
@@ -10,6 +11,7 @@ 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/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
@@ -20,11 +22,29 @@ type Server struct {
store *store.Store
oidcUserResolver oidcUserResolver
auth *auth.Authenticator
oidcClient oidcPublicClient
oidcSessions oidcSessionManager
oidcSessionCipher *oidcsession.Cipher
runner *runner.Service
logger *slog.Logger
geminiUploadSessions sync.Map
}
type oidcPublicClient interface {
AuthorizationURL(context.Context, string, string, string) (string, error)
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
RevokeRefreshToken(context.Context, string) error
EndSessionURL(context.Context, string) (string, error)
}
type oidcSessionManager interface {
Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error)
Resolve(context.Context, string) (*auth.User, error)
Delete(context.Context, string) (oidcsession.TokenBundle, error)
Cleanup(context.Context) (int64, error)
}
func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
return NewServerWithContext(context.Background(), cfg, db, logger)
}
@@ -55,10 +75,44 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
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)
}
}
}
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
server.runner.StartAsyncQueueWorker(ctx)
server.startLocalTempAssetCleanup(ctx)
server.startOIDCSessionCleanup(ctx)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health)
@@ -69,7 +123,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
mux.HandleFunc("POST /api/v1/auth/oidc/session", server.createOIDCBrowserSession)
mux.HandleFunc("GET /api/v1/auth/oidc/login", server.startOIDCLogin)
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
@@ -217,6 +273,23 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
}
func oidcSessionRequestError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, oidcsession.ErrSessionExpired):
return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_EXPIRED", "登录会话已过期,请重新登录")
case errors.Is(err, oidcsession.ErrSessionRefreshUnavailable):
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_REFRESH_UNAVAILABLE", "认证中心暂时不可用,请稍后重试")
case errors.Is(err, oidcsession.ErrSessionStoreUnavailable):
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_STORE_UNAVAILABLE", "登录会话存储暂时不可用")
case errors.Is(err, oidcsession.ErrGatewayUserDisabled):
return auth.NewRequestAuthError(http.StatusForbidden, "GATEWAY_USER_DISABLED", "该 Gateway 账号已停用,请联系管理员")
default:
return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_INVALID", "登录会话无效,请重新登录")
}
}
func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) http.Handler {
return s.requireUser(permission, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())