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

修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
+22
View File
@@ -5906,6 +5906,19 @@
"description": "登录后返回的站内相对路径",
"name": "returnTo",
"in": "query"
},
{
"type": "string",
"description": "显式登录上下文:platform 或 tenant",
"name": "contextType",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Tenant 上下文可选的稳定 Tenant UUID",
"name": "tenantHint",
"in": "query"
}
],
"responses": {
@@ -10064,6 +10077,9 @@
"apiKeySecret": {
"type": "string"
},
"contextType": {
"type": "string"
},
"gatewayTenantId": {
"type": "string"
},
@@ -12432,6 +12448,12 @@
"httpapi.publicIdentityConfiguration": {
"type": "object",
"properties": {
"contextTypes": {
"type": "array",
"items": {
"type": "string"
}
},
"enabled": {
"type": "boolean"
},
+15
View File
@@ -14,6 +14,8 @@ definitions:
type: array
apiKeySecret:
type: string
contextType:
type: string
gatewayTenantId:
type: string
gatewayUserId:
@@ -1662,6 +1664,10 @@ definitions:
type: object
httpapi.publicIdentityConfiguration:
properties:
contextTypes:
items:
type: string
type: array
enabled:
type: boolean
loginUrl:
@@ -7889,6 +7895,15 @@ paths:
in: query
name: returnTo
type: string
- description: 显式登录上下文:platform 或 tenant
in: query
name: contextType
required: true
type: string
- description: Tenant 上下文可选的稳定 Tenant UUID
in: query
name: tenantHint
type: string
responses:
"303":
description: See Other
+1
View File
@@ -37,6 +37,7 @@ type User struct {
ID string `json:"sub"`
Username string `json:"username"`
Roles []string `json:"role,omitempty"`
ContextType string `json:"contextType,omitempty"`
TenantID string `json:"tenantId,omitempty"`
TenantName string `json:"tenantName,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
+16 -5
View File
@@ -50,6 +50,7 @@ type OIDCConfig struct {
type OIDCSecurityEventIdentity struct {
Issuer string
ApplicationID string
ContextType string
TenantID string
Subject string
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)
}
claims, ok := token.Claims.(jwt.MapClaims)
contextType := stringClaim(claims, "context_type")
tenantID := stringClaim(claims, "tid")
validTenant := tenantID == v.config.TenantID
validContext := contextType == "tenant" && tenantID == v.config.TenantID
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")
if !ok || stringClaim(claims, "sub") == "" || !validTenant ||
if !ok || stringClaim(claims, "sub") == "" || !validContext ||
v.config.ClientID != "" && clientID != v.config.ClientID {
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 {
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
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 {
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{
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,
}, nil
}
+16 -2
View File
@@ -78,18 +78,32 @@ func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error {
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) {
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 {
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)
if err != nil {
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) != "" {
options = append(options, oauth2.SetAuthURLParam("tenant_hint", strings.TrimSpace(tenantHint)))
}
+6 -1
View File
@@ -68,7 +68,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
t.Fatalf("ValidateConfiguration() error = %v", err)
}
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 {
t.Fatal(err)
}
@@ -82,6 +84,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
if 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 {
t.Fatal(err)
}
+87
View File
@@ -99,6 +99,8 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
{"missing issuer", "ISSUER_MISSING", func(claims jwt.MapClaims) { delete(claims, "iss") }},
{"missing audience", "AUDIENCE_MISSING", func(claims jwt.MapClaims) { delete(claims, "aud") }},
{"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" }},
{"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"} }},
@@ -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) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
active := true
@@ -312,6 +398,7 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod,
now := time.Now()
claims := jwt.MapClaims{
"iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1",
"context_type": "tenant",
"preferred_username": "acceptance", "roles": []string{"gateway.admin"},
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
"exp": now.Add(time.Hour).Unix(),
@@ -39,11 +39,12 @@ type identityRuntimeStatus struct {
}
type publicIdentityConfiguration struct {
Enabled bool `json:"enabled"`
OIDCLogin bool `json:"oidcLogin"`
LoginURL string `json:"loginUrl,omitempty"`
LogoutURL string `json:"logoutUrl,omitempty"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
OIDCLogin bool `json:"oidcLogin"`
LoginURL string `json:"loginUrl,omitempty"`
LogoutURL string `json:"logoutUrl,omitempty"`
ContextTypes []string `json:"contextTypes,omitempty"`
Status string `json:"status"`
}
type identityPolicyPatch struct {
@@ -99,6 +100,10 @@ func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.R
view.OIDCLogin = true
view.LoginURL = "/api/v1/auth/oidc/login"
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)
}
+28 -4
View File
@@ -36,6 +36,8 @@ const (
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
// @Tags auth
// @Param returnTo query string false "登录后返回的站内相对路径"
// @Param contextType query string true "显式登录上下文:platform 或 tenant"
// @Param tenantHint query string false "Tenant 上下文可选的稳定 Tenant UUID"
// @Success 303
// @Failure 400 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
@@ -50,12 +52,22 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
if returnTo == "" {
returnTo = "/"
}
contextType := strings.TrimSpace(r.URL.Query().Get("contextType"))
tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint"))
if tenantHint != "" && (runtime.Revision.TenantMode != "multi_tenant" || uuid.Validate(tenantHint) != nil) {
writeError(w, http.StatusBadRequest, "租户提示无效", errorCodeOIDCLoginInvalid)
validContext := contextType == "tenant" ||
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
}
transaction, err := oidcsession.NewLoginTransactionWithTenantHint(returnTo, tenantHint, time.Now())
transaction, err := oidcsession.NewLoginTransactionWithContext(
returnTo, contextType, tenantHint, time.Now(),
)
if err != nil {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return
@@ -67,7 +79,9 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
return
}
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 {
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), "认证中心访问令牌校验失败")
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)
if err != nil || idSubject != identity.ID {
s.writeOIDCTokenFailure(w, r, "ID_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心身份令牌校验失败")
+37 -7
View File
@@ -25,9 +25,10 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
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()
server.startOIDCLogin(recorder, request)
response := recorder.Result()
@@ -40,10 +41,11 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
t.Fatalf("unsafe login transaction cookie: %#v", cookies)
}
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)
}
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")
}
}
@@ -55,7 +57,7 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
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") != "" {
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"
recorder := httptest.NewRecorder()
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 {
t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint)
@@ -103,7 +105,7 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
}
recorder := httptest.NewRecorder()
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") != "" {
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) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil {
@@ -511,12 +539,14 @@ func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
type fakeOIDCClient struct {
authorizationURL string
state, nonce, challenge string
contextType string
tenantHint 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.contextType = contextType
f.tenantHint = tenantHint
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")
}
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 := ""
tenantSlug := ""
tenantMetadataStatus := ""
tenantMetadataVersion := ""
tenantMetadataETag := ""
var tenantMetadataUpdatedAt time.Time
if revision.TenantMode == "multi_tenant" {
if user.ContextType == "tenant" &&
revision.TenantMode == "multi_tenant" {
if runtime.TenantContext == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable")
}
@@ -151,6 +168,7 @@ func (s *Server) resolveOIDCUserProjectionWithTenantContext(
Subject: user.ID,
Username: user.Username,
Roles: user.Roles,
ContextType: user.ContextType,
TenantID: user.TenantID,
TenantMode: revision.TenantMode,
TenantName: tenantName,
@@ -62,6 +62,7 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
ID: "platform-user",
Username: "alice",
Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "external-tenant",
Source: "oidc",
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 = request.WithContext(auth.WithUser(request.Context(), &auth.User{
ID: "platform-user",
Username: "alice",
Roles: []string{"basic"},
TenantID: "external-tenant",
Source: "oidc",
ID: "platform-user",
Username: "alice",
Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "external-tenant",
Source: "oidc",
}))
recorder := httptest.NewRecorder()
@@ -146,7 +148,8 @@ func TestResolveOIDCMultiTenantProjectionUsesRuntimeTenantContext(t *testing.T)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "shared-subject", TenantID: tenantID, Username: "alice", Roles: []string{"basic"},
ID: "shared-subject", ContextType: "tenant", TenantID: tenantID,
Username: "alice", Roles: []string{"basic"},
}, runtime)
if err != nil {
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) {
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
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)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, runtime); err != nil {
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)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, runtime); err != nil {
t.Fatal(err)
}
@@ -228,7 +265,7 @@ func TestResolveOIDCMultiTenantProjectionFallsBackToSyncedCacheOnTemporaryFailur
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
@@ -257,7 +294,7 @@ func TestResolveOIDCMultiTenantProjectionRevalidatesDisabledBindingBeforeReassig
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: applicationID,
@@ -283,7 +320,8 @@ func TestResolveOIDCMultiTenantProjectionKeepsDisabledBindingClosedDuringContext
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0",
ID: "subject", ContextType: "tenant",
TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0",
}, &identityRequestRuntime{
Revision: identity.Revision{
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
@@ -313,7 +351,7 @@ func TestResolveOIDCMultiTenantProjectionRejectsMissingOrInactiveTenant(t *testi
server := &Server{oidcUserResolver: resolver}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
ID: "subject", TenantID: tenantID,
ID: "subject", ContextType: "tenant", TenantID: tenantID,
}, &identityRequestRuntime{
Revision: identity.Revision{TenantMode: "multi_tenant"},
TenantContext: test.reader,
@@ -346,7 +384,8 @@ func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
ID: "platform-user", Source: "oidc", TenantID: "external-tenant",
ID: "platform-user", Source: "oidc",
ContextType: "tenant", TenantID: "external-tenant",
}))
recorder := httptest.NewRecorder()
server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+1 -1
View File
@@ -52,7 +52,7 @@ type Server struct {
}
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)
VerifyIDToken(context.Context, string, string) (string, error)
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
@@ -20,18 +20,37 @@ type LoginTransaction struct {
Nonce string `json:"nonce"`
PKCEVerifier string `json:"pkceVerifier"`
ReturnTo string `json:"returnTo"`
ContextType string `json:"contextType"`
TenantHint string `json:"tenantHint,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
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) {
return NewLoginTransactionWithContext(returnTo, "tenant", tenantHint, now)
}
func NewLoginTransactionWithContext(
returnTo, contextType, tenantHint string,
now time.Time,
) (LoginTransaction, error) {
if !ValidReturnTo(returnTo) {
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)
if err != nil {
return LoginTransaction{}, err
@@ -46,7 +65,8 @@ func NewLoginTransactionWithTenantHint(returnTo, tenantHint string, now time.Tim
}
return LoginTransaction{
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
}
@@ -68,6 +88,8 @@ func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTra
return LoginTransaction{}, err
}
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.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")
@@ -37,7 +37,7 @@ func TestLoginTransactionEncryptsTenantHint(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
cipher, _ := NewCipher(bytes.Repeat([]byte{9}, 32))
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
transaction, err := NewLoginTransactionWithTenantHint("/", tenantHint, now)
transaction, err := NewLoginTransactionWithContext("/", "tenant", tenantHint, now)
if err != nil {
t.Fatal(err)
}
@@ -49,11 +49,30 @@ func TestLoginTransactionEncryptsTenantHint(t *testing.T) {
t.Fatal("login transaction cookie contains plaintext tenant hint")
}
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)
}
}
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) {
for _, value := range []string{"https://evil.example", "//evil.example", "/\\evil", "", "workspace"} {
if ValidReturnTo(value) {
+19 -4
View File
@@ -120,6 +120,13 @@ func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *aut
if verified.ID != localUser.ID {
return "", newSessionCreationError(sessionCreationIdentityMismatch, ErrSessionInvalid)
}
if verified.ContextType != localUser.ContextType ||
verified.TenantID != localUser.TenantID {
return "", newSessionCreationError(
sessionCreationIdentityMismatch,
ErrSessionInvalid,
)
}
if localUser.OIDCUserBindingID != "" &&
(localUser.Issuer == "" || localUser.ApplicationID == "" || localUser.TenantID == "" || localUser.OIDCClientID == "" ||
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{
SessionTokenHash: hash, GatewayUserID: localUser.GatewayUserID, GatewayTenantID: localUser.GatewayTenantID,
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,
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 {
return nil, ErrSessionInvalid
}
if record.OIDCUserBindingID != "" &&
(user.Issuer != record.Issuer || user.ApplicationID != record.ApplicationID ||
user.TenantID != record.TenantID || user.OIDCClientID != record.OIDCClientID) {
identityBound := record.ContextType != "" ||
record.OIDCUserBindingID != ""
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 user, nil
+54 -2
View File
@@ -292,6 +292,53 @@ func TestServiceBindsMultiTenantSessionToIssuerApplicationTenantAndClient(t *tes
}
}
func TestServiceBindsPlatformSessionToExplicitContextWithoutTenant(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
applicationID := "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
verified := &auth.User{
ID: "platform-subject", Source: "oidc",
Issuer: "https://auth.example.test",
ApplicationID: applicationID, ContextType: "platform",
OIDCClientID: "gateway-browser",
TokenExpiresAt: now.Add(5 * time.Minute),
}
repository := newFakeRepository("platform-subject")
service := newTestService(
t,
repository,
fakeVerifier{users: map[string]*auth.User{"access": verified}},
&fakePublicClient{},
)
service.now = func() time.Time { return now }
raw, err := service.Create(
context.Background(),
TokenBundle{AccessToken: "access", RefreshToken: "refresh"},
&auth.User{
ID: "platform-subject",
GatewayUserID: "11111111-1111-4111-8111-111111111111",
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
ContextType: "platform",
},
)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
record := repository.snapshot()
if record.ContextType != "platform" || record.TenantID != "" ||
record.Issuer != verified.Issuer ||
record.ApplicationID != applicationID {
t.Fatalf("platform session record=%#v", record)
}
verified.ContextType = "tenant"
verified.TenantID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
if _, err := service.Resolve(
context.Background(),
raw,
); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("Resolve() error = %v, want ErrSessionInvalid", err)
}
}
func TestServiceCreateReportsSafeFailureCategory(t *testing.T) {
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
validVerified := &auth.User{
@@ -445,11 +492,16 @@ func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.Create
if f.createError != nil {
return store.OIDCSession{}, f.createError
}
external := input.Subject
if external == "" {
external = f.external
}
f.record = store.OIDCSession{
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,
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,
LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1,
}
@@ -0,0 +1,247 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const oidcPlatformTenantKey = "default"
func (s *Store) resolveOrProvisionOIDCPlatformUser(
ctx context.Context,
input ResolveOrProvisionOIDCUserInput,
) (ResolveOrProvisionOIDCUserResult, error) {
if input.Issuer == "" || input.Subject == "" ||
input.ContextType != "platform" ||
input.TenantMode != "multi_tenant" ||
uuid.Validate(input.ApplicationID) != nil ||
input.TenantID != "" {
return ResolveOrProvisionOIDCUserResult{},
errors.New("invalid platform OIDC user projection input")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
defer rollbackTransaction(tx)
userKey := deriveOIDCPlatformUserKey(
input.Issuer,
input.ApplicationID,
input.Subject,
)
user, groupKey, err := loadOIDCPlatformUser(ctx, tx, userKey)
created := false
if errors.Is(err, pgx.ErrNoRows) {
if !input.ProvisioningEnabled {
return ResolveOrProvisionOIDCUserResult{},
ErrOIDCUserNotProvisioned
}
user, groupKey, created, err = s.createOIDCPlatformUser(
ctx,
tx,
userKey,
input,
)
}
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if user.Status != "active" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
}
rolesJSON, err := json.Marshal(input.Roles)
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,
last_login_at=now(),synced_at=now(),source_updated_at=now(),
updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2_platform'
AND status='active' AND deleted_at IS NULL
RETURNING `+userColumns,
user.ID,
input.Username,
string(rolesJSON),
))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
}
return ResolveOrProvisionOIDCUserResult{}, err
}
var auditID string
if created {
subjectHash := sha256.Sum256([]byte(input.Subject))
audit, auditErr := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity",
Action: "identity.oidc_platform_user.provisioned",
ActorGatewayUserID: user.ID,
ActorUsername: user.Username,
ActorSource: "oidc",
ActorRoles: user.Roles,
TargetType: "gateway_user",
TargetID: user.ID,
TargetGatewayUserID: user.ID,
TargetGatewayTenantID: user.GatewayTenantID,
RequestIP: input.RequestIP,
UserAgent: input.UserAgent,
AfterState: map[string]any{
"source": "oidc_v2_platform",
"contextType": "platform",
"tenantKey": user.TenantKey,
"userGroupId": user.DefaultUserGroupID,
},
Metadata: map[string]any{
"provisioningMode": "oidc-platform-jit",
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
},
})
if auditErr != nil {
return ResolveOrProvisionOIDCUserResult{}, auditErr
}
auditID = audit.ID
}
if err := tx.Commit(ctx); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
return ResolveOrProvisionOIDCUserResult{
User: platformAuthUser(user, groupKey, input),
Created: created,
AuditID: auditID,
}, nil
}
func loadOIDCPlatformUser(
ctx context.Context,
tx pgx.Tx,
userKey string,
) (GatewayUser, string, error) {
var userID string
if err := tx.QueryRow(ctx, `SELECT id::text
FROM gateway_users
WHERE user_key=$1 AND source='oidc_v2_platform'
FOR UPDATE`, userKey).Scan(&userID); err != nil {
return GatewayUser{}, "", err
}
user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+`
FROM gateway_users WHERE id=$1::uuid`, userID))
if err != nil {
return GatewayUser{}, "", err
}
var groupKey string
if err := tx.QueryRow(ctx, `SELECT group_record.group_key
FROM gateway_users user_record
JOIN gateway_tenants tenant
ON tenant.id=user_record.gateway_tenant_id
AND tenant.status='active' AND tenant.deleted_at IS NULL
JOIN gateway_user_groups group_record
ON group_record.id=user_record.default_user_group_id
AND group_record.status='active'
WHERE user_record.id=$1::uuid`, userID).Scan(&groupKey); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return GatewayUser{}, "", ErrOIDCTenantUnavailable
}
return GatewayUser{}, "", err
}
return user, groupKey, nil
}
func (s *Store) createOIDCPlatformUser(
ctx context.Context,
tx pgx.Tx,
userKey string,
input ResolveOrProvisionOIDCUserInput,
) (GatewayUser, string, bool, error) {
gatewayTenantID, groupID, groupKey, err :=
loadOIDCProvisioningTenant(ctx, tx, oidcPlatformTenantKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return GatewayUser{}, "", false, ErrOIDCTenantUnavailable
}
return GatewayUser{}, "", false, err
}
rolesJSON, err := json.Marshal(input.Roles)
if err != nil {
return GatewayUser{}, "", false, err
}
metadataJSON, err := json.Marshal(map[string]any{
"provisioningMode": "oidc-platform-jit",
"contextType": "platform",
"applicationId": input.ApplicationID,
})
if err != nil {
return GatewayUser{}, "", false, err
}
username := input.Username
if username == "" {
username = "oidc-platform-" +
strings.TrimPrefix(userKey, "oidc2-platform:")[:12]
}
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
user_key,source,external_user_id,username,gateway_tenant_id,
tenant_id,tenant_key,default_user_group_id,roles,auth_profile,
metadata,status,last_login_at,synced_at,source_updated_at
) VALUES(
$1,'oidc_v2_platform',NULL,$2,$3::uuid,NULL,$4,$5::uuid,
$6::jsonb,'{}'::jsonb,$7::jsonb,'active',now(),now(),now()
)
ON CONFLICT(user_key) DO NOTHING
RETURNING `+userColumns,
userKey,
username,
gatewayTenantID,
oidcPlatformTenantKey,
groupID,
string(rolesJSON),
string(metadataJSON),
))
if errors.Is(err, pgx.ErrNoRows) {
user, groupKey, err = loadOIDCPlatformUser(ctx, tx, userKey)
return user, groupKey, false, err
}
if err != nil {
return GatewayUser{}, "", false, err
}
if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil {
return GatewayUser{}, "", false, err
}
return user, groupKey, true, nil
}
func deriveOIDCPlatformUserKey(
issuer string,
applicationID string,
subject string,
) string {
sum := sha256.Sum256([]byte(
strings.TrimRight(issuer, "/") + "\x00" +
applicationID + "\x00" + subject,
))
return fmt.Sprintf("oidc2-platform:%x", sum)
}
func platformAuthUser(
user GatewayUser,
groupKey string,
input ResolveOrProvisionOIDCUserInput,
) *auth.User {
result := authUserFromOIDCProjection(user, groupKey)
result.ID = input.Subject
result.ContextType = "platform"
result.TenantID = ""
result.Issuer = input.Issuer
result.ApplicationID = input.ApplicationID
result.OIDCClientID = input.OIDCClientID
return result
}
@@ -0,0 +1,60 @@
package store
import "testing"
func TestOIDCProjectionKindRequiresExplicitCompatibleContext(t *testing.T) {
applicationID := "11111111-1111-4111-8111-111111111111"
tests := []struct {
name string
input ResolveOrProvisionOIDCUserInput
want string
}{
{
name: "platform in multi-tenant application",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "platform", TenantMode: "multi_tenant",
ApplicationID: applicationID,
},
want: "platform",
},
{
name: "tenant in multi-tenant application",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "tenant", TenantMode: "multi_tenant",
ApplicationID: applicationID,
TenantID: "22222222-2222-4222-8222-222222222222",
},
want: "multi_tenant",
},
{
name: "tenant in single-tenant application",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "tenant", TenantMode: "single_tenant",
TenantID: "tenant-contract-id",
},
want: "single_tenant",
},
{
name: "missing context",
input: ResolveOrProvisionOIDCUserInput{
TenantMode: "multi_tenant", ApplicationID: applicationID,
TenantID: "22222222-2222-4222-8222-222222222222",
},
},
{
name: "platform context with tenant",
input: ResolveOrProvisionOIDCUserInput{
ContextType: "platform", TenantMode: "multi_tenant",
ApplicationID: applicationID,
TenantID: "22222222-2222-4222-8222-222222222222",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := oidcProjectionKind(test.input); got != test.want {
t.Fatalf("projection kind=%q, want %q", got, test.want)
}
})
}
}
+44 -13
View File
@@ -19,6 +19,7 @@ type OIDCSession struct {
ExternalUserID string
Issuer string
ApplicationID string
ContextType string
TenantID string
OIDCClientID string
UserStatus string
@@ -43,6 +44,8 @@ type CreateOIDCSessionInput struct {
OIDCClientID string
Issuer string
ApplicationID string
ContextType string
Subject string
TenantID string
TokenCiphertext []byte
AccessTokenExpiresAt time.Time
@@ -57,26 +60,51 @@ func (s *Store) CreateOIDCSession(ctx context.Context, input CreateOIDCSessionIn
INSERT INTO gateway_oidc_sessions (
session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext,
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
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
WHERE u.id = $2::uuid
AND u.gateway_tenant_id = $3::uuid
AND ($9 = '' OR (
ub.gateway_user_id = u.id
AND tb.gateway_tenant_id = u.gateway_tenant_id
AND tb.issuer = $11
AND tb.application_id = $12
AND tb.external_tenant_id = $13
))
AND (
(
$13 = 'platform'
AND $9 = ''
AND $15 = ''
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`,
input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext,
input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt,
input.OIDCUserBindingID, input.OIDCClientID,
input.Issuer, input.ApplicationID, input.TenantID,
input.Issuer, input.ApplicationID, input.ContextType,
input.Subject, input.TenantID,
).Scan(&id)
if err != nil {
return OIDCSession{}, err
@@ -165,8 +193,10 @@ WHERE idle_expires_at <= $1 OR absolute_expires_at <= $1`, now)
const oidcSessionColumns = `
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(tb.issuer, ''), COALESCE(tb.application_id, ''), COALESCE(tb.external_tenant_id, ''),
COALESCE(s.oidc_user_binding_id::text, ''),
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,
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, ''),
@@ -177,7 +207,8 @@ func scanOIDCSession(row pgx.Row) (OIDCSession, error) {
err := row.Scan(
&item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID,
&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.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt,
)
+38 -1
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
@@ -26,6 +27,7 @@ type ResolveOrProvisionOIDCUserInput struct {
Subject string
Username string
Roles []string
ContextType string
TenantID string
TenantMode string
TenantName string
@@ -58,8 +60,16 @@ type oidcUserProjection struct {
func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
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)
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 == "" {
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input")
@@ -187,6 +197,31 @@ RETURNING `+userColumns,
}, 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) {
if projection.userDeleted || projection.user.Status != "active" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
@@ -308,6 +343,7 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
input.Subject = strings.TrimSpace(input.Subject)
input.Username = strings.TrimSpace(input.Username)
input.ContextType = strings.TrimSpace(input.ContextType)
input.TenantID = strings.TrimSpace(input.TenantID)
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
input.TenantMode = strings.TrimSpace(input.TenantMode)
@@ -355,6 +391,7 @@ func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.Use
ID: user.ExternalUserID,
Username: user.Username,
Roles: user.Roles,
ContextType: "tenant",
TenantID: user.TenantID,
GatewayTenantID: user.GatewayTenantID,
TenantKey: user.TenantKey,
@@ -2,8 +2,10 @@ package store
import (
"context"
"crypto/sha256"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
@@ -35,7 +37,8 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
return ResolveOrProvisionOIDCUserInput{
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",
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
@@ -173,7 +176,9 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
Subject: subject,
Username: "jit-user-" + suffix,
Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "auth-center-test-tenant",
TenantMode: "single_tenant",
GatewayTenantKey: "default",
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) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
@@ -318,7 +437,9 @@ func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing
Subject: subject,
Username: "missing-user",
Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "auth-center-test-tenant",
TenantMode: "single_tenant",
GatewayTenantKey: "missing-tenant-key",
}
@@ -204,7 +204,8 @@ func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *test
projection := func(tenantID string) *auth.User {
result, err := db.ResolveOrProvisionOIDCUser(ctx, ResolveOrProvisionOIDCUserInput{
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],
TenantMetadataStatus: "synced", TenantMetadataVersion: "1",
TenantMetadataUpdatedAt: time.Now().UTC(), OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
@@ -225,6 +226,7 @@ func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *test
SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID,
GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID,
OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID,
ContextType: "tenant", Subject: user.ID,
TenantID: user.TenantID, TokenCiphertext: []byte{marker},
AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now,
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 <> '')
)
)
);