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