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:
@@ -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)
|
||||
}
|
||||
@@ -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,并跳转认证中心退出地址。
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user