@fonderie/auth 7.14.4 → 7.16.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/README.md +20 -0
- package/brain/outcomes.md +3 -0
- package/brain/signatures.md +1 -0
- package/dist/index.cjs +149 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +136 -37
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +80 -10
- 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 +81 -11
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/020_session_rotation.sql +22 -0
- package/dist/{session-BBF5t-3Y.d.cts → session-auNb2L8m.d.cts} +1 -0
- package/dist/{session-BBF5t-3Y.d.ts → session-auNb2L8m.d.ts} +1 -0
- package/env.json +10 -0
- package/package.json +1 -1
|
@@ -37,12 +37,23 @@ module.exports = __toCommonJS(middlewares_exports);
|
|
|
37
37
|
// src/services/jwt.ts
|
|
38
38
|
var import_node_crypto = require("crypto");
|
|
39
39
|
var import_jsonwebtoken = __toESM(require("jsonwebtoken"), 1);
|
|
40
|
+
function keyIdOf(secret) {
|
|
41
|
+
return (0, import_node_crypto.createHash)("sha256").update(secret).digest("hex").slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
function keyRing(config) {
|
|
44
|
+
return [config.jwtSecret, ...config.jwtPreviousSecrets ?? []].filter((s) => typeof s === "string" && s.length > 0);
|
|
45
|
+
}
|
|
40
46
|
function verifyToken(token, config) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
const ring = keyRing(config);
|
|
48
|
+
const kid = import_jsonwebtoken.default.decode(token, { complete: true })?.header?.kid;
|
|
49
|
+
const candidates = typeof kid === "string" ? ring.filter((s) => keyIdOf(s) === kid) : ring;
|
|
50
|
+
for (const secret of candidates) {
|
|
51
|
+
try {
|
|
52
|
+
return import_jsonwebtoken.default.verify(token, secret);
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
45
55
|
}
|
|
56
|
+
return null;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
// src/models/user.model.ts
|
|
@@ -381,6 +392,9 @@ var UserModel = class {
|
|
|
381
392
|
};
|
|
382
393
|
var MAX_LIST_LIMIT = 200;
|
|
383
394
|
|
|
395
|
+
// src/models/session.model.ts
|
|
396
|
+
var import_node_crypto2 = require("crypto");
|
|
397
|
+
|
|
384
398
|
// src/services/request-location.ts
|
|
385
399
|
var LOCATION_TIMEOUT_MS = 500;
|
|
386
400
|
var TEXT_MAX = 128;
|
|
@@ -463,6 +477,10 @@ async function resolveOnce(resolver, req, timeoutMs) {
|
|
|
463
477
|
}
|
|
464
478
|
|
|
465
479
|
// src/models/session.model.ts
|
|
480
|
+
function hashRefreshToken(token) {
|
|
481
|
+
return (0, import_node_crypto2.createHash)("sha256").update(token).digest("hex");
|
|
482
|
+
}
|
|
483
|
+
var REFRESH_GRACE_MS = 3e4;
|
|
466
484
|
var SessionModel = class {
|
|
467
485
|
// `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
|
|
468
486
|
constructor(store, locate) {
|
|
@@ -474,12 +492,12 @@ var SessionModel = class {
|
|
|
474
492
|
async create(userId, token, expiresAt, sid, meta) {
|
|
475
493
|
const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
|
|
476
494
|
await this.store.query(
|
|
477
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
|
|
478
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
495
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
|
|
496
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
479
497
|
ON CONFLICT (token) DO NOTHING`,
|
|
480
498
|
[
|
|
481
499
|
userId,
|
|
482
|
-
token,
|
|
500
|
+
hashRefreshToken(token),
|
|
483
501
|
expiresAt,
|
|
484
502
|
sid ?? null,
|
|
485
503
|
meta?.userAgent ?? null,
|
|
@@ -489,7 +507,56 @@ var SessionModel = class {
|
|
|
489
507
|
);
|
|
490
508
|
}
|
|
491
509
|
async delete(token) {
|
|
492
|
-
await this.store.query(`DELETE FROM fonderie_sessions WHERE token
|
|
510
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Where a presented refresh token stands: the session's current token, its
|
|
514
|
+
* previous one within the grace (a retry or race), its previous one AFTER
|
|
515
|
+
* the grace (reuse — a theft signal), or nothing known.
|
|
516
|
+
*/
|
|
517
|
+
async match(token) {
|
|
518
|
+
const hash = hashRefreshToken(token);
|
|
519
|
+
const [current] = await this.store.query(
|
|
520
|
+
`SELECT id, user_id, sid, token FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
|
|
521
|
+
[hash, token]
|
|
522
|
+
);
|
|
523
|
+
if (current) return { kind: "current", row: toRow(current) };
|
|
524
|
+
const [previous] = await this.store.query(
|
|
525
|
+
`SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace
|
|
526
|
+
FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
|
|
527
|
+
[hash]
|
|
528
|
+
);
|
|
529
|
+
if (!previous) return { kind: "unknown" };
|
|
530
|
+
return { kind: previous.in_grace ? "grace" : "reused", row: toRow(previous) };
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Rotate a session in place to a new refresh token: same row, same device.
|
|
534
|
+
* From the CURRENT token it is optimistic — only if the stored token is
|
|
535
|
+
* still the one matched; a concurrent rotation makes it return false and the
|
|
536
|
+
* caller re-matches (and now finds the token as the previous one). The old
|
|
537
|
+
* token becomes the previous, valid for the grace. From the PREVIOUS token
|
|
538
|
+
* (within the grace) it is authorised by that hash and deadline alone, so
|
|
539
|
+
* racing retries never starve; the previous and its deadline are kept.
|
|
540
|
+
*/
|
|
541
|
+
async rotate(row, presented, next, from) {
|
|
542
|
+
const rows = await this.store.query(
|
|
543
|
+
`UPDATE fonderie_sessions
|
|
544
|
+
SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,
|
|
545
|
+
previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,
|
|
546
|
+
token = $3,
|
|
547
|
+
expires_at = $4,
|
|
548
|
+
last_used_at = now()
|
|
549
|
+
WHERE id = $1
|
|
550
|
+
AND CASE WHEN $5 THEN token = $2
|
|
551
|
+
ELSE previous_token_hash = $6 AND previous_valid_until > now() END
|
|
552
|
+
RETURNING id`,
|
|
553
|
+
[row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === "current", hashRefreshToken(presented), String(REFRESH_GRACE_MS)]
|
|
554
|
+
);
|
|
555
|
+
return rows.length === 1;
|
|
556
|
+
}
|
|
557
|
+
/** Revoke one session by its row id (any user) — reuse detection, admin. */
|
|
558
|
+
async revokeById(id) {
|
|
559
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);
|
|
493
560
|
}
|
|
494
561
|
// Delete the session an access token is bound to (by its sid claim). Lets
|
|
495
562
|
// logout revoke the current session without the client resending the refresh
|
|
@@ -555,8 +622,8 @@ var SessionModel = class {
|
|
|
555
622
|
}
|
|
556
623
|
async exists(token) {
|
|
557
624
|
const rows = await this.store.query(
|
|
558
|
-
`SELECT id FROM fonderie_sessions WHERE token
|
|
559
|
-
[token]
|
|
625
|
+
`SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,
|
|
626
|
+
[hashRefreshToken(token), token]
|
|
560
627
|
);
|
|
561
628
|
return rows.length > 0;
|
|
562
629
|
}
|
|
@@ -569,6 +636,9 @@ var SessionModel = class {
|
|
|
569
636
|
return rows.length > 0;
|
|
570
637
|
}
|
|
571
638
|
};
|
|
639
|
+
function toRow(r) {
|
|
640
|
+
return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
|
|
641
|
+
}
|
|
572
642
|
|
|
573
643
|
// src/middlewares/session.ts
|
|
574
644
|
function withSession(store, config) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/services/request-location.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\t// One page of users, newest first. Keyset-paginated on (created_at, id) —\n\t// the same cursor contract as login history and the audit log. The +1\n\t// over-fetch that detects a next page lives here so no outer clamp can\n\t// shave it off. Soft-deleted rows are excluded, as in every other finder.\n\tasync list(query: IUserListQuery = {}): Promise<IUserPage> {\n\t\tconst limit = Math.min(query.limit ?? 50, MAX_LIST_LIMIT);\n\t\tconst params: unknown[] = [];\n\t\tconst where: string[] = [query.deleted ? 'deleted_at IS NOT NULL' : 'deleted_at IS NULL'];\n\n\t\tif (query.cursor) {\n\t\t\tparams.push(query.cursor.createdAt, query.cursor.id);\n\t\t\twhere.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);\n\t\t}\n\n\t\tparams.push(limit + 1);\n\t\tconst rows = await this.store.query<IUser & { createdAtRaw?: string }>(\n\t\t\t`SELECT ${USER_COLUMNS}, created_at::text AS \"createdAtRaw\"\n\t\t\t FROM fonderie_users\n\t\t\t WHERE ${where.join(' AND ')}\n\t\t\t ORDER BY created_at DESC, id DESC\n\t\t\t LIMIT $${params.length}`,\n\t\t\tparams,\n\t\t);\n\t\treturn { users: rows.slice(0, limit), hasMore: rows.length > limit };\n\t}\n\n\t// For operator views only: a soft-deleted account still resolves, with\n\t// deletedAt set, so the console can say \"deleted on …\" instead of \"no such\n\t// user\". Nothing that authenticates or acts on an account may use this.\n\tasync findByIdIncludingDeleted(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`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\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\t// Only on insert: an existing account keeps the locale it chose.\n\t\tlocale: 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, locale)\n\t\t\tVALUES ($1, $2, $3, COALESCE($4, 'en-US'))\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, locale],\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\t// The sign-up language, else the app's system locale; null keeps the\n\t\t// column default (en-US) for callers that predate locales.\n\t\tlocale: string | null = 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, locale)\n\t\t\tVALUES ($1, $2, $3, $4, COALESCE($5, 'en-US'))\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName, locale],\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\t// The operator's lock: login, refresh and the session middleware all refuse a\n\t// suspended user. Returns false when there is no such user.\n\tasync setSuspended(id: string, suspended: boolean): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET suspended = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL RETURNING id`,\n\t\t\t[id, suspended],\n\t\t);\n\t\treturn rows.length > 0;\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\nconst MAX_LIST_LIMIT = 200;\n\nexport interface IUserListQuery {\n\tlimit?: number;\n\tcursor?: { createdAt: string; id: string };\n\t// true ⇒ ONLY soft-deleted accounts (an operator view; every other finder\n\t// excludes them). They stay until the retention purge hard-deletes them.\n\tdeleted?: boolean;\n}\n\nexport interface IUserPage {\n\t// Carries createdAtRaw (created_at::text) so the cursor keeps the\n\t// microsecond precision a Date round-trip would drop.\n\tusers: Array<IUser & { createdAtRaw?: string }>;\n\thasMore: boolean;\n}\n","// Optional location enrichment for auth events (login history, registrations,\n// sessions).\n//\n// Auth never resolves a location itself and imports no geo code: the app hands\n// in a resolver (IAuthConfig.location). Typical choices — the platform's\n// edge headers via @fonderie/geo's geoFromHeaders (zero infrastructure), a\n// self-hosted table, or a hosted IP API that also knows the network (ISP, ASN,\n// proxy/VPN, hosting). Field names match @fonderie/geo's GeoLocation, so its\n// result can be returned as-is.\n//\n// Whatever the resolver returns is treated as untrusted input: every field is\n// type-checked and bounded before it is stored, and a resolver that throws or\n// stalls costs the login nothing — the row is written without a location.\n\n/** Where a login attempt came from. Every field is optional; unknown is null. */\nexport interface IRequestLocation {\n\tcountry?: string | null; // ISO-3166-1 alpha-2\n\tcountryName?: string | null;\n\tsubdivision?: string | null; // ISO-3166-2 region part (QC, CA, ENG)\n\tsubdivisionName?: string | null;\n\tcity?: string | null;\n\t/** Postal / ZIP code as the provider reports it (94043, K1A 0B1, SW1A). Approximate. */\n\tpostalCode?: string | null;\n\tcontinent?: string | null; // two-letter continent code\n\ttimeZone?: string | null; // IANA name\n\tlatitude?: number | null;\n\tlongitude?: number | null;\n\t/** How far off the point may be, in kilometres (MaxMind's accuracy_radius).\n\t * Lets a UI say \"near Mountain View (±20 km)\" instead of implying precision. */\n\taccuracyRadius?: number | null;\n\t/** A stable, language-neutral place key (MaxMind/GeoNames id). Lets a reader\n\t * see the stored place in their own language later, and lets an app map it\n\t * to its own regions. Names above are a snapshot; this is the reference. */\n\tgeonameId?: number | null;\n\t/** Network facts — only resolvers backed by an IP-intelligence API know these. */\n\tisp?: string | null;\n\torg?: string | null;\n\tasn?: string | null; // e.g. \"AS15169\"\n\tmobile?: boolean | null;\n\tproxy?: boolean | null;\n\thosting?: boolean | null;\n}\n\nexport interface ILocationRequest {\n\t/** The resolved client IP (adapter-resolved, proxy-aware), or null. */\n\tip: string | null;\n\t/** The incoming request's headers — for platform geolocation headers. */\n\theaders: Headers;\n}\n\nexport type LocationResolver = (\n\treq: ILocationRequest,\n) => IRequestLocation | null | undefined | Promise<IRequestLocation | null | undefined>;\n\n// How long auth waits for the resolver before writing the row without a\n// location. Internal, deliberately not exported. It is zero-cost for in-process\n// resolvers (edge headers return in microseconds) and exists for the ones that\n// are not: @fonderie/geo's PostgresGeoProvider does a DB query that can hang\n// on an exhausted pool, and an app may choose a hosted IP-intelligence API for\n// richer data (ISP, ASN, proxy/VPN, accuracy radius). A legitimate lookup\n// answers in a few to ~150 ms; the session write that triggers resolution is\n// on the login path, so the cap bounds the worst-case delay a slow provider\n// can add to a sign-in.\nconst LOCATION_TIMEOUT_MS = 500;\n\nconst TEXT_MAX = 128;\n\nfunction str(v: unknown, re?: RegExp): string | null {\n\tif (typeof v !== 'string') return null;\n\tconst t = v.trim();\n\tif (t.length === 0 || t.length > TEXT_MAX) return null;\n\treturn re && !re.test(t) ? null : t;\n}\nfunction upperCode(v: unknown, re: RegExp): string | null {\n\tconst t = str(v, re);\n\treturn t ? t.toUpperCase() : null;\n}\nfunction bool(v: unknown): boolean | null {\n\treturn typeof v === 'boolean' ? v : null;\n}\n// Two decimals ≈ 1 km: plenty for \"which city\", and a login history should not\n// keep more precision than the city it names.\nfunction coord(v: unknown, limit: number): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || Math.abs(v) > limit) return null;\n\treturn Math.round(v * 100) / 100;\n}\n\n// Kilometres, a positive whole number. MaxMind's values run 1–1000; anything\n// beyond the planet's half-circumference is not a radius.\nfunction radius(v: unknown): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || v > 20_000) return null;\n\treturn Math.round(v);\n}\n\n// GeoNames ids are positive integers well inside 2^31.\nfunction placeId(v: unknown): number | null {\n\tconst n = typeof v === 'string' && /^\\d{1,10}$/.test(v) ? Number(v) : v;\n\treturn typeof n === 'number' && Number.isInteger(n) && n > 0 && n <= 2_147_483_647 ? n : null;\n}\n\n/** Keep only well-formed, bounded fields. Returns null when nothing survives. */\nexport function sanitizeLocation(input: unknown): IRequestLocation | null {\n\tif (!input || typeof input !== 'object') return null;\n\tconst i = input as Record<string, unknown>;\n\tconst out: IRequestLocation = {\n\t\tcountry: upperCode(i['country'], /^[A-Za-z]{2}$/),\n\t\tcountryName: str(i['countryName']),\n\t\tsubdivision: upperCode(i['subdivision'], /^[A-Za-z0-9]{1,3}$/),\n\t\tsubdivisionName: str(i['subdivisionName']),\n\t\tcity: str(i['city']),\n\t\tpostalCode: upperCode(i['postalCode'], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),\n\t\tcontinent: upperCode(i['continent'], /^[A-Za-z]{2}$/),\n\t\ttimeZone: str(i['timeZone'], /^(UTC|[A-Za-z_]+(\\/[A-Za-z0-9_+-]+)+)$/),\n\t\tlatitude: coord(i['latitude'], 90),\n\t\tlongitude: coord(i['longitude'], 180),\n\t\taccuracyRadius: radius(i['accuracyRadius']),\n\t\tgeonameId: placeId(i['geonameId']),\n\t\tisp: str(i['isp']),\n\t\torg: str(i['org']),\n\t\tasn: upperCode(i['asn'], /^AS\\d{1,10}$/i),\n\t\tmobile: bool(i['mobile']),\n\t\tproxy: bool(i['proxy']),\n\t\thosting: bool(i['hosting']),\n\t};\n\t// Drop nulls so the stored JSON carries only what is known.\n\tconst known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null)) as IRequestLocation;\n\treturn Object.keys(known).length > 0 ? known : null;\n}\n\n/** Run the resolver bounded by a timeout; never throws. */\n// One resolution per request: a sign-in writes a login-event row AND a session\n// row, and both ask. Keyed on the request's Headers object (unique per\n// request, collected with it), so the resolver — possibly a paid API — runs\n// once.\nconst perRequest = new WeakMap<Headers, { resolver: LocationResolver; result: Promise<IRequestLocation | null> }>();\n\nexport function resolveLocation(\n\tresolver: LocationResolver | undefined,\n\treq: ILocationRequest,\n\ttimeoutMs = LOCATION_TIMEOUT_MS,\n): Promise<IRequestLocation | null> {\n\tif (!resolver) return Promise.resolve(null);\n\tconst cached = perRequest.get(req.headers);\n\tif (cached && cached.resolver === resolver) return cached.result;\n\tconst result = resolveOnce(resolver, req, timeoutMs);\n\tperRequest.set(req.headers, { resolver, result });\n\treturn result;\n}\n\nasync function resolveOnce(\n\tresolver: LocationResolver,\n\treq: ILocationRequest,\n\ttimeoutMs: number,\n): Promise<IRequestLocation | null> {\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\ttry {\n\t\tconst result = await Promise.race([\n\t\t\tPromise.resolve().then(() => resolver(req)),\n\t\t\tnew Promise<null>((resolve) => {\n\t\t\t\ttimer = setTimeout(() => resolve(null), timeoutMs);\n\t\t\t}),\n\t\t]);\n\t\treturn sanitizeLocation(result);\n\t} catch (err) {\n\t\tconsole.warn('[auth] location resolver failed; recording without a location:', err);\n\t\treturn null;\n\t} finally {\n\t\tif (timer) clearTimeout(timer);\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\n\nexport class SessionModel {\n\t// `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate locate?: LocationResolver,\n\t) {}\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\tconst location =\n\t\t\tthis.locate && meta?.headers\n\t\t\t\t? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers })\n\t\t\t\t: null;\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\ttoken,\n\t\t\t\texpiresAt,\n\t\t\t\tsid ?? null,\n\t\t\t\tmeta?.userAgent ?? null,\n\t\t\t\tmeta?.ipAddress ?? null,\n\t\t\t\tlocation ? JSON.stringify(location) : null,\n\t\t\t],\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\tlocation: IRequestLocation | 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 location,\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,yBAA2B;AAC3B,0BAAsC;AAwF/B,SAAS,YACf,OACA,QAC0C;AAC1C,MAAI;AACH,WAAO,oBAAAA,QAAI,OAAO,OAAO,OAAO,SAAS;AAAA,EAC1C,QAAQ;AACP,WAAO;AAAA,EACR;AACD;;;ACrFA,IAAM,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwBd,IAAM,YAAN,MAAgB;AAAA,EACtB,YAAoB,OAAsB;AAAtB;AAAA,EAAuB;AAAA,EAAvB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMpB,MAAM,KAAK,QAAwB,CAAC,GAAuB;AAC1D,UAAM,QAAQ,KAAK,IAAI,MAAM,SAAS,IAAI,cAAc;AACxD,UAAM,SAAoB,CAAC;AAC3B,UAAM,QAAkB,CAAC,MAAM,UAAU,2BAA2B,oBAAoB;AAExF,QAAI,MAAM,QAAQ;AACjB,aAAO,KAAK,MAAM,OAAO,WAAW,MAAM,OAAO,EAAE;AACnD,YAAM,KAAK,wBAAwB,OAAO,SAAS,CAAC,mBAAmB,OAAO,MAAM,SAAS;AAAA,IAC9F;AAEA,WAAO,KAAK,QAAQ,CAAC;AACrB,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B,UAAU,YAAY;AAAA;AAAA,aAEZ,MAAM,KAAK,OAAO,CAAC;AAAA;AAAA,cAElB,OAAO,MAAM;AAAA,MACxB;AAAA,IACD;AACA,WAAO,EAAE,OAAO,KAAK,MAAM,GAAG,KAAK,GAAG,SAAS,KAAK,SAAS,MAAM;AAAA,EACpE;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,yBAAyB,IAAmC;AACjE,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,UAAU,YAAY;AAAA,MACtB,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,SAAS,IAAmC;AACjD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,UAAU,YAAY;AAAA,MACtB,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,YAAY,OAAsC;AACvD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,UAAU,YAAY;AAAA,MACtB,CAAC,KAAK;AAAA,IACP;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,YAAY,OAAsC;AACvD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,UAAU,YAAY;AAAA,MACtB,CAAC,KAAK;AAAA,IACP;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,oBACL,OACA,YAA2B,MAC3B,WAA0B,MAE1B,SAAwB,MACE;AAC1B,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAOA,CAAC,OAAO,WAAW,UAAU,MAAM;AAAA,IACpC;AACA,WAAO;AAAA,EACR;AAAA,EAEA,MAAM,OACL,OACA,cACA,WACA,UAGA,SAAwB,MACS;AACjC,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA;AAAA;AAAA,MAGA,CAAC,MAAM,YAAY,EAAE,KAAK,GAAG,cAAc,WAAW,UAAU,MAAM;AAAA,IACvE;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,OAAO,IAAY,QAA2D;AACnF,UAAM,YAAoC;AAAA,MACzC,WAAW;AAAA,MACX,UAAU;AAAA,MACV,aAAa;AAAA,MACb,WAAW;AAAA,MACX,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,aAAa;AAAA,IACd;AAEA,UAAM,OAAiB,CAAC;AACxB,UAAM,SAAoB,CAAC;AAE3B,eAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,SAAS,GAAG;AACnD,UAAK,OAAmC,GAAG,MAAM,QAAW;AAC3D,eAAO,KAAM,OAAmC,GAAG,CAAC;AACpD,aAAK,KAAK,GAAG,GAAG,OAAO,OAAO,MAAM,EAAE;AAAA,MACvC;AAAA,IACD;AAEA,WAAO,KAAK,EAAE;AACd,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,6BAA6B,KAAK,KAAK,IAAI,CAAC,oCAAoC,OAAO,MAAM;AAAA,MAC7F;AAAA,IACD;AACA,WAAO,OAAO;AAAA,EACf;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,MAAM,cAAc,IAA8B;AACjD,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMA,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA,EAEA,MAAM,eAAe,IAAY,cAAqC;AACrE,UAAM,KAAK,MAAM,MAAM,8DAA8D;AAAA,MACpF;AAAA,MACA;AAAA,IACD,CAAC;AAAA,EACF;AAAA,EAEA,MAAM,kBAAkB,IAA2B;AAClD,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA;AAAA;AAAA,EAIA,MAAM,aAAa,IAAY,WAAsC;AACpE,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,IAAI,SAAS;AAAA,IACf;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA,EAEA,MAAM,WAAW,IAA2B;AAC3C,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA,EAEA,MAAM,cAAc,IAAY,QAA+B;AAC9D,UAAM,KAAK,MAAM,MAAM,2DAA2D,CAAC,QAAQ,EAAE,CAAC;AAAA,EAC/F;AAAA,EAEA,MAAM,qBAAqB,IAAY,QAA+B;AACrE,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA;AAAA;AAAA,MAGA,CAAC,QAAQ,EAAE;AAAA,IACZ;AAAA,EACD;AAAA,EAEA,MAAM,oBAAoB,IAAoC;AAC7D,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA;AAAA,MAEA,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,KAAK,sBAAsB;AAAA,EACnC;AAAA,EAEA,MAAM,iBAAiB,IAA2B;AACjD,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAOA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA,EAEA,MAAM,UAAU,IAA2B;AAC1C,UAAM,KAAK,MAAM,MAAM,8DAA8D,CAAC,EAAE,CAAC;AAAA,EAC1F;AAAA,EAEA,MAAM,WAAW,IAA2B;AAC3C,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA,EAEA,MAAM,YAAY,IAAY,OAA8B;AAC3D,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,MAAM,YAAY,EAAE,KAAK,GAAG,EAAE;AAAA,IAChC;AAAA,EACD;AAAA,EAEA,MAAM,YAAY,IAAY,OAA8B;AAC3D,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,OAAO,EAAE;AAAA,IACX;AAAA,EACD;AAAA,EAEA,MAAM,kBACL,IACA,QAKiC;AACjC,UAAM,OAAiB,CAAC;AACxB,UAAM,SAAoB,CAAC;AAE3B,QAAI,OAAO,WAAW,QAAW;AAChC,aAAO,KAAK,OAAO,MAAM;AACzB,WAAK,KAAK,aAAa,OAAO,MAAM,EAAE;AAAA,IACvC;AACA,QAAI,OAAO,aAAa,QAAW;AAClC,aAAO,KAAK,OAAO,QAAQ;AAC3B,WAAK,KAAK,eAAe,OAAO,MAAM,EAAE;AAAA,IACzC;AACA,QAAI,OAAO,SAAS,OAAO,KAAK,OAAO,KAAK,EAAE,SAAS,GAAG;AACzD,aAAO,KAAK,KAAK,UAAU,OAAO,KAAK,CAAC;AACxC,WAAK,KAAK,iCAAiC,OAAO,MAAM,SAAS;AAAA,IAClE;AAEA,QAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,WAAO,KAAK,EAAE;AACd,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,6BAA6B,KAAK,KAAK,IAAI,CAAC,oCAAoC,OAAO,MAAM;AAAA,MAC7F;AAAA,IACD;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,aAAa,IAAoC;AACtD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,KAAK,cAAc;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmCA,MAAM,iBACL,OACA,UACA,YAMS;AACT,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAM9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MA8BA,CAAC,OAAO,UAAU,UAAU;AAAA,IAC7B;AACA,WAAO,OAAO;AAAA,EACf;AACD;AAEA,IAAM,iBAAiB;;;AC7VvB,IAAM,sBAAsB;AAE5B,IAAM,WAAW;AAEjB,SAAS,IAAI,GAAY,IAA4B;AACpD,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,IAAI,EAAE,KAAK;AACjB,MAAI,EAAE,WAAW,KAAK,EAAE,SAAS,SAAU,QAAO;AAClD,SAAO,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,OAAO;AACnC;AACA,SAAS,UAAU,GAAY,IAA2B;AACzD,QAAM,IAAI,IAAI,GAAG,EAAE;AACnB,SAAO,IAAI,EAAE,YAAY,IAAI;AAC9B;AACA,SAAS,KAAK,GAA4B;AACzC,SAAO,OAAO,MAAM,YAAY,IAAI;AACrC;AAGA,SAAS,MAAM,GAAY,OAA8B;AACxD,MAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,KAAK,KAAK,IAAI,CAAC,IAAI,MAAO,QAAO;AAChF,SAAO,KAAK,MAAM,IAAI,GAAG,IAAI;AAC9B;AAIA,SAAS,OAAO,GAA2B;AAC1C,MAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,KAAK,KAAK,KAAK,IAAI,IAAQ,QAAO;AACjF,SAAO,KAAK,MAAM,CAAC;AACpB;AAGA,SAAS,QAAQ,GAA2B;AAC3C,QAAM,IAAI,OAAO,MAAM,YAAY,aAAa,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI;AACtE,SAAO,OAAO,MAAM,YAAY,OAAO,UAAU,CAAC,KAAK,IAAI,KAAK,KAAK,aAAgB,IAAI;AAC1F;AAGO,SAAS,iBAAiB,OAAyC;AACzE,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,IAAI;AACV,QAAM,MAAwB;AAAA,IAC7B,SAAS,UAAU,EAAE,SAAS,GAAG,eAAe;AAAA,IAChD,aAAa,IAAI,EAAE,aAAa,CAAC;AAAA,IACjC,aAAa,UAAU,EAAE,aAAa,GAAG,oBAAoB;AAAA,IAC7D,iBAAiB,IAAI,EAAE,iBAAiB,CAAC;AAAA,IACzC,MAAM,IAAI,EAAE,MAAM,CAAC;AAAA,IACnB,YAAY,UAAU,EAAE,YAAY,GAAG,kCAAkC;AAAA,IACzE,WAAW,UAAU,EAAE,WAAW,GAAG,eAAe;AAAA,IACpD,UAAU,IAAI,EAAE,UAAU,GAAG,wCAAwC;AAAA,IACrE,UAAU,MAAM,EAAE,UAAU,GAAG,EAAE;AAAA,IACjC,WAAW,MAAM,EAAE,WAAW,GAAG,GAAG;AAAA,IACpC,gBAAgB,OAAO,EAAE,gBAAgB,CAAC;AAAA,IAC1C,WAAW,QAAQ,EAAE,WAAW,CAAC;AAAA,IACjC,KAAK,IAAI,EAAE,KAAK,CAAC;AAAA,IACjB,KAAK,IAAI,EAAE,KAAK,CAAC;AAAA,IACjB,KAAK,UAAU,EAAE,KAAK,GAAG,eAAe;AAAA,IACxC,QAAQ,KAAK,EAAE,QAAQ,CAAC;AAAA,IACxB,OAAO,KAAK,EAAE,OAAO,CAAC;AAAA,IACtB,SAAS,KAAK,EAAE,SAAS,CAAC;AAAA,EAC3B;AAEA,QAAM,QAAQ,OAAO,YAAY,OAAO,QAAQ,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,MAAM,MAAM,IAAI,CAAC;AAClF,SAAO,OAAO,KAAK,KAAK,EAAE,SAAS,IAAI,QAAQ;AAChD;AAOA,IAAM,aAAa,oBAAI,QAA2F;AAE3G,SAAS,gBACf,UACA,KACA,YAAY,qBACuB;AACnC,MAAI,CAAC,SAAU,QAAO,QAAQ,QAAQ,IAAI;AAC1C,QAAM,SAAS,WAAW,IAAI,IAAI,OAAO;AACzC,MAAI,UAAU,OAAO,aAAa,SAAU,QAAO,OAAO;AAC1D,QAAM,SAAS,YAAY,UAAU,KAAK,SAAS;AACnD,aAAW,IAAI,IAAI,SAAS,EAAE,UAAU,OAAO,CAAC;AAChD,SAAO;AACR;AAEA,eAAe,YACd,UACA,KACA,WACmC;AACnC,MAAI;AACJ,MAAI;AACH,UAAM,SAAS,MAAM,QAAQ,KAAK;AAAA,MACjC,QAAQ,QAAQ,EAAE,KAAK,MAAM,SAAS,GAAG,CAAC;AAAA,MAC1C,IAAI,QAAc,CAAC,YAAY;AAC9B,gBAAQ,WAAW,MAAM,QAAQ,IAAI,GAAG,SAAS;AAAA,MAClD,CAAC;AAAA,IACF,CAAC;AACD,WAAO,iBAAiB,MAAM;AAAA,EAC/B,SAAS,KAAK;AACb,YAAQ,KAAK,kEAAkE,GAAG;AAClF,WAAO;AAAA,EACR,UAAE;AACD,QAAI,MAAO,cAAa,KAAK;AAAA,EAC9B;AACD;;;ACpKO,IAAM,eAAN,MAAmB;AAAA;AAAA,EAEzB,YACS,OACA,QACP;AAFO;AACA;AAAA,EACN;AAAA,EAFM;AAAA,EACA;AAAA,EAGT,MAAM,OACL,QACA,OACA,WACA,KACA,MACgB;AAChB,UAAM,WACL,KAAK,UAAU,MAAM,UAClB,MAAM,gBAAgB,KAAK,QAAQ,EAAE,IAAI,KAAK,WAAW,SAAS,KAAK,QAAQ,CAAC,IAChF;AACJ,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA;AAAA;AAAA,MAGA;AAAA,QACC;AAAA,QACA;AAAA,QACA;AAAA,QACA,OAAO;AAAA,QACP,MAAM,aAAa;AAAA,QACnB,MAAM,aAAa;AAAA,QACnB,WAAW,KAAK,UAAU,QAAQ,IAAI;AAAA,MACvC;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAM,OAAO,OAA8B;AAC1C,UAAM,KAAK,MAAM,MAAM,kDAAkD,CAAC,KAAK,CAAC;AAAA,EACjF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,YAAY,KAA4B;AAC7C,UAAM,KAAK,MAAM,MAAM,gDAAgD,CAAC,GAAG,CAAC;AAAA,EAC7E;AAAA;AAAA;AAAA,EAIA,MAAM,aAAa,QAA+B;AACjD,UAAM,KAAK,MAAM,MAAM,oDAAoD,CAAC,MAAM,CAAC;AAAA,EACpF;AAAA;AAAA,EAGA,MAAM,WAAW,QAEf;AACD,WAAO,KAAK,MAAM;AAAA,MACjB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAQA,CAAC,MAAM;AAAA,IACR;AAAA,EACD;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,eAAe,QAUnB;AACD,WAAO,KAAK,MAAM;AAAA,MACjB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MASA,CAAC,MAAM;AAAA,IACR;AAAA,EACD;AAAA;AAAA;AAAA,EAIA,MAAM,cAAc,QAAgB,IAA8B;AACjE,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,IAAI,MAAM;AAAA,IACZ;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA;AAAA;AAAA,EAIA,MAAM,gBAAgB,QAAgB,SAAkC;AACvE,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA;AAAA;AAAA,MAGA,CAAC,QAAQ,OAAO;AAAA,IACjB;AACA,WAAO,KAAK;AAAA,EACb;AAAA,EAEA,MAAM,OAAO,OAAiC;AAC7C,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,KAAK;AAAA,IACP;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA;AAAA,EAGA,MAAM,WAAW,KAA+B;AAC/C,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,GAAG;AAAA,IACL;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AACD;;;AChIO,SAAS,YAAY,OAAsB,QAAiC;AAClF,QAAM,QAAQ,IAAI,UAAU,KAAK;AACjC,QAAM,WAAW,IAAI,aAAa,KAAK;AAEvC,SAAO,OAAO,KAAK,SAAS;AAC3B,UAAM,QAAQ,aAAa,IAAI,OAAO;AACtC,QAAI,CAAC,OAAO;AACX,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,UAAU,YAAY,OAAO,MAAM;AACzC,QAAI,CAAC,WAAW,QAAQ,SAAS,UAAU;AAC1C,aAAO,KAAK;AAAA,IACb;AAQA,QAAI,QAAQ,OAAO,CAAE,MAAM,SAAS,WAAW,QAAQ,GAAG,GAAI;AAC7D,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS,QAAQ,GAAG;AAC7C,QAAI,CAAC,QAAQ,KAAK,aAAa,KAAK,WAAW;AAC9C,aAAO,KAAK;AAAA,IACb;AAEA,WAAO,OAAO,KAAK;AAAA,MAClB,MAAM;AAAA,QACL,GAAG;AAAA,QACH,aAAa,QAAQ,eAAe;AAAA,QACpC,eAAe,QAAQ,iBAAiB;AAAA,QACxC,YAAa,QAA2B,cAAc;AAAA;AAAA;AAAA,QAGtD,KAAK,QAAQ,OAAO;AAAA,MACrB;AAAA,IACD,CAAC;AAED,WAAO,KAAK;AAAA,EACb;AACD;AAEA,SAAS,aAAa,SAAiC;AACtD,QAAM,OAAO,QAAQ,QAAQ,IAAI,eAAe;AAChD,MAAI,MAAM,WAAW,SAAS,GAAG;AAChC,WAAO,KAAK,MAAM,CAAC;AAAA,EACpB;AAEA,QAAM,SAAS,QAAQ,QAAQ,IAAI,QAAQ,KAAK;AAChD,QAAM,QAAQ,OAAO,MAAM,gCAAgC;AAC3D,SAAO,QAAQ,CAAC,KAAK;AACtB;;;AClEA,yBAA4C;;;ANG5C,IAAM,cAAc;AAAA,EACnB;AAAA,EACA;AACD;AAEA,IAAO,sBAAQ;","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/services/request-location.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 { createHash, 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\t// Keep an existing session id (a refresh rotates the same device session);\n\t// absent ⇒ a new session.\n\tsid?: string;\n}\n\n// ── Key ring ──────────────────────────────────────────────────────────────\n// Tokens are signed with the CURRENT secret and carry its key id (`kid`).\n// Verification picks the secret by kid among [jwtSecret, ...jwtPreviousSecrets],\n// so rotating the secret signs nobody out: move the old one to\n// jwtPreviousSecrets, deploy, and drop it once the longest-lived token signed\n// with it has expired. The kid is derived from the secret — stable, no extra\n// config, and a truncated hash reveals nothing usable.\nexport function keyIdOf(secret: string): string {\n\treturn createHash('sha256').update(secret).digest('hex').slice(0, 16);\n}\n\nfunction keyRing(config: IAuthConfig): string[] {\n\treturn [config.jwtSecret, ...(config.jwtPreviousSecrets ?? [])].filter((s): s is string => typeof s === 'string' && s.length > 0);\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', keyid: keyIdOf(config.jwtSecret) },\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 = options.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// jwtid: every token is unique. With the session id kept across rotations,\n\t\t// two tokens issued in the same second would otherwise be byte-identical —\n\t\t// and a rotated-away token indistinguishable from the live one.\n\t\t{ expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() } 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, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() } 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\tconst ring = keyRing(config);\n\tconst kid = (jwt.decode(token, { complete: true }) as { header?: { kid?: unknown } } | null)?.header?.kid;\n\t// A kid names exactly one secret; a token without one (signed before key\n\t// ids) is tried against each. An unknown kid verifies against nothing.\n\tconst candidates = typeof kid === 'string' ? ring.filter((s) => keyIdOf(s) === kid) : ring;\n\tfor (const secret of candidates) {\n\t\ttry {\n\t\t\treturn jwt.verify(token, secret) as IAccessPayload | IRefreshPayload;\n\t\t} catch {\n\t\t\t// wrong key, bad signature or expired — try the next candidate\n\t\t}\n\t}\n\treturn null;\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\t// One page of users, newest first. Keyset-paginated on (created_at, id) —\n\t// the same cursor contract as login history and the audit log. The +1\n\t// over-fetch that detects a next page lives here so no outer clamp can\n\t// shave it off. Soft-deleted rows are excluded, as in every other finder.\n\tasync list(query: IUserListQuery = {}): Promise<IUserPage> {\n\t\tconst limit = Math.min(query.limit ?? 50, MAX_LIST_LIMIT);\n\t\tconst params: unknown[] = [];\n\t\tconst where: string[] = [query.deleted ? 'deleted_at IS NOT NULL' : 'deleted_at IS NULL'];\n\n\t\tif (query.cursor) {\n\t\t\tparams.push(query.cursor.createdAt, query.cursor.id);\n\t\t\twhere.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);\n\t\t}\n\n\t\tparams.push(limit + 1);\n\t\tconst rows = await this.store.query<IUser & { createdAtRaw?: string }>(\n\t\t\t`SELECT ${USER_COLUMNS}, created_at::text AS \"createdAtRaw\"\n\t\t\t FROM fonderie_users\n\t\t\t WHERE ${where.join(' AND ')}\n\t\t\t ORDER BY created_at DESC, id DESC\n\t\t\t LIMIT $${params.length}`,\n\t\t\tparams,\n\t\t);\n\t\treturn { users: rows.slice(0, limit), hasMore: rows.length > limit };\n\t}\n\n\t// For operator views only: a soft-deleted account still resolves, with\n\t// deletedAt set, so the console can say \"deleted on …\" instead of \"no such\n\t// user\". Nothing that authenticates or acts on an account may use this.\n\tasync findByIdIncludingDeleted(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`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\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\t// Only on insert: an existing account keeps the locale it chose.\n\t\tlocale: 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, locale)\n\t\t\tVALUES ($1, $2, $3, COALESCE($4, 'en-US'))\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, locale],\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\t// The sign-up language, else the app's system locale; null keeps the\n\t\t// column default (en-US) for callers that predate locales.\n\t\tlocale: string | null = 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, locale)\n\t\t\tVALUES ($1, $2, $3, $4, COALESCE($5, 'en-US'))\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName, locale],\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\t// The operator's lock: login, refresh and the session middleware all refuse a\n\t// suspended user. Returns false when there is no such user.\n\tasync setSuspended(id: string, suspended: boolean): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET suspended = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL RETURNING id`,\n\t\t\t[id, suspended],\n\t\t);\n\t\treturn rows.length > 0;\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\nconst MAX_LIST_LIMIT = 200;\n\nexport interface IUserListQuery {\n\tlimit?: number;\n\tcursor?: { createdAt: string; id: string };\n\t// true ⇒ ONLY soft-deleted accounts (an operator view; every other finder\n\t// excludes them). They stay until the retention purge hard-deletes them.\n\tdeleted?: boolean;\n}\n\nexport interface IUserPage {\n\t// Carries createdAtRaw (created_at::text) so the cursor keeps the\n\t// microsecond precision a Date round-trip would drop.\n\tusers: Array<IUser & { createdAtRaw?: string }>;\n\thasMore: boolean;\n}\n","import { createHash } from 'node:crypto';\n\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\n\n// What `fonderie_sessions.token` holds: the SHA-256 of the refresh token, so a\n// database dump contains no working refresh token. Lookups also accept the raw\n// value, for rows written before migration 020 hashed them.\nexport function hashRefreshToken(token: string): string {\n\treturn createHash('sha256').update(token).digest('hex');\n}\n\n/** How long a just-rotated refresh token stays valid: a retry or a race. */\nexport const REFRESH_GRACE_MS = 30_000;\n\nexport interface ISessionRow {\n\tid: string;\n\tuserId: string;\n\tsid: string | null;\n\t/** What the stored `token` column holds now (a hash, or a legacy raw token). */\n\tstored: string;\n}\n\n/** How a presented refresh token relates to the sessions table. */\nexport type RefreshMatch =\n\t| { kind: 'current'; row: ISessionRow }\n\t| { kind: 'grace'; row: ISessionRow }\n\t| { kind: 'reused'; row: ISessionRow }\n\t| { kind: 'unknown' };\n\nexport class SessionModel {\n\t// `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate locate?: LocationResolver,\n\t) {}\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\tconst location =\n\t\t\tthis.locate && meta?.headers\n\t\t\t\t? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers })\n\t\t\t\t: null;\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, now())\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\thashRefreshToken(token),\n\t\t\t\texpiresAt,\n\t\t\t\tsid ?? null,\n\t\t\t\tmeta?.userAgent ?? null,\n\t\t\t\tmeta?.ipAddress ?? null,\n\t\t\t\tlocation ? JSON.stringify(location) : null,\n\t\t\t],\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 IN ($1, $2)`, [hashRefreshToken(token), token]);\n\t}\n\n\t/**\n\t * Where a presented refresh token stands: the session's current token, its\n\t * previous one within the grace (a retry or race), its previous one AFTER\n\t * the grace (reuse — a theft signal), or nothing known.\n\t */\n\tasync match(token: string): Promise<RefreshMatch> {\n\t\tconst hash = hashRefreshToken(token);\n\t\tconst [current] = await this.store.query<{ id: string; user_id: string; sid: string | null; token: string }>(\n\t\t\t`SELECT id, user_id, sid, token FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,\n\t\t\t[hash, token],\n\t\t);\n\t\tif (current) return { kind: 'current', row: toRow(current) };\n\t\tconst [previous] = await this.store.query<{ id: string; user_id: string; sid: string | null; token: string; in_grace: boolean }>(\n\t\t\t`SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace\n\t\t\t FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,\n\t\t\t[hash],\n\t\t);\n\t\tif (!previous) return { kind: 'unknown' };\n\t\treturn { kind: previous.in_grace ? 'grace' : 'reused', row: toRow(previous) };\n\t}\n\n\t/**\n\t * Rotate a session in place to a new refresh token: same row, same device.\n\t * From the CURRENT token it is optimistic — only if the stored token is\n\t * still the one matched; a concurrent rotation makes it return false and the\n\t * caller re-matches (and now finds the token as the previous one). The old\n\t * token becomes the previous, valid for the grace. From the PREVIOUS token\n\t * (within the grace) it is authorised by that hash and deadline alone, so\n\t * racing retries never starve; the previous and its deadline are kept.\n\t */\n\tasync rotate(\n\t\trow: ISessionRow,\n\t\tpresented: string,\n\t\tnext: { token: string; expiresAt: Date },\n\t\tfrom: 'current' | 'grace',\n\t): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_sessions\n\t\t\t SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,\n\t\t\t previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,\n\t\t\t token = $3,\n\t\t\t expires_at = $4,\n\t\t\t last_used_at = now()\n\t\t\t WHERE id = $1\n\t\t\t AND CASE WHEN $5 THEN token = $2\n\t\t\t ELSE previous_token_hash = $6 AND previous_valid_until > now() END\n\t\t\tRETURNING id`,\n\t\t\t[row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === 'current', hashRefreshToken(presented), String(REFRESH_GRACE_MS)],\n\t\t);\n\t\treturn rows.length === 1;\n\t}\n\n\t/** Revoke one session by its row id (any user) — reuse detection, admin. */\n\tasync revokeById(id: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);\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\tlocation: IRequestLocation | 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 location,\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 IN ($1, $2) AND expires_at > now()`,\n\t\t\t[hashRefreshToken(token), 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\nfunction toRow(r: { id: string; user_id: string; sid: string | null; token: string }): ISessionRow {\n\treturn { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };\n}\n","// Optional location enrichment for auth events (login history, registrations,\n// sessions).\n//\n// Auth never resolves a location itself and imports no geo code: the app hands\n// in a resolver (IAuthConfig.location). Typical choices — the platform's\n// edge headers via @fonderie/geo's geoFromHeaders (zero infrastructure), a\n// self-hosted table, or a hosted IP API that also knows the network (ISP, ASN,\n// proxy/VPN, hosting). Field names match @fonderie/geo's GeoLocation, so its\n// result can be returned as-is.\n//\n// Whatever the resolver returns is treated as untrusted input: every field is\n// type-checked and bounded before it is stored, and a resolver that throws or\n// stalls costs the login nothing — the row is written without a location.\n\n/** Where a login attempt came from. Every field is optional; unknown is null. */\nexport interface IRequestLocation {\n\tcountry?: string | null; // ISO-3166-1 alpha-2\n\tcountryName?: string | null;\n\tsubdivision?: string | null; // ISO-3166-2 region part (QC, CA, ENG)\n\tsubdivisionName?: string | null;\n\tcity?: string | null;\n\t/** Postal / ZIP code as the provider reports it (94043, K1A 0B1, SW1A). Approximate. */\n\tpostalCode?: string | null;\n\tcontinent?: string | null; // two-letter continent code\n\ttimeZone?: string | null; // IANA name\n\tlatitude?: number | null;\n\tlongitude?: number | null;\n\t/** How far off the point may be, in kilometres (MaxMind's accuracy_radius).\n\t * Lets a UI say \"near Mountain View (±20 km)\" instead of implying precision. */\n\taccuracyRadius?: number | null;\n\t/** A stable, language-neutral place key (MaxMind/GeoNames id). Lets a reader\n\t * see the stored place in their own language later, and lets an app map it\n\t * to its own regions. Names above are a snapshot; this is the reference. */\n\tgeonameId?: number | null;\n\t/** Network facts — only resolvers backed by an IP-intelligence API know these. */\n\tisp?: string | null;\n\torg?: string | null;\n\tasn?: string | null; // e.g. \"AS15169\"\n\tmobile?: boolean | null;\n\tproxy?: boolean | null;\n\thosting?: boolean | null;\n}\n\nexport interface ILocationRequest {\n\t/** The resolved client IP (adapter-resolved, proxy-aware), or null. */\n\tip: string | null;\n\t/** The incoming request's headers — for platform geolocation headers. */\n\theaders: Headers;\n}\n\nexport type LocationResolver = (\n\treq: ILocationRequest,\n) => IRequestLocation | null | undefined | Promise<IRequestLocation | null | undefined>;\n\n// How long auth waits for the resolver before writing the row without a\n// location. Internal, deliberately not exported. It is zero-cost for in-process\n// resolvers (edge headers return in microseconds) and exists for the ones that\n// are not: @fonderie/geo's PostgresGeoProvider does a DB query that can hang\n// on an exhausted pool, and an app may choose a hosted IP-intelligence API for\n// richer data (ISP, ASN, proxy/VPN, accuracy radius). A legitimate lookup\n// answers in a few to ~150 ms; the session write that triggers resolution is\n// on the login path, so the cap bounds the worst-case delay a slow provider\n// can add to a sign-in.\nconst LOCATION_TIMEOUT_MS = 500;\n\nconst TEXT_MAX = 128;\n\nfunction str(v: unknown, re?: RegExp): string | null {\n\tif (typeof v !== 'string') return null;\n\tconst t = v.trim();\n\tif (t.length === 0 || t.length > TEXT_MAX) return null;\n\treturn re && !re.test(t) ? null : t;\n}\nfunction upperCode(v: unknown, re: RegExp): string | null {\n\tconst t = str(v, re);\n\treturn t ? t.toUpperCase() : null;\n}\nfunction bool(v: unknown): boolean | null {\n\treturn typeof v === 'boolean' ? v : null;\n}\n// Two decimals ≈ 1 km: plenty for \"which city\", and a login history should not\n// keep more precision than the city it names.\nfunction coord(v: unknown, limit: number): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || Math.abs(v) > limit) return null;\n\treturn Math.round(v * 100) / 100;\n}\n\n// Kilometres, a positive whole number. MaxMind's values run 1–1000; anything\n// beyond the planet's half-circumference is not a radius.\nfunction radius(v: unknown): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || v > 20_000) return null;\n\treturn Math.round(v);\n}\n\n// GeoNames ids are positive integers well inside 2^31.\nfunction placeId(v: unknown): number | null {\n\tconst n = typeof v === 'string' && /^\\d{1,10}$/.test(v) ? Number(v) : v;\n\treturn typeof n === 'number' && Number.isInteger(n) && n > 0 && n <= 2_147_483_647 ? n : null;\n}\n\n/** Keep only well-formed, bounded fields. Returns null when nothing survives. */\nexport function sanitizeLocation(input: unknown): IRequestLocation | null {\n\tif (!input || typeof input !== 'object') return null;\n\tconst i = input as Record<string, unknown>;\n\tconst out: IRequestLocation = {\n\t\tcountry: upperCode(i['country'], /^[A-Za-z]{2}$/),\n\t\tcountryName: str(i['countryName']),\n\t\tsubdivision: upperCode(i['subdivision'], /^[A-Za-z0-9]{1,3}$/),\n\t\tsubdivisionName: str(i['subdivisionName']),\n\t\tcity: str(i['city']),\n\t\tpostalCode: upperCode(i['postalCode'], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),\n\t\tcontinent: upperCode(i['continent'], /^[A-Za-z]{2}$/),\n\t\ttimeZone: str(i['timeZone'], /^(UTC|[A-Za-z_]+(\\/[A-Za-z0-9_+-]+)+)$/),\n\t\tlatitude: coord(i['latitude'], 90),\n\t\tlongitude: coord(i['longitude'], 180),\n\t\taccuracyRadius: radius(i['accuracyRadius']),\n\t\tgeonameId: placeId(i['geonameId']),\n\t\tisp: str(i['isp']),\n\t\torg: str(i['org']),\n\t\tasn: upperCode(i['asn'], /^AS\\d{1,10}$/i),\n\t\tmobile: bool(i['mobile']),\n\t\tproxy: bool(i['proxy']),\n\t\thosting: bool(i['hosting']),\n\t};\n\t// Drop nulls so the stored JSON carries only what is known.\n\tconst known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null)) as IRequestLocation;\n\treturn Object.keys(known).length > 0 ? known : null;\n}\n\n/** Run the resolver bounded by a timeout; never throws. */\n// One resolution per request: a sign-in writes a login-event row AND a session\n// row, and both ask. Keyed on the request's Headers object (unique per\n// request, collected with it), so the resolver — possibly a paid API — runs\n// once.\nconst perRequest = new WeakMap<Headers, { resolver: LocationResolver; result: Promise<IRequestLocation | null> }>();\n\nexport function resolveLocation(\n\tresolver: LocationResolver | undefined,\n\treq: ILocationRequest,\n\ttimeoutMs = LOCATION_TIMEOUT_MS,\n): Promise<IRequestLocation | null> {\n\tif (!resolver) return Promise.resolve(null);\n\tconst cached = perRequest.get(req.headers);\n\tif (cached && cached.resolver === resolver) return cached.result;\n\tconst result = resolveOnce(resolver, req, timeoutMs);\n\tperRequest.set(req.headers, { resolver, result });\n\treturn result;\n}\n\nasync function resolveOnce(\n\tresolver: LocationResolver,\n\treq: ILocationRequest,\n\ttimeoutMs: number,\n): Promise<IRequestLocation | null> {\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\ttry {\n\t\tconst result = await Promise.race([\n\t\t\tPromise.resolve().then(() => resolver(req)),\n\t\t\tnew Promise<null>((resolve) => {\n\t\t\t\ttimer = setTimeout(() => resolve(null), timeoutMs);\n\t\t\t}),\n\t\t]);\n\t\treturn sanitizeLocation(result);\n\t} catch (err) {\n\t\tconsole.warn('[auth] location resolver failed; recording without a location:', err);\n\t\treturn null;\n\t} finally {\n\t\tif (timer) clearTimeout(timer);\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","import_node_crypto"]}
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
// src/services/jwt.ts
|
|
2
|
-
import { randomUUID } from "crypto";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
3
|
import jwt from "jsonwebtoken";
|
|
4
|
+
function keyIdOf(secret) {
|
|
5
|
+
return createHash("sha256").update(secret).digest("hex").slice(0, 16);
|
|
6
|
+
}
|
|
7
|
+
function keyRing(config) {
|
|
8
|
+
return [config.jwtSecret, ...config.jwtPreviousSecrets ?? []].filter((s) => typeof s === "string" && s.length > 0);
|
|
9
|
+
}
|
|
4
10
|
function verifyToken(token, config) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
const ring = keyRing(config);
|
|
12
|
+
const kid = jwt.decode(token, { complete: true })?.header?.kid;
|
|
13
|
+
const candidates = typeof kid === "string" ? ring.filter((s) => keyIdOf(s) === kid) : ring;
|
|
14
|
+
for (const secret of candidates) {
|
|
15
|
+
try {
|
|
16
|
+
return jwt.verify(token, secret);
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
9
19
|
}
|
|
20
|
+
return null;
|
|
10
21
|
}
|
|
11
22
|
|
|
12
23
|
// src/models/user.model.ts
|
|
@@ -345,6 +356,9 @@ var UserModel = class {
|
|
|
345
356
|
};
|
|
346
357
|
var MAX_LIST_LIMIT = 200;
|
|
347
358
|
|
|
359
|
+
// src/models/session.model.ts
|
|
360
|
+
import { createHash as createHash2 } from "crypto";
|
|
361
|
+
|
|
348
362
|
// src/services/request-location.ts
|
|
349
363
|
var LOCATION_TIMEOUT_MS = 500;
|
|
350
364
|
var TEXT_MAX = 128;
|
|
@@ -427,6 +441,10 @@ async function resolveOnce(resolver, req, timeoutMs) {
|
|
|
427
441
|
}
|
|
428
442
|
|
|
429
443
|
// src/models/session.model.ts
|
|
444
|
+
function hashRefreshToken(token) {
|
|
445
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
446
|
+
}
|
|
447
|
+
var REFRESH_GRACE_MS = 3e4;
|
|
430
448
|
var SessionModel = class {
|
|
431
449
|
// `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
|
|
432
450
|
constructor(store, locate) {
|
|
@@ -438,12 +456,12 @@ var SessionModel = class {
|
|
|
438
456
|
async create(userId, token, expiresAt, sid, meta) {
|
|
439
457
|
const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
|
|
440
458
|
await this.store.query(
|
|
441
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
|
|
442
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
459
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
|
|
460
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
443
461
|
ON CONFLICT (token) DO NOTHING`,
|
|
444
462
|
[
|
|
445
463
|
userId,
|
|
446
|
-
token,
|
|
464
|
+
hashRefreshToken(token),
|
|
447
465
|
expiresAt,
|
|
448
466
|
sid ?? null,
|
|
449
467
|
meta?.userAgent ?? null,
|
|
@@ -453,7 +471,56 @@ var SessionModel = class {
|
|
|
453
471
|
);
|
|
454
472
|
}
|
|
455
473
|
async delete(token) {
|
|
456
|
-
await this.store.query(`DELETE FROM fonderie_sessions WHERE token
|
|
474
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Where a presented refresh token stands: the session's current token, its
|
|
478
|
+
* previous one within the grace (a retry or race), its previous one AFTER
|
|
479
|
+
* the grace (reuse — a theft signal), or nothing known.
|
|
480
|
+
*/
|
|
481
|
+
async match(token) {
|
|
482
|
+
const hash = hashRefreshToken(token);
|
|
483
|
+
const [current] = await this.store.query(
|
|
484
|
+
`SELECT id, user_id, sid, token FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
|
|
485
|
+
[hash, token]
|
|
486
|
+
);
|
|
487
|
+
if (current) return { kind: "current", row: toRow(current) };
|
|
488
|
+
const [previous] = await this.store.query(
|
|
489
|
+
`SELECT id, user_id, sid, token, (previous_valid_until > now()) AS in_grace
|
|
490
|
+
FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
|
|
491
|
+
[hash]
|
|
492
|
+
);
|
|
493
|
+
if (!previous) return { kind: "unknown" };
|
|
494
|
+
return { kind: previous.in_grace ? "grace" : "reused", row: toRow(previous) };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Rotate a session in place to a new refresh token: same row, same device.
|
|
498
|
+
* From the CURRENT token it is optimistic — only if the stored token is
|
|
499
|
+
* still the one matched; a concurrent rotation makes it return false and the
|
|
500
|
+
* caller re-matches (and now finds the token as the previous one). The old
|
|
501
|
+
* token becomes the previous, valid for the grace. From the PREVIOUS token
|
|
502
|
+
* (within the grace) it is authorised by that hash and deadline alone, so
|
|
503
|
+
* racing retries never starve; the previous and its deadline are kept.
|
|
504
|
+
*/
|
|
505
|
+
async rotate(row, presented, next, from) {
|
|
506
|
+
const rows = await this.store.query(
|
|
507
|
+
`UPDATE fonderie_sessions
|
|
508
|
+
SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,
|
|
509
|
+
previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,
|
|
510
|
+
token = $3,
|
|
511
|
+
expires_at = $4,
|
|
512
|
+
last_used_at = now()
|
|
513
|
+
WHERE id = $1
|
|
514
|
+
AND CASE WHEN $5 THEN token = $2
|
|
515
|
+
ELSE previous_token_hash = $6 AND previous_valid_until > now() END
|
|
516
|
+
RETURNING id`,
|
|
517
|
+
[row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === "current", hashRefreshToken(presented), String(REFRESH_GRACE_MS)]
|
|
518
|
+
);
|
|
519
|
+
return rows.length === 1;
|
|
520
|
+
}
|
|
521
|
+
/** Revoke one session by its row id (any user) — reuse detection, admin. */
|
|
522
|
+
async revokeById(id) {
|
|
523
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);
|
|
457
524
|
}
|
|
458
525
|
// Delete the session an access token is bound to (by its sid claim). Lets
|
|
459
526
|
// logout revoke the current session without the client resending the refresh
|
|
@@ -519,8 +586,8 @@ var SessionModel = class {
|
|
|
519
586
|
}
|
|
520
587
|
async exists(token) {
|
|
521
588
|
const rows = await this.store.query(
|
|
522
|
-
`SELECT id FROM fonderie_sessions WHERE token
|
|
523
|
-
[token]
|
|
589
|
+
`SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,
|
|
590
|
+
[hashRefreshToken(token), token]
|
|
524
591
|
);
|
|
525
592
|
return rows.length > 0;
|
|
526
593
|
}
|
|
@@ -533,6 +600,9 @@ var SessionModel = class {
|
|
|
533
600
|
return rows.length > 0;
|
|
534
601
|
}
|
|
535
602
|
};
|
|
603
|
+
function toRow(r) {
|
|
604
|
+
return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token };
|
|
605
|
+
}
|
|
536
606
|
|
|
537
607
|
// src/middlewares/session.ts
|
|
538
608
|
function withSession(store, config) {
|