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
+76 -8
View File
@@ -4279,16 +4279,16 @@
} }
} }
}, },
"/api/v1/auth/oidc/session": { "/api/v1/auth/oidc/callback": {
"post": { "get": {
"description": "验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway JWT。", "description": "Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token,不发送 Client SecretToken 加密存入服务端 Session。",
"tags": [ "tags": [
"auth" "auth"
], ],
"summary": "建立 OIDC 浏览器会话", "summary": "完成 OIDC 公共客户端登录",
"responses": { "responses": {
"204": { "303": {
"description": "No Content" "description": "See Other"
}, },
"400": { "400": {
"description": "Bad Request", "description": "Bad Request",
@@ -4302,6 +4302,40 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope" "$ref": "#/definitions/httpapi.ErrorEnvelope"
} }
}, },
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/v1/auth/oidc/login": {
"get": {
"description": "Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。",
"tags": [
"auth"
],
"summary": "开始 OIDC 公共客户端登录",
"parameters": [
{
"type": "string",
"description": "登录后返回的站内相对路径",
"name": "returnTo",
"in": "query"
}
],
"responses": {
"303": {
"description": "See Other"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": { "404": {
"description": "Not Found", "description": "Not Found",
"schema": { "schema": {
@@ -4309,15 +4343,49 @@
} }
} }
} }
}, }
},
"/api/v1/auth/oidc/logout": {
"post": {
"description": "删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。",
"tags": [
"auth"
],
"summary": "注销 OIDC 登录会话",
"responses": {
"303": {
"description": "See Other"
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/v1/auth/oidc/session": {
"delete": { "delete": {
"tags": [ "tags": [
"auth" "auth"
], ],
"summary": "注销 OIDC 浏览器会话", "summary": "删除本地 OIDC 浏览器会话",
"responses": { "responses": {
"204": { "204": {
"description": "No Content" "description": "No Content"
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
} }
} }
} }
+58 -14
View File
@@ -5276,20 +5276,13 @@ paths:
summary: 本地登录 summary: 本地登录
tags: tags:
- auth - auth
/api/v1/auth/oidc/session: /api/v1/auth/oidc/callback:
delete: get:
description: Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token,不发送 Client SecretToken
加密存入服务端 Session。
responses: responses:
"204": "303":
description: No Content description: See Other
summary: 注销 OIDC 浏览器会话
tags:
- auth
post:
description: 验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway
JWT。
responses:
"204":
description: No Content
"400": "400":
description: Bad Request description: Bad Request
schema: schema:
@@ -5298,11 +5291,62 @@ paths:
description: Unauthorized description: Unauthorized
schema: schema:
$ref: '#/definitions/httpapi.ErrorEnvelope' $ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
summary: 完成 OIDC 公共客户端登录
tags:
- auth
/api/v1/auth/oidc/login:
get:
description: Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
parameters:
- description: 登录后返回的站内相对路径
in: query
name: returnTo
type: string
responses:
"303":
description: See Other
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404": "404":
description: Not Found description: Not Found
schema: schema:
$ref: '#/definitions/httpapi.ErrorEnvelope' $ref: '#/definitions/httpapi.ErrorEnvelope'
summary: 建立 OIDC 浏览器会话 summary: 开始 OIDC 公共客户端登录
tags:
- auth
/api/v1/auth/oidc/logout:
post:
description: 删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。
responses:
"303":
description: See Other
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
summary: 注销 OIDC 登录会话
tags:
- auth
/api/v1/auth/oidc/session:
delete:
responses:
"204":
description: No Content
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
summary: 删除本地 OIDC 浏览器会话
tags: tags:
- auth - auth
/api/v1/auth/register: /api/v1/auth/register:
@@ -6,12 +6,16 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -42,12 +46,54 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
t.Fatalf("generate test signing key: %v", err) t.Fatalf("generate test signing key: %v", err)
} }
var issuer string var issuer string
var gatewayBaseURL string
var expectedPKCEChallenge string
validSubject := ""
issuerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { issuerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/.well-known/openid-configuration": case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"}) _ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer, "jwks_uri": issuer + "/jwks",
"authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token",
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
})
case "/jwks": case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}}) _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}})
case "/authorize":
if r.URL.Query().Get("response_type") != "code" || r.URL.Query().Get("client_id") != "gateway-public-test" ||
r.URL.Query().Get("code_challenge_method") != "S256" || r.URL.Query().Get("nonce") == "" {
http.Error(w, "invalid authorization request", http.StatusBadRequest)
return
}
expectedPKCEChallenge = r.URL.Query().Get("code_challenge")
callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=test-code&state=" + url.QueryEscape(r.URL.Query().Get("state")) +
"&nonce=" + url.QueryEscape(r.URL.Query().Get("nonce"))
http.Redirect(w, r, callback, http.StatusSeeOther)
case "/token":
if err := r.ParseForm(); err != nil || r.Form.Get("grant_type") != "authorization_code" ||
r.Form.Get("client_id") != "gateway-public-test" || r.Form.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
http.Error(w, "invalid token request", http.StatusBadRequest)
return
}
digest := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
if base64.RawURLEncoding.EncodeToString(digest[:]) != expectedPKCEChallenge {
http.Error(w, "invalid PKCE verifier", http.StatusBadRequest)
return
}
accessToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
idToken := signedOIDCJITToken(t, key, issuer, validSubject, func(claims jwt.MapClaims) {
claims["aud"] = "gateway-public-test"
// The nonce is retained from the authorization request by this test issuer.
claims["nonce"] = currentOIDCTestNonce
})
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": accessToken, "refresh_token": "opaque-test-refresh-token", "id_token": idToken,
"token_type": "Bearer", "expires_in": 300,
})
case "/revoke":
w.WriteHeader(http.StatusOK)
case "/logout":
http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -56,7 +102,7 @@ func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
issuer = issuerServer.URL issuer = issuerServer.URL
suffix := time.Now().UTC().Format("20060102150405.000000000") suffix := time.Now().UTC().Format("20060102150405.000000000")
validSubject := "platform-http-jit-" + suffix validSubject = "platform-http-jit-" + suffix
rejectedSubjects := []string{ rejectedSubjects := []string{
"platform-http-scope-" + suffix, "platform-http-scope-" + suffix,
"platform-http-role-" + suffix, "platform-http-role-" + suffix,
@@ -73,31 +119,39 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
}) })
baseConfig := config.Config{ baseConfig := config.Config{
AppEnv: "test", AppEnv: "test",
HTTPAddr: ":0", HTTPAddr: ":0",
DatabaseURL: databaseURL, DatabaseURL: databaseURL,
IdentityMode: "hybrid", IdentityMode: "hybrid",
JWTSecret: "test-only-jwt-secret", JWTSecret: "test-only-jwt-secret",
OIDCEnabled: true, OIDCEnabled: true,
OIDCIssuer: issuer, OIDCIssuer: issuer,
OIDCAudience: "gateway-api", OIDCAudience: "gateway-api",
OIDCTenantID: "auth-center-test-tenant", OIDCTenantID: "auth-center-test-tenant",
OIDCRolePrefix: "gateway.", OIDCRolePrefix: "gateway.",
OIDCRequiredScopes: []string{"gateway.access"}, OIDCRequiredScopes: []string{"gateway.access"},
OIDCJWKSCacheTTLSeconds: 60, OIDCJWKSCacheTTLSeconds: 60,
OIDCAcceptLegacyHS256: true, OIDCAcceptLegacyHS256: true,
OIDCJITProvisioningEnabled: true, OIDCJITProvisioningEnabled: true,
OIDCGatewayTenantKey: "default", OIDCGatewayTenantKey: "default",
OIDCBrowserSessionEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false, OIDCSessionCookieSecure: false,
LocalGeneratedStorageDir: t.TempDir(), OIDCClientID: "gateway-public-test",
LocalUploadedStorageDir: t.TempDir(), OIDCRedirectURI: "http://localhost/api/v1/auth/oidc/callback",
LocalTempAssetTTLHours: 1, OIDCPostLogoutRedirectURI: "http://localhost:5178/",
CORSAllowedOrigin: "http://localhost:5178", OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32)),
TaskProgressCallbackEnabled: false, OIDCSessionIdleTTLSeconds: 1800,
OIDCSessionAbsoluteTTLSeconds: 28800,
OIDCSessionRefreshBeforeSeconds: 60,
LocalGeneratedStorageDir: t.TempDir(),
LocalUploadedStorageDir: t.TempDir(),
LocalTempAssetTTLHours: 1,
CORSAllowedOrigin: "http://localhost:5178",
TaskProgressCallbackEnabled: false,
} }
server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close() defer server.Close()
gatewayBaseURL = server.URL
validToken := signedOIDCJITToken(t, key, issuer, validSubject, nil) validToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
var me auth.User var me auth.User
@@ -105,7 +159,7 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
if me.ID != validSubject || me.Source != "oidc" || me.GatewayUserID == "" || me.GatewayTenantID == "" || me.TenantKey != "default" || me.UserGroupID == "" { if me.ID != validSubject || me.Source != "oidc" || me.GatewayUserID == "" || me.GatewayTenantID == "" || me.TenantKey != "default" || me.UserGroupID == "" {
t.Fatalf("OIDC /me did not include the local Gateway projection") t.Fatalf("OIDC /me did not include the local Gateway projection")
} }
sessionCookie := createOIDCSessionCookie(t, server.URL, validToken) sessionCookie := createOIDCBFFSessionCookie(t, server.URL)
request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/me", nil) request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/me", nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -216,26 +270,37 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled) assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
} }
func createOIDCSessionCookie(t *testing.T, baseURL string, token string) *http.Cookie { var currentOIDCTestNonce string
func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
t.Helper() t.Helper()
request, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/oidc/session", nil) jar, err := cookiejar.New(nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
request.Header.Set("Authorization", "Bearer "+token) client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error {
request.Header.Set("Origin", "http://localhost:5178") if request.URL.Path == "/api/v1/auth/oidc/callback" {
response, err := http.DefaultClient.Do(request) currentOIDCTestNonce = request.URL.Query().Get("nonce")
}
if len(via) > 10 {
return errors.New("too many redirects")
}
return nil
}}
response, err := client.Get(baseURL + "/api/v1/auth/oidc/login?returnTo=%2Fapi%2Fv1%2Fme")
if err != nil { if err != nil {
t.Fatalf("create OIDC browser session: %v", err) t.Fatalf("complete OIDC BFF login: %v", err)
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode != http.StatusNoContent { if response.StatusCode != http.StatusOK {
t.Fatalf("create OIDC browser session status = %d, want 204", response.StatusCode) body, _ := io.ReadAll(response.Body)
t.Fatalf("OIDC BFF login status = %d, want 200: %s", response.StatusCode, body)
} }
for _, cookie := range response.Cookies() { parsedBaseURL, _ := url.Parse(baseURL)
for _, cookie := range jar.Cookies(parsedBaseURL) {
if cookie.Name == auth.OIDCSessionCookieName { if cookie.Name == auth.OIDCSessionCookieName {
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { if strings.Count(cookie.Value, ".") == 2 {
t.Fatalf("unsafe OIDC browser session cookie: %#v", cookie) t.Fatal("browser session cookie contains a JWT instead of an opaque session ID")
} }
return cookie return cookie
} }
+253 -60
View File
@@ -1,90 +1,291 @@
package httpapi package httpapi
import ( import (
"context"
"errors"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
) )
const ( const (
errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED" errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED"
errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID" errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID"
errorCodeOIDCSessionTooLarge = "OIDC_SESSION_TOKEN_TOO_LARGE" errorCodeOIDCSessionExpired = "OIDC_SESSION_EXPIRED"
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED" errorCodeOIDCSessionStoreUnavailable = "OIDC_SESSION_STORE_UNAVAILABLE"
maxOIDCSessionCookieTokenBytes = 3800 errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
errorCodeOIDCLoginInvalid = "OIDC_LOGIN_INVALID"
errorCodeOIDCTokenExchangeFailed = "OIDC_TOKEN_EXCHANGE_FAILED"
) )
// createOIDCBrowserSession godoc // startOIDCLogin godoc
// @Summary 建立 OIDC 浏览器会话 // @Summary 开始 OIDC 公共客户端登录
// @Description 验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway JWT // @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token
// @Tags auth // @Tags auth
// @Success 204 // @Param returnTo query string false "登录后返回的站内相对路径"
// @Success 303
// @Failure 400 {object} ErrorEnvelope // @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/session [post] // @Router /api/v1/auth/oidc/login [get]
func (s *Server) createOIDCBrowserSession(w http.ResponseWriter, r *http.Request) { func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || s.auth == nil || s.auth.OIDCVerifier == nil { if !s.oidcBrowserSessionReady() {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return return
} }
raw := bearerToken(r.Header.Get("Authorization")) returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
if raw == "" { if returnTo == "" {
writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid) returnTo = "/"
}
transaction, challenge, err := oidcsession.NewLoginTransaction(returnTo, time.Now())
if err != nil {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return return
} }
if len(raw) > maxOIDCSessionCookieTokenBytes { encoded, err := s.oidcSessionCipher.EncodeLoginTransaction(transaction)
writeError(w, http.StatusBadRequest, "OIDC access token is too large for browser session", errorCodeOIDCSessionTooLarge) if err != nil {
s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return return
} }
user, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), raw) authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, challenge)
if err != nil || user == nil || !strings.EqualFold(strings.TrimSpace(user.Source), "oidc") { if err != nil {
writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid) s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE")
return
}
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,
})
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, authorizationURL, http.StatusSeeOther)
}
// completeOIDCLogin godoc
// @Summary 完成 OIDC 公共客户端登录
// @Description Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token,不发送 Client SecretToken 加密存入服务端 Session。
// @Tags auth
// @Success 303
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/callback [get]
func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
if !s.oidcBrowserSessionReady() {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
s.clearOIDCLoginCookie(w)
cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName)
if err != nil {
s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务无效或已过期", errorCodeOIDCLoginInvalid)
return
}
transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
if err != nil || r.URL.Query().Get("state") == "" || r.URL.Query().Get("state") != transaction.State || r.URL.Query().Get("code") == "" {
s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务校验失败,请重新登录", errorCodeOIDCLoginInvalid)
return
}
tokens, err := s.oidcClient.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)
if err != nil || identity == nil {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
return
}
idSubject, err := s.auth.OIDCVerifier.VerifyIDToken(r.Context(), tokens.IDToken, s.cfg.OIDCClientID, transaction.Nonce)
if err != nil || idSubject != identity.ID {
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
return
}
projection, err := s.resolveOIDCUserProjection(r.Context(), r, identity)
if err != nil {
s.writeOIDCCallbackProjectionError(w, r, err)
return
}
if projection.User == nil || projection.User.GatewayUserID == "" {
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user"))
return
}
rawSession, err := s.oidcSessions.Create(r.Context(), oidcsession.TokenBundle{
AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken,
}, projection.User)
if err != nil {
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "登录会话保存失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return return
} }
now := time.Now() now := time.Now()
if user.TokenExpiresAt.IsZero() || !user.TokenExpiresAt.After(now) { http.SetCookie(w, &http.Cookie{
writeError(w, http.StatusUnauthorized, "OIDC access token has expired", errorCodeOIDCSessionInvalid) 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,
})
w.Header().Set("Cache-Control", "no-store")
s.recordOIDCSessionAudit(r, projection.User)
http.Redirect(w, r, s.oidcReturnLocation(transaction.ReturnTo), http.StatusSeeOther)
}
// logoutOIDCSession godoc
// @Summary 注销 OIDC 登录会话
// @Description 删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。
// @Tags auth
// @Success 303
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/logout [post]
func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
if !s.oidcBrowserSessionReady() {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return return
} }
maxAge := int(time.Until(user.TokenExpiresAt).Seconds()) var bundle oidcsession.TokenBundle
if maxAge < 1 { if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
maxAge = 1 bundle, err = s.oidcSessions.Delete(r.Context(), cookie.Value)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
return
}
} }
http.SetCookie(w, &http.Cookie{ s.clearOIDCSessionCookie(w)
Name: auth.OIDCSessionCookieName, if bundle.RefreshToken != "" {
Value: raw, if err := s.oidcClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil {
Path: "/", s.logger.WarnContext(r.Context(), "revoke OIDC refresh token failed", "error", err)
Expires: user.TokenExpiresAt, }
MaxAge: maxAge, }
HttpOnly: true, // Do not put the encrypted-at-rest ID Token into a browser-visible redirect URL.
Secure: s.cfg.OIDCSessionCookieSecure, location, err := s.oidcClient.EndSessionURL(r.Context(), "")
SameSite: http.SameSiteStrictMode, if err != nil {
}) location = s.cfg.OIDCPostLogoutRedirectURI
}
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, location, http.StatusSeeOther)
}
// deleteOIDCBrowserSession godoc
// @Summary 删除本地 OIDC 浏览器会话
// @Tags auth
// @Success 204
// @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 {
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
if _, err := s.oidcSessions.Delete(r.Context(), cookie.Value); err != nil {
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
return
}
}
}
s.clearOIDCSessionCookie(w)
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// deleteOIDCBrowserSession godoc func (s *Server) oidcBrowserSessionReady() bool {
// @Summary 注销 OIDC 浏览器会话 return s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled && s.auth != nil && s.auth.OIDCVerifier != nil &&
// @Tags auth s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil
// @Success 204 }
// @Router /api/v1/auth/oidc/session [delete]
func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, _ *http.Request) { func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: auth.OIDCSessionCookieName, Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(),
Value: "", Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode,
Path: "/",
Expires: time.Unix(1, 0),
MaxAge: -1,
HttpOnly: true,
Secure: s.cfg.OIDCSessionCookieSecure,
SameSite: http.SameSiteStrictMode,
}) })
w.Header().Set("Cache-Control", "no-store") }
w.WriteHeader(http.StatusNoContent)
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,
})
}
func (s *Server) oidcReturnLocation(returnTo string) string {
if base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/"); base != "" {
return base + returnTo
}
return returnTo
}
func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request, status int, message, code string) {
base := strings.TrimRight(strings.TrimSpace(s.cfg.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)
parsed.RawQuery = query.Encode()
http.Redirect(w, r, parsed.String(), http.StatusSeeOther)
return
}
writeError(w, status, message, code)
}
func (s *Server) writeOIDCCallbackProjectionError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned)
case errors.Is(err, store.ErrOIDCUserDisabled):
s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该 Gateway 账号已停用,请联系管理员", errorCodeGatewayUserDisabled)
case errors.Is(err, store.ErrOIDCTenantUnavailable):
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 租户尚未就绪,请联系管理员", errorCodeGatewayTenantUnavailable)
default:
s.logger.ErrorContext(r.Context(), "resolve OIDC gateway user during callback failed", "error", err)
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 账号初始化失败,请稍后重试", errorCodeGatewayProvisioningFailed)
}
}
func (s *Server) recordOIDCSessionAudit(r *http.Request, user *auth.User) {
if s.store == nil || user == nil {
return
}
audit, err := s.store.RecordAuditLog(r.Context(), store.AuditLogInput{
Category: "identity", Action: "identity.oidc_session.created",
ActorGatewayUserID: user.GatewayUserID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles,
TargetType: "gateway_user", TargetID: user.GatewayUserID,
TargetGatewayUserID: user.GatewayUserID, TargetGatewayTenantID: user.GatewayTenantID,
RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512),
Metadata: map[string]any{"sessionMode": "public-client-pkce-bff"},
})
if err != nil {
s.logger.WarnContext(r.Context(), "record OIDC session audit failed", "error", err)
return
}
s.logger.InfoContext(r.Context(), "OIDC server session created", "gatewayUserId", user.GatewayUserID, "auditId", audit.ID)
}
func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
if s.oidcSessions == nil {
return
}
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := s.oidcSessions.Cleanup(ctx); err != nil {
s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err)
}
}
}
}()
} }
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
@@ -106,14 +307,6 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
}) })
} }
func bearerToken(value string) string {
fields := strings.Fields(value)
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
return fields[1]
}
return ""
}
func isSafeHTTPMethod(method string) bool { func isSafeHTTPMethod(method string) bool {
switch method { switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions: case http.MethodGet, http.MethodHead, http.MethodOptions:
+78 -146
View File
@@ -1,140 +1,90 @@
package httpapi package httpapi
import ( import (
"crypto/ecdsa" "bytes"
"crypto/elliptic" "context"
"crypto/rand"
"encoding/json"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "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/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
) )
func TestCreateOIDCBrowserSessionSetsProtectedSharedCookie(t *testing.T) { func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testing.T) {
server, token, closeIssuer := newOIDCSessionTestServer(t) cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
defer closeIssuer() client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize?request=redacted"}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil) server := &Server{
request.Header.Set("Authorization", "Bearer "+token) cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, OIDCSessionCookieSecure: true},
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, request)
server.createOIDCBrowserSession(recorder, request)
response := recorder.Result() response := recorder.Result()
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode != http.StatusNoContent { if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != client.authorizationURL {
t.Fatalf("session creation status = %d, want 204", response.StatusCode) t.Fatalf("login status=%d location=%q", response.StatusCode, response.Header.Get("Location"))
} }
var sessionCookie *http.Cookie cookies := response.Cookies()
for _, cookie := range response.Cookies() { if len(cookies) != 1 || cookies[0].Name != oidcsession.LoginTransactionCookieName || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode || cookies[0].MaxAge != 600 {
if cookie.Name == auth.OIDCSessionCookieName { t.Fatalf("unsafe login transaction cookie: %#v", cookies)
sessionCookie = cookie
break
}
} }
if sessionCookie == nil { transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute))
t.Fatal("OIDC session cookie was not set") if err != nil || transaction.ReturnTo != "/workspace?tab=wallet" {
t.Fatalf("transaction=%#v err=%v", transaction, err)
} }
if !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode || sessionCookie.Path != "/" { if client.state == "" || client.nonce == "" || client.challenge == "" {
t.Fatalf("unsafe OIDC session cookie attributes: %#v", sessionCookie) t.Fatal("authorization redirect omitted state, nonce or PKCE challenge")
}
if sessionCookie.MaxAge <= 0 || sessionCookie.Expires.IsZero() {
t.Fatalf("OIDC session cookie did not inherit token expiration: %#v", sessionCookie)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), token) {
t.Fatal("OIDC access token leaked into session response body")
} }
} }
func TestCreateOIDCBrowserSessionRejectsNonOIDCCredential(t *testing.T) { func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
server, _, closeIssuer := newOIDCSessionTestServer(t) cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
defer closeIssuer() server := &Server{
localToken, err := server.auth.SignJWT(&auth.User{ID: "local-user", Source: "gateway", Roles: []string{"user"}}, 0) cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true},
if err != nil { auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
t.Fatal(err) oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
request.Header.Set("Authorization", "Bearer "+localToken)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example", nil))
server.createOIDCBrowserSession(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("local credential session creation status = %d, want 401", recorder.Code)
}
}
func TestCreateOIDCBrowserSessionRejectsOversizedTokenBeforeCookieWrite(t *testing.T) {
server, _, closeIssuer := newOIDCSessionTestServer(t)
defer closeIssuer()
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
request.Header.Set("Authorization", "Bearer "+strings.Repeat("a", maxOIDCSessionCookieTokenBytes+1))
recorder := httptest.NewRecorder()
server.createOIDCBrowserSession(recorder, request)
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" { if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("oversized token response status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie")) t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
} }
} }
func TestCreateOIDCBrowserSessionHonorsDisabledFlag(t *testing.T) { func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
server, token, closeIssuer := newOIDCSessionTestServer(t) sessions := &fakeOIDCSessions{}
defer closeIssuer() server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions}
server.cfg.OIDCBrowserSessionEnabled = false
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.createOIDCBrowserSession(recorder, request)
if recorder.Code != http.StatusNotFound || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("disabled session response status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
}
}
func TestDeleteOIDCBrowserSessionExpiresCookie(t *testing.T) {
server, _, closeIssuer := newOIDCSessionTestServer(t)
defer closeIssuer()
request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil) request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"}) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "opaque-session"})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.deleteOIDCBrowserSession(recorder, request) server.deleteOIDCBrowserSession(recorder, request)
response := recorder.Result() response := recorder.Result()
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode != http.StatusNoContent { if response.StatusCode != http.StatusNoContent || sessions.deleted != "opaque-session" {
t.Fatalf("session deletion status = %d, want 204", response.StatusCode) t.Fatalf("delete status=%d session=%q", response.StatusCode, sessions.deleted)
} }
cookies := response.Cookies() cookies := response.Cookies()
if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 { if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("OIDC session cookie was not expired: %#v", cookies) t.Fatalf("OIDC session cookie was not safely expired: %#v", cookies)
} }
} }
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
server := &Server{cfg: config.Config{ server := &Server{cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, CORSAllowedOrigin: "https://gateway.example.com"}}
OIDCEnabled: true,
OIDCBrowserSessionEnabled: true,
CORSAllowedOrigin: "https://gateway.example.com",
}}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
handler := server.protectOIDCSessionCookie(next) handler := server.protectOIDCSessionCookie(next)
for _, test := range []struct { for _, test := range []struct {
name string name, method, origin string
method string bearer bool
origin string wantStatus int
bearer bool
wantStatus int
}{ }{
{name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden}, {name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden}, {name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
@@ -145,9 +95,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(test.method, "/api/workspace/tasks", nil) request := httptest.NewRequest(test.method, "/api/workspace/tasks", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"}) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"})
if test.origin != "" { request.Header.Set("Origin", test.origin)
request.Header.Set("Origin", test.origin)
}
if test.bearer { if test.bearer {
request.Header.Set("Authorization", "Bearer explicit-token") request.Header.Set("Authorization", "Bearer explicit-token")
} }
@@ -161,60 +109,44 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
} }
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) { func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
server := &Server{cfg: config.Config{ server := &Server{cfg: config.Config{OIDCEnabled: false, OIDCBrowserSessionEnabled: true}}
OIDCEnabled: false,
OIDCBrowserSessionEnabled: true,
}}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"}) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })).ServeHTTP(recorder, request)
server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent { if recorder.Code != http.StatusNoContent {
t.Fatalf("OIDC-disabled request status = %d, want 204", recorder.Code) t.Fatalf("OIDC-disabled request status = %d, want 204", recorder.Code)
} }
} }
func newOIDCSessionTestServer(t *testing.T) (*Server, string, func()) { type fakeOIDCClient struct {
t.Helper() authorizationURL string
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) state, nonce, challenge string
if err != nil {
t.Fatal(err)
}
var issuer string
issuerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}})
default:
http.NotFound(w, r)
}
}))
issuer = issuerServer.URL
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "auth-center-test-tenant",
RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: issuerServer.Client(),
})
if err != nil {
issuerServer.Close()
t.Fatal(err)
}
authenticator := auth.New("test-local-jwt-secret", "", "")
authenticator.OIDCVerifier = verifier
server := &Server{
cfg: config.Config{
OIDCEnabled: true,
OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false,
CORSAllowedOrigin: "http://localhost:5178",
},
auth: authenticator,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
return server, signedOIDCJITToken(t, key, issuer, "session-user", nil), issuerServer.Close
} }
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge string) (string, error) {
f.state, f.nonce, f.challenge = state, nonce, challenge
return f.authorizationURL, nil
}
func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) {
return auth.OIDCTokenResponse{}, nil
}
func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) {
return auth.OIDCTokenResponse{}, nil
}
func (f *fakeOIDCClient) RevokeRefreshToken(context.Context, string) error { return nil }
func (f *fakeOIDCClient) EndSessionURL(context.Context, string) (string, error) {
return "https://gateway.example.com/", nil
}
type fakeOIDCSessions struct{ deleted string }
func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) {
return "opaque-session", nil
}
func (f *fakeOIDCSessions) Resolve(context.Context, string) (*auth.User, error) { return nil, nil }
func (f *fakeOIDCSessions) Delete(_ context.Context, raw string) (oidcsession.TokenBundle, error) {
f.deleted = raw
return oidcsession.TokenBundle{}, nil
}
func (f *fakeOIDCSessions) Cleanup(context.Context) (int64, error) { return 0, nil }
@@ -32,22 +32,7 @@ func (s *Server) resolveGatewayUser(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
if s.oidcUserResolver == nil { result, err := s.resolveOIDCUserProjection(r.Context(), r, user)
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver is unavailable"))
return
}
result, err := s.oidcUserResolver.ResolveOrProvisionOIDCUser(r.Context(), store.ResolveOrProvisionOIDCUserInput{
Issuer: s.cfg.OIDCIssuer,
Subject: user.ID,
Username: user.Username,
Roles: user.Roles,
TenantID: user.TenantID,
GatewayTenantKey: s.cfg.OIDCGatewayTenantKey,
ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled,
RequestIP: limitAuditText(requestIP(r), 128),
UserAgent: limitAuditText(r.UserAgent(), 512),
})
if err != nil { if err != nil {
s.writeOIDCUserResolutionError(w, r, err) s.writeOIDCUserResolutionError(w, r, err)
return return
@@ -66,6 +51,23 @@ 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) {
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,
Subject: user.ID,
Username: user.Username,
Roles: user.Roles,
TenantID: user.TenantID,
GatewayTenantKey: s.cfg.OIDCGatewayTenantKey,
ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled,
RequestIP: limitAuditText(requestIP(r), 128),
UserAgent: limitAuditText(r.UserAgent(), 512),
})
}
func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) { func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) {
switch { switch {
case errors.Is(err, store.ErrOIDCUserNotProvisioned): case errors.Is(err, store.ErrOIDCUserNotProvisioned):
+74 -1
View File
@@ -2,6 +2,7 @@ package httpapi
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "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/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config" "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/runner"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
) )
@@ -20,11 +22,29 @@ type Server struct {
store *store.Store store *store.Store
oidcUserResolver oidcUserResolver oidcUserResolver oidcUserResolver
auth *auth.Authenticator auth *auth.Authenticator
oidcClient oidcPublicClient
oidcSessions oidcSessionManager
oidcSessionCipher *oidcsession.Cipher
runner *runner.Service runner *runner.Service
logger *slog.Logger logger *slog.Logger
geminiUploadSessions sync.Map 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 { func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
return NewServerWithContext(context.Background(), cfg, db, logger) 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()) panic("invalid OIDC configuration: " + err.Error())
} }
server.auth.OIDCVerifier = verifier 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.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
server.runner.StartAsyncQueueWorker(ctx) server.runner.StartAsyncQueueWorker(ctx)
server.startLocalTempAssetCleanup(ctx) server.startLocalTempAssetCleanup(ctx)
server.startOIDCSessionCleanup(ctx)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health) 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/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.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.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/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))) 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))) 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 { 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) { return s.requireUser(permission, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context()) user, _ := auth.UserFromContext(r.Context())