import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql' import { User, UserRole, Role } from '@open-mercato/core/modules/auth/data/entities' import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' import { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash' import { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities' import { emitSsoEvent } from '../events' import { EmailNotVerifiedError, SsoRolesRevokedError } from '../lib/errors' import type { SsoIdentityPayload } from '../lib/types' import { createLogger } from '@open-mercato/shared/lib/logger' import { conflict } from '@open-mercato/shared/lib/crud/errors' import { lockUserRoleWriterAuthorizationState } from '@open-mercato/core/modules/auth/lib/authorizationStateLocks' const logger = createLogger('sso').child({ component: 'account-linking' }) export class AccountLinkingService { constructor(private em: EntityManager) {} async resolveUser( config: SsoConfig, idpPayload: SsoIdentityPayload, tenantId: string, ): Promise<{ user: User; identity: SsoIdentity }> { const existing = await this.findExistingLink(config.id, idpPayload.subject, tenantId, config.organizationId) if (existing) { await this.assignRolesFromSso(this.em, existing.user, config, tenantId, idpPayload.groups) return existing } if (idpPayload.emailVerified !== true) { throw new EmailNotVerifiedError('IdP did not verify the email claim — cannot link or provision account') } const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase() if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) { throw new Error('Email domain is not in the allowed domains for this SSO configuration') } const emailLinked = config.autoLinkByEmail ? await this.linkByEmail(config, idpPayload, tenantId) : null if (emailLinked) { await this.assignRolesFromSso(this.em, emailLinked.user, config, tenantId, idpPayload.groups) return emailLinked } if (config.jitEnabled) { const scimActive = await this.em.count(ScimToken, { ssoConfigId: config.id, isActive: true }) > 0 if (scimActive) { throw new Error('JIT provisioning is disabled because SCIM directory sync is active') } return this.jitProvision(config, idpPayload, tenantId) } throw new Error('No matching user found and JIT provisioning is disabled') } private async findExistingLink( ssoConfigId: string, idpSubject: string, tenantId: string, organizationId: string, ): Promise<{ user: User; identity: SsoIdentity } | null> { const identity = await findOneWithDecryption( this.em, SsoIdentity, { ssoConfigId, idpSubject, deletedAt: null }, {}, { tenantId, organizationId }, ) if (!identity) return null const user = await findOneWithDecryption( this.em, User, { id: identity.userId, deletedAt: null }, {}, { tenantId, organizationId }, ) if (!user) { identity.deletedAt = new Date() await this.em.flush() return null } identity.lastLoginAt = new Date() await this.em.flush() return { user, identity } } private async linkByEmail( config: SsoConfig, idpPayload: SsoIdentityPayload, tenantId: string, ): Promise<{ user: User; identity: SsoIdentity } | null> { const emailHash = computeEmailHash(idpPayload.email) const user = await findOneWithDecryption( this.em, User, { organizationId: config.organizationId, deletedAt: null, $or: [ { email: idpPayload.email }, { emailHash }, ], } as FilterQuery, {}, { tenantId, organizationId: config.organizationId }, ) if (!user) return null const now = new Date() const identity = this.em.create(SsoIdentity, { tenantId, organizationId: config.organizationId, ssoConfigId: config.id, userId: user.id, idpSubject: idpPayload.subject, idpEmail: idpPayload.email, idpName: idpPayload.name ?? null, idpGroups: idpPayload.groups ?? [], provisioningMethod: 'manual', firstLoginAt: now, lastLoginAt: now, createdAt: now, updatedAt: now, } as RequiredEntityData) await this.em.persist(identity).flush() void emitSsoEvent('sso.identity.linked', { id: identity.id, tenantId, organizationId: config.organizationId, }).catch((eventError) => logger.error('SSO event emit failed', { err: eventError })) return { user, identity } } private async jitProvision( config: SsoConfig, idpPayload: SsoIdentityPayload, tenantId: string, ): Promise<{ user: User; identity: SsoIdentity }> { return this.em.transactional(async (txEm) => { const user = txEm.create(User, { tenantId, organizationId: config.organizationId, email: idpPayload.email, emailHash: computeEmailHash(idpPayload.email), name: idpPayload.name ?? null, passwordHash: null, isConfirmed: true, createdAt: new Date(), }) await txEm.persist(user).flush() await this.assignRolesFromSso(txEm, user, config, tenantId, idpPayload.groups) const now = new Date() const identity = txEm.create(SsoIdentity, { tenantId, organizationId: config.organizationId, ssoConfigId: config.id, userId: user.id, idpSubject: idpPayload.subject, idpEmail: idpPayload.email, idpName: idpPayload.name ?? null, idpGroups: idpPayload.groups ?? [], provisioningMethod: 'jit', firstLoginAt: now, lastLoginAt: now, createdAt: now, updatedAt: now, } as RequiredEntityData) await txEm.persist(identity).flush() void emitSsoEvent('sso.identity.created', { id: identity.id, tenantId, organizationId: config.organizationId, }).catch((eventError) => logger.error('SSO event emit failed', { err: eventError })) return { user, identity } }) } private async assignRolesFromSso( em: EntityManager, user: User, config: SsoConfig, tenantId: string, idpGroups?: string[], ): Promise { const hasMappings = config.appRoleMappings && Object.keys(config.appRoleMappings).length > 0 if (!hasMappings) return const synchronize = async (transactionalEm: EntityManager): Promise => { const managedUser = typeof transactionalEm.getReference === 'function' ? transactionalEm.getReference(User, String(user.id)) : user await this.syncMappedRoles(transactionalEm, managedUser, config, tenantId, idpGroups) const remainingGrant = await transactionalEm.findOne(SsoRoleGrant, { userId: user.id, ssoConfigId: config.id, }) return remainingGrant !== null } const hasAnySsoRole = typeof em.isInTransaction === 'function' && em.isInTransaction() ? await synchronize(em) : await em.transactional(synchronize) if (!hasAnySsoRole) { throw new SsoRolesRevokedError( String(user.id), 'No roles could be resolved from IdP groups — login denied. Configure role mappings or ensure the IdP sends matching group claims.', ) } } /** * Sync/replace SSO-sourced roles: on each login, SSO-managed roles are replaced * with what the IdP sends, while manually-assigned roles are preserved. */ private async syncMappedRoles( em: EntityManager, user: User, config: SsoConfig, tenantId: string, idpGroups?: string[], ): Promise { const resolvedTenantId = tenantId || user.tenantId || '' if (!resolvedTenantId) return const desiredRoleNames = resolveRoleNamesFromIdpGroups(idpGroups, config.appRoleMappings) let allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null, } as FilterQuery) let desiredRoleIds = resolveActiveRoleIds(allRoles, desiredRoleNames) // Query current SSO grants for this user+config let existingGrants = await em.find(SsoRoleGrant, { userId: user.id, ssoConfigId: config.id, }) let allUserRoles = await em.find(UserRole, { user: user.id } as FilterQuery) const lockedRoleIds = new Set([ ...desiredRoleIds, ...existingGrants.map((grant) => grant.roleId), ...allUserRoles .filter((userRole) => userRole.deletedAt) .map((userRole) => String(userRole.role?.id ?? userRole.role)), ]) await lockUserRoleWriterAuthorizationState(em, { userIds: [String(user.id)], roleIds: Array.from(lockedRoleIds), }) allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null, } as FilterQuery, { refresh: true }) existingGrants = await em.find(SsoRoleGrant, { userId: user.id, ssoConfigId: config.id, }, { refresh: true }) allUserRoles = await em.find( UserRole, { user: user.id } as FilterQuery, { refresh: true }, ) desiredRoleIds = resolveActiveRoleIds(allRoles, desiredRoleNames) const existingGrantedRoleIds = new Set(existingGrants.map((g) => g.roleId)) // Compute diff const toAdd = [...desiredRoleIds].filter((id) => !existingGrantedRoleIds.has(id)) const toRemove = existingGrants.filter((g) => !desiredRoleIds.has(g.roleId)) const softDeletedUserRoles = allUserRoles.filter((userRole) => userRole.deletedAt) assertRoleMutationFootprintWasLocked([ ...desiredRoleIds, ...toRemove.map((grant) => grant.roleId), ...softDeletedUserRoles.map((userRole) => String(userRole.role?.id ?? userRole.role)), ], lockedRoleIds) // Add new roles for (const roleId of toAdd) { const role = allRoles.find((r) => r.id === roleId) if (!role) continue await this.ensureUserRole(em, user, role) const grant = em.create(SsoRoleGrant, { tenantId: resolvedTenantId, organizationId: config.organizationId, userId: user.id, roleId, ssoConfigId: config.id, } as RequiredEntityData) em.persist(grant) } // Remove stale SSO-sourced roles for (const grant of toRemove) { const userRole = await em.findOne(UserRole, { user: user.id, role: grant.roleId, deletedAt: null, } as FilterQuery) if (userRole) { em.remove(userRole) } em.remove(grant) } // Clean up orphaned soft-deleted UserRole rows (ghost rows from previous soft-delete logic) for (const userRole of softDeletedUserRoles) { em.remove(userRole) } if (toAdd.length > 0 || toRemove.length > 0 || softDeletedUserRoles.length > 0) { await em.flush() } } private async ensureUserRole(em: EntityManager, user: User, role: Role): Promise { const existingLink = await em.findOne(UserRole, { user: user.id, role: role.id, deletedAt: null, } as FilterQuery) if (existingLink) return const userRole = em.create(UserRole, { user, role, createdAt: new Date() }) em.persist(userRole) } } function assertRoleMutationFootprintWasLocked( roleIds: Iterable, lockedRoleIds: ReadonlySet, ): void { for (const roleId of roleIds) { if (!lockedRoleIds.has(roleId)) { throw conflict('[internal] Authorization state changed while acquiring its lock footprint') } } } function resolveActiveRoleIds(roles: Role[], desiredRoleNames: string[]): Set { const roleByNormalizedName = new Map() for (const role of roles) { const normalized = normalizeToken(role.name) if (normalized) roleByNormalizedName.set(normalized, role) } const desiredRoleIds = new Set() for (const roleName of desiredRoleNames) { const role = roleByNormalizedName.get(roleName) if (role) desiredRoleIds.add(role.id) } return desiredRoleIds } function resolveRoleNamesFromIdpGroups( idpGroups?: string[], configMappings?: Record, ): string[] { if (!Array.isArray(idpGroups) || idpGroups.length === 0) return [] const normalizedGroups = idpGroups .map((group) => normalizeToken(group)) .filter((group): group is string => group !== null) if (normalizedGroups.length === 0) return [] const mergedMappings = loadMergedMappings(configMappings) const roleNames = new Set() for (const group of normalizedGroups) { const mapped = mergedMappings.get(group) if (!mapped?.length) continue for (const role of mapped) roleNames.add(role) } return Array.from(roleNames) } function loadMergedMappings(configMappings?: Record): Map { const mappings = new Map() if (configMappings && Object.keys(configMappings).length > 0) { for (const [group, roleName] of Object.entries(configMappings)) { const normalizedGroup = normalizeToken(group) if (!normalizedGroup) continue const normalizedRole = normalizeToken(roleName) if (!normalizedRole) continue mappings.set(normalizedGroup, [normalizedRole]) } } return mappings } function normalizeToken(value: unknown): string | null { if (typeof value !== 'string') return null const normalized = value.trim().toLowerCase() return normalized.length > 0 ? normalized : null }