diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index ab42e51..6792cb6 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -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" }, diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index c564793..b223e20 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -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 diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index eec61f7..3f477d3 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -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"` diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 3b6af3d..348cb56 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -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 } diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index c570ec1..b3f46b1 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -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))) } diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index 44ed0c2..2853258 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -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) } diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index d197cda..5c65954 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -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(), diff --git a/apps/api/internal/httpapi/identity_configuration_handlers.go b/apps/api/internal/httpapi/identity_configuration_handlers.go index 83f6063..9d475a2 100644 --- a/apps/api/internal/httpapi/identity_configuration_handlers.go +++ b/apps/api/internal/httpapi/identity_configuration_handlers.go @@ -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) } diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index d0f8c39..12d89b2 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -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), "认证中心身份令牌校验失败") diff --git a/apps/api/internal/httpapi/oidc_session_test.go b/apps/api/internal/httpapi/oidc_session_test.go index c823f5a..c84a01b 100644 --- a/apps/api/internal/httpapi/oidc_session_test.go +++ b/apps/api/internal/httpapi/oidc_session_test.go @@ -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 } diff --git a/apps/api/internal/httpapi/oidc_user_middleware.go b/apps/api/internal/httpapi/oidc_user_middleware.go index 18471cd..6b16ca6 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware.go +++ b/apps/api/internal/httpapi/oidc_user_middleware.go @@ -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, diff --git a/apps/api/internal/httpapi/oidc_user_middleware_test.go b/apps/api/internal/httpapi/oidc_user_middleware_test.go index 5381fc0..506943d 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware_test.go +++ b/apps/api/internal/httpapi/oidc_user_middleware_test.go @@ -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) { diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 43ca3f0..09207a8 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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) diff --git a/apps/api/internal/oidcsession/login_transaction.go b/apps/api/internal/oidcsession/login_transaction.go index 551f8f1..0e51960 100644 --- a/apps/api/internal/oidcsession/login_transaction.go +++ b/apps/api/internal/oidcsession/login_transaction.go @@ -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") diff --git a/apps/api/internal/oidcsession/login_transaction_test.go b/apps/api/internal/oidcsession/login_transaction_test.go index e48bbc1..1980915 100644 --- a/apps/api/internal/oidcsession/login_transaction_test.go +++ b/apps/api/internal/oidcsession/login_transaction_test.go @@ -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) { diff --git a/apps/api/internal/oidcsession/service.go b/apps/api/internal/oidcsession/service.go index 1568e32..2bd9e9e 100644 --- a/apps/api/internal/oidcsession/service.go +++ b/apps/api/internal/oidcsession/service.go @@ -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 diff --git a/apps/api/internal/oidcsession/service_test.go b/apps/api/internal/oidcsession/service_test.go index 431a9e0..c9c8cb4 100644 --- a/apps/api/internal/oidcsession/service_test.go +++ b/apps/api/internal/oidcsession/service_test.go @@ -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, } diff --git a/apps/api/internal/store/oidc_platform_users.go b/apps/api/internal/store/oidc_platform_users.go new file mode 100644 index 0000000..2430a12 --- /dev/null +++ b/apps/api/internal/store/oidc_platform_users.go @@ -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 +} diff --git a/apps/api/internal/store/oidc_projection_test.go b/apps/api/internal/store/oidc_projection_test.go new file mode 100644 index 0000000..d3406d5 --- /dev/null +++ b/apps/api/internal/store/oidc_projection_test.go @@ -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) + } + }) + } +} diff --git a/apps/api/internal/store/oidc_sessions.go b/apps/api/internal/store/oidc_sessions.go index dafc7e3..ec5fe11 100644 --- a/apps/api/internal/store/oidc_sessions.go +++ b/apps/api/internal/store/oidc_sessions.go @@ -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, ) diff --git a/apps/api/internal/store/oidc_users.go b/apps/api/internal/store/oidc_users.go index b525076..120dd53 100644 --- a/apps/api/internal/store/oidc_users.go +++ b/apps/api/internal/store/oidc_users.go @@ -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, diff --git a/apps/api/internal/store/oidc_users_integration_test.go b/apps/api/internal/store/oidc_users_integration_test.go index a06c830..d8e404f 100644 --- a/apps/api/internal/store/oidc_users_integration_test.go +++ b/apps/api/internal/store/oidc_users_integration_test.go @@ -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", } diff --git a/apps/api/internal/store/security_events_integration_test.go b/apps/api/internal/store/security_events_integration_test.go index 96d50d1..1c73f9b 100644 --- a/apps/api/internal/store/security_events_integration_test.go +++ b/apps/api/internal/store/security_events_integration_test.go @@ -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), diff --git a/apps/api/migrations/0095_oidc_session_context_identity.sql b/apps/api/migrations/0095_oidc_session_context_identity.sql new file mode 100644 index 0000000..9c75aa4 --- /dev/null +++ b/apps/api/migrations/0095_oidc_session_context_identity.sql @@ -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 <> '') + ) + ) + ); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index dd76921..8d2201e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -135,6 +135,7 @@ import { loadOIDCRuntimeConfiguration, startOIDCLogin, startOIDCLogout, + type OIDCContextType, } from './lib/oidc'; import { runTask, type RunTaskOptions } from './lib/run-task'; import { AdminPage } from './pages/AdminPage'; @@ -280,6 +281,7 @@ export function App() { const [error, setError] = useState(''); const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError()); const [oidcEnabled, setOIDCEnabled] = useState(false); + const [oidcContextTypes, setOIDCContextTypes] = useState([]); const currentCredentialRef = useRef(token); const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined); currentCredentialRef.current = token; @@ -338,6 +340,7 @@ export function App() { if (cancelled) return; const identityEnabled = configuration.enabled && configuration.oidcLogin; setOIDCEnabled(identityEnabled); + setOIDCContextTypes(configuration.contextTypes); if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) { await reconcileOIDCBrowserSessionAfterRuntimeChange({ credential: currentCredentialRef.current, @@ -1304,11 +1307,14 @@ export function App() { navigatePath('/'); } - function loginWithOIDC() { + function loginWithOIDC( + contextType: OIDCContextType = + oidcContextTypes.includes('platform') ? 'platform' : 'tenant', + ) { setState('loading'); setError(''); setOIDCCallbackError(null); - void startOIDCLogin().catch((err) => { + void startOIDCLogin(contextType).catch((err) => { setState('error'); setError(err instanceof Error ? err.message : '统一认证登录失败'); }); @@ -1321,9 +1327,13 @@ export function App() { const configuration = await loadOIDCRuntimeConfiguration(true); const identityEnabled = configuration.enabled && configuration.oidcLogin; setOIDCEnabled(identityEnabled); - if (loginEntryAction(identityEnabled) === 'oidc') { + setOIDCContextTypes(configuration.contextTypes); + if ( + loginEntryAction(identityEnabled) === 'oidc' && + configuration.contextTypes.length === 1 + ) { setOIDCCallbackError(null); - await startOIDCLogin(configuration); + await startOIDCLogin(configuration.contextTypes[0], configuration); return; } setState('idle'); @@ -1482,6 +1492,7 @@ export function App() { onSubmitLogin={submitLogin} onSubmitRegister={submitRegister} oidcEnabled={oidcEnabled} + oidcContextTypes={oidcContextTypes} onOIDCLogin={loginWithOIDC} /> ) @@ -1554,6 +1565,7 @@ export function App() { onSubmitLogin={submitLogin} onSubmitRegister={submitRegister} oidcEnabled={oidcEnabled} + oidcContextTypes={oidcContextTypes} onOIDCLogin={loginWithOIDC} /> ) diff --git a/apps/web/src/components/AuthPanel.test.tsx b/apps/web/src/components/AuthPanel.test.tsx index 68f5e52..5135efe 100644 --- a/apps/web/src/components/AuthPanel.test.tsx +++ b/apps/web/src/components/AuthPanel.test.tsx @@ -15,14 +15,16 @@ const baseProps = { onSubmitExternalToken: vi.fn(), onSubmitLogin: vi.fn(), onSubmitRegister: vi.fn(), + oidcContextTypes: ['platform', 'tenant'] as const, onOIDCLogin: vi.fn(), }; describe('AuthPanel', () => { - it('shows only the authentication center entry when OIDC is enabled', () => { + it('shows explicit platform and tenant entries when OIDC is enabled', () => { const html = renderToStaticMarkup(); - expect(html).toContain('使用统一认证中心登录'); + expect(html).toContain('使用平台账号登录'); + expect(html).toContain('使用租户账号登录'); expect(html).not.toContain('用户名或邮箱'); expect(html).not.toContain('注册账号'); expect(html).not.toContain('外部 Token'); diff --git a/apps/web/src/components/AuthPanel.tsx b/apps/web/src/components/AuthPanel.tsx index ce01ca2..0f17023 100644 --- a/apps/web/src/components/AuthPanel.tsx +++ b/apps/web/src/components/AuthPanel.tsx @@ -2,6 +2,7 @@ import type { FormEvent } from 'react'; import { LogIn, UserPlus } from 'lucide-react'; import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, PasswordInput, Tabs } from './ui'; import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types'; +import type { OIDCContextType } from '../lib/oidc'; const tabs = [ { value: 'login', label: '账号登录' }, @@ -23,7 +24,8 @@ export function AuthPanel(props: { onSubmitLogin: (event: FormEvent) => void; onSubmitRegister: (event: FormEvent) => void; oidcEnabled: boolean; - onOIDCLogin: () => void; + oidcContextTypes: readonly OIDCContextType[]; + onOIDCLogin: (contextType: OIDCContextType) => void; }) { return (
@@ -36,10 +38,28 @@ export function AuthPanel(props: { {props.oidcEnabled ? ( - +
+ {props.oidcContextTypes.includes('platform') && ( + + )} + {props.oidcContextTypes.includes('tenant') && ( + + )} +
) : ( <> diff --git a/apps/web/src/lib/oidc.test.ts b/apps/web/src/lib/oidc.test.ts index d36bb9d..6eaf0c0 100644 --- a/apps/web/src/lib/oidc.test.ts +++ b/apps/web/src/lib/oidc.test.ts @@ -7,21 +7,22 @@ describe('OIDC BFF navigation', () => { vi.resetModules(); }); - it('sends only returnTo to the Gateway login endpoint', async () => { + it('binds platform context and returnTo at the Gateway login endpoint', async () => { vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }), + json: async () => ({ enabled: true, oidcLogin: true, contextTypes: ['platform', 'tenant'], loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }), })); const assign = vi.fn(); vi.stubGlobal('window', { location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign }, }); const { startOIDCLogin } = await import('./oidc'); - await startOIDCLogin(); + await startOIDCLogin('platform'); const target = new URL(String(assign.mock.calls[0]?.[0])); expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login'); expect(target.searchParams.get('returnTo')).toBe('/workspace?tab=wallet#balance'); + expect(target.searchParams.get('contextType')).toBe('platform'); expect(target.searchParams.has('client_id')).toBe(false); expect(target.searchParams.has('code_challenge')).toBe(false); }); @@ -33,6 +34,7 @@ describe('OIDC BFF navigation', () => { json: async () => ({ enabled: true, oidcLogin: true, + contextTypes: ['platform', 'tenant'], loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active', @@ -44,12 +46,13 @@ describe('OIDC BFF navigation', () => { }); const { startOIDCLogin } = await import('./oidc'); - await startOIDCLogin(); + await startOIDCLogin('tenant'); const target = new URL(String(assign.mock.calls[0]?.[0])); expect(target.origin).toBe('https://ai.51easyai.com'); expect(target.pathname).toBe('/gateway-api/api/v1/auth/oidc/login'); expect(target.searchParams.get('returnTo')).toBe('/workspace/overview'); + expect(target.searchParams.get('contextType')).toBe('tenant'); }); it('submits logout as a top-level POST without exposing tokens', async () => { diff --git a/apps/web/src/lib/oidc.ts b/apps/web/src/lib/oidc.ts index 81715b5..244d134 100644 --- a/apps/web/src/lib/oidc.ts +++ b/apps/web/src/lib/oidc.ts @@ -3,12 +3,20 @@ const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://loc export type OIDCRuntimeConfiguration = { enabled: boolean; oidcLogin: boolean; + contextTypes: OIDCContextType[]; loginUrl?: string; logoutUrl?: string; status: string; }; -const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' }; +export type OIDCContextType = 'platform' | 'tenant'; + +const disabledRuntime: OIDCRuntimeConfiguration = { + enabled: false, + oidcLogin: false, + contextTypes: [], + status: 'disabled', +}; let runtimeConfiguration = disabledRuntime; let runtimeLoaded = false; let runtimeRequest: Promise | null = null; @@ -64,6 +72,12 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise + item === 'platform' || item === 'tenant', + ) + : [], status: value.status, ...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}), ...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}), @@ -77,12 +91,21 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise