feat: 接入 OIDC 公共客户端登录链路
由 Gateway 服务端完成 PKCE 换码、ID Token 校验、JIT 用户解析和 Session 建立。新增登录、回调、退出与本地会话删除接口,并移除旧的 Access Token Cookie 桥接接口。
This commit is contained in:
@@ -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 Secret;Token 加密存入服务端 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
@@ -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 Secret;Token
|
||||||
|
加密存入服务端 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Secret;Token 加密存入服务端 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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user