feat(identity): 接入认证中心多租户登录

支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
+9 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
@@ -90,8 +91,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/auth/register [post]
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
if !s.localIdentityEnabled() || !s.ordinaryLocalJWTEnabled() {
writeError(w, http.StatusForbidden, "local registration is disabled")
if !s.localRegistrationEnabled() {
writeError(w, http.StatusForbidden, "统一认证启用后,本地注册已关闭", "LOCAL_REGISTRATION_DISABLED")
return
}
var input store.LocalRegisterInput
@@ -185,6 +186,12 @@ func (s *Server) localIdentityEnabled() bool {
return mode == "" || mode == "standalone" || mode == "hybrid"
}
func (s *Server) localRegistrationEnabled() bool {
runtime := s.currentIdentityRuntime()
return (runtime == nil || runtime.Revision.State != identity.RevisionActive) &&
s.localIdentityEnabled() && s.ordinaryLocalJWTEnabled()
}
func (s *Server) localLoginAllowed(user store.GatewayUser) bool {
for _, role := range user.Roles {
if role == "manager" || role == "admin" {
@@ -0,0 +1,41 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
func TestRegisterIsStructurallyForbiddenWhenIdentityRevisionIsActive(t *testing.T) {
server := &Server{
cfg: config.Config{IdentityMode: "hybrid"},
identityTestRevision: identity.Revision{
State: identity.RevisionActive, Issuer: "https://auth.example.test",
LegacyJWTEnabled: true,
},
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(`{}`))
recorder := httptest.NewRecorder()
server.register(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d, want 403", recorder.Code)
}
var envelope struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Error.Code != "LOCAL_REGISTRATION_DISABLED" {
t.Fatalf("code=%q", envelope.Error.Code)
}
}
@@ -331,7 +331,7 @@ func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Reques
return
}
policy := identity.RevisionPolicy{
LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled,
TenantMode: revision.TenantMode, LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled,
LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds,
SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds,
}
@@ -15,6 +15,10 @@ type oidcTokenVerifier interface {
Verify(context.Context, string) (*auth.User, error)
}
type tenantContextReader interface {
Get(context.Context, string, string) (identity.TenantContext, bool, error)
}
// identityRequestRuntime is an immutable request-level snapshot. A handler that
// starts with one runtime keeps using it even when an administrator activates a
// new revision while that request is in flight.
@@ -25,6 +29,7 @@ type identityRequestRuntime struct {
Sessions oidcSessionManager
SessionCipher *oidcsession.Cipher
SecurityEvents *ssfreceiver.ConnectionManager
TenantContext tenantContextReader
CookieSecure bool
BrowserEnabled bool
}
@@ -38,7 +43,8 @@ func (s *Server) currentIdentityRuntime() *identityRequestRuntime {
return &identityRequestRuntime{
Revision: runtime.Revision, Verifier: runtime.Verifier, PublicClient: runtime.PublicClient,
Sessions: runtime.Sessions, SessionCipher: runtime.SessionCipher, SecurityEvents: runtime.SecurityEvents,
CookieSecure: runtime.CookieSecure, BrowserEnabled: runtime.PublicClient != nil,
TenantContext: runtime.TenantContext,
CookieSecure: runtime.CookieSecure, BrowserEnabled: runtime.PublicClient != nil,
}
}
@@ -0,0 +1,548 @@
package httpapi
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"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/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
func TestLocalCrossRepositoryMultiTenantGatewayFlow(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
onboardingContract := strings.TrimSpace(os.Getenv("AUTH_CENTER_ONBOARDING_CONTRACT"))
tenantRuntimeContract := strings.TrimSpace(os.Getenv("AUTH_CENTER_TENANT_RUNTIME_CONTRACT"))
if databaseURL == "" || onboardingContract == "" || tenantRuntimeContract == "" {
t.Skip("set the local Gateway database and Auth Center contract paths to run the cross-repository E2E")
}
assertAuthCenterMultiTenantContracts(t, onboardingContract, tenantRuntimeContract)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect Gateway store: %v", err)
}
t.Cleanup(db.Close)
breakGlassCreated := false
var breakGlassUserID string
hasBreakGlass, err := db.HasBreakGlassManager(ctx)
if err != nil {
t.Fatal(err)
}
if !hasBreakGlass {
manager, registerErr := db.RegisterLocalUser(ctx, store.LocalRegisterInput{
Username: "multi-tenant-e2e-manager-" + uuid.NewString(),
Password: uuid.NewString() + "-local-only",
})
if registerErr != nil {
t.Fatalf("create local Break-glass Manager fixture: %v", registerErr)
}
breakGlassCreated, breakGlassUserID = true, manager.ID
}
applicationID := uuid.NewString()
tenantA, tenantB := uuid.NewString(), uuid.NewString()
subject := "shared-cross-repository-subject-" + uuid.NewString()
browserClientID := "gateway-browser-" + uuid.NewString()
machineClientID := "gateway-machine-" + uuid.NewString()
machineSecret := "local-cross-repository-machine-secret"
audience := "urn:easyai:gateway:" + applicationID
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
var issuer, ssfIssuer, gatewayBaseURL string
ssfAudience := "urn:easyai:ssf:receiver:" + applicationID
var revokedRefreshTokens atomic.Int64
type authorization struct {
TenantID string
Nonce string
}
var authorizationMu sync.Mutex
authorizations := map[string]authorization{}
var ssfStreamMu sync.Mutex
var ssfStream map[string]any
tenantNames := map[string]string{tenantA: "本地租户 A", tenantB: "本地租户 B"}
tenantSlugs := map[string]string{tenantA: "local-tenant-a", tenantB: "local-tenant-b"}
authCenter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/.well-known/openid-configuration":
writeTestJSON(w, 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",
"introspection_endpoint": issuer + "/introspect",
})
case r.URL.Path == "/.well-known/ssf-configuration/ssf":
writeTestJSON(w, map[string]any{
"spec_version": "1_0", "issuer": ssfIssuer, "jwks_uri": issuer + "/jwks",
"configuration_endpoint": issuer + "/ssf/streams",
"status_endpoint": issuer + "/ssf/status",
"verification_endpoint": issuer + "/ssf/v1/verify",
"delivery_methods_supported": []string{"urn:ietf:rfc:8935"},
})
case r.URL.Path == "/jwks":
writeTestJSON(w, map[string]any{"keys": []any{oidcJITECJWK("multi-tenant-key", &key.PublicKey)}})
case r.URL.Path == "/authorize":
tenantID := r.URL.Query().Get("tenant_hint")
if tenantID != tenantA && tenantID != tenantB ||
r.URL.Query().Get("client_id") != browserClientID ||
r.URL.Query().Get("code_challenge_method") != "S256" {
http.Error(w, "authorization request rejected", http.StatusBadRequest)
return
}
code := uuid.NewString()
authorizationMu.Lock()
authorizations[code] = authorization{TenantID: tenantID, Nonce: r.URL.Query().Get("nonce")}
authorizationMu.Unlock()
callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=" + url.QueryEscape(code) +
"&state=" + url.QueryEscape(r.URL.Query().Get("state"))
http.Redirect(w, r, callback, http.StatusSeeOther)
case r.URL.Path == "/token":
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid token request", http.StatusBadRequest)
return
}
if r.Form.Get("grant_type") == "client_credentials" {
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != machineClientID || secret != machineSecret || r.Form.Get("scope") == "" {
http.Error(w, "machine token rejected", http.StatusUnauthorized)
return
}
writeTestJSON(w, map[string]any{
"access_token": "opaque-local-machine-token", "token_type": "Bearer", "expires_in": 300,
})
return
}
code := r.Form.Get("code")
authorizationMu.Lock()
requested, ok := authorizations[code]
delete(authorizations, code)
authorizationMu.Unlock()
if !ok || r.Form.Get("grant_type") != "authorization_code" ||
r.Form.Get("client_id") != browserClientID || r.Form.Get("client_secret") != "" {
http.Error(w, "authorization code rejected", http.StatusBadRequest)
return
}
accessToken := signedMultiTenantE2EToken(
t, key, issuer, audience, browserClientID, applicationID, requested.TenantID, subject, "",
)
idToken := signedMultiTenantE2EToken(
t, key, issuer, browserClientID, browserClientID, applicationID, requested.TenantID, subject, requested.Nonce,
)
writeTestJSON(w, map[string]any{
"access_token": accessToken, "refresh_token": "opaque-refresh-" + requested.TenantID,
"id_token": idToken, "token_type": "Bearer", "expires_in": 300,
})
case r.URL.Path == "/introspect":
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != machineClientID || secret != machineSecret {
http.Error(w, "introspection rejected", http.StatusUnauthorized)
return
}
writeTestJSON(w, map[string]any{"active": true})
case r.URL.Path == "/revoke":
revokedRefreshTokens.Add(1)
w.WriteHeader(http.StatusOK)
case r.URL.Path == "/logout":
http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther)
case strings.HasPrefix(r.URL.Path, "/api/v1/runtime/tenants/"):
tenantID := strings.TrimPrefix(r.URL.Path, "/api/v1/runtime/tenants/")
if r.Header.Get("Authorization") != "Bearer opaque-local-machine-token" {
http.Error(w, "tenant context unauthorized", http.StatusUnauthorized)
return
}
displayName, ok := tenantNames[tenantID]
if !ok {
http.NotFound(w, r)
return
}
etag := `"` + tenantID + `-v1"`
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
writeTestJSON(w, identity.TenantContext{
ApplicationID: applicationID, TenantID: tenantID, DisplayName: displayName, Slug: tenantSlugs[tenantID],
TenantStatus: "active", TenantApplicationStatus: "active", Version: "1", UpdatedAt: time.Now().UTC(),
})
case r.URL.Path == "/ssf/streams":
if r.Header.Get("Authorization") != "Bearer opaque-local-machine-token" {
http.Error(w, "SSF management unauthorized", http.StatusUnauthorized)
return
}
ssfStreamMu.Lock()
defer ssfStreamMu.Unlock()
if r.Method == http.MethodGet {
if ssfStream == nil {
writeTestJSON(w, []any{})
} else {
writeTestJSON(w, []any{ssfStream})
}
return
}
if r.Method != http.MethodPost {
http.Error(w, "SSF method rejected", http.StatusMethodNotAllowed)
return
}
var input struct {
EventsRequested []string `json:"events_requested"`
Description string `json:"description"`
Delivery struct {
Method string `json:"method"`
EndpointURL string `json:"endpoint_url"`
} `json:"delivery"`
}
if json.NewDecoder(r.Body).Decode(&input) != nil {
http.Error(w, "SSF stream rejected", http.StatusBadRequest)
return
}
ssfStream = map[string]any{
"stream_id": uuid.NewString(), "iss": ssfIssuer, "aud": ssfAudience,
"events_requested": input.EventsRequested, "description": input.Description,
"delivery": map[string]any{"method": input.Delivery.Method, "endpoint_url": input.Delivery.EndpointURL},
}
writeTestJSON(w, ssfStream)
case r.URL.Path == "/ssf/status":
writeTestJSON(w, map[string]any{})
case r.URL.Path == "/ssf/v1/verify":
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
defer authCenter.Close()
issuer = authCenter.URL
ssfIssuer = issuer + "/ssf"
cfg := config.Config{
AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "hybrid",
JWTSecret: "local-cross-repository-jwt-secret", IdentitySecretStore: "file",
IdentitySecretDir: t.TempDir(), LocalGeneratedStorageDir: t.TempDir(), LocalUploadedStorageDir: t.TempDir(),
LocalTempAssetTTLHours: 1, CORSAllowedOrigin: "http://localhost:5178", TaskProgressCallbackEnabled: false,
}
previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx)
if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) {
t.Fatal(previousErr)
}
revision := prepareMultiTenantE2ERevision(
t, ctx, db, cfg, issuer, ssfIssuer, ssfAudience,
applicationID, audience, browserClientID, machineClientID, machineSecret,
)
revisionID := revision.ID
t.Cleanup(func() {
_, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE transmitter_issuer=$1`, ssfIssuer)
_, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, ssfIssuer)
restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, []string{revisionID})
if breakGlassCreated {
if err := db.DeleteGatewayUser(context.Background(), breakGlassUserID); err != nil {
t.Errorf("delete local Break-glass Manager fixture: %v", err)
}
}
})
activeRevision, _, err := db.ActivateIdentityRevision(ctx, revision.ID, revision.Version, "multi-tenant-e2e", "multi-tenant-e2e")
if err != nil {
t.Fatalf("activate multi-tenant revision: %v", err)
}
revision = activeRevision
gateway := httptest.NewServer(NewServerWithContext(ctx, cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer gateway.Close()
gatewayBaseURL = gateway.URL
tokenA := signedMultiTenantE2EToken(t, key, issuer, audience, browserClientID, applicationID, tenantA, subject, "")
tokenB := signedMultiTenantE2EToken(t, key, issuer, audience, browserClientID, applicationID, tenantB, subject, "")
var meA, meB auth.User
doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenA, nil, http.StatusOK, &meA)
doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenB, nil, http.StatusOK, &meB)
if meA.ID != subject || meB.ID != subject || meA.GatewayTenantID == meB.GatewayTenantID ||
meA.GatewayUserID == meB.GatewayUserID || meA.TenantName != tenantNames[tenantA] || meB.TenantName != tenantNames[tenantB] {
t.Fatalf("cross-tenant projection mismatch: A=%+v B=%+v", meA, meB)
}
var createdKey struct {
APIKey struct {
ID string `json:"id"`
} `json:"apiKey"`
}
doOIDCJITJSON(t, gateway.URL, http.MethodPost, "/api/v1/api-keys", tokenA,
map[string]any{"name": "multi-tenant-local-e2e"}, http.StatusCreated, &createdKey)
var keysB struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/api-keys", tokenB, nil, http.StatusOK, &keysB)
for _, item := range keysB.Items {
if item.ID == createdKey.APIKey.ID {
t.Fatal("Tenant A API Key crossed into Tenant B")
}
}
switchJar := newMultiTenantE2ECookieJar(t)
loginMultiTenantE2E(t, switchJar, gateway.URL, tenantA)
loginMultiTenantE2E(t, switchJar, gateway.URL, tenantB)
var switched auth.User
doMultiTenantE2ECookieJSON(t, switchJar, gateway.URL+"/api/v1/me", http.StatusOK, &switched)
if switched.TenantID != tenantB || revokedRefreshTokens.Load() != 1 {
t.Fatalf("tenant switch did not replace A session: me=%+v revoked=%d", switched, revokedRefreshTokens.Load())
}
var switchedASessions int
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$1::uuid`, meA.GatewayTenantID).Scan(&switchedASessions); err != nil {
t.Fatal(err)
}
if switchedASessions != 0 {
t.Fatalf("tenant switch retained %d old Tenant A sessions", switchedASessions)
}
jarA, jarB := newMultiTenantE2ECookieJar(t), newMultiTenantE2ECookieJar(t)
loginMultiTenantE2E(t, jarA, gateway.URL, tenantA)
loginMultiTenantE2E(t, jarB, gateway.URL, tenantB)
principalResult, err := db.ApplySessionRevoked(ctx, store.ApplySessionRevokedInput{
Issuer: issuer + "/ssf", Audience: "urn:easyai:ssf:" + applicationID, JTI: uuid.NewString(),
SubjectIssuer: issuer, ApplicationID: applicationID, SubjectType: "principal",
TenantID: tenantA, Subject: subject, EventTimestamp: time.Now().UTC().Add(-2 * time.Second), InitiatingEntity: "local-e2e",
})
if err != nil || principalResult.SessionsDeleted != 1 {
t.Fatalf("principal revocation result=%+v err=%v", principalResult, err)
}
doMultiTenantE2ECookieJSON(t, jarA, gateway.URL+"/api/v1/me", http.StatusUnauthorized, nil)
doMultiTenantE2ECookieJSON(t, jarB, gateway.URL+"/api/v1/me", http.StatusOK, nil)
loginMultiTenantE2E(t, jarA, gateway.URL, tenantA)
tenantResult, err := db.ApplySessionRevoked(ctx, store.ApplySessionRevokedInput{
Issuer: issuer + "/ssf", Audience: "urn:easyai:ssf:" + applicationID, JTI: uuid.NewString(),
SubjectIssuer: issuer, ApplicationID: applicationID, SubjectType: "tenant",
TenantID: tenantA, Subject: tenantA, EventTimestamp: time.Now().UTC().Add(-2 * time.Second), InitiatingEntity: "local-e2e",
})
if err != nil || tenantResult.SessionsDeleted != 1 {
t.Fatalf("tenant revocation result=%+v err=%v", tenantResult, err)
}
doMultiTenantE2ECookieJSON(t, jarA, gateway.URL+"/api/v1/me", http.StatusUnauthorized, nil)
doMultiTenantE2ECookieJSON(t, jarB, gateway.URL+"/api/v1/me", http.StatusOK, nil)
var restored auth.User
doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenA, nil, http.StatusOK, &restored)
if restored.GatewayTenantID != meA.GatewayTenantID || restored.GatewayUserID != meA.GatewayUserID {
t.Fatalf("reassignment did not reuse projection: before=%+v after=%+v", meA, restored)
}
var survivingAPIKey int
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_api_keys WHERE id=$1::uuid`, createdKey.APIKey.ID).Scan(&survivingAPIKey); err != nil {
t.Fatal(err)
}
if survivingAPIKey != 1 {
t.Fatal("OIDC tenant revocation unexpectedly removed a Gateway API Key")
}
doOIDCJITJSON(t, gateway.URL, http.MethodPost, "/api/v1/auth/register", "",
map[string]any{"username": "must-not-register", "password": "not-a-real-password"}, http.StatusForbidden, nil)
}
func assertAuthCenterMultiTenantContracts(t *testing.T, onboardingPath, runtimePath string) {
t.Helper()
for path, required := range map[string][]string{
onboardingPath: {"schema_version", "multi_tenant", "tenant_context", "machine_to_machine"},
runtimePath: {"/api/v1/runtime/tenants/{tenantId}", "tenant.context.read", "If-None-Match", "ETag"},
} {
raw, err := os.ReadFile(filepath.Clean(path))
if err != nil {
t.Fatalf("read Auth Center contract %s: %v", filepath.Base(path), err)
}
for _, marker := range required {
if !bytes.Contains(raw, []byte(marker)) {
t.Fatalf("Auth Center contract %s is missing %q", filepath.Base(path), marker)
}
}
}
}
func prepareMultiTenantE2ERevision(
t *testing.T,
ctx context.Context,
db *store.Store,
cfg config.Config,
issuer, ssfIssuer, ssfAudience, applicationID, audience, browserClientID, machineClientID, machineSecret string,
) identity.Revision {
t.Helper()
draft, err := identity.NewDraft(identity.PairingInput{
AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
LegacyJWTEnabled: true,
}, "test")
if err != nil {
t.Fatal(err)
}
draft, err = db.CreateIdentityConfigurationRevision(ctx, draft)
if err != nil {
t.Fatal(err)
}
secrets, err := identitySecretStore(cfg)
if err != nil {
t.Fatal(err)
}
machineRef, sessionRef := "multi-tenant-machine-"+draft.ID, "multi-tenant-session-"+draft.ID
if err := secrets.Put(ctx, machineRef, []byte(machineSecret)); err != nil {
t.Fatal(err)
}
if err := secrets.Put(ctx, sessionRef, bytes.Repeat([]byte{9}, 32)); err != nil {
t.Fatal(err)
}
for _, ref := range []string{machineRef, sessionRef} {
if err := db.QueueIdentitySecretCleanup(ctx, ref, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{
Manifest: identity.ManifestV2{
SchemaVersion: 2, TenantMode: "multi_tenant", Issuer: issuer, ApplicationID: applicationID,
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"},
Audience: audience, Scopes: []string{"gateway.access"},
Clients: identity.ManifestClients{
BrowserLogin: &identity.ManifestClient{ClientID: browserClientID},
MachineToMachine: &identity.ManifestClient{ClientID: machineClientID},
},
TenantContext: &identity.ManifestTenantContext{
Endpoint: issuer + "/api/v1/runtime/tenants/{tenantId}",
Audience: "urn:easyai:auth-center:tenant-context", Scope: "tenant.context.read",
},
SecurityEvents: &identity.ManifestSecurityEvents{
TransmitterIssuer: ssfIssuer, ConfigurationEndpoint: issuer + "/ssf/streams", Audience: ssfAudience,
},
},
MachineCredentialRef: machineRef, SessionEncryptionKeyRef: sessionRef,
TraceID: "multi-tenant-local-e2e", AuditID: "multi-tenant-local-e2e", AppEnv: "test",
})
if err != nil {
t.Fatal(err)
}
draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "multi-tenant-local-e2e", "multi-tenant-local-e2e")
if err != nil {
t.Fatal(err)
}
builder := identityruntime.NewRuntimeBuilder(ctx, db, secrets, identityruntime.RuntimeBuilderConfig{
AppEnv: "test", HeartbeatInterval: time.Hour, StaleAfter: 3 * time.Hour, ClockSkew: time.Minute,
}, nil)
if err := builder.PrepareSecurityEvents(ctx, draft, []byte(machineSecret)); err != nil {
t.Fatalf("prepare application-scoped SSF receiver: %v", err)
}
return draft
}
func signedMultiTenantE2EToken(
t *testing.T,
key *ecdsa.PrivateKey,
issuer, audience, clientID, applicationID, tenantID, subject, nonce string,
) string {
t.Helper()
now := time.Now().UTC()
claims := jwt.MapClaims{
"iss": issuer, "aud": audience, "sub": subject, "tid": tenantID, "client_id": clientID,
"application_id": applicationID, "preferred_username": "shared-local-user",
"roles": []string{"gateway.user"}, "scope": "openid gateway.access",
"iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": now.Add(time.Hour).Unix(),
}
if nonce != "" {
claims["nonce"] = nonce
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = "multi-tenant-key"
raw, err := token.SignedString(key)
if err != nil {
t.Fatal(err)
}
return raw
}
func newMultiTenantE2ECookieJar(t *testing.T) *cookiejar.Jar {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return jar
}
func loginMultiTenantE2E(t *testing.T, jar *cookiejar.Jar, gatewayURL, tenantID string) {
t.Helper()
gateway, err := url.Parse(gatewayURL)
if err != nil {
t.Fatal(err)
}
client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) > 10 {
return errors.New("too many redirects")
}
if request.URL.Host != gateway.Host && request.URL.Path != "/authorize" {
return http.ErrUseLastResponse
}
return nil
}}
response, err := client.Get(gatewayURL + "/api/v1/auth/oidc/login?returnTo=%2F&tenantHint=" + url.QueryEscape(tenantID))
if err != nil {
t.Fatalf("complete tenant %s login: %v", tenantID[:8], err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusSeeOther && response.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
t.Fatalf("tenant %s login status=%d body=%s", tenantID[:8], response.StatusCode, raw)
}
for _, cookie := range jar.Cookies(gateway) {
if cookie.Name == auth.OIDCSessionCookieName && cookie.Value != "" {
return
}
}
t.Fatalf("tenant %s login returned no opaque session", tenantID[:8])
}
func doMultiTenantE2ECookieJSON(t *testing.T, jar *cookiejar.Jar, requestURL string, expectedStatus int, output any) {
t.Helper()
client := &http.Client{Jar: jar}
response, err := client.Get(requestURL)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
t.Fatal(err)
}
if response.StatusCode != expectedStatus {
t.Fatalf("GET %s status=%d want=%d body=%s", requestURL, response.StatusCode, expectedStatus, raw)
}
if output != nil {
if err := json.Unmarshal(raw, output); err != nil {
t.Fatal(err)
}
}
}
func writeTestJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
+46 -3
View File
@@ -13,6 +13,7 @@ import (
"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"
"github.com/google/uuid"
)
const (
@@ -49,7 +50,12 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
if returnTo == "" {
returnTo = "/"
}
transaction, err := oidcsession.NewLoginTransaction(returnTo, time.Now())
tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint"))
if tenantHint != "" && (runtime.Revision.TenantMode != "multi_tenant" || uuid.Validate(tenantHint) != nil) {
writeError(w, http.StatusBadRequest, "租户提示无效", errorCodeOIDCLoginInvalid)
return
}
transaction, err := oidcsession.NewLoginTransactionWithTenantHint(returnTo, tenantHint, time.Now())
if err != nil {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return
@@ -60,7 +66,9 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return
}
authorizationURL, err := runtime.PublicClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier)
authorizationURL, err := runtime.PublicClient.AuthorizationURL(
r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier, transaction.TenantHint,
)
if err != nil {
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE")
@@ -124,7 +132,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.writeOIDCTokenFailure(w, r, "ID_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心身份令牌校验失败")
return
}
projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision)
projection, err := s.resolveOIDCUserProjectionForRuntime(r.Context(), r, identity, runtime)
if err != nil {
s.writeOIDCCallbackProjectionError(w, r, err)
return
@@ -140,6 +148,13 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "登录会话保存失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return
}
if err := s.replaceOIDCBrowserSession(r.Context(), r, runtime, rawSession); err != nil {
if _, cleanupErr := runtime.Sessions.Delete(r.Context(), rawSession); cleanupErr != nil {
s.logger.WarnContext(r.Context(), "cleanup replacement OIDC session failed", "error", cleanupErr)
}
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "旧登录会话清理失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
return
}
now := time.Now()
http.SetCookie(w, &http.Cookie{
Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/",
@@ -151,6 +166,34 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther)
}
func (s *Server) replaceOIDCBrowserSession(
ctx context.Context,
r *http.Request,
runtime *identityRequestRuntime,
newRawSession string,
) error {
previous, err := r.Cookie(auth.OIDCSessionCookieName)
if errors.Is(err, http.ErrNoCookie) {
return nil
}
if err != nil {
return err
}
if previous.Value == "" || previous.Value == newRawSession {
return nil
}
bundle, err := runtime.Sessions.Delete(ctx, previous.Value)
if err != nil {
return err
}
if bundle.RefreshToken != "" {
if err := runtime.PublicClient.RevokeRefreshToken(ctx, bundle.RefreshToken); err != nil {
s.logger.WarnContext(ctx, "revoke replaced OIDC refresh token failed", "error", err)
}
}
return nil
}
// logoutOIDCSession godoc
// @Summary 注销 OIDC 登录会话
// @Description 删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。
+100 -4
View File
@@ -60,6 +60,57 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
}
}
func TestStartOIDCLoginEncryptsAndForwardsMultiTenantHint(t *testing.T) {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize"}
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+tenantHint, nil,
))
if recorder.Code != http.StatusSeeOther || client.tenantHint != tenantHint {
t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint)
}
cookies := recorder.Result().Cookies()
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, time.Now())
if err != nil || transaction.TenantHint != tenantHint {
t.Fatalf("transaction=%+v err=%v", transaction, err)
}
}
func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
for _, test := range []struct {
name string
revision identity.Revision
hint string
}{
{name: "invalid UUID", revision: identity.Revision{TenantMode: "multi_tenant"}, hint: "not-a-uuid"},
{name: "single tenant", revision: identity.Revision{TenantMode: "single_tenant"}, hint: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},
} {
t.Run(test.name, func(t *testing.T) {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: test.revision, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+test.hint, nil,
))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
}
})
}
}
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil {
@@ -191,6 +242,42 @@ func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
}
}
func TestReplaceOIDCBrowserSessionDeletesPreviousSessionAndRevokesRefreshToken(t *testing.T) {
sessions := &fakeOIDCSessions{
deleteBundle: oidcsession.TokenBundle{RefreshToken: "previous-refresh-token"},
}
client := &fakeOIDCClient{}
server := &Server{}
runtime := &identityRequestRuntime{Sessions: sessions, PublicClient: client}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "previous-session"})
if err := server.replaceOIDCBrowserSession(request.Context(), request, runtime, "new-session"); err != nil {
t.Fatal(err)
}
if sessions.deleted != "previous-session" {
t.Fatalf("deleted session=%q", sessions.deleted)
}
if client.revokedRefreshToken != "previous-refresh-token" {
t.Fatalf("revoked refresh token=%q", client.revokedRefreshToken)
}
}
func TestReplaceOIDCBrowserSessionDoesNotDeleteNewSession(t *testing.T) {
sessions := &fakeOIDCSessions{}
server := &Server{}
runtime := &identityRequestRuntime{Sessions: sessions, PublicClient: &fakeOIDCClient{}}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback", nil)
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "same-session"})
if err := server.replaceOIDCBrowserSession(request.Context(), request, runtime, "same-session"); err != nil {
t.Fatal(err)
}
if sessions.deleted != "" {
t.Fatalf("unexpected deleted session=%q", sessions.deleted)
}
}
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
server := &Server{
cfg: config.Config{CORSAllowedOrigin: "https://gateway.example.com"},
@@ -329,10 +416,13 @@ func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
type fakeOIDCClient struct {
authorizationURL string
state, nonce, challenge string
tenantHint string
revokedRefreshToken string
}
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge string) (string, error) {
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, tenantHint string) (string, error) {
f.state, f.nonce, f.challenge = state, nonce, challenge
f.tenantHint = tenantHint
return f.authorizationURL, nil
}
func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) {
@@ -344,12 +434,18 @@ func (f *fakeOIDCClient) VerifyIDToken(context.Context, string, string) (string,
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) RevokeRefreshToken(_ context.Context, refreshToken string) error {
f.revokedRefreshToken = refreshToken
return nil
}
func (f *fakeOIDCClient) EndSessionURL(context.Context, string) (string, error) {
return "https://gateway.example.com/", nil
}
type fakeOIDCSessions struct{ deleted string }
type fakeOIDCSessions struct {
deleted string
deleteBundle oidcsession.TokenBundle
}
func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) {
return "opaque-session", nil
@@ -357,6 +453,6 @@ func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *aut
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
return f.deleteBundle, nil
}
func (f *fakeOIDCSessions) Cleanup(context.Context) (int64, error) { return 0, nil }
+109 -11
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
@@ -22,6 +23,10 @@ type oidcUserResolver interface {
ResolveOrProvisionOIDCUser(context.Context, store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error)
}
type oidcTenantBindingReader interface {
OIDCTenantBindingContext(context.Context, string, string, string) (store.OIDCTenantBindingContext, error)
}
func (s *Server) requireUser(permission auth.Permission, next http.Handler) http.Handler {
return s.auth.Require(permission, s.resolveGatewayUser(next))
}
@@ -57,26 +62,119 @@ func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request,
if runtime == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable")
}
return s.resolveOIDCUserProjectionForRevision(ctx, r, user, runtime.Revision)
return s.resolveOIDCUserProjectionForRuntime(ctx, r, user, runtime)
}
func (s *Server) resolveOIDCUserProjectionForRevision(ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision) (store.ResolveOrProvisionOIDCUserResult, error) {
func (s *Server) resolveOIDCUserProjectionForRuntime(ctx context.Context, r *http.Request, user *auth.User, runtime *identityRequestRuntime) (store.ResolveOrProvisionOIDCUserResult, error) {
if s.oidcUserResolver == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
}
revision := runtime.Revision
tenantName := ""
tenantSlug := ""
tenantMetadataStatus := ""
tenantMetadataVersion := ""
tenantMetadataETag := ""
var tenantMetadataUpdatedAt time.Time
if revision.TenantMode == "multi_tenant" {
if runtime.TenantContext == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable")
}
var cached store.OIDCTenantBindingContext
bindingDisabled := false
if reader, ok := s.oidcUserResolver.(oidcTenantBindingReader); ok {
cached, _ = reader.OIDCTenantBindingContext(ctx, revision.Issuer, revision.ApplicationID, user.TenantID)
bindingDisabled = cached.ID != "" && cached.AccessStatus != "active"
if !bindingDisabled && cached.MetadataStatus == "synced" && cached.NextSyncAt.After(time.Now()) {
tenantName, tenantSlug = cached.DisplayName, cached.Slug
tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version
tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt
}
}
if tenantMetadataStatus == "synced" {
return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision,
tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt)
}
etag := cached.ETag
if bindingDisabled {
// A revoked binding can only be reactivated after a fresh positive
// Tenant Context response. Do not let a stale 304 or cached profile
// reopen access after reassignment.
etag = ""
}
tenant, unchanged, err := runtime.TenantContext.Get(ctx, user.TenantID, etag)
switch {
case err == nil && unchanged && !bindingDisabled && cached.MetadataStatus == "synced":
tenantName, tenantSlug = cached.DisplayName, cached.Slug
tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version
tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt
case err == nil && tenant.Active():
tenantName = tenant.DisplayName
tenantSlug = tenant.Slug
tenantMetadataStatus = "synced"
tenantMetadataVersion = tenant.Version
tenantMetadataETag = tenant.ETag
tenantMetadataUpdatedAt = tenant.UpdatedAt
case err == nil, errors.Is(err, identity.ErrTenantContextNotFound):
return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable
case errors.Is(err, identity.ErrTenantContextUnavailable):
if bindingDisabled {
return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable
}
if cached.MetadataStatus == "synced" {
tenantName, tenantSlug = cached.DisplayName, cached.Slug
tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version
tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt
} else {
tenantName = oidcTenantPlaceholderName(user.TenantID)
tenantMetadataStatus = "metadata_pending"
}
default:
return store.ResolveOrProvisionOIDCUserResult{}, err
}
}
return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision,
tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt)
}
func (s *Server) resolveOIDCUserProjectionWithTenantContext(
ctx context.Context,
r *http.Request,
user *auth.User,
revision identity.Revision,
tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag string,
tenantMetadataUpdatedAt time.Time,
) (store.ResolveOrProvisionOIDCUserResult, error) {
return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{
Issuer: revision.Issuer,
Subject: user.ID,
Username: user.Username,
Roles: user.Roles,
TenantID: user.TenantID,
GatewayTenantKey: revision.LocalTenantKey,
ProvisioningEnabled: revision.JITEnabled,
RequestIP: limitAuditText(requestIP(r), 128),
UserAgent: limitAuditText(r.UserAgent(), 512),
Issuer: revision.Issuer,
ApplicationID: revision.ApplicationID,
Subject: user.ID,
Username: user.Username,
Roles: user.Roles,
TenantID: user.TenantID,
TenantMode: revision.TenantMode,
TenantName: tenantName,
TenantSlug: tenantSlug,
TenantMetadataStatus: tenantMetadataStatus,
TenantMetadataVersion: tenantMetadataVersion,
TenantMetadataETag: tenantMetadataETag,
TenantMetadataUpdatedAt: tenantMetadataUpdatedAt,
OIDCClientID: user.OIDCClientID,
GatewayTenantKey: revision.LocalTenantKey,
ProvisioningEnabled: revision.JITEnabled,
RequestIP: limitAuditText(requestIP(r), 128),
UserAgent: limitAuditText(r.UserAgent(), 512),
})
}
func oidcTenantPlaceholderName(tenantID string) string {
compact := strings.ReplaceAll(strings.TrimSpace(tenantID), "-", "")
if len(compact) > 8 {
compact = compact[:8]
}
return "认证中心租户 " + compact
}
func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
@@ -16,10 +17,30 @@ import (
)
type fakeOIDCUserResolver struct {
result store.ResolveOrProvisionOIDCUserResult
result store.ResolveOrProvisionOIDCUserResult
err error
calls int
input store.ResolveOrProvisionOIDCUserInput
binding store.OIDCTenantBindingContext
bindingErr error
bindingCalls int
}
type fakeTenantContextReader struct {
tenant identity.TenantContext
err error
calls int
input store.ResolveOrProvisionOIDCUserInput
calls *int
etag *string
}
func (reader fakeTenantContextReader) Get(_ context.Context, _, etag string) (identity.TenantContext, bool, error) {
if reader.calls != nil {
(*reader.calls)++
}
if reader.etag != nil {
*reader.etag = etag
}
return reader.tenant, false, reader.err
}
func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, input store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) {
@@ -28,6 +49,14 @@ func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, inp
return f.result, f.err
}
func (f *fakeOIDCUserResolver) OIDCTenantBindingContext(context.Context, string, string, string) (store.OIDCTenantBindingContext, error) {
f.bindingCalls++
if f.binding.ID == "" && f.bindingErr == nil {
return store.OIDCTenantBindingContext{}, store.ErrOIDCTenantUnavailable
}
return f.binding, f.bindingErr
}
func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{
ID: "platform-user",
@@ -99,6 +128,203 @@ func TestResolveGatewayUserLeavesNonOIDCIdentityChainsUnchanged(t *testing.T) {
}
}
func TestResolveOIDCMultiTenantProjectionUsesRuntimeTenantContext(t *testing.T) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
applicationID := "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8"
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}}
server := &Server{oidcUserResolver: resolver}
runtime := &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: applicationID,
TenantMode: "multi_tenant", JITEnabled: true,
},
TenantContext: fakeTenantContextReader{tenant: identity.TenantContext{
ApplicationID: applicationID, TenantID: tenantID, DisplayName: "租户 A", Slug: "tenant-a",
TenantStatus: "active", TenantApplicationStatus: "active", Version: "v7",
UpdatedAt: time.Date(2026, 7, 28, 9, 0, 0, 0, time.UTC), ETag: `"tenant-v7"`,
}},
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "shared-subject", TenantID: tenantID, Username: "alice", Roles: []string{"basic"},
}, runtime)
if err != nil {
t.Fatalf("resolve multi-tenant projection: %v", err)
}
if resolver.input.TenantMode != "multi_tenant" || resolver.input.ApplicationID != applicationID ||
resolver.input.TenantName != "租户 A" || resolver.input.TenantSlug != "tenant-a" ||
resolver.input.TenantMetadataStatus != "synced" || resolver.input.TenantMetadataVersion != "v7" ||
resolver.input.TenantMetadataETag != `"tenant-v7"` {
t.Fatalf("unexpected tenant context projection input: %+v", resolver.input)
}
}
func TestResolveOIDCMultiTenantProjectionTreatsTemporaryContextFailureAsPending(t *testing.T) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}}
server := &Server{oidcUserResolver: resolver}
runtime := &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
TenantMode: "multi_tenant", JITEnabled: true,
},
TenantContext: fakeTenantContextReader{err: identity.ErrTenantContextUnavailable},
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
}, runtime); err != nil {
t.Fatalf("temporary tenant context failure should reach fail-closed store projection: %v", err)
}
if resolver.input.TenantMetadataStatus != "metadata_pending" ||
resolver.input.TenantName != "认证中心租户 d9dcb4e7" {
t.Fatalf("unexpected pending projection input: %+v", resolver.input)
}
}
func TestResolveOIDCMultiTenantProjectionUsesFreshLocalTenantCache(t *testing.T) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
remoteCalls := 0
resolver := &fakeOIDCUserResolver{
result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}},
binding: store.OIDCTenantBindingContext{
ID: "binding-a", AccessStatus: "active", MetadataStatus: "synced", DisplayName: "缓存租户 A",
Slug: "tenant-a", Version: "v8", ETag: `"tenant-v8"`,
MetadataUpdatedAt: time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC),
NextSyncAt: time.Now().Add(time.Minute),
},
}
server := &Server{oidcUserResolver: resolver}
runtime := &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
TenantMode: "multi_tenant", JITEnabled: true,
},
TenantContext: fakeTenantContextReader{
err: errors.New("fresh cache must avoid a remote request"), calls: &remoteCalls,
},
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
}, runtime); err != nil {
t.Fatal(err)
}
if remoteCalls != 0 || resolver.input.TenantName != "缓存租户 A" ||
resolver.input.TenantMetadataVersion != "v8" {
t.Fatalf("remote calls=%d input=%+v", remoteCalls, resolver.input)
}
}
func TestResolveOIDCMultiTenantProjectionFallsBackToSyncedCacheOnTemporaryFailure(t *testing.T) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
resolver := &fakeOIDCUserResolver{
result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}},
binding: store.OIDCTenantBindingContext{
ID: "binding-a", AccessStatus: "active", MetadataStatus: "synced", DisplayName: "缓存租户 A",
Slug: "tenant-a", Version: "v7", ETag: `"tenant-v7"`, NextSyncAt: time.Now().Add(-time.Minute),
},
}
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
}, &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
TenantMode: "multi_tenant", JITEnabled: true,
},
TenantContext: fakeTenantContextReader{err: identity.ErrTenantContextUnavailable},
}); err != nil {
t.Fatal(err)
}
if resolver.input.TenantName != "缓存租户 A" || resolver.input.TenantMetadataStatus != "synced" {
t.Fatalf("cached fallback input=%+v", resolver.input)
}
}
func TestResolveOIDCMultiTenantProjectionRevalidatesDisabledBindingBeforeReassignment(t *testing.T) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
applicationID := "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8"
remoteETag := "not-called"
resolver := &fakeOIDCUserResolver{
result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}},
binding: store.OIDCTenantBindingContext{
ID: "binding-a", AccessStatus: "disabled", MetadataStatus: "rejected",
ETag: `"revoked-v7"`,
},
}
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
}, &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: applicationID,
TenantMode: "multi_tenant", JITEnabled: true,
},
TenantContext: fakeTenantContextReader{tenant: identity.TenantContext{
ApplicationID: applicationID, TenantID: tenantID, DisplayName: "恢复后的租户 A", Slug: "tenant-a",
TenantStatus: "active", TenantApplicationStatus: "active", Version: "v8", UpdatedAt: time.Now(),
}, etag: &remoteETag},
})
if err != nil {
t.Fatalf("revalidate reassigned tenant: %v", err)
}
if remoteETag != "" || resolver.calls != 1 || resolver.input.TenantMetadataStatus != "synced" {
t.Fatalf("etag=%q calls=%d input=%+v", remoteETag, resolver.calls, resolver.input)
}
}
func TestResolveOIDCMultiTenantProjectionKeepsDisabledBindingClosedDuringContextFailure(t *testing.T) {
resolver := &fakeOIDCUserResolver{binding: store.OIDCTenantBindingContext{
ID: "binding-a", AccessStatus: "disabled", MetadataStatus: "rejected",
}}
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0",
}, &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
TenantMode: "multi_tenant", JITEnabled: true,
},
TenantContext: fakeTenantContextReader{err: identity.ErrTenantContextUnavailable},
})
if !errors.Is(err, store.ErrOIDCTenantUnavailable) || resolver.calls != 0 {
t.Fatalf("err=%v resolver calls=%d", err, resolver.calls)
}
}
func TestResolveOIDCMultiTenantProjectionRejectsMissingOrInactiveTenant(t *testing.T) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
tests := []struct {
name string
reader fakeTenantContextReader
}{
{name: "not found", reader: fakeTenantContextReader{err: identity.ErrTenantContextNotFound}},
{name: "inactive", reader: fakeTenantContextReader{tenant: identity.TenantContext{
TenantStatus: "suspended", TenantApplicationStatus: "active",
}}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := &fakeOIDCUserResolver{}
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
}, &identityRequestRuntime{
Revision: identity.Revision{TenantMode: "multi_tenant"},
TenantContext: test.reader,
})
if !errors.Is(err, store.ErrOIDCTenantUnavailable) || resolver.calls != 0 {
t.Fatalf("err=%v resolver calls=%d", err, resolver.calls)
}
})
}
}
func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
tests := []struct {
name string
+1 -1
View File
@@ -48,7 +48,7 @@ type Server struct {
}
type oidcPublicClient interface {
AuthorizationURL(context.Context, string, string, string) (string, error)
AuthorizationURL(context.Context, string, string, string, string) (string, error)
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
VerifyIDToken(context.Context, string, string) (string, error)
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)