From 8610a67bb93a6be68f4044774f37977a94607c54 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 23:44:26 +0200 Subject: [PATCH] refactor(oidc): OIDC identities and settings through repositories --- server/src/nest/oidc/oidc.module.ts | 6 +- server/src/nest/oidc/oidc.service.ts | 177 ++++++++++++-------- server/tests/unit/nest/oidc.service.test.ts | 69 +++++++- 3 files changed, 175 insertions(+), 77 deletions(-) diff --git a/server/src/nest/oidc/oidc.module.ts b/server/src/nest/oidc/oidc.module.ts index e8d28fa2b..ea77998f9 100644 --- a/server/src/nest/oidc/oidc.module.ts +++ b/server/src/nest/oidc/oidc.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; import { AdminOidcController, OidcController } from './oidc.controller'; import { AuditModule } from '../audit/audit.module'; import { OidcService } from './oidc.service'; import { AuthModule } from '../auth/auth.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; +import { Users } from '../../db/entities/Users.entity'; +import { InviteTokens } from '../../db/entities/InviteTokens.entity'; +import { AppSettings } from '../../db/entities/AppSettings.entity'; @Module({ - imports: [AuthModule, TripMembershipModule, AuditModule], + imports: [AuthModule, TripMembershipModule, AuditModule, MikroOrmModule.forFeature([Users, InviteTokens, AppSettings])], controllers: [OidcController, AdminOidcController], providers: [OidcService], }) diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index 7f6b03e03..3e930508d 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import bcrypt from 'bcryptjs'; import type { Request, Response } from 'express'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { readEnv, getAppUrl } from '../../app-config'; import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config'; import { User } from '../../types'; @@ -12,9 +13,14 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { setAuthCookie, RememberOption } from '../common/cookie'; import { AuthService } from '../auth/auth.service'; -import { DatabaseService } from '../database/database.service'; import { UnitOfWork } from '../database/unit-of-work'; import { safeFetchAdminConfigured } from '../../utils/ssrfGuard'; +import { Users } from '../../db/entities/Users.entity'; +import type { UsersRepository, UserRow } from '../../db/repositories/Users.repository'; +import { InviteTokens } from '../../db/entities/InviteTokens.entity'; +import type { InviteTokensRepository, InviteTokenRow } from '../../db/repositories/InviteTokens.repository'; +import { AppSettings } from '../../db/entities/AppSettings.entity'; +import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; // --------------------------------------------------------------------------- // Types @@ -144,17 +150,6 @@ function assertResponseSize(res: { headers?: { get(name: string): string | null if (length > MAX_RESPONSE_BYTES) throw new Error('OIDC response too large'); } -/** The invite_tokens row shape findOrCreateUser consumes. */ -interface InviteTokenRow { - id: number; - token: string; - max_uses: number; - used_count: number; - expires_at: string | null; - created_by: number | null; - trip_id: number | null; -} - function base64UrlDecode(input: string): Buffer { const padded = input.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (input.length % 4)) % 4); return Buffer.from(padded, 'base64'); @@ -171,18 +166,47 @@ function safeOidcPicture(picture: unknown): string | null { return /^https:\/\//i.test(url) ? url : null; } +/** + * `UsersRepository`'s full-row shape (`UserRow` — `role: string`, several + * `T | null` columns) vs. the client-payload contract type `User` (`role: + * 'admin' | 'user'`, those same columns `T | undefined`). Same mapping as + * `auth.service.ts`/`passkey.service.ts`'s own file-local `toClientUser` + * (Plan 3b Task 5 review, F5 precedent) — kept as a third, file-local copy + * rather than exported/shared, matching that precedent's own reasoning. + */ +function toClientUser(row: UserRow): User { + return { + ...row, + role: row.role === 'admin' ? 'admin' : 'user', + mfa_enabled: row.mfa_enabled ?? undefined, + must_change_password: row.must_change_password ?? undefined, + created_at: row.created_at ?? undefined, + updated_at: row.updated_at ?? undefined, + }; +} + /** * DI-native OIDC service — the legacy services/oidcService.ts folded in whole * (pure relocation): PKCE state, discovery, the strict id_token/JWKS - * verification, user provisioning and the auth-code hand-off, all over the - * injected DatabaseService, with the resolveAuthToggles bridge import replaced - * by the injected AuthService. + * verification, user provisioning and the auth-code hand-off, with the + * resolveAuthToggles bridge import replaced by the injected AuthService. + * + * Plan 3b Task 6: the raw SQL the service used to run directly against the + * DB handle (O1–O18 in the plan's inventory) is now + * `UsersRepository`/`InviteTokensRepository`/`AppSettingsRepository` + * calls (`OidcModule`'s `forFeature([Users, InviteTokens, AppSettings])`), + * same statements, same order, same transaction scope + * (`.superpowers/sdd/2026-09-22-orm-phase3b/task-6-report.md`) — no + * `DatabaseService` left in this file. * * The legacy module-level state (pending-state / auth-code maps and their two * sweep intervals, the discovery cache, the JWKS cache) lives on the instance: * nothing outside the container consumes this domain, so no bridge needs to * share it. The sweepers start in the constructor (legacy started them at - * import) and are cleared in onModuleDestroy. + * import) and are cleared in onModuleDestroy. They are pure in-process + * `setInterval`s over in-memory `Map`s — never DB-touching, so Task 6 left + * them untouched (verified: no `this.db`/repository reference anywhere in + * either sweeper's closure). * * Post-migration fixes on top of the relocated legacy behavior (exchange-rates * precedent): every outbound fetch carries an AbortSignal timeout and a @@ -226,10 +250,12 @@ export class OidcService implements OnModuleDestroy { private readonly codeSweeper: NodeJS.Timeout; constructor( - private readonly db: DatabaseService, private readonly auth: AuthService, private readonly membership: TripMembershipService, private readonly uow: UnitOfWork, + @InjectRepository(Users) private readonly usersRepo: UsersRepository, + @InjectRepository(InviteTokens) private readonly inviteTokens: InviteTokensRepository, + @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, ) { this.stateSweeper = setInterval(() => { const now = Date.now(); @@ -307,15 +333,14 @@ export class OidcService implements OnModuleDestroy { // ------------------------------------------------------------------------- async getOidcConfig(): Promise { - const get = (key: string) => - (this.db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; + const get = (key: string) => this.appSettings.getValue(key); const oidcEnv = readEnv().oidc; - const issuer = oidcEnv.issuer || get('oidc_issuer'); - const clientId = oidcEnv.clientId || get('oidc_client_id'); - const clientSecret = oidcEnv.clientSecret || decrypt_api_key(get('oidc_client_secret')); - const displayName = oidcEnv.displayName || get('oidc_display_name') || 'SSO'; - const discoveryUrl = oidcEnv.discoveryUrl || get('oidc_discovery_url') || null; + const issuer = oidcEnv.issuer || (await get('oidc_issuer')); + const clientId = oidcEnv.clientId || (await get('oidc_client_id')); + const clientSecret = oidcEnv.clientSecret || decrypt_api_key(await get('oidc_client_secret')); + const displayName = oidcEnv.displayName || (await get('oidc_display_name')) || 'SSO'; + const discoveryUrl = oidcEnv.discoveryUrl || (await get('oidc_discovery_url')) || null; if (!issuer || !clientId || !clientSecret) return null; // The lookbehind pins the trailing-slash strip (here and below) to the start of @@ -449,7 +474,7 @@ export class OidcService implements OnModuleDestroy { // Embed the current password_version so an OIDC-issued session is invalidated // by a password change/reset exactly like a password-login session (the auth // middleware compares this `pv` against users.password_version). - const pv = (this.db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0; + const pv = (await this.usersRepo.getPasswordVersion(user.id)) ?? 0; // "Remember me" mirrors the password flow: the JWT lifetime matches the // persistent cookie maxAge picked by the cookie service off the same flag, // and the claim lets sliding renewal preserve those semantics. @@ -624,10 +649,10 @@ export class OidcService implements OnModuleDestroy { const picture = safeOidcPicture(userInfo.picture); // Try to find existing user by sub, then by email - let user = this.db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer) as User | undefined; + let user = await this.usersRepo.findByOidcIdentity(sub, config.issuer); if (!user) { // Never link/log-in to a guest (#1362) via its synthetic email. - user = this.db.prepare('SELECT * FROM users WHERE LOWER(email) = ? AND COALESCE(is_guest, 0) = 0').get(email) as User | undefined; + user = await this.usersRepo.findByEmailCI(email); } if (user) { @@ -639,15 +664,15 @@ export class OidcService implements OnModuleDestroy { if (!emailVerified) { return { error: 'email_not_verified' }; } - this.db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id); - user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer } as User; + await this.usersRepo.linkOidcIdentity(user.id, sub, config.issuer); + user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer }; } else if (user.oidc_issuer !== config.issuer || user.oidc_sub !== sub) { // The admin pointed the instance at a different IdP. We got here through the // verified-email lookup, so this is the same person arriving from the new // provider; leaving the old sub and issuer on the row would keep the account // pinned to a provider that no longer exists (#2110). - this.db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id); - user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer } as User; + await this.usersRepo.linkOidcIdentity(user.id, sub, config.issuer); + user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer }; } // Update role based on OIDC claims on every login (if claim mapping is configured) let roleChange: OidcRoleChange | undefined; @@ -655,7 +680,7 @@ export class OidcService implements OnModuleDestroy { const resolution = this.resolveOidcRoleDetailed(userInfo, false); const newRole = resolution.role; if (resolution.claimMissing) { - this.warnMissingAdminClaim(resolution, user); + this.warnMissingAdminClaim(resolution, { id: user.id, username: user.username, role: user.role === 'admin' ? 'admin' : 'user' }); } else if (user.role !== newRole) { // Never let the claim-based downgrade strip the last admin. The bootstrap // admin (first SSO user) usually doesn't carry the admin claim, so a forced @@ -664,13 +689,13 @@ export class OidcService implements OnModuleDestroy { const demotingLastAdmin = user.role === 'admin' && newRole !== 'admin' && - (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1; + (await this.usersRepo.countAdmins()) <= 1; if (demotingLastAdmin) { console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`); } else { - this.db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); - roleChange = { from: user.role, to: newRole, claim: resolution.claimKey }; - user = { ...user, role: newRole } as User; + await this.usersRepo.setRole(user.id, newRole); + roleChange = { from: user.role === 'admin' ? 'admin' : 'user', to: newRole, claim: resolution.claimKey }; + user = { ...user, role: newRole }; } } } @@ -685,19 +710,19 @@ export class OidcService implements OnModuleDestroy { // (#2110). An uploaded avatar is a bare filename and stays untouched either way. const avatarIsOidc = !!user.avatar && /^https:\/\//i.test(user.avatar); if (picture ? picture !== user.avatar && (!user.avatar || avatarIsOidc) : avatarIsOidc) { - this.db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(picture, user.id); - user = { ...user, avatar: picture } as User; + await this.usersRepo.setAvatarRaw(user.id, picture); + user = { ...user, avatar: picture }; } - return { user, roleChange }; + return { user: toClientUser(user), roleChange }; } // --- New user registration --- - const userCount = (this.db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; + const userCount = await this.usersRepo.countNonGuest(); const isFirstUser = userCount === 0; let validInvite: InviteTokenRow | null = null; if (inviteToken) { - validInvite = (this.db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(inviteToken) as InviteTokenRow | undefined) ?? null; + validInvite = await this.inviteTokens.findByToken(inviteToken); if (validInvite) { if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null; if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null; @@ -723,8 +748,8 @@ export class OidcService implements OnModuleDestroy { // usernames (see the ^[a-zA-Z0-9_.-]+$ validation in authService) and common // in OIDC name claims like "first.last". let username = name.replace(/[^a-zA-Z0-9_.-]/g, '').substring(0, 30) || 'user'; - const existing = this.db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username); - if (existing) username = `${username}_${Date.now() % 10000}`; + const existingId = await this.usersRepo.findIdByUsernameCIAny(username); + if (existingId !== null) username = `${username}_${Date.now() % 10000}`; // Atomic registration: if an invite was presented, the increment IS // the capacity check — UPDATE matches zero rows the moment another @@ -733,28 +758,36 @@ export class OidcService implements OnModuleDestroy { // both pass the earlier SELECT-based check and each create a user. const inviteRaceError = new Error('invite_exhausted'); try { - const result = (await this.uow.transactional(async () => { + const insertedId = await this.uow.transactional(async () => { if (validInvite) { - const updated = this.db.prepare( - 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)', - ).run(validInvite.id); - if (updated.changes === 0) throw inviteRaceError; + const updated = await this.inviteTokens.incrementUsedCount(validInvite.token); + if (!updated) throw inviteRaceError; } - const ins = this.db.prepare( - 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)', - ).run(username, email, hash, role, sub, config.issuer, picture, readEnv().app.appVersion || '0.0.0'); + const created = await this.usersRepo.insertUser({ + username, + email, + password_hash: hash, + role, + first_seen_version: readEnv().app.appVersion || '0.0.0', + oidc_sub: sub, + oidc_issuer: config.issuer, + avatar: picture, + }); // Trip-bound invite (#1402): auto-add the new SSO user to the trip inside the // same atomic step as the invite consume. Idempotent + owner-safe. if (validInvite?.trip_id) { - await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null); + await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(created.id), validInvite.created_by ?? null); } - return ins; - })) as { lastInsertRowid: number | bigint }; + return created.id; + }); // Re-select so the returned User carries the real row (password_version, // is_guest, created_at, …) instead of a hand-built partial — same shape - // the existing-user branch returns. - user = this.db.prepare('SELECT * FROM users WHERE id = ?').get(Number(result.lastInsertRowid)) as User; - return { user }; + // the existing-user branch returns. A separate read after the transaction + // resolves (not `insertUser`'s own internal read-back), mirroring the + // legacy INSERT-then-reselect structure exactly. + const created = await this.usersRepo.findById(insertedId); + if (!created) throw new Error('findOrCreateUser: read-back after insert found no row'); + return { user: toClientUser(created) }; } catch (err) { if (err === inviteRaceError) { console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exhausted — concurrent callback won the last slot`); @@ -769,7 +802,7 @@ export class OidcService implements OnModuleDestroy { // ------------------------------------------------------------------------- async touchLastLogin(userId: number): Promise { - this.db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(userId); + await this.usersRepo.touchLastLogin(userId); } // ── OIDC settings ────────────────────────────────────────────────────────── @@ -779,16 +812,15 @@ export class OidcService implements OnModuleDestroy { // every user out of the instance. async getOidcSettings() { - const get = (key: string) => - this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || ''; - const secret = decrypt_api_key(get('oidc_client_secret')); + const get = async (key: string) => (await this.appSettings.getValue(key)) || ''; + const secret = decrypt_api_key(await get('oidc_client_secret')); return { - issuer: get('oidc_issuer'), - client_id: get('oidc_client_id'), + issuer: await get('oidc_issuer'), + client_id: await get('oidc_client_id'), client_secret_set: !!secret, - display_name: get('oidc_display_name'), - oidc_only: get('oidc_only') === 'true', - discovery_url: get('oidc_discovery_url'), + display_name: await get('oidc_display_name'), + oidc_only: (await get('oidc_only')) === 'true', + discovery_url: await get('oidc_discovery_url'), }; } @@ -807,16 +839,15 @@ export class OidcService implements OnModuleDestroy { }; } - const set = (key: string, val: string) => - this.db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', key, val || ''); + const set = (key: string, val: string) => this.appSettings.setValue(key, val || ''); // All five writes are one SSO config — a partial apply would leave the // instance with an issuer but no client id (or vice versa). await this.uow.transactional(async () => { - set('oidc_issuer', data.issuer ?? ''); - set('oidc_client_id', data.client_id ?? ''); - if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? ''); - set('oidc_display_name', data.display_name ?? ''); - set('oidc_discovery_url', data.discovery_url ?? ''); + await set('oidc_issuer', data.issuer ?? ''); + await set('oidc_client_id', data.client_id ?? ''); + if (data.client_secret !== undefined) await set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? ''); + await set('oidc_display_name', data.display_name ?? ''); + await set('oidc_discovery_url', data.discovery_url ?? ''); }); return { success: true }; } diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index 0ac72dd1e..edf500fa6 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -142,7 +142,7 @@ beforeAll(async () => { await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb), await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb), ); - svc = new OidcService(new DatabaseService(testDb), auth, membership, await createTestUnitOfWork(testDb)); + svc = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb)); }); const MOCK_CONFIG = { @@ -776,6 +776,69 @@ describe('findOrCreateUser', () => { expect(row.oidc_issuer).toBe('https://new-idp.example.com'); }); + // Plan 3b Task 6 (O5/O6, `linkOidcIdentity`) — the repository write names only + // `oidc_sub`/`oidc_issuer`; pinning the WHOLE row (not just those two columns, + // as OIDC-SVC-025/062 already do) proves no other column moved — the exact + // identity-map-stale-write-back shape the program's disableIdentityMap ruling + // exists to foreclose (task-1-fix-report.md). + it('OIDC-SVC-090: identity linking (O5) leaves the rest of the users row byte-identical', async () => { + const { user } = createUser(testDb, { email: 'linkrow@example.com', username: 'linkrowuser' }); + testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id); + const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; + + await svc.findOrCreateUser( + { sub: 'sub-linkrow', email: 'linkrow@example.com', name: 'Link Row', email_verified: true }, + MOCK_CONFIG, + ); + + const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; + expect(after).toEqual({ ...before, oidc_sub: 'sub-linkrow', oidc_issuer: MOCK_CONFIG.issuer }); + }); + + it('OIDC-SVC-091: the provider-switch branch (O6) leaves the rest of the users row byte-identical', async () => { + const { user } = createUser(testDb, { email: 'switchrow@example.com', username: 'switchrowuser' }); + testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') + .run('sub-old-row', 'https://old-idp.example.com', user.id); + const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; + + await svc.findOrCreateUser( + { sub: 'sub-new-row', email: 'switchrow@example.com', name: 'Switch Row', email_verified: true }, + { ...MOCK_CONFIG, issuer: 'https://new-idp-row.example.com' }, + ); + + const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; + expect(after).toEqual({ ...before, oidc_sub: 'sub-new-row', oidc_issuer: 'https://new-idp-row.example.com' }); + }); + + // Plan 3b Task 6 (O13, `InviteTokensRepository.incrementUsedCount`) — the + // ruling: a `null` result inside `uow.transactional` throws the same + // reference-compared `inviteRaceError` sentinel the legacy `changes === 0` + // branch threw, mapped to the same `registration_disabled` outcome. Mirrors + // `WebauthnChallenges.repository.test.ts`'s `Promise.all` concurrent-claim + // proof (WEBAUTHN-CHAL-REPO-011) — same single-SQLite-connection + // serialization, one winner. + it('OIDC-SVC-092: two concurrent SSO registrations racing the same one-use invite — the loser gets registration_disabled, byte-identical to the legacy invite_exhausted outcome', async () => { + const { user: creator } = createUser(testDb, { email: 'racecreator@example.com' }); + testDb.prepare( + "INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-race', 1, 0, ?)" + ).run(creator.id); + + const [first, second] = await Promise.all([ + svc.findOrCreateUser({ sub: 'sub-race-1', email: 'racer1@example.com', name: 'Racer One' }, MOCK_CONFIG, 'tok-race'), + svc.findOrCreateUser({ sub: 'sub-race-2', email: 'racer2@example.com', name: 'Racer Two' }, MOCK_CONFIG, 'tok-race'), + ]); + + const outcomes = [first, second]; + const winners = outcomes.filter((r) => 'user' in r); + const losers = outcomes.filter((r) => 'error' in r) as Array<{ error: string }>; + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(1); + expect(losers[0]!.error).toBe('registration_disabled'); + + const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-race'").get() as { used_count: number }; + expect(token.used_count).toBe(1); + }); + it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', async () => { const result = await svc.findOrCreateUser({ sub: 'sub-no-email', name: 'No Email' }, MOCK_CONFIG); expect('error' in result).toBe(true); @@ -925,7 +988,7 @@ describe('findOrCreateUser role mapping', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); // The warning is deduped per claim name for the life of the process, so this // case needs an instance that has not seen the claim yet. - const fresh = new OidcService(new DatabaseService(testDb), auth, membership, await createTestUnitOfWork(testDb)); + const fresh = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb)); try { const info = { sub: 'sub-warned', email: 'warned@example.com', groups: ['authentik Admins'] }; @@ -1028,7 +1091,7 @@ describe('findOrCreateUser role mapping', () => { ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin'); ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const fresh = new OidcService(new DatabaseService(testDb), auth, membership, await createTestUnitOfWork(testDb)); + const fresh = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb)); try { await fresh.findOrCreateUser({ sub: 'sub-shared-admin', email: 'shared-admin@example.com' }, MOCK_CONFIG);