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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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), "认证中心身份令牌校验失败")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,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 () => {
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user