fix(oidc): 同步认证中心用户资料到本地用户
从已验证的 OIDC Claim 提取用户名、显示名称、邮箱、手机号和头像,并覆盖单租户、多租户及平台用户的 JIT 创建与重复登录同步。\n\n保留 metadata.manualProfile 标记下的人工资料,限制字段长度且仅接收已验证联系方式与 HTTPS 头像。已通过 auth、httpapi、store 测试及临时 PostgreSQL 集成验证。
This commit is contained in:
@@ -36,6 +36,10 @@ const (
|
||||
type User struct {
|
||||
ID string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"-"`
|
||||
Email string `json:"-"`
|
||||
Phone string `json:"-"`
|
||||
AvatarURL string `json:"-"`
|
||||
Roles []string `json:"role,omitempty"`
|
||||
ContextType string `json:"contextType,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
|
||||
@@ -252,18 +252,51 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
return nil, oidcUnauthorized("TOKEN_INACTIVE", "token is inactive", err)
|
||||
}
|
||||
}
|
||||
username := stringClaim(claims, "preferred_username")
|
||||
username := oidcProfileText(claims, "preferred_username", 320)
|
||||
if username == "" {
|
||||
username = stringClaim(claims, "username")
|
||||
username = oidcProfileText(claims, "username", 320)
|
||||
}
|
||||
return &User{
|
||||
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
|
||||
ID: stringClaim(claims, "sub"), Username: username,
|
||||
DisplayName: oidcProfileText(claims, "name", 200),
|
||||
Email: oidcVerifiedProfileText(claims, "email", "email_verified", 320),
|
||||
Phone: oidcVerifiedProfileText(claims, "phone_number", "phone_number_verified", 64),
|
||||
AvatarURL: safeOIDCProfileURL(oidcProfileText(claims, "picture", 2048)),
|
||||
Roles: roles,
|
||||
ContextType: contextType, TenantID: tenantID, Source: "oidc",
|
||||
TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
|
||||
ApplicationID: v.config.ApplicationID, OIDCClientID: clientID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func oidcProfileText(claims jwt.MapClaims, key string, limit int) string {
|
||||
value := strings.TrimSpace(stringClaim(claims, key))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) > limit {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func oidcVerifiedProfileText(claims jwt.MapClaims, key, verifiedKey string, limit int) string {
|
||||
verified, ok := claims[verifiedKey].(bool)
|
||||
if !ok || !verified {
|
||||
return ""
|
||||
}
|
||||
return oidcProfileText(claims, key, limit)
|
||||
}
|
||||
|
||||
func safeOIDCProfileURL(value string) string {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
type oidcValidationError struct {
|
||||
category string
|
||||
reason string
|
||||
|
||||
@@ -66,11 +66,27 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.ID != "platform-subject" || user.TenantID != "tenant-1" || user.Source != "oidc" ||
|
||||
user.Username != "acceptance" || user.DisplayName != "王小明" ||
|
||||
user.Email != "real.user@example.test" || user.Phone != "+8613800000000" ||
|
||||
user.AvatarURL != "https://static.example.test/avatar.png" ||
|
||||
len(user.Roles) != 1 || user.Roles[0] != "admin" {
|
||||
t.Fatalf("unexpected OIDC user: %#v", user)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unverified := signedOIDCToken(t, issuer, "rsa-key", jwt.SigningMethodRS256, rsaKey, func(claims jwt.MapClaims) {
|
||||
claims["email_verified"] = false
|
||||
claims["phone_number_verified"] = false
|
||||
claims["picture"] = "javascript:alert(1)"
|
||||
})
|
||||
user, err := verifier.Verify(context.Background(), unverified)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Email != "" || user.Phone != "" || user.AvatarURL != "" {
|
||||
t.Fatalf("untrusted profile claims were accepted: %#v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
|
||||
@@ -400,7 +416,10 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod,
|
||||
"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(),
|
||||
"name": "王小明", "email": "real.user@example.test", "email_verified": true,
|
||||
"phone_number": "+8613800000000", "phone_number_verified": true,
|
||||
"picture": "https://static.example.test/avatar.png",
|
||||
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
}
|
||||
if mutate != nil {
|
||||
|
||||
@@ -167,6 +167,10 @@ func (s *Server) resolveOIDCUserProjectionWithTenantContext(
|
||||
ApplicationID: revision.ApplicationID,
|
||||
Subject: user.ID,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
AvatarURL: user.AvatarURL,
|
||||
Roles: user.Roles,
|
||||
ContextType: user.ContextType,
|
||||
TenantID: user.TenantID,
|
||||
|
||||
@@ -89,6 +89,10 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
DisplayName: "王小明",
|
||||
Email: "alice@example.test",
|
||||
Phone: "+8613800000000",
|
||||
AvatarURL: "https://static.example.test/avatar.png",
|
||||
Roles: []string{"basic"},
|
||||
ContextType: "tenant",
|
||||
TenantID: "external-tenant",
|
||||
@@ -100,7 +104,9 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", recorder.Code)
|
||||
}
|
||||
if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled {
|
||||
if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled ||
|
||||
resolver.input.DisplayName != "王小明" || resolver.input.Email != "alice@example.test" ||
|
||||
resolver.input.Phone != "+8613800000000" || resolver.input.AvatarURL != "https://static.example.test/avatar.png" {
|
||||
t.Fatalf("unexpected resolver call: calls=%d input=%+v", resolver.calls, resolver.input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +91,15 @@ func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input
|
||||
}
|
||||
rolesJSON, _ := json.Marshal(input.Roles)
|
||||
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()
|
||||
username=COALESCE(NULLIF($2,''),username),
|
||||
display_name=CASE WHEN metadata->>'manualProfile'='true' THEN display_name ELSE COALESCE(NULLIF($3,''),display_name) END,
|
||||
email=CASE WHEN metadata->>'manualProfile'='true' THEN email ELSE COALESCE(NULLIF($4,''),email) END,
|
||||
phone=CASE WHEN metadata->>'manualProfile'='true' THEN phone ELSE COALESCE(NULLIF($5,''),phone) END,
|
||||
avatar_url=CASE WHEN metadata->>'manualProfile'='true' THEN avatar_url ELSE COALESCE(NULLIF($6,''),avatar_url) END,
|
||||
roles=$7::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now()
|
||||
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL
|
||||
RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON)))
|
||||
RETURNING `+userColumns, user.ID, input.Username, input.DisplayName, input.Email,
|
||||
input.Phone, input.AvatarURL, string(rolesJSON)))
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
@@ -203,13 +209,16 @@ func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindin
|
||||
rolesJSON, _ := json.Marshal(input.Roles)
|
||||
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"})
|
||||
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
|
||||
user_key,source,external_user_id,username,gateway_tenant_id,tenant_id,tenant_key,
|
||||
user_key,source,external_user_id,username,display_name,email,phone,avatar_url,
|
||||
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',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb,
|
||||
) VALUES($1,'oidc_v2',NULL,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),
|
||||
$7::uuid,$8,$9,$10::uuid,$11::jsonb,'{}'::jsonb,$12::jsonb,
|
||||
'active',now(),now(),now())
|
||||
ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at
|
||||
RETURNING `+userColumns,
|
||||
userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata),
|
||||
userKey, username, input.DisplayName, input.Email, input.Phone, input.AvatarURL,
|
||||
gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata),
|
||||
))
|
||||
if err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
|
||||
@@ -64,7 +64,12 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser(
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
|
||||
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,
|
||||
username=COALESCE(NULLIF($2,''),username),
|
||||
display_name=CASE WHEN metadata->>'manualProfile'='true' THEN display_name ELSE COALESCE(NULLIF($3,''),display_name) END,
|
||||
email=CASE WHEN metadata->>'manualProfile'='true' THEN email ELSE COALESCE(NULLIF($4,''),email) END,
|
||||
phone=CASE WHEN metadata->>'manualProfile'='true' THEN phone ELSE COALESCE(NULLIF($5,''),phone) END,
|
||||
avatar_url=CASE WHEN metadata->>'manualProfile'='true' THEN avatar_url ELSE COALESCE(NULLIF($6,''),avatar_url) END,
|
||||
roles=$7::jsonb,
|
||||
last_login_at=now(),synced_at=now(),source_updated_at=now(),
|
||||
updated_at=now()
|
||||
WHERE id=$1::uuid AND source='oidc_v2_platform'
|
||||
@@ -72,6 +77,10 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser(
|
||||
RETURNING `+userColumns,
|
||||
user.ID,
|
||||
input.Username,
|
||||
input.DisplayName,
|
||||
input.Email,
|
||||
input.Phone,
|
||||
input.AvatarURL,
|
||||
string(rolesJSON),
|
||||
))
|
||||
if err != nil {
|
||||
@@ -189,17 +198,21 @@ func (s *Store) createOIDCPlatformUser(
|
||||
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,
|
||||
user_key,source,external_user_id,username,display_name,email,phone,avatar_url,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()
|
||||
$1,'oidc_v2_platform',NULL,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),
|
||||
$7::uuid,NULL,$8,$9::uuid,$10::jsonb,'{}'::jsonb,$11::jsonb,'active',now(),now(),now()
|
||||
)
|
||||
ON CONFLICT(user_key) DO NOTHING
|
||||
RETURNING `+userColumns,
|
||||
userKey,
|
||||
username,
|
||||
input.DisplayName,
|
||||
input.Email,
|
||||
input.Phone,
|
||||
input.AvatarURL,
|
||||
gatewayTenantID,
|
||||
oidcPlatformTenantKey,
|
||||
groupID,
|
||||
|
||||
@@ -2,6 +2,31 @@ package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAuthUserFromOIDCProjectionIncludesProfile(t *testing.T) {
|
||||
projected := authUserFromOIDCProjection(GatewayUser{
|
||||
ID: "gateway-user", ExternalUserID: "external-user", Username: "alice",
|
||||
DisplayName: "王小明", Email: "alice@example.test", Phone: "+8613800000000",
|
||||
AvatarURL: "https://static.example.test/avatar.png",
|
||||
}, "default")
|
||||
if projected.DisplayName != "王小明" || projected.Email != "alice@example.test" ||
|
||||
projected.Phone != "+8613800000000" || projected.AvatarURL != "https://static.example.test/avatar.png" {
|
||||
t.Fatalf("OIDC profile was not projected: %#v", projected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOIDCUserInputBoundsProfileClaims(t *testing.T) {
|
||||
input := normalizeOIDCUserInput(ResolveOrProvisionOIDCUserInput{
|
||||
DisplayName: " 王小明 ",
|
||||
Email: " alice@example.test ",
|
||||
Phone: " +8613800000000 ",
|
||||
AvatarURL: " https://static.example.test/avatar.png ",
|
||||
})
|
||||
if input.DisplayName != "王小明" || input.Email != "alice@example.test" ||
|
||||
input.Phone != "+8613800000000" || input.AvatarURL != "https://static.example.test/avatar.png" {
|
||||
t.Fatalf("OIDC profile was not normalized: %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProjectionKindRequiresExplicitCompatibleContext(t *testing.T) {
|
||||
applicationID := "11111111-1111-4111-8111-111111111111"
|
||||
tests := []struct {
|
||||
|
||||
@@ -26,6 +26,10 @@ type ResolveOrProvisionOIDCUserInput struct {
|
||||
ApplicationID string
|
||||
Subject string
|
||||
Username string
|
||||
DisplayName string
|
||||
Email string
|
||||
Phone string
|
||||
AvatarURL string
|
||||
Roles []string
|
||||
ContextType string
|
||||
TenantID string
|
||||
@@ -122,16 +126,22 @@ func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrP
|
||||
|
||||
createdUser, err := scanUser(tx.QueryRow(ctx, `
|
||||
INSERT INTO gateway_users (
|
||||
user_key, source, external_user_id, username, gateway_tenant_id, tenant_id, tenant_key,
|
||||
user_key, source, external_user_id, username, display_name, email, phone, avatar_url,
|
||||
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,
|
||||
VALUES ($1, 'oidc', $2, $3, NULLIF($4, ''), NULLIF($5, ''), NULLIF($6, ''), NULLIF($7, ''),
|
||||
$8::uuid, $9, $10, $11::uuid, $12::jsonb, '{}'::jsonb, $13::jsonb,
|
||||
'active', now(), now(), now())
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING `+userColumns,
|
||||
userKey,
|
||||
input.Subject,
|
||||
username,
|
||||
input.DisplayName,
|
||||
input.Email,
|
||||
input.Phone,
|
||||
input.AvatarURL,
|
||||
tenantID,
|
||||
input.TenantID,
|
||||
input.GatewayTenantKey,
|
||||
@@ -244,7 +254,11 @@ func (s *Store) syncExistingOIDCUser(ctx context.Context, tx pgx.Tx, projection
|
||||
updated, err := scanUser(tx.QueryRow(ctx, `
|
||||
UPDATE gateway_users
|
||||
SET username = COALESCE(NULLIF($2, ''), username),
|
||||
roles = $3::jsonb,
|
||||
display_name = CASE WHEN metadata->>'manualProfile' = 'true' THEN display_name ELSE COALESCE(NULLIF($3, ''), display_name) END,
|
||||
email = CASE WHEN metadata->>'manualProfile' = 'true' THEN email ELSE COALESCE(NULLIF($4, ''), email) END,
|
||||
phone = CASE WHEN metadata->>'manualProfile' = 'true' THEN phone ELSE COALESCE(NULLIF($5, ''), phone) END,
|
||||
avatar_url = CASE WHEN metadata->>'manualProfile' = 'true' THEN avatar_url ELSE COALESCE(NULLIF($6, ''), avatar_url) END,
|
||||
roles = $7::jsonb,
|
||||
last_login_at = now(),
|
||||
synced_at = now(),
|
||||
source_updated_at = now(),
|
||||
@@ -256,6 +270,10 @@ WHERE id = $1::uuid
|
||||
RETURNING `+userColumns,
|
||||
projection.user.ID,
|
||||
input.Username,
|
||||
input.DisplayName,
|
||||
input.Email,
|
||||
input.Phone,
|
||||
input.AvatarURL,
|
||||
string(rolesJSON),
|
||||
))
|
||||
if err != nil {
|
||||
@@ -343,6 +361,10 @@ 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.DisplayName = limitOIDCProfileText(input.DisplayName, 200)
|
||||
input.Email = limitOIDCProfileText(input.Email, 320)
|
||||
input.Phone = limitOIDCProfileText(input.Phone, 64)
|
||||
input.AvatarURL = limitOIDCProfileText(input.AvatarURL, 2048)
|
||||
input.ContextType = strings.TrimSpace(input.ContextType)
|
||||
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
|
||||
@@ -359,6 +381,15 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
|
||||
return input
|
||||
}
|
||||
|
||||
func limitOIDCProfileText(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
if len(runes) > limit {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeOIDCRoles(roles []string) []string {
|
||||
result := make([]string, 0, len(roles))
|
||||
seen := make(map[string]struct{}, len(roles))
|
||||
@@ -390,6 +421,10 @@ func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.Use
|
||||
return &auth.User{
|
||||
ID: user.ExternalUserID,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
AvatarURL: user.AvatarURL,
|
||||
Roles: user.Roles,
|
||||
ContextType: "tenant",
|
||||
TenantID: user.TenantID,
|
||||
|
||||
@@ -39,7 +39,9 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
|
||||
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
|
||||
return ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
|
||||
Roles: []string{"basic"}, ContextType: "tenant",
|
||||
DisplayName: "Shared User", Email: "shared@example.test", Phone: "+8613800000000",
|
||||
AvatarURL: "https://static.example.test/shared.png",
|
||||
Roles: []string{"basic"}, ContextType: "tenant",
|
||||
TenantID: tenantID, TenantMode: "multi_tenant",
|
||||
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
|
||||
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
|
||||
@@ -69,7 +71,9 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
|
||||
if userA == nil {
|
||||
userA = result.User
|
||||
}
|
||||
if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID {
|
||||
if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID ||
|
||||
result.User.DisplayName != "Shared User" || result.User.Email != "shared@example.test" ||
|
||||
result.User.Phone != "+8613800000000" || result.User.AvatarURL != "https://static.example.test/shared.png" {
|
||||
t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User)
|
||||
}
|
||||
if result.Created {
|
||||
@@ -177,6 +181,10 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
|
||||
Issuer: "https://auth.test.example/issuer/shared",
|
||||
Subject: subject,
|
||||
Username: "jit-user-" + suffix,
|
||||
DisplayName: "JIT User",
|
||||
Email: "jit-user@example.test",
|
||||
Phone: "+8613900000000",
|
||||
AvatarURL: "https://static.example.test/jit-user.png",
|
||||
Roles: []string{"basic"},
|
||||
ContextType: "tenant",
|
||||
TenantID: "auth-center-test-tenant",
|
||||
@@ -214,7 +222,9 @@ WHERE target_gateway_user_id IN (
|
||||
t.Fatalf("concurrent resolve %d: %v", index, err)
|
||||
}
|
||||
result := results[index]
|
||||
if result.User == nil || result.User.GatewayUserID == "" {
|
||||
if result.User == nil || result.User.GatewayUserID == "" ||
|
||||
result.User.DisplayName != input.DisplayName || result.User.Email != input.Email ||
|
||||
result.User.Phone != input.Phone || result.User.AvatarURL != input.AvatarURL {
|
||||
t.Fatalf("concurrent resolve %d returned no local user: %+v", index, result)
|
||||
}
|
||||
if firstID == "" {
|
||||
@@ -262,12 +272,18 @@ WHERE id = $1::uuid`, auditID).Scan(&auditProjection); err != nil {
|
||||
UPDATE gateway_users
|
||||
SET display_name = 'Manual Display Name',
|
||||
email = 'manual-profile@example.test',
|
||||
phone = '+8613000000000',
|
||||
avatar_url = 'https://static.example.test/manual-profile.png',
|
||||
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.DisplayName = "Remote Display Name"
|
||||
input.Email = "remote-profile@example.test"
|
||||
input.Phone = "+8613700000000"
|
||||
input.AvatarURL = "https://static.example.test/remote-profile.png"
|
||||
input.Roles = []string{"basic", "admin"}
|
||||
input.ProvisioningEnabled = false
|
||||
repeated, err := db.ResolveOrProvisionOIDCUser(ctx, input)
|
||||
@@ -280,14 +296,16 @@ WHERE id = $1::uuid`, firstID); err != nil {
|
||||
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 displayName, email, phone, avatarURL 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 {
|
||||
SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||
COALESCE((metadata->>'manualProfile')::boolean, false)
|
||||
FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &phone, &avatarURL, &manualProfile); err != nil {
|
||||
t.Fatalf("read manually managed profile fields: %v", err)
|
||||
}
|
||||
if displayName != "Manual Display Name" || email != "manual-profile@example.test" || !manualProfile {
|
||||
if displayName != "Manual Display Name" || email != "manual-profile@example.test" ||
|
||||
phone != "+8613000000000" || avatarURL != "https://static.example.test/manual-profile.png" || !manualProfile {
|
||||
t.Fatalf("repeat resolve overwrote manually managed profile fields")
|
||||
}
|
||||
|
||||
@@ -352,6 +370,10 @@ WHERE source='oidc_v2_platform'
|
||||
ApplicationID: applicationID,
|
||||
Subject: subject,
|
||||
Username: "platform-user-" + suffix,
|
||||
DisplayName: "Platform User",
|
||||
Email: "platform-user@example.test",
|
||||
Phone: "+8613600000000",
|
||||
AvatarURL: "https://static.example.test/platform-user.png",
|
||||
Roles: []string{"admin"},
|
||||
ContextType: "platform",
|
||||
TenantMode: "multi_tenant",
|
||||
@@ -367,6 +389,10 @@ WHERE source='oidc_v2_platform'
|
||||
result.User.TenantID != "" ||
|
||||
result.User.TenantKey != oidcPlatformTenantKey ||
|
||||
result.User.GatewayTenantID == "" ||
|
||||
result.User.DisplayName != "Platform User" ||
|
||||
result.User.Email != "platform-user@example.test" ||
|
||||
result.User.Phone != "+8613600000000" ||
|
||||
result.User.AvatarURL != "https://static.example.test/platform-user.png" ||
|
||||
result.AuditID == "" {
|
||||
t.Fatalf("platform projection=%#v", result)
|
||||
}
|
||||
@@ -407,6 +433,10 @@ WHERE source='oidc_v2_platform'
|
||||
ApplicationID: applicationID,
|
||||
Subject: subject,
|
||||
Username: "platform-user-renamed-" + suffix,
|
||||
DisplayName: "Platform User Renamed",
|
||||
Email: "platform-user-renamed@example.test",
|
||||
Phone: "+8613500000000",
|
||||
AvatarURL: "https://static.example.test/platform-user-renamed.png",
|
||||
Roles: []string{"viewer"},
|
||||
ContextType: "platform",
|
||||
TenantMode: "multi_tenant",
|
||||
@@ -415,6 +445,10 @@ WHERE source='oidc_v2_platform'
|
||||
)
|
||||
if err != nil || repeated.Created ||
|
||||
repeated.User.GatewayUserID != result.User.GatewayUserID ||
|
||||
repeated.User.DisplayName != "Platform User Renamed" ||
|
||||
repeated.User.Email != "platform-user-renamed@example.test" ||
|
||||
repeated.User.Phone != "+8613500000000" ||
|
||||
repeated.User.AvatarURL != "https://static.example.test/platform-user-renamed.png" ||
|
||||
!reflect.DeepEqual(repeated.User.Roles, []string{"viewer"}) {
|
||||
t.Fatalf("repeated platform projection=%#v err=%v", repeated, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user