@fonderie/auth 7.5.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.
- package/brain/signatures.md +2 -2
- package/dist/index.cjs +66 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +66 -3
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +30 -3
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +30 -3
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-Dwlcgxww.d.cts → session-Bv4qu9xV.d.cts} +1 -0
- package/dist/{session-Dwlcgxww.d.ts → session-Bv4qu9xV.d.ts} +1 -0
- package/package.json +1 -1
|
@@ -288,22 +288,49 @@ var UserModel = class {
|
|
|
288
288
|
* The prior state is read in a CTE of the SAME statement, so it sees the
|
|
289
289
|
* snapshot from before the insert. Reading it in a separate query would
|
|
290
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.
|
|
291
302
|
*/
|
|
292
303
|
async upsertByProvider(email, provider, providerId) {
|
|
293
304
|
const [row] = await this.store.query(
|
|
294
305
|
`WITH prior AS (
|
|
295
|
-
SELECT provider
|
|
306
|
+
SELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password
|
|
307
|
+
FROM fonderie_users WHERE email = $1
|
|
296
308
|
), upserted AS (
|
|
297
309
|
INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
|
|
298
310
|
VALUES ($1, now(), $2, $3)
|
|
299
311
|
ON CONFLICT (email) DO UPDATE
|
|
300
312
|
SET provider = $2, provider_id = $3,
|
|
301
|
-
email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
|
|
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
|
|
302
325
|
RETURNING id
|
|
303
326
|
)
|
|
304
327
|
SELECT upserted.id,
|
|
305
328
|
NOT EXISTS (SELECT 1 FROM prior) AS inserted,
|
|
306
|
-
(SELECT provider FROM prior) AS "previousProvider"
|
|
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"
|
|
307
334
|
FROM upserted`,
|
|
308
335
|
[email, provider, providerId]
|
|
309
336
|
);
|
|
@@ -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\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\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{ id: string; inserted: boolean; previousProvider: string | null } | 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}>(\n\t\t\t`WITH prior AS (\n\t\t\t\tSELECT provider 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\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\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
|
+
{"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"]}
|
|
@@ -252,22 +252,49 @@ var UserModel = class {
|
|
|
252
252
|
* The prior state is read in a CTE of the SAME statement, so it sees the
|
|
253
253
|
* snapshot from before the insert. Reading it in a separate query would
|
|
254
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.
|
|
255
266
|
*/
|
|
256
267
|
async upsertByProvider(email, provider, providerId) {
|
|
257
268
|
const [row] = await this.store.query(
|
|
258
269
|
`WITH prior AS (
|
|
259
|
-
SELECT provider
|
|
270
|
+
SELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password
|
|
271
|
+
FROM fonderie_users WHERE email = $1
|
|
260
272
|
), upserted AS (
|
|
261
273
|
INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
|
|
262
274
|
VALUES ($1, now(), $2, $3)
|
|
263
275
|
ON CONFLICT (email) DO UPDATE
|
|
264
276
|
SET provider = $2, provider_id = $3,
|
|
265
|
-
email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
|
|
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
|
|
266
289
|
RETURNING id
|
|
267
290
|
)
|
|
268
291
|
SELECT upserted.id,
|
|
269
292
|
NOT EXISTS (SELECT 1 FROM prior) AS inserted,
|
|
270
|
-
(SELECT provider FROM prior) AS "previousProvider"
|
|
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"
|
|
271
298
|
FROM upserted`,
|
|
272
299
|
[email, provider, providerId]
|
|
273
300
|
);
|
|
@@ -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\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\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{ id: string; inserted: boolean; previousProvider: string | null } | 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}>(\n\t\t\t`WITH prior AS (\n\t\t\t\tSELECT provider 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\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\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":[]}
|
|
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,7 @@ 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";
|
|
47
48
|
readonly oauthRegistration: "oauth-registration";
|
|
48
49
|
readonly oauthLinked: "oauth-linked";
|
|
49
50
|
readonly oauthUnlinked: "oauth-unlinked";
|
|
@@ -44,6 +44,7 @@ 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";
|
|
47
48
|
readonly oauthRegistration: "oauth-registration";
|
|
48
49
|
readonly oauthLinked: "oauth-linked";
|
|
49
50
|
readonly oauthUnlinked: "oauth-unlinked";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/auth",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.6.0",
|
|
4
4
|
"fonderie": { "stability": "stable" },
|
|
5
5
|
"description": "Drop-in auth for SaaS — email/password, phone OTP, Google OAuth, stateless JWT sessions, TOTP-based MFA with backup codes, and self-service password recovery.",
|
|
6
6
|
"keywords": [
|