@pramen/auth 0.0.39 → 0.0.41

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/dist/index.d.ts CHANGED
@@ -47,8 +47,56 @@ export declare function verifyPassword(password: string, stored: string): Promis
47
47
  export declare function signToken(claims: Record<string, unknown>, secret: string, opts?: {
48
48
  ttlSeconds?: number;
49
49
  }): Promise<string>;
50
- /** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
51
- * the client never picks its own roles. Spread into your handler map. */
50
+ export interface AuthHandlerOptions {
51
+ /** Which column `login` resolves the submitted identifier against.
52
+ *
53
+ * - `"username"` (default) — the PK only, the historical behaviour.
54
+ * - `"email"` — the email column only. For apps where the username is an opaque id
55
+ * (a migrated tenant identity, say) and members know only their email address.
56
+ * - `"either"` — username first (it is the PK, so it cannot be ambiguous), then email.
57
+ * Use when two populations coexist: migrated members keyed by an opaque id, and
58
+ * newer accounts that signed up with their email as the username.
59
+ *
60
+ * Email lookup tries an exact match, then falls back to a case-insensitive one — but
61
+ * ONLY when that matches exactly one row, so a pair of addresses differing just by
62
+ * case can never resolve to an arbitrary account. */
63
+ loginBy?: "username" | "email" | "either";
64
+ }
65
+ /** Build signup / login / me / refreshSession. Roles are assigned server-side (default
66
+ * `["user"]`) — the client never picks its own roles. Spread into your handler map. */
67
+ export declare function createAuthHandlers(opts?: AuthHandlerOptions): {
68
+ signup: import("@pramen/server").Handler<{
69
+ username: string;
70
+ password: string;
71
+ email?: string;
72
+ }, {
73
+ token: string;
74
+ user: {
75
+ username: string;
76
+ roles: string[];
77
+ email: string | null;
78
+ };
79
+ }>;
80
+ login: import("@pramen/server").Handler<{
81
+ username: string;
82
+ password: string;
83
+ }, {
84
+ token: string;
85
+ user: {
86
+ username: string;
87
+ roles: string[];
88
+ };
89
+ }>;
90
+ me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
91
+ refreshSession: import("@pramen/server").Handler<unknown, {
92
+ token: string;
93
+ user: {
94
+ username: string;
95
+ roles: string[];
96
+ };
97
+ }>;
98
+ };
99
+ /** Default handlers: login resolves by username only (the historical behaviour). */
52
100
  export declare const authHandlers: {
53
101
  signup: import("@pramen/server").Handler<{
54
102
  username: string;
package/dist/index.js CHANGED
@@ -204,81 +204,105 @@ function buildRefreshSession(ttlOf, table = "auth_users") {
204
204
  return { token, user: { username: String(u.username), roles } };
205
205
  }, { auth: "authenticated" });
206
206
  }
207
- /** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
208
- * — the client never picks its own roles. Spread into your handler map. */
209
- export const authHandlers = {
210
- // NOTE on username enumeration: signup returns a distinct "username is taken" error,
211
- // which is an enumeration oracle. This is INHERENT to systems where the username is a
212
- // user-chosen, publicly-visible identifier — the caller learns "taken" the moment the
213
- // name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
214
- // timing side channel: both the taken and the available paths run the same expensive
215
- // PBKDF2 hash before responding, so response time doesn't leak which path was taken.
216
- // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
217
- // which keys on the email and always returns the same `{ ok: true }`.
218
- signup: mutation(async (ctx, input) => {
219
- const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
220
- if (existing.length > 0) {
221
- // Equalize timing with the available path (which hashes below) so the taken vs.
222
- // available decision isn't a fast timing oracle on top of the response-body one.
223
- await hashPassword(input.password);
224
- throw new BadRequest("username is taken");
207
+ /** Build signup / login / me / refreshSession. Roles are assigned server-side (default
208
+ * `["user"]`) — the client never picks its own roles. Spread into your handler map. */
209
+ export function createAuthHandlers(opts = {}) {
210
+ const loginBy = opts.loginBy ?? "username";
211
+ /** Resolve the submitted identifier to a row, per `loginBy`. Returns undefined when
212
+ * nothing matches; the caller still runs a dummy verify so the timing is flat. */
213
+ async function findLoginRow(ctx, identifier) {
214
+ const cols = "SELECT username, passwordHash, roles, active FROM auth_users";
215
+ if (loginBy !== "email") {
216
+ const byName = await ctx.db.exec(`${cols} WHERE username = ? LIMIT 1`, identifier);
217
+ if (byName[0])
218
+ return byName[0];
219
+ if (loginBy === "username")
220
+ return undefined;
225
221
  }
226
- // A supplied email must be free (the column is unique). Same clean-400 shape as
227
- // changeEmail rather than surfacing the DB constraint as a 500.
228
- if (input.email) {
229
- const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
230
- if (emailTaken.length > 0)
231
- throw new BadRequest("email already in use");
232
- }
233
- const roles = DEFAULT_ROLES;
234
- const passwordHash = await hashPassword(input.password);
235
- // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
236
- // createEmailVerification (requestEmailVerification runs right after signup the
237
- // client already holds the returned session token).
238
- await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)", input.username, passwordHash, JSON.stringify(roles), input.email ?? null, Date.now());
239
- const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
240
- return { token, user: { username: input.username, roles, email: input.email ?? null } };
241
- }, { input: parseCreds }),
242
- login: mutation(async (ctx, input) => {
243
- const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
244
- const u = rows[0];
245
- if (!u) {
246
- // No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
247
- // not-found path costs the same as a wrong-password path no timing oracle that
248
- // distinguishes "unknown username" from "bad password".
249
- await verifyPassword(input.password, await dummyPasswordHash());
250
- throw new Unauthorized("invalid username or password");
251
- }
252
- if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
253
- throw new Unauthorized("invalid username or password");
254
- }
255
- // Only after the password verifies (so this can't enumerate accounts): a
256
- // deactivated user gets no new token. Existing tokens expire within the TTL.
257
- if (!isActive(u.active))
258
- throw new Unauthorized("account is deactivated");
259
- // Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
260
- // moment the plaintext is in hand for a user whose hash predates pramen, so rehash
261
- // to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
262
- // deactivated account is never rewritten. Best-effort: a failed upgrade must not
263
- // fail an otherwise valid login — the row simply upgrades on a later attempt.
264
- if (isForeignHash(String(u.passwordHash))) {
265
- try {
266
- await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(input.password), String(u.username));
222
+ const exact = await ctx.db.exec(`${cols} WHERE email = ? LIMIT 1`, identifier);
223
+ if (exact[0])
224
+ return exact[0];
225
+ // Case-insensitive fallback, accepted only when unambiguous: `unique(email)` is
226
+ // case-SENSITIVE, so two rows may differ only by case and neither may be picked
227
+ // arbitrarily.
228
+ const loose = await ctx.db.exec(`${cols} WHERE lower(email) = lower(?) LIMIT 2`, identifier);
229
+ return loose.length === 1 ? loose[0] : undefined;
230
+ }
231
+ return {
232
+ // NOTE on username enumeration: signup returns a distinct "username is taken" error,
233
+ // which is an enumeration oracle. This is INHERENT to systems where the username is a
234
+ // user-chosen, publicly-visible identifier the caller learns "taken" the moment the
235
+ // name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
236
+ // timing side channel: both the taken and the available paths run the same expensive
237
+ // PBKDF2 hash before responding, so response time doesn't leak which path was taken.
238
+ // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
239
+ // which keys on the email and always returns the same `{ ok: true }`.
240
+ signup: mutation(async (ctx, input) => {
241
+ const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
242
+ if (existing.length > 0) {
243
+ // Equalize timing with the available path (which hashes below) so the taken vs.
244
+ // available decision isn't a fast timing oracle on top of the response-body one.
245
+ await hashPassword(input.password);
246
+ throw new BadRequest("username is taken");
267
247
  }
268
- catch {
269
- /* keep the legacy hash; the next login retries */
248
+ // A supplied email must be free (the column is unique). Same clean-400 shape as
249
+ // changeEmail rather than surfacing the DB constraint as a 500.
250
+ if (input.email) {
251
+ const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
252
+ if (emailTaken.length > 0)
253
+ throw new BadRequest("email already in use");
270
254
  }
271
- }
272
- const roles = JSON.parse(String(u.roles));
273
- const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
274
- return { token, user: { username: String(u.username), roles } };
275
- }, { input: parseCreds }),
276
- me: query((ctx) => ctx.identity),
277
- // Re-read roles/active for the caller and reissue a token at the env-configured session
278
- // TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
279
- // the user out, and picks up role grants without re-login. See buildRefreshSession.
280
- refreshSession: buildRefreshSession(sessionTtlOf),
281
- };
255
+ const roles = DEFAULT_ROLES;
256
+ const passwordHash = await hashPassword(input.password);
257
+ // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
258
+ // createEmailVerification (requestEmailVerification runs right after signup the
259
+ // client already holds the returned session token).
260
+ await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)", input.username, passwordHash, JSON.stringify(roles), input.email ?? null, Date.now());
261
+ const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
262
+ return { token, user: { username: input.username, roles, email: input.email ?? null } };
263
+ }, { input: parseCreds }),
264
+ login: mutation(async (ctx, input) => {
265
+ const u = await findLoginRow(ctx, input.username);
266
+ if (!u) {
267
+ // No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
268
+ // not-found path costs the same as a wrong-password path — no timing oracle that
269
+ // distinguishes "unknown username" from "bad password".
270
+ await verifyPassword(input.password, await dummyPasswordHash());
271
+ throw new Unauthorized("invalid username or password");
272
+ }
273
+ if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
274
+ throw new Unauthorized("invalid username or password");
275
+ }
276
+ // Only after the password verifies (so this can't enumerate accounts): a
277
+ // deactivated user gets no new token. Existing tokens expire within the TTL.
278
+ if (!isActive(u.active))
279
+ throw new Unauthorized("account is deactivated");
280
+ // Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
281
+ // moment the plaintext is in hand for a user whose hash predates pramen, so rehash
282
+ // to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
283
+ // deactivated account is never rewritten. Best-effort: a failed upgrade must not
284
+ // fail an otherwise valid login — the row simply upgrades on a later attempt.
285
+ if (isForeignHash(String(u.passwordHash))) {
286
+ try {
287
+ await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(input.password), String(u.username));
288
+ }
289
+ catch {
290
+ /* keep the legacy hash; the next login retries */
291
+ }
292
+ }
293
+ const roles = JSON.parse(String(u.roles));
294
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
295
+ return { token, user: { username: String(u.username), roles } };
296
+ }, { input: parseCreds }),
297
+ me: query((ctx) => ctx.identity),
298
+ // Re-read roles/active for the caller and reissue a token at the env-configured session
299
+ // TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
300
+ // the user out, and picks up role grants without re-login. See buildRefreshSession.
301
+ refreshSession: buildRefreshSession(sessionTtlOf),
302
+ };
303
+ }
304
+ /** Default handlers: login resolves by username only (the historical behaviour). */
305
+ export const authHandlers = createAuthHandlers();
282
306
  // --- magic link (passwordless) login ---------------------------------------
283
307
  //
284
308
  // A one-time, single-use, time-boxed link emailed to the user. The flow is two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,6 +34,6 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pramen/server": "0.0.39"
37
+ "@pramen/server": "0.0.41"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -256,101 +256,138 @@ function buildRefreshSession(ttlOf: (ctx: HandlerContext) => number, table = "au
256
256
  );
257
257
  }
258
258
 
259
- /** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
260
- * the client never picks its own roles. Spread into your handler map. */
261
- export const authHandlers = {
262
- // NOTE on username enumeration: signup returns a distinct "username is taken" error,
263
- // which is an enumeration oracle. This is INHERENT to systems where the username is a
264
- // user-chosen, publicly-visible identifier the caller learns "taken" the moment the
265
- // name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
266
- // timing side channel: both the taken and the available paths run the same expensive
267
- // PBKDF2 hash before responding, so response time doesn't leak which path was taken.
268
- // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
269
- // which keys on the email and always returns the same `{ ok: true }`.
270
- signup: mutation(
271
- async (ctx, input: { username: string; password: string; email?: string }) => {
272
- const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
273
- if (existing.length > 0) {
274
- // Equalize timing with the available path (which hashes below) so the taken vs.
275
- // available decision isn't a fast timing oracle on top of the response-body one.
276
- await hashPassword(input.password);
277
- throw new BadRequest("username is taken");
278
- }
279
- // A supplied email must be free (the column is unique). Same clean-400 shape as
280
- // changeEmail rather than surfacing the DB constraint as a 500.
281
- if (input.email) {
282
- const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
283
- if (emailTaken.length > 0) throw new BadRequest("email already in use");
284
- }
285
- const roles = DEFAULT_ROLES;
286
- const passwordHash = await hashPassword(input.password);
287
- // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
288
- // createEmailVerification (requestEmailVerification runs right after signup — the
289
- // client already holds the returned session token).
290
- await ctx.db.exec(
291
- "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
292
- input.username,
293
- passwordHash,
294
- JSON.stringify(roles),
295
- input.email ?? null,
296
- Date.now(),
297
- );
298
- const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
299
- return { token, user: { username: input.username, roles, email: input.email ?? null } };
300
- },
301
- { input: parseCreds },
302
- ),
303
-
304
- login: mutation(
305
- async (ctx, input: { username: string; password: string }) => {
306
- const rows = await ctx.db.exec(
307
- "SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1",
308
- input.username,
309
- );
310
- const u = rows[0];
311
- if (!u) {
312
- // No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
313
- // not-found path costs the same as a wrong-password path — no timing oracle that
314
- // distinguishes "unknown username" from "bad password".
315
- await verifyPassword(input.password, await dummyPasswordHash());
316
- throw new Unauthorized("invalid username or password");
317
- }
318
- if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
319
- throw new Unauthorized("invalid username or password");
320
- }
321
- // Only after the password verifies (so this can't enumerate accounts): a
322
- // deactivated user gets no new token. Existing tokens expire within the TTL.
323
- if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
324
- // Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
325
- // moment the plaintext is in hand for a user whose hash predates pramen, so rehash
326
- // to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
327
- // deactivated account is never rewritten. Best-effort: a failed upgrade must not
328
- // fail an otherwise valid login — the row simply upgrades on a later attempt.
329
- if (isForeignHash(String(u.passwordHash))) {
330
- try {
331
- await ctx.db.exec(
332
- "UPDATE auth_users SET passwordHash = ? WHERE username = ?",
333
- await hashPassword(input.password),
334
- String(u.username),
335
- );
336
- } catch {
337
- /* keep the legacy hash; the next login retries */
259
+ export interface AuthHandlerOptions {
260
+ /** Which column `login` resolves the submitted identifier against.
261
+ *
262
+ * - `"username"` (default) the PK only, the historical behaviour.
263
+ * - `"email"` the email column only. For apps where the username is an opaque id
264
+ * (a migrated tenant identity, say) and members know only their email address.
265
+ * - `"either"` username first (it is the PK, so it cannot be ambiguous), then email.
266
+ * Use when two populations coexist: migrated members keyed by an opaque id, and
267
+ * newer accounts that signed up with their email as the username.
268
+ *
269
+ * Email lookup tries an exact match, then falls back to a case-insensitive one — but
270
+ * ONLY when that matches exactly one row, so a pair of addresses differing just by
271
+ * case can never resolve to an arbitrary account. */
272
+ loginBy?: "username" | "email" | "either";
273
+ }
274
+
275
+ /** Build signup / login / me / refreshSession. Roles are assigned server-side (default
276
+ * `["user"]`) — the client never picks its own roles. Spread into your handler map. */
277
+ export function createAuthHandlers(opts: AuthHandlerOptions = {}) {
278
+ const loginBy = opts.loginBy ?? "username";
279
+
280
+ /** Resolve the submitted identifier to a row, per `loginBy`. Returns undefined when
281
+ * nothing matches; the caller still runs a dummy verify so the timing is flat. */
282
+ async function findLoginRow(ctx: HandlerContext, identifier: string): Promise<Record<string, unknown> | undefined> {
283
+ const cols = "SELECT username, passwordHash, roles, active FROM auth_users";
284
+ if (loginBy !== "email") {
285
+ const byName = await ctx.db.exec(`${cols} WHERE username = ? LIMIT 1`, identifier);
286
+ if (byName[0]) return byName[0];
287
+ if (loginBy === "username") return undefined;
288
+ }
289
+ const exact = await ctx.db.exec(`${cols} WHERE email = ? LIMIT 1`, identifier);
290
+ if (exact[0]) return exact[0];
291
+ // Case-insensitive fallback, accepted only when unambiguous: `unique(email)` is
292
+ // case-SENSITIVE, so two rows may differ only by case and neither may be picked
293
+ // arbitrarily.
294
+ const loose = await ctx.db.exec(`${cols} WHERE lower(email) = lower(?) LIMIT 2`, identifier);
295
+ return loose.length === 1 ? loose[0] : undefined;
296
+ }
297
+
298
+ return {
299
+ // NOTE on username enumeration: signup returns a distinct "username is taken" error,
300
+ // which is an enumeration oracle. This is INHERENT to systems where the username is a
301
+ // user-chosen, publicly-visible identifier — the caller learns "taken" the moment the
302
+ // name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
303
+ // timing side channel: both the taken and the available paths run the same expensive
304
+ // PBKDF2 hash before responding, so response time doesn't leak which path was taken.
305
+ // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
306
+ // which keys on the email and always returns the same `{ ok: true }`.
307
+ signup: mutation(
308
+ async (ctx, input: { username: string; password: string; email?: string }) => {
309
+ const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
310
+ if (existing.length > 0) {
311
+ // Equalize timing with the available path (which hashes below) so the taken vs.
312
+ // available decision isn't a fast timing oracle on top of the response-body one.
313
+ await hashPassword(input.password);
314
+ throw new BadRequest("username is taken");
338
315
  }
339
- }
340
- const roles = JSON.parse(String(u.roles)) as string[];
341
- const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
342
- return { token, user: { username: String(u.username), roles } };
343
- },
344
- { input: parseCreds },
345
- ),
316
+ // A supplied email must be free (the column is unique). Same clean-400 shape as
317
+ // changeEmail rather than surfacing the DB constraint as a 500.
318
+ if (input.email) {
319
+ const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
320
+ if (emailTaken.length > 0) throw new BadRequest("email already in use");
321
+ }
322
+ const roles = DEFAULT_ROLES;
323
+ const passwordHash = await hashPassword(input.password);
324
+ // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
325
+ // createEmailVerification (requestEmailVerification runs right after signup — the
326
+ // client already holds the returned session token).
327
+ await ctx.db.exec(
328
+ "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
329
+ input.username,
330
+ passwordHash,
331
+ JSON.stringify(roles),
332
+ input.email ?? null,
333
+ Date.now(),
334
+ );
335
+ const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
336
+ return { token, user: { username: input.username, roles, email: input.email ?? null } };
337
+ },
338
+ { input: parseCreds },
339
+ ),
340
+
341
+ login: mutation(
342
+ async (ctx, input: { username: string; password: string }) => {
343
+ const u = await findLoginRow(ctx, input.username);
344
+ if (!u) {
345
+ // No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
346
+ // not-found path costs the same as a wrong-password path — no timing oracle that
347
+ // distinguishes "unknown username" from "bad password".
348
+ await verifyPassword(input.password, await dummyPasswordHash());
349
+ throw new Unauthorized("invalid username or password");
350
+ }
351
+ if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
352
+ throw new Unauthorized("invalid username or password");
353
+ }
354
+ // Only after the password verifies (so this can't enumerate accounts): a
355
+ // deactivated user gets no new token. Existing tokens expire within the TTL.
356
+ if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
357
+ // Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
358
+ // moment the plaintext is in hand for a user whose hash predates pramen, so rehash
359
+ // to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
360
+ // deactivated account is never rewritten. Best-effort: a failed upgrade must not
361
+ // fail an otherwise valid login — the row simply upgrades on a later attempt.
362
+ if (isForeignHash(String(u.passwordHash))) {
363
+ try {
364
+ await ctx.db.exec(
365
+ "UPDATE auth_users SET passwordHash = ? WHERE username = ?",
366
+ await hashPassword(input.password),
367
+ String(u.username),
368
+ );
369
+ } catch {
370
+ /* keep the legacy hash; the next login retries */
371
+ }
372
+ }
373
+ const roles = JSON.parse(String(u.roles)) as string[];
374
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
375
+ return { token, user: { username: String(u.username), roles } };
376
+ },
377
+ { input: parseCreds },
378
+ ),
346
379
 
347
- me: query((ctx) => ctx.identity),
380
+ me: query((ctx) => ctx.identity),
348
381
 
349
- // Re-read roles/active for the caller and reissue a token at the env-configured session
350
- // TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
351
- // the user out, and picks up role grants without re-login. See buildRefreshSession.
352
- refreshSession: buildRefreshSession(sessionTtlOf),
353
- };
382
+ // Re-read roles/active for the caller and reissue a token at the env-configured session
383
+ // TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
384
+ // the user out, and picks up role grants without re-login. See buildRefreshSession.
385
+ refreshSession: buildRefreshSession(sessionTtlOf),
386
+ };
387
+ }
388
+
389
+ /** Default handlers: login resolves by username only (the historical behaviour). */
390
+ export const authHandlers = createAuthHandlers();
354
391
 
355
392
  // --- magic link (passwordless) login ---------------------------------------
356
393
  //