@fonderie/auth 7.4.0 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,6 +64,7 @@ var USER_COLUMNS = `
64
64
  ip_whitelist AS "ipWhitelist",
65
65
  mfa_enabled AS "mfaEnabled",
66
66
  email_verified_at AS "emailVerifiedAt",
67
+ provider,
67
68
  deleted_at AS "deletedAt",
68
69
  created_at AS "createdAt",
69
70
  updated_at AS "updatedAt"
@@ -141,6 +142,31 @@ var UserModel = class {
141
142
  );
142
143
  return row ?? null;
143
144
  }
145
+ /**
146
+ * Clear the OAuth provider, but ONLY when the account can still be signed
147
+ * into afterwards.
148
+ *
149
+ * An account created through Google has no password. Unlinking it without
150
+ * checking would not be "removing a sign-in method" — it would be locking
151
+ * the owner out permanently, which is account deletion wearing a friendlier
152
+ * label. The guard lives in the WHERE clause so the check and the write are
153
+ * one atomic statement: a password cannot be removed between them.
154
+ *
155
+ * Returns false when the account has no password (nothing was changed) so
156
+ * the caller can say why rather than reporting a silent success.
157
+ */
158
+ async clearProvider(id) {
159
+ const rows = await this.store.query(
160
+ `UPDATE fonderie_users
161
+ SET provider = NULL, provider_id = NULL
162
+ WHERE id = $1
163
+ AND password_hash IS NOT NULL
164
+ AND provider IS NOT NULL
165
+ RETURNING id`,
166
+ [id]
167
+ );
168
+ return rows.length > 0;
169
+ }
144
170
  async updatePassword(id, passwordHash) {
145
171
  await this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
146
172
  passwordHash,
@@ -241,14 +267,71 @@ var UserModel = class {
241
267
  );
242
268
  return row?.mfa_secret ?? null;
243
269
  }
270
+ /**
271
+ * Link an OAuth identity to the account owning `email`, creating it if there
272
+ * is none.
273
+ *
274
+ * Returns what HAPPENED, not just who it happened to. Three outcomes hide
275
+ * behind one upsert, and callers must tell them apart:
276
+ *
277
+ * • `inserted` — a brand-new account. Whoever provisions on signup (the
278
+ * personal workspace, a wallet, a welcome email) must run, exactly as
279
+ * it does for a password registration. This is the case that was
280
+ * previously invisible: the upsert returned an id and looked identical
281
+ * to a returning user signing in, so OAuth signups silently skipped
282
+ * every signup side effect.
283
+ * • `previousProvider` differs — an existing account gained, or switched,
284
+ * a way to sign in. A security event for the owner.
285
+ * • `previousProvider` is the same — an ordinary login. Nothing to do, and
286
+ * notifying here would email the user on every single sign-in.
287
+ *
288
+ * The prior state is read in a CTE of the SAME statement, so it sees the
289
+ * snapshot from before the insert. Reading it in a separate query would
290
+ * race two concurrent logins into both seeing "new".
291
+ *
292
+ * SECURITY — linking is BY EMAIL, so this merges into whatever row already
293
+ * holds the address. If that row was never verified, its password was never
294
+ * proven to belong to the mailbox; anyone can register an address they do
295
+ * not own. The provider HAS proven ownership. So an unverified password is
296
+ * revoked at the moment of linking: without that, linking sets
297
+ * email_verified_at and the earlier registrant is left holding a working
298
+ * password on an account now treated as verified.
299
+ *
300
+ * The legitimate user loses nothing but a password reset — they own the
301
+ * mailbox. Someone who does not own it cannot receive that mail.
302
+ */
244
303
  async upsertByProvider(email, provider, providerId) {
245
304
  const [row] = await this.store.query(
246
- `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
247
- VALUES ($1, now(), $2, $3)
248
- ON CONFLICT (email) DO UPDATE
249
- SET provider = $2, provider_id = $3,
250
- email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
251
- RETURNING id`,
305
+ `WITH prior AS (
306
+ SELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password
307
+ FROM fonderie_users WHERE email = $1
308
+ ), upserted AS (
309
+ INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
310
+ VALUES ($1, now(), $2, $3)
311
+ ON CONFLICT (email) DO UPDATE
312
+ SET provider = $2, provider_id = $3,
313
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),
314
+ -- A password on an UNVERIFIED account is a claim, not a
315
+ -- credential: nobody ever proved that mailbox belongs to
316
+ -- whoever set it. The provider has now proven it belongs to
317
+ -- the person signing in. Drop the unproven claim rather than
318
+ -- promote it \u2014 otherwise linking marks the account verified
319
+ -- and silently hands the earlier registrant a working
320
+ -- password on a now-trusted account.
321
+ password_hash = CASE
322
+ WHEN fonderie_users.email_verified_at IS NULL THEN NULL
323
+ ELSE fonderie_users.password_hash
324
+ END
325
+ RETURNING id
326
+ )
327
+ SELECT upserted.id,
328
+ NOT EXISTS (SELECT 1 FROM prior) AS inserted,
329
+ (SELECT provider FROM prior) AS "previousProvider",
330
+ -- True only when a password was actually revoked, so the
331
+ -- caller can tell the owner their sign-in method changed.
332
+ COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)
333
+ AS "clearedUnverifiedPassword"
334
+ FROM upserted`,
252
335
  [email, provider, providerId]
253
336
  );
254
337
  return row ?? null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/index.ts","../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/models/session.model.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts"],"sourcesContent":["import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n","import { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport type { IAuthConfig } from '../config';\n\nexport interface TokenPair {\n\taccessToken: string;\n\trefreshToken: string;\n\t// Server-side session id both tokens are bound to. Store it on the\n\t// fonderie_sessions row — deleting that row revokes the access token\n\t// immediately, not just the refresh token.\n\tsid: string;\n}\n\nexport interface IAccessPayload {\n\tsub: string; // userId\n\ttype: 'access';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tmfaPending?: boolean;\n\t// Absent only on legacy tokens issued before session binding and on\n\t// short-lived mfaPending tokens (no session exists yet at that point).\n\tsid?: string;\n}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\n}\n\nexport function issueMfaPendingToken(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\tloginMethod: 'email' | 'phone',\n): string {\n\treturn jwt.sign(\n\t\t{\n\t\t\tsub: userId,\n\t\t\ttype: 'access',\n\t\t\tloginMethod,\n\t\t\tphoneVerified: false,\n\t\t\tmfaPending: true,\n\t\t} satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: '5m' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } as SignOptions,\n\t);\n\n\treturn { accessToken, refreshToken, sid };\n}\n\nexport function refreshTokenExpiry(token: string): Date {\n\tconst decoded = jwt.decode(token) as { exp?: number } | null;\n\treturn decoded?.exp\n\t\t? new Date(decoded.exp * 1000)\n\t\t: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);\n}\n\nexport function verifyToken(\n\ttoken: string,\n\tconfig: IAuthConfig,\n): IAccessPayload | IRefreshPayload | null {\n\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\nimport type { IUser } from '../types';\n\nexport interface IUserUpdateFields {\n\tfirstName?: string;\n\tlastName?: string;\n\tphoneNumber?: string;\n\tavatarUrl?: string;\n\tlocale?: string;\n\ttimezone?: string;\n\tpreferences?: unknown;\n}\n\nconst USER_COLUMNS = `\n\tid,\n\temail,\n\tpassword_hash AS \"passwordHash\",\n\tfirst_name AS \"firstName\",\n\tlast_name AS \"lastName\",\n\tphone,\n\tprofile_image_url AS \"profileImageUrl\",\n\tlocale,\n\ttimezone,\n\tis_active AS \"isActive\",\n\tlast_login AS \"lastLogin\",\n\tpreferences,\n\tsuspended,\n\twhitelist,\n\tip_whitelist AS \"ipWhitelist\",\n\tmfa_enabled AS \"mfaEnabled\",\n\temail_verified_at AS \"emailVerifiedAt\",\n\tdeleted_at AS \"deletedAt\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nexport class UserModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync findById(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByEmail(email: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NULL`,\n\t\t\t[email],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByPhone(phone: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,\n\t\t\t[phone],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findOrCreateByPhone(\n\t\tphone: string,\n\t\tfirstName: string | null = null,\n\t\tlastName: string | null = null,\n\t): Promise<{ id: string }> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (phone, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3)\n\t\t\tON CONFLICT (phone) DO UPDATE\n\t\t\tSET first_name = COALESCE(EXCLUDED.first_name, fonderie_users.first_name),\n\t\t\t last_name = COALESCE(EXCLUDED.last_name, fonderie_users.last_name),\n\t\t\t updated_at = now()\n\t\t\tRETURNING id`,\n\t\t\t[phone, firstName, lastName],\n\t\t);\n\t\treturn row!;\n\t}\n\n\tasync create(\n\t\temail: string,\n\t\tpasswordHash: string,\n\t\tfirstName: string | null,\n\t\tlastName: string | null,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, password_hash, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync update(id: string, fields: IUserUpdateFields): Promise<{ id: string } | null> {\n\t\tconst columnMap: Record<string, string> = {\n\t\t\tfirstName: 'first_name',\n\t\t\tlastName: 'last_name',\n\t\t\tphoneNumber: 'phone',\n\t\t\tavatarUrl: 'profile_image_url',\n\t\t\tlocale: 'locale',\n\t\t\ttimezone: 'timezone',\n\t\t\tpreferences: 'preferences',\n\t\t};\n\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tfor (const [key, col] of Object.entries(columnMap)) {\n\t\t\tif ((fields as Record<string, unknown>)[key] !== undefined) {\n\t\t\t\tvalues.push((fields as Record<string, unknown>)[key]);\n\t\t\t\tsets.push(`${col} = $${values.length}`);\n\t\t\t}\n\t\t}\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync updatePassword(id: string, passwordHash: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [\n\t\t\tpasswordHash,\n\t\t\tid,\n\t\t]);\n\t}\n\n\tasync markEmailVerified(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync softDelete(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET deleted_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync saveMfaSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_secret = $1 WHERE id = $2`, [secret, id]);\n\t}\n\n\tasync saveMfaPendingSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret_pending = $1, mfa_secret_pending_expires_at = now() + interval '15 minutes'\n\t\t\t WHERE id = $2`,\n\t\t\t[secret, id],\n\t\t);\n\t}\n\n\tasync getMfaPendingSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret_pending: string | null }>(\n\t\t\t`SELECT mfa_secret_pending FROM fonderie_users\n\t\t\t WHERE id = $1 AND mfa_secret_pending_expires_at > now()`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret_pending ?? null;\n\t}\n\n\tasync confirmMfaSecret(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret = mfa_secret_pending,\n\t\t\t mfa_secret_pending = NULL,\n\t\t\t mfa_secret_pending_expires_at = NULL,\n\t\t\t mfa_enabled = true,\n\t\t\t updated_at = now()\n\t\t\t WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync enableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_enabled = true WHERE id = $1`, [id]);\n\t}\n\n\tasync disableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET mfa_enabled = false, mfa_secret = NULL, updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync updateEmail(id: string, email: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email = $1, email_verified_at = NULL, updated_at = now() WHERE id = $2`,\n\t\t\t[email.toLowerCase().trim(), id],\n\t\t);\n\t}\n\n\tasync updatePhone(id: string, phone: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET phone = $1, updated_at = now() WHERE id = $2`,\n\t\t\t[phone, id],\n\t\t);\n\t}\n\n\tasync updatePreferences(\n\t\tid: string,\n\t\tfields: {\n\t\t\tlocale?: string;\n\t\t\ttimezone?: string;\n\t\t\tpatch?: Record<string, unknown>;\n\t\t},\n\t): Promise<{ id: string } | null> {\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tif (fields.locale !== undefined) {\n\t\t\tvalues.push(fields.locale);\n\t\t\tsets.push(`locale = $${values.length}`);\n\t\t}\n\t\tif (fields.timezone !== undefined) {\n\t\t\tvalues.push(fields.timezone);\n\t\t\tsets.push(`timezone = $${values.length}`);\n\t\t}\n\t\tif (fields.patch && Object.keys(fields.patch).length > 0) {\n\t\t\tvalues.push(JSON.stringify(fields.patch));\n\t\t\tsets.push(`preferences = preferences || $${values.length}::jsonb`);\n\t\t}\n\n\t\tif (sets.length === 0) return null;\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync getMfaSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret: string | null }>(\n\t\t\t`SELECT mfa_secret FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret ?? null;\n\t}\n\n\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)\n\t\t\tVALUES ($1, now(), $2, $3)\n\t\t\tON CONFLICT (email) DO UPDATE\n\t\t\tSET provider = $2, provider_id = $3,\n\t\t\t email_verified_at = COALESCE(fonderie_users.email_verified_at, now())\n\t\t\tRETURNING id`,\n\t\t\t[email, provider, providerId],\n\t\t);\n\t\treturn row ?? null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\n\nexport class SessionModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync create(\n\t\tuserId: string,\n\t\ttoken: string,\n\t\texpiresAt: Date,\n\t\tsid?: string,\n\t\tmeta?: IRequestMeta,\n\t): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null],\n\t\t);\n\t}\n\n\tasync delete(token: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);\n\t}\n\n\t// Delete the session an access token is bound to (by its sid claim). Lets\n\t// logout revoke the current session without the client resending the refresh\n\t// token — the access token already identifies its session.\n\tasync deleteBySid(sid: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);\n\t}\n\n\t// Revoke every session for a user (e.g. on password change). Access tokens\n\t// bound to these sessions via the sid claim die on their next request.\n\tasync deleteByUser(userId: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);\n\t}\n\n\t// Session metadata for a user (no tokens) — for the data-export / SAR bundle.\n\tasync listByUser(userId: string): Promise<\n\t\tArray<{ id: string; userAgent: string | null; ipAddress: string | null; createdAt: Date; expiresAt: Date }>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Live sessions for the Active Sessions screen: unexpired only, carrying the\n\t// sid so the caller can flag the current one (row.sid === the JWT's sid).\n\t// No token — that never leaves the server.\n\tasync listLiveByUser(userId: string): Promise<\n\t\tArray<{\n\t\t\tid: string;\n\t\t\tsid: string | null;\n\t\t\tuserAgent: string | null;\n\t\t\tipAddress: string | null;\n\t\t\tcreatedAt: Date;\n\t\t\texpiresAt: Date;\n\t\t}>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id, sid,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND expires_at > now()\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Terminate one session by row id, scoped to its owner (a user can never\n\t// delete another user's session). Returns whether a row was removed.\n\tasync terminateById(userId: string, id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Terminate all of a user's sessions except the current one (by sid).\n\t// Returns the number of sessions removed.\n\tasync terminateOthers(userId: string, keepSid: string): Promise<number> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)\n\t\t\t RETURNING id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\n\t}\n\n\tasync exists(token: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,\n\t\t\t[token],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Liveness check for session-bound access tokens (by the sid claim).\n\tasync aliveBySid(sid: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE sid = $1 AND expires_at > now()`,\n\t\t\t[sid],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n}\n","import type { Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAuthConfig } from '../config';\nimport { verifyToken } from '../services/jwt';\nimport type { IAccessPayload } from '../services/jwt';\nimport { UserModel } from '../models/user.model';\nimport { SessionModel } from '../models/session.model';\n\n// Reads the Bearer token or session cookie, populates ctx.user\n// Does NOT reject — anonymous requests pass through\nexport function withSession(store: IStoreAdapter, config: IAuthConfig): Middleware {\n\tconst users = new UserModel(store);\n\tconst sessions = new SessionModel(store);\n\n\treturn async (ctx, next) => {\n\t\tconst token = extractToken(ctx.request);\n\t\tif (!token) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst payload = verifyToken(token, config);\n\t\tif (!payload || payload.type !== 'access') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Revocation: access tokens are bound to their refresh session via the\n\t\t// sid claim. Logout, rotation, and password change delete that row, so\n\t\t// the access token dies with it instead of living out its JWT expiry.\n\t\t// Tokens without a sid pass through: short-lived mfaPending tokens\n\t\t// (no session exists yet) and legacy tokens from before session\n\t\t// binding, which age out within one accessTokenDuration of deploy.\n\t\tif (payload.sid && !(await sessions.aliveBySid(payload.sid))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst user = await users.findById(payload.sub);\n\t\tif (!user || user.suspended || user.deletedAt) {\n\t\t\treturn next();\n\t\t}\n\n\t\tObject.assign(ctx, {\n\t\t\tuser: {\n\t\t\t\t...user,\n\t\t\t\tloginMethod: payload.loginMethod ?? 'email',\n\t\t\t\tphoneVerified: payload.phoneVerified ?? false,\n\t\t\t\tmfaPending: (payload as IAccessPayload).mfaPending ?? false,\n\t\t\t\t// The session this request is authenticated on, so the sessions\n\t\t\t\t// API can flag the current row and \"terminate others\" can spare it.\n\t\t\t\tsid: payload.sid ?? null,\n\t\t\t},\n\t\t});\n\n\t\treturn next();\n\t};\n}\n\nfunction extractToken(request: Request): string | null {\n\tconst auth = request.headers.get('authorization');\n\tif (auth?.startsWith('Bearer ')) {\n\t\treturn auth.slice(7);\n\t}\n\n\tconst cookie = request.headers.get('cookie') ?? '';\n\tconst match = cookie.match(/(?:^|;\\s*)access_token=([^;]+)/);\n\treturn match?.[1] ?? null;\n}\n","export { requireAuth, requireAnyAuth } from '@fonderie/core/middlewares';\n"],"mappings":"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","names":["jwt"]}
1
+ {"version":3,"sources":["../../src/middlewares/index.ts","../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/models/session.model.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts"],"sourcesContent":["import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n","import { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport type { IAuthConfig } from '../config';\n\nexport interface TokenPair {\n\taccessToken: string;\n\trefreshToken: string;\n\t// Server-side session id both tokens are bound to. Store it on the\n\t// fonderie_sessions row — deleting that row revokes the access token\n\t// immediately, not just the refresh token.\n\tsid: string;\n}\n\nexport interface IAccessPayload {\n\tsub: string; // userId\n\ttype: 'access';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tmfaPending?: boolean;\n\t// Absent only on legacy tokens issued before session binding and on\n\t// short-lived mfaPending tokens (no session exists yet at that point).\n\tsid?: string;\n}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\n}\n\nexport function issueMfaPendingToken(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\tloginMethod: 'email' | 'phone',\n): string {\n\treturn jwt.sign(\n\t\t{\n\t\t\tsub: userId,\n\t\t\ttype: 'access',\n\t\t\tloginMethod,\n\t\t\tphoneVerified: false,\n\t\t\tmfaPending: true,\n\t\t} satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: '5m' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } as SignOptions,\n\t);\n\n\treturn { accessToken, refreshToken, sid };\n}\n\nexport function refreshTokenExpiry(token: string): Date {\n\tconst decoded = jwt.decode(token) as { exp?: number } | null;\n\treturn decoded?.exp\n\t\t? new Date(decoded.exp * 1000)\n\t\t: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);\n}\n\nexport function verifyToken(\n\ttoken: string,\n\tconfig: IAuthConfig,\n): IAccessPayload | IRefreshPayload | null {\n\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\nimport type { IUser } from '../types';\n\nexport interface IUserUpdateFields {\n\tfirstName?: string;\n\tlastName?: string;\n\tphoneNumber?: string;\n\tavatarUrl?: string;\n\tlocale?: string;\n\ttimezone?: string;\n\tpreferences?: unknown;\n}\n\nconst USER_COLUMNS = `\n\tid,\n\temail,\n\tpassword_hash AS \"passwordHash\",\n\tfirst_name AS \"firstName\",\n\tlast_name AS \"lastName\",\n\tphone,\n\tprofile_image_url AS \"profileImageUrl\",\n\tlocale,\n\ttimezone,\n\tis_active AS \"isActive\",\n\tlast_login AS \"lastLogin\",\n\tpreferences,\n\tsuspended,\n\twhitelist,\n\tip_whitelist AS \"ipWhitelist\",\n\tmfa_enabled AS \"mfaEnabled\",\n\temail_verified_at AS \"emailVerifiedAt\",\n\tprovider,\n\tdeleted_at AS \"deletedAt\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nexport class UserModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync findById(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByEmail(email: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NULL`,\n\t\t\t[email],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByPhone(phone: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,\n\t\t\t[phone],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findOrCreateByPhone(\n\t\tphone: string,\n\t\tfirstName: string | null = null,\n\t\tlastName: string | null = null,\n\t): Promise<{ id: string }> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (phone, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3)\n\t\t\tON CONFLICT (phone) DO UPDATE\n\t\t\tSET first_name = COALESCE(EXCLUDED.first_name, fonderie_users.first_name),\n\t\t\t last_name = COALESCE(EXCLUDED.last_name, fonderie_users.last_name),\n\t\t\t updated_at = now()\n\t\t\tRETURNING id`,\n\t\t\t[phone, firstName, lastName],\n\t\t);\n\t\treturn row!;\n\t}\n\n\tasync create(\n\t\temail: string,\n\t\tpasswordHash: string,\n\t\tfirstName: string | null,\n\t\tlastName: string | null,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, password_hash, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync update(id: string, fields: IUserUpdateFields): Promise<{ id: string } | null> {\n\t\tconst columnMap: Record<string, string> = {\n\t\t\tfirstName: 'first_name',\n\t\t\tlastName: 'last_name',\n\t\t\tphoneNumber: 'phone',\n\t\t\tavatarUrl: 'profile_image_url',\n\t\t\tlocale: 'locale',\n\t\t\ttimezone: 'timezone',\n\t\t\tpreferences: 'preferences',\n\t\t};\n\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tfor (const [key, col] of Object.entries(columnMap)) {\n\t\t\tif ((fields as Record<string, unknown>)[key] !== undefined) {\n\t\t\t\tvalues.push((fields as Record<string, unknown>)[key]);\n\t\t\t\tsets.push(`${col} = $${values.length}`);\n\t\t\t}\n\t\t}\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\t/**\n\t * Clear the OAuth provider, but ONLY when the account can still be signed\n\t * into afterwards.\n\t *\n\t * An account created through Google has no password. Unlinking it without\n\t * checking would not be \"removing a sign-in method\" — it would be locking\n\t * the owner out permanently, which is account deletion wearing a friendlier\n\t * label. The guard lives in the WHERE clause so the check and the write are\n\t * one atomic statement: a password cannot be removed between them.\n\t *\n\t * Returns false when the account has no password (nothing was changed) so\n\t * the caller can say why rather than reporting a silent success.\n\t */\n\tasync clearProvider(id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET provider = NULL, provider_id = NULL\n\t\t\t WHERE id = $1\n\t\t\t AND password_hash IS NOT NULL\n\t\t\t AND provider IS NOT NULL\n\t\t\t RETURNING id`,\n\t\t\t[id],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tasync updatePassword(id: string, passwordHash: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [\n\t\t\tpasswordHash,\n\t\t\tid,\n\t\t]);\n\t}\n\n\tasync markEmailVerified(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync softDelete(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET deleted_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync saveMfaSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_secret = $1 WHERE id = $2`, [secret, id]);\n\t}\n\n\tasync saveMfaPendingSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret_pending = $1, mfa_secret_pending_expires_at = now() + interval '15 minutes'\n\t\t\t WHERE id = $2`,\n\t\t\t[secret, id],\n\t\t);\n\t}\n\n\tasync getMfaPendingSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret_pending: string | null }>(\n\t\t\t`SELECT mfa_secret_pending FROM fonderie_users\n\t\t\t WHERE id = $1 AND mfa_secret_pending_expires_at > now()`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret_pending ?? null;\n\t}\n\n\tasync confirmMfaSecret(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret = mfa_secret_pending,\n\t\t\t mfa_secret_pending = NULL,\n\t\t\t mfa_secret_pending_expires_at = NULL,\n\t\t\t mfa_enabled = true,\n\t\t\t updated_at = now()\n\t\t\t WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync enableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_enabled = true WHERE id = $1`, [id]);\n\t}\n\n\tasync disableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET mfa_enabled = false, mfa_secret = NULL, updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync updateEmail(id: string, email: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email = $1, email_verified_at = NULL, updated_at = now() WHERE id = $2`,\n\t\t\t[email.toLowerCase().trim(), id],\n\t\t);\n\t}\n\n\tasync updatePhone(id: string, phone: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET phone = $1, updated_at = now() WHERE id = $2`,\n\t\t\t[phone, id],\n\t\t);\n\t}\n\n\tasync updatePreferences(\n\t\tid: string,\n\t\tfields: {\n\t\t\tlocale?: string;\n\t\t\ttimezone?: string;\n\t\t\tpatch?: Record<string, unknown>;\n\t\t},\n\t): Promise<{ id: string } | null> {\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tif (fields.locale !== undefined) {\n\t\t\tvalues.push(fields.locale);\n\t\t\tsets.push(`locale = $${values.length}`);\n\t\t}\n\t\tif (fields.timezone !== undefined) {\n\t\t\tvalues.push(fields.timezone);\n\t\t\tsets.push(`timezone = $${values.length}`);\n\t\t}\n\t\tif (fields.patch && Object.keys(fields.patch).length > 0) {\n\t\t\tvalues.push(JSON.stringify(fields.patch));\n\t\t\tsets.push(`preferences = preferences || $${values.length}::jsonb`);\n\t\t}\n\n\t\tif (sets.length === 0) return null;\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync getMfaSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret: string | null }>(\n\t\t\t`SELECT mfa_secret FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret ?? null;\n\t}\n\n\t/**\n\t * Link an OAuth identity to the account owning `email`, creating it if there\n\t * is none.\n\t *\n\t * Returns what HAPPENED, not just who it happened to. Three outcomes hide\n\t * behind one upsert, and callers must tell them apart:\n\t *\n\t * • `inserted` — a brand-new account. Whoever provisions on signup (the\n\t * personal workspace, a wallet, a welcome email) must run, exactly as\n\t * it does for a password registration. This is the case that was\n\t * previously invisible: the upsert returned an id and looked identical\n\t * to a returning user signing in, so OAuth signups silently skipped\n\t * every signup side effect.\n\t * • `previousProvider` differs — an existing account gained, or switched,\n\t * a way to sign in. A security event for the owner.\n\t * • `previousProvider` is the same — an ordinary login. Nothing to do, and\n\t * notifying here would email the user on every single sign-in.\n\t *\n\t * The prior state is read in a CTE of the SAME statement, so it sees the\n\t * snapshot from before the insert. Reading it in a separate query would\n\t * race two concurrent logins into both seeing \"new\".\n\t *\n\t * SECURITY — linking is BY EMAIL, so this merges into whatever row already\n\t * holds the address. If that row was never verified, its password was never\n\t * proven to belong to the mailbox; anyone can register an address they do\n\t * not own. The provider HAS proven ownership. So an unverified password is\n\t * revoked at the moment of linking: without that, linking sets\n\t * email_verified_at and the earlier registrant is left holding a working\n\t * password on an account now treated as verified.\n\t *\n\t * The legitimate user loses nothing but a password reset — they own the\n\t * mailbox. Someone who does not own it cannot receive that mail.\n\t */\n\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{\n\t\tid: string;\n\t\tinserted: boolean;\n\t\tpreviousProvider: string | null;\n\t\tclearedUnverifiedPassword: boolean;\n\t} | null> {\n\t\tconst [row] = await this.store.query<{\n\t\t\tid: string;\n\t\t\tinserted: boolean;\n\t\t\tpreviousProvider: string | null;\n\t\t\tclearedUnverifiedPassword: boolean;\n\t\t}>(\n\t\t\t`WITH prior AS (\n\t\t\t\tSELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password\n\t\t\t\t FROM fonderie_users WHERE email = $1\n\t\t\t), upserted AS (\n\t\t\t\tINSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)\n\t\t\t\tVALUES ($1, now(), $2, $3)\n\t\t\t\tON CONFLICT (email) DO UPDATE\n\t\t\t\tSET provider = $2, provider_id = $3,\n\t\t\t\t email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),\n\t\t\t\t -- A password on an UNVERIFIED account is a claim, not a\n\t\t\t\t -- credential: nobody ever proved that mailbox belongs to\n\t\t\t\t -- whoever set it. The provider has now proven it belongs to\n\t\t\t\t -- the person signing in. Drop the unproven claim rather than\n\t\t\t\t -- promote it — otherwise linking marks the account verified\n\t\t\t\t -- and silently hands the earlier registrant a working\n\t\t\t\t -- password on a now-trusted account.\n\t\t\t\t password_hash = CASE\n\t\t\t\t WHEN fonderie_users.email_verified_at IS NULL THEN NULL\n\t\t\t\t ELSE fonderie_users.password_hash\n\t\t\t\t END\n\t\t\t\tRETURNING id\n\t\t\t)\n\t\t\tSELECT upserted.id,\n\t\t\t NOT EXISTS (SELECT 1 FROM prior) AS inserted,\n\t\t\t (SELECT provider FROM prior) AS \"previousProvider\",\n\t\t\t -- True only when a password was actually revoked, so the\n\t\t\t -- caller can tell the owner their sign-in method changed.\n\t\t\t COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)\n\t\t\t AS \"clearedUnverifiedPassword\"\n\t\t\tFROM upserted`,\n\t\t\t[email, provider, providerId],\n\t\t);\n\t\treturn row ?? null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\n\nexport class SessionModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync create(\n\t\tuserId: string,\n\t\ttoken: string,\n\t\texpiresAt: Date,\n\t\tsid?: string,\n\t\tmeta?: IRequestMeta,\n\t): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null],\n\t\t);\n\t}\n\n\tasync delete(token: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);\n\t}\n\n\t// Delete the session an access token is bound to (by its sid claim). Lets\n\t// logout revoke the current session without the client resending the refresh\n\t// token — the access token already identifies its session.\n\tasync deleteBySid(sid: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);\n\t}\n\n\t// Revoke every session for a user (e.g. on password change). Access tokens\n\t// bound to these sessions via the sid claim die on their next request.\n\tasync deleteByUser(userId: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);\n\t}\n\n\t// Session metadata for a user (no tokens) — for the data-export / SAR bundle.\n\tasync listByUser(userId: string): Promise<\n\t\tArray<{ id: string; userAgent: string | null; ipAddress: string | null; createdAt: Date; expiresAt: Date }>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Live sessions for the Active Sessions screen: unexpired only, carrying the\n\t// sid so the caller can flag the current one (row.sid === the JWT's sid).\n\t// No token — that never leaves the server.\n\tasync listLiveByUser(userId: string): Promise<\n\t\tArray<{\n\t\t\tid: string;\n\t\t\tsid: string | null;\n\t\t\tuserAgent: string | null;\n\t\t\tipAddress: string | null;\n\t\t\tcreatedAt: Date;\n\t\t\texpiresAt: Date;\n\t\t}>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id, sid,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND expires_at > now()\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Terminate one session by row id, scoped to its owner (a user can never\n\t// delete another user's session). Returns whether a row was removed.\n\tasync terminateById(userId: string, id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Terminate all of a user's sessions except the current one (by sid).\n\t// Returns the number of sessions removed.\n\tasync terminateOthers(userId: string, keepSid: string): Promise<number> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)\n\t\t\t RETURNING id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\n\t}\n\n\tasync exists(token: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,\n\t\t\t[token],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Liveness check for session-bound access tokens (by the sid claim).\n\tasync aliveBySid(sid: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE sid = $1 AND expires_at > now()`,\n\t\t\t[sid],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n}\n","import type { Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAuthConfig } from '../config';\nimport { verifyToken } from '../services/jwt';\nimport type { IAccessPayload } from '../services/jwt';\nimport { UserModel } from '../models/user.model';\nimport { SessionModel } from '../models/session.model';\n\n// Reads the Bearer token or session cookie, populates ctx.user\n// Does NOT reject — anonymous requests pass through\nexport function withSession(store: IStoreAdapter, config: IAuthConfig): Middleware {\n\tconst users = new UserModel(store);\n\tconst sessions = new SessionModel(store);\n\n\treturn async (ctx, next) => {\n\t\tconst token = extractToken(ctx.request);\n\t\tif (!token) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst payload = verifyToken(token, config);\n\t\tif (!payload || payload.type !== 'access') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Revocation: access tokens are bound to their refresh session via the\n\t\t// sid claim. Logout, rotation, and password change delete that row, so\n\t\t// the access token dies with it instead of living out its JWT expiry.\n\t\t// Tokens without a sid pass through: short-lived mfaPending tokens\n\t\t// (no session exists yet) and legacy tokens from before session\n\t\t// binding, which age out within one accessTokenDuration of deploy.\n\t\tif (payload.sid && !(await sessions.aliveBySid(payload.sid))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst user = await users.findById(payload.sub);\n\t\tif (!user || user.suspended || user.deletedAt) {\n\t\t\treturn next();\n\t\t}\n\n\t\tObject.assign(ctx, {\n\t\t\tuser: {\n\t\t\t\t...user,\n\t\t\t\tloginMethod: payload.loginMethod ?? 'email',\n\t\t\t\tphoneVerified: payload.phoneVerified ?? false,\n\t\t\t\tmfaPending: (payload as IAccessPayload).mfaPending ?? false,\n\t\t\t\t// The session this request is authenticated on, so the sessions\n\t\t\t\t// API can flag the current row and \"terminate others\" can spare it.\n\t\t\t\tsid: payload.sid ?? null,\n\t\t\t},\n\t\t});\n\n\t\treturn next();\n\t};\n}\n\nfunction extractToken(request: Request): string | null {\n\tconst auth = request.headers.get('authorization');\n\tif (auth?.startsWith('Bearer ')) {\n\t\treturn auth.slice(7);\n\t}\n\n\tconst cookie = request.headers.get('cookie') ?? '';\n\tconst match = cookie.match(/(?:^|;\\s*)access_token=([^;]+)/);\n\treturn match?.[1] ?? null;\n}\n","export { requireAuth, requireAnyAuth } from '@fonderie/core/middlewares';\n"],"mappings":"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","names":["jwt"]}
@@ -1,5 +1,5 @@
1
1
  import * as _fonderie_core from '@fonderie/core';
2
- import { w as withSession } from '../session-CvNqy0Kd.cjs';
2
+ import { w as withSession } from '../session-Bv4qu9xV.cjs';
3
3
  import '@fonderie/store';
4
4
  import '@fonderie/rate-limit';
5
5
 
@@ -1,5 +1,5 @@
1
1
  import * as _fonderie_core from '@fonderie/core';
2
- import { w as withSession } from '../session-CvNqy0Kd.js';
2
+ import { w as withSession } from '../session-Bv4qu9xV.js';
3
3
  import '@fonderie/store';
4
4
  import '@fonderie/rate-limit';
5
5
 
@@ -28,6 +28,7 @@ var USER_COLUMNS = `
28
28
  ip_whitelist AS "ipWhitelist",
29
29
  mfa_enabled AS "mfaEnabled",
30
30
  email_verified_at AS "emailVerifiedAt",
31
+ provider,
31
32
  deleted_at AS "deletedAt",
32
33
  created_at AS "createdAt",
33
34
  updated_at AS "updatedAt"
@@ -105,6 +106,31 @@ var UserModel = class {
105
106
  );
106
107
  return row ?? null;
107
108
  }
109
+ /**
110
+ * Clear the OAuth provider, but ONLY when the account can still be signed
111
+ * into afterwards.
112
+ *
113
+ * An account created through Google has no password. Unlinking it without
114
+ * checking would not be "removing a sign-in method" — it would be locking
115
+ * the owner out permanently, which is account deletion wearing a friendlier
116
+ * label. The guard lives in the WHERE clause so the check and the write are
117
+ * one atomic statement: a password cannot be removed between them.
118
+ *
119
+ * Returns false when the account has no password (nothing was changed) so
120
+ * the caller can say why rather than reporting a silent success.
121
+ */
122
+ async clearProvider(id) {
123
+ const rows = await this.store.query(
124
+ `UPDATE fonderie_users
125
+ SET provider = NULL, provider_id = NULL
126
+ WHERE id = $1
127
+ AND password_hash IS NOT NULL
128
+ AND provider IS NOT NULL
129
+ RETURNING id`,
130
+ [id]
131
+ );
132
+ return rows.length > 0;
133
+ }
108
134
  async updatePassword(id, passwordHash) {
109
135
  await this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
110
136
  passwordHash,
@@ -205,14 +231,71 @@ var UserModel = class {
205
231
  );
206
232
  return row?.mfa_secret ?? null;
207
233
  }
234
+ /**
235
+ * Link an OAuth identity to the account owning `email`, creating it if there
236
+ * is none.
237
+ *
238
+ * Returns what HAPPENED, not just who it happened to. Three outcomes hide
239
+ * behind one upsert, and callers must tell them apart:
240
+ *
241
+ * • `inserted` — a brand-new account. Whoever provisions on signup (the
242
+ * personal workspace, a wallet, a welcome email) must run, exactly as
243
+ * it does for a password registration. This is the case that was
244
+ * previously invisible: the upsert returned an id and looked identical
245
+ * to a returning user signing in, so OAuth signups silently skipped
246
+ * every signup side effect.
247
+ * • `previousProvider` differs — an existing account gained, or switched,
248
+ * a way to sign in. A security event for the owner.
249
+ * • `previousProvider` is the same — an ordinary login. Nothing to do, and
250
+ * notifying here would email the user on every single sign-in.
251
+ *
252
+ * The prior state is read in a CTE of the SAME statement, so it sees the
253
+ * snapshot from before the insert. Reading it in a separate query would
254
+ * race two concurrent logins into both seeing "new".
255
+ *
256
+ * SECURITY — linking is BY EMAIL, so this merges into whatever row already
257
+ * holds the address. If that row was never verified, its password was never
258
+ * proven to belong to the mailbox; anyone can register an address they do
259
+ * not own. The provider HAS proven ownership. So an unverified password is
260
+ * revoked at the moment of linking: without that, linking sets
261
+ * email_verified_at and the earlier registrant is left holding a working
262
+ * password on an account now treated as verified.
263
+ *
264
+ * The legitimate user loses nothing but a password reset — they own the
265
+ * mailbox. Someone who does not own it cannot receive that mail.
266
+ */
208
267
  async upsertByProvider(email, provider, providerId) {
209
268
  const [row] = await this.store.query(
210
- `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
211
- VALUES ($1, now(), $2, $3)
212
- ON CONFLICT (email) DO UPDATE
213
- SET provider = $2, provider_id = $3,
214
- email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
215
- RETURNING id`,
269
+ `WITH prior AS (
270
+ SELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password
271
+ FROM fonderie_users WHERE email = $1
272
+ ), upserted AS (
273
+ INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
274
+ VALUES ($1, now(), $2, $3)
275
+ ON CONFLICT (email) DO UPDATE
276
+ SET provider = $2, provider_id = $3,
277
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),
278
+ -- A password on an UNVERIFIED account is a claim, not a
279
+ -- credential: nobody ever proved that mailbox belongs to
280
+ -- whoever set it. The provider has now proven it belongs to
281
+ -- the person signing in. Drop the unproven claim rather than
282
+ -- promote it \u2014 otherwise linking marks the account verified
283
+ -- and silently hands the earlier registrant a working
284
+ -- password on a now-trusted account.
285
+ password_hash = CASE
286
+ WHEN fonderie_users.email_verified_at IS NULL THEN NULL
287
+ ELSE fonderie_users.password_hash
288
+ END
289
+ RETURNING id
290
+ )
291
+ SELECT upserted.id,
292
+ NOT EXISTS (SELECT 1 FROM prior) AS inserted,
293
+ (SELECT provider FROM prior) AS "previousProvider",
294
+ -- True only when a password was actually revoked, so the
295
+ -- caller can tell the owner their sign-in method changed.
296
+ COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)
297
+ AS "clearedUnverifiedPassword"
298
+ FROM upserted`,
216
299
  [email, provider, providerId]
217
300
  );
218
301
  return row ?? null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/models/session.model.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts","../../src/middlewares/index.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport type { IAuthConfig } from '../config';\n\nexport interface TokenPair {\n\taccessToken: string;\n\trefreshToken: string;\n\t// Server-side session id both tokens are bound to. Store it on the\n\t// fonderie_sessions row — deleting that row revokes the access token\n\t// immediately, not just the refresh token.\n\tsid: string;\n}\n\nexport interface IAccessPayload {\n\tsub: string; // userId\n\ttype: 'access';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tmfaPending?: boolean;\n\t// Absent only on legacy tokens issued before session binding and on\n\t// short-lived mfaPending tokens (no session exists yet at that point).\n\tsid?: string;\n}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\n}\n\nexport function issueMfaPendingToken(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\tloginMethod: 'email' | 'phone',\n): string {\n\treturn jwt.sign(\n\t\t{\n\t\t\tsub: userId,\n\t\t\ttype: 'access',\n\t\t\tloginMethod,\n\t\t\tphoneVerified: false,\n\t\t\tmfaPending: true,\n\t\t} satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: '5m' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } as SignOptions,\n\t);\n\n\treturn { accessToken, refreshToken, sid };\n}\n\nexport function refreshTokenExpiry(token: string): Date {\n\tconst decoded = jwt.decode(token) as { exp?: number } | null;\n\treturn decoded?.exp\n\t\t? new Date(decoded.exp * 1000)\n\t\t: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);\n}\n\nexport function verifyToken(\n\ttoken: string,\n\tconfig: IAuthConfig,\n): IAccessPayload | IRefreshPayload | null {\n\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\nimport type { IUser } from '../types';\n\nexport interface IUserUpdateFields {\n\tfirstName?: string;\n\tlastName?: string;\n\tphoneNumber?: string;\n\tavatarUrl?: string;\n\tlocale?: string;\n\ttimezone?: string;\n\tpreferences?: unknown;\n}\n\nconst USER_COLUMNS = `\n\tid,\n\temail,\n\tpassword_hash AS \"passwordHash\",\n\tfirst_name AS \"firstName\",\n\tlast_name AS \"lastName\",\n\tphone,\n\tprofile_image_url AS \"profileImageUrl\",\n\tlocale,\n\ttimezone,\n\tis_active AS \"isActive\",\n\tlast_login AS \"lastLogin\",\n\tpreferences,\n\tsuspended,\n\twhitelist,\n\tip_whitelist AS \"ipWhitelist\",\n\tmfa_enabled AS \"mfaEnabled\",\n\temail_verified_at AS \"emailVerifiedAt\",\n\tdeleted_at AS \"deletedAt\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nexport class UserModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync findById(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByEmail(email: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NULL`,\n\t\t\t[email],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByPhone(phone: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,\n\t\t\t[phone],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findOrCreateByPhone(\n\t\tphone: string,\n\t\tfirstName: string | null = null,\n\t\tlastName: string | null = null,\n\t): Promise<{ id: string }> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (phone, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3)\n\t\t\tON CONFLICT (phone) DO UPDATE\n\t\t\tSET first_name = COALESCE(EXCLUDED.first_name, fonderie_users.first_name),\n\t\t\t last_name = COALESCE(EXCLUDED.last_name, fonderie_users.last_name),\n\t\t\t updated_at = now()\n\t\t\tRETURNING id`,\n\t\t\t[phone, firstName, lastName],\n\t\t);\n\t\treturn row!;\n\t}\n\n\tasync create(\n\t\temail: string,\n\t\tpasswordHash: string,\n\t\tfirstName: string | null,\n\t\tlastName: string | null,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, password_hash, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync update(id: string, fields: IUserUpdateFields): Promise<{ id: string } | null> {\n\t\tconst columnMap: Record<string, string> = {\n\t\t\tfirstName: 'first_name',\n\t\t\tlastName: 'last_name',\n\t\t\tphoneNumber: 'phone',\n\t\t\tavatarUrl: 'profile_image_url',\n\t\t\tlocale: 'locale',\n\t\t\ttimezone: 'timezone',\n\t\t\tpreferences: 'preferences',\n\t\t};\n\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tfor (const [key, col] of Object.entries(columnMap)) {\n\t\t\tif ((fields as Record<string, unknown>)[key] !== undefined) {\n\t\t\t\tvalues.push((fields as Record<string, unknown>)[key]);\n\t\t\t\tsets.push(`${col} = $${values.length}`);\n\t\t\t}\n\t\t}\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync updatePassword(id: string, passwordHash: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [\n\t\t\tpasswordHash,\n\t\t\tid,\n\t\t]);\n\t}\n\n\tasync markEmailVerified(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync softDelete(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET deleted_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync saveMfaSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_secret = $1 WHERE id = $2`, [secret, id]);\n\t}\n\n\tasync saveMfaPendingSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret_pending = $1, mfa_secret_pending_expires_at = now() + interval '15 minutes'\n\t\t\t WHERE id = $2`,\n\t\t\t[secret, id],\n\t\t);\n\t}\n\n\tasync getMfaPendingSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret_pending: string | null }>(\n\t\t\t`SELECT mfa_secret_pending FROM fonderie_users\n\t\t\t WHERE id = $1 AND mfa_secret_pending_expires_at > now()`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret_pending ?? null;\n\t}\n\n\tasync confirmMfaSecret(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret = mfa_secret_pending,\n\t\t\t mfa_secret_pending = NULL,\n\t\t\t mfa_secret_pending_expires_at = NULL,\n\t\t\t mfa_enabled = true,\n\t\t\t updated_at = now()\n\t\t\t WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync enableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_enabled = true WHERE id = $1`, [id]);\n\t}\n\n\tasync disableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET mfa_enabled = false, mfa_secret = NULL, updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync updateEmail(id: string, email: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email = $1, email_verified_at = NULL, updated_at = now() WHERE id = $2`,\n\t\t\t[email.toLowerCase().trim(), id],\n\t\t);\n\t}\n\n\tasync updatePhone(id: string, phone: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET phone = $1, updated_at = now() WHERE id = $2`,\n\t\t\t[phone, id],\n\t\t);\n\t}\n\n\tasync updatePreferences(\n\t\tid: string,\n\t\tfields: {\n\t\t\tlocale?: string;\n\t\t\ttimezone?: string;\n\t\t\tpatch?: Record<string, unknown>;\n\t\t},\n\t): Promise<{ id: string } | null> {\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tif (fields.locale !== undefined) {\n\t\t\tvalues.push(fields.locale);\n\t\t\tsets.push(`locale = $${values.length}`);\n\t\t}\n\t\tif (fields.timezone !== undefined) {\n\t\t\tvalues.push(fields.timezone);\n\t\t\tsets.push(`timezone = $${values.length}`);\n\t\t}\n\t\tif (fields.patch && Object.keys(fields.patch).length > 0) {\n\t\t\tvalues.push(JSON.stringify(fields.patch));\n\t\t\tsets.push(`preferences = preferences || $${values.length}::jsonb`);\n\t\t}\n\n\t\tif (sets.length === 0) return null;\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync getMfaSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret: string | null }>(\n\t\t\t`SELECT mfa_secret FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret ?? null;\n\t}\n\n\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)\n\t\t\tVALUES ($1, now(), $2, $3)\n\t\t\tON CONFLICT (email) DO UPDATE\n\t\t\tSET provider = $2, provider_id = $3,\n\t\t\t email_verified_at = COALESCE(fonderie_users.email_verified_at, now())\n\t\t\tRETURNING id`,\n\t\t\t[email, provider, providerId],\n\t\t);\n\t\treturn row ?? null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\n\nexport class SessionModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync create(\n\t\tuserId: string,\n\t\ttoken: string,\n\t\texpiresAt: Date,\n\t\tsid?: string,\n\t\tmeta?: IRequestMeta,\n\t): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null],\n\t\t);\n\t}\n\n\tasync delete(token: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);\n\t}\n\n\t// Delete the session an access token is bound to (by its sid claim). Lets\n\t// logout revoke the current session without the client resending the refresh\n\t// token — the access token already identifies its session.\n\tasync deleteBySid(sid: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);\n\t}\n\n\t// Revoke every session for a user (e.g. on password change). Access tokens\n\t// bound to these sessions via the sid claim die on their next request.\n\tasync deleteByUser(userId: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);\n\t}\n\n\t// Session metadata for a user (no tokens) — for the data-export / SAR bundle.\n\tasync listByUser(userId: string): Promise<\n\t\tArray<{ id: string; userAgent: string | null; ipAddress: string | null; createdAt: Date; expiresAt: Date }>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Live sessions for the Active Sessions screen: unexpired only, carrying the\n\t// sid so the caller can flag the current one (row.sid === the JWT's sid).\n\t// No token — that never leaves the server.\n\tasync listLiveByUser(userId: string): Promise<\n\t\tArray<{\n\t\t\tid: string;\n\t\t\tsid: string | null;\n\t\t\tuserAgent: string | null;\n\t\t\tipAddress: string | null;\n\t\t\tcreatedAt: Date;\n\t\t\texpiresAt: Date;\n\t\t}>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id, sid,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND expires_at > now()\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Terminate one session by row id, scoped to its owner (a user can never\n\t// delete another user's session). Returns whether a row was removed.\n\tasync terminateById(userId: string, id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Terminate all of a user's sessions except the current one (by sid).\n\t// Returns the number of sessions removed.\n\tasync terminateOthers(userId: string, keepSid: string): Promise<number> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)\n\t\t\t RETURNING id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\n\t}\n\n\tasync exists(token: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,\n\t\t\t[token],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Liveness check for session-bound access tokens (by the sid claim).\n\tasync aliveBySid(sid: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE sid = $1 AND expires_at > now()`,\n\t\t\t[sid],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n}\n","import type { Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAuthConfig } from '../config';\nimport { verifyToken } from '../services/jwt';\nimport type { IAccessPayload } from '../services/jwt';\nimport { UserModel } from '../models/user.model';\nimport { SessionModel } from '../models/session.model';\n\n// Reads the Bearer token or session cookie, populates ctx.user\n// Does NOT reject — anonymous requests pass through\nexport function withSession(store: IStoreAdapter, config: IAuthConfig): Middleware {\n\tconst users = new UserModel(store);\n\tconst sessions = new SessionModel(store);\n\n\treturn async (ctx, next) => {\n\t\tconst token = extractToken(ctx.request);\n\t\tif (!token) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst payload = verifyToken(token, config);\n\t\tif (!payload || payload.type !== 'access') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Revocation: access tokens are bound to their refresh session via the\n\t\t// sid claim. Logout, rotation, and password change delete that row, so\n\t\t// the access token dies with it instead of living out its JWT expiry.\n\t\t// Tokens without a sid pass through: short-lived mfaPending tokens\n\t\t// (no session exists yet) and legacy tokens from before session\n\t\t// binding, which age out within one accessTokenDuration of deploy.\n\t\tif (payload.sid && !(await sessions.aliveBySid(payload.sid))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst user = await users.findById(payload.sub);\n\t\tif (!user || user.suspended || user.deletedAt) {\n\t\t\treturn next();\n\t\t}\n\n\t\tObject.assign(ctx, {\n\t\t\tuser: {\n\t\t\t\t...user,\n\t\t\t\tloginMethod: payload.loginMethod ?? 'email',\n\t\t\t\tphoneVerified: payload.phoneVerified ?? false,\n\t\t\t\tmfaPending: (payload as IAccessPayload).mfaPending ?? false,\n\t\t\t\t// The session this request is authenticated on, so the sessions\n\t\t\t\t// API can flag the current row and \"terminate others\" can spare it.\n\t\t\t\tsid: payload.sid ?? null,\n\t\t\t},\n\t\t});\n\n\t\treturn next();\n\t};\n}\n\nfunction extractToken(request: Request): string | null {\n\tconst auth = request.headers.get('authorization');\n\tif (auth?.startsWith('Bearer ')) {\n\t\treturn auth.slice(7);\n\t}\n\n\tconst cookie = request.headers.get('cookie') ?? '';\n\tconst match = cookie.match(/(?:^|;\\s*)access_token=([^;]+)/);\n\treturn match?.[1] ?? null;\n}\n","export { requireAuth, requireAnyAuth } from '@fonderie/core/middlewares';\n","import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/models/session.model.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts","../../src/middlewares/index.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport type { IAuthConfig } from '../config';\n\nexport interface TokenPair {\n\taccessToken: string;\n\trefreshToken: string;\n\t// Server-side session id both tokens are bound to. Store it on the\n\t// fonderie_sessions row — deleting that row revokes the access token\n\t// immediately, not just the refresh token.\n\tsid: string;\n}\n\nexport interface IAccessPayload {\n\tsub: string; // userId\n\ttype: 'access';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tmfaPending?: boolean;\n\t// Absent only on legacy tokens issued before session binding and on\n\t// short-lived mfaPending tokens (no session exists yet at that point).\n\tsid?: string;\n}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\n}\n\nexport function issueMfaPendingToken(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\tloginMethod: 'email' | 'phone',\n): string {\n\treturn jwt.sign(\n\t\t{\n\t\t\tsub: userId,\n\t\t\ttype: 'access',\n\t\t\tloginMethod,\n\t\t\tphoneVerified: false,\n\t\t\tmfaPending: true,\n\t\t} satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: '5m' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } as SignOptions,\n\t);\n\n\treturn { accessToken, refreshToken, sid };\n}\n\nexport function refreshTokenExpiry(token: string): Date {\n\tconst decoded = jwt.decode(token) as { exp?: number } | null;\n\treturn decoded?.exp\n\t\t? new Date(decoded.exp * 1000)\n\t\t: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);\n}\n\nexport function verifyToken(\n\ttoken: string,\n\tconfig: IAuthConfig,\n): IAccessPayload | IRefreshPayload | null {\n\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\nimport type { IUser } from '../types';\n\nexport interface IUserUpdateFields {\n\tfirstName?: string;\n\tlastName?: string;\n\tphoneNumber?: string;\n\tavatarUrl?: string;\n\tlocale?: string;\n\ttimezone?: string;\n\tpreferences?: unknown;\n}\n\nconst USER_COLUMNS = `\n\tid,\n\temail,\n\tpassword_hash AS \"passwordHash\",\n\tfirst_name AS \"firstName\",\n\tlast_name AS \"lastName\",\n\tphone,\n\tprofile_image_url AS \"profileImageUrl\",\n\tlocale,\n\ttimezone,\n\tis_active AS \"isActive\",\n\tlast_login AS \"lastLogin\",\n\tpreferences,\n\tsuspended,\n\twhitelist,\n\tip_whitelist AS \"ipWhitelist\",\n\tmfa_enabled AS \"mfaEnabled\",\n\temail_verified_at AS \"emailVerifiedAt\",\n\tprovider,\n\tdeleted_at AS \"deletedAt\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nexport class UserModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync findById(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByEmail(email: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NULL`,\n\t\t\t[email],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByPhone(phone: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,\n\t\t\t[phone],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findOrCreateByPhone(\n\t\tphone: string,\n\t\tfirstName: string | null = null,\n\t\tlastName: string | null = null,\n\t): Promise<{ id: string }> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (phone, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3)\n\t\t\tON CONFLICT (phone) DO UPDATE\n\t\t\tSET first_name = COALESCE(EXCLUDED.first_name, fonderie_users.first_name),\n\t\t\t last_name = COALESCE(EXCLUDED.last_name, fonderie_users.last_name),\n\t\t\t updated_at = now()\n\t\t\tRETURNING id`,\n\t\t\t[phone, firstName, lastName],\n\t\t);\n\t\treturn row!;\n\t}\n\n\tasync create(\n\t\temail: string,\n\t\tpasswordHash: string,\n\t\tfirstName: string | null,\n\t\tlastName: string | null,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, password_hash, first_name, last_name)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync update(id: string, fields: IUserUpdateFields): Promise<{ id: string } | null> {\n\t\tconst columnMap: Record<string, string> = {\n\t\t\tfirstName: 'first_name',\n\t\t\tlastName: 'last_name',\n\t\t\tphoneNumber: 'phone',\n\t\t\tavatarUrl: 'profile_image_url',\n\t\t\tlocale: 'locale',\n\t\t\ttimezone: 'timezone',\n\t\t\tpreferences: 'preferences',\n\t\t};\n\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tfor (const [key, col] of Object.entries(columnMap)) {\n\t\t\tif ((fields as Record<string, unknown>)[key] !== undefined) {\n\t\t\t\tvalues.push((fields as Record<string, unknown>)[key]);\n\t\t\t\tsets.push(`${col} = $${values.length}`);\n\t\t\t}\n\t\t}\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\t/**\n\t * Clear the OAuth provider, but ONLY when the account can still be signed\n\t * into afterwards.\n\t *\n\t * An account created through Google has no password. Unlinking it without\n\t * checking would not be \"removing a sign-in method\" — it would be locking\n\t * the owner out permanently, which is account deletion wearing a friendlier\n\t * label. The guard lives in the WHERE clause so the check and the write are\n\t * one atomic statement: a password cannot be removed between them.\n\t *\n\t * Returns false when the account has no password (nothing was changed) so\n\t * the caller can say why rather than reporting a silent success.\n\t */\n\tasync clearProvider(id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET provider = NULL, provider_id = NULL\n\t\t\t WHERE id = $1\n\t\t\t AND password_hash IS NOT NULL\n\t\t\t AND provider IS NOT NULL\n\t\t\t RETURNING id`,\n\t\t\t[id],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tasync updatePassword(id: string, passwordHash: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [\n\t\t\tpasswordHash,\n\t\t\tid,\n\t\t]);\n\t}\n\n\tasync markEmailVerified(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync softDelete(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET deleted_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync saveMfaSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_secret = $1 WHERE id = $2`, [secret, id]);\n\t}\n\n\tasync saveMfaPendingSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret_pending = $1, mfa_secret_pending_expires_at = now() + interval '15 minutes'\n\t\t\t WHERE id = $2`,\n\t\t\t[secret, id],\n\t\t);\n\t}\n\n\tasync getMfaPendingSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret_pending: string | null }>(\n\t\t\t`SELECT mfa_secret_pending FROM fonderie_users\n\t\t\t WHERE id = $1 AND mfa_secret_pending_expires_at > now()`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret_pending ?? null;\n\t}\n\n\tasync confirmMfaSecret(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret = mfa_secret_pending,\n\t\t\t mfa_secret_pending = NULL,\n\t\t\t mfa_secret_pending_expires_at = NULL,\n\t\t\t mfa_enabled = true,\n\t\t\t updated_at = now()\n\t\t\t WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync enableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_enabled = true WHERE id = $1`, [id]);\n\t}\n\n\tasync disableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET mfa_enabled = false, mfa_secret = NULL, updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync updateEmail(id: string, email: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email = $1, email_verified_at = NULL, updated_at = now() WHERE id = $2`,\n\t\t\t[email.toLowerCase().trim(), id],\n\t\t);\n\t}\n\n\tasync updatePhone(id: string, phone: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET phone = $1, updated_at = now() WHERE id = $2`,\n\t\t\t[phone, id],\n\t\t);\n\t}\n\n\tasync updatePreferences(\n\t\tid: string,\n\t\tfields: {\n\t\t\tlocale?: string;\n\t\t\ttimezone?: string;\n\t\t\tpatch?: Record<string, unknown>;\n\t\t},\n\t): Promise<{ id: string } | null> {\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tif (fields.locale !== undefined) {\n\t\t\tvalues.push(fields.locale);\n\t\t\tsets.push(`locale = $${values.length}`);\n\t\t}\n\t\tif (fields.timezone !== undefined) {\n\t\t\tvalues.push(fields.timezone);\n\t\t\tsets.push(`timezone = $${values.length}`);\n\t\t}\n\t\tif (fields.patch && Object.keys(fields.patch).length > 0) {\n\t\t\tvalues.push(JSON.stringify(fields.patch));\n\t\t\tsets.push(`preferences = preferences || $${values.length}::jsonb`);\n\t\t}\n\n\t\tif (sets.length === 0) return null;\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync getMfaSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret: string | null }>(\n\t\t\t`SELECT mfa_secret FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret ?? null;\n\t}\n\n\t/**\n\t * Link an OAuth identity to the account owning `email`, creating it if there\n\t * is none.\n\t *\n\t * Returns what HAPPENED, not just who it happened to. Three outcomes hide\n\t * behind one upsert, and callers must tell them apart:\n\t *\n\t * • `inserted` — a brand-new account. Whoever provisions on signup (the\n\t * personal workspace, a wallet, a welcome email) must run, exactly as\n\t * it does for a password registration. This is the case that was\n\t * previously invisible: the upsert returned an id and looked identical\n\t * to a returning user signing in, so OAuth signups silently skipped\n\t * every signup side effect.\n\t * • `previousProvider` differs — an existing account gained, or switched,\n\t * a way to sign in. A security event for the owner.\n\t * • `previousProvider` is the same — an ordinary login. Nothing to do, and\n\t * notifying here would email the user on every single sign-in.\n\t *\n\t * The prior state is read in a CTE of the SAME statement, so it sees the\n\t * snapshot from before the insert. Reading it in a separate query would\n\t * race two concurrent logins into both seeing \"new\".\n\t *\n\t * SECURITY — linking is BY EMAIL, so this merges into whatever row already\n\t * holds the address. If that row was never verified, its password was never\n\t * proven to belong to the mailbox; anyone can register an address they do\n\t * not own. The provider HAS proven ownership. So an unverified password is\n\t * revoked at the moment of linking: without that, linking sets\n\t * email_verified_at and the earlier registrant is left holding a working\n\t * password on an account now treated as verified.\n\t *\n\t * The legitimate user loses nothing but a password reset — they own the\n\t * mailbox. Someone who does not own it cannot receive that mail.\n\t */\n\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{\n\t\tid: string;\n\t\tinserted: boolean;\n\t\tpreviousProvider: string | null;\n\t\tclearedUnverifiedPassword: boolean;\n\t} | null> {\n\t\tconst [row] = await this.store.query<{\n\t\t\tid: string;\n\t\t\tinserted: boolean;\n\t\t\tpreviousProvider: string | null;\n\t\t\tclearedUnverifiedPassword: boolean;\n\t\t}>(\n\t\t\t`WITH prior AS (\n\t\t\t\tSELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password\n\t\t\t\t FROM fonderie_users WHERE email = $1\n\t\t\t), upserted AS (\n\t\t\t\tINSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)\n\t\t\t\tVALUES ($1, now(), $2, $3)\n\t\t\t\tON CONFLICT (email) DO UPDATE\n\t\t\t\tSET provider = $2, provider_id = $3,\n\t\t\t\t email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),\n\t\t\t\t -- A password on an UNVERIFIED account is a claim, not a\n\t\t\t\t -- credential: nobody ever proved that mailbox belongs to\n\t\t\t\t -- whoever set it. The provider has now proven it belongs to\n\t\t\t\t -- the person signing in. Drop the unproven claim rather than\n\t\t\t\t -- promote it — otherwise linking marks the account verified\n\t\t\t\t -- and silently hands the earlier registrant a working\n\t\t\t\t -- password on a now-trusted account.\n\t\t\t\t password_hash = CASE\n\t\t\t\t WHEN fonderie_users.email_verified_at IS NULL THEN NULL\n\t\t\t\t ELSE fonderie_users.password_hash\n\t\t\t\t END\n\t\t\t\tRETURNING id\n\t\t\t)\n\t\t\tSELECT upserted.id,\n\t\t\t NOT EXISTS (SELECT 1 FROM prior) AS inserted,\n\t\t\t (SELECT provider FROM prior) AS \"previousProvider\",\n\t\t\t -- True only when a password was actually revoked, so the\n\t\t\t -- caller can tell the owner their sign-in method changed.\n\t\t\t COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)\n\t\t\t AS \"clearedUnverifiedPassword\"\n\t\t\tFROM upserted`,\n\t\t\t[email, provider, providerId],\n\t\t);\n\t\treturn row ?? null;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\n\nexport class SessionModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync create(\n\t\tuserId: string,\n\t\ttoken: string,\n\t\texpiresAt: Date,\n\t\tsid?: string,\n\t\tmeta?: IRequestMeta,\n\t): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null],\n\t\t);\n\t}\n\n\tasync delete(token: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);\n\t}\n\n\t// Delete the session an access token is bound to (by its sid claim). Lets\n\t// logout revoke the current session without the client resending the refresh\n\t// token — the access token already identifies its session.\n\tasync deleteBySid(sid: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);\n\t}\n\n\t// Revoke every session for a user (e.g. on password change). Access tokens\n\t// bound to these sessions via the sid claim die on their next request.\n\tasync deleteByUser(userId: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);\n\t}\n\n\t// Session metadata for a user (no tokens) — for the data-export / SAR bundle.\n\tasync listByUser(userId: string): Promise<\n\t\tArray<{ id: string; userAgent: string | null; ipAddress: string | null; createdAt: Date; expiresAt: Date }>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Live sessions for the Active Sessions screen: unexpired only, carrying the\n\t// sid so the caller can flag the current one (row.sid === the JWT's sid).\n\t// No token — that never leaves the server.\n\tasync listLiveByUser(userId: string): Promise<\n\t\tArray<{\n\t\t\tid: string;\n\t\t\tsid: string | null;\n\t\t\tuserAgent: string | null;\n\t\t\tipAddress: string | null;\n\t\t\tcreatedAt: Date;\n\t\t\texpiresAt: Date;\n\t\t}>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id, sid,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND expires_at > now()\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Terminate one session by row id, scoped to its owner (a user can never\n\t// delete another user's session). Returns whether a row was removed.\n\tasync terminateById(userId: string, id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Terminate all of a user's sessions except the current one (by sid).\n\t// Returns the number of sessions removed.\n\tasync terminateOthers(userId: string, keepSid: string): Promise<number> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)\n\t\t\t RETURNING id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\n\t}\n\n\tasync exists(token: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,\n\t\t\t[token],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Liveness check for session-bound access tokens (by the sid claim).\n\tasync aliveBySid(sid: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE sid = $1 AND expires_at > now()`,\n\t\t\t[sid],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n}\n","import type { Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAuthConfig } from '../config';\nimport { verifyToken } from '../services/jwt';\nimport type { IAccessPayload } from '../services/jwt';\nimport { UserModel } from '../models/user.model';\nimport { SessionModel } from '../models/session.model';\n\n// Reads the Bearer token or session cookie, populates ctx.user\n// Does NOT reject — anonymous requests pass through\nexport function withSession(store: IStoreAdapter, config: IAuthConfig): Middleware {\n\tconst users = new UserModel(store);\n\tconst sessions = new SessionModel(store);\n\n\treturn async (ctx, next) => {\n\t\tconst token = extractToken(ctx.request);\n\t\tif (!token) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst payload = verifyToken(token, config);\n\t\tif (!payload || payload.type !== 'access') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Revocation: access tokens are bound to their refresh session via the\n\t\t// sid claim. Logout, rotation, and password change delete that row, so\n\t\t// the access token dies with it instead of living out its JWT expiry.\n\t\t// Tokens without a sid pass through: short-lived mfaPending tokens\n\t\t// (no session exists yet) and legacy tokens from before session\n\t\t// binding, which age out within one accessTokenDuration of deploy.\n\t\tif (payload.sid && !(await sessions.aliveBySid(payload.sid))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst user = await users.findById(payload.sub);\n\t\tif (!user || user.suspended || user.deletedAt) {\n\t\t\treturn next();\n\t\t}\n\n\t\tObject.assign(ctx, {\n\t\t\tuser: {\n\t\t\t\t...user,\n\t\t\t\tloginMethod: payload.loginMethod ?? 'email',\n\t\t\t\tphoneVerified: payload.phoneVerified ?? false,\n\t\t\t\tmfaPending: (payload as IAccessPayload).mfaPending ?? false,\n\t\t\t\t// The session this request is authenticated on, so the sessions\n\t\t\t\t// API can flag the current row and \"terminate others\" can spare it.\n\t\t\t\tsid: payload.sid ?? null,\n\t\t\t},\n\t\t});\n\n\t\treturn next();\n\t};\n}\n\nfunction extractToken(request: Request): string | null {\n\tconst auth = request.headers.get('authorization');\n\tif (auth?.startsWith('Bearer ')) {\n\t\treturn auth.slice(7);\n\t}\n\n\tconst cookie = request.headers.get('cookie') ?? '';\n\tconst match = cookie.match(/(?:^|;\\s*)access_token=([^;]+)/);\n\treturn match?.[1] ?? null;\n}\n","export { requireAuth, requireAnyAuth } from '@fonderie/core/middlewares';\n","import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n"],"mappings":"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","names":[]}
@@ -44,6 +44,10 @@ declare const MESSAGE_KEYS: {
44
44
  readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated";
45
45
  readonly emailChanged: "email-changed";
46
46
  readonly phoneChanged: "phone-changed";
47
+ readonly passwordRevoked: "password-revoked";
48
+ readonly oauthRegistration: "oauth-registration";
49
+ readonly oauthLinked: "oauth-linked";
50
+ readonly oauthUnlinked: "oauth-unlinked";
47
51
  };
48
52
  type AuthMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];
49
53
  interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {
@@ -64,7 +68,7 @@ interface IDataExportContributor {
64
68
  name: string;
65
69
  collect: (userId: string) => Promise<unknown> | unknown;
66
70
  }
67
- type AuthRouteId = 'providers' | 'register' | 'login' | 'refresh' | 'forgotPassword' | 'resetPassword' | 'verifyEmail' | 'sendVerification' | 'logout' | 'me' | 'updateProfile' | 'updatePreferences' | 'updateEmail' | 'updatePhone' | 'changePassword' | 'deleteMe' | 'exportMe' | 'loginHistory' | 'listSessions' | 'terminateSession' | 'terminateOtherSessions' | 'mfaSetup' | 'mfaVerify' | 'mfaDisable' | 'mfaBackupCodes';
71
+ type AuthRouteId = 'providers' | 'unlinkOauth' | 'register' | 'login' | 'refresh' | 'forgotPassword' | 'resetPassword' | 'verifyEmail' | 'sendVerification' | 'logout' | 'me' | 'updateProfile' | 'updatePreferences' | 'updateEmail' | 'updatePhone' | 'changePassword' | 'deleteMe' | 'exportMe' | 'loginHistory' | 'listSessions' | 'terminateSession' | 'terminateOtherSessions' | 'mfaSetup' | 'mfaVerify' | 'mfaDisable' | 'mfaBackupCodes';
68
72
  type AuthRouteOverride = string | {
69
73
  method?: string;
70
74
  path?: string;
@@ -44,6 +44,10 @@ declare const MESSAGE_KEYS: {
44
44
  readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated";
45
45
  readonly emailChanged: "email-changed";
46
46
  readonly phoneChanged: "phone-changed";
47
+ readonly passwordRevoked: "password-revoked";
48
+ readonly oauthRegistration: "oauth-registration";
49
+ readonly oauthLinked: "oauth-linked";
50
+ readonly oauthUnlinked: "oauth-unlinked";
47
51
  };
48
52
  type AuthMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];
49
53
  interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {
@@ -64,7 +68,7 @@ interface IDataExportContributor {
64
68
  name: string;
65
69
  collect: (userId: string) => Promise<unknown> | unknown;
66
70
  }
67
- type AuthRouteId = 'providers' | 'register' | 'login' | 'refresh' | 'forgotPassword' | 'resetPassword' | 'verifyEmail' | 'sendVerification' | 'logout' | 'me' | 'updateProfile' | 'updatePreferences' | 'updateEmail' | 'updatePhone' | 'changePassword' | 'deleteMe' | 'exportMe' | 'loginHistory' | 'listSessions' | 'terminateSession' | 'terminateOtherSessions' | 'mfaSetup' | 'mfaVerify' | 'mfaDisable' | 'mfaBackupCodes';
71
+ type AuthRouteId = 'providers' | 'unlinkOauth' | 'register' | 'login' | 'refresh' | 'forgotPassword' | 'resetPassword' | 'verifyEmail' | 'sendVerification' | 'logout' | 'me' | 'updateProfile' | 'updatePreferences' | 'updateEmail' | 'updatePhone' | 'changePassword' | 'deleteMe' | 'exportMe' | 'loginHistory' | 'listSessions' | 'terminateSession' | 'terminateOtherSessions' | 'mfaSetup' | 'mfaVerify' | 'mfaDisable' | 'mfaBackupCodes';
68
72
  type AuthRouteOverride = string | {
69
73
  method?: string;
70
74
  path?: string;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["export interface IUserPreferences {\n\tlocale: string;\n\ttimezone: string;\n\tnotifications: {\n\t\temail: boolean;\n\t\tinApp: boolean;\n\t\tsms: boolean;\n\t\tpush: boolean;\n\t};\n\temailDigest: string;\n\tdateFormat?: string;\n\ttimeFormat?: string;\n}\n\n// Concrete types — fulfill the stubs in @fonderie/core\nexport interface IUser {\n\tid: string;\n\temail: string | null;\n\tfirstName: string | null;\n\tlastName: string | null;\n\tphone: string | null;\n\tprofileImageUrl: string | null;\n\tlocale: string;\n\ttimezone: string;\n\tisActive: boolean;\n\tlastLogin: Date | null;\n\tpreferences: IUserPreferences;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tdeletedAt: Date | null;\n\tcreatedAt: Date;\n\tupdatedAt: Date;\n\tmfaEnabled: boolean;\n\tpasswordHash: string | null;\n\temailVerifiedAt: Date | null;\n}\n\nexport interface ISession {\n\tid: string;\n\ttoken: string;\n\tuserId: string;\n\tuserAgent: string | null;\n\tipAddress: string | null;\n\texpiresAt: Date;\n\tcreatedAt: Date;\n}\n\nexport interface IMfaChallenge {\n\ttoken: string;\n\tuserId: string;\n\texpiresAt: Date;\n\tusedAt: Date | null;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAAA;AAAA;","names":[]}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["export interface IUserPreferences {\n\tlocale: string;\n\ttimezone: string;\n\tnotifications: {\n\t\temail: boolean;\n\t\tinApp: boolean;\n\t\tsms: boolean;\n\t\tpush: boolean;\n\t};\n\temailDigest: string;\n\tdateFormat?: string;\n\ttimeFormat?: string;\n}\n\n// Concrete types — fulfill the stubs in @fonderie/core\nexport interface IUser {\n\tid: string;\n\temail: string | null;\n\tfirstName: string | null;\n\tlastName: string | null;\n\tphone: string | null;\n\tprofileImageUrl: string | null;\n\tlocale: string;\n\ttimezone: string;\n\tisActive: boolean;\n\tlastLogin: Date | null;\n\tpreferences: IUserPreferences;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tdeletedAt: Date | null;\n\tcreatedAt: Date;\n\tupdatedAt: Date;\n\tmfaEnabled: boolean;\n\tpasswordHash: string | null;\n\temailVerifiedAt: Date | null;\n\t/**\n\t * OAuth provider this account is linked to, or null. Exposed so a settings\n\t * screen can show — and offer to remove — the connection. Deliberately NOT\n\t * accompanied by provider_id: that is an identifier at the provider, and\n\t * the client has no use for it.\n\t */\n\tprovider: string | null;\n}\n\nexport interface ISession {\n\tid: string;\n\ttoken: string;\n\tuserId: string;\n\tuserAgent: string | null;\n\tipAddress: string | null;\n\texpiresAt: Date;\n\tcreatedAt: Date;\n}\n\nexport interface IMfaChallenge {\n\ttoken: string;\n\tuserId: string;\n\texpiresAt: Date;\n\tusedAt: Date | null;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAAA;AAAA;","names":[]}
package/dist/types.d.cts CHANGED
@@ -32,6 +32,13 @@ interface IUser {
32
32
  mfaEnabled: boolean;
33
33
  passwordHash: string | null;
34
34
  emailVerifiedAt: Date | null;
35
+ /**
36
+ * OAuth provider this account is linked to, or null. Exposed so a settings
37
+ * screen can show — and offer to remove — the connection. Deliberately NOT
38
+ * accompanied by provider_id: that is an identifier at the provider, and
39
+ * the client has no use for it.
40
+ */
41
+ provider: string | null;
35
42
  }
36
43
  interface ISession {
37
44
  id: string;
package/dist/types.d.ts CHANGED
@@ -32,6 +32,13 @@ interface IUser {
32
32
  mfaEnabled: boolean;
33
33
  passwordHash: string | null;
34
34
  emailVerifiedAt: Date | null;
35
+ /**
36
+ * OAuth provider this account is linked to, or null. Exposed so a settings
37
+ * screen can show — and offer to remove — the connection. Deliberately NOT
38
+ * accompanied by provider_id: that is an identifier at the provider, and
39
+ * the client has no use for it.
40
+ */
41
+ provider: string | null;
35
42
  }
36
43
  interface ISession {
37
44
  id: string;