@pramen/auth 0.0.37 → 0.0.39

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
@@ -33,12 +33,22 @@ export declare const authSchema: {
33
33
  }, Record<string, never>>;
34
34
  };
35
35
  export declare function hashPassword(password: string): Promise<string>;
36
+ /** Verify `password` against a foreign hash `payload` (the stored value with its
37
+ * `<scheme>$` prefix already stripped). May be sync or async; throwing counts as a
38
+ * failed verification, never a 500. */
39
+ export type PasswordVerifier = (password: string, payload: string) => boolean | Promise<boolean>;
40
+ /** Register a verifier for an imported hash scheme. Call once at module scope, before
41
+ * any login can run. `pbkdf2` is built in and cannot be overridden. */
42
+ export declare function registerPasswordVerifier(scheme: string, verify: PasswordVerifier): void;
43
+ /** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
44
+ * after a successful verify. Also true for an unparseable value, which never verifies. */
45
+ export declare function isForeignHash(stored: string): boolean;
36
46
  export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
37
47
  export declare function signToken(claims: Record<string, unknown>, secret: string, opts?: {
38
48
  ttlSeconds?: number;
39
49
  }): Promise<string>;
40
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
41
- * client never picks its own roles. Spread into your handler map. */
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. */
42
52
  export declare const authHandlers: {
43
53
  signup: import("@pramen/server").Handler<{
44
54
  username: string;
@@ -63,6 +73,13 @@ export declare const authHandlers: {
63
73
  };
64
74
  }>;
65
75
  me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
76
+ refreshSession: import("@pramen/server").Handler<unknown, {
77
+ token: string;
78
+ user: {
79
+ username: string;
80
+ roles: string[];
81
+ };
82
+ }>;
66
83
  };
67
84
  export declare const magicLinkSchema: {
68
85
  auth_magic_links: import("@pramen/server").EntityDef<{
@@ -180,8 +197,11 @@ export declare function createUserHandlers(opts?: {
180
197
  username: string;
181
198
  roles: string[];
182
199
  }, Record<string, unknown>>;
183
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
184
- * token refresh); existing tokens still expire naturally within the TTL. */
200
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
201
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
202
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
203
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
204
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
185
205
  setUserActive: import("@pramen/server").Handler<{
186
206
  username: string;
187
207
  active: boolean;
@@ -230,8 +250,11 @@ export declare const userHandlers: {
230
250
  username: string;
231
251
  roles: string[];
232
252
  }, Record<string, unknown>>;
233
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
234
- * token refresh); existing tokens still expire naturally within the TTL. */
253
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
254
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
255
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
256
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
257
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
235
258
  setUserActive: import("@pramen/server").Handler<{
236
259
  username: string;
237
260
  active: boolean;
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // Passwordless magic-link login is also available via createMagicLinkAuth (spread
16
16
  // magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
17
17
  // the token lifecycle. See createMagicLinkAuth below.
18
- import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
18
+ import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized, denySession, allowSession } from "@pramen/server";
19
19
  // --- schema fragment: spread into your defineSchema so the table is migrated ---
20
20
  export const authSchema = {
21
21
  auth_users: Entity((t) => ({
@@ -84,7 +84,38 @@ function parseStoredHash(stored) {
84
84
  const hash = algSeg === "sha512" ? "SHA-512" : "SHA-256";
85
85
  return { iterations, hash, saltB64, hashB64 };
86
86
  }
87
+ const foreignVerifiers = new Map();
88
+ /** Register a verifier for an imported hash scheme. Call once at module scope, before
89
+ * any login can run. `pbkdf2` is built in and cannot be overridden. */
90
+ export function registerPasswordVerifier(scheme, verify) {
91
+ if (!scheme || scheme.includes("$"))
92
+ throw new Error(`invalid hash scheme '${scheme}' (must be non-empty and contain no '$')`);
93
+ if (scheme === "pbkdf2")
94
+ throw new Error("'pbkdf2' is built in and cannot be overridden");
95
+ foreignVerifiers.set(scheme, verify);
96
+ }
97
+ /** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
98
+ * after a successful verify. Also true for an unparseable value, which never verifies. */
99
+ export function isForeignHash(stored) {
100
+ return stored.split("$")[0] !== "pbkdf2";
101
+ }
87
102
  export async function verifyPassword(password, stored) {
103
+ const scheme = stored.split("$")[0];
104
+ if (scheme !== "pbkdf2") {
105
+ const verify = foreignVerifiers.get(scheme);
106
+ // Unknown scheme (including the empty passwordHash of a passwordless user) never
107
+ // verifies — same as before this feature existed.
108
+ if (!verify)
109
+ return false;
110
+ try {
111
+ return await verify(password, stored.slice(scheme.length + 1));
112
+ }
113
+ catch {
114
+ // A broken/garbage payload must fail closed, not surface as a 500 that
115
+ // distinguishes it from a wrong password.
116
+ return false;
117
+ }
118
+ }
88
119
  const parsed = parseStoredHash(stored);
89
120
  if (!parsed)
90
121
  return false;
@@ -146,8 +177,35 @@ function parseCreds(raw) {
146
177
  }
147
178
  return { username: o.username, password: o.password, email };
148
179
  }
149
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
150
- * client never picks its own roles. Spread into your handler map. */
180
+ /** Build the `refreshSession` handler: an AUTHENTICATED mutation that re-reads the caller's
181
+ * `roles` + `active` from the users table (keyed on `username` = the JWT `sub`) and reissues a
182
+ * fresh token with the configured session TTL — the same `{ token, user }` shape as `login`.
183
+ * Throws Unauthorized if the row is gone or the account is deactivated (the `isActive` helper).
184
+ *
185
+ * Why it exists: the core is stateless verify-only, so roles/active are baked into a token at
186
+ * login. refreshSession lets a client (1) silently refresh at ~half-TTL, so AUTH_SESSION_TTL_SECONDS
187
+ * can be kept SHORT (bounded revocation lag) without logging the user out; and (2) pick up a role
188
+ * GRANT immediately — e.g. right after a subscription checkout flips the role — with no re-login.
189
+ * Shared by password users (`authHandlers`) and magic-link users (`createMagicLinkAuth`): both
190
+ * store the row in the same authSchema-shaped table keyed on the immutable `username`. `ttlOf`
191
+ * supplies each factory's own configured TTL. */
192
+ function buildRefreshSession(ttlOf, table = "auth_users") {
193
+ return mutation(async (ctx) => {
194
+ const userId = requireUserId(ctx);
195
+ const rows = await ctx.db.exec(`SELECT username, roles, active FROM ${table} WHERE username = ? LIMIT 1`, userId);
196
+ const u = rows[0];
197
+ // Gone or deactivated ⇒ no fresh token (mirrors login). The Worker denylist already
198
+ // fails a deactivated user's outstanding token closed; this ensures refresh can't
199
+ // launder a revoked session into a new, longer-lived one either.
200
+ if (!u || !isActive(u.active))
201
+ throw new Unauthorized("session is no longer valid");
202
+ const roles = JSON.parse(String(u.roles));
203
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: ttlOf(ctx) });
204
+ return { token, user: { username: String(u.username), roles } };
205
+ }, { auth: "authenticated" });
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. */
151
209
  export const authHandlers = {
152
210
  // NOTE on username enumeration: signup returns a distinct "username is taken" error,
153
211
  // which is an enumeration oracle. This is INHERENT to systems where the username is a
@@ -198,11 +256,28 @@ export const authHandlers = {
198
256
  // deactivated user gets no new token. Existing tokens expire within the TTL.
199
257
  if (!isActive(u.active))
200
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));
267
+ }
268
+ catch {
269
+ /* keep the legacy hash; the next login retries */
270
+ }
271
+ }
201
272
  const roles = JSON.parse(String(u.roles));
202
273
  const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
203
274
  return { token, user: { username: String(u.username), roles } };
204
275
  }, { input: parseCreds }),
205
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),
206
281
  };
207
282
  // --- magic link (passwordless) login ---------------------------------------
208
283
  //
@@ -291,6 +366,10 @@ export function createMagicLinkAuth(opts) {
291
366
  const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
292
367
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
293
368
  const handlers = {
369
+ // Silent token refresh for magic-link users (same table, keyed on username). Reissues
370
+ // at this factory's configured session TTL. Shared implementation with authHandlers —
371
+ // when both are spread into one app, either definition serves either user.
372
+ refreshSession: buildRefreshSession(() => sessionTtl),
294
373
  /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
295
374
  * and email them a fresh magic link. Idempotent — inviting an existing user just
296
375
  * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
@@ -381,12 +460,19 @@ export function createMagicLinkAuth(opts) {
381
460
  // roles (admin manages everyone; the authenticated user manages only itself). Because
382
461
  // the admin read policy restricts `fields`, `passwordHash` is never projected back.
383
462
  //
384
- // Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
385
- // take effect on the user's NEXT login not instantly. That lag is the cost of the
386
- // stateless, verify-only core (no session store, by design). Tune the revocation window
387
- // with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
388
- // app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
389
- // deliberately left to the app rather than building a session store into the core.
463
+ // Roles are baked into the JWT at login, so the core is stateless verify-only. Two
464
+ // mechanisms close the gap that leaves, without a session store:
465
+ // - `refreshSession` (authHandlers / createMagicLinkAuth): an authenticated caller
466
+ // re-reads roles/active and gets a fresh token. A client refreshing at ~half-TTL lets
467
+ // AUTH_SESSION_TTL_SECONDS (default 3600) stay short bounding how long a stale
468
+ // setUserRoles/setUserActive lingers and picks up a role GRANT immediately (no re-login).
469
+ // - KV denylist (HARD revocation, independent of TTL): setUserActive(false) and deleteUser
470
+ // write an `authDenied:<username>` entry via `denySession(ctx.kv, …)`; the core Worker
471
+ // checks `isSessionDenied` right after resolving identity and fails a revoked token closed
472
+ // (401) — so a deactivate/delete takes effect on the NEXT request, not the next login. The
473
+ // entry self-expires at the session TTL (the list never grows); reactivation lifts it
474
+ // (`allowSession`). `denySession`/`allowSession`/`isSessionDenied` are exported from
475
+ // @pramen/server so an app can revoke on its own compromise signals too.
390
476
  /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
391
477
  function isActive(v) {
392
478
  return v == null || Number(v) !== 0;
@@ -435,8 +521,11 @@ export function createUserHandlers(opts = {}) {
435
521
  throw new BadRequest("user not found"); // (or out of the caller's update scope)
436
522
  return updated;
437
523
  }),
438
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
439
- * token refresh); existing tokens still expire naturally within the TTL. */
524
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
525
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
526
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
527
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
528
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
440
529
  setUserActive: mutation(async (ctx, input) => {
441
530
  if (typeof input?.username !== "string" || input.username.length === 0)
442
531
  throw new BadRequest("username is required");
@@ -448,6 +537,11 @@ export function createUserHandlers(opts = {}) {
448
537
  const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
449
538
  if (!updated)
450
539
  throw new BadRequest("user not found");
540
+ // KV is not part of the mutation's transaction — do it after the update succeeds.
541
+ if (input.active === false)
542
+ await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
543
+ else
544
+ await allowSession(ctx.kv, input.username);
451
545
  return updated;
452
546
  }),
453
547
  /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
@@ -460,6 +554,8 @@ export function createUserHandlers(opts = {}) {
460
554
  const deleted = await usersDb(ctx).delete(table, input.username);
461
555
  if (!deleted)
462
556
  throw new BadRequest("user not found");
557
+ // Revoke any outstanding tokens for the now-deleted user (self-expires at the TTL).
558
+ await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
463
559
  return { ok: true };
464
560
  }),
465
561
  /** Self-service: change the caller's contact email. The ACL self policy scopes the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
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.37"
37
+ "@pramen/server": "0.0.39"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  // magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
17
17
  // the token lifecycle. See createMagicLinkAuth below.
18
18
 
19
- import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
19
+ import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized, denySession, allowSession } from "@pramen/server";
20
20
  import type { AppTaskMap, HandlerContext, HandlerMap, Policy } from "@pramen/server";
21
21
 
22
22
  // --- schema fragment: spread into your defineSchema so the table is migrated ---
@@ -94,7 +94,64 @@ function parseStoredHash(stored: string): { iterations: number; hash: string; sa
94
94
  return { iterations, hash, saltB64, hashB64 };
95
95
  }
96
96
 
97
+ // --- foreign hash schemes (opt-in, for migrating in from another system) -----
98
+ //
99
+ // Importing users from an existing app means importing hashes that are NOT PBKDF2 —
100
+ // bcrypt from Contember/Rails, `pbkdf2_sha256$` from Django, and so on. Those cannot be
101
+ // converted (that needs the plaintext), so the only alternative would be forcing every
102
+ // user to reset their password.
103
+ //
104
+ // Instead: store the foreign hash under its own scheme prefix (`bcrypt$<payload>`),
105
+ // register a verifier for that scheme, and let login UPGRADE it. On the one successful
106
+ // login where the plaintext is briefly in hand, the row is rehashed to PBKDF2. The
107
+ // scheme deletes itself as users return; nothing has to be migrated ahead of time.
108
+ //
109
+ // pramen deliberately does NOT bundle an implementation. bcrypt needs a pure-JS library
110
+ // (WebCrypto has none) which is real bundle weight, and it is useless to the apps that
111
+ // never import anything — so the app supplies it:
112
+ //
113
+ // import bcrypt from "bcryptjs";
114
+ // registerPasswordVerifier("bcrypt", (password, payload) => bcrypt.compare(password, payload));
115
+ //
116
+ // then import rows with `passwordHash = "bcrypt$" + row.password_hash` (a bcrypt hash is
117
+ // itself `$2b$10$...`, so the stored value reads `bcrypt$$2b$10$...`).
118
+
119
+ /** Verify `password` against a foreign hash `payload` (the stored value with its
120
+ * `<scheme>$` prefix already stripped). May be sync or async; throwing counts as a
121
+ * failed verification, never a 500. */
122
+ export type PasswordVerifier = (password: string, payload: string) => boolean | Promise<boolean>;
123
+
124
+ const foreignVerifiers = new Map<string, PasswordVerifier>();
125
+
126
+ /** Register a verifier for an imported hash scheme. Call once at module scope, before
127
+ * any login can run. `pbkdf2` is built in and cannot be overridden. */
128
+ export function registerPasswordVerifier(scheme: string, verify: PasswordVerifier): void {
129
+ if (!scheme || scheme.includes("$")) throw new Error(`invalid hash scheme '${scheme}' (must be non-empty and contain no '$')`);
130
+ if (scheme === "pbkdf2") throw new Error("'pbkdf2' is built in and cannot be overridden");
131
+ foreignVerifiers.set(scheme, verify);
132
+ }
133
+
134
+ /** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
135
+ * after a successful verify. Also true for an unparseable value, which never verifies. */
136
+ export function isForeignHash(stored: string): boolean {
137
+ return stored.split("$")[0] !== "pbkdf2";
138
+ }
139
+
97
140
  export async function verifyPassword(password: string, stored: string): Promise<boolean> {
141
+ const scheme = stored.split("$")[0];
142
+ if (scheme !== "pbkdf2") {
143
+ const verify = foreignVerifiers.get(scheme);
144
+ // Unknown scheme (including the empty passwordHash of a passwordless user) never
145
+ // verifies — same as before this feature existed.
146
+ if (!verify) return false;
147
+ try {
148
+ return await verify(password, stored.slice(scheme.length + 1));
149
+ } catch {
150
+ // A broken/garbage payload must fail closed, not surface as a 500 that
151
+ // distinguishes it from a wrong password.
152
+ return false;
153
+ }
154
+ }
98
155
  const parsed = parseStoredHash(stored);
99
156
  if (!parsed) return false;
100
157
  const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
@@ -169,8 +226,38 @@ function parseCreds(raw: unknown): { username: string; password: string; email?:
169
226
  return { username: o.username, password: o.password, email };
170
227
  }
171
228
 
172
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
173
- * client never picks its own roles. Spread into your handler map. */
229
+ /** Build the `refreshSession` handler: an AUTHENTICATED mutation that re-reads the caller's
230
+ * `roles` + `active` from the users table (keyed on `username` = the JWT `sub`) and reissues a
231
+ * fresh token with the configured session TTL — the same `{ token, user }` shape as `login`.
232
+ * Throws Unauthorized if the row is gone or the account is deactivated (the `isActive` helper).
233
+ *
234
+ * Why it exists: the core is stateless verify-only, so roles/active are baked into a token at
235
+ * login. refreshSession lets a client (1) silently refresh at ~half-TTL, so AUTH_SESSION_TTL_SECONDS
236
+ * can be kept SHORT (bounded revocation lag) without logging the user out; and (2) pick up a role
237
+ * GRANT immediately — e.g. right after a subscription checkout flips the role — with no re-login.
238
+ * Shared by password users (`authHandlers`) and magic-link users (`createMagicLinkAuth`): both
239
+ * store the row in the same authSchema-shaped table keyed on the immutable `username`. `ttlOf`
240
+ * supplies each factory's own configured TTL. */
241
+ function buildRefreshSession(ttlOf: (ctx: HandlerContext) => number, table = "auth_users") {
242
+ return mutation(
243
+ async (ctx) => {
244
+ const userId = requireUserId(ctx);
245
+ const rows = await ctx.db.exec(`SELECT username, roles, active FROM ${table} WHERE username = ? LIMIT 1`, userId);
246
+ const u = rows[0];
247
+ // Gone or deactivated ⇒ no fresh token (mirrors login). The Worker denylist already
248
+ // fails a deactivated user's outstanding token closed; this ensures refresh can't
249
+ // launder a revoked session into a new, longer-lived one either.
250
+ if (!u || !isActive(u.active)) throw new Unauthorized("session is no longer valid");
251
+ const roles = JSON.parse(String(u.roles)) as string[];
252
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: ttlOf(ctx) });
253
+ return { token, user: { username: String(u.username), roles } };
254
+ },
255
+ { auth: "authenticated" },
256
+ );
257
+ }
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. */
174
261
  export const authHandlers = {
175
262
  // NOTE on username enumeration: signup returns a distinct "username is taken" error,
176
263
  // which is an enumeration oracle. This is INHERENT to systems where the username is a
@@ -234,6 +321,22 @@ export const authHandlers = {
234
321
  // Only after the password verifies (so this can't enumerate accounts): a
235
322
  // deactivated user gets no new token. Existing tokens expire within the TTL.
236
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 */
338
+ }
339
+ }
237
340
  const roles = JSON.parse(String(u.roles)) as string[];
238
341
  const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
239
342
  return { token, user: { username: String(u.username), roles } };
@@ -242,6 +345,11 @@ export const authHandlers = {
242
345
  ),
243
346
 
244
347
  me: query((ctx) => ctx.identity),
348
+
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),
245
353
  };
246
354
 
247
355
  // --- magic link (passwordless) login ---------------------------------------
@@ -357,6 +465,11 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
357
465
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
358
466
 
359
467
  const handlers: HandlerMap = {
468
+ // Silent token refresh for magic-link users (same table, keyed on username). Reissues
469
+ // at this factory's configured session TTL. Shared implementation with authHandlers —
470
+ // when both are spread into one app, either definition serves either user.
471
+ refreshSession: buildRefreshSession(() => sessionTtl),
472
+
360
473
  /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
361
474
  * and email them a fresh magic link. Idempotent — inviting an existing user just
362
475
  * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
@@ -489,12 +602,19 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
489
602
  // roles (admin manages everyone; the authenticated user manages only itself). Because
490
603
  // the admin read policy restricts `fields`, `passwordHash` is never projected back.
491
604
  //
492
- // Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
493
- // take effect on the user's NEXT login not instantly. That lag is the cost of the
494
- // stateless, verify-only core (no session store, by design). Tune the revocation window
495
- // with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
496
- // app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
497
- // deliberately left to the app rather than building a session store into the core.
605
+ // Roles are baked into the JWT at login, so the core is stateless verify-only. Two
606
+ // mechanisms close the gap that leaves, without a session store:
607
+ // - `refreshSession` (authHandlers / createMagicLinkAuth): an authenticated caller
608
+ // re-reads roles/active and gets a fresh token. A client refreshing at ~half-TTL lets
609
+ // AUTH_SESSION_TTL_SECONDS (default 3600) stay short bounding how long a stale
610
+ // setUserRoles/setUserActive lingers and picks up a role GRANT immediately (no re-login).
611
+ // - KV denylist (HARD revocation, independent of TTL): setUserActive(false) and deleteUser
612
+ // write an `authDenied:<username>` entry via `denySession(ctx.kv, …)`; the core Worker
613
+ // checks `isSessionDenied` right after resolving identity and fails a revoked token closed
614
+ // (401) — so a deactivate/delete takes effect on the NEXT request, not the next login. The
615
+ // entry self-expires at the session TTL (the list never grows); reactivation lifts it
616
+ // (`allowSession`). `denySession`/`allowSession`/`isSessionDenied` are exported from
617
+ // @pramen/server so an app can revoke on its own compromise signals too.
498
618
 
499
619
  /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
500
620
  function isActive(v: unknown): boolean {
@@ -553,8 +673,11 @@ export function createUserHandlers(opts: { table?: string } = {}) {
553
673
  return updated;
554
674
  }),
555
675
 
556
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
557
- * token refresh); existing tokens still expire naturally within the TTL. */
676
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
677
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
678
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
679
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
680
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
558
681
  setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
559
682
  if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
560
683
  if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
@@ -563,6 +686,9 @@ export function createUserHandlers(opts: { table?: string } = {}) {
563
686
  }
564
687
  const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
565
688
  if (!updated) throw new BadRequest("user not found");
689
+ // KV is not part of the mutation's transaction — do it after the update succeeds.
690
+ if (input.active === false) await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
691
+ else await allowSession(ctx.kv, input.username);
566
692
  return updated;
567
693
  }),
568
694
 
@@ -573,6 +699,8 @@ export function createUserHandlers(opts: { table?: string } = {}) {
573
699
  if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
574
700
  const deleted = await usersDb(ctx).delete(table, input.username);
575
701
  if (!deleted) throw new BadRequest("user not found");
702
+ // Revoke any outstanding tokens for the now-deleted user (self-expires at the TTL).
703
+ await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
576
704
  return { ok: true };
577
705
  }),
578
706