/** * Finds or creates the user behind an external sign-in (OIDC, LDAP, a * plugin's provider). The rules used to be copied into every SSO route; they * live here once so every login method gets the same ones: * * - the allowed-users list is checked on every sign-in, new user or not * - the first user ever becomes admin and skips the list * - anyone else is only created when SSO auto-provisioning is on * - an admin group, when the provider reports one, keeps admin in step * - a mapped role set, when the provider reports one, keeps roles in step */ import { nanoid } from "nanoid"; import { authLogger } from "../utils/logger.js"; import { AuthManager } from "../utils/auth-manager.js"; import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js"; import { PermissionManager } from "../utils/permission-manager.js"; import { createCurrentRoleRepository, createCurrentSettingsRepository, createCurrentUserAuthRepository, createCurrentUserRepository, } from "../database/repositories/factory.js"; import type { UserRecord } from "../database/repositories/user-repository.js"; import { isExternalUserAllowed } from "./allowed-users.js"; import { LoginMethodError, type VerifiedIdentity } from "./types.js"; type ExternalIdentity = Extract; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; const ONE_DAY_MS = 24 * 60 * 60 * 1000; /** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */ async function isExternalProvisioningAllowed(): Promise { try { if ( await createCurrentSettingsRepository().getBoolean( "oidc_auto_provision", false, ) ) { return true; } } catch { // fall through to the environment } return ( (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true" ); } /** * Whether a newly provisioned user must wait for an admin. There is no * approval queue yet, so nobody does; a plugin or a later release can change * that here without touching any login method. */ async function isApprovalRequired( _identity: ExternalIdentity, ): Promise { return false; } function isAllowed(identity: ExternalIdentity): boolean { if (!identity.allowedUsers) return true; const email = identity.email ?? undefined; return ( isExternalUserAllowed(identity.allowedUsers, identity.subject, email) || (!!identity.legacyIdentifier && isExternalUserAllowed( identity.allowedUsers, identity.legacyIdentifier, email, )) ); } async function syncAdmin( user: UserRecord, isAdmin: boolean, ): Promise { if (!!user.isAdmin === isAdmin) return user; const updated = (await createCurrentUserRepository().update(user.id, { isAdmin })) ?? user; try { await createCurrentRoleRepository().switchUserRoleName({ userId: user.id, addRoleName: isAdmin ? "admin" : "user", removeRoleName: isAdmin ? "user" : "admin", grantedBy: user.id, }); } catch { // Roles follow on the next sign-in; the admin flag itself is saved. } authLogger.info("Admin status synced from the identity provider", { operation: "external_admin_sync", userId: user.id, isAdmin, }); return updated; } /** * Adds and removes the roles a provider's group map manages. Roles assigned * by hand, and roles outside the map, are never touched. */ async function applyRoleSync( userId: string, roleSync: { desired: string[]; managed: string[] }, ): Promise { const desired = new Set(roleSync.desired); const managed = new Set(roleSync.managed); const roleRepository = createCurrentRoleRepository(); const currentRoles = await roleRepository.listUserRoles(userId); const currentNames = new Set(currentRoles.map((role) => role.roleName)); const toAdd = [...desired].filter((name) => !currentNames.has(name)); const toRemove = currentRoles.filter( (role) => managed.has(role.roleName) && !desired.has(role.roleName), ); for (const roleName of toAdd) { const assigned = await roleRepository.assignRoleNameToUser({ userId, roleName, grantedBy: userId, }); if (!assigned) { authLogger.warn("Role map references a role that does not exist", { operation: "external_role_map_missing_role", userId, roleName, }); } } for (const role of toRemove) { await roleRepository.removeRoleFromUser(userId, role.roleId); } if (toAdd.length > 0 || toRemove.length > 0) { authLogger.info("Roles synced from provider group membership", { operation: "external_role_map_sync", userId, added: toAdd, removed: toRemove.map((role) => role.roleName), }); PermissionManager.getInstance().invalidateUserPermissionCache(userId); } } async function findLinkedUser( identity: ExternalIdentity, ): Promise { const identities = createCurrentUserAuthRepository(); const users = createCurrentUserRepository(); const linked = await identities.findIdentity( identity.provider, identity.subject, ); if (linked) { const user = await users.findById(linked.userId); if (user) return user; } // Accounts from before user_external_identities matched on the raw // identifier column; link them the first time they sign in. The column is // not scoped to a provider, so only an SSO account that has no link yet, // from the same provider when both say which, can be claimed this way. if (identity.legacyIdentifier) { const legacy = await users.findByExternalIdentifier( identity.legacyIdentifier, ); if (legacy && (await mayClaimLegacyAccount(legacy, identity))) { await identities.linkIdentity({ userId: legacy.id, providerId: identity.provider, subject: identity.subject, email: identity.email, }); return legacy; } } return null; } async function mayClaimLegacyAccount( legacy: UserRecord, identity: ExternalIdentity, ): Promise { if (!legacy.isOidc) return false; if ( legacy.ssoProviderId != null && identity.ssoProviderId != null && legacy.ssoProviderId !== identity.ssoProviderId ) { return false; } const links = await createCurrentUserAuthRepository().listIdentitiesForUser( legacy.id, ); return links.length === 0; } /** * A username nobody else has. An identity provider's display name must never * give an SSO account the same username as a local one, such as "admin". */ async function freeUsername( wanted: string, selfId: string | null, ): Promise { const users = createCurrentUserRepository(); let candidate = wanted; for (let attempt = 0; attempt < 5; attempt++) { const holder = await users.findByUsername(candidate); if (!holder || holder.id === selfId) return candidate; candidate = `${wanted}-${nanoid(4)}`; } return `${wanted}-${nanoid(10)}`; } async function createUser( identity: ExternalIdentity, deviceType: string, ): Promise { const users = createCurrentUserRepository(); const id = nanoid(); const created = await users.createFirstSsoUser({ id, username: await freeUsername(identity.name || identity.subject, null), passwordHash: "", isAdmin: !!identity.isAdmin, isOidc: true, oidcIdentifier: identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`, ssoProviderId: identity.ssoProviderId ?? null, }); try { const assigned = await createCurrentRoleRepository().assignRoleNameToUser({ userId: id, roleName: created.user.isAdmin ? "admin" : "user", grantedBy: id, }); if (!assigned) { authLogger.warn("Default role not found for a provisioned user", { operation: "external_default_role", userId: id, }); } } catch (roleError) { authLogger.error("Failed to assign a default role", roleError, { operation: "external_default_role", userId: id, }); } try { const sessionDurationMs = deviceType === "desktop" || deviceType === "mobile" ? THIRTY_DAYS_MS : ONE_DAY_MS; await AuthManager.getInstance().registerExternalUser(id, sessionDurationMs); } catch (encryptionError) { await users.delete(id); authLogger.error( "Failed to set up a provisioned user's encryption, user removed", encryptionError, { operation: "external_user_create_failed", userId: id }, ); throw new LoginMethodError( "Failed to setup user security - user creation cancelled", 500, "setup_failed", ); } await createCurrentUserAuthRepository().linkIdentity({ userId: id, providerId: identity.provider, subject: identity.subject, email: identity.email, }); try { await DatabaseSaveTrigger.forceSave("external_user_create"); } catch (saveError) { authLogger.error("Failed to persist a provisioned user", saveError, { operation: "external_user_create_save_failed", userId: id, }); } return created.user; } export async function findOrProvisionExternalUser( identity: ExternalIdentity, deviceType: string, ): Promise { let user = await findLinkedUser(identity); if (!user) { const isFirstUser = (await createCurrentUserRepository().countAll()) === 0; if (!isFirstUser) { if (!isAllowed(identity)) { authLogger.warn("External user not in the allowed list", { operation: "external_user_not_allowed", provider: identity.provider, }); throw new LoginMethodError("User not allowed", 403, "user_not_allowed"); } if (!(await isExternalProvisioningAllowed())) { authLogger.warn("External sign-in refused, provisioning is off", { operation: "external_registration_disabled", provider: identity.provider, }); throw new LoginMethodError( "Registration is disabled", 403, "registration_disabled", ); } if (await isApprovalRequired(identity)) { throw new LoginMethodError( "Your account is waiting for an admin to approve it", 403, "approval_required", ); } } user = await createUser(identity, deviceType); } else { if (!isAllowed(identity)) { authLogger.warn("External user not in the allowed list", { operation: "external_user_not_allowed_existing", provider: identity.provider, userId: user.id, }); throw new LoginMethodError("User not allowed", 403, "user_not_allowed"); } // A user who also has a password picked their own username; leave it. const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== ""; if (!isDualAuth && identity.name && user.username !== identity.name) { // Taken by someone else: keep the name the account already has. const holder = await createCurrentUserRepository().findByUsername( identity.name, ); if (!holder || holder.id === user.id) { user = (await createCurrentUserRepository().update(user.id, { username: identity.name, })) ?? user; } } if (identity.isAdmin !== undefined) { user = await syncAdmin(user, identity.isAdmin); } } if (identity.roleSync) { try { await applyRoleSync(user.id, identity.roleSync); } catch (roleSyncError) { authLogger.error("Failed to sync roles from provider", roleSyncError, { operation: "external_role_map_sync_failed", userId: user.id, }); } } return user; }