fix: 修复 OIDC 用户预配与跨标签页登录态

增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
2026-07-13 17:07:52 +08:00
parent 17b1f77e1d
commit a81a7b5200
37 changed files with 2694 additions and 179 deletions
@@ -38,8 +38,9 @@ func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) {
// @Produce json
// @Security BearerAuth
// @Success 200 {object} AccessRuleListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/api-keys/access-rules [get]
func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
@@ -47,7 +48,7 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListAPIKeyAccessRules(r.Context(), user)
if err != nil {
if errors.Is(err, store.ErrLocalUserRequired) {
writeError(w, http.StatusBadRequest, err.Error())
writeLocalUserRequired(w)
return
}
s.logger.Error("list api key access rules failed", "error", err)
@@ -157,7 +158,7 @@ func (s *Server) batchAPIKeyAccessRules(w http.ResponseWriter, r *http.Request)
items, err := s.store.BatchAPIKeyAccessRules(r.Context(), input, user)
if err != nil {
if errors.Is(err, store.ErrLocalUserRequired) {
writeError(w, http.StatusBadRequest, err.Error())
writeLocalUserRequired(w)
return
}
if store.IsNotFound(err) {
+1 -1
View File
@@ -45,7 +45,7 @@ var geminiGenerateContentRoutePrefixes = []string{
}
func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
handler := s.auth.Require(auth.PermissionBasic, http.HandlerFunc(s.geminiGenerateContent))
handler := s.requireUser(auth.PermissionBasic, http.HandlerFunc(s.geminiGenerateContent))
for _, prefix := range geminiGenerateContentRoutePrefixes {
mux.Handle("POST "+prefix, geminiGenerateContentRouteHandler(prefix, handler))
}
+21 -6
View File
@@ -57,6 +57,8 @@ func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
// @Security BearerAuth
// @Success 200 {object} auth.User
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/me [get]
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
@@ -630,6 +632,8 @@ func (s *Server) listUserGroups(w http.ResponseWriter, r *http.Request) {
// @Security BearerAuth
// @Success 200 {object} UserGroupListResponse
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/user-groups [get]
func (s *Server) listCurrentUserGroups(w http.ResponseWriter, r *http.Request) {
@@ -669,6 +673,8 @@ func compactAuthStrings(values ...string) []string {
// @Security BearerAuth
// @Success 200 {object} APIKeyListResponse
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/api-keys [get]
func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
@@ -689,8 +695,9 @@ func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
// @Produce json
// @Security BearerAuth
// @Success 200 {object} PlayableAPIKeyListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/playground/api-keys [get]
func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) {
@@ -698,7 +705,7 @@ func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListPlayableAPIKeys(r.Context(), user)
if err != nil {
if errors.Is(err, store.ErrLocalUserRequired) {
writeError(w, http.StatusBadRequest, err.Error())
writeLocalUserRequired(w)
return
}
s.logger.Error("list playable api keys failed", "error", err)
@@ -719,6 +726,8 @@ func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) {
// @Success 201 {object} store.CreatedAPIKey
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/api-keys [post]
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
@@ -731,7 +740,7 @@ func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
created, err := s.store.CreateAPIKey(r.Context(), input, user)
if err != nil {
if errors.Is(err, store.ErrLocalUserRequired) {
writeError(w, http.StatusBadRequest, err.Error())
writeLocalUserRequired(w)
return
}
s.logger.Error("create api key failed", "error", err)
@@ -768,7 +777,11 @@ func (s *Server) updateAPIKeyScopes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, item)
return
}
if errors.Is(err, store.ErrLocalUserRequired) || errors.Is(err, store.ErrInvalidAPIKeyScopes) {
if errors.Is(err, store.ErrLocalUserRequired) {
writeLocalUserRequired(w)
return
}
if errors.Is(err, store.ErrInvalidAPIKeyScopes) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
@@ -801,7 +814,7 @@ func (s *Server) disableAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if errors.Is(err, store.ErrLocalUserRequired) {
writeError(w, http.StatusBadRequest, err.Error())
writeLocalUserRequired(w)
return
}
if store.IsNotFound(err) {
@@ -833,7 +846,7 @@ func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if errors.Is(err, store.ErrLocalUserRequired) {
writeError(w, http.StatusBadRequest, err.Error())
writeLocalUserRequired(w)
return
}
if store.IsNotFound(err) {
@@ -1505,6 +1518,8 @@ func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
// @Success 200 {object} TaskListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks [get]
// @Router /api/v1/tasks [get]
@@ -0,0 +1,328 @@
package httpapi
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"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/store"
"github.com/golang-jwt/jwt/v5"
)
func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC JIT HTTP integration tests")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate test signing key: %v", 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)
}
}))
defer issuerServer.Close()
issuer = issuerServer.URL
suffix := time.Now().UTC().Format("20060102150405.000000000")
validSubject := "platform-http-jit-" + suffix
rejectedSubjects := []string{
"platform-http-scope-" + suffix,
"platform-http-role-" + suffix,
"platform-http-tenant-" + suffix,
"platform-http-disabled-jit-" + suffix,
"platform-http-missing-tenant-" + suffix,
}
allSubjects := append([]string{validSubject}, rejectedSubjects...)
t.Cleanup(func() {
_, _ = db.Pool().Exec(context.Background(), `
DELETE FROM gateway_audit_logs
WHERE target_id IN (SELECT id::text FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[]));
DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[]);`, allSubjects)
})
baseConfig := config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "test-only-jwt-secret",
OIDCEnabled: true,
OIDCIssuer: issuer,
OIDCAudience: "gateway-api",
OIDCTenantID: "auth-center-test-tenant",
OIDCRolePrefix: "gateway.",
OIDCRequiredScopes: []string{"gateway.access"},
OIDCJWKSCacheTTLSeconds: 60,
OIDCAcceptLegacyHS256: true,
OIDCJITProvisioningEnabled: true,
OIDCGatewayTenantKey: "default",
OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false,
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))))
defer server.Close()
validToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
var me auth.User
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/me", validToken, nil, http.StatusOK, &me)
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")
}
sessionCookie := createOIDCSessionCookie(t, server.URL, validToken)
request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/me", nil)
if err != nil {
t.Fatal(err)
}
request.AddCookie(sessionCookie)
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("execute cookie-authenticated /me: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("cookie-authenticated /me status = %d, want 200", response.StatusCode)
}
var cookieMe auth.User
if err := json.NewDecoder(response.Body).Decode(&cookieMe); err != nil {
t.Fatalf("decode cookie-authenticated /me: %v", err)
}
if cookieMe.GatewayUserID != me.GatewayUserID || cookieMe.ID != me.ID {
t.Fatalf("new-tab cookie resolved a different Gateway user: %#v", cookieMe)
}
var automaticallyCreatedAPIKeys int
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_api_keys WHERE gateway_user_id = $1::uuid`, me.GatewayUserID).Scan(&automaticallyCreatedAPIKeys); err != nil {
t.Fatalf("count pre-created API keys: %v", err)
}
if automaticallyCreatedAPIKeys != 0 {
t.Fatalf("OIDC JIT created %d API keys before explicit user action", automaticallyCreatedAPIKeys)
}
for _, path := range []string{
"/api/workspace/user-groups",
"/api/workspace/wallet",
"/api/workspace/tasks",
"/api/v1/api-keys",
} {
doOIDCJITJSON(t, server.URL, http.MethodGet, path, validToken, nil, http.StatusOK, nil)
}
var createdKey struct {
Secret string `json:"secret"`
APIKey struct {
ID string `json:"id"`
} `json:"apiKey"`
}
doOIDCJITJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", validToken, map[string]any{"name": "OIDC JIT integration key"}, http.StatusCreated, &createdKey)
if createdKey.Secret == "" || createdKey.APIKey.ID == "" {
t.Fatal("OIDC user API Key creation returned incomplete data")
}
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys", validToken, nil, http.StatusOK, nil)
doOIDCJITJSON(t, server.URL, http.MethodDelete, "/api/v1/api-keys/"+createdKey.APIKey.ID, validToken, nil, http.StatusNoContent, nil)
var users struct {
Items []store.GatewayUser `json:"items"`
}
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/admin/users", validToken, nil, http.StatusOK, &users)
foundOIDCUser := false
for _, user := range users.Items {
if user.ID == me.GatewayUserID {
foundOIDCUser = user.Source == "oidc" && user.ExternalUserID == validSubject
break
}
}
if !foundOIDCUser {
t.Fatal("admin user list did not expose the OIDC Gateway projection")
}
negativeTokens := []struct {
subject string
mutate func(jwt.MapClaims)
}{
{rejectedSubjects[0], func(claims jwt.MapClaims) { claims["scope"] = "openid" }},
{rejectedSubjects[1], func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }},
{rejectedSubjects[2], func(claims jwt.MapClaims) { claims["tid"] = "wrong-tenant" }},
}
for _, negative := range negativeTokens {
token := signedOIDCJITToken(t, key, issuer, negative.subject, negative.mutate)
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/me", token, nil, http.StatusUnauthorized, nil)
}
var rejectedWrites int
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[])`, rejectedSubjects[:3]).Scan(&rejectedWrites); err != nil {
t.Fatalf("count rejected OIDC writes: %v", err)
}
if rejectedWrites != 0 {
t.Fatalf("rejected OIDC tokens created %d local users", rejectedWrites)
}
disabledJITConfig := baseConfig
disabledJITConfig.OIDCJITProvisioningEnabled = false
disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, disabledJITConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer disabledJITServer.Close()
assertOIDCJITError(t, disabledJITServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[3], nil), http.StatusForbidden, errorCodeGatewayUserNotProvisioned)
missingTenantConfig := baseConfig
missingTenantConfig.OIDCGatewayTenantKey = "missing-tenant-" + suffix
missingTenantServer := httptest.NewServer(NewServerWithContext(ctx, missingTenantConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer missingTenantServer.Close()
assertOIDCJITError(t, missingTenantServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[4], nil), http.StatusServiceUnavailable, errorCodeGatewayTenantUnavailable)
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
t.Fatalf("disable projected user: %v", err)
}
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'active' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
t.Fatalf("restore projected user for delete test: %v", err)
}
if err := db.DeleteGatewayUser(ctx, me.GatewayUserID); err != nil {
t.Fatalf("delete projected user: %v", err)
}
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
}
func createOIDCSessionCookie(t *testing.T, baseURL string, token string) *http.Cookie {
t.Helper()
request, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/oidc/session", nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Origin", "http://localhost:5178")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("create OIDC browser session: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("create OIDC browser session status = %d, want 204", response.StatusCode)
}
for _, cookie := range response.Cookies() {
if cookie.Name == auth.OIDCSessionCookieName {
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("unsafe OIDC browser session cookie: %#v", cookie)
}
return cookie
}
}
t.Fatal("OIDC browser session cookie was not returned")
return nil
}
func assertOIDCJITError(t *testing.T, baseURL string, token string, expectedStatus int, expectedCode string) {
t.Helper()
var envelope struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
doOIDCJITJSON(t, baseURL, http.MethodGet, "/api/v1/me", token, nil, expectedStatus, &envelope)
if envelope.Error.Code != expectedCode {
t.Fatalf("error code = %q, want %q", envelope.Error.Code, expectedCode)
}
}
func doOIDCJITJSON(t *testing.T, baseURL string, method string, path string, token string, payload any, expectedStatus int, output any) {
t.Helper()
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal OIDC JIT request: %v", err)
}
body = bytes.NewReader(raw)
}
request, err := http.NewRequest(method, baseURL+path, body)
if err != nil {
t.Fatalf("build %s %s request: %v", method, path, err)
}
request.Header.Set("Authorization", "Bearer "+token)
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("execute %s %s: %v", method, path, err)
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
t.Fatalf("read %s %s response: %v", method, path, err)
}
if response.StatusCode != expectedStatus {
t.Fatalf("%s %s status=%d, want=%d", method, path, response.StatusCode, expectedStatus)
}
if output != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, output); err != nil {
t.Fatalf("decode %s %s response: %v", method, path, err)
}
}
}
func signedOIDCJITToken(t *testing.T, key *ecdsa.PrivateKey, issuer string, subject string, mutate func(jwt.MapClaims)) string {
t.Helper()
now := time.Now()
claims := jwt.MapClaims{
"iss": issuer, "aud": "gateway-api", "sub": subject, "tid": "auth-center-test-tenant",
"preferred_username": "oidc-jit-acceptance", "roles": []string{"gateway.admin"},
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
"exp": now.Add(time.Hour).Unix(),
}
if mutate != nil {
mutate(claims)
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = "jit-key"
raw, err := token.SignedString(key)
if err != nil {
t.Fatalf("sign OIDC JIT test token: %v", err)
}
return raw
}
func oidcJITECJWK(kid string, key *ecdsa.PublicKey) map[string]any {
return map[string]any{
"kid": kid,
"kty": "EC",
"use": "sig",
"alg": "ES256",
"crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(key.X.FillBytes(make([]byte, 32))),
"y": base64.RawURLEncoding.EncodeToString(key.Y.FillBytes(make([]byte, 32))),
}
}
+131
View File
@@ -0,0 +1,131 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
)
const (
errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED"
errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID"
errorCodeOIDCSessionTooLarge = "OIDC_SESSION_TOKEN_TOO_LARGE"
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
maxOIDCSessionCookieTokenBytes = 3800
)
// createOIDCBrowserSession godoc
// @Summary 建立 OIDC 浏览器会话
// @Description 验证 Auth Center Access Token 后写入 HttpOnly 会话 Cookie;不会签发 Gateway JWT。
// @Tags auth
// @Success 204
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /api/v1/auth/oidc/session [post]
func (s *Server) createOIDCBrowserSession(w http.ResponseWriter, r *http.Request) {
if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || s.auth == nil || s.auth.OIDCVerifier == nil {
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
return
}
raw := bearerToken(r.Header.Get("Authorization"))
if raw == "" {
writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid)
return
}
if len(raw) > maxOIDCSessionCookieTokenBytes {
writeError(w, http.StatusBadRequest, "OIDC access token is too large for browser session", errorCodeOIDCSessionTooLarge)
return
}
user, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), raw)
if err != nil || user == nil || !strings.EqualFold(strings.TrimSpace(user.Source), "oidc") {
writeError(w, http.StatusUnauthorized, "valid OIDC access token is required", errorCodeOIDCSessionInvalid)
return
}
now := time.Now()
if user.TokenExpiresAt.IsZero() || !user.TokenExpiresAt.After(now) {
writeError(w, http.StatusUnauthorized, "OIDC access token has expired", errorCodeOIDCSessionInvalid)
return
}
maxAge := int(time.Until(user.TokenExpiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(w, &http.Cookie{
Name: auth.OIDCSessionCookieName,
Value: raw,
Path: "/",
Expires: user.TokenExpiresAt,
MaxAge: maxAge,
HttpOnly: true,
Secure: s.cfg.OIDCSessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
// deleteOIDCBrowserSession godoc
// @Summary 注销 OIDC 浏览器会话
// @Tags auth
// @Success 204
// @Router /api/v1/auth/oidc/session [delete]
func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, _ *http.Request) {
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,
})
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) {
next.ServeHTTP(w, r)
return
}
if _, err := r.Cookie(auth.OIDCSessionCookieName); err != nil {
next.ServeHTTP(w, r)
return
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || !originAllowed(origin, s.cfg.CORSAllowedOrigin) {
writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF)
return
}
next.ServeHTTP(w, r)
})
}
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 {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}
func hasExplicitCredential(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get("Authorization")) != "" ||
strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" ||
strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" ||
strings.TrimSpace(r.URL.Query().Get("key")) != ""
}
@@ -0,0 +1,220 @@
package httpapi
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
)
func TestCreateOIDCBrowserSessionSetsProtectedSharedCookie(t *testing.T) {
server, token, closeIssuer := newOIDCSessionTestServer(t)
defer closeIssuer()
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.createOIDCBrowserSession(recorder, request)
response := recorder.Result()
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("session creation status = %d, want 204", response.StatusCode)
}
var sessionCookie *http.Cookie
for _, cookie := range response.Cookies() {
if cookie.Name == auth.OIDCSessionCookieName {
sessionCookie = cookie
break
}
}
if sessionCookie == nil {
t.Fatal("OIDC session cookie was not set")
}
if !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode || sessionCookie.Path != "/" {
t.Fatalf("unsafe OIDC session cookie attributes: %#v", sessionCookie)
}
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) {
server, _, closeIssuer := newOIDCSessionTestServer(t)
defer closeIssuer()
localToken, err := server.auth.SignJWT(&auth.User{ID: "local-user", Source: "gateway", Roles: []string{"user"}}, 0)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/session", nil)
request.Header.Set("Authorization", "Bearer "+localToken)
recorder := httptest.NewRecorder()
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") != "" {
t.Fatalf("oversized token response status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
}
}
func TestCreateOIDCBrowserSessionHonorsDisabledFlag(t *testing.T) {
server, token, closeIssuer := newOIDCSessionTestServer(t)
defer closeIssuer()
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.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"})
recorder := httptest.NewRecorder()
server.deleteOIDCBrowserSession(recorder, request)
response := recorder.Result()
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("session deletion status = %d, want 204", response.StatusCode)
}
cookies := response.Cookies()
if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 {
t.Fatalf("OIDC session cookie was not expired: %#v", cookies)
}
}
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
server := &Server{cfg: config.Config{
OIDCEnabled: true,
OIDCBrowserSessionEnabled: true,
CORSAllowedOrigin: "https://gateway.example.com",
}}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
handler := server.protectOIDCSessionCookie(next)
for _, test := range []struct {
name string
method string
origin string
bearer bool
wantStatus int
}{
{name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
{name: "allowed origin", method: http.MethodPatch, origin: "https://gateway.example.com", wantStatus: http.StatusNoContent},
{name: "safe request", method: http.MethodGet, wantStatus: http.StatusNoContent},
{name: "explicit bearer bypasses cookie csrf", method: http.MethodPost, bearer: true, wantStatus: http.StatusNoContent},
} {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(test.method, "/api/workspace/tasks", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"})
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
if test.bearer {
request.Header.Set("Authorization", "Bearer explicit-token")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != test.wantStatus {
t.Fatalf("status = %d, want %d", recorder.Code, test.wantStatus)
}
})
}
}
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
server := &Server{cfg: config.Config{
OIDCEnabled: false,
OIDCBrowserSessionEnabled: true,
}}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"})
recorder := httptest.NewRecorder()
server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("OIDC-disabled request status = %d, want 204", recorder.Code)
}
}
func newOIDCSessionTestServer(t *testing.T) (*Server, string, func()) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
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
}
@@ -0,0 +1,90 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
errorCodeGatewayUserNotProvisioned = "GATEWAY_USER_NOT_PROVISIONED"
errorCodeGatewayUserDisabled = "GATEWAY_USER_DISABLED"
errorCodeGatewayTenantUnavailable = "GATEWAY_TENANT_UNAVAILABLE"
errorCodeGatewayProvisioningFailed = "GATEWAY_USER_PROVISIONING_FAILED"
)
type oidcUserResolver interface {
ResolveOrProvisionOIDCUser(context.Context, store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error)
}
func (s *Server) requireUser(permission auth.Permission, next http.Handler) http.Handler {
return s.auth.Require(permission, s.resolveGatewayUser(next))
}
func (s *Server) resolveGatewayUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil || !strings.EqualFold(strings.TrimSpace(user.Source), "oidc") {
next.ServeHTTP(w, r)
return
}
if s.oidcUserResolver == nil {
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 {
s.writeOIDCUserResolutionError(w, r, err)
return
}
if result.User == nil || strings.TrimSpace(result.User.GatewayUserID) == "" {
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user"))
return
}
if result.Created {
s.logger.InfoContext(r.Context(), "OIDC gateway user provisioned",
"gatewayUserId", result.User.GatewayUserID,
"auditId", result.AuditID,
)
}
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), result.User)))
})
}
func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
writeError(w, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned)
case errors.Is(err, store.ErrOIDCUserDisabled):
writeError(w, http.StatusForbidden, "该 Gateway 账号已停用,请联系管理员", errorCodeGatewayUserDisabled)
case errors.Is(err, store.ErrOIDCTenantUnavailable):
writeError(w, http.StatusServiceUnavailable, "Gateway 租户尚未就绪,请联系管理员", errorCodeGatewayTenantUnavailable)
default:
s.logger.ErrorContext(r.Context(), "resolve OIDC gateway user failed", "error", err, "path", r.URL.Path)
writeError(w, http.StatusServiceUnavailable, "Gateway 账号初始化失败,请稍后重试", errorCodeGatewayProvisioningFailed)
}
}
func limitAuditText(value string, limit int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if limit > 0 && len(runes) > limit {
return string(runes[:limit])
}
return value
}
@@ -0,0 +1,153 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"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/store"
)
type fakeOIDCUserResolver struct {
result store.ResolveOrProvisionOIDCUserResult
err error
calls int
input store.ResolveOrProvisionOIDCUserInput
}
func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, input store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) {
f.calls++
f.input = input
return f.result, f.err
}
func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{
ID: "platform-user",
Username: "alice",
Roles: []string{"basic"},
TenantID: "external-tenant",
Source: "oidc",
GatewayUserID: "21dd9ccb-3793-4023-ab31-4d04982ca4d3",
GatewayTenantID: "8f17f3ac-136e-4d0f-b097-655e2a6240a3",
TenantKey: "default",
UserGroupID: "6dcf86f2-8eaf-4b43-8e69-181315db24f0",
}}}
server := &Server{
cfg: config.Config{
OIDCIssuer: "https://auth.test.example/realms/easyai",
OIDCGatewayTenantKey: "default",
OIDCJITProvisioningEnabled: true,
},
oidcUserResolver: resolver,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user.GatewayUserID == "" || user.GatewayTenantID == "" || user.UserGroupID == "" {
t.Fatalf("resolved Gateway context missing: %+v", user)
}
writeJSON(w, http.StatusOK, user)
})
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
ID: "platform-user",
Username: "alice",
Roles: []string{"basic"},
TenantID: "external-tenant",
Source: "oidc",
}))
recorder := httptest.NewRecorder()
server.resolveGatewayUser(next).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled {
t.Fatalf("unexpected resolver call: calls=%d input=%+v", resolver.calls, resolver.input)
}
}
func TestResolveGatewayUserLeavesNonOIDCIdentityChainsUnchanged(t *testing.T) {
for _, source := range []string{"gateway", "api_key", "server-main"} {
t.Run(source, func(t *testing.T) {
resolver := &fakeOIDCUserResolver{}
server := &Server{oidcUserResolver: resolver, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
original := &auth.User{ID: "local-user", Source: source, GatewayUserID: "local-user"}
request = request.WithContext(auth.WithUser(request.Context(), original))
recorder := httptest.NewRecorder()
server.resolveGatewayUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resolved, _ := auth.UserFromContext(r.Context())
if resolved != original {
t.Fatalf("non-OIDC identity context was replaced: %+v", resolved)
}
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent || resolver.calls != 0 {
t.Fatalf("status=%d resolver calls=%d", recorder.Code, resolver.calls)
}
})
}
}
func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
tests := []struct {
name string
err error
status int
code string
}{
{name: "not provisioned", err: store.ErrOIDCUserNotProvisioned, status: http.StatusForbidden, code: "GATEWAY_USER_NOT_PROVISIONED"},
{name: "disabled", err: store.ErrOIDCUserDisabled, status: http.StatusForbidden, code: "GATEWAY_USER_DISABLED"},
{name: "tenant unavailable", err: store.ErrOIDCTenantUnavailable, status: http.StatusServiceUnavailable, code: "GATEWAY_TENANT_UNAVAILABLE"},
{name: "storage failure", err: errors.New("database unavailable"), status: http.StatusServiceUnavailable, code: "GATEWAY_USER_PROVISIONING_FAILED"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := &Server{
cfg: config.Config{OIDCIssuer: "https://auth.test.example", OIDCGatewayTenantKey: "default"},
oidcUserResolver: &fakeOIDCUserResolver{err: test.err},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
ID: "platform-user", Source: "oidc", TenantID: "external-tenant",
}))
recorder := httptest.NewRecorder()
server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("next handler must not run")
})).ServeHTTP(recorder, request)
if recorder.Code != test.status {
t.Fatalf("status = %d, want %d", recorder.Code, test.status)
}
var envelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
} `json:"error"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode error envelope: %v", err)
}
if envelope.Error.Code != test.code || envelope.Error.Status != test.status || envelope.Error.Message == "" {
t.Fatalf("unexpected error envelope: %+v", envelope)
}
if envelope.Error.Message == test.err.Error() {
t.Fatalf("internal error leaked to response: %q", envelope.Error.Message)
}
})
}
}
+4
View File
@@ -33,6 +33,10 @@ func writeErrorWithDetails(w http.ResponseWriter, status int, message string, de
writeJSON(w, status, map[string]any{"error": errorPayload})
}
func writeLocalUserRequired(w http.ResponseWriter) {
writeError(w, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned)
}
func sendSSE(w http.ResponseWriter, event string, payload any) {
bytes, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "event: %s\n", event)
+87 -83
View File
@@ -18,6 +18,7 @@ type Server struct {
ctx context.Context
cfg config.Config
store *store.Store
oidcUserResolver oidcUserResolver
auth *auth.Authenticator
runner *runner.Service
logger *slog.Logger
@@ -30,12 +31,13 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
server := &Server{
ctx: ctx,
cfg: cfg,
store: db,
auth: auth.New(cfg.JWTSecret, cfg.ServerMainBaseURL, cfg.ServerMainInternalToken),
runner: runner.New(cfg, db, logger),
logger: logger,
ctx: ctx,
cfg: cfg,
store: db,
oidcUserResolver: db,
auth: auth.New(cfg.JWTSecret, cfg.ServerMainBaseURL, cfg.ServerMainInternalToken),
runner: runner.New(cfg, db, logger),
logger: logger,
}
server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
@@ -67,7 +69,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
mux.Handle("GET /api/v1/me", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.HandleFunc("POST /api/v1/auth/oidc/session", server.createOIDCBrowserSession)
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels)))
mux.Handle("GET /api/v1/public/client-customization", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.getPublicClientCustomizationSettings)))
@@ -101,29 +105,29 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/admin/access-rules/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules)))
mux.Handle("PATCH /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule)))
mux.Handle("DELETE /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule)))
mux.Handle("GET /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys)))
mux.Handle("POST /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
mux.Handle("GET /api/v1/api-keys/access-rules", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
mux.Handle("GET /api/workspace/desktop-config", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getDesktopConfig)))
mux.Handle("GET /api/workspace/user-groups", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups)))
mux.Handle("GET /api/workspace/wallet", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
mux.Handle("GET /api/workspace/wallet/transactions", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
mux.Handle("GET /api/workspace/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/workspace/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/workspace/tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("GET /api/workspace/tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("GET /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys)))
mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
mux.Handle("GET /api/playground/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
mux.Handle("GET /api/workspace/desktop-config", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getDesktopConfig)))
mux.Handle("GET /api/workspace/user-groups", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups)))
mux.Handle("GET /api/workspace/wallet", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
mux.Handle("GET /api/workspace/wallet/transactions", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
mux.Handle("GET /api/workspace/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/workspace/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/workspace/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("GET /api/workspace/tasks/{taskID}/param-preprocessing", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("GET /api/admin/pricing/rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
mux.Handle("GET /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets)))
mux.Handle("POST /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet)))
mux.Handle("PATCH /api/admin/pricing/rule-sets/{ruleSetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePricingRuleSet)))
mux.Handle("DELETE /api/admin/pricing/rule-sets/{ruleSetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePricingRuleSet)))
mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
mux.Handle("POST /api/v1/pricing/estimate", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
mux.Handle("GET /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRuntimePolicySets)))
mux.Handle("POST /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet)))
mux.Handle("PATCH /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet)))
@@ -150,71 +154,71 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/admin/platform-models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel)))
mux.Handle("DELETE /api/admin/platform-models/{modelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel)))
mux.Handle("GET /api/admin/models", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModels)))
mux.Handle("GET /api/v1/model-catalog", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModelCatalog)))
mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms)))
mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
mux.Handle("GET /api/v1/model-catalog", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listModelCatalog)))
mux.Handle("GET /api/v1/platforms", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms)))
mux.Handle("GET /api/v1/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
mux.Handle("GET /api/v1/playground/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false)))
mux.Handle("POST /api/v1/embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", false)))
mux.Handle("POST /api/v1/reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", false)))
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations", false)))
mux.Handle("POST /api/v1/song/generations", server.auth.Require(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /api/v1/music/generations", server.auth.Require(auth.PermissionBasic, server.createTask("music.generations", true)))
mux.Handle("POST /api/v1/speech/generations", server.auth.Require(auth.PermissionBasic, server.createTask("speech.generations", true)))
mux.Handle("POST /api/v1/voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true)))
mux.Handle("GET /api/v1/voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
mux.Handle("POST /api/v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
mux.Handle("POST /api/v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", false)))
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", false)))
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", false)))
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", false)))
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", false)))
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", false)))
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /api/v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
mux.Handle("POST /api/v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
mux.Handle("POST /api/v1/voice_clone", server.requireUser(auth.PermissionBasic, server.createTask("voice.clone", true)))
mux.Handle("GET /api/v1/voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
mux.Handle("POST /api/v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
server.registerGeminiGenerateContentRoutes(mux)
mux.Handle("POST /upload/{version}/files", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /upload/{version}/files/{uploadID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
mux.Handle("GET /api/v1/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("GET /api/v1/tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
mux.Handle("GET /api/v1/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("GET /tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("GET /tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
mux.Handle("GET /tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("POST /chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", true)))
mux.Handle("POST /v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", true)))
mux.Handle("POST /responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /v1/embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /v1/reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /song/generations", server.auth.Require(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /v1/song/generations", server.auth.Require(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /music/generations", server.auth.Require(auth.PermissionBasic, server.createTask("music.generations", true)))
mux.Handle("POST /v1/music/generations", server.auth.Require(auth.PermissionBasic, server.createTask("music.generations", true)))
mux.Handle("POST /speech/generations", server.auth.Require(auth.PermissionBasic, server.createTask("speech.generations", true)))
mux.Handle("POST /v1/speech/generations", server.auth.Require(auth.PermissionBasic, server.createTask("speech.generations", true)))
mux.Handle("POST /voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true)))
mux.Handle("POST /v1/voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true)))
mux.Handle("GET /voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
mux.Handle("GET /v1/voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
mux.Handle("DELETE /voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
mux.Handle("DELETE /v1/voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
mux.Handle("POST /v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
mux.Handle("POST /v1/tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/v1/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("GET /api/v1/tasks/{taskID}/param-preprocessing", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
mux.Handle("GET /api/v1/tasks/{taskID}/events", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("GET /tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("GET /tasks/{taskID}/param-preprocessing", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
mux.Handle("GET /tasks/{taskID}/events", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("POST /chat/completions", server.requireUser(auth.PermissionBasic, server.createTask("chat.completions", true)))
mux.Handle("POST /v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createTask("chat.completions", true)))
mux.Handle("POST /responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
mux.Handle("POST /v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
mux.Handle("POST /speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
mux.Handle("POST /v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
mux.Handle("POST /voice_clone", server.requireUser(auth.PermissionBasic, server.createTask("voice.clone", true)))
mux.Handle("POST /v1/voice_clone", server.requireUser(auth.PermissionBasic, server.createTask("voice.clone", true)))
mux.Handle("GET /voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
mux.Handle("GET /v1/voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
mux.Handle("DELETE /voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
mux.Handle("DELETE /v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
mux.Handle("POST /v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
mux.Handle("POST /v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
return server.recover(server.cors(mux))
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
}
func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) http.Handler {
return s.auth.Require(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())
if user != nil && strings.TrimSpace(user.APIKeyID) != "" {
writeError(w, http.StatusForbidden, "admin api does not accept api key credentials")
@@ -16,6 +16,8 @@ import (
// @Param currency query string false "币种" default(USD)
// @Success 200 {object} store.WalletSummary
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/wallet [get]
func (s *Server) getWallet(w http.ResponseWriter, r *http.Request) {
@@ -49,6 +51,8 @@ func (s *Server) getWallet(w http.ResponseWriter, r *http.Request) {
// @Success 200 {object} WalletTransactionListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/wallet/transactions [get]
func (s *Server) listWalletTransactions(w http.ResponseWriter, r *http.Request) {