@pramen/auth 0.0.37 → 0.0.38

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
@@ -37,8 +37,8 @@ export declare function verifyPassword(password: string, stored: string): Promis
37
37
  export declare function signToken(claims: Record<string, unknown>, secret: string, opts?: {
38
38
  ttlSeconds?: number;
39
39
  }): 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. */
40
+ /** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
41
+ * — the client never picks its own roles. Spread into your handler map. */
42
42
  export declare const authHandlers: {
43
43
  signup: import("@pramen/server").Handler<{
44
44
  username: string;
@@ -63,6 +63,13 @@ export declare const authHandlers: {
63
63
  };
64
64
  }>;
65
65
  me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
66
+ refreshSession: import("@pramen/server").Handler<unknown, {
67
+ token: string;
68
+ user: {
69
+ username: string;
70
+ roles: string[];
71
+ };
72
+ }>;
66
73
  };
67
74
  export declare const magicLinkSchema: {
68
75
  auth_magic_links: import("@pramen/server").EntityDef<{
@@ -180,8 +187,11 @@ export declare function createUserHandlers(opts?: {
180
187
  username: string;
181
188
  roles: string[];
182
189
  }, 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. */
190
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
191
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
192
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
193
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
194
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
185
195
  setUserActive: import("@pramen/server").Handler<{
186
196
  username: string;
187
197
  active: boolean;
@@ -230,8 +240,11 @@ export declare const userHandlers: {
230
240
  username: string;
231
241
  roles: string[];
232
242
  }, 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. */
243
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
244
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
245
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
246
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
247
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
235
248
  setUserActive: import("@pramen/server").Handler<{
236
249
  username: string;
237
250
  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) => ({
@@ -146,8 +146,35 @@ function parseCreds(raw) {
146
146
  }
147
147
  return { username: o.username, password: o.password, email };
148
148
  }
149
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
150
- * client never picks its own roles. Spread into your handler map. */
149
+ /** Build the `refreshSession` handler: an AUTHENTICATED mutation that re-reads the caller's
150
+ * `roles` + `active` from the users table (keyed on `username` = the JWT `sub`) and reissues a
151
+ * fresh token with the configured session TTL — the same `{ token, user }` shape as `login`.
152
+ * Throws Unauthorized if the row is gone or the account is deactivated (the `isActive` helper).
153
+ *
154
+ * Why it exists: the core is stateless verify-only, so roles/active are baked into a token at
155
+ * login. refreshSession lets a client (1) silently refresh at ~half-TTL, so AUTH_SESSION_TTL_SECONDS
156
+ * can be kept SHORT (bounded revocation lag) without logging the user out; and (2) pick up a role
157
+ * GRANT immediately — e.g. right after a subscription checkout flips the role — with no re-login.
158
+ * Shared by password users (`authHandlers`) and magic-link users (`createMagicLinkAuth`): both
159
+ * store the row in the same authSchema-shaped table keyed on the immutable `username`. `ttlOf`
160
+ * supplies each factory's own configured TTL. */
161
+ function buildRefreshSession(ttlOf, table = "auth_users") {
162
+ return mutation(async (ctx) => {
163
+ const userId = requireUserId(ctx);
164
+ const rows = await ctx.db.exec(`SELECT username, roles, active FROM ${table} WHERE username = ? LIMIT 1`, userId);
165
+ const u = rows[0];
166
+ // Gone or deactivated ⇒ no fresh token (mirrors login). The Worker denylist already
167
+ // fails a deactivated user's outstanding token closed; this ensures refresh can't
168
+ // launder a revoked session into a new, longer-lived one either.
169
+ if (!u || !isActive(u.active))
170
+ throw new Unauthorized("session is no longer valid");
171
+ const roles = JSON.parse(String(u.roles));
172
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: ttlOf(ctx) });
173
+ return { token, user: { username: String(u.username), roles } };
174
+ }, { auth: "authenticated" });
175
+ }
176
+ /** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
177
+ * — the client never picks its own roles. Spread into your handler map. */
151
178
  export const authHandlers = {
152
179
  // NOTE on username enumeration: signup returns a distinct "username is taken" error,
153
180
  // which is an enumeration oracle. This is INHERENT to systems where the username is a
@@ -203,6 +230,10 @@ export const authHandlers = {
203
230
  return { token, user: { username: String(u.username), roles } };
204
231
  }, { input: parseCreds }),
205
232
  me: query((ctx) => ctx.identity),
233
+ // Re-read roles/active for the caller and reissue a token at the env-configured session
234
+ // TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
235
+ // the user out, and picks up role grants without re-login. See buildRefreshSession.
236
+ refreshSession: buildRefreshSession(sessionTtlOf),
206
237
  };
207
238
  // --- magic link (passwordless) login ---------------------------------------
208
239
  //
@@ -291,6 +322,10 @@ export function createMagicLinkAuth(opts) {
291
322
  const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
292
323
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
293
324
  const handlers = {
325
+ // Silent token refresh for magic-link users (same table, keyed on username). Reissues
326
+ // at this factory's configured session TTL. Shared implementation with authHandlers —
327
+ // when both are spread into one app, either definition serves either user.
328
+ refreshSession: buildRefreshSession(() => sessionTtl),
294
329
  /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
295
330
  * and email them a fresh magic link. Idempotent — inviting an existing user just
296
331
  * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
@@ -381,12 +416,19 @@ export function createMagicLinkAuth(opts) {
381
416
  // roles (admin manages everyone; the authenticated user manages only itself). Because
382
417
  // the admin read policy restricts `fields`, `passwordHash` is never projected back.
383
418
  //
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.
419
+ // Roles are baked into the JWT at login, so the core is stateless verify-only. Two
420
+ // mechanisms close the gap that leaves, without a session store:
421
+ // - `refreshSession` (authHandlers / createMagicLinkAuth): an authenticated caller
422
+ // re-reads roles/active and gets a fresh token. A client refreshing at ~half-TTL lets
423
+ // AUTH_SESSION_TTL_SECONDS (default 3600) stay short bounding how long a stale
424
+ // setUserRoles/setUserActive lingers and picks up a role GRANT immediately (no re-login).
425
+ // - KV denylist (HARD revocation, independent of TTL): setUserActive(false) and deleteUser
426
+ // write an `authDenied:<username>` entry via `denySession(ctx.kv, …)`; the core Worker
427
+ // checks `isSessionDenied` right after resolving identity and fails a revoked token closed
428
+ // (401) — so a deactivate/delete takes effect on the NEXT request, not the next login. The
429
+ // entry self-expires at the session TTL (the list never grows); reactivation lifts it
430
+ // (`allowSession`). `denySession`/`allowSession`/`isSessionDenied` are exported from
431
+ // @pramen/server so an app can revoke on its own compromise signals too.
390
432
  /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
391
433
  function isActive(v) {
392
434
  return v == null || Number(v) !== 0;
@@ -435,8 +477,11 @@ export function createUserHandlers(opts = {}) {
435
477
  throw new BadRequest("user not found"); // (or out of the caller's update scope)
436
478
  return updated;
437
479
  }),
438
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
439
- * token refresh); existing tokens still expire naturally within the TTL. */
480
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
481
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
482
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
483
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
484
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
440
485
  setUserActive: mutation(async (ctx, input) => {
441
486
  if (typeof input?.username !== "string" || input.username.length === 0)
442
487
  throw new BadRequest("username is required");
@@ -448,6 +493,11 @@ export function createUserHandlers(opts = {}) {
448
493
  const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
449
494
  if (!updated)
450
495
  throw new BadRequest("user not found");
496
+ // KV is not part of the mutation's transaction — do it after the update succeeds.
497
+ if (input.active === false)
498
+ await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
499
+ else
500
+ await allowSession(ctx.kv, input.username);
451
501
  return updated;
452
502
  }),
453
503
  /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
@@ -460,6 +510,8 @@ export function createUserHandlers(opts = {}) {
460
510
  const deleted = await usersDb(ctx).delete(table, input.username);
461
511
  if (!deleted)
462
512
  throw new BadRequest("user not found");
513
+ // Revoke any outstanding tokens for the now-deleted user (self-expires at the TTL).
514
+ await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
463
515
  return { ok: true };
464
516
  }),
465
517
  /** 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.38",
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.38"
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 ---
@@ -169,8 +169,38 @@ function parseCreds(raw: unknown): { username: string; password: string; email?:
169
169
  return { username: o.username, password: o.password, email };
170
170
  }
171
171
 
172
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
173
- * client never picks its own roles. Spread into your handler map. */
172
+ /** Build the `refreshSession` handler: an AUTHENTICATED mutation that re-reads the caller's
173
+ * `roles` + `active` from the users table (keyed on `username` = the JWT `sub`) and reissues a
174
+ * fresh token with the configured session TTL — the same `{ token, user }` shape as `login`.
175
+ * Throws Unauthorized if the row is gone or the account is deactivated (the `isActive` helper).
176
+ *
177
+ * Why it exists: the core is stateless verify-only, so roles/active are baked into a token at
178
+ * login. refreshSession lets a client (1) silently refresh at ~half-TTL, so AUTH_SESSION_TTL_SECONDS
179
+ * can be kept SHORT (bounded revocation lag) without logging the user out; and (2) pick up a role
180
+ * GRANT immediately — e.g. right after a subscription checkout flips the role — with no re-login.
181
+ * Shared by password users (`authHandlers`) and magic-link users (`createMagicLinkAuth`): both
182
+ * store the row in the same authSchema-shaped table keyed on the immutable `username`. `ttlOf`
183
+ * supplies each factory's own configured TTL. */
184
+ function buildRefreshSession(ttlOf: (ctx: HandlerContext) => number, table = "auth_users") {
185
+ return mutation(
186
+ async (ctx) => {
187
+ const userId = requireUserId(ctx);
188
+ const rows = await ctx.db.exec(`SELECT username, roles, active FROM ${table} WHERE username = ? LIMIT 1`, userId);
189
+ const u = rows[0];
190
+ // Gone or deactivated ⇒ no fresh token (mirrors login). The Worker denylist already
191
+ // fails a deactivated user's outstanding token closed; this ensures refresh can't
192
+ // launder a revoked session into a new, longer-lived one either.
193
+ if (!u || !isActive(u.active)) throw new Unauthorized("session is no longer valid");
194
+ const roles = JSON.parse(String(u.roles)) as string[];
195
+ const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: ttlOf(ctx) });
196
+ return { token, user: { username: String(u.username), roles } };
197
+ },
198
+ { auth: "authenticated" },
199
+ );
200
+ }
201
+
202
+ /** signup / login / me / refreshSession. Roles are assigned server-side (default `["user"]`)
203
+ * — the client never picks its own roles. Spread into your handler map. */
174
204
  export const authHandlers = {
175
205
  // NOTE on username enumeration: signup returns a distinct "username is taken" error,
176
206
  // which is an enumeration oracle. This is INHERENT to systems where the username is a
@@ -242,6 +272,11 @@ export const authHandlers = {
242
272
  ),
243
273
 
244
274
  me: query((ctx) => ctx.identity),
275
+
276
+ // Re-read roles/active for the caller and reissue a token at the env-configured session
277
+ // TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
278
+ // the user out, and picks up role grants without re-login. See buildRefreshSession.
279
+ refreshSession: buildRefreshSession(sessionTtlOf),
245
280
  };
246
281
 
247
282
  // --- magic link (passwordless) login ---------------------------------------
@@ -357,6 +392,11 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
357
392
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
358
393
 
359
394
  const handlers: HandlerMap = {
395
+ // Silent token refresh for magic-link users (same table, keyed on username). Reissues
396
+ // at this factory's configured session TTL. Shared implementation with authHandlers —
397
+ // when both are spread into one app, either definition serves either user.
398
+ refreshSession: buildRefreshSession(() => sessionTtl),
399
+
360
400
  /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
361
401
  * and email them a fresh magic link. Idempotent — inviting an existing user just
362
402
  * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
@@ -489,12 +529,19 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
489
529
  // roles (admin manages everyone; the authenticated user manages only itself). Because
490
530
  // the admin read policy restricts `fields`, `passwordHash` is never projected back.
491
531
  //
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.
532
+ // Roles are baked into the JWT at login, so the core is stateless verify-only. Two
533
+ // mechanisms close the gap that leaves, without a session store:
534
+ // - `refreshSession` (authHandlers / createMagicLinkAuth): an authenticated caller
535
+ // re-reads roles/active and gets a fresh token. A client refreshing at ~half-TTL lets
536
+ // AUTH_SESSION_TTL_SECONDS (default 3600) stay short bounding how long a stale
537
+ // setUserRoles/setUserActive lingers and picks up a role GRANT immediately (no re-login).
538
+ // - KV denylist (HARD revocation, independent of TTL): setUserActive(false) and deleteUser
539
+ // write an `authDenied:<username>` entry via `denySession(ctx.kv, …)`; the core Worker
540
+ // checks `isSessionDenied` right after resolving identity and fails a revoked token closed
541
+ // (401) — so a deactivate/delete takes effect on the NEXT request, not the next login. The
542
+ // entry self-expires at the session TTL (the list never grows); reactivation lifts it
543
+ // (`allowSession`). `denySession`/`allowSession`/`isSessionDenied` are exported from
544
+ // @pramen/server so an app can revoke on its own compromise signals too.
498
545
 
499
546
  /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
500
547
  function isActive(v: unknown): boolean {
@@ -553,8 +600,11 @@ export function createUserHandlers(opts: { table?: string } = {}) {
553
600
  return updated;
554
601
  }),
555
602
 
556
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
557
- * token refresh); existing tokens still expire naturally within the TTL. */
603
+ /** Admin: activate / deactivate a user. Deactivating blocks future logins AND
604
+ * refreshSession, AND revokes OUTSTANDING tokens immediately via the KV denylist (the
605
+ * Worker fails them closed) — so revocation no longer waits out the token TTL. The
606
+ * denylist entry self-expires at the session TTL. Reactivating LIFTS the entry (it is
607
+ * username-scoped, so a stale entry would otherwise lock out even a fresh login). */
558
608
  setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
559
609
  if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
560
610
  if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
@@ -563,6 +613,9 @@ export function createUserHandlers(opts: { table?: string } = {}) {
563
613
  }
564
614
  const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
565
615
  if (!updated) throw new BadRequest("user not found");
616
+ // KV is not part of the mutation's transaction — do it after the update succeeds.
617
+ if (input.active === false) await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
618
+ else await allowSession(ctx.kv, input.username);
566
619
  return updated;
567
620
  }),
568
621
 
@@ -573,6 +626,8 @@ export function createUserHandlers(opts: { table?: string } = {}) {
573
626
  if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
574
627
  const deleted = await usersDb(ctx).delete(table, input.username);
575
628
  if (!deleted) throw new BadRequest("user not found");
629
+ // Revoke any outstanding tokens for the now-deleted user (self-expires at the TTL).
630
+ await denySession(ctx.kv, input.username, sessionTtlOf(ctx));
576
631
  return { ok: true };
577
632
  }),
578
633