fix(identity): 支持平台用户显式登录 AI Gateway

修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
+22
View File
@@ -5906,6 +5906,19 @@
"description": "登录后返回的站内相对路径", "description": "登录后返回的站内相对路径",
"name": "returnTo", "name": "returnTo",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "显式登录上下文:platform 或 tenant",
"name": "contextType",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Tenant 上下文可选的稳定 Tenant UUID",
"name": "tenantHint",
"in": "query"
} }
], ],
"responses": { "responses": {
@@ -10064,6 +10077,9 @@
"apiKeySecret": { "apiKeySecret": {
"type": "string" "type": "string"
}, },
"contextType": {
"type": "string"
},
"gatewayTenantId": { "gatewayTenantId": {
"type": "string" "type": "string"
}, },
@@ -12432,6 +12448,12 @@
"httpapi.publicIdentityConfiguration": { "httpapi.publicIdentityConfiguration": {
"type": "object", "type": "object",
"properties": { "properties": {
"contextTypes": {
"type": "array",
"items": {
"type": "string"
}
},
"enabled": { "enabled": {
"type": "boolean" "type": "boolean"
}, },
+15
View File
@@ -14,6 +14,8 @@ definitions:
type: array type: array
apiKeySecret: apiKeySecret:
type: string type: string
contextType:
type: string
gatewayTenantId: gatewayTenantId:
type: string type: string
gatewayUserId: gatewayUserId:
@@ -1662,6 +1664,10 @@ definitions:
type: object type: object
httpapi.publicIdentityConfiguration: httpapi.publicIdentityConfiguration:
properties: properties:
contextTypes:
items:
type: string
type: array
enabled: enabled:
type: boolean type: boolean
loginUrl: loginUrl:
@@ -7889,6 +7895,15 @@ paths:
in: query in: query
name: returnTo name: returnTo
type: string type: string
- description: 显式登录上下文:platform 或 tenant
in: query
name: contextType
required: true
type: string
- description: Tenant 上下文可选的稳定 Tenant UUID
in: query
name: tenantHint
type: string
responses: responses:
"303": "303":
description: See Other description: See Other
+1
View File
@@ -37,6 +37,7 @@ type User struct {
ID string `json:"sub"` ID string `json:"sub"`
Username string `json:"username"` Username string `json:"username"`
Roles []string `json:"role,omitempty"` Roles []string `json:"role,omitempty"`
ContextType string `json:"contextType,omitempty"`
TenantID string `json:"tenantId,omitempty"` TenantID string `json:"tenantId,omitempty"`
TenantName string `json:"tenantName,omitempty"` TenantName string `json:"tenantName,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"` GatewayTenantID string `json:"gatewayTenantId,omitempty"`
+16 -5
View File
@@ -50,6 +50,7 @@ type OIDCConfig struct {
type OIDCSecurityEventIdentity struct { type OIDCSecurityEventIdentity struct {
Issuer string Issuer string
ApplicationID string ApplicationID string
ContextType string
TenantID string TenantID string
Subject string Subject string
IssuedAt time.Time IssuedAt time.Time
@@ -179,13 +180,21 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
return nil, oidcUnauthorized(registeredClaimsValidationCategory(err), "signature or registered claims are invalid", err) return nil, oidcUnauthorized(registeredClaimsValidationCategory(err), "signature or registered claims are invalid", err)
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
contextType := stringClaim(claims, "context_type")
tenantID := stringClaim(claims, "tid") tenantID := stringClaim(claims, "tid")
validTenant := tenantID == v.config.TenantID validContext := contextType == "tenant" && tenantID == v.config.TenantID
if v.config.TenantMode == "multi_tenant" { if v.config.TenantMode == "multi_tenant" {
validTenant = uuid.Validate(tenantID) == nil switch contextType {
case "platform":
validContext = tenantID == ""
case "tenant":
validContext = uuid.Validate(tenantID) == nil
default:
validContext = false
}
} }
clientID := stringClaim(claims, "client_id") clientID := stringClaim(claims, "client_id")
if !ok || stringClaim(claims, "sub") == "" || !validTenant || if !ok || stringClaim(claims, "sub") == "" || !validContext ||
v.config.ClientID != "" && clientID != v.config.ClientID { v.config.ClientID != "" && clientID != v.config.ClientID {
return nil, oidcUnauthorized("STABLE_IDENTITY_CLAIMS_INVALID", "stable identity claims are invalid", nil) return nil, oidcUnauthorized("STABLE_IDENTITY_CLAIMS_INVALID", "stable identity claims are invalid", nil)
} }
@@ -211,7 +220,8 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
if v.config.SecurityEventEvaluator != nil { if v.config.SecurityEventEvaluator != nil {
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{ evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
Issuer: v.config.Issuer, ApplicationID: v.config.ApplicationID, Issuer: v.config.Issuer, ApplicationID: v.config.ApplicationID,
TenantID: tenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt, ContextType: contextType, TenantID: tenantID,
Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
}) })
if evaluateErr != nil { if evaluateErr != nil {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用") return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
@@ -248,7 +258,8 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
} }
return &User{ return &User{
ID: stringClaim(claims, "sub"), Username: username, Roles: roles, ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
TenantID: tenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer, ContextType: contextType, TenantID: tenantID, Source: "oidc",
TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
ApplicationID: v.config.ApplicationID, OIDCClientID: clientID, ApplicationID: v.config.ApplicationID, OIDCClientID: clientID,
}, nil }, nil
} }
+16 -2
View File
@@ -78,18 +78,32 @@ func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error {
return err return err
} }
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier, tenantHint string) (string, error) { func (c *OIDCPublicClient) AuthorizationURL(
ctx context.Context,
state, nonce, pkceVerifier, contextType, tenantHint string,
) (string, error) {
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) { if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
return "", errors.New("state, nonce and PKCE verifier are required") return "", errors.New("state, nonce and PKCE verifier are required")
} }
contextType = strings.TrimSpace(contextType)
if contextType != "platform" && contextType != "tenant" {
return "", errors.New("context type must be platform or tenant")
}
if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil { if strings.TrimSpace(tenantHint) != "" && uuid.Validate(strings.TrimSpace(tenantHint)) != nil {
return "", errors.New("tenant hint must be a UUID") return "", errors.New("tenant hint must be a UUID")
} }
if contextType == "platform" && strings.TrimSpace(tenantHint) != "" {
return "", errors.New("platform context cannot bind a tenant hint")
}
config, _, err := c.configuration(ctx) config, _, err := c.configuration(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
options := []oauth2.AuthCodeOption{oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)} options := []oauth2.AuthCodeOption{
oidc.Nonce(nonce),
oauth2.S256ChallengeOption(pkceVerifier),
oauth2.SetAuthURLParam("context_type", contextType),
}
if strings.TrimSpace(tenantHint) != "" { if strings.TrimSpace(tenantHint) != "" {
options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint))) options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint)))
} }
+6 -1
View File
@@ -68,7 +68,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
t.Fatalf("ValidateConfiguration() error = %v", err) t.Fatalf("ValidateConfiguration() error = %v", err)
} }
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier, tenantHint) authorizationURL, err := client.AuthorizationURL(
context.Background(), "state", "nonce", pkceVerifier, "tenant", tenantHint,
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -82,6 +84,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
if query.Get("tenant_hint") != tenantHint { if query.Get("tenant_hint") != tenantHint {
t.Fatalf("tenant_hint = %q, want %q", query.Get("tenant_hint"), tenantHint) t.Fatalf("tenant_hint = %q, want %q", query.Get("tenant_hint"), tenantHint)
} }
if query.Get("context_type") != "tenant" {
t.Fatalf("context_type = %q, want tenant", query.Get("context_type"))
}
if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil { if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+87
View File
@@ -99,6 +99,8 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
{"missing issuer", "ISSUER_MISSING", func(claims jwt.MapClaims) { delete(claims, "iss") }}, {"missing issuer", "ISSUER_MISSING", func(claims jwt.MapClaims) { delete(claims, "iss") }},
{"missing audience", "AUDIENCE_MISSING", func(claims jwt.MapClaims) { delete(claims, "aud") }}, {"missing audience", "AUDIENCE_MISSING", func(claims jwt.MapClaims) { delete(claims, "aud") }},
{"wrong audience", "AUDIENCE_INVALID", func(claims jwt.MapClaims) { claims["aud"] = "other-api" }}, {"wrong audience", "AUDIENCE_INVALID", func(claims jwt.MapClaims) { claims["aud"] = "other-api" }},
{"missing context type", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { delete(claims, "context_type") }},
{"unknown context type", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { claims["context_type"] = "account" }},
{"wrong tenant", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { claims["tid"] = "tenant-2" }}, {"wrong tenant", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { claims["tid"] = "tenant-2" }},
{"missing scope", "REQUIRED_SCOPE_MISSING", func(claims jwt.MapClaims) { claims["scope"] = "openid" }}, {"missing scope", "REQUIRED_SCOPE_MISSING", func(claims jwt.MapClaims) { claims["scope"] = "openid" }},
{"unmapped role", "MAPPED_ROLE_MISSING", func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }}, {"unmapped role", "MAPPED_ROLE_MISSING", func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }},
@@ -120,6 +122,90 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
} }
} }
func TestOIDCVerifierAcceptsExplicitPlatformAndTenantContextsForMultiTenantApplication(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/.well-known/openid-configuration" {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer,
"jwks_uri": issuer + "/jwks",
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []any{ecJWK("ec-key", &key.PublicKey)},
})
}))
defer server.Close()
issuer = server.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api",
TenantMode: "multi_tenant",
ApplicationID: "11111111-1111-4111-8111-111111111111",
RolePrefix: "gateway.", HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
platformToken := signedOIDCToken(
t,
issuer,
"ec-key",
jwt.SigningMethodES256,
key,
func(claims jwt.MapClaims) {
claims["context_type"] = "platform"
delete(claims, "tid")
},
)
platformUser, err := verifier.Verify(context.Background(), platformToken)
if err != nil {
t.Fatalf("platform context rejected: %v", err)
}
if platformUser.ContextType != "platform" || platformUser.TenantID != "" {
t.Fatalf("platform user=%#v", platformUser)
}
tenantID := "22222222-2222-4222-8222-222222222222"
tenantToken := signedOIDCToken(
t,
issuer,
"ec-key",
jwt.SigningMethodES256,
key,
func(claims jwt.MapClaims) {
claims["context_type"] = "tenant"
claims["tid"] = tenantID
},
)
tenantUser, err := verifier.Verify(context.Background(), tenantToken)
if err != nil {
t.Fatalf("tenant context rejected: %v", err)
}
if tenantUser.ContextType != "tenant" || tenantUser.TenantID != tenantID {
t.Fatalf("tenant user=%#v", tenantUser)
}
for name, mutate := range map[string]func(jwt.MapClaims){
"platform with tenant": func(claims jwt.MapClaims) {
claims["context_type"] = "platform"
},
"tenant without tenant": func(claims jwt.MapClaims) {
claims["context_type"] = "tenant"
delete(claims, "tid")
},
} {
t.Run(name, func(t *testing.T) {
raw := signedOIDCToken(
t, issuer, "ec-key", jwt.SigningMethodES256, key, mutate,
)
if _, err := verifier.Verify(context.Background(), raw); err == nil {
t.Fatal("context/tenant mismatch was accepted")
}
})
}
}
func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) { func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048) key, _ := rsa.GenerateKey(rand.Reader, 2048)
active := true active := true
@@ -312,6 +398,7 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod,
now := time.Now() now := time.Now()
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1", "iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1",
"context_type": "tenant",
"preferred_username": "acceptance", "roles": []string{"gateway.admin"}, "preferred_username": "acceptance", "roles": []string{"gateway.admin"},
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), "scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
"exp": now.Add(time.Hour).Unix(), "exp": now.Add(time.Hour).Unix(),
@@ -39,11 +39,12 @@ type identityRuntimeStatus struct {
} }
type publicIdentityConfiguration struct { type publicIdentityConfiguration struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
OIDCLogin bool `json:"oidcLogin"` OIDCLogin bool `json:"oidcLogin"`
LoginURL string `json:"loginUrl,omitempty"` LoginURL string `json:"loginUrl,omitempty"`
LogoutURL string `json:"logoutUrl,omitempty"` LogoutURL string `json:"logoutUrl,omitempty"`
Status string `json:"status"` ContextTypes []string `json:"contextTypes,omitempty"`
Status string `json:"status"`
} }
type identityPolicyPatch struct { type identityPolicyPatch struct {
@@ -99,6 +100,10 @@ func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.R
view.OIDCLogin = true view.OIDCLogin = true
view.LoginURL = "/api/v1/auth/oidc/login" view.LoginURL = "/api/v1/auth/oidc/login"
view.LogoutURL = "/api/v1/auth/oidc/logout" view.LogoutURL = "/api/v1/auth/oidc/logout"
view.ContextTypes = []string{"tenant"}
if runtime.Revision.TenantMode == "multi_tenant" {
view.ContextTypes = []string{"platform", "tenant"}
}
} }
writeJSON(w, http.StatusOK, view) writeJSON(w, http.StatusOK, view)
} }
+28 -4
View File
@@ -36,6 +36,8 @@ const (
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。 // @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
// @Tags auth // @Tags auth
// @Param returnTo query string false "登录后返回的站内相对路径" // @Param returnTo query string false "登录后返回的站内相对路径"
// @Param contextType query string true "显式登录上下文:platform 或 tenant"
// @Param tenantHint query string false "Tenant 上下文可选的稳定 Tenant UUID"
// @Success 303 // @Success 303
// @Failure 400 {object} ErrorEnvelope // @Failure 400 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
@@ -50,12 +52,22 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
if returnTo == "" { if returnTo == "" {
returnTo = "/" returnTo = "/"
} }
contextType := strings.TrimSpace(r.URL.Query().Get("contextType"))
tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint")) tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint"))
if tenantHint != "" && (runtime.Revision.TenantMode != "multi_tenant" || uuid.Validate(tenantHint) != nil) { validContext := contextType == "tenant" ||
writeError(w, http.StatusBadRequest, "租户提示无效", errorCodeOIDCLoginInvalid) contextType == "platform" &&
runtime.Revision.TenantMode == "multi_tenant"
validTenantHint := tenantHint == "" ||
contextType == "tenant" &&
runtime.Revision.TenantMode == "multi_tenant" &&
uuid.Validate(tenantHint) == nil
if !validContext || !validTenantHint {
writeError(w, http.StatusBadRequest, "登录上下文无效", errorCodeOIDCLoginInvalid)
return return
} }
transaction, err := oidcsession.NewLoginTransactionWithTenantHint(returnTo, tenantHint, time.Now()) transaction, err := oidcsession.NewLoginTransactionWithContext(
returnTo, contextType, tenantHint, time.Now(),
)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid) writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return return
@@ -67,7 +79,9 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
authorizationURL, err := runtime.PublicClient.AuthorizationURL( authorizationURL, err := runtime.PublicClient.AuthorizationURL(
r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier, transaction.TenantHint, r.Context(), transaction.State, transaction.Nonce,
transaction.PKCEVerifier, transaction.ContextType,
transaction.TenantHint,
) )
if err != nil { if err != nil {
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err) s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
@@ -127,6 +141,16 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.writeOIDCTokenFailure(w, r, "ACCESS_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心访问令牌校验失败") s.writeOIDCTokenFailure(w, r, "ACCESS_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心访问令牌校验失败")
return return
} }
if identity.ContextType != transaction.ContextType ||
transaction.TenantHint != "" &&
identity.TenantID != transaction.TenantHint {
s.writeOIDCTokenFailure(
w, r, "ACCESS_TOKEN_CONTEXT_MISMATCH",
"STABLE_IDENTITY_CLAIMS_INVALID",
"认证中心访问令牌上下文与登录入口不匹配",
)
return
}
idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce) idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce)
if err != nil || idSubject != identity.ID { if err != nil || idSubject != identity.ID {
s.writeOIDCTokenFailure(w, r, "ID_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心身份令牌校验失败") s.writeOIDCTokenFailure(w, r, "ID_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心身份令牌校验失败")
+37 -7
View File
@@ -25,9 +25,10 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
server := &Server{ server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client, auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?contextType=platform&returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, request) server.startOIDCLogin(recorder, request)
response := recorder.Result() response := recorder.Result()
@@ -40,10 +41,11 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
t.Fatalf("unsafe login transaction cookie: %#v", cookies) t.Fatalf("unsafe login transaction cookie: %#v", cookies)
} }
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute)) transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute))
if err != nil || transaction.ReturnTo != "/workspace?tab=wallet" { if err != nil || transaction.ContextType != "platform" || transaction.ReturnTo != "/workspace?tab=wallet" {
t.Fatalf("transaction=%#v err=%v", transaction, err) t.Fatalf("transaction=%#v err=%v", transaction, err)
} }
if client.state == "" || client.nonce == "" || client.challenge == "" { if client.state == "" || client.nonce == "" || client.challenge == "" ||
client.contextType != "platform" {
t.Fatal("authorization redirect omitted state, nonce or PKCE challenge") t.Fatal("authorization redirect omitted state, nonce or PKCE challenge")
} }
} }
@@ -55,7 +57,7 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example", nil)) server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?contextType=platform&returnTo=https%3A%2F%2Fevil.example", nil))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" { if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie")) t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
} }
@@ -73,7 +75,7 @@ func TestStartOIDCLoginEncryptsAndForwardsMultiTenantHint(t *testing.T) {
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest( server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+tenantHint, nil, http.MethodGet, "/api/v1/auth/oidc/login?contextType=tenant&tenantHint="+tenantHint, nil,
)) ))
if recorder.Code != http.StatusSeeOther || client.tenantHint != tenantHint { if recorder.Code != http.StatusSeeOther || client.tenantHint != tenantHint {
t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint) t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint)
@@ -103,7 +105,7 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
} }
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest( server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+test.hint, nil, http.MethodGet, "/api/v1/auth/oidc/login?contextType=tenant&tenantHint="+test.hint, nil,
)) ))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" { if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie")) t.Fatalf("status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
@@ -112,6 +114,32 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
} }
} }
func TestStartOIDCLoginRejectsMissingOrIncompatibleContext(t *testing.T) {
for _, path := range []string{
"/api/v1/auth/oidc/login",
"/api/v1/auth/oidc/login?contextType=account",
"/api/v1/auth/oidc/login?contextType=platform&tenantHint=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
} {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}},
oidcClient: &fakeOIDCClient{}, oidcSessions: &fakeOIDCSessions{},
oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(
recorder,
httptest.NewRequest(http.MethodGet, path, nil),
)
if recorder.Code != http.StatusBadRequest ||
recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("path=%q status=%d cookie=%q", path, recorder.Code, recorder.Header().Get("Set-Cookie"))
}
}
}
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) { func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil { if err != nil {
@@ -511,12 +539,14 @@ func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
type fakeOIDCClient struct { type fakeOIDCClient struct {
authorizationURL string authorizationURL string
state, nonce, challenge string state, nonce, challenge string
contextType string
tenantHint string tenantHint string
revokedRefreshToken string revokedRefreshToken string
} }
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, tenantHint string) (string, error) { func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, contextType, tenantHint string) (string, error) {
f.state, f.nonce, f.challenge = state, nonce, challenge f.state, f.nonce, f.challenge = state, nonce, challenge
f.contextType = contextType
f.tenantHint = tenantHint f.tenantHint = tenantHint
return f.authorizationURL, nil return f.authorizationURL, nil
} }
@@ -70,13 +70,30 @@ func (s *Server) resolveOIDCUserProjectionForRuntime(ctx context.Context, r *htt
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable") return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
} }
revision := runtime.Revision revision := runtime.Revision
switch user.ContextType {
case "platform":
if revision.TenantMode != "multi_tenant" ||
strings.TrimSpace(user.TenantID) != "" {
return store.ResolveOrProvisionOIDCUserResult{},
errors.New("platform OIDC context is incompatible with the active identity revision")
}
case "tenant":
if strings.TrimSpace(user.TenantID) == "" {
return store.ResolveOrProvisionOIDCUserResult{},
errors.New("tenant OIDC context is missing its tenant")
}
default:
return store.ResolveOrProvisionOIDCUserResult{},
errors.New("OIDC context type is missing or invalid")
}
tenantName := "" tenantName := ""
tenantSlug := "" tenantSlug := ""
tenantMetadataStatus := "" tenantMetadataStatus := ""
tenantMetadataVersion := "" tenantMetadataVersion := ""
tenantMetadataETag := "" tenantMetadataETag := ""
var tenantMetadataUpdatedAt time.Time var tenantMetadataUpdatedAt time.Time
if revision.TenantMode == "multi_tenant" { if user.ContextType == "tenant" &&
revision.TenantMode == "multi_tenant" {
if runtime.TenantContext == nil { if runtime.TenantContext == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable") return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable")
} }
@@ -151,6 +168,7 @@ func (s *Server) resolveOIDCUserProjectionWithTenantContext(
Subject: user.ID, Subject: user.ID,
Username: user.Username, Username: user.Username,
Roles: user.Roles, Roles: user.Roles,
ContextType: user.ContextType,
TenantID: user.TenantID, TenantID: user.TenantID,
TenantMode: revision.TenantMode, TenantMode: revision.TenantMode,
TenantName: tenantName, TenantName: tenantName,
@@ -62,6 +62,7 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
ID: "platform-user", ID: "platform-user",
Username: "alice", Username: "alice",
Roles: []string{"basic"}, Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "external-tenant", TenantID: "external-tenant",
Source: "oidc", Source: "oidc",
GatewayUserID: "21dd9ccb-3793-4023-ab31-4d04982ca4d3", GatewayUserID: "21dd9ccb-3793-4023-ab31-4d04982ca4d3",
@@ -86,11 +87,12 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
}) })
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{ request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
ID: "platform-user", ID: "platform-user",
Username: "alice", Username: "alice",
Roles: []string{"basic"}, Roles: []string{"basic"},
TenantID: "external-tenant", ContextType: "tenant",
Source: "oidc", TenantID: "external-tenant",
Source: "oidc",
})) }))
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@@ -146,7 +148,8 @@ func TestResolveOIDCMultiTenantProjectionUsesRuntimeTenantContext(t *testing.T)
} }
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "shared-subject", TenantID: tenantID, Username: "alice", Roles: []string{"basic"}, ID: "shared-subject", ContextType: "tenant", TenantID: tenantID,
Username: "alice", Roles: []string{"basic"},
}, runtime) }, runtime)
if err != nil { if err != nil {
t.Fatalf("resolve multi-tenant projection: %v", err) t.Fatalf("resolve multi-tenant projection: %v", err)
@@ -159,6 +162,40 @@ func TestResolveOIDCMultiTenantProjectionUsesRuntimeTenantContext(t *testing.T)
} }
} }
func TestResolveOIDCPlatformProjectionSkipsTenantContext(t *testing.T) {
applicationID := "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8"
resolver := &fakeOIDCUserResolver{
result: store.ResolveOrProvisionOIDCUserResult{
User: &auth.User{GatewayUserID: "local-platform-user"},
},
}
server := &Server{oidcUserResolver: resolver}
runtime := &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: applicationID,
TenantMode: "multi_tenant", JITEnabled: true,
},
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(
request.Context(),
request,
&auth.User{
ID: "platform-subject", ContextType: "platform",
Username: "platform-admin", Roles: []string{"admin"},
},
runtime,
)
if err != nil {
t.Fatalf("resolve platform projection: %v", err)
}
if resolver.input.ContextType != "platform" ||
resolver.input.TenantID != "" ||
resolver.input.ApplicationID != applicationID {
t.Fatalf("unexpected platform projection input: %+v", resolver.input)
}
}
func TestResolveOIDCMultiTenantProjectionTreatsTemporaryContextFailureAsPending(t *testing.T) { func TestResolveOIDCMultiTenantProjectionTreatsTemporaryContextFailureAsPending(t *testing.T) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0" tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}} resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}}
@@ -172,7 +209,7 @@ func TestResolveOIDCMultiTenantProjectionTreatsTemporaryContextFailureAsPending(
} }
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID, ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, runtime); err != nil { }, runtime); err != nil {
t.Fatalf("temporary tenant context failure should reach fail-closed store projection: %v", err) t.Fatalf("temporary tenant context failure should reach fail-closed store projection: %v", err)
} }
@@ -206,7 +243,7 @@ func TestResolveOIDCMultiTenantProjectionUsesFreshLocalTenantCache(t *testing.T)
} }
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID, ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, runtime); err != nil { }, runtime); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -228,7 +265,7 @@ func TestResolveOIDCMultiTenantProjectionFallsBackToSyncedCacheOnTemporaryFailur
server := &Server{oidcUserResolver: resolver} server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID, ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, &identityRequestRuntime{ }, &identityRequestRuntime{
Revision: identity.Revision{ Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8", Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
@@ -257,7 +294,7 @@ func TestResolveOIDCMultiTenantProjectionRevalidatesDisabledBindingBeforeReassig
server := &Server{oidcUserResolver: resolver} server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID, ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, &identityRequestRuntime{ }, &identityRequestRuntime{
Revision: identity.Revision{ Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: applicationID, Issuer: "https://auth.test.example", ApplicationID: applicationID,
@@ -283,7 +320,8 @@ func TestResolveOIDCMultiTenantProjectionKeepsDisabledBindingClosedDuringContext
server := &Server{oidcUserResolver: resolver} server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0", ID: "subject", ContextType: "tenant",
TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0",
}, &identityRequestRuntime{ }, &identityRequestRuntime{
Revision: identity.Revision{ Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8", Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
@@ -313,7 +351,7 @@ func TestResolveOIDCMultiTenantProjectionRejectsMissingOrInactiveTenant(t *testi
server := &Server{oidcUserResolver: resolver} server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{ _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID, ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, &identityRequestRuntime{ }, &identityRequestRuntime{
Revision: identity.Revision{TenantMode: "multi_tenant"}, Revision: identity.Revision{TenantMode: "multi_tenant"},
TenantContext: test.reader, TenantContext: test.reader,
@@ -346,7 +384,8 @@ func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
} }
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{ request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
ID: "platform-user", Source: "oidc", TenantID: "external-tenant", ID: "platform-user", Source: "oidc",
ContextType: "tenant", TenantID: "external-tenant",
})) }))
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+1 -1
View File
@@ -52,7 +52,7 @@ type Server struct {
} }
type oidcPublicClient interface { type oidcPublicClient interface {
AuthorizationURL(context.Context, string, string, string, string) (string, error) AuthorizationURL(context.Context, string, string, string, string, string) (string, error)
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
VerifyIDToken(context.Context, string, string) (string, error) VerifyIDToken(context.Context, string, string) (string, error)
Refresh(context.Context, string) (auth.OIDCTokenResponse, error) Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
@@ -20,18 +20,37 @@ type LoginTransaction struct {
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
PKCEVerifier string `json:"pkceVerifier"` PKCEVerifier string `json:"pkceVerifier"`
ReturnTo string `json:"returnTo"` ReturnTo string `json:"returnTo"`
ContextType string `json:"contextType"`
TenantHint string `json:"tenantHint,omitempty"` TenantHint string `json:"tenantHint,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, error) { func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, error) {
return NewLoginTransactionWithTenantHint(returnTo, "", now) return NewLoginTransactionWithContext(returnTo, "tenant", "", now)
} }
func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Time) (LoginTransaction, error) { func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Time) (LoginTransaction, error) {
return NewLoginTransactionWithContext(returnTo, "tenant", tenantHint, now)
}
func NewLoginTransactionWithContext(
returnTo, contextType, tenantHint string,
now time.Time,
) (LoginTransaction, error) {
if !ValidReturnTo(returnTo) { if !ValidReturnTo(returnTo) {
return LoginTransaction{}, errors.New("returnTo must be a same-origin relative path") return LoginTransaction{}, errors.New("returnTo must be a same-origin relative path")
} }
contextType = strings.TrimSpace(contextType)
tenantHint = strings.TrimSpace(tenantHint)
if contextType != "platform" && contextType != "tenant" {
return LoginTransaction{}, errors.New("contextType must be platform or tenant")
}
if contextType == "platform" && tenantHint != "" {
return LoginTransaction{}, errors.New("platform context cannot bind a tenant hint")
}
if tenantHint != "" && uuid.Validate(tenantHint) != nil {
return LoginTransaction{}, errors.New("tenantHint must be a UUID")
}
state, err := randomBase64URL(32) state, err := randomBase64URL(32)
if err != nil { if err != nil {
return LoginTransaction{}, err return LoginTransaction{}, err
@@ -46,7 +65,8 @@ func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Tim
} }
return LoginTransaction{ return LoginTransaction{
State: state, Nonce: nonce, PKCEVerifier: verifier, State: state, Nonce: nonce, PKCEVerifier: verifier,
ReturnTo: returnTo, TenantHint: strings.TrimSpace(tenantHint), CreatedAt: now.UTC(), ReturnTo: returnTo, ContextType: contextType,
TenantHint: tenantHint, CreatedAt: now.UTC(),
}, nil }, nil
} }
@@ -68,6 +88,8 @@ func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTra
return LoginTransaction{}, err return LoginTransaction{}, err
} }
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) || if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
transaction.ContextType != "platform" && transaction.ContextType != "tenant" ||
transaction.ContextType == "platform" && transaction.TenantHint != "" ||
transaction.TenantHint != "" && uuid.Validate(transaction.TenantHint) != nil || transaction.TenantHint != "" && uuid.Validate(transaction.TenantHint) != nil ||
transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) { transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) {
return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid") return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid")
@@ -37,7 +37,7 @@ func TestLoginTransactionEncryptsTenantHint(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
cipher, _ := NewCipher(bytes.Repeat([]byte{9}, 32)) cipher, _ := NewCipher(bytes.Repeat([]byte{9}, 32))
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
transaction, err := NewLoginTransactionWithTenantHint("/", tenantHint, now) transaction, err := NewLoginTransactionWithContext("/", "tenant", tenantHint, now)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -49,11 +49,30 @@ func TestLoginTransactionEncryptsTenantHint(t *testing.T) {
t.Fatal("login transaction cookie contains plaintext tenant hint") t.Fatal("login transaction cookie contains plaintext tenant hint")
} }
decoded, err := cipher.DecodeLoginTransaction(encoded, now) decoded, err := cipher.DecodeLoginTransaction(encoded, now)
if err != nil || decoded.TenantHint != tenantHint { if err != nil || decoded.ContextType != "tenant" || decoded.TenantHint != tenantHint {
t.Fatalf("decoded transaction=%+v err=%v", decoded, err) t.Fatalf("decoded transaction=%+v err=%v", decoded, err)
} }
} }
func TestLoginTransactionRejectsInvalidContextBinding(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
for _, test := range []struct {
contextType string
tenantHint string
}{
{contextType: ""},
{contextType: "account"},
{contextType: "platform", tenantHint: tenantHint},
} {
if _, err := NewLoginTransactionWithContext(
"/", test.contextType, test.tenantHint, now,
); err == nil {
t.Fatalf("unsafe context binding accepted: %#v", test)
}
}
}
func TestValidReturnToRejectsOpenRedirects(t *testing.T) { func TestValidReturnToRejectsOpenRedirects(t *testing.T) {
for _, value := range []string{"https://evil.example", "//evil.example", "/\\evil", "", "workspace"} { for _, value := range []string{"https://evil.example", "//evil.example", "/\\evil", "", "workspace"} {
if ValidReturnTo(value) { if ValidReturnTo(value) {
+19 -4
View File
@@ -120,6 +120,13 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut
if verified.ID != localUser.ID { if verified.ID != localUser.ID {
return "", newSessionCreationError(sessionCreationIdentityMismatch, ErrSessionInvalid) return "", newSessionCreationError(sessionCreationIdentityMismatch, ErrSessionInvalid)
} }
if verified.ContextType != localUser.ContextType ||
verified.TenantID != localUser.TenantID {
return "", newSessionCreationError(
sessionCreationIdentityMismatch,
ErrSessionInvalid,
)
}
if localUser.OIDCUserBindingID != "" && if localUser.OIDCUserBindingID != "" &&
(localUser.Issuer == "" || localUser.ApplicationID == "" || localUser.TenantID == "" || localUser.OIDCClientID == "" || (localUser.Issuer == "" || localUser.ApplicationID == "" || localUser.TenantID == "" || localUser.OIDCClientID == "" ||
verified.Issuer != localUser.Issuer || verified.ApplicationID != localUser.ApplicationID || verified.Issuer != localUser.Issuer || verified.ApplicationID != localUser.ApplicationID ||
@@ -142,7 +149,9 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut
_, err = s.repository.CreateOIDCSession(ctx, store.CreateOIDCSessionInput{ _, err = s.repository.CreateOIDCSession(ctx, store.CreateOIDCSessionInput{
SessionTokenHash: hash, GatewayUserID: localUser.GatewayUserID, GatewayTenantID: localUser.GatewayTenantID, SessionTokenHash: hash, GatewayUserID: localUser.GatewayUserID, GatewayTenantID: localUser.GatewayTenantID,
OIDCUserBindingID: localUser.OIDCUserBindingID, OIDCClientID: verified.OIDCClientID, OIDCUserBindingID: localUser.OIDCUserBindingID, OIDCClientID: verified.OIDCClientID,
Issuer: verified.Issuer, ApplicationID: verified.ApplicationID, TenantID: verified.TenantID, Issuer: verified.Issuer, ApplicationID: verified.ApplicationID,
ContextType: verified.ContextType, Subject: verified.ID,
TenantID: verified.TenantID,
TokenCiphertext: ciphertext, AccessTokenExpiresAt: verified.TokenExpiresAt, TokenCiphertext: ciphertext, AccessTokenExpiresAt: verified.TokenExpiresAt,
LastSeenAt: now, IdleExpiresAt: now.Add(s.config.IdleTTL), AbsoluteExpiresAt: now.Add(s.config.AbsoluteTTL), LastSeenAt: now, IdleExpiresAt: now.Add(s.config.IdleTTL), AbsoluteExpiresAt: now.Add(s.config.AbsoluteTTL),
}) })
@@ -310,9 +319,15 @@ func (s *Service) verifySessionUser(ctx context.Context, record store.OIDCSessio
if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID { if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID {
return nil, ErrSessionInvalid return nil, ErrSessionInvalid
} }
if record.OIDCUserBindingID != "" && identityBound := record.ContextType != "" ||
(user.Issuer != record.Issuer || user.ApplicationID != record.ApplicationID || record.OIDCUserBindingID != ""
user.TenantID != record.TenantID || user.OIDCClientID != record.OIDCClientID) { if identityBound &&
(user.Issuer != record.Issuer ||
user.ApplicationID != record.ApplicationID ||
user.TenantID != record.TenantID ||
user.OIDCClientID != record.OIDCClientID ||
record.ContextType != "" &&
user.ContextType != record.ContextType) {
return nil, ErrSessionInvalid return nil, ErrSessionInvalid
} }
return user, nil return user, nil
+54 -2
View File
@@ -292,6 +292,53 @@ func TestServiceBindsMultiTenantSessionToIssuerApplicationTenantAndClient(t *tes
} }
} }
func TestServiceBindsPlatformSessionToExplicitContextWithoutTenant(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
applicationID := "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
verified := &auth.User{
ID: "platform-subject", Source: "oidc",
Issuer: "https://auth.example.test",
ApplicationID: applicationID, ContextType: "platform",
OIDCClientID: "gateway-browser",
TokenExpiresAt: now.Add(5 * time.Minute),
}
repository := newFakeRepository("platform-subject")
service := newTestService(
t,
repository,
fakeVerifier{users: map[string]*auth.User{"access": verified}},
&fakePublicClient{},
)
service.now = func() time.Time { return now }
raw, err := service.Create(
context.Background(),
TokenBundle{AccessToken: "access", RefreshToken: "refresh"},
&auth.User{
ID: "platform-subject",
GatewayUserID: "11111111-1111-4111-8111-111111111111",
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
ContextType: "platform",
},
)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
record := repository.snapshot()
if record.ContextType != "platform" || record.TenantID != "" ||
record.Issuer != verified.Issuer ||
record.ApplicationID != applicationID {
t.Fatalf("platform session record=%#v", record)
}
verified.ContextType = "tenant"
verified.TenantID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
if _, err := service.Resolve(
context.Background(),
raw,
); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("Resolve() error = %v, want ErrSessionInvalid", err)
}
}
func TestServiceCreateReportsSafeFailureCategory(t *testing.T) { func TestServiceCreateReportsSafeFailureCategory(t *testing.T) {
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
validVerified := &auth.User{ validVerified := &auth.User{
@@ -445,11 +492,16 @@ func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.Create
if f.createError != nil { if f.createError != nil {
return store.OIDCSession{}, f.createError return store.OIDCSession{}, f.createError
} }
external := input.Subject
if external == "" {
external = f.external
}
f.record = store.OIDCSession{ f.record = store.OIDCSession{
ID: "33333333-3333-4333-8333-333333333333", SessionTokenHash: append([]byte(nil), input.SessionTokenHash...), ID: "33333333-3333-4333-8333-333333333333", SessionTokenHash: append([]byte(nil), input.SessionTokenHash...),
GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: f.external, GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: external,
OIDCUserBindingID: input.OIDCUserBindingID, OIDCClientID: input.OIDCClientID, OIDCUserBindingID: input.OIDCUserBindingID, OIDCClientID: input.OIDCClientID,
Issuer: input.Issuer, ApplicationID: input.ApplicationID, TenantID: input.TenantID, Issuer: input.Issuer, ApplicationID: input.ApplicationID,
ContextType: input.ContextType, TenantID: input.TenantID,
UserStatus: "active", TokenCiphertext: append([]byte(nil), input.TokenCiphertext...), AccessTokenExpiresAt: input.AccessTokenExpiresAt, UserStatus: "active", TokenCiphertext: append([]byte(nil), input.TokenCiphertext...), AccessTokenExpiresAt: input.AccessTokenExpiresAt,
LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1, LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1,
} }
@@ -0,0 +1,247 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const oidcPlatformTenantKey = "default"
func (s *Store) resolveOrProvisionOIDCPlatformUser(
ctx context.Context,
input ResolveOrProvisionOIDCUserInput,
) (ResolveOrProvisionOIDCUserResult, error) {
if input.Issuer == "" || input.Subject == "" ||
input.ContextType != "platform" ||
input.TenantMode != "multi_tenant" ||
uuid.Validate(input.ApplicationID) != nil ||
input.TenantID != "" {
return ResolveOrProvisionOIDCUserResult{},
errors.New("invalid platform OIDC user projection input")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
defer rollbackTransaction(tx)
userKey := deriveOIDCPlatformUserKey(
input.Issuer,
input.ApplicationID,
input.Subject,
)
user, groupKey, err := loadOIDCPlatformUser(ctx, tx, userKey)
created := false
if errors.Is(err, pgx.ErrNoRows) {
if !input.ProvisioningEnabled {
return ResolveOrProvisionOIDCUserResult{},
ErrOIDCUserNotProvisioned
}
user, groupKey, created, err = s.createOIDCPlatformUser(
ctx,
tx,
userKey,
input,
)
}
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if user.Status != "active" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
}
rolesJSON, err := json.Marshal(input.Roles)
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,
last_login_at=now(),synced_at=now(),source_updated_at=now(),
updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2_platform'
AND status='active' AND deleted_at IS NULL
RETURNING `+userColumns,
user.ID,
input.Username,
string(rolesJSON),
))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
}
return ResolveOrProvisionOIDCUserResult{}, err
}
var auditID string
if created {
subjectHash := sha256.Sum256([]byte(input.Subject))
audit, auditErr := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity",
Action: "identity.oidc_platform_user.provisioned",
ActorGatewayUserID: user.ID,
ActorUsername: user.Username,
ActorSource: "oidc",
ActorRoles: user.Roles,
TargetType: "gateway_user",
TargetID: user.ID,
TargetGatewayUserID: user.ID,
TargetGatewayTenantID: user.GatewayTenantID,
RequestIP: input.RequestIP,
UserAgent: input.UserAgent,
AfterState: map[string]any{
"source": "oidc_v2_platform",
"contextType": "platform",
"tenantKey": user.TenantKey,
"userGroupId": user.DefaultUserGroupID,
},
Metadata: map[string]any{
"provisioningMode": "oidc-platform-jit",
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
},
})
if auditErr != nil {
return ResolveOrProvisionOIDCUserResult{}, auditErr
}
auditID = audit.ID
}
if err := tx.Commit(ctx); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
return ResolveOrProvisionOIDCUserResult{
User: platformAuthUser(user, groupKey, input),
Created: created,
AuditID: auditID,
}, nil
}
func loadOIDCPlatformUser(
ctx context.Context,
tx pgx.Tx,
userKey string,
) (GatewayUser, string, error) {
var userID string
if err := tx.QueryRow(ctx, `SELECT id::text
FROM gateway_users
WHERE user_key=$1 AND source='oidc_v2_platform'
FOR UPDATE`, userKey).Scan(&userID); err != nil {
return GatewayUser{}, "", err
}
user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+`
FROM gateway_users WHERE id=$1::uuid`, userID))
if err != nil {
return GatewayUser{}, "", err
}
var groupKey string
if err := tx.QueryRow(ctx, `SELECT group_record.group_key
FROM gateway_users user_record
JOIN gateway_tenants tenant
ON tenant.id=user_record.gateway_tenant_id
AND tenant.status='active' AND tenant.deleted_at IS NULL
JOIN gateway_user_groups group_record
ON group_record.id=user_record.default_user_group_id
AND group_record.status='active'
WHERE user_record.id=$1::uuid`, userID).Scan(&groupKey); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return GatewayUser{}, "", ErrOIDCTenantUnavailable
}
return GatewayUser{}, "", err
}
return user, groupKey, nil
}
func (s *Store) createOIDCPlatformUser(
ctx context.Context,
tx pgx.Tx,
userKey string,
input ResolveOrProvisionOIDCUserInput,
) (GatewayUser, string, bool, error) {
gatewayTenantID, groupID, groupKey, err :=
loadOIDCProvisioningTenant(ctx, tx, oidcPlatformTenantKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return GatewayUser{}, "", false, ErrOIDCTenantUnavailable
}
return GatewayUser{}, "", false, err
}
rolesJSON, err := json.Marshal(input.Roles)
if err != nil {
return GatewayUser{}, "", false, err
}
metadataJSON, err := json.Marshal(map[string]any{
"provisioningMode": "oidc-platform-jit",
"contextType": "platform",
"applicationId": input.ApplicationID,
})
if err != nil {
return GatewayUser{}, "", false, err
}
username := input.Username
if username == "" {
username = "oidc-platform-" +
strings.TrimPrefix(userKey, "oidc2-platform:")[:12]
}
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
user_key,source,external_user_id,username,gateway_tenant_id,
tenant_id,tenant_key,default_user_group_id,roles,auth_profile,
metadata,status,last_login_at,synced_at,source_updated_at
) VALUES(
$1,'oidc_v2_platform',NULL,$2,$3::uuid,NULL,$4,$5::uuid,
$6::jsonb,'{}'::jsonb,$7::jsonb,'active',now(),now(),now()
)
ON CONFLICT(user_key) DO NOTHING
RETURNING `+userColumns,
userKey,
username,
gatewayTenantID,
oidcPlatformTenantKey,
groupID,
string(rolesJSON),
string(metadataJSON),
))
if errors.Is(err, pgx.ErrNoRows) {
user, groupKey, err = loadOIDCPlatformUser(ctx, tx, userKey)
return user, groupKey, false, err
}
if err != nil {
return GatewayUser{}, "", false, err
}
if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil {
return GatewayUser{}, "", false, err
}
return user, groupKey, true, nil
}
func deriveOIDCPlatformUserKey(
issuer string,
applicationID string,
subject string,
) string {
sum := sha256.Sum256([]byte(
strings.TrimRight(issuer, "/") + "\x00" +
applicationID + "\x00" + subject,
))
return fmt.Sprintf("oidc2-platform:%x", sum)
}
func platformAuthUser(
user GatewayUser,
groupKey string,
input ResolveOrProvisionOIDCUserInput,
) *auth.User {
result := authUserFromOIDCProjection(user, groupKey)
result.ID = input.Subject
result.ContextType = "platform"
result.TenantID = ""
result.Issuer = input.Issuer
result.ApplicationID = input.ApplicationID
result.OIDCClientID = input.OIDCClientID
return result
}
@@ -0,0 +1,60 @@
package store
import "testing"
func TestOIDCProjectionKindRequiresExplicitCompatibleContext(t *testing.T) {
applicationID := "11111111-1111-4111-8111-111111111111"
tests := []struct {
name string
input ResolveOrProvisionOIDCUserInput
want string
}{
{
name: "platform in multi-tenant application",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "platform", TenantMode: "multi_tenant",
ApplicationID: applicationID,
},
want: "platform",
},
{
name: "tenant in multi-tenant application",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "tenant", TenantMode: "multi_tenant",
ApplicationID: applicationID,
TenantID: "22222222-2222-4222-8222-222222222222",
},
want: "multi_tenant",
},
{
name: "tenant in single-tenant application",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "tenant", TenantMode: "single_tenant",
TenantID: "tenant-contract-id",
},
want: "single_tenant",
},
{
name: "missing context",
input: ResolveOrProvisionOIDCUserInput{
TenantMode: "multi_tenant", ApplicationID: applicationID,
TenantID: "22222222-2222-4222-8222-222222222222",
},
},
{
name: "platform context with tenant",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "platform", TenantMode: "multi_tenant",
ApplicationID: applicationID,
TenantID: "22222222-2222-4222-8222-222222222222",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := oidcProjectionKind(test.input); got != test.want {
t.Fatalf("projection kind=%q, want %q", got, test.want)
}
})
}
}
+44 -13
View File
@@ -19,6 +19,7 @@ type OIDCSession struct {
ExternalUserID string ExternalUserID string
Issuer string Issuer string
ApplicationID string ApplicationID string
ContextType string
TenantID string TenantID string
OIDCClientID string OIDCClientID string
UserStatus string UserStatus string
@@ -43,6 +44,8 @@ type CreateOIDCSessionInput struct {
OIDCClientID string OIDCClientID string
Issuer string Issuer string
ApplicationID string ApplicationID string
ContextType string
Subject string
TenantID string TenantID string
TokenCiphertext []byte TokenCiphertext []byte
AccessTokenExpiresAt time.Time AccessTokenExpiresAt time.Time
@@ -57,26 +60,51 @@ func (s *Store) CreateOIDCSession(ctx context.Context, input CreateOIDCSessionIn
INSERT INTO gateway_oidc_sessions ( INSERT INTO gateway_oidc_sessions (
session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext, session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext,
access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at, access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at,
oidc_user_binding_id, oidc_client_id oidc_user_binding_id, oidc_client_id, issuer, application_id,
context_type, subject, tenant_id
) )
SELECT $1, u.id, $3::uuid, $4, $5, $6, $7, $8, NULLIF($9, '')::uuid, NULLIF($10, '') SELECT $1, u.id, $3::uuid, $4, $5, $6, $7, $8,
NULLIF($9, '')::uuid, NULLIF($10, ''), $11, $12, $13, $14,
NULLIF($15, '')
FROM gateway_users u FROM gateway_users u
LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = NULLIF($9, '')::uuid LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = NULLIF($9, '')::uuid
LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id
WHERE u.id = $2::uuid WHERE u.id = $2::uuid
AND u.gateway_tenant_id = $3::uuid AND u.gateway_tenant_id = $3::uuid
AND ($9 = '' OR ( AND (
ub.gateway_user_id = u.id (
AND tb.gateway_tenant_id = u.gateway_tenant_id $13 = 'platform'
AND tb.issuer = $11 AND $9 = ''
AND tb.application_id = $12 AND $15 = ''
AND tb.external_tenant_id = $13 AND u.source = 'oidc_v2_platform'
)) )
OR
(
$13 = 'tenant'
AND (
(
$9 = ''
AND u.source = 'oidc'
AND u.external_user_id = $14
)
OR
(
ub.gateway_user_id = u.id
AND ub.subject = $14
AND tb.gateway_tenant_id = u.gateway_tenant_id
AND tb.issuer = $11
AND tb.application_id = $12
AND tb.external_tenant_id = $15
)
)
)
)
RETURNING id::text`, RETURNING id::text`,
input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext, input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext,
input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt, input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt,
input.OIDCUserBindingID, input.OIDCClientID, input.OIDCUserBindingID, input.OIDCClientID,
input.Issuer, input.ApplicationID, input.TenantID, input.Issuer, input.ApplicationID, input.ContextType,
input.Subject, input.TenantID,
).Scan(&id) ).Scan(&id)
if err != nil { if err != nil {
return OIDCSession{}, err return OIDCSession{}, err
@@ -165,8 +193,10 @@ WHERE idle_expires_at <= $1 OR absolute_expires_at <= $1`, now)
const oidcSessionColumns = ` const oidcSessionColumns = `
s.id::text, s.session_token_hash, s.gateway_user_id::text, s.gateway_tenant_id::text, s.id::text, s.session_token_hash, s.gateway_user_id::text, s.gateway_tenant_id::text,
COALESCE(s.oidc_user_binding_id::text, ''), COALESCE(ub.subject, u.external_user_id, ''), COALESCE(s.oidc_user_binding_id::text, ''),
COALESCE(tb.issuer, ''), COALESCE(tb.application_id, ''), COALESCE(tb.external_tenant_id, ''), COALESCE(s.subject, ub.subject, u.external_user_id, ''),
COALESCE(s.issuer, tb.issuer, ''), COALESCE(s.application_id, tb.application_id, ''),
COALESCE(s.context_type, ''), COALESCE(s.tenant_id, tb.external_tenant_id, ''),
COALESCE(s.oidc_client_id, ''), u.status, u.deleted_at IS NOT NULL, COALESCE(s.oidc_client_id, ''), u.status, u.deleted_at IS NOT NULL,
s.token_ciphertext, s.access_token_expires_at, s.last_seen_at, s.idle_expires_at, s.token_ciphertext, s.access_token_expires_at, s.last_seen_at, s.idle_expires_at,
s.absolute_expires_at, s.refresh_version, COALESCE(s.refresh_lock_id::text, ''), s.absolute_expires_at, s.refresh_version, COALESCE(s.refresh_lock_id::text, ''),
@@ -177,7 +207,8 @@ func scanOIDCSession(row pgx.Row) (OIDCSession, error) {
err := row.Scan( err := row.Scan(
&item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID, &item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID,
&item.OIDCUserBindingID, &item.ExternalUserID, &item.Issuer, &item.ApplicationID, &item.OIDCUserBindingID, &item.ExternalUserID, &item.Issuer, &item.ApplicationID,
&item.TenantID, &item.OIDCClientID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext, &item.ContextType, &item.TenantID, &item.OIDCClientID,
&item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
&item.AccessTokenExpiresAt, &item.LastSeenAt, &item.IdleExpiresAt, &item.AbsoluteExpiresAt, &item.AccessTokenExpiresAt, &item.LastSeenAt, &item.IdleExpiresAt, &item.AbsoluteExpiresAt,
&item.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt, &item.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt,
) )
+38 -1
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
@@ -26,6 +27,7 @@ type ResolveOrProvisionOIDCUserInput struct {
Subject string Subject string
Username string Username string
Roles []string Roles []string
ContextType string
TenantID string TenantID string
TenantMode string TenantMode string
TenantName string TenantName string
@@ -58,8 +60,16 @@ type oidcUserProjection struct {
func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) { func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
input = normalizeOIDCUserInput(input) input = normalizeOIDCUserInput(input)
if input.TenantMode == "multi_tenant" { switch oidcProjectionKind(input) {
case "platform":
return s.resolveOrProvisionOIDCPlatformUser(ctx, input)
case "multi_tenant":
return s.resolveOrProvisionOIDCMultiTenantUser(ctx, input) return s.resolveOrProvisionOIDCMultiTenantUser(ctx, input)
case "single_tenant":
// Continue through the fixed local Tenant projection below.
default:
return ResolveOrProvisionOIDCUserResult{},
errors.New("invalid OIDC user context")
} }
if input.Issuer == "" || input.Subject == "" || input.TenantID == "" { if input.Issuer == "" || input.Subject == "" || input.TenantID == "" {
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input") return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input")
@@ -187,6 +197,31 @@ RETURNING `+userColumns,
}, nil }, nil
} }
func oidcProjectionKind(input ResolveOrProvisionOIDCUserInput) string {
switch input.ContextType {
case "platform":
if input.TenantMode == "multi_tenant" &&
uuid.Validate(input.ApplicationID) == nil &&
input.TenantID == "" {
return "platform"
}
case "tenant":
if input.TenantID == "" {
return ""
}
switch input.TenantMode {
case "multi_tenant":
if uuid.Validate(input.ApplicationID) == nil &&
uuid.Validate(input.TenantID) == nil {
return "multi_tenant"
}
case "single_tenant":
return "single_tenant"
}
}
return ""
}
func (s *Store) syncExistingOIDCUser(ctx context.Context, tx pgx.Tx, projection oidcUserProjection, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) { func (s *Store) syncExistingOIDCUser(ctx context.Context, tx pgx.Tx, projection oidcUserProjection, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
if projection.userDeleted || projection.user.Status != "active" { if projection.userDeleted || projection.user.Status != "active" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
@@ -308,6 +343,7 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/") input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
input.Subject = strings.TrimSpace(input.Subject) input.Subject = strings.TrimSpace(input.Subject)
input.Username = strings.TrimSpace(input.Username) input.Username = strings.TrimSpace(input.Username)
input.ContextType = strings.TrimSpace(input.ContextType)
input.TenantID = strings.TrimSpace(input.TenantID) input.TenantID = strings.TrimSpace(input.TenantID)
input.ApplicationID = strings.TrimSpace(input.ApplicationID) input.ApplicationID = strings.TrimSpace(input.ApplicationID)
input.TenantMode = strings.TrimSpace(input.TenantMode) input.TenantMode = strings.TrimSpace(input.TenantMode)
@@ -355,6 +391,7 @@ func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.Use
ID: user.ExternalUserID, ID: user.ExternalUserID,
Username: user.Username, Username: user.Username,
Roles: user.Roles, Roles: user.Roles,
ContextType: "tenant",
TenantID: user.TenantID, TenantID: user.TenantID,
GatewayTenantID: user.GatewayTenantID, GatewayTenantID: user.GatewayTenantID,
TenantKey: user.TenantKey, TenantKey: user.TenantKey,
@@ -2,8 +2,10 @@ package store
import ( import (
"context" "context"
"crypto/sha256"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"runtime" "runtime"
"sort" "sort"
"strings" "strings"
@@ -35,7 +37,8 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput { input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
return ResolveOrProvisionOIDCUserInput{ return ResolveOrProvisionOIDCUserInput{
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject", Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant", Roles: []string{"basic"}, ContextType: "tenant",
TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced", TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`, TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(), TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
@@ -173,7 +176,9 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
Subject: subject, Subject: subject,
Username: "jit-user-" + suffix, Username: "jit-user-" + suffix,
Roles: []string{"basic"}, Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "auth-center-test-tenant", TenantID: "auth-center-test-tenant",
TenantMode: "single_tenant",
GatewayTenantKey: "default", GatewayTenantKey: "default",
ProvisioningEnabled: true, ProvisioningEnabled: true,
} }
@@ -299,6 +304,120 @@ FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &ma
} }
} }
func TestResolveOrProvisionOIDCPlatformUserUsesExplicitContextWithoutExternalTenant(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC JIT PostgreSQL integration tests")
}
ctx := context.Background()
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
suffix := time.Now().UTC().Format("20060102150405.000000000")
subject := "platform-context-" + suffix
applicationID := uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `
DELETE FROM gateway_audit_logs
WHERE target_gateway_user_id IN (
SELECT id FROM gateway_users
WHERE source='oidc_v2_platform'
AND user_key=$1
)`, deriveOIDCPlatformUserKey(
"https://auth.test.example/issuer/shared",
applicationID,
subject,
))
_, _ = db.pool.Exec(context.Background(), `
DELETE FROM gateway_users
WHERE source='oidc_v2_platform'
AND user_key=$1`, deriveOIDCPlatformUserKey(
"https://auth.test.example/issuer/shared",
applicationID,
subject,
))
})
result, err := db.ResolveOrProvisionOIDCUser(
ctx,
ResolveOrProvisionOIDCUserInput{
Issuer: "https://auth.test.example/issuer/shared",
ApplicationID: applicationID,
Subject: subject,
Username: "platform-user-" + suffix,
Roles: []string{"admin"},
ContextType: "platform",
TenantMode: "multi_tenant",
ProvisioningEnabled: true,
},
)
if err != nil {
t.Fatalf("resolve platform user: %v", err)
}
if !result.Created || result.User == nil ||
result.User.ID != subject ||
result.User.ContextType != "platform" ||
result.User.TenantID != "" ||
result.User.TenantKey != oidcPlatformTenantKey ||
result.User.GatewayTenantID == "" ||
result.AuditID == "" {
t.Fatalf("platform projection=%#v", result)
}
now := time.Now().UTC()
sessionHash := sha256.Sum256([]byte("platform-session-" + suffix))
session, err := db.CreateOIDCSession(ctx, CreateOIDCSessionInput{
SessionTokenHash: sessionHash[:],
GatewayUserID: result.User.GatewayUserID,
GatewayTenantID: result.User.GatewayTenantID,
OIDCClientID: "gateway-browser",
Issuer: result.User.Issuer,
ApplicationID: applicationID,
ContextType: "platform",
Subject: subject,
TokenCiphertext: []byte("encrypted-test-token"),
AccessTokenExpiresAt: now.Add(time.Hour),
LastSeenAt: now,
IdleExpiresAt: now.Add(time.Hour),
AbsoluteExpiresAt: now.Add(2 * time.Hour),
})
if err != nil {
t.Fatalf("create platform session: %v", err)
}
if session.ContextType != "platform" ||
session.ExternalUserID != subject ||
session.Issuer != result.User.Issuer ||
session.ApplicationID != applicationID ||
session.TenantID != "" ||
session.OIDCUserBindingID != "" {
t.Fatalf("platform session=%#v", session)
}
repeated, err := db.ResolveOrProvisionOIDCUser(
ctx,
ResolveOrProvisionOIDCUserInput{
Issuer: "https://auth.test.example/issuer/shared",
ApplicationID: applicationID,
Subject: subject,
Username: "platform-user-renamed-" + suffix,
Roles: []string{"viewer"},
ContextType: "platform",
TenantMode: "multi_tenant",
ProvisioningEnabled: false,
},
)
if err != nil || repeated.Created ||
repeated.User.GatewayUserID != result.User.GatewayUserID ||
!reflect.DeepEqual(repeated.User.Roles, []string{"viewer"}) {
t.Fatalf("repeated platform projection=%#v err=%v", repeated, err)
}
}
func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing.T) { func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" { if databaseURL == "" {
@@ -318,7 +437,9 @@ func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing
Subject: subject, Subject: subject,
Username: "missing-user", Username: "missing-user",
Roles: []string{"basic"}, Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "auth-center-test-tenant", TenantID: "auth-center-test-tenant",
TenantMode: "single_tenant",
GatewayTenantKey: "missing-tenant-key", GatewayTenantKey: "missing-tenant-key",
} }
@@ -204,7 +204,8 @@ func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *test
projection := func(tenantID string) *auth.User { projection := func(tenantID string) *auth.User {
result, err := db.ResolveOrProvisionOIDCUser(ctx, ResolveOrProvisionOIDCUserInput{ result, err := db.ResolveOrProvisionOIDCUser(ctx, ResolveOrProvisionOIDCUserInput{
Issuer: subjectIssuer, ApplicationID: applicationID, Subject: subject, Username: "shared-user", Issuer: subjectIssuer, ApplicationID: applicationID, Subject: subject, Username: "shared-user",
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant", Roles: []string{"basic"}, ContextType: "tenant",
TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: "Tenant " + tenantID[:8], TenantSlug: "tenant-" + tenantID[:8], TenantName: "Tenant " + tenantID[:8], TenantSlug: "tenant-" + tenantID[:8],
TenantMetadataStatus: "synced", TenantMetadataVersion: "1", TenantMetadataStatus: "synced", TenantMetadataVersion: "1",
TenantMetadataUpdatedAt: time.Now().UTC(), OIDCClientID: "gateway-browser", ProvisioningEnabled: true, TenantMetadataUpdatedAt: time.Now().UTC(), OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
@@ -225,6 +226,7 @@ func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *test
SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID, SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID,
GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID, GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID,
OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID, OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID,
ContextType: "tenant", Subject: user.ID,
TenantID: user.TenantID, TokenCiphertext: []byte{marker}, TenantID: user.TenantID, TokenCiphertext: []byte{marker},
AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now, AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now,
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour), IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour),
@@ -0,0 +1,40 @@
ALTER TABLE gateway_oidc_sessions
ADD COLUMN IF NOT EXISTS context_type text,
ADD COLUMN IF NOT EXISTS issuer text,
ADD COLUMN IF NOT EXISTS application_id text,
ADD COLUMN IF NOT EXISTS subject text,
ADD COLUMN IF NOT EXISTS tenant_id text;
UPDATE gateway_oidc_sessions session_record
SET context_type = 'tenant',
issuer = binding.issuer,
application_id = binding.application_id,
subject = user_binding.subject,
tenant_id = binding.external_tenant_id
FROM gateway_oidc_user_bindings user_binding
JOIN gateway_oidc_tenant_bindings binding
ON binding.id = user_binding.tenant_binding_id
WHERE session_record.oidc_user_binding_id = user_binding.id
AND session_record.context_type IS NULL;
ALTER TABLE gateway_oidc_sessions
DROP CONSTRAINT IF EXISTS gateway_oidc_sessions_context_identity;
ALTER TABLE gateway_oidc_sessions
ADD CONSTRAINT gateway_oidc_sessions_context_identity CHECK (
context_type IS NULL
OR (
context_type IN ('platform', 'tenant')
AND issuer IS NOT NULL
AND issuer <> ''
AND application_id IS NOT NULL
AND application_id <> ''
AND subject IS NOT NULL
AND subject <> ''
AND (
(context_type = 'platform' AND tenant_id IS NULL)
OR
(context_type = 'tenant' AND tenant_id IS NOT NULL AND tenant_id <> '')
)
)
);
+16 -4
View File
@@ -135,6 +135,7 @@ import {
loadOIDCRuntimeConfiguration, loadOIDCRuntimeConfiguration,
startOIDCLogin, startOIDCLogin,
startOIDCLogout, startOIDCLogout,
type OIDCContextType,
} from './lib/oidc'; } from './lib/oidc';
import { runTask, type RunTaskOptions } from './lib/run-task'; import { runTask, type RunTaskOptions } from './lib/run-task';
import { AdminPage } from './pages/AdminPage'; import { AdminPage } from './pages/AdminPage';
@@ -280,6 +281,7 @@ export function App() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError()); const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
const [oidcEnabled, setOIDCEnabled] = useState(false); const [oidcEnabled, setOIDCEnabled] = useState(false);
const [oidcContextTypes, setOIDCContextTypes] = useState<OIDCContextType[]>([]);
const currentCredentialRef = useRef(token); const currentCredentialRef = useRef(token);
const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined); const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined);
currentCredentialRef.current = token; currentCredentialRef.current = token;
@@ -338,6 +340,7 @@ export function App() {
if (cancelled) return; if (cancelled) return;
const identityEnabled = configuration.enabled && configuration.oidcLogin; const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled); setOIDCEnabled(identityEnabled);
setOIDCContextTypes(configuration.contextTypes);
if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) { if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) {
await reconcileOIDCBrowserSessionAfterRuntimeChange({ await reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: currentCredentialRef.current, credential: currentCredentialRef.current,
@@ -1304,11 +1307,14 @@ export function App() {
navigatePath('/'); navigatePath('/');
} }
function loginWithOIDC() { function loginWithOIDC(
contextType: OIDCContextType =
oidcContextTypes.includes('platform') ? 'platform' : 'tenant',
) {
setState('loading'); setState('loading');
setError(''); setError('');
setOIDCCallbackError(null); setOIDCCallbackError(null);
void startOIDCLogin().catch((err) => { void startOIDCLogin(contextType).catch((err) => {
setState('error'); setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败'); setError(err instanceof Error ? err.message : '统一认证登录失败');
}); });
@@ -1321,9 +1327,13 @@ export function App() {
const configuration = await loadOIDCRuntimeConfiguration(true); const configuration = await loadOIDCRuntimeConfiguration(true);
const identityEnabled = configuration.enabled && configuration.oidcLogin; const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled); setOIDCEnabled(identityEnabled);
if (loginEntryAction(identityEnabled) === 'oidc') { setOIDCContextTypes(configuration.contextTypes);
if (
loginEntryAction(identityEnabled) === 'oidc' &&
configuration.contextTypes.length === 1
) {
setOIDCCallbackError(null); setOIDCCallbackError(null);
await startOIDCLogin(configuration); await startOIDCLogin(configuration.contextTypes[0], configuration);
return; return;
} }
setState('idle'); setState('idle');
@@ -1482,6 +1492,7 @@ export function App() {
onSubmitLogin={submitLogin} onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister} onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled} oidcEnabled={oidcEnabled}
oidcContextTypes={oidcContextTypes}
onOIDCLogin={loginWithOIDC} onOIDCLogin={loginWithOIDC}
/> />
) )
@@ -1554,6 +1565,7 @@ export function App() {
onSubmitLogin={submitLogin} onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister} onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled} oidcEnabled={oidcEnabled}
oidcContextTypes={oidcContextTypes}
onOIDCLogin={loginWithOIDC} onOIDCLogin={loginWithOIDC}
/> />
) )
+4 -2
View File
@@ -15,14 +15,16 @@ const baseProps = {
onSubmitExternalToken: vi.fn(), onSubmitExternalToken: vi.fn(),
onSubmitLogin: vi.fn(), onSubmitLogin: vi.fn(),
onSubmitRegister: vi.fn(), onSubmitRegister: vi.fn(),
oidcContextTypes: ['platform', 'tenant'] as const,
onOIDCLogin: vi.fn(), onOIDCLogin: vi.fn(),
}; };
describe('AuthPanel', () => { describe('AuthPanel', () => {
it('shows only the authentication center entry when OIDC is enabled', () => { it('shows explicit platform and tenant entries when OIDC is enabled', () => {
const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled />); const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled />);
expect(html).toContain('使用统一认证中心登录'); expect(html).toContain('使用平台账号登录');
expect(html).toContain('使用租户账号登录');
expect(html).not.toContain('用户名或邮箱'); expect(html).not.toContain('用户名或邮箱');
expect(html).not.toContain('注册账号'); expect(html).not.toContain('注册账号');
expect(html).not.toContain('外部 Token'); expect(html).not.toContain('外部 Token');
+25 -5
View File
@@ -2,6 +2,7 @@ import type { FormEvent } from 'react';
import { LogIn, UserPlus } from 'lucide-react'; import { LogIn, UserPlus } from 'lucide-react';
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, PasswordInput, Tabs } from './ui'; import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, PasswordInput, Tabs } from './ui';
import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types'; import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types';
import type { OIDCContextType } from '../lib/oidc';
const tabs = [ const tabs = [
{ value: 'login', label: '账号登录' }, { value: 'login', label: '账号登录' },
@@ -23,7 +24,8 @@ export function AuthPanel(props: {
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void; onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void; onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
oidcEnabled: boolean; oidcEnabled: boolean;
onOIDCLogin: () => void; oidcContextTypes: readonly OIDCContextType[];
onOIDCLogin: (contextType: OIDCContextType) => void;
}) { }) {
return ( return (
<section className="authShell" aria-label="登录"> <section className="authShell" aria-label="登录">
@@ -36,10 +38,28 @@ export function AuthPanel(props: {
</CardHeader> </CardHeader>
<CardContent className="authContent"> <CardContent className="authContent">
{props.oidcEnabled ? ( {props.oidcEnabled ? (
<Button type="button" disabled={props.state === 'loading'} onClick={props.onOIDCLogin}> <div className="formGrid">
<LogIn size={15} /> {props.oidcContextTypes.includes('platform') && (
使 <Button
</Button> type="button"
disabled={props.state === 'loading'}
onClick={() => props.onOIDCLogin('platform')}
>
<LogIn size={15} />
使
</Button>
)}
{props.oidcContextTypes.includes('tenant') && (
<Button
type="button"
disabled={props.state === 'loading'}
onClick={() => props.onOIDCLogin('tenant')}
>
<LogIn size={15} />
使
</Button>
)}
</div>
) : ( ) : (
<> <>
<Tabs value={props.authMode} tabs={tabs} onValueChange={props.onAuthModeChange} /> <Tabs value={props.authMode} tabs={tabs} onValueChange={props.onAuthModeChange} />
+7 -4
View File
@@ -7,21 +7,22 @@ describe('OIDC BFF navigation', () => {
vi.resetModules(); vi.resetModules();
}); });
it('sends only returnTo to the Gateway login endpoint', async () => { it('binds platform context and returnTo at the Gateway login endpoint', async () => {
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }), json: async () => ({ enabled: true, oidcLogin: true, contextTypes: ['platform', 'tenant'], loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }),
})); }));
const assign = vi.fn(); const assign = vi.fn();
vi.stubGlobal('window', { vi.stubGlobal('window', {
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign }, location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
}); });
const { startOIDCLogin } = await import('./oidc'); const { startOIDCLogin } = await import('./oidc');
await startOIDCLogin(); await startOIDCLogin('platform');
const target = new URL(String(assign.mock.calls[0]?.[0])); const target = new URL(String(assign.mock.calls[0]?.[0]));
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login'); expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance'); expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance');
expect(target.searchParams.get('contextType')).toBe('platform');
expect(target.searchParams.has('client_id')).toBe(false); expect(target.searchParams.has('client_id')).toBe(false);
expect(target.searchParams.has('code_challenge')).toBe(false); expect(target.searchParams.has('code_challenge')).toBe(false);
}); });
@@ -33,6 +34,7 @@ describe('OIDC BFF navigation', () => {
json: async () => ({ json: async () => ({
enabled: true, enabled: true,
oidcLogin: true, oidcLogin: true,
contextTypes: ['platform', 'tenant'],
loginUrl: '/api/v1/auth/oidc/login', loginUrl: '/api/v1/auth/oidc/login',
logoutUrl: '/api/v1/auth/oidc/logout', logoutUrl: '/api/v1/auth/oidc/logout',
status: 'active', status: 'active',
@@ -44,12 +46,13 @@ describe('OIDC BFF navigation', () => {
}); });
const { startOIDCLogin } = await import('./oidc'); const { startOIDCLogin } = await import('./oidc');
await startOIDCLogin(); await startOIDCLogin('tenant');
const target = new URL(String(assign.mock.calls[0]?.[0])); const target = new URL(String(assign.mock.calls[0]?.[0]));
expect(target.origin).toBe('https://ai.51easyai.com'); expect(target.origin).toBe('https://ai.51easyai.com');
expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login'); expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login');
expect(target.searchParams.get('returnTo')).toBe('/workspace/overview'); expect(target.searchParams.get('returnTo')).toBe('/workspace/overview');
expect(target.searchParams.get('contextType')).toBe('tenant');
}); });
it('submits logout as a top-level POST without exposing tokens', async () => { it('submits logout as a top-level POST without exposing tokens', async () => {
+27 -4
View File
@@ -3,12 +3,20 @@ const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://loc
export type OIDCRuntimeConfiguration = { export type OIDCRuntimeConfiguration = {
enabled: boolean; enabled: boolean;
oidcLogin: boolean; oidcLogin: boolean;
contextTypes: OIDCContextType[];
loginUrl?: string; loginUrl?: string;
logoutUrl?: string; logoutUrl?: string;
status: string; status: string;
}; };
const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' }; export type OIDCContextType = 'platform' | 'tenant';
const disabledRuntime: OIDCRuntimeConfiguration = {
enabled: false,
oidcLogin: false,
contextTypes: [],
status: 'disabled',
};
let runtimeConfiguration = disabledRuntime; let runtimeConfiguration = disabledRuntime;
let runtimeLoaded = false; let runtimeLoaded = false;
let runtimeRequest: Promise<OIDCRuntimeConfiguration> | null = null; let runtimeRequest: Promise<OIDCRuntimeConfiguration> | null = null;
@@ -64,6 +72,12 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
return { return {
enabled: value.enabled, enabled: value.enabled,
oidcLogin: value.oidcLogin, oidcLogin: value.oidcLogin,
contextTypes: Array.isArray(value.contextTypes)
? value.contextTypes.filter(
(item): item is OIDCContextType =>
item === 'platform' || item === 'tenant',
)
: [],
status: value.status, status: value.status,
...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}), ...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}),
...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}), ...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}),
@@ -77,12 +91,21 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
return runtimeRequest; return runtimeRequest;
} }
export async function startOIDCLogin(configuration?: OIDCRuntimeConfiguration) { export async function startOIDCLogin(
contextType: OIDCContextType,
configuration?: OIDCRuntimeConfiguration,
) {
configuration ??= await loadOIDCRuntimeConfiguration(true); configuration ??= await loadOIDCRuntimeConfiguration(true);
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置'); if (
!configuration.enabled ||
!configuration.oidcLogin ||
!configuration.loginUrl ||
!configuration.contextTypes.includes(contextType)
) throw new Error('统一认证登录上下文未配置');
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/'; const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin); const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/'); loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
loginURL.searchParams.set('contextType', contextType);
window.location.assign(loginURL.toString()); window.location.assign(loginURL.toString());
} }