diff --git a/.changeset/device-2fa-survives-relogin.md b/.changeset/device-2fa-survives-relogin.md new file mode 100644 index 0000000..6826506 --- /dev/null +++ b/.changeset/device-2fa-survives-relogin.md @@ -0,0 +1,42 @@ +--- +'@factiii/auth': patch +--- + +Stop a revoked session's 2FA secret from answering the login challenge, and +carry the live one onto the session that replaces it + +In device mode the TOTP secret lives on `Session.twoFaSecret`, but it belongs to +the phone rather than to any one session of it. `findTwoFaSecretsByUserId` did +not filter revoked rows, so a secret from a device the user had deliberately +revoked — an old phone, a sold one, "log out everywhere" after a compromise — +still passed the 2FA challenge. Revoking a device did not revoke its second +factor. Present in every 0.x. + +The filter alone would have turned that leak into a lockout, because nothing +carried the secret across a session replacement: `revokeDeviceSessionsForUser` +retires the session this device already held on every ordinary sign-in, so +filtering revoked rows would leave a re-logged-in user with no live secret at +all, and an account with no email on file with no way back in. So the two halves +are one change. `carryDeviceTwoFaSecret` moves the secret onto the replacement +in the password, OAuth and magic-link paths, and only then does the adapter +query — both the Prisma and Drizzle implementations — filter `revokedAt`. + +It **moves** the secret rather than copying it, which the schema requires: +`Session.twoFaSecret` is `@unique`, so the donor and the recipient cannot hold +the same string even for the length of a transaction. `DeviceAuthAdapter` gains +an optional `moveTwoFaSecret`, implemented atomically by both shipped adapters +(clear the donor, then write the recipient, in one transaction) so a crash cannot +leave the secret on neither row. It is optional, not required, so an adapter +written against an earlier version still satisfies the interface; without it the +carry falls back to a clear-then-set pair, which is not atomic but fails in the +recoverable direction. + +The carry is best-effort and never throws: losing a cached second factor is +recoverable, since the vault can mint a new one, while failing here would reject +a sign-in whose credentials have already been accepted. It does report a failure +rather than swallowing it. It is a no-op in standard mode, where the secret is on +the user row and no session replacement can touch it. + +This also fixes an existing failure: a consumer that already filtered revoked +rows on its own approval path found no secret at all after a re-login, so +push approvals failed until the vault happened to re-materialize one. diff --git a/.changeset/oauth-attach-trusts-proven-email.md b/.changeset/oauth-attach-trusts-proven-email.md new file mode 100644 index 0000000..1c57b8c --- /dev/null +++ b/.changeset/oauth-attach-trusts-proven-email.md @@ -0,0 +1,28 @@ +--- +'@factiii/auth': patch +--- + +Only attach an OAuth sign-in to an existing account by an email that was proven + +`oAuthLogin` attaches a new Google or Apple identity to an existing passwordless +account whose email matches the provider's. Two things let that attach land on +the wrong account. + +The email could come from the client. When Apple's signed token carried no +email claim, the verifier fell back to `user.email` from the request, so anyone +holding a valid Apple token for their own Apple ID could name another person's +address and be signed into that person's passwordless account. The Google branch +trusted `payload.email` without checking `email_verified`. The verifier now takes +the email only from the signed token, and from Google only when it is marked +verified. A token with no trusted email still verifies — an identity already +linked by its subject keeps signing in — but it can no longer attach or create. +`OAuthResult.email` is now optional to say so. + +And the account's own email did not have to be proven. A consumer that lets a +user store an unclaimed address unverified is open to pre-hijacking: register a +passwordless account under a victim's address, and the victim's first genuine +sign-in lands in an account the registrant still controls. No token is forged +for that one. Attach-by-email now requires the matching account's +`emailVerificationStatus` to be `VERIFIED`, and refuses otherwise; the user can +still link the provider from a signed-in session. An adapter that does not return +the field refuses every attach rather than allowing any. diff --git a/packages/auth/src/adapters/deviceAuth.ts b/packages/auth/src/adapters/deviceAuth.ts index 1fdfa05..d0b1345 100644 --- a/packages/auth/src/adapters/deviceAuth.ts +++ b/packages/auth/src/adapters/deviceAuth.ts @@ -37,6 +37,24 @@ export interface DeviceAuthAdapter { clearTwoFaSecrets(userId: number, excludeSessionId?: number): Promise; /** Set the `twoFaSecret` on a single session. */ setTwoFaSecret(sessionId: number, secret: string | null): Promise; + /** + * Move a session's `twoFaSecret` to another session of the same user, in one + * atomic step. No-op when `fromSessionId` holds none. + * + * This exists because the secret cannot be COPIED: the reference schema + * declares `Session.twoFaSecret` as `@unique`, so two rows may not hold the + * same string for an instant, let alone a transaction. The donor must give it + * up in the same breath the recipient takes it, and a crash in between must + * not leave it on neither row. + * + * Optional so that adding it does not break an adapter written against an + * earlier version. `carryDeviceTwoFaSecret` falls back to a clear-then-set + * pair when it is absent — correct, but not atomic, and a crash between the + * two writes costs the device its cached second factor (recoverable: the + * vault mints a new one). Implement it if your database can do better, which + * both shipped adapters do. + */ + moveTwoFaSecret?(userId: number, fromSessionId: number, toSessionId: number): Promise; /** Find a session with its (optional) device join, scoped to a user. */ findByIdWithDevice(id: number, userId: number): Promise; /** Read just the deviceId from a session, scoped to a user. */ diff --git a/packages/auth/src/adapters/drizzleAdapter.ts b/packages/auth/src/adapters/drizzleAdapter.ts index 6c566b9..b36aa20 100644 --- a/packages/auth/src/adapters/drizzleAdapter.ts +++ b/packages/auth/src/adapters/drizzleAdapter.ts @@ -425,11 +425,20 @@ export function createDrizzleDeviceAdapter( return { session: { + // The revoked filter is load-bearing — see the prisma twin for why a + // revoked device's secret must stop answering the login challenge, and why + // this is only safe alongside `carryDeviceTwoFaSecret`. async findTwoFaSecretsByUserId(userId: number): Promise<{ twoFaSecret: string | null }[]> { const secretRows = await db .select({ twoFaSecret: sessions.twoFaSecret }) .from(sessions) - .where(and(eq(sessions.userId, userId), sql`${sessions.twoFaSecret} is not null`)); + .where( + and( + eq(sessions.userId, userId), + sql`${sessions.twoFaSecret} is not null`, + isNull(sessions.revokedAt) + ) + ); return secretRows as { twoFaSecret: string | null }[]; }, @@ -449,6 +458,32 @@ export function createDrizzleDeviceAdapter( await db.update(sessions).set({ twoFaSecret: secret }).where(eq(sessions.id, sessionId)); }, + // See the prisma twin for why this exists and why the clear must precede + // the write: `twoFaSecret` is unique, so the secret is moved, never copied. + async moveTwoFaSecret( + userId: number, + fromSessionId: number, + toSessionId: number + ): Promise { + await db.transaction(async (tx) => { + const rows = await tx + .select({ twoFaSecret: sessions.twoFaSecret }) + .from(sessions) + .where(and(eq(sessions.id, fromSessionId), eq(sessions.userId, userId))); + const secret = rows[0]?.twoFaSecret; + if (!secret) return; + + await tx + .update(sessions) + .set({ twoFaSecret: null }) + .where(and(eq(sessions.id, fromSessionId), eq(sessions.userId, userId))); + await tx + .update(sessions) + .set({ twoFaSecret: secret }) + .where(and(eq(sessions.id, toSessionId), eq(sessions.userId, userId))); + }); + }, + async findByIdWithDevice(id: number, userId: number): Promise { const rows = await db .select({ diff --git a/packages/auth/src/adapters/prismaAdapter.ts b/packages/auth/src/adapters/prismaAdapter.ts index 4847864..e688a85 100644 --- a/packages/auth/src/adapters/prismaAdapter.ts +++ b/packages/auth/src/adapters/prismaAdapter.ts @@ -347,9 +347,19 @@ export function createPrismaDeviceAdapter(prisma: unknown): DeviceAuthAdapter { const db = prisma as PrismaModelAccess; return { session: { + // `revokedAt: null` is load-bearing, not tidiness. Revoking a session sets + // the column and leaves the row, so without this filter the TOTP secret of + // a device the user deliberately revoked — an old phone, a sold one, "log + // out everywhere" after a compromise — still answers the login challenge. + // Revoking a device has to revoke its second factor with it. + // + // Safe only because `carryDeviceTwoFaSecret` moves the secret onto the + // replacement session on every sign-in (utilities/issueCookies.ts). Without + // that, this filter turns the leak into a lockout: an ordinary re-login + // would leave the account with no live secret at all. async findTwoFaSecretsByUserId(userId: number): Promise<{ twoFaSecret: string | null }[]> { return db.session.findMany({ - where: { userId, twoFaSecret: { not: null } }, + where: { userId, twoFaSecret: { not: null }, revokedAt: null }, select: { twoFaSecret: true }, }) as Promise<{ twoFaSecret: string | null }[]>; }, @@ -371,6 +381,43 @@ export function createPrismaDeviceAdapter(prisma: unknown): DeviceAuthAdapter { }); }, + async moveTwoFaSecret( + userId: number, + fromSessionId: number, + toSessionId: number + ): Promise { + const apply = async (client: PrismaModelAccess) => { + const from = (await client.session.findUnique({ + where: { id: fromSessionId, userId }, + select: { twoFaSecret: true }, + })) as { twoFaSecret: string | null } | null; + if (!from?.twoFaSecret) return; + + // Clear BEFORE writing, even inside the transaction. `twoFaSecret` is + // `@unique` and Prisma does not declare the constraint DEFERRABLE, so + // Postgres checks it per statement: the donor has to be empty before + // the recipient can hold the string. Writing first fails outright, and + // copying — which is what this method replaced — fails the same way. + await client.session.updateMany({ + where: { id: fromSessionId, userId }, + data: { twoFaSecret: null }, + }); + await client.session.updateMany({ + where: { id: toSessionId, userId }, + data: { twoFaSecret: from.twoFaSecret }, + }); + }; + + // Both writes or neither, so a crash cannot leave the secret on no row + // at all. `$transaction` is optional on the client shape this adapter + // accepts; without it the pair still runs in the fail-safe order. + if (db.$transaction) { + await db.$transaction((tx) => apply(tx as PrismaModelAccess)); + return; + } + await apply(db); + }, + async findByIdWithDevice(id: number, userId: number): Promise { const session = await db.session.findUnique({ where: { id, userId }, diff --git a/packages/auth/src/procedures/base.ts b/packages/auth/src/procedures/base.ts index 18b53b0..add3532 100644 --- a/packages/auth/src/procedures/base.ts +++ b/packages/auth/src/procedures/base.ts @@ -7,7 +7,11 @@ import { detectBrowser } from '../utilities/browser'; import { isTwoFaEnabled, verifyTwoFaChallenge } from './twoFa/verifyChallenge'; import type { ResolvedAuthConfig } from '../utilities/config'; import { clearAuthCookies, setAuthCookies } from '../utilities/cookies'; -import { issueAuthCookies, revokeDeviceSessionsForUser } from '../utilities/issueCookies'; +import { + carryDeviceTwoFaSecret, + issueAuthCookies, + revokeDeviceSessionsForUser, +} from '../utilities/issueCookies'; import { createAuthToken } from '../utilities/jwt'; import { comparePassword, hashPassword } from '../utilities/password'; import type { UsernameMode } from '../types/config'; @@ -266,7 +270,11 @@ export class BaseProcedureFactory< // Credentials and 2FA have both passed by here, so a session this device // already holds for the same account is stale, not a reason to refuse. // Retire it and issue a fresh one. - await revokeDeviceSessionsForUser(this.config, ctx.headers.cookie, user.id); + const replacedSessionIds = await revokeDeviceSessionsForUser( + this.config, + ctx.headers.cookie, + user.id + ); const extraSessionData = this.config.hooks?.getSessionData ? await this.config.hooks.getSessionData(typedInput) @@ -279,6 +287,14 @@ export class BaseProcedureFactory< ...extraSessionData, }); + // The device's second factor rides on the session, so it has to move to + // the replacement or the sign-in quietly costs the user their 2FA. + await carryDeviceTwoFaSecret(this.config, { + userId: user.id, + revokedSessionIds: replacedSessionIds, + newSessionId: session.id, + }); + if (this.config.hooks?.onUserLogin) { await this.config.hooks.onUserLogin(user.id, session.id); } diff --git a/packages/auth/src/procedures/magicLink.ts b/packages/auth/src/procedures/magicLink.ts index 4264471..94e3dee 100644 --- a/packages/auth/src/procedures/magicLink.ts +++ b/packages/auth/src/procedures/magicLink.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { type BaseProcedure } from '../types/trpc'; import type { ResolvedAuthConfig } from '../utilities/config'; -import { revokeDeviceSessionsForUser } from '../utilities/issueCookies'; +import { carryDeviceTwoFaSecret, revokeDeviceSessionsForUser } from '../utilities/issueCookies'; import { createSessionWithTokenAndCookie } from '../utilities/session'; /** Factory for magic link authentication procedures. */ @@ -56,7 +56,11 @@ export class MagicLinkProcedureFactory { // The link proves control of the address, so a session this device // already holds for the same account is stale, not a reason to refuse. - await revokeDeviceSessionsForUser(this.config, ctx.headers.cookie, magicLink.userId); + const replacedSessionIds = await revokeDeviceSessionsForUser( + this.config, + ctx.headers.cookie, + magicLink.userId + ); // Mark as used (single-use) await db.markUsed(magicLink.id); @@ -68,7 +72,7 @@ export class MagicLinkProcedureFactory { ? await this.config.hooks.onBeforeMagicLinkSession(magicLink.userId) : {}; - await createSessionWithTokenAndCookie( + const { sessionId } = await createSessionWithTokenAndCookie( this.config, { userId: magicLink.userId, @@ -79,6 +83,14 @@ export class MagicLinkProcedureFactory { ctx.res ); + // Same rule as the other sign-in paths: the device keeps the second + // factor it already had. A magic link is not a reason to lose it. + await carryDeviceTwoFaSecret(this.config, { + userId: magicLink.userId, + revokedSessionIds: replacedSessionIds, + newSessionId: sessionId, + }); + return { success: true }; }); } diff --git a/packages/auth/src/procedures/oauth.ts b/packages/auth/src/procedures/oauth.ts index 95c887a..955ce4c 100644 --- a/packages/auth/src/procedures/oauth.ts +++ b/packages/auth/src/procedures/oauth.ts @@ -6,7 +6,11 @@ import type { SchemaExtensions } from '../types/hooks'; import { type AuthProcedure, type BaseProcedure } from '../types/trpc'; import { detectBrowser } from '../utilities'; import type { ResolvedAuthConfig } from '../utilities/config'; -import { issueAuthCookies, revokeDeviceSessionsForUser } from '../utilities/issueCookies'; +import { + carryDeviceTwoFaSecret, + issueAuthCookies, + revokeDeviceSessionsForUser, +} from '../utilities/issueCookies'; import { assertKeepsLoginMethod } from '../utilities/loginMethods'; import { createOAuthVerifier, type OAuthProvider, type OAuthResult } from '../utilities/oauth'; import { type CreatedSchemas, type OAuthSchemaInput } from '../validators'; @@ -104,6 +108,19 @@ export class OAuthLoginProcedureFactory< }); } + // Attaching by email is only safe when the address was PROVEN on the + // account being attached to. Consumers can let a user store any unclaimed + // address unverified, so an unverified match may be an account someone + // else registered in the victim's name — and signing the victim into it + // hands them an account the registrant still controls (pre-hijacking). + // An adapter that omits the field refuses every attach: fail-closed. + if (existing && existing.emailVerificationStatus !== 'VERIFIED') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Sign in another way, then link this provider from Settings.', + }); + } + let created = false; if (existing) { user = existing; @@ -145,7 +162,11 @@ export class OAuthLoginProcedureFactory< // The provider has vouched for this identity, so a session this device // already holds for the same account is stale, not a reason to refuse. - await revokeDeviceSessionsForUser(this.config, ctx.headers.cookie, user.id); + const replacedSessionIds = await revokeDeviceSessionsForUser( + this.config, + ctx.headers.cookie, + user.id + ); const extraSessionData = this.config.hooks?.getSessionData ? await this.config.hooks.getSessionData(typedInput) @@ -158,6 +179,14 @@ export class OAuthLoginProcedureFactory< ...extraSessionData, }); + // An OAuth account can still have device 2FA enrolled from a password it + // used to have, so this path carries the secret like any other. + await carryDeviceTwoFaSecret(this.config, { + userId: user.id, + revokedSessionIds: replacedSessionIds, + newSessionId: session.id, + }); + if (this.config.hooks?.onUserLogin) { await this.config.hooks.onUserLogin(user.id, session.id); } diff --git a/packages/auth/src/utilities/issueCookies.ts b/packages/auth/src/utilities/issueCookies.ts index 5c59714..22f784e 100644 --- a/packages/auth/src/utilities/issueCookies.ts +++ b/packages/auth/src/utilities/issueCookies.ts @@ -136,6 +136,86 @@ export async function revokeDeviceSessionsForUser( return revoked; } +/** + * Move a device-mode TOTP secret from the sessions a sign-in just retired onto + * the session that replaced them. Call it after creating the replacement, with + * the ids `revokeDeviceSessionsForUser` returned. + * + * MOVE, not copy. `Session.twoFaSecret` is `@unique` in the reference schema, so + * two rows may not hold the same string for an instant: writing the secret to the + * replacement while the retired row still holds it throws a unique-constraint + * error, and the first version of this function did exactly that and then + * swallowed the error — leaving the replacement with nothing, which with the + * revoked filter live is the lockout this function exists to prevent. The unit + * tests missed it because a mocked adapter has no unique index, and the package's + * own e2e schema had dropped the constraint. Any future change here must keep the + * donor and the recipient from holding the string at the same time. + * + * In device mode the second factor lives on `Session.twoFaSecret`, but it + * belongs to the *phone*, not to any one session of it — `enableTwofa` writes it + * once and the vault caches it from there. Nothing carried it across a + * replacement, so every ordinary sign-in left the device's live secret on a + * revoked row and gave the replacement none. That was survivable only because + * `findTwoFaSecretsByUserId` did not filter revoked rows, which is a hole: + * a secret from a device the user deliberately revoked still answered the login + * challenge. Closing that hole without this carry would turn the leak into a + * lockout — after any re-login the user would have no live secret at all, and on + * an account with no email on file, no way back in. The two changes are one + * change; do not ship either alone. + * + * It also fixes something already broken: a consumer that filters revoked rows + * itself (factiii's push-approval path does) finds nothing after a re-login, so + * approvals fail until the vault happens to re-materialize a secret. + * + * Best effort by design. A failure here costs the device its cached second + * factor — recoverable, since the vault can mint a new one — while throwing + * would fail a sign-in whose credentials have already been accepted. It is not + * SILENT, though: swallowing without a word is how the copy bug survived review + * and every unit test, so a failure says so on the way past. + */ +export async function carryDeviceTwoFaSecret( + config: ResolvedAuthConfig, + params: { userId: number; revokedSessionIds: number[]; newSessionId: number } +): Promise { + const { userId, revokedSessionIds, newSessionId } = params; + // Standard mode keeps the secret on the user row, where a session replacement + // cannot touch it, so there is nothing to carry and no column to write. + if (config.features.twoFaMode !== 'device') return; + const deviceAuth = config.deviceAuth; + if (!deviceAuth) return; + if (revokedSessionIds.length === 0) return; + + try { + for (const id of revokedSessionIds) { + const row = await deviceAuth.session.findByIdWithDevice(id, userId); + // First one wins. Several retired sessions can each hold a secret (one per + // sign-in that enrolled), and any of them is a secret this device's vault + // has been using — they are alternatives, not a set to merge. + if (!row?.twoFaSecret) continue; + + if (deviceAuth.session.moveTwoFaSecret) { + await deviceAuth.session.moveTwoFaSecret(userId, id, newSessionId); + return; + } + // Fallback for an adapter written before `moveTwoFaSecret` existed. Not + // atomic, so the order is the safety: clearing first means the worst case + // is a secret on neither row, which the vault can re-mint. Writing first + // would simply throw against a unique index and change nothing. + await deviceAuth.session.setTwoFaSecret(id, null); + await deviceAuth.session.setTwoFaSecret(newSessionId, row.twoFaSecret); + return; + } + } catch (err) { + // Never fail a sign-in whose credentials have already been accepted — but + // never disappear either. This losing quietly is what cost a release. + console.error( + '[@factiii/auth] could not carry the device 2FA secret to the new session; ' + + 'the device must re-materialize one from its vault:', + err + ); + } +} + /** Returns session ids from the request cookie. */ function readExistingBundle( cookieHeader: string | undefined, diff --git a/packages/auth/src/utilities/oauth.ts b/packages/auth/src/utilities/oauth.ts index 85502a7..2a913ce 100644 --- a/packages/auth/src/utilities/oauth.ts +++ b/packages/auth/src/utilities/oauth.ts @@ -4,7 +4,18 @@ import { OAuth2Client } from 'google-auth-library'; export type OAuthProvider = 'GOOGLE' | 'APPLE'; export interface OAuthResult { - email: string; + /** + * The email the PROVIDER vouches for, or undefined when it vouches for none. + * + * Optional on purpose. An email here is used to attach a new identity to an + * existing account, so it must only ever come from a verified token — never + * from the client, and never from a provider claim marked unverified. When + * there is no such email the verifier still succeeds: `oauthId` alone is what + * resolves an already-linked account, and a linked user whose token omits the + * email must keep signing in. Callers that need an email to attach or create + * must refuse on its absence, as `oAuthLogin` does. + */ + email?: string; oauthId: string; } @@ -53,7 +64,10 @@ export function createOAuthVerifier(keys: OAuthKeys) { return async function verifyOAuthToken( provider: OAuthProvider, token: string, - extra?: { email?: string } + // Still accepted so every caller's signature keeps compiling, and never read. + // It is the client-supplied email that used to fill in for a missing Apple + // token email — see the Apple branch for why that can no longer happen. + _extra?: { email?: string } ): Promise { if (provider === 'GOOGLE') { if (!keys.google?.clientId) { @@ -75,13 +89,18 @@ export function createOAuthVerifier(keys: OAuthKeys) { }); const payload = ticket.getPayload(); - if (!payload?.sub || !payload.email) { + if (!payload?.sub) { throw new OAuthVerificationError('Invalid Google token', 401); } + // A Google ID token can carry an email Google has NOT verified, flagged + // `email_verified: false`. The email is what attaches a new identity to an + // existing account, so an unverified one would let whoever holds that + // Google account claim an address they never proved they own. Trust it + // only when Google says it checked. return { oauthId: payload.sub, - email: payload.email, + email: payload.email && payload.email_verified === true ? payload.email : undefined, }; } @@ -100,14 +119,22 @@ export function createOAuthVerifier(keys: OAuthKeys) { ignoreExpiration: false, }); - const finalEmail = email || extra?.email; - if (!finalEmail || !sub) { + if (!sub) { throw new OAuthVerificationError('Invalid Apple token', 401); } + // Only the signed token may name the email. This used to fall back to + // `extra.email` — a value the CLIENT sends — whenever the token carried no + // email claim. Combined with attach-by-email in `oAuthLogin`, that let + // anyone holding a valid Apple token for their own Apple ID name a victim's + // address and be signed into the victim's passwordless account. + // + // No email is not an error: an Apple user already linked by `sub` must keep + // signing in when Apple omits the claim, and `oAuthLogin` resolves them by + // `sub` before it ever looks at the email. return { oauthId: sub, - email: finalEmail, + email: email || undefined, }; } diff --git a/packages/auth/tests/adapter-parity.test.ts b/packages/auth/tests/adapter-parity.test.ts index 303ee55..33e4a04 100644 --- a/packages/auth/tests/adapter-parity.test.ts +++ b/packages/auth/tests/adapter-parity.test.ts @@ -63,6 +63,10 @@ const DEVICE_EXPECTED_METHODS: Record = { 'findTwoFaSecretsByUserId', 'clearTwoFaSecrets', 'setTwoFaSecret', + // Optional on the interface — an older custom adapter is still valid without + // it — but both shipped adapters must implement it, or one of them silently + // takes the non-atomic fallback while the other does not. + 'moveTwoFaSecret', 'findByIdWithDevice', 'getDeviceId', 'revokeByDevicePushToken', diff --git a/packages/auth/tests/oauthAttach.test.ts b/packages/auth/tests/oauthAttach.test.ts new file mode 100644 index 0000000..3019d54 --- /dev/null +++ b/packages/auth/tests/oauthAttach.test.ts @@ -0,0 +1,205 @@ +import { initTRPC } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuthLoginProcedureFactory } from '../src/procedures/oauth'; +import type { AuthProcedure, BaseProcedure, TrpcContext } from '../src/types/trpc'; +import { createAuthConfig } from '../src/utilities/config'; +import { oAuthLoginSchema } from '../src/validators'; + +/** + * `oAuthLogin` attaches a NEW OAuth identity to an existing passwordless account + * whose email matches the provider's. That used to happen whether or not the + * account's email had ever been proven. A consumer that lets users store any + * unclaimed address unverified (factiii's profile update does) is then open to + * account pre-hijacking: register a passwordless account under a victim's + * address, wait for the victim's first genuine Google sign-in, and watch it land + * in an account the attacker still holds a passkey for. + * + * The attacker needs nothing forged here — the victim's token is real and + * verified. The only defence is refusing to attach to an unproven address. + * + * The in-memory adapter returns FULL user rows, the way the real Prisma + * (`findFirst`, no select) and Drizzle (`.select()`, no args) adapters do. A mock + * that dropped `emailVerificationStatus` would make the new check refuse every + * attach and hide the lockout regression this file exists to catch. + */ + +const { googleVerify } = vi.hoisted(() => ({ googleVerify: vi.fn() })); + +vi.mock('google-auth-library', () => ({ + OAuth2Client: class { + verifyIdToken = googleVerify; + }, +})); + +// Imported by the verifier; never exercised here, but mocked so no test depends +// on the real library's network or key handling. +vi.mock('apple-signin-auth', () => ({ default: { verifyIdToken: vi.fn() } })); + +const KEYS = { google: { clientId: 'google-client' } }; + +type UserRow = { + id: number; + email: string; + username: string; + password: string | null; + status: string; + emailVerificationStatus?: string; + updatedAt: Date; + verifiedHumanAt: Date | null; +}; + +const account = (overrides: Partial): UserRow => ({ + id: 7, + email: 'victim@gmail.com', + username: 'someone', + password: null, + status: 'ACTIVE', + emailVerificationStatus: 'VERIFIED', + updatedAt: new Date('2026-01-01'), + verifiedHumanAt: null, + ...overrides, +}); + +// The victim's own, genuine Google sign-in: a real subject and an email Google +// has verified. Nothing about this token is forged. +const genuineGoogleToken = () => + googleVerify.mockResolvedValue({ + getPayload: () => ({ + sub: 'victims-own-google-sub', + email: 'victim@gmail.com', + email_verified: true, + }), + }); + +function buildCaller(opts: { user: UserRow; linkedUserId?: number }) { + const oauthAccounts = { + resolve: vi.fn(async () => (opts.linkedUserId ? { userId: opts.linkedUserId } : null)), + link: vi.fn(async () => {}), + list: vi.fn(async () => []), + unlink: vi.fn(async () => {}), + }; + const database = { + user: { + findActiveById: vi.fn(async (id: number) => (id === opts.user.id ? opts.user : null)), + findByEmailInsensitive: vi.fn(async (email: string) => + email.toLowerCase() === opts.user.email.toLowerCase() ? opts.user : null + ), + create: vi.fn(), + }, + session: { + create: vi.fn(async (data: { userId: number }) => ({ id: 99, userId: data.userId })), + findManyByIds: vi.fn(async () => []), + }, + }; + + const config = createAuthConfig({ + database, + secrets: { jwt: 'test-secret-key' }, + features: { twoFa: false, oauth: { google: true } }, + oauthKeys: KEYS, + oauthAccounts, + } as unknown as Parameters[0]); + + const t = initTRPC.context().create(); + const factory = new OAuthLoginProcedureFactory( + config, + t.procedure as unknown as BaseProcedure, + t.procedure as unknown as AuthProcedure + ); + const router = t.router( + factory.createOAuthLoginProcedures({ oauth: oAuthLoginSchema } as unknown as Parameters< + typeof factory.createOAuthLoginProcedures + >[0]) + ); + const ctx = { + headers: { 'user-agent': 'Mozilla/5.0' }, + res: { setHeader: vi.fn() }, + userId: null, + sessionId: null, + socketId: null, + ip: '127.0.0.1', + } as unknown as TrpcContext; + + return { caller: t.createCallerFactory(router)(ctx), oauthAccounts, database }; +} + +beforeEach(() => { + googleVerify.mockReset(); +}); + +describe('oAuthLogin attach-by-email', () => { + it('refuses to pre-hijack: a genuine Google sign-in never lands in an account registered under the unverified address', async () => { + genuineGoogleToken(); + const { caller, oauthAccounts, database } = buildCaller({ + user: account({ emailVerificationStatus: 'UNVERIFIED' }), + }); + + await expect( + caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'victims-real-token' }) + ).rejects.toThrow('Sign in another way, then link this provider from Settings.'); + + expect(oauthAccounts.link).not.toHaveBeenCalled(); + expect(database.session.create).not.toHaveBeenCalled(); + }); + + it('still attaches and signs in when the matching account proved its email', async () => { + // The lockout regression. If the verification check were wrong — or the + // adapter did not return the field — this real user would be refused. + genuineGoogleToken(); + const { caller, oauthAccounts, database } = buildCaller({ user: account({}) }); + + const result = await caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token' }); + + expect(result).toMatchObject({ success: true, user: { id: 7 } }); + expect(oauthAccounts.link).toHaveBeenCalledWith( + 7, + expect.objectContaining({ provider: 'GOOGLE', subject: 'victims-own-google-sub' }) + ); + expect(database.session.create).toHaveBeenCalledWith(expect.objectContaining({ userId: 7 })); + }); + + it('leaves an already-linked user alone: resolved by sub, no attach, whatever the email status', async () => { + // The check guards attaching a NEW identity only. A user already linked is + // found in step 1 and never reaches it. + genuineGoogleToken(); + const { caller, oauthAccounts, database } = buildCaller({ + user: account({ emailVerificationStatus: 'UNVERIFIED' }), + linkedUserId: 7, + }); + + const result = await caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token' }); + + expect(result).toMatchObject({ success: true, user: { id: 7 } }); + expect(oauthAccounts.link).not.toHaveBeenCalled(); + expect(database.session.create).toHaveBeenCalledWith(expect.objectContaining({ userId: 7 })); + }); + + it('still refuses a password account with the original message, before the new check', async () => { + // UNVERIFIED on purpose: the password refusal must still win, word for word, + // because consumers assert that exact text. + genuineGoogleToken(); + const { caller, oauthAccounts } = buildCaller({ + user: account({ password: 'hashed', emailVerificationStatus: 'UNVERIFIED' }), + }); + + await expect( + caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token' }) + ).rejects.toThrow('This email uses password login. Please use email/password.'); + + expect(oauthAccounts.link).not.toHaveBeenCalled(); + }); + + it('fails closed when an adapter does not return the verification status', async () => { + genuineGoogleToken(); + const { caller, oauthAccounts } = buildCaller({ + user: account({ emailVerificationStatus: undefined }), + }); + + await expect( + caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'real-token' }) + ).rejects.toThrow('Sign in another way, then link this provider from Settings.'); + + expect(oauthAccounts.link).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/auth/tests/oauthVerifier.test.ts b/packages/auth/tests/oauthVerifier.test.ts new file mode 100644 index 0000000..725a94d --- /dev/null +++ b/packages/auth/tests/oauthVerifier.test.ts @@ -0,0 +1,237 @@ +import { initTRPC } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuthLoginProcedureFactory } from '../src/procedures/oauth'; +import type { AuthProcedure, BaseProcedure, TrpcContext } from '../src/types/trpc'; +import { createAuthConfig } from '../src/utilities/config'; +import { createOAuthVerifier } from '../src/utilities/oauth'; +import { oAuthLoginSchema } from '../src/validators'; + +/** + * Apple sign-in used to take the account email from the CLIENT whenever Apple's + * signed token carried no email claim (`finalEmail = email || extra?.email`). + * `oAuthLogin` attaches a new identity to any existing passwordless account with + * a matching email, so anyone holding a valid Apple token for their OWN Apple ID + * could name a victim's address and be signed into the victim's account. Google + * had the same shape one step removed: it trusted `payload.email` without + * checking `email_verified`. + * + * The mocks below can hand back an email-less Apple token and an unverified + * Google email — the dangerous inputs. A mock that can only produce well-formed + * tokens is how a bug like this passes a green suite. + */ + +const { appleVerify, googleVerify } = vi.hoisted(() => ({ + appleVerify: vi.fn(), + googleVerify: vi.fn(), +})); + +vi.mock('apple-signin-auth', () => ({ + default: { verifyIdToken: appleVerify }, +})); + +// A real class, not `vi.fn(() => ({}))`: the verifier calls `new OAuth2Client()`. +vi.mock('google-auth-library', () => ({ + OAuth2Client: class { + verifyIdToken = googleVerify; + }, +})); + +const KEYS = { apple: { clientId: 'apple-client' }, google: { clientId: 'google-client' } }; + +const googleToken = (payload: Record) => ({ getPayload: () => payload }); + +beforeEach(() => { + appleVerify.mockReset(); + googleVerify.mockReset(); +}); + +describe('createOAuthVerifier: Apple', () => { + const verify = createOAuthVerifier(KEYS); + + it('never lets a client-supplied email stand in for a missing token email', async () => { + appleVerify.mockResolvedValue({ sub: 'attacker-apple-sub' }); + + const result = await verify('APPLE', 'token', { email: 'victim@example.com' }); + + expect(result.email).not.toBe('victim@example.com'); + expect(result.email).toBeUndefined(); + expect(result.oauthId).toBe('attacker-apple-sub'); + }); + + it('returns the signed token email and ignores whatever the client sent', async () => { + appleVerify.mockResolvedValue({ sub: 'apple-sub', email: 'real@icloud.com' }); + + const result = await verify('APPLE', 'token', { email: 'victim@example.com' }); + + expect(result).toEqual({ oauthId: 'apple-sub', email: 'real@icloud.com' }); + }); + + it('does not throw when the token has no email, so a linked user is not locked out', async () => { + appleVerify.mockResolvedValue({ sub: 'apple-sub' }); + + await expect(verify('APPLE', 'token')).resolves.toEqual({ + oauthId: 'apple-sub', + email: undefined, + }); + }); + + it('still refuses a token with no subject', async () => { + appleVerify.mockResolvedValue({ email: 'real@icloud.com' }); + + await expect(verify('APPLE', 'token')).rejects.toThrow('Invalid Apple token'); + }); +}); + +describe('createOAuthVerifier: Google', () => { + const verify = createOAuthVerifier(KEYS); + + it('does not trust an email Google marks unverified', async () => { + googleVerify.mockResolvedValue( + googleToken({ sub: 'google-sub', email: 'victim@example.com', email_verified: false }) + ); + + const result = await verify('GOOGLE', 'token'); + + expect(result.email).toBeUndefined(); + expect(result.oauthId).toBe('google-sub'); + }); + + it('does not trust an email when the verified flag is missing', async () => { + // Absent is not "true". Only an explicit verification counts. + googleVerify.mockResolvedValue(googleToken({ sub: 'google-sub', email: 'victim@example.com' })); + + expect((await verify('GOOGLE', 'token')).email).toBeUndefined(); + }); + + it('returns a verified email', async () => { + googleVerify.mockResolvedValue( + googleToken({ sub: 'google-sub', email: 'real@gmail.com', email_verified: true }) + ); + + expect(await verify('GOOGLE', 'token')).toEqual({ + oauthId: 'google-sub', + email: 'real@gmail.com', + }); + }); + + it('still refuses a token with no subject', async () => { + googleVerify.mockResolvedValue(googleToken({ email: 'real@gmail.com', email_verified: true })); + + await expect(verify('GOOGLE', 'token')).rejects.toThrow('Invalid Google token'); + }); +}); + +/** + * The verifier tests prove what the verifier returns. These prove what that means + * at sign-in, through the real `oAuthLogin` procedure: the attack is refused, and + * the fix does not lock out the linked users it could most easily break. + */ +describe('oAuthLogin with the fixed verifier', () => { + const VICTIM = { + id: 7, + email: 'victim@example.com', + username: 'victim', + password: null, + status: 'ACTIVE', + updatedAt: new Date('2026-01-01'), + verifiedHumanAt: null, + }; + + function buildCaller(opts: { linkedUserId?: number }) { + const oauthAccounts = { + resolve: vi.fn(async () => (opts.linkedUserId ? { userId: opts.linkedUserId } : null)), + link: vi.fn(async () => {}), + list: vi.fn(async () => []), + unlink: vi.fn(async () => {}), + }; + const database = { + user: { + findActiveById: vi.fn(async (id: number) => (id === VICTIM.id ? VICTIM : null)), + // The victim is passwordless: exactly the account attach-by-email targets. + findByEmailInsensitive: vi.fn(async (email: string) => + email.toLowerCase() === VICTIM.email ? VICTIM : null + ), + create: vi.fn(), + }, + session: { + create: vi.fn(async (data: { userId: number }) => ({ id: 99, userId: data.userId })), + findManyByIds: vi.fn(async () => []), + }, + }; + + const config = createAuthConfig({ + database, + secrets: { jwt: 'test-secret-key' }, + features: { twoFa: false, oauth: { apple: true, google: true } }, + oauthKeys: KEYS, + oauthAccounts, + } as unknown as Parameters[0]); + + const t = initTRPC.context().create(); + const factory = new OAuthLoginProcedureFactory( + config, + t.procedure as unknown as BaseProcedure, + t.procedure as unknown as AuthProcedure + ); + const router = t.router( + factory.createOAuthLoginProcedures({ oauth: oAuthLoginSchema } as unknown as Parameters< + typeof factory.createOAuthLoginProcedures + >[0]) + ); + const ctx = { + headers: { 'user-agent': 'Mozilla/5.0' }, + res: { setHeader: vi.fn() }, + userId: null, + sessionId: null, + socketId: null, + ip: '127.0.0.1', + } as unknown as TrpcContext; + + return { caller: t.createCallerFactory(router)(ctx), oauthAccounts, database }; + } + + it('refuses an Apple token that names a victim email, and attaches nothing', async () => { + appleVerify.mockResolvedValue({ sub: 'attacker-apple-sub' }); + const { caller, oauthAccounts, database } = buildCaller({}); + + await expect( + caller.oAuthLogin({ + provider: 'APPLE', + idToken: 'attacker-token', + user: { email: VICTIM.email }, + }) + ).rejects.toThrow('Email not provided by OAuth provider'); + + expect(oauthAccounts.link).not.toHaveBeenCalled(); + expect(database.session.create).not.toHaveBeenCalled(); + }); + + it('refuses a Google token whose email is unverified, and attaches nothing', async () => { + googleVerify.mockResolvedValue( + googleToken({ sub: 'attacker-google-sub', email: VICTIM.email, email_verified: false }) + ); + const { caller, oauthAccounts, database } = buildCaller({}); + + await expect( + caller.oAuthLogin({ provider: 'GOOGLE', idToken: 'attacker-token' }) + ).rejects.toThrow('Email not provided by OAuth provider'); + + expect(oauthAccounts.link).not.toHaveBeenCalled(); + expect(database.session.create).not.toHaveBeenCalled(); + }); + + it('still signs in a linked Apple user whose token has no email, through sub', async () => { + // The regression this fix could most easily cause. The old verifier threw on + // a missing email before `oAuthLogin` could resolve the user by `sub`. + appleVerify.mockResolvedValue({ sub: 'victims-own-apple-sub' }); + const { caller, database } = buildCaller({ linkedUserId: VICTIM.id }); + + const result = await caller.oAuthLogin({ provider: 'APPLE', idToken: 'real-token' }); + + expect(result).toMatchObject({ success: true, user: { id: VICTIM.id } }); + expect(database.session.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: VICTIM.id }) + ); + }); +}); diff --git a/packages/auth/tests/relogin.test.ts b/packages/auth/tests/relogin.test.ts index d6b0a14..c0e8e1b 100644 --- a/packages/auth/tests/relogin.test.ts +++ b/packages/auth/tests/relogin.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; -import { revokeDeviceSessionsForUser } from '../src/utilities/issueCookies'; +import { + carryDeviceTwoFaSecret, + revokeDeviceSessionsForUser, +} from '../src/utilities/issueCookies'; import { createAuthToken } from '../src/utilities/jwt'; import type { ResolvedAuthConfig } from '../src/utilities/config'; @@ -158,3 +161,224 @@ describe('revokeDeviceSessionsForUser', () => { expect(revoke).toHaveBeenCalledTimes(2); }); }); + +/** + * In device mode the TOTP secret sits on `Session.twoFaSecret`, but it belongs + * to the phone, not to any one session of it. Nothing used to move it when a + * sign-in replaced the session holding it, which was survivable only because + * `findTwoFaSecretsByUserId` did not filter revoked rows — a hole, since a + * revoked device's secret still answered the login challenge. Closing that hole + * alone would turn the leak into a lockout, so these pin the carry that makes it + * safe. If they fail, do not "fix" them by relaxing the adapter filter. + * + * The fake below enforces the `@unique` constraint the real schema declares on + * `Session.twoFaSecret`, because the first version of this code COPIED the secret + * and every mocked test passed: a mock that cannot fail the way production fails + * proves nothing. Two rows must never hold the same string, so the secret is + * moved — the donor gives it up in the same step the recipient takes it. + */ +describe('carryDeviceTwoFaSecret', () => { + /** + * An in-memory sessions table with the unique index on `twoFaSecret`. + * `withMove: false` models an adapter written before `moveTwoFaSecret` existed, + * which is the fallback path inside the helper. + */ + function buildDeviceConfig( + secretsBySession: Record, + opts: { twoFaMode?: 'device' | 'standard'; withMove?: boolean } = {} + ) { + const { twoFaMode = 'device', withMove = true } = opts; + const rows: Record = { ...secretsBySession }; + const writes: Array<{ id: number; secret: string | null }> = []; + + const write = (id: number, secret: string | null) => { + if (secret !== null) { + const holder = Object.entries(rows).find( + ([otherId, value]) => value === secret && Number(otherId) !== id + ); + if (holder) { + // What Postgres says, near enough, when a second row reaches for a + // string another row already holds. + throw new Error( + 'Unique constraint failed on the fields: ("twoFaSecret")' + ); + } + } + rows[id] = secret; + writes.push({ id, secret }); + }; + + const setTwoFaSecret = vi.fn(async (id: number, secret: string | null) => { + write(id, secret); + }); + + const moveTwoFaSecret = vi.fn( + async (_userId: number, fromId: number, toId: number) => { + const secret = rows[fromId]; + if (!secret) return; + // Clear before write, exactly as both shipped adapters do. + write(fromId, null); + write(toId, secret); + } + ); + + const findByIdWithDevice = vi.fn(async (id: number) => + id in rows ? { twoFaSecret: rows[id], deviceId: null, device: null } : null + ); + + const session: Record = { + findByIdWithDevice, + setTwoFaSecret, + ...(withMove ? { moveTwoFaSecret } : {}), + }; + + const config = { + features: { twoFaMode }, + deviceAuth: { session }, + } as unknown as ResolvedAuthConfig; + + return { config, rows, writes, setTwoFaSecret, moveTwoFaSecret, findByIdWithDevice }; + } + + it('moves the secret to the replacement and leaves it on no other row', async () => { + const { config, rows } = buildDeviceConfig({ 10: 'SECRET-A' }); + + await carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [10], + newSessionId: 20, + }); + + expect(rows[20]).toBe('SECRET-A'); + // The retired row must NOT still hold it. Under the real unique index it + // cannot, which is the whole reason this is a move. + expect(rows[10]).toBeNull(); + }); + + it('does not trip the unique index it used to trip', async () => { + // The regression. Copying threw here, the helper swallowed it, and the + // replacement session ended up with no second factor at all. + const { config, rows } = buildDeviceConfig({ 10: 'SECRET-A' }); + + await carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [10], + newSessionId: 20, + }); + + expect(rows[20]).toBe('SECRET-A'); + }); + + it('takes the first secret it finds when several sessions were retired', async () => { + // Each is a secret this device's vault has been using; they are + // alternatives, not a set to merge. + const { config, rows } = buildDeviceConfig({ + 10: null, + 11: 'SECRET-B', + 12: 'SECRET-C', + }); + + await carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [10, 11, 12], + newSessionId: 20, + }); + + expect(rows[20]).toBe('SECRET-B'); + expect(rows[11]).toBeNull(); + expect(rows[12]).toBe('SECRET-C'); + }); + + it('clears the donor before writing the recipient, without moveTwoFaSecret', async () => { + // An adapter from before the method existed. Not atomic, so the ORDER is the + // safety: the worst case has to be a secret on neither row, never a write + // that throws against the unique index and changes nothing. + const { config, rows, writes } = buildDeviceConfig( + { 10: 'SECRET-A' }, + { withMove: false } + ); + + await carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [10], + newSessionId: 20, + }); + + expect(writes).toEqual([ + { id: 10, secret: null }, + { id: 20, secret: 'SECRET-A' }, + ]); + expect(rows[20]).toBe('SECRET-A'); + expect(rows[10]).toBeNull(); + }); + + it('writes nothing when the retired sessions held no secret', async () => { + const { config, writes } = buildDeviceConfig({ 10: null }); + + await carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [10], + newSessionId: 20, + }); + + expect(writes).toEqual([]); + }); + + it('is a no-op on a first-time sign-in, where nothing was replaced', async () => { + const { config, findByIdWithDevice } = buildDeviceConfig({ 10: 'SECRET-A' }); + + await carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [], + newSessionId: 20, + }); + + expect(findByIdWithDevice).not.toHaveBeenCalled(); + }); + + it('is a no-op in standard mode, where the secret lives on the user', async () => { + // There is no session column to write, so touching one would be a bug. + const { config, findByIdWithDevice, writes } = buildDeviceConfig( + { 10: 'SECRET-A' }, + { twoFaMode: 'standard' } + ); + + await carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [10], + newSessionId: 20, + }); + + expect(findByIdWithDevice).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + }); + + it('never fails a sign-in that has already been approved', async () => { + // Losing the cached second factor is recoverable — the vault can mint a new + // one. Throwing here would reject a login whose credentials already passed. + // It reports the failure rather than swallowing it in silence, which is how + // the copy bug survived a review and 196 green tests. + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const config = { + features: { twoFaMode: 'device' }, + deviceAuth: { + session: { + findByIdWithDevice: vi.fn(async () => { + throw new Error('database went away'); + }), + setTwoFaSecret: vi.fn(async () => {}), + }, + }, + } as unknown as ResolvedAuthConfig; + + await expect( + carryDeviceTwoFaSecret(config, { + userId: 1, + revokedSessionIds: [10], + newSessionId: 20, + }) + ).resolves.toBeUndefined(); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +});