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, } }