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 {
|
type User struct {
|
||||||
ID string `json:"sub"`
|
ID string `json:"sub"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"-"`
|
||||||
|
Email string `json:"-"`
|
||||||
|
Phone string `json:"-"`
|
||||||
|
AvatarURL string `json:"-"`
|
||||||
Roles []string `json:"role,omitempty"`
|
Roles []string `json:"role,omitempty"`
|
||||||
ContextType string `json:"contextType,omitempty"`
|
ContextType string `json:"contextType,omitempty"`
|
||||||
TenantID string `json:"tenantId,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)
|
return nil, oidcUnauthorized("TOKEN_INACTIVE", "token is inactive", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
username := stringClaim(claims, "preferred_username")
|
username := oidcProfileText(claims, "preferred_username", 320)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = stringClaim(claims, "username")
|
username = oidcProfileText(claims, "username", 320)
|
||||||
}
|
}
|
||||||
return &User{
|
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",
|
ContextType: contextType, TenantID: tenantID, Source: "oidc",
|
||||||
TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
|
TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
|
||||||
ApplicationID: v.config.ApplicationID, OIDCClientID: clientID,
|
ApplicationID: v.config.ApplicationID, OIDCClientID: clientID,
|
||||||
}, nil
|
}, 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 {
|
type oidcValidationError struct {
|
||||||
category string
|
category string
|
||||||
reason string
|
reason string
|
||||||
|
|||||||
@@ -66,11 +66,27 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if user.ID != "platform-subject" || user.TenantID != "tenant-1" || user.Source != "oidc" ||
|
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" {
|
len(user.Roles) != 1 || user.Roles[0] != "admin" {
|
||||||
t.Fatalf("unexpected OIDC user: %#v", user)
|
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) {
|
func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
|
||||||
@@ -400,6 +416,9 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod,
|
|||||||
"iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1",
|
"iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1",
|
||||||
"context_type": "tenant",
|
"context_type": "tenant",
|
||||||
"preferred_username": "acceptance", "roles": []string{"gateway.admin"},
|
"preferred_username": "acceptance", "roles": []string{"gateway.admin"},
|
||||||
|
"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(),
|
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
|
||||||
"exp": now.Add(time.Hour).Unix(),
|
"exp": now.Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ func (s *Server) resolveOIDCUserProjectionWithTenantContext(
|
|||||||
ApplicationID: revision.ApplicationID,
|
ApplicationID: revision.ApplicationID,
|
||||||
Subject: user.ID,
|
Subject: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Email: user.Email,
|
||||||
|
Phone: user.Phone,
|
||||||
|
AvatarURL: user.AvatarURL,
|
||||||
Roles: user.Roles,
|
Roles: user.Roles,
|
||||||
ContextType: user.ContextType,
|
ContextType: user.ContextType,
|
||||||
TenantID: user.TenantID,
|
TenantID: user.TenantID,
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
|||||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||||
ID: "platform-user",
|
ID: "platform-user",
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
|
DisplayName: "王小明",
|
||||||
|
Email: "alice@example.test",
|
||||||
|
Phone: "+8613800000000",
|
||||||
|
AvatarURL: "https://static.example.test/avatar.png",
|
||||||
Roles: []string{"basic"},
|
Roles: []string{"basic"},
|
||||||
ContextType: "tenant",
|
ContextType: "tenant",
|
||||||
TenantID: "external-tenant",
|
TenantID: "external-tenant",
|
||||||
@@ -100,7 +104,9 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", recorder.Code)
|
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)
|
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)
|
rolesJSON, _ := json.Marshal(input.Roles)
|
||||||
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
|
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
|
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 {
|
if err != nil {
|
||||||
return ResolveOrProvisionOIDCUserResult{}, err
|
return ResolveOrProvisionOIDCUserResult{}, err
|
||||||
}
|
}
|
||||||
@@ -203,13 +209,16 @@ func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindin
|
|||||||
rolesJSON, _ := json.Marshal(input.Roles)
|
rolesJSON, _ := json.Marshal(input.Roles)
|
||||||
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"})
|
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"})
|
||||||
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
|
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
|
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())
|
'active',now(),now(),now())
|
||||||
ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at
|
ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at
|
||||||
RETURNING `+userColumns,
|
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 {
|
if err != nil {
|
||||||
return GatewayUser{}, "", false, err
|
return GatewayUser{}, "", false, err
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser(
|
|||||||
return ResolveOrProvisionOIDCUserResult{}, err
|
return ResolveOrProvisionOIDCUserResult{}, err
|
||||||
}
|
}
|
||||||
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
|
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(),
|
last_login_at=now(),synced_at=now(),source_updated_at=now(),
|
||||||
updated_at=now()
|
updated_at=now()
|
||||||
WHERE id=$1::uuid AND source='oidc_v2_platform'
|
WHERE id=$1::uuid AND source='oidc_v2_platform'
|
||||||
@@ -72,6 +77,10 @@ func (s *Store) resolveOrProvisionOIDCPlatformUser(
|
|||||||
RETURNING `+userColumns,
|
RETURNING `+userColumns,
|
||||||
user.ID,
|
user.ID,
|
||||||
input.Username,
|
input.Username,
|
||||||
|
input.DisplayName,
|
||||||
|
input.Email,
|
||||||
|
input.Phone,
|
||||||
|
input.AvatarURL,
|
||||||
string(rolesJSON),
|
string(rolesJSON),
|
||||||
))
|
))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,17 +198,21 @@ func (s *Store) createOIDCPlatformUser(
|
|||||||
strings.TrimPrefix(userKey, "oidc2-platform:")[:12]
|
strings.TrimPrefix(userKey, "oidc2-platform:")[:12]
|
||||||
}
|
}
|
||||||
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
|
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,
|
tenant_id,tenant_key,default_user_group_id,roles,auth_profile,
|
||||||
metadata,status,last_login_at,synced_at,source_updated_at
|
metadata,status,last_login_at,synced_at,source_updated_at
|
||||||
) VALUES(
|
) VALUES(
|
||||||
$1,'oidc_v2_platform',NULL,$2,$3::uuid,NULL,$4,$5::uuid,
|
$1,'oidc_v2_platform',NULL,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),
|
||||||
$6::jsonb,'{}'::jsonb,$7::jsonb,'active',now(),now(),now()
|
$7::uuid,NULL,$8,$9::uuid,$10::jsonb,'{}'::jsonb,$11::jsonb,'active',now(),now(),now()
|
||||||
)
|
)
|
||||||
ON CONFLICT(user_key) DO NOTHING
|
ON CONFLICT(user_key) DO NOTHING
|
||||||
RETURNING `+userColumns,
|
RETURNING `+userColumns,
|
||||||
userKey,
|
userKey,
|
||||||
username,
|
username,
|
||||||
|
input.DisplayName,
|
||||||
|
input.Email,
|
||||||
|
input.Phone,
|
||||||
|
input.AvatarURL,
|
||||||
gatewayTenantID,
|
gatewayTenantID,
|
||||||
oidcPlatformTenantKey,
|
oidcPlatformTenantKey,
|
||||||
groupID,
|
groupID,
|
||||||
|
|||||||
@@ -2,6 +2,31 @@ package store
|
|||||||
|
|
||||||
import "testing"
|
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) {
|
func TestOIDCProjectionKindRequiresExplicitCompatibleContext(t *testing.T) {
|
||||||
applicationID := "11111111-1111-4111-8111-111111111111"
|
applicationID := "11111111-1111-4111-8111-111111111111"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ type ResolveOrProvisionOIDCUserInput struct {
|
|||||||
ApplicationID string
|
ApplicationID string
|
||||||
Subject string
|
Subject string
|
||||||
Username string
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Email string
|
||||||
|
Phone string
|
||||||
|
AvatarURL string
|
||||||
Roles []string
|
Roles []string
|
||||||
ContextType string
|
ContextType string
|
||||||
TenantID string
|
TenantID string
|
||||||
@@ -122,16 +126,22 @@ func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrP
|
|||||||
|
|
||||||
createdUser, err := scanUser(tx.QueryRow(ctx, `
|
createdUser, err := scanUser(tx.QueryRow(ctx, `
|
||||||
INSERT INTO gateway_users (
|
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
|
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())
|
'active', now(), now(), now())
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING `+userColumns,
|
RETURNING `+userColumns,
|
||||||
userKey,
|
userKey,
|
||||||
input.Subject,
|
input.Subject,
|
||||||
username,
|
username,
|
||||||
|
input.DisplayName,
|
||||||
|
input.Email,
|
||||||
|
input.Phone,
|
||||||
|
input.AvatarURL,
|
||||||
tenantID,
|
tenantID,
|
||||||
input.TenantID,
|
input.TenantID,
|
||||||
input.GatewayTenantKey,
|
input.GatewayTenantKey,
|
||||||
@@ -244,7 +254,11 @@ func (s *Store) syncExistingOIDCUser(ctx context.Context, tx pgx.Tx, projection
|
|||||||
updated, err := scanUser(tx.QueryRow(ctx, `
|
updated, err := scanUser(tx.QueryRow(ctx, `
|
||||||
UPDATE gateway_users
|
UPDATE gateway_users
|
||||||
SET username = COALESCE(NULLIF($2, ''), username),
|
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(),
|
last_login_at = now(),
|
||||||
synced_at = now(),
|
synced_at = now(),
|
||||||
source_updated_at = now(),
|
source_updated_at = now(),
|
||||||
@@ -256,6 +270,10 @@ WHERE id = $1::uuid
|
|||||||
RETURNING `+userColumns,
|
RETURNING `+userColumns,
|
||||||
projection.user.ID,
|
projection.user.ID,
|
||||||
input.Username,
|
input.Username,
|
||||||
|
input.DisplayName,
|
||||||
|
input.Email,
|
||||||
|
input.Phone,
|
||||||
|
input.AvatarURL,
|
||||||
string(rolesJSON),
|
string(rolesJSON),
|
||||||
))
|
))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -343,6 +361,10 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
|
|||||||
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
|
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
|
||||||
input.Subject = strings.TrimSpace(input.Subject)
|
input.Subject = strings.TrimSpace(input.Subject)
|
||||||
input.Username = strings.TrimSpace(input.Username)
|
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.ContextType = strings.TrimSpace(input.ContextType)
|
||||||
input.TenantID = strings.TrimSpace(input.TenantID)
|
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||||
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
|
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
|
||||||
@@ -359,6 +381,15 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
|
|||||||
return input
|
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 {
|
func normalizeOIDCRoles(roles []string) []string {
|
||||||
result := make([]string, 0, len(roles))
|
result := make([]string, 0, len(roles))
|
||||||
seen := make(map[string]struct{}, len(roles))
|
seen := make(map[string]struct{}, len(roles))
|
||||||
@@ -390,6 +421,10 @@ func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.Use
|
|||||||
return &auth.User{
|
return &auth.User{
|
||||||
ID: user.ExternalUserID,
|
ID: user.ExternalUserID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Email: user.Email,
|
||||||
|
Phone: user.Phone,
|
||||||
|
AvatarURL: user.AvatarURL,
|
||||||
Roles: user.Roles,
|
Roles: user.Roles,
|
||||||
ContextType: "tenant",
|
ContextType: "tenant",
|
||||||
TenantID: user.TenantID,
|
TenantID: user.TenantID,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
|
|||||||
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
|
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
|
||||||
return ResolveOrProvisionOIDCUserInput{
|
return ResolveOrProvisionOIDCUserInput{
|
||||||
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
|
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
|
||||||
|
DisplayName: "Shared User", Email: "shared@example.test", Phone: "+8613800000000",
|
||||||
|
AvatarURL: "https://static.example.test/shared.png",
|
||||||
Roles: []string{"basic"}, ContextType: "tenant",
|
Roles: []string{"basic"}, ContextType: "tenant",
|
||||||
TenantID: tenantID, TenantMode: "multi_tenant",
|
TenantID: tenantID, TenantMode: "multi_tenant",
|
||||||
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
|
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
|
||||||
@@ -69,7 +71,9 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
|
|||||||
if userA == nil {
|
if userA == nil {
|
||||||
userA = result.User
|
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)
|
t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User)
|
||||||
}
|
}
|
||||||
if result.Created {
|
if result.Created {
|
||||||
@@ -177,6 +181,10 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
|
|||||||
Issuer: "https://auth.test.example/issuer/shared",
|
Issuer: "https://auth.test.example/issuer/shared",
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Username: "jit-user-" + suffix,
|
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"},
|
Roles: []string{"basic"},
|
||||||
ContextType: "tenant",
|
ContextType: "tenant",
|
||||||
TenantID: "auth-center-test-tenant",
|
TenantID: "auth-center-test-tenant",
|
||||||
@@ -214,7 +222,9 @@ WHERE target_gateway_user_id IN (
|
|||||||
t.Fatalf("concurrent resolve %d: %v", index, err)
|
t.Fatalf("concurrent resolve %d: %v", index, err)
|
||||||
}
|
}
|
||||||
result := results[index]
|
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)
|
t.Fatalf("concurrent resolve %d returned no local user: %+v", index, result)
|
||||||
}
|
}
|
||||||
if firstID == "" {
|
if firstID == "" {
|
||||||
@@ -262,12 +272,18 @@ WHERE id = $1::uuid`, auditID).Scan(&auditProjection); err != nil {
|
|||||||
UPDATE gateway_users
|
UPDATE gateway_users
|
||||||
SET display_name = 'Manual Display Name',
|
SET display_name = 'Manual Display Name',
|
||||||
email = 'manual-profile@example.test',
|
email = 'manual-profile@example.test',
|
||||||
|
phone = '+8613000000000',
|
||||||
|
avatar_url = 'https://static.example.test/manual-profile.png',
|
||||||
metadata = metadata || '{"manualProfile":true}'::jsonb
|
metadata = metadata || '{"manualProfile":true}'::jsonb
|
||||||
WHERE id = $1::uuid`, firstID); err != nil {
|
WHERE id = $1::uuid`, firstID); err != nil {
|
||||||
t.Fatalf("seed manually managed profile fields: %v", err)
|
t.Fatalf("seed manually managed profile fields: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
input.Username = "jit-user-renamed-" + suffix
|
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.Roles = []string{"basic", "admin"}
|
||||||
input.ProvisioningEnabled = false
|
input.ProvisioningEnabled = false
|
||||||
repeated, err := db.ResolveOrProvisionOIDCUser(ctx, input)
|
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") {
|
if repeated.User.Username != input.Username || !containsOIDCTestRole(repeated.User.Roles, "admin") {
|
||||||
t.Fatalf("repeat resolve did not sync token projection: %+v", repeated.User)
|
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
|
var manualProfile bool
|
||||||
if err := db.pool.QueryRow(ctx, `
|
if err := db.pool.QueryRow(ctx, `
|
||||||
SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE((metadata->>'manualProfile')::boolean, false)
|
SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||||
FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &manualProfile); err != nil {
|
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)
|
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")
|
t.Fatalf("repeat resolve overwrote manually managed profile fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +370,10 @@ WHERE source='oidc_v2_platform'
|
|||||||
ApplicationID: applicationID,
|
ApplicationID: applicationID,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Username: "platform-user-" + suffix,
|
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"},
|
Roles: []string{"admin"},
|
||||||
ContextType: "platform",
|
ContextType: "platform",
|
||||||
TenantMode: "multi_tenant",
|
TenantMode: "multi_tenant",
|
||||||
@@ -367,6 +389,10 @@ WHERE source='oidc_v2_platform'
|
|||||||
result.User.TenantID != "" ||
|
result.User.TenantID != "" ||
|
||||||
result.User.TenantKey != oidcPlatformTenantKey ||
|
result.User.TenantKey != oidcPlatformTenantKey ||
|
||||||
result.User.GatewayTenantID == "" ||
|
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 == "" {
|
result.AuditID == "" {
|
||||||
t.Fatalf("platform projection=%#v", result)
|
t.Fatalf("platform projection=%#v", result)
|
||||||
}
|
}
|
||||||
@@ -407,6 +433,10 @@ WHERE source='oidc_v2_platform'
|
|||||||
ApplicationID: applicationID,
|
ApplicationID: applicationID,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Username: "platform-user-renamed-" + suffix,
|
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"},
|
Roles: []string{"viewer"},
|
||||||
ContextType: "platform",
|
ContextType: "platform",
|
||||||
TenantMode: "multi_tenant",
|
TenantMode: "multi_tenant",
|
||||||
@@ -415,6 +445,10 @@ WHERE source='oidc_v2_platform'
|
|||||||
)
|
)
|
||||||
if err != nil || repeated.Created ||
|
if err != nil || repeated.Created ||
|
||||||
repeated.User.GatewayUserID != result.User.GatewayUserID ||
|
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"}) {
|
!reflect.DeepEqual(repeated.User.Roles, []string{"viewer"}) {
|
||||||
t.Fatalf("repeated platform projection=%#v err=%v", repeated, err)
|
t.Fatalf("repeated platform projection=%#v err=%v", repeated, err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user