fix(oidc): a sign-in links to an account by email only when the address was not set by the account itself

An SSO login that matched no account by its subject took the account with the
same email address and linked the provider identity to it. That address proves
nothing when the account typed it in itself: anyone could sign up with, or
switch their profile to, a colleague's address and receive the colleague's
first SSO login. The provider switch branch (#2110) relinked a row on any email
match without even asking for email_verified, and also for another subject of
the same provider.

users.email_self_asserted remembers an address the account set itself: signup
(not the first account) and an email change in the profile settings set it, an
admin entering a new address clears it, and accounts the provider creates start
without it. Existing accounts keep linking, except those whose address moved
away from the registered one without an admin editing it. A login by email now
needs a verified address and an unmarked account, and is refused for another
subject of the same issuer, with email_link_blocked and a translated message on
the login screen. An email change in the profile settings writes a
user.email_change audit row.

Refs #2110
This commit is contained in:
Maurice
2026-10-12 17:06:52 +02:00
parent 1b58acc78f
commit 3d7e8be9aa
49 changed files with 688 additions and 29 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"src/db/repositories/Places.repository.ts": 1440,
"src/db/repositories/Reservations.repository.ts": 1675,
"src/db/repositories/Trips.repository.ts": 1121,
"src/db/repositories/Users.repository.ts": 1446,
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas-geo.ts": 1005,
"src/nest/atlas/atlas.service.ts": 1182,
"src/nest/auth/auth.service.ts": 1143,
+2
View File
@@ -115,6 +115,7 @@ export class Users {
airtrail_write_enabled?: number | null = 0;
display_name?: string | null;
immich_allow_insecure_tls: number & Opt = 0;
email_self_asserted: number & Opt = 0;
assignment_participants_collection = new Collection<AssignmentParticipants>(this);
audit_log_collection = new Collection<AuditLog>(this);
bucket_list_collection = new Collection<BucketList>(this);
@@ -255,6 +256,7 @@ export const UsersSchema = defineEntity({
airtrail_write_enabled: p.integer().nullable(),
display_name: p.text().nullable(),
immich_allow_insecure_tls: p.integer().default(0),
email_self_asserted: p.integer().default(0),
assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('user').hidden(),
audit_log_collection: () => p.oneToMany(AuditLog).mappedBy('user').hidden(),
bucket_list_collection: () => p.oneToMany(BucketList).mappedBy('user').hidden(),
+1
View File
@@ -43,4 +43,5 @@ export interface UsersTable {
airtrail_write_enabled: InsertOptional<number | null>;
display_name: InsertOptional<string | null>;
immich_allow_insecure_tls: InsertOptional<number>;
email_self_asserted: InsertOptional<number>;
}
@@ -0,0 +1,80 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
interface UserEmailRow {
id: number;
email: string;
}
interface AuditRow {
user_id: number | null;
resource: string | null;
details: string | null;
}
/** The parsed `details` of an audit row, or null for an empty or broken one. */
function detailsOf(row: AuditRow): Record<string, unknown> | null {
if (!row.details) return null;
try {
const parsed: unknown = JSON.parse(row.details);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
/**
* Remembers whether an account's email address is one the account typed in
* itself.
*
* An SSO sign-in that matches no linked account falls back to the email
* address and links the provider identity to whichever account holds it. That
* address proves nothing when the account chose it at signup or in its profile
* settings: anyone could put a colleague's address on their own account and
* receive the colleague's first SSO sign-in. `email_self_asserted` is 1 for
* such an address and keeps the sign-in from linking to it; an address an admin
* set, or one the provider created the account with, stays 0.
*
* Existing accounts start at 0, so instances keep linking the way they did.
* The exception is an account whose address provably moved away from the one
* it registered with (its `user.register` audit row) without an admin ever
* editing its email (an `admin.user_update` row naming `email`): only the
* account itself can have changed it. The audit details are JSON text, read
* and compared here rather than in SQL.
*/
export class Migration20200101042800_an_email_the_account_typed_in_itself extends Migration {
override name = 'Migration20200101042800_an_email_the_account_typed_in_itself';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'email_self_asserted', 'email_self_asserted INTEGER NOT NULL DEFAULT 0');
const registrations = (await this.execute(
`SELECT user_id, resource, details FROM audit_log WHERE action = 'user.register' AND user_id IS NOT NULL`,
)) as AuditRow[];
if (registrations.length === 0) return;
const registeredWith = new Map<number, string[]>();
for (const row of registrations) {
const email = detailsOf(row)?.email;
if (typeof email !== 'string' || row.user_id === null) continue;
registeredWith.set(row.user_id, [...(registeredWith.get(row.user_id) ?? []), email.trim().toLowerCase()]);
}
const adminEdits = (await this.execute(
`SELECT user_id, resource, details FROM audit_log WHERE action = 'admin.user_update'`,
)) as AuditRow[];
const editedByAdmin = new Set<string>();
for (const row of adminEdits) {
const fields = detailsOf(row)?.fields;
if (row.resource && Array.isArray(fields) && fields.includes('email')) editedByAdmin.add(row.resource);
}
const users = (await this.execute(`SELECT id, email FROM users WHERE COALESCE(is_guest, 0) = 0`)) as UserEmailRow[];
for (const user of users) {
const registered = registeredWith.get(user.id);
if (!registered || editedByAdmin.has(String(user.id))) continue;
if (registered.includes(user.email.trim().toLowerCase())) continue;
await this.execute('UPDATE users SET email_self_asserted = 1 WHERE id = ?', [user.id]);
}
}
}
+10 -11
View File
@@ -63,6 +63,7 @@ export interface UserRow {
airtrail_write_enabled: number | null;
display_name: string | null;
immich_allow_insecure_tls: number;
email_self_asserted: number;
}
/**
@@ -85,6 +86,8 @@ export interface NewUserRow {
oidc_sub?: string | null;
oidc_issuer?: string | null;
avatar?: string | null;
/** 1 for an address typed into the signup form (nobody vouches for it), 0 for the first account and SSO ones. */
email_self_asserted?: number;
}
/**
@@ -119,6 +122,7 @@ export interface UserProfilePatch {
amap_api_key?: string | null;
username?: string;
email?: string;
email_self_asserted?: number;
}
/** `UserProfileService.getApiKeyColumns`/`currentKeys` (UP1). */
@@ -280,6 +284,7 @@ export interface AdminEditPatch {
role?: string;
password_hash?: string;
password_version?: number;
email_self_asserted?: number;
}
export class UsersRepository extends TrekRepository<Users> {
@@ -474,6 +479,7 @@ export class UsersRepository extends TrekRepository<Users> {
oidc_sub: row.oidc_sub ?? null,
oidc_issuer: row.oidc_issuer ?? null,
avatar: row.avatar ?? null,
email_self_asserted: row.email_self_asserted ?? 0,
}),
);
const inserted = await this.findById(id);
@@ -990,17 +996,10 @@ export class UsersRepository extends TrekRepository<Users> {
// ---------------------------------------------------------------------
/**
* `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
* WHERE id = ? `` — the SET clause is built at the CALL SITE from a fixed
* set of 6 possible literal column names, never here: `patchProfile` takes
* only the already-decided final columns as a typed partial and writes
* them in ONE `nativeUpdate` (never `assign`+`flush`, which would be a
* second statement shape for the identical intent) — the entity-API
* equivalent of the legacy's dynamic `SET` list, without ever building SQL
* text. `updated_at` is always stamped, matching the legacy: this method's
* only caller (`UserProfileService.updateSettings`) calls it only when at
* least one field changed (`updates.length > 0`), the same guard the
* legacy statement was built under.
* `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ? ``: the
* caller decides the columns of {@link UserProfilePatch}, this writes them in ONE `nativeUpdate`
* (never `assign`+`flush`) and never builds SQL text. `updated_at` is always stamped, as in the
* legacy; the only caller (`UserProfileService.updateSettings`) calls it only when a field changed.
*/
async patchProfile(id: number, changes: UserProfilePatch): Promise<void> {
const platform = this.getEntityManager().getPlatform();
+4
View File
@@ -271,6 +271,10 @@ export class AdminService {
const patch: AdminEditPatch = {};
if (username) patch.username = username;
if (email) patch.email = email;
// An address the admin enters is one the admin vouches for, so an SSO sign-in
// may link to it again. The edit dialog sends the email on every save, so only
// a new address counts, not the unchanged one or the same one in another case.
if (email && email.toLowerCase() !== user.email.toLowerCase()) patch.email_self_asserted = 0;
if (role) patch.role = role;
if (password) {
patch.password_hash = passwordHash!;
+1
View File
@@ -15,6 +15,7 @@ const ACTION_LABELS: Record<string, string> = {
'user.account_delete': 'deleted account',
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'user.email_change': 'changed email',
'user.session_revoke': 'signed out a session',
'user.sessions_revoke_others': 'signed out other sessions',
'settings.app_update': 'updated settings',
+5
View File
@@ -196,6 +196,11 @@ export class AuthController {
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = await this.profile.updateSettings(user.id, body);
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
// The address itself stays out of the row, like the key values above; the row
// is what tells an admin later that the account chose its current address.
if (result.emailChanged) {
await this.audit.writeAudit({ userId: user.id, action: 'user.email_change', ip: getClientIp(req) });
}
return { success: result.success, user: result.user };
}
+4 -4
View File
@@ -468,10 +468,9 @@ export class AuthService {
const password_hash = bcrypt.hashSync(password, BCRYPT_COST);
try {
// One transaction for the whole signup: a mid-sequence throw (invite
// bookkeeping, trip auto-join) must not leave a half-registered user.
// The collision check and the first-user count run inside it too, so two
// signups at once cannot both pass the check or both become admin.
// One transaction for the whole signup: a mid-sequence throw (invite bookkeeping, trip auto-join)
// must not leave a half-registered user. The collision check and the first-user count run inside
// it too, so two signups at once cannot both pass the check or both become admin.
return await this.uow.transactional(async () => {
// Ignore guests (#1362): their synthetic username/email must never block a real signup.
const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username);
@@ -482,6 +481,7 @@ export class AuthService {
const role = isFirstUser ? 'admin' : 'user';
const inserted = await this.usersRepo.insertUser({
username, email, password_hash, role, first_seen_version: readEnv().app.appVersion || '0.0.0',
email_self_asserted: isFirstUser ? 0 : 1,
});
const user = { id: inserted.id, username, email, role, avatar: null, mfa_enabled: false };
+10 -1
View File
@@ -210,7 +210,7 @@ export class UserProfileService {
async updateSettings(
userId: number,
rawBody: unknown,
): Promise<{ success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] }> {
): Promise<{ success?: boolean; user?: Record<string, unknown>; changedKeys?: string[]; emailChanged?: boolean }> {
const body = rawBody as {
maps_api_key?: string;
openweather_api_key?: string;
@@ -261,6 +261,14 @@ export class UserProfileService {
if (username !== undefined) changes.username = username.trim();
if (email !== undefined) changes.email = email.trim();
// A new address typed in here is the account's own word for it, so an SSO
// sign-in no longer links to it by email (see OidcService.refreshLinkedUser).
// Changing only the case keeps the address and its standing.
const previousEmail = changes.email !== undefined ? await this.usersRepo.getEmail(userId) : null;
const emailChanged =
changes.email !== undefined && changes.email.toLowerCase() !== (previousEmail ?? '').toLowerCase();
if (emailChanged) changes.email_self_asserted = 1;
// Read before the write, so the comparison sees the old value; the role in
// the same row decides whether the two instance-wide names travel with it.
const current = await this.currentKeys(userId);
@@ -297,6 +305,7 @@ export class UserProfileService {
avatar_url: avatarUrl(updated || {}),
},
changedKeys,
emailChanged,
};
}
+25 -11
View File
@@ -665,21 +665,35 @@ export class OidcService implements OnModuleDestroy {
userInfo: OidcUserInfo,
picture: string | null,
): Promise<{ user: User; roleChange?: OidcRoleChange } | { error: string }> {
// Reaching here without an oidc_sub means we matched an existing local
// account by email. Only auto-link the OIDC identity when the IdP asserts
// the email is verified; an unverified email must not auto-link.
if (!user.oidc_sub) {
// A row that does not carry this sub and issuer was found by its email
// address alone. Linking it takes two promises: the IdP says the address is
// verified, and the address on the row was not typed in by the account itself
// (signup, profile settings), or whoever did that would receive this person's
// sign-in. The unverified answer comes first, so it never tells an unverified
// login whether such an account exists.
if (user.oidc_sub !== sub || user.oidc_issuer !== config.issuer) {
const emailVerified = userInfo.email_verified === true || userInfo.email_verified === 'true';
if (!emailVerified) {
return { error: 'email_not_verified' };
}
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).
if (user.email_self_asserted) {
console.warn(
`[OIDC] Refused to link a sign-in to user ${user.id} by email: the account set that address itself. An admin can confirm it by changing the email in the admin panel.`,
);
return { error: 'email_link_blocked' };
}
// The same provider with another subject is another person at that
// provider, never this account under a new name.
if (user.oidc_sub && user.oidc_issuer === config.issuer) {
console.warn(
`[OIDC] Refused to link a sign-in to user ${user.id} by email: it is linked to another subject of this provider.`,
);
return { error: 'email_link_blocked' };
}
// Without an oidc_sub this is the account's first SSO sign-in. With one from
// another issuer, the admin pointed the instance at a different IdP: leaving
// the old sub and issuer on the row would keep the account pinned to a
// provider that no longer exists (#2110).
await this.usersRepo.linkOidcIdentity(user.id, sub, config.issuer);
user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer };
}
+87
View File
@@ -12,6 +12,7 @@ import { db as testDb } from '../../src/db/database';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { OidcService } from '../../src/nest/oidc/oidc.service';
import { authCookie } from '../helpers/auth';
import { createUser } from '../helpers/factories';
import type { FactoryOrm } from '../helpers/factories/context';
import { findRow, findRows } from '../helpers/factories/rows';
@@ -349,6 +350,92 @@ describe('GET /api/auth/oidc/callback', () => {
});
});
// ── an email the account set itself ──────────────────────────────────────────
describe('GET /api/auth/oidc/callback onto an email the account set itself', () => {
/** One verified SSO sign-in for this sub and email, run through the real callback. */
async function ssoSignIn(sub: string, email: string): Promise<request.Response> {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub } });
mockGetUserInfo.mockResolvedValueOnce({ sub, email, name: 'Colleague', email_verified: true });
const { state } = await oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
return request(app)
.get(`/api/auth/oidc/callback?code=c-${sub}&state=${state}`)
.set('Cookie', `trek_oidc_state=${state}`);
}
async function register(username: string, email: string): Promise<request.Response> {
const res = await request(app).post('/api/auth/register').send({ username, email, password: 'Secure123!x' });
expect(res.status).toBe(201);
return res;
}
it('OIDC-030: a signup with a colleague address does not receive the colleague sign-in', async () => {
await register('owner', 'owner@example.com');
const squatter = await register('squatter', 'colleague@example.com');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const res = await ssoSignIn('sub-colleague', 'colleague@example.com');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('/login?oidc_error=email_link_blocked');
const row = await readUser(orm(), squatter.body.user.id);
expect(row.oidc_sub).toBeNull();
} finally {
warn.mockRestore();
}
});
it('OIDC-031: an address changed in the profile settings does not receive the sign-in either', async () => {
await register('owner', 'owner@example.com');
const member = await register('member', 'member@example.com');
const cookie = readCookie(member, 'trek_session');
const change = await request(app)
.put('/api/auth/me/settings')
.set('Cookie', `trek_session=${cookie}`)
.send({ email: 'colleague@example.com' });
expect(change.status).toBe(200);
const audit = await findRows(orm(), AuditLog, { user: member.body.user.id, action: 'user.email_change' });
expect(audit).toHaveLength(1);
expect(audit[0].details).toBeNull();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const res = await ssoSignIn('sub-colleague', 'colleague@example.com');
expect(res.headers.location).toContain('/login?oidc_error=email_link_blocked');
expect((await readUser(orm(), member.body.user.id)).oidc_sub).toBeNull();
} finally {
warn.mockRestore();
}
});
it('OIDC-032: an account an admin created, or whose email an admin changed, links on its first sign-in', async () => {
const owner = await register('owner', 'owner@example.com');
const created = await request(app)
.post('/api/admin/users')
.set('Cookie', authCookie(owner.body.user.id))
.send({ username: 'colleague', email: 'colleague@example.com', password: 'Secure123!x' });
expect(created.status).toBe(201);
const linked = await ssoSignIn('sub-colleague', 'colleague@example.com');
expect(linked.headers.location).toContain('/login?oidc_code=');
expect((await readUser(orm(), created.body.user.id)).oidc_sub).toBe('sub-colleague');
// A blocked account is released by the admin entering the address.
const member = await register('member', 'typo@example.com');
const edited = await request(app)
.put(`/api/admin/users/${member.body.user.id}`)
.set('Cookie', authCookie(owner.body.user.id))
.send({ email: 'member@example.com' });
expect(edited.status).toBe(200);
const res = await ssoSignIn('sub-member', 'member@example.com');
expect(res.headers.location).toContain('/login?oidc_code=');
expect((await readUser(orm(), member.body.user.id)).oidc_sub).toBe('sub-member');
});
});
// ── /exchange ─────────────────────────────────────────────────────────────────
describe('GET /api/auth/oidc/exchange', () => {
@@ -0,0 +1,94 @@
/**
* `Migration20200101042800_an_email_the_account_typed_in_itself`: every
* existing account starts out trusted (0), except one whose address provably
* moved away from the one it registered with while no admin ever edited its
* email. A broken audit row is skipped, and a second run changes nothing.
*/
import { Migration20200101042800_an_email_the_account_typed_in_itself } from '../../../src/db/migrations/Migration20200101042800_an_email_the_account_typed_in_itself';
import {
createMigrationOrm,
migrateTo,
pendingNames,
rawExec,
rawQuery,
runMigrationDirect,
} from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101042800_an_email_the_account_typed_in_itself';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
async function seed(orm: MikroORM): Promise<void> {
// 1 registered and kept the address; 2 registered and changed it itself;
// 3 registered, changed it, and an admin set an email later; 4 was made by
// an admin (no register row); 5 registered in another case only; 6 has a
// broken register row.
await rawExec(
orm,
`INSERT INTO users (id, username, email, password_hash) VALUES
(1, 'kept', 'kept@test', 'x'),
(2, 'moved', 'colleague@test', 'x'),
(3, 'fixed', 'fixed-by-admin@test', 'x'),
(4, 'made', 'made@test', 'x'),
(5, 'cased', 'Cased@Test', 'x'),
(6, 'broken', 'broken-now@test', 'x')`,
);
await rawExec(
orm,
`INSERT INTO audit_log (user_id, action, resource, details) VALUES
(1, 'user.register', NULL, '{"username":"kept","email":"kept@test","role":"user"}'),
(2, 'user.register', NULL, '{"username":"moved","email":"moved@test","role":"user"}'),
(3, 'user.register', NULL, '{"username":"fixed","email":"fixed@test","role":"user"}'),
(4, 'admin.user_update', '3', '{"targetUser":"fixed-self@test","fields":["username","email","role"]}'),
(4, 'admin.user_update', '2', '{"targetUser":"colleague@test","fields":["role"]}'),
(5, 'user.register', NULL, '{"username":"cased","email":"cased@test","role":"user"}'),
(6, 'user.register', NULL, 'not json')`,
);
}
const flags = (orm: MikroORM) =>
rawQuery<{ id: number; email_self_asserted: number }>(orm, 'SELECT id, email_self_asserted FROM users ORDER BY id');
const EXPECTED = [
{ id: 1, email_self_asserted: 0 },
{ id: 2, email_self_asserted: 1 },
{ id: 3, email_self_asserted: 0 },
{ id: 4, email_self_asserted: 0 },
{ id: 5, email_self_asserted: 0 },
{ id: 6, email_self_asserted: 0 },
];
describe('email self-asserted migration', () => {
it('EMAILSELF-MIG-001: only an address the account provably changed itself is marked', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
expect(await flags(orm)).toEqual(EXPECTED);
} finally {
await orm.close(true);
}
}, 30000);
it('EMAILSELF-MIG-002: running it again changes nothing', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
await runMigrationDirect(orm, Migration20200101042800_an_email_the_account_typed_in_itself);
expect(await flags(orm)).toEqual(EXPECTED);
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -39,6 +39,7 @@ const UNNUMBERED = [
'Migration20200101042500_a_cron_tick_runs_in_one_process',
'Migration20200101042600_a_session_row_per_sign_in',
'Migration20200101042700_timestamps_stored_in_one_spelling',
'Migration20200101042800_an_email_the_account_typed_in_itself',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -240,6 +240,19 @@ describe('UsersRepository', () => {
expect(raw).toMatchObject({ username: 'localuser', email: 'local@example.com', role: 'user', login_count: 0 });
});
it('USERSREPO-016b: the self-set email mark is 0 unless the caller names it', async () => {
const base = { password_hash: 'hashed', role: 'user', first_seen_version: '1.2.3' };
const plain = await users.insertUser({ ...base, username: 'plainmark', email: 'plain@example.com' });
const own = await users.insertUser({
...base,
username: 'ownmark',
email: 'own@example.com',
email_self_asserted: 1,
});
expect(plain.email_self_asserted).toBe(0);
expect(own.email_self_asserted).toBe(1);
});
it('USERSREPO-017: SSO registration shape (O14) — oidc_sub/oidc_issuer/avatar set', async () => {
const row = await users.insertUser({
username: 'ssouser',
@@ -795,6 +795,21 @@ describe('admin quirk fixes (post-fold)', () => {
const row = await readUser(orm, user.id);
expect(row.username).toBe(user.username);
});
// The edit dialog sends the email on every save, so only a new address is the
// admin vouching for it; the account's own mark survives any other edit.
it('ADMIN-SVC-073 — a new email from the admin clears the self-set mark, an unchanged one keeps it', async () => {
const { user } = createUser(testDb, { email: 'self@example.com' });
await updateRows(orm, Users, { id: user.id }, { email_self_asserted: 1 });
await updateUser(String(user.id), { email: 'self@example.com', role: 'user', username: user.username });
expect((await readUser(orm, user.id)).email_self_asserted).toBe(1);
await updateUser(String(user.id), { email: 'SELF@example.com' });
expect((await readUser(orm, user.id)).email_self_asserted).toBe(1);
await updateUser(String(user.id), { email: 'confirmed@example.com' });
expect((await readUser(orm, user.id)).email_self_asserted).toBe(0);
});
});
// ── What an admin password reset ends, and what an ordinary edit must not ─────
@@ -707,6 +707,37 @@ describe('AuthController (authenticated)', () => {
expect(writeAudit).not.toHaveBeenCalled();
});
it('update-settings logs a new email address without the address, and nothing for any other save', async () => {
writeAudit.mockClear();
const withEmail = ac(
asvc({}),
rl(),
{},
{
updateSettings: vi
.fn()
.mockReturnValue({ success: true, user: { id: 1 }, changedKeys: [], emailChanged: true }),
},
);
expect(await withEmail.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
expect(writeAudit).toHaveBeenCalledTimes(1);
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ userId: user.id, action: 'user.email_change' }));
expect(writeAudit.mock.calls[0][0].details).toBeUndefined();
writeAudit.mockClear();
await ac(
asvc({}),
rl(),
{},
{
updateSettings: vi
.fn()
.mockReturnValue({ success: true, user: { id: 1 }, changedKeys: [], emailChanged: false }),
},
).updateSettings(user, {}, req);
expect(writeAudit).not.toHaveBeenCalled();
});
it('update-settings + get-settings map errors, else return their payloads', async () => {
expect(
await thrownAsync(() =>
@@ -921,6 +921,13 @@ describe('registerUser — success paths', () => {
expect(result.auditDetails).toEqual({ username: 'first', email: 'first@x.com', role: 'admin' });
});
it('AUTH-DB-056b: a signup marks the address it typed in, except for the first account', async () => {
const first = await svc.registerUser({ username: 'owner', email: 'owner@x.com', password: 'Secure123!' });
const second = await svc.registerUser({ username: 'joiner', email: 'joiner@x.com', password: 'Secure123!' });
expect((await readUser(orm, first.auditUserId!)).email_self_asserted).toBe(0);
expect((await readUser(orm, second.auditUserId!)).email_self_asserted).toBe(1);
});
it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', async () => {
createUser(testDb, { username: 'taken', email: 'taken@x.com' });
expect(await asLegacyResult(svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' }))).toEqual({
+148 -1
View File
@@ -125,7 +125,13 @@ const orm = () => sharedTestOrm(testDb);
/** Writes columns of the user row directly, the state a login starts from. */
async function setUserColumns(
userId: number,
data: { oidc_sub?: string | null; oidc_issuer?: string | null; avatar?: string | null; updated_at?: string },
data: {
oidc_sub?: string | null;
oidc_issuer?: string | null;
avatar?: string | null;
updated_at?: string;
email_self_asserted?: number;
},
): Promise<void> {
await updateRows(await orm(), Users, { id: userId }, data);
}
@@ -1030,6 +1036,147 @@ describe('findOrCreateUser', () => {
// ── claim-based role mapping on login (#2364) ────────────────────────────────
// A sign-in that finds an account only through its email address. The address is
// proof of nothing when the account typed it in itself (signup, profile settings):
// whoever did that would otherwise get every later SSO login for that address.
describe('findOrCreateUser linking by email', () => {
const SELF = { email_self_asserted: 1 };
it('OIDC-SVC-100: a verified sign-in is not linked to an account that set this email itself', async () => {
const { user } = createUser(testDb, { email: 'colleague@corp.test' });
await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, ...SELF });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await svc.findOrCreateUser(
{ sub: 'victim-sub', email: 'colleague@corp.test', name: 'Colleague', email_verified: true },
MOCK_CONFIG,
);
expect(result).toEqual({ error: 'email_link_blocked' });
const row = await readUser(await orm(), user.id);
expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({ oidc_sub: null, oidc_issuer: null });
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-101: an unverified sign-in against such an account says email_not_verified, not that the account exists', async () => {
const { user } = createUser(testDb, { email: 'quiet@corp.test' });
await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, ...SELF });
const result = await svc.findOrCreateUser({ sub: 'other-sub', email: 'quiet@corp.test' }, MOCK_CONFIG);
expect(result).toEqual({ error: 'email_not_verified' });
expect((await readUser(await orm(), user.id)).oidc_sub).toBeNull();
});
it('OIDC-SVC-102: an account an admin created still links on its first verified sign-in', async () => {
const { user } = createUser(testDb, { email: 'made-by-admin@corp.test' });
await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, email_self_asserted: 0 });
const result = await svc.findOrCreateUser(
{ sub: 'sub-made-by-admin', email: 'made-by-admin@corp.test', email_verified: 'true' },
MOCK_CONFIG,
);
expect((result as { user: { id: number } }).user.id).toBe(user.id);
expect((await readUser(await orm(), user.id)).oidc_sub).toBe('sub-made-by-admin');
});
it('OIDC-SVC-103: the same provider with another subject is refused, verified or not, and the row keeps its link', async () => {
const { user } = createUser(testDb, { email: 'linked@corp.test' });
await setUserColumns(user.id, { oidc_sub: 'sub-owner', oidc_issuer: MOCK_CONFIG.issuer });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const verified = await svc.findOrCreateUser(
{ sub: 'sub-stranger', email: 'linked@corp.test', email_verified: true },
MOCK_CONFIG,
);
const unverified = await svc.findOrCreateUser({ sub: 'sub-stranger', email: 'linked@corp.test' }, MOCK_CONFIG);
expect(verified).toEqual({ error: 'email_link_blocked' });
expect(unverified).toEqual({ error: 'email_not_verified' });
const row = await readUser(await orm(), user.id);
expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({
oidc_sub: 'sub-owner',
oidc_issuer: MOCK_CONFIG.issuer,
});
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-104: a provider switch needs a verified email, the row stays on the old provider otherwise', async () => {
const { user } = createUser(testDb, { email: 'mover@corp.test' });
await setUserColumns(user.id, { oidc_sub: 'sub-old', oidc_issuer: 'https://old-idp.example.com' });
const result = await svc.findOrCreateUser(
{ sub: 'sub-new', email: 'mover@corp.test' },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
expect(result).toEqual({ error: 'email_not_verified' });
const row = await readUser(await orm(), user.id);
expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({
oidc_sub: 'sub-old',
oidc_issuer: 'https://old-idp.example.com',
});
});
it('OIDC-SVC-105: a provider switch onto an email the account set itself is refused', async () => {
const { user } = createUser(testDb, { email: 'switched-self@corp.test' });
await setUserColumns(user.id, { oidc_sub: 'sub-old', oidc_issuer: 'https://old-idp.example.com', ...SELF });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await svc.findOrCreateUser(
{ sub: 'sub-new', email: 'switched-self@corp.test', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
expect(result).toEqual({ error: 'email_link_blocked' });
expect((await readUser(await orm(), user.id)).oidc_sub).toBe('sub-old');
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-106: a refused link never hands the account the role from the claims', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
const { user } = createUser(testDb, { email: 'trap@corp.test', role: 'user' });
await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, ...SELF });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await svc.findOrCreateUser(
{ sub: 'admin-sub', email: 'trap@corp.test', email_verified: true, groups: ['trek-admins'] },
MOCK_CONFIG,
);
expect((await readUser(await orm(), user.id)).role).toBe('user');
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-107: an account found by its own subject signs in, whoever set its email', async () => {
const { user } = createUser(testDb, { email: 'own@corp.test' });
await setUserColumns(user.id, { oidc_sub: 'sub-own', oidc_issuer: MOCK_CONFIG.issuer, ...SELF });
const result = await svc.findOrCreateUser({ sub: 'sub-own', email: 'own@corp.test' }, MOCK_CONFIG);
expect((result as { user: { id: number } }).user.id).toBe(user.id);
});
it('OIDC-SVC-108: an account the provider creates starts out with a trusted email', async () => {
createUser(testDb, { email: 'someone@corp.test' });
await svc.findOrCreateUser({ sub: 'sub-fresh', email: 'fresh@corp.test', name: 'Fresh' }, MOCK_CONFIG);
expect((await userByEmail('fresh@corp.test'))?.email_self_asserted).toBe(0);
});
});
describe('findOrCreateUser role mapping', () => {
type RoleResult = { user: any; roleChange?: { from: string; to: string; claim: string } };
@@ -143,6 +143,32 @@ describe('updateSettings', () => {
});
});
// An address the account types in itself is not one an SSO sign-in may link to by email.
describe('updateSettings marks an email the account set itself', () => {
const flagOf = async (id: number) => (await readUser(await sharedTestOrm(testDb), id)).email_self_asserted;
it('AUTH-DB-120: a new address is marked and reported as changed', async () => {
const { user } = createUser(testDb, { email: 'before@example.com' });
const result = await profile.updateSettings(user.id, { email: 'colleague@example.com' });
expect(result.emailChanged).toBe(true);
expect(await flagOf(user.id)).toBe(1);
});
it('AUTH-DB-121: the same address in another case keeps its standing', async () => {
const { user } = createUser(testDb, { email: 'same@example.com' });
const result = await profile.updateSettings(user.id, { email: ' Same@Example.com ' });
expect(result.emailChanged).toBe(false);
expect(await flagOf(user.id)).toBe(0);
});
it('AUTH-DB-122: a save without an email leaves the mark alone', async () => {
const { user } = createUser(testDb);
const result = await profile.updateSettings(user.id, { username: 'renamed' });
expect(result.emailChanged).toBe(false);
expect(await flagOf(user.id)).toBe(0);
});
});
// ---------------------------------------------------------------------------
// getSettings
// ---------------------------------------------------------------------------