fix: 修复 OIDC 用户预配与跨标签页登录态
增加受控 JIT 本地用户投影,并使用 HttpOnly Cookie 在新标签页恢复认证中心登录态。补充错误语义、安全校验、自动化测试与回滚配置。
This commit is contained in:
@@ -197,7 +197,7 @@ UPDATE gateway_users
|
||||
SET deleted_at = now(),
|
||||
status = 'deleted',
|
||||
user_key = user_key || ':deleted:' || left(id::text, 8),
|
||||
external_user_id = NULL,
|
||||
external_user_id = CASE WHEN source = 'oidc' THEN external_user_id ELSE NULL END,
|
||||
email = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid AND deleted_at IS NULL`, id)
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
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/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOIDCUserNotProvisioned = errors.New("OIDC gateway user is not provisioned")
|
||||
ErrOIDCUserDisabled = errors.New("OIDC gateway user is disabled")
|
||||
ErrOIDCTenantUnavailable = errors.New("OIDC gateway tenant is unavailable")
|
||||
)
|
||||
|
||||
type ResolveOrProvisionOIDCUserInput struct {
|
||||
Issuer string
|
||||
Subject string
|
||||
Username string
|
||||
Roles []string
|
||||
TenantID string
|
||||
GatewayTenantKey string
|
||||
ProvisioningEnabled bool
|
||||
RequestIP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type ResolveOrProvisionOIDCUserResult struct {
|
||||
User *auth.User
|
||||
Created bool
|
||||
AuditID string
|
||||
}
|
||||
|
||||
type oidcUserProjection struct {
|
||||
user GatewayUser
|
||||
userGroupKey string
|
||||
tenantStatus string
|
||||
tenantDeleted bool
|
||||
groupStatus string
|
||||
userDeleted bool
|
||||
}
|
||||
|
||||
func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
|
||||
input = normalizeOIDCUserInput(input)
|
||||
if input.Issuer == "" || input.Subject == "" || input.TenantID == "" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
projection, err := loadOIDCUserProjection(ctx, tx, input.Subject)
|
||||
if err == nil {
|
||||
result, resolveErr := s.syncExistingOIDCUser(ctx, tx, projection, input)
|
||||
if resolveErr != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, resolveErr
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
if !input.ProvisioningEnabled {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
|
||||
}
|
||||
if input.GatewayTenantKey == "" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
|
||||
tenantID, userGroupID, userGroupKey, err := loadOIDCProvisioningTenant(ctx, tx, input.GatewayTenantKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
rolesJSON, err := json.Marshal(input.Roles)
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
userKey := deriveOIDCUserKey(input.Issuer, input.Subject)
|
||||
username := input.Username
|
||||
if username == "" {
|
||||
username = "oidc-" + strings.TrimPrefix(userKey, "oidc:")[:12]
|
||||
}
|
||||
metadataJSON := `{"provisioningMode":"oidc-jit"}`
|
||||
|
||||
createdUser, 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', $2, $3, $4::uuid, $5, $6, $7::uuid, $8::jsonb, '{}'::jsonb, $9::jsonb,
|
||||
'active', now(), now(), now())
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING `+userColumns,
|
||||
userKey,
|
||||
input.Subject,
|
||||
username,
|
||||
tenantID,
|
||||
input.TenantID,
|
||||
input.GatewayTenantKey,
|
||||
userGroupID,
|
||||
string(rolesJSON),
|
||||
metadataJSON,
|
||||
))
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
projection, err = loadOIDCUserProjection(ctx, tx, input.Subject)
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
result, resolveErr := s.syncExistingOIDCUser(ctx, tx, projection, input)
|
||||
if resolveErr != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, resolveErr
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if _, err := s.ensureWalletAccount(ctx, tx, createdUser.ID, "resource"); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
subjectHash := sha256.Sum256([]byte(input.Subject))
|
||||
audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
|
||||
Category: "identity",
|
||||
Action: "identity.oidc_user.provisioned",
|
||||
ActorGatewayUserID: createdUser.ID,
|
||||
ActorUsername: createdUser.Username,
|
||||
ActorSource: "oidc",
|
||||
ActorRoles: createdUser.Roles,
|
||||
TargetType: "gateway_user",
|
||||
TargetID: createdUser.ID,
|
||||
TargetGatewayUserID: createdUser.ID,
|
||||
TargetGatewayTenantID: createdUser.GatewayTenantID,
|
||||
RequestIP: input.RequestIP,
|
||||
UserAgent: input.UserAgent,
|
||||
AfterState: map[string]any{
|
||||
"source": "oidc",
|
||||
"tenantKey": createdUser.TenantKey,
|
||||
"userGroupId": createdUser.DefaultUserGroupID,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"provisioningMode": "oidc-jit",
|
||||
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
return ResolveOrProvisionOIDCUserResult{
|
||||
User: authUserFromOIDCProjection(createdUser, userGroupKey),
|
||||
Created: true,
|
||||
AuditID: audit.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if projection.tenantDeleted || projection.tenantStatus != "active" || projection.groupStatus != "active" ||
|
||||
projection.user.GatewayTenantID == "" || projection.user.DefaultUserGroupID == "" || projection.userGroupKey == "" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
if input.GatewayTenantKey != "" && projection.user.TenantKey != input.GatewayTenantKey {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
if projection.user.TenantID != "" && projection.user.TenantID != input.TenantID {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
||||
}
|
||||
|
||||
rolesJSON, err := json.Marshal(input.Roles)
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
updated, 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'
|
||||
AND deleted_at IS NULL
|
||||
AND status = 'active'
|
||||
RETURNING `+userColumns,
|
||||
projection.user.ID,
|
||||
input.Username,
|
||||
string(rolesJSON),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
|
||||
}
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
return ResolveOrProvisionOIDCUserResult{User: authUserFromOIDCProjection(updated, projection.userGroupKey)}, nil
|
||||
}
|
||||
|
||||
func loadOIDCUserProjection(ctx context.Context, tx pgx.Tx, subject string) (oidcUserProjection, error) {
|
||||
var projection oidcUserProjection
|
||||
var roles []byte
|
||||
var authProfile []byte
|
||||
var metadata []byte
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
u.id::text, u.user_key, u.source, COALESCE(u.external_user_id, ''), u.username,
|
||||
COALESCE(u.display_name, ''), COALESCE(u.email, ''), COALESCE(u.phone, ''), COALESCE(u.avatar_url, ''),
|
||||
COALESCE(u.gateway_tenant_id::text, ''), COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, ''),
|
||||
COALESCE(u.default_user_group_id::text, ''), u.roles, u.auth_profile, u.metadata,
|
||||
u.status, COALESCE(u.last_login_at::text, ''), COALESCE(u.synced_at::text, ''), COALESCE(u.source_updated_at::text, ''),
|
||||
u.created_at, u.updated_at,
|
||||
COALESCE(g.group_key, ''), COALESCE(t.status, ''), t.deleted_at IS NOT NULL,
|
||||
COALESCE(g.status, ''), u.deleted_at IS NOT NULL
|
||||
FROM gateway_users u
|
||||
LEFT JOIN gateway_tenants t ON t.id = u.gateway_tenant_id
|
||||
LEFT JOIN gateway_user_groups g ON g.id = u.default_user_group_id
|
||||
WHERE u.source = 'oidc' AND u.external_user_id = $1
|
||||
FOR UPDATE OF u`, subject).Scan(
|
||||
&projection.user.ID,
|
||||
&projection.user.UserKey,
|
||||
&projection.user.Source,
|
||||
&projection.user.ExternalUserID,
|
||||
&projection.user.Username,
|
||||
&projection.user.DisplayName,
|
||||
&projection.user.Email,
|
||||
&projection.user.Phone,
|
||||
&projection.user.AvatarURL,
|
||||
&projection.user.GatewayTenantID,
|
||||
&projection.user.TenantID,
|
||||
&projection.user.TenantKey,
|
||||
&projection.user.DefaultUserGroupID,
|
||||
&roles,
|
||||
&authProfile,
|
||||
&metadata,
|
||||
&projection.user.Status,
|
||||
&projection.user.LastLoginAt,
|
||||
&projection.user.SyncedAt,
|
||||
&projection.user.SourceUpdatedAt,
|
||||
&projection.user.CreatedAt,
|
||||
&projection.user.UpdatedAt,
|
||||
&projection.userGroupKey,
|
||||
&projection.tenantStatus,
|
||||
&projection.tenantDeleted,
|
||||
&projection.groupStatus,
|
||||
&projection.userDeleted,
|
||||
)
|
||||
if err != nil {
|
||||
return oidcUserProjection{}, err
|
||||
}
|
||||
projection.user.Roles = decodeStringArray(roles)
|
||||
projection.user.AuthProfile = decodeObject(authProfile)
|
||||
projection.user.Metadata = decodeObject(metadata)
|
||||
return projection, nil
|
||||
}
|
||||
|
||||
func loadOIDCProvisioningTenant(ctx context.Context, tx pgx.Tx, tenantKey string) (string, string, string, error) {
|
||||
var tenantID string
|
||||
var groupID string
|
||||
var groupKey string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT t.id::text, t.default_user_group_id::text, g.group_key
|
||||
FROM gateway_tenants t
|
||||
JOIN gateway_user_groups g ON g.id = t.default_user_group_id
|
||||
WHERE t.tenant_key = $1
|
||||
AND t.status = 'active'
|
||||
AND t.deleted_at IS NULL
|
||||
AND g.status = 'active'`, tenantKey).Scan(&tenantID, &groupID, &groupKey)
|
||||
return tenantID, groupID, groupKey, err
|
||||
}
|
||||
|
||||
func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProvisionOIDCUserInput {
|
||||
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
|
||||
input.Subject = strings.TrimSpace(input.Subject)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||
input.GatewayTenantKey = strings.TrimSpace(input.GatewayTenantKey)
|
||||
input.RequestIP = strings.TrimSpace(input.RequestIP)
|
||||
input.UserAgent = strings.TrimSpace(input.UserAgent)
|
||||
input.Roles = normalizeOIDCRoles(input.Roles)
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeOIDCRoles(roles []string) []string {
|
||||
result := make([]string, 0, len(roles))
|
||||
seen := make(map[string]struct{}, len(roles))
|
||||
for _, role := range roles {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[role]; ok {
|
||||
continue
|
||||
}
|
||||
seen[role] = struct{}{}
|
||||
result = append(result, role)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func deriveOIDCUserKey(issuer string, subject string) string {
|
||||
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
|
||||
sum := sha256.Sum256([]byte(issuer + "\x00" + strings.TrimSpace(subject)))
|
||||
return fmt.Sprintf("oidc:%x", sum)
|
||||
}
|
||||
|
||||
func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.User {
|
||||
groupKeys := []string(nil)
|
||||
if userGroupKey != "" {
|
||||
groupKeys = []string{userGroupKey}
|
||||
}
|
||||
return &auth.User{
|
||||
ID: user.ExternalUserID,
|
||||
Username: user.Username,
|
||||
Roles: user.Roles,
|
||||
TenantID: user.TenantID,
|
||||
GatewayTenantID: user.GatewayTenantID,
|
||||
TenantKey: user.TenantKey,
|
||||
Source: "oidc",
|
||||
GatewayUserID: user.ID,
|
||||
UserGroupID: user.DefaultUserGroupID,
|
||||
UserGroupKey: userGroupKey,
|
||||
UserGroupKeys: groupKeys,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(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-jit-" + suffix
|
||||
input := ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: "https://auth.test.example/realms/easyai",
|
||||
Subject: subject,
|
||||
Username: "jit-user-" + suffix,
|
||||
Roles: []string{"basic"},
|
||||
TenantID: "auth-center-test-tenant",
|
||||
GatewayTenantKey: "default",
|
||||
ProvisioningEnabled: true,
|
||||
}
|
||||
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' AND external_user_id = $1
|
||||
)`, subject)
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject)
|
||||
})
|
||||
|
||||
const callers = 12
|
||||
results := make([]ResolveOrProvisionOIDCUserResult, callers)
|
||||
errs := make([]error, callers)
|
||||
var wg sync.WaitGroup
|
||||
for index := 0; index < callers; index++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
results[index], errs[index] = db.ResolveOrProvisionOIDCUser(ctx, input)
|
||||
}(index)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
firstID := ""
|
||||
createdCount := 0
|
||||
auditID := ""
|
||||
for index, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent resolve %d: %v", index, err)
|
||||
}
|
||||
result := results[index]
|
||||
if result.User == nil || result.User.GatewayUserID == "" {
|
||||
t.Fatalf("concurrent resolve %d returned no local user: %+v", index, result)
|
||||
}
|
||||
if firstID == "" {
|
||||
firstID = result.User.GatewayUserID
|
||||
}
|
||||
if result.User.GatewayUserID != firstID {
|
||||
t.Fatalf("concurrent resolve returned different users: %q and %q", firstID, result.User.GatewayUserID)
|
||||
}
|
||||
if result.Created {
|
||||
createdCount++
|
||||
auditID = result.AuditID
|
||||
}
|
||||
}
|
||||
if createdCount != 1 {
|
||||
t.Fatalf("created count = %d, want 1", createdCount)
|
||||
}
|
||||
if auditID == "" {
|
||||
t.Fatal("first provision must return an audit ID")
|
||||
}
|
||||
|
||||
var users, wallets, audits int
|
||||
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject).Scan(&users); err != nil {
|
||||
t.Fatalf("count OIDC users: %v", err)
|
||||
}
|
||||
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_wallet_accounts WHERE gateway_user_id = $1::uuid AND currency = 'resource'`, firstID).Scan(&wallets); err != nil {
|
||||
t.Fatalf("count wallets: %v", err)
|
||||
}
|
||||
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_audit_logs WHERE action = 'identity.oidc_user.provisioned' AND target_gateway_user_id = $1::uuid`, firstID).Scan(&audits); err != nil {
|
||||
t.Fatalf("count audits: %v", err)
|
||||
}
|
||||
if users != 1 || wallets != 1 || audits != 1 {
|
||||
t.Fatalf("users=%d wallets=%d audits=%d, want one of each", users, wallets, audits)
|
||||
}
|
||||
var auditProjection string
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(actor_user_id, '') || metadata::text || after_state::text
|
||||
FROM gateway_audit_logs
|
||||
WHERE id = $1::uuid`, auditID).Scan(&auditProjection); err != nil {
|
||||
t.Fatalf("read OIDC provisioning audit: %v", err)
|
||||
}
|
||||
if strings.Contains(auditProjection, subject) || strings.Contains(auditProjection, input.Issuer) {
|
||||
t.Fatal("OIDC provisioning audit exposed raw external identity claims")
|
||||
}
|
||||
if _, err := db.pool.Exec(ctx, `
|
||||
UPDATE gateway_users
|
||||
SET display_name = 'Manual Display Name',
|
||||
email = 'manual-profile@example.test',
|
||||
metadata = metadata || '{"manualProfile":true}'::jsonb
|
||||
WHERE id = $1::uuid`, firstID); err != nil {
|
||||
t.Fatalf("seed manually managed profile fields: %v", err)
|
||||
}
|
||||
|
||||
input.Username = "jit-user-renamed-" + suffix
|
||||
input.Roles = []string{"basic", "admin"}
|
||||
input.ProvisioningEnabled = false
|
||||
repeated, err := db.ResolveOrProvisionOIDCUser(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat resolve: %v", err)
|
||||
}
|
||||
if repeated.Created || repeated.AuditID != "" || repeated.User.GatewayUserID != firstID {
|
||||
t.Fatalf("unexpected repeat result: %+v", repeated)
|
||||
}
|
||||
if repeated.User.Username != input.Username || !containsOIDCTestRole(repeated.User.Roles, "admin") {
|
||||
t.Fatalf("repeat resolve did not sync token projection: %+v", repeated.User)
|
||||
}
|
||||
var displayName, email string
|
||||
var manualProfile bool
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE((metadata->>'manualProfile')::boolean, false)
|
||||
FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &manualProfile); err != nil {
|
||||
t.Fatalf("read manually managed profile fields: %v", err)
|
||||
}
|
||||
if displayName != "Manual Display Name" || email != "manual-profile@example.test" || !manualProfile {
|
||||
t.Fatalf("repeat resolve overwrote manually managed profile fields")
|
||||
}
|
||||
|
||||
if _, err := db.pool.Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, firstID); err != nil {
|
||||
t.Fatalf("disable OIDC user: %v", err)
|
||||
}
|
||||
if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCUserDisabled) {
|
||||
t.Fatalf("disabled resolve error = %v, want ErrOIDCUserDisabled", err)
|
||||
}
|
||||
var status string
|
||||
if err := db.pool.QueryRow(ctx, `SELECT status FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&status); err != nil {
|
||||
t.Fatalf("read disabled status: %v", err)
|
||||
}
|
||||
if status != "disabled" {
|
||||
t.Fatalf("disabled OIDC user was reactivated: %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(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()
|
||||
|
||||
subject := "platform-jit-missing-" + time.Now().UTC().Format("20060102150405.000000000")
|
||||
input := ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: "https://auth.test.example/realms/easyai",
|
||||
Subject: subject,
|
||||
Username: "missing-user",
|
||||
Roles: []string{"basic"},
|
||||
TenantID: "auth-center-test-tenant",
|
||||
GatewayTenantKey: "missing-tenant-key",
|
||||
}
|
||||
|
||||
if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCUserNotProvisioned) {
|
||||
t.Fatalf("disabled JIT error = %v, want ErrOIDCUserNotProvisioned", err)
|
||||
}
|
||||
input.ProvisioningEnabled = true
|
||||
if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCTenantUnavailable) {
|
||||
t.Fatalf("missing tenant error = %v, want ErrOIDCTenantUnavailable", err)
|
||||
}
|
||||
var users int
|
||||
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject).Scan(&users); err != nil {
|
||||
t.Fatalf("count rejected users: %v", err)
|
||||
}
|
||||
if users != 0 {
|
||||
t.Fatalf("rejected OIDC request created %d users", users)
|
||||
}
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102150405.000000000")
|
||||
groupKey := "jit-disabled-group-" + suffix
|
||||
tenantKey := "jit-disabled-tenant-" + suffix
|
||||
var groupID string
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_user_groups (group_key, name, status)
|
||||
VALUES ($1, 'OIDC JIT disabled group test', 'active')
|
||||
RETURNING id::text`, groupKey).Scan(&groupID); err != nil {
|
||||
t.Fatalf("create disabled-mapping test group: %v", err)
|
||||
}
|
||||
var tenantID string
|
||||
if err := db.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_tenants (tenant_key, name, default_user_group_id, status)
|
||||
VALUES ($1, 'OIDC JIT disabled tenant test', $2::uuid, 'disabled')
|
||||
RETURNING id::text`, tenantKey, groupID).Scan(&tenantID); err != nil {
|
||||
t.Fatalf("create disabled-mapping test tenant: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tenants WHERE id = $1::uuid`, tenantID)
|
||||
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, groupID)
|
||||
})
|
||||
|
||||
disabledMappingInput := input
|
||||
disabledMappingInput.Subject += "-disabled-mapping"
|
||||
disabledMappingInput.GatewayTenantKey = tenantKey
|
||||
if _, err := db.ResolveOrProvisionOIDCUser(ctx, disabledMappingInput); !errorsIs(err, ErrOIDCTenantUnavailable) {
|
||||
t.Fatalf("disabled tenant error = %v, want ErrOIDCTenantUnavailable", err)
|
||||
}
|
||||
if _, err := db.pool.Exec(ctx, `UPDATE gateway_tenants SET status = 'active' WHERE id = $1::uuid`, tenantID); err != nil {
|
||||
t.Fatalf("enable test tenant: %v", err)
|
||||
}
|
||||
if _, err := db.pool.Exec(ctx, `UPDATE gateway_user_groups SET status = 'disabled' WHERE id = $1::uuid`, groupID); err != nil {
|
||||
t.Fatalf("disable test group: %v", err)
|
||||
}
|
||||
if _, err := db.ResolveOrProvisionOIDCUser(ctx, disabledMappingInput); !errorsIs(err, ErrOIDCTenantUnavailable) {
|
||||
t.Fatalf("disabled user group error = %v, want ErrOIDCTenantUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCUserKeyIsStableAndDoesNotExposeClaims(t *testing.T) {
|
||||
issuer := "https://auth.test.example/realms/easyai"
|
||||
subject := "platform-sensitive-subject"
|
||||
first := deriveOIDCUserKey(issuer, subject)
|
||||
second := deriveOIDCUserKey(issuer+"/", subject)
|
||||
if first == "" || first != second {
|
||||
t.Fatalf("OIDC user key is not stable: %q != %q", first, second)
|
||||
}
|
||||
if strings.Contains(first, subject) || strings.Contains(first, issuer) {
|
||||
t.Fatalf("OIDC user key exposes raw claims: %q", first)
|
||||
}
|
||||
if first == deriveOIDCUserKey(issuer, subject+"-other") {
|
||||
t.Fatal("different subjects produced the same OIDC user key")
|
||||
}
|
||||
}
|
||||
|
||||
func errorsIs(err error, target error) bool {
|
||||
for err != nil {
|
||||
if err == target {
|
||||
return true
|
||||
}
|
||||
type unwrapper interface{ Unwrap() error }
|
||||
wrapped, ok := err.(unwrapper)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
err = wrapped.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsOIDCTestRole(roles []string, expected string) bool {
|
||||
for _, role := range roles {
|
||||
if role == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyOIDCJITTestMigrations(t *testing.T, ctx context.Context, databaseURL string) {
|
||||
t.Helper()
|
||||
_, filename, _, _ := runtime.Caller(0)
|
||||
migrationFiles, err := filepath.Glob(filepath.Join(filepath.Dir(filename), "..", "..", "migrations", "*.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration files: %v", err)
|
||||
}
|
||||
sort.Strings(migrationFiles)
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect migration db: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
t.Fatalf("ensure schema migrations: %v", err)
|
||||
}
|
||||
for _, migrationPath := range migrationFiles {
|
||||
version := strings.TrimSuffix(filepath.Base(migrationPath), filepath.Ext(migrationPath))
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&exists); err != nil {
|
||||
t.Fatalf("check migration %s: %v", version, err)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
migration, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", version, err)
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin migration %s: %v", version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(migration)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("apply migration %s: %v", version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, version); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("record migration %s: %v", version, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit migration %s: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user