@pramen/auth 0.0.36 → 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
@@ -19,6 +19,9 @@ export declare const authSchema: {
19
19
  } & {
20
20
  readonly unique: true;
21
21
  };
22
+ emailVerified: {
23
+ readonly type: "integer";
24
+ };
22
25
  active: {
23
26
  readonly type: "boolean";
24
27
  } & {
@@ -34,17 +37,19 @@ export declare function verifyPassword(password: string, stored: string): Promis
34
37
  export declare function signToken(claims: Record<string, unknown>, secret: string, opts?: {
35
38
  ttlSeconds?: number;
36
39
  }): Promise<string>;
37
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
38
- * 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. */
39
42
  export declare const authHandlers: {
40
43
  signup: import("@pramen/server").Handler<{
41
44
  username: string;
42
45
  password: string;
46
+ email?: string;
43
47
  }, {
44
48
  token: string;
45
49
  user: {
46
50
  username: string;
47
51
  roles: string[];
52
+ email: string | null;
48
53
  };
49
54
  }>;
50
55
  login: import("@pramen/server").Handler<{
@@ -58,6 +63,13 @@ export declare const authHandlers: {
58
63
  };
59
64
  }>;
60
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
+ }>;
61
73
  };
62
74
  export declare const magicLinkSchema: {
63
75
  auth_magic_links: import("@pramen/server").EntityDef<{
@@ -80,6 +92,33 @@ export declare const magicLinkSchema: {
80
92
  };
81
93
  }, Record<string, never>>;
82
94
  };
95
+ export declare const emailTokenSchema: {
96
+ auth_email_tokens: import("@pramen/server").EntityDef<{
97
+ tokenHash: {
98
+ readonly type: "text";
99
+ readonly primaryKey: true;
100
+ readonly notNull: true;
101
+ };
102
+ purpose: {
103
+ readonly type: "text";
104
+ };
105
+ username: {
106
+ readonly type: "text";
107
+ };
108
+ email: {
109
+ readonly type: "text";
110
+ };
111
+ expiresAt: {
112
+ readonly type: "integer";
113
+ };
114
+ consumedAt: {
115
+ readonly type: "integer";
116
+ };
117
+ createdAt: {
118
+ readonly type: "integer";
119
+ };
120
+ }, Record<string, never>>;
121
+ };
83
122
  export interface MagicLinkOptions {
84
123
  /** Deliver the link to the recipient. Receives the handler ctx and the raw token —
85
124
  * build the URL however your app routes it, e.g. `${ctx.env.APP_URL}/auth?token=${token}`.
@@ -148,8 +187,11 @@ export declare function createUserHandlers(opts?: {
148
187
  username: string;
149
188
  roles: string[];
150
189
  }, Record<string, unknown>>;
151
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
152
- * 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). */
153
195
  setUserActive: import("@pramen/server").Handler<{
154
196
  username: string;
155
197
  active: boolean;
@@ -166,7 +208,9 @@ export declare function createUserHandlers(opts?: {
166
208
  * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
167
209
  changeEmail: import("@pramen/server").Handler<{
168
210
  email: string;
169
- }, Record<string, unknown>>;
211
+ }, {
212
+ emailVerified: null;
213
+ }>;
170
214
  /** Self-service: change the caller's password. A credential op — it reads the
171
215
  * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
172
216
  * password, then writes the new one. Self-scoped by the verified identity, so it
@@ -196,8 +240,11 @@ export declare const userHandlers: {
196
240
  username: string;
197
241
  roles: string[];
198
242
  }, Record<string, unknown>>;
199
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
200
- * 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). */
201
248
  setUserActive: import("@pramen/server").Handler<{
202
249
  username: string;
203
250
  active: boolean;
@@ -214,7 +261,9 @@ export declare const userHandlers: {
214
261
  * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
215
262
  changeEmail: import("@pramen/server").Handler<{
216
263
  email: string;
217
- }, Record<string, unknown>>;
264
+ }, {
265
+ emailVerified: null;
266
+ }>;
218
267
  /** Self-service: change the caller's password. A credential op — it reads the
219
268
  * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
220
269
  * password, then writes the new one. Self-scoped by the verified identity, so it
@@ -253,3 +302,53 @@ export declare function authPolicies(opts?: {
253
302
  admin: Policy[];
254
303
  self: Policy[];
255
304
  };
305
+ export interface PasswordResetOptions {
306
+ /** Deliver the reset link. Receives the ctx + `{ email, token, username }` — build the
307
+ * URL your app routes to, e.g. `${ctx.env.APP_URL}/reset?token=${token}`. Called from the
308
+ * `sendPasswordResetEmail` TASK (after commit), like magic-link's sendEmail. */
309
+ sendEmail: (ctx: HandlerContext, args: {
310
+ email: string;
311
+ token: string;
312
+ username: string;
313
+ }) => void | Promise<void>;
314
+ /** The users table to reset against (must have `username` PK + `passwordHash`/`email`).
315
+ * Default `auth_users`; pass your own authSchema-shaped table (as with createUserHandlers). */
316
+ table?: string;
317
+ /** How long the reset link stays valid, in seconds. Default 3600 (1h). */
318
+ linkTtlSeconds?: number;
319
+ }
320
+ /** Build the `requestPasswordReset` / `resetPassword` handler pair + the
321
+ * `sendPasswordResetEmail` task. Both handlers are ANONYMOUS — the emailed token is the
322
+ * capability. `requestPasswordReset` is enumeration-safe (always `{ ok: true }`, sends only
323
+ * when an active account matches the email); `resetPassword` redeems the single-use token
324
+ * and sets the new password. Spread `emailTokenSchema` into your schema and `.tasks` into
325
+ * your task map. */
326
+ export declare function createPasswordReset(opts: PasswordResetOptions): {
327
+ handlers: HandlerMap;
328
+ tasks: AppTaskMap;
329
+ };
330
+ export interface EmailVerificationOptions {
331
+ /** Deliver the verification link. Receives the ctx + `{ email, token, username }` — build
332
+ * the URL your app routes to, e.g. `${ctx.env.APP_URL}/verify?token=${token}`. Called from
333
+ * the `sendVerificationEmail` TASK (after commit). */
334
+ sendEmail: (ctx: HandlerContext, args: {
335
+ email: string;
336
+ token: string;
337
+ username: string;
338
+ }) => void | Promise<void>;
339
+ /** The users table (must have `username` PK + `email`/`emailVerified`). Default `auth_users`. */
340
+ table?: string;
341
+ /** How long the verification link stays valid, in seconds. Default 86400 (24h). */
342
+ linkTtlSeconds?: number;
343
+ }
344
+ /** Build the `requestEmailVerification` / `verifyEmail` handler pair + the
345
+ * `sendVerificationEmail` task. `requestEmailVerification` is AUTHENTICATED (a caller
346
+ * verifies their OWN current email — runs right after signup, when the client already holds
347
+ * the session token); `verifyEmail` is ANONYMOUS (the token is the capability) and stamps
348
+ * `auth_users.emailVerified`. A token is bound to the address current at request time, so a
349
+ * later `changeEmail` invalidates it (verifyEmail rejects a token whose address no longer
350
+ * matches). Spread `emailTokenSchema` into your schema and `.tasks` into your task map. */
351
+ export declare function createEmailVerification(opts: EmailVerificationOptions): {
352
+ handlers: HandlerMap;
353
+ tasks: AppTaskMap;
354
+ };
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) => ({
@@ -23,6 +23,7 @@ export const authSchema = {
23
23
  passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
24
24
  roles: t.json(), // string[]
25
25
  email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
26
+ emailVerified: t.int(), // epoch ms the current `email` was confirmed; NULL = unverified (additive)
26
27
  active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
27
28
  createdAt: t.int(),
28
29
  })),
@@ -124,16 +125,56 @@ function sessionTtlOf(ctx) {
124
125
  const v = Number(ctx.env.AUTH_SESSION_TTL_SECONDS);
125
126
  return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
126
127
  }
128
+ /** A permissive email shape check (one `@`, a dot in the domain). The single source of
129
+ * truth for `parseEmail` and the optional email at signup. */
130
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
127
131
  function parseCreds(raw) {
128
132
  const o = (raw ?? {});
129
133
  if (typeof o.username !== "string" || o.username.length === 0)
130
134
  throw new Error("username is required");
131
135
  if (typeof o.password !== "string" || o.password.length < 8)
132
136
  throw new Error("password must be at least 8 characters");
133
- return { username: o.username, password: o.password };
137
+ // Optional contact email at signup — validated + normalized when present, so password
138
+ // reset and email verification work without a separate changeEmail round-trip. Absent ⇒
139
+ // the row's email stays NULL (still allowed; the user can set it later).
140
+ let email;
141
+ if (o.email !== undefined && o.email !== null && o.email !== "") {
142
+ const e = String(o.email).trim().toLowerCase();
143
+ if (!EMAIL_RE.test(e))
144
+ throw new Error("a valid email is required");
145
+ email = e;
146
+ }
147
+ return { username: o.username, password: o.password, email };
134
148
  }
135
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
136
- * 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. */
137
178
  export const authHandlers = {
138
179
  // NOTE on username enumeration: signup returns a distinct "username is taken" error,
139
180
  // which is an enumeration oracle. This is INHERENT to systems where the username is a
@@ -151,11 +192,21 @@ export const authHandlers = {
151
192
  await hashPassword(input.password);
152
193
  throw new BadRequest("username is taken");
153
194
  }
195
+ // A supplied email must be free (the column is unique). Same clean-400 shape as
196
+ // changeEmail rather than surfacing the DB constraint as a 500.
197
+ if (input.email) {
198
+ const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
199
+ if (emailTaken.length > 0)
200
+ throw new BadRequest("email already in use");
201
+ }
154
202
  const roles = DEFAULT_ROLES;
155
203
  const passwordHash = await hashPassword(input.password);
156
- await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", input.username, passwordHash, JSON.stringify(roles), Date.now());
204
+ // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
205
+ // createEmailVerification (requestEmailVerification runs right after signup — the
206
+ // client already holds the returned session token).
207
+ 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());
157
208
  const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
158
- return { token, user: { username: input.username, roles } };
209
+ return { token, user: { username: input.username, roles, email: input.email ?? null } };
159
210
  }, { input: parseCreds }),
160
211
  login: mutation(async (ctx, input) => {
161
212
  const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
@@ -179,6 +230,10 @@ export const authHandlers = {
179
230
  return { token, user: { username: String(u.username), roles } };
180
231
  }, { input: parseCreds }),
181
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),
182
237
  };
183
238
  // --- magic link (passwordless) login ---------------------------------------
184
239
  //
@@ -206,6 +261,23 @@ export const magicLinkSchema = {
206
261
  createdAt: t.int(),
207
262
  })),
208
263
  };
264
+ // One-time email-token table shared by password reset AND email verification (spread it
265
+ // once if you use EITHER `createPasswordReset` or `createEmailVerification`). Rows are
266
+ // discriminated by `purpose` ("reset" | "verify"); only a SHA-256 hash of the token is
267
+ // stored, so a DB leak never exposes a live token. `username` binds the token to the
268
+ // account it acts on; `email` pins the address it was minted for (verification rejects a
269
+ // token whose address the user has since changed).
270
+ export const emailTokenSchema = {
271
+ auth_email_tokens: Entity((t) => ({
272
+ tokenHash: t.textId(), // PK = sha256(token)
273
+ purpose: t.text(), // "reset" | "verify"
274
+ username: t.text(), // the account (JWT sub) the token acts on
275
+ email: t.text(), // the address at mint time
276
+ expiresAt: t.int(), // epoch ms
277
+ consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
278
+ createdAt: t.int(),
279
+ })),
280
+ };
209
281
  async function sha256Hex(s) {
210
282
  const digest = await crypto.subtle.digest("SHA-256", enc(s));
211
283
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -217,7 +289,7 @@ function mintToken() {
217
289
  function parseEmail(raw) {
218
290
  const o = (raw ?? {});
219
291
  const email = typeof o.email === "string" ? o.email.trim().toLowerCase() : "";
220
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
292
+ if (!EMAIL_RE.test(email))
221
293
  throw new BadRequest("a valid email is required");
222
294
  return { email };
223
295
  }
@@ -250,6 +322,10 @@ export function createMagicLinkAuth(opts) {
250
322
  const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
251
323
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
252
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),
253
329
  /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
254
330
  * and email them a fresh magic link. Idempotent — inviting an existing user just
255
331
  * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
@@ -340,12 +416,19 @@ export function createMagicLinkAuth(opts) {
340
416
  // roles (admin manages everyone; the authenticated user manages only itself). Because
341
417
  // the admin read policy restricts `fields`, `passwordHash` is never projected back.
342
418
  //
343
- // Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
344
- // take effect on the user's NEXT login not instantly. That lag is the cost of the
345
- // stateless, verify-only core (no session store, by design). Tune the revocation window
346
- // with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
347
- // app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
348
- // 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.
349
432
  /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
350
433
  function isActive(v) {
351
434
  return v == null || Number(v) !== 0;
@@ -394,8 +477,11 @@ export function createUserHandlers(opts = {}) {
394
477
  throw new BadRequest("user not found"); // (or out of the caller's update scope)
395
478
  return updated;
396
479
  }),
397
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
398
- * 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). */
399
485
  setUserActive: mutation(async (ctx, input) => {
400
486
  if (typeof input?.username !== "string" || input.username.length === 0)
401
487
  throw new BadRequest("username is required");
@@ -407,6 +493,11 @@ export function createUserHandlers(opts = {}) {
407
493
  const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
408
494
  if (!updated)
409
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);
410
501
  return updated;
411
502
  }),
412
503
  /** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
@@ -419,6 +510,8 @@ export function createUserHandlers(opts = {}) {
419
510
  const deleted = await usersDb(ctx).delete(table, input.username);
420
511
  if (!deleted)
421
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));
422
515
  return { ok: true };
423
516
  }),
424
517
  /** Self-service: change the caller's contact email. The ACL self policy scopes the
@@ -433,7 +526,13 @@ export function createUserHandlers(opts = {}) {
433
526
  const updated = await usersDb(ctx).update(table, userId, { email });
434
527
  if (!updated)
435
528
  throw new Unauthorized("authentication required");
436
- return updated;
529
+ // The new address is UNVERIFIED — clear any prior verification so `emailVerified`
530
+ // never claims an unconfirmed address. Raw (ACL-bypassing) but self-scoped by the
531
+ // verified identity, and it only ever CLEARS the flag (routing it through the self
532
+ // update policy would instead let a user set their own verified state). Any pending
533
+ // verify token for the old address is now dead (verifyEmail's current-email guard).
534
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = NULL WHERE username = ?`, userId);
535
+ return { ...updated, emailVerified: null };
437
536
  }),
438
537
  /** Self-service: change the caller's password. A credential op — it reads the
439
538
  * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
@@ -460,9 +559,9 @@ export function createUserHandlers(opts = {}) {
460
559
  * `createUserHandlers()`; spread alongside `authHandlers`. */
461
560
  export const userHandlers = createUserHandlers();
462
561
  // Fields a self-service caller may see of their own row (never passwordHash/roles).
463
- const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
562
+ const SELF_READ_FIELDS = ["username", "email", "emailVerified", "active", "createdAt"];
464
563
  // Fields an admin may see of any user (never passwordHash).
465
- const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
564
+ const ADMIN_READ_FIELDS = ["username", "roles", "email", "emailVerified", "active", "createdAt"];
466
565
  /** ACL policy fragments that turn on the user-management handlers. Spread `admin`
467
566
  * into your admin role and `self` into your authenticated-user role:
468
567
  *
@@ -497,3 +596,138 @@ export function authPolicies(opts = {}) {
497
596
  ],
498
597
  };
499
598
  }
599
+ // --- password reset + email verification -------------------------------------
600
+ //
601
+ // Two one-time-email-token flows, built on the same machinery as magic-link: mint a
602
+ // random token, persist only its SHA-256 HASH + an expiry (in the shared
603
+ // `auth_email_tokens` table, spread `emailTokenSchema`), email the raw token from a TASK
604
+ // (off the mutation's storage transaction — a slow send can't hold the store lock), and
605
+ // redeem it once. Both are transport-agnostic: you supply `sendEmail`; pramen owns the
606
+ // token lifecycle. Wire the returned `tasks` into your app's task map, or the token is
607
+ // written but the email never sends.
608
+ const PURPOSE_RESET = "reset";
609
+ const PURPOSE_VERIFY = "verify";
610
+ /** Mint a one-time token for `username`, invalidate any prior pending token of the same
611
+ * purpose for that user (only the latest works), and persist its hash + expiry. Returns
612
+ * the raw token (the caller enqueues the send task with it). */
613
+ async function issueEmailToken(ctx, purpose, username, email, expiresAt) {
614
+ const token = mintToken();
615
+ const tokenHash = await sha256Hex(token);
616
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", purpose, username);
617
+ await ctx.db.exec("INSERT INTO auth_email_tokens (tokenHash, purpose, username, email, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)", tokenHash, purpose, username, email, expiresAt, Date.now());
618
+ return token;
619
+ }
620
+ /** Validate a token (right purpose, unexpired, unconsumed) and CONSUME it (single-use).
621
+ * Returns the account + address it was minted for. Throws Unauthorized on any failure —
622
+ * the same opaque error for missing / wrong-purpose / expired / already-used, so a caller
623
+ * learns nothing beyond "this token won't work". */
624
+ async function redeemEmailToken(ctx, purpose, token) {
625
+ const tokenHash = await sha256Hex(token);
626
+ const rows = await ctx.db.exec("SELECT username, email, expiresAt, consumedAt FROM auth_email_tokens WHERE tokenHash = ? AND purpose = ? LIMIT 1", tokenHash, purpose);
627
+ const row = rows[0];
628
+ if (!row || row.consumedAt != null || Number(row.expiresAt) < Date.now())
629
+ throw new Unauthorized("invalid or expired token");
630
+ await ctx.db.exec("UPDATE auth_email_tokens SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
631
+ return { username: String(row.username), email: String(row.email) };
632
+ }
633
+ /** Parse `{ token, newPassword }` for `resetPassword`. */
634
+ function parseResetInput(raw) {
635
+ const o = (raw ?? {});
636
+ if (typeof o.token !== "string" || o.token.length === 0)
637
+ throw new BadRequest("token is required");
638
+ if (typeof o.newPassword !== "string" || o.newPassword.length < 8)
639
+ throw new BadRequest("newPassword must be at least 8 characters");
640
+ return { token: o.token, newPassword: o.newPassword };
641
+ }
642
+ /** Build the `requestPasswordReset` / `resetPassword` handler pair + the
643
+ * `sendPasswordResetEmail` task. Both handlers are ANONYMOUS — the emailed token is the
644
+ * capability. `requestPasswordReset` is enumeration-safe (always `{ ok: true }`, sends only
645
+ * when an active account matches the email); `resetPassword` redeems the single-use token
646
+ * and sets the new password. Spread `emailTokenSchema` into your schema and `.tasks` into
647
+ * your task map. */
648
+ export function createPasswordReset(opts) {
649
+ const table = assertIdentifier(opts.table ?? "auth_users");
650
+ const linkTtlMs = (opts.linkTtlSeconds ?? 3600) * 1000;
651
+ const handlers = {
652
+ /** Anonymous: request a reset link for `email`. Resolves the address to an ACTIVE
653
+ * account and, only then, mints a token + enqueues the send — but the response is the
654
+ * same `{ ok: true }` whether or not any account matched (no enumeration). */
655
+ requestPasswordReset: mutation(async (ctx, input) => {
656
+ const rows = await ctx.db.exec(`SELECT username, active FROM ${table} WHERE email = ? LIMIT 1`, input.email);
657
+ const u = rows[0];
658
+ if (u && isActive(u.active)) {
659
+ const token = await issueEmailToken(ctx, PURPOSE_RESET, String(u.username), input.email, Date.now() + linkTtlMs);
660
+ await ctx.tasks.enqueue({ kind: "sendPasswordResetEmail", payload: { email: input.email, token, username: String(u.username) } });
661
+ }
662
+ return { ok: true };
663
+ }, { input: parseEmail }),
664
+ /** Anonymous: redeem a reset token and set the new password. Single-use (the token is
665
+ * consumed first). The account must still exist + be active. Any other pending reset
666
+ * tokens for the user are dropped on success. */
667
+ resetPassword: mutation(async (ctx, input) => {
668
+ const { username } = await redeemEmailToken(ctx, PURPOSE_RESET, input.token);
669
+ const rows = await ctx.db.exec(`SELECT active FROM ${table} WHERE username = ? LIMIT 1`, username);
670
+ if (!rows[0])
671
+ throw new Unauthorized("invalid or expired token");
672
+ if (!isActive(rows[0].active))
673
+ throw new Unauthorized("account is deactivated");
674
+ await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(input.newPassword), username);
675
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", PURPOSE_RESET, username);
676
+ return { ok: true };
677
+ }, { input: parseResetInput }),
678
+ };
679
+ const tasks = {
680
+ sendPasswordResetEmail: async (ctx, payload) => {
681
+ const p = payload;
682
+ await opts.sendEmail(ctx, p);
683
+ },
684
+ };
685
+ return { handlers, tasks };
686
+ }
687
+ /** Build the `requestEmailVerification` / `verifyEmail` handler pair + the
688
+ * `sendVerificationEmail` task. `requestEmailVerification` is AUTHENTICATED (a caller
689
+ * verifies their OWN current email — runs right after signup, when the client already holds
690
+ * the session token); `verifyEmail` is ANONYMOUS (the token is the capability) and stamps
691
+ * `auth_users.emailVerified`. A token is bound to the address current at request time, so a
692
+ * later `changeEmail` invalidates it (verifyEmail rejects a token whose address no longer
693
+ * matches). Spread `emailTokenSchema` into your schema and `.tasks` into your task map. */
694
+ export function createEmailVerification(opts) {
695
+ const table = assertIdentifier(opts.table ?? "auth_users");
696
+ const linkTtlMs = (opts.linkTtlSeconds ?? 86_400) * 1000;
697
+ const handlers = {
698
+ /** Authenticated: email the caller a verification link for their CURRENT address. A
699
+ * no-op `{ ok: true, alreadyVerified: true }` if already verified; 400 if no email is set. */
700
+ requestEmailVerification: mutation(async (ctx) => {
701
+ const userId = requireUserId(ctx);
702
+ const rows = await ctx.db.exec(`SELECT email, emailVerified FROM ${table} WHERE username = ? LIMIT 1`, userId);
703
+ const u = rows[0];
704
+ const email = u && typeof u.email === "string" ? u.email : "";
705
+ if (!email)
706
+ throw new BadRequest("no email on file — set one with changeEmail first");
707
+ if (u.emailVerified != null)
708
+ return { ok: true, alreadyVerified: true };
709
+ const token = await issueEmailToken(ctx, PURPOSE_VERIFY, userId, email, Date.now() + linkTtlMs);
710
+ await ctx.tasks.enqueue({ kind: "sendVerificationEmail", payload: { email, token, username: userId } });
711
+ return { ok: true };
712
+ }, { auth: "authenticated" }),
713
+ /** Anonymous: redeem a verification token and mark the address verified. Guards that the
714
+ * account's CURRENT email still equals the address the token was minted for — a stale
715
+ * token (email changed since request) is rejected, never verifying the new address. */
716
+ verifyEmail: mutation(async (ctx, input) => {
717
+ const { username, email } = await redeemEmailToken(ctx, PURPOSE_VERIFY, input.token);
718
+ const rows = await ctx.db.exec(`SELECT email FROM ${table} WHERE username = ? LIMIT 1`, username);
719
+ const current = rows[0] && typeof rows[0].email === "string" ? String(rows[0].email) : null;
720
+ if (current == null || current !== email)
721
+ throw new Unauthorized("invalid or expired token");
722
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = ? WHERE username = ?`, Date.now(), username);
723
+ return { ok: true, email };
724
+ }, { input: parseLinkToken }),
725
+ };
726
+ const tasks = {
727
+ sendVerificationEmail: async (ctx, payload) => {
728
+ const p = payload;
729
+ await opts.sendEmail(ctx, p);
730
+ },
731
+ };
732
+ return { handlers, tasks };
733
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.36",
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.36"
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 ---
@@ -27,6 +27,7 @@ export const authSchema = {
27
27
  passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
28
28
  roles: t.json(), // string[]
29
29
  email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
30
+ emailVerified: t.int(), // epoch ms the current `email` was confirmed; NULL = unverified (additive)
30
31
  active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
31
32
  createdAt: t.int(),
32
33
  })),
@@ -148,15 +149,58 @@ function sessionTtlOf(ctx: HandlerContext): number {
148
149
  return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
149
150
  }
150
151
 
151
- function parseCreds(raw: unknown): { username: string; password: string } {
152
+ /** A permissive email shape check (one `@`, a dot in the domain). The single source of
153
+ * truth for `parseEmail` and the optional email at signup. */
154
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
155
+
156
+ function parseCreds(raw: unknown): { username: string; password: string; email?: string } {
152
157
  const o = (raw ?? {}) as Record<string, unknown>;
153
158
  if (typeof o.username !== "string" || o.username.length === 0) throw new Error("username is required");
154
159
  if (typeof o.password !== "string" || o.password.length < 8) throw new Error("password must be at least 8 characters");
155
- return { username: o.username, password: o.password };
160
+ // Optional contact email at signup — validated + normalized when present, so password
161
+ // reset and email verification work without a separate changeEmail round-trip. Absent ⇒
162
+ // the row's email stays NULL (still allowed; the user can set it later).
163
+ let email: string | undefined;
164
+ if (o.email !== undefined && o.email !== null && o.email !== "") {
165
+ const e = String(o.email).trim().toLowerCase();
166
+ if (!EMAIL_RE.test(e)) throw new Error("a valid email is required");
167
+ email = e;
168
+ }
169
+ return { username: o.username, password: o.password, email };
156
170
  }
157
171
 
158
- /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
159
- * 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. */
160
204
  export const authHandlers = {
161
205
  // NOTE on username enumeration: signup returns a distinct "username is taken" error,
162
206
  // which is an enumeration oracle. This is INHERENT to systems where the username is a
@@ -167,7 +211,7 @@ export const authHandlers = {
167
211
  // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
168
212
  // which keys on the email and always returns the same `{ ok: true }`.
169
213
  signup: mutation(
170
- async (ctx, input: { username: string; password: string }) => {
214
+ async (ctx, input: { username: string; password: string; email?: string }) => {
171
215
  const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
172
216
  if (existing.length > 0) {
173
217
  // Equalize timing with the available path (which hashes below) so the taken vs.
@@ -175,17 +219,27 @@ export const authHandlers = {
175
219
  await hashPassword(input.password);
176
220
  throw new BadRequest("username is taken");
177
221
  }
222
+ // A supplied email must be free (the column is unique). Same clean-400 shape as
223
+ // changeEmail rather than surfacing the DB constraint as a 500.
224
+ if (input.email) {
225
+ const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
226
+ if (emailTaken.length > 0) throw new BadRequest("email already in use");
227
+ }
178
228
  const roles = DEFAULT_ROLES;
179
229
  const passwordHash = await hashPassword(input.password);
230
+ // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
231
+ // createEmailVerification (requestEmailVerification runs right after signup — the
232
+ // client already holds the returned session token).
180
233
  await ctx.db.exec(
181
- "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
234
+ "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
182
235
  input.username,
183
236
  passwordHash,
184
237
  JSON.stringify(roles),
238
+ input.email ?? null,
185
239
  Date.now(),
186
240
  );
187
241
  const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
188
- return { token, user: { username: input.username, roles } };
242
+ return { token, user: { username: input.username, roles, email: input.email ?? null } };
189
243
  },
190
244
  { input: parseCreds },
191
245
  ),
@@ -218,6 +272,11 @@ export const authHandlers = {
218
272
  ),
219
273
 
220
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),
221
280
  };
222
281
 
223
282
  // --- magic link (passwordless) login ---------------------------------------
@@ -248,6 +307,24 @@ export const magicLinkSchema = {
248
307
  })),
249
308
  };
250
309
 
310
+ // One-time email-token table shared by password reset AND email verification (spread it
311
+ // once if you use EITHER `createPasswordReset` or `createEmailVerification`). Rows are
312
+ // discriminated by `purpose` ("reset" | "verify"); only a SHA-256 hash of the token is
313
+ // stored, so a DB leak never exposes a live token. `username` binds the token to the
314
+ // account it acts on; `email` pins the address it was minted for (verification rejects a
315
+ // token whose address the user has since changed).
316
+ export const emailTokenSchema = {
317
+ auth_email_tokens: Entity((t) => ({
318
+ tokenHash: t.textId(), // PK = sha256(token)
319
+ purpose: t.text(), // "reset" | "verify"
320
+ username: t.text(), // the account (JWT sub) the token acts on
321
+ email: t.text(), // the address at mint time
322
+ expiresAt: t.int(), // epoch ms
323
+ consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
324
+ createdAt: t.int(),
325
+ })),
326
+ };
327
+
251
328
  async function sha256Hex(s: string): Promise<string> {
252
329
  const digest = await crypto.subtle.digest("SHA-256", enc(s));
253
330
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -261,7 +338,7 @@ function mintToken(): string {
261
338
  function parseEmail(raw: unknown): { email: string } {
262
339
  const o = (raw ?? {}) as Record<string, unknown>;
263
340
  const email = typeof o.email === "string" ? o.email.trim().toLowerCase() : "";
264
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new BadRequest("a valid email is required");
341
+ if (!EMAIL_RE.test(email)) throw new BadRequest("a valid email is required");
265
342
  return { email };
266
343
  }
267
344
 
@@ -315,6 +392,11 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
315
392
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
316
393
 
317
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
+
318
400
  /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
319
401
  * and email them a fresh magic link. Idempotent — inviting an existing user just
320
402
  * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
@@ -447,12 +529,19 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
447
529
  // roles (admin manages everyone; the authenticated user manages only itself). Because
448
530
  // the admin read policy restricts `fields`, `passwordHash` is never projected back.
449
531
  //
450
- // Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
451
- // take effect on the user's NEXT login not instantly. That lag is the cost of the
452
- // stateless, verify-only core (no session store, by design). Tune the revocation window
453
- // with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
454
- // app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
455
- // 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.
456
545
 
457
546
  /** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
458
547
  function isActive(v: unknown): boolean {
@@ -511,8 +600,11 @@ export function createUserHandlers(opts: { table?: string } = {}) {
511
600
  return updated;
512
601
  }),
513
602
 
514
- /** Admin: activate / deactivate a user. Deactivating blocks future logins (and
515
- * 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). */
516
608
  setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
517
609
  if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
518
610
  if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
@@ -521,6 +613,9 @@ export function createUserHandlers(opts: { table?: string } = {}) {
521
613
  }
522
614
  const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
523
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);
524
619
  return updated;
525
620
  }),
526
621
 
@@ -531,6 +626,8 @@ export function createUserHandlers(opts: { table?: string } = {}) {
531
626
  if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
532
627
  const deleted = await usersDb(ctx).delete(table, input.username);
533
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));
534
631
  return { ok: true };
535
632
  }),
536
633
 
@@ -544,7 +641,13 @@ export function createUserHandlers(opts: { table?: string } = {}) {
544
641
  if (taken.length > 0) throw new BadRequest("email already in use");
545
642
  const updated = await usersDb(ctx).update(table, userId, { email });
546
643
  if (!updated) throw new Unauthorized("authentication required");
547
- return updated;
644
+ // The new address is UNVERIFIED — clear any prior verification so `emailVerified`
645
+ // never claims an unconfirmed address. Raw (ACL-bypassing) but self-scoped by the
646
+ // verified identity, and it only ever CLEARS the flag (routing it through the self
647
+ // update policy would instead let a user set their own verified state). Any pending
648
+ // verify token for the old address is now dead (verifyEmail's current-email guard).
649
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = NULL WHERE username = ?`, userId);
650
+ return { ...updated, emailVerified: null };
548
651
  }),
549
652
 
550
653
  /** Self-service: change the caller's password. A credential op — it reads the
@@ -573,9 +676,9 @@ export function createUserHandlers(opts: { table?: string } = {}) {
573
676
  export const userHandlers = createUserHandlers();
574
677
 
575
678
  // Fields a self-service caller may see of their own row (never passwordHash/roles).
576
- const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
679
+ const SELF_READ_FIELDS = ["username", "email", "emailVerified", "active", "createdAt"];
577
680
  // Fields an admin may see of any user (never passwordHash).
578
- const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
681
+ const ADMIN_READ_FIELDS = ["username", "roles", "email", "emailVerified", "active", "createdAt"];
579
682
 
580
683
  /** ACL policy fragments that turn on the user-management handlers. Spread `admin`
581
684
  * into your admin role and `self` into your authenticated-user role:
@@ -619,3 +722,192 @@ export function authPolicies(opts: {
619
722
  ],
620
723
  };
621
724
  }
725
+
726
+ // --- password reset + email verification -------------------------------------
727
+ //
728
+ // Two one-time-email-token flows, built on the same machinery as magic-link: mint a
729
+ // random token, persist only its SHA-256 HASH + an expiry (in the shared
730
+ // `auth_email_tokens` table, spread `emailTokenSchema`), email the raw token from a TASK
731
+ // (off the mutation's storage transaction — a slow send can't hold the store lock), and
732
+ // redeem it once. Both are transport-agnostic: you supply `sendEmail`; pramen owns the
733
+ // token lifecycle. Wire the returned `tasks` into your app's task map, or the token is
734
+ // written but the email never sends.
735
+
736
+ const PURPOSE_RESET = "reset";
737
+ const PURPOSE_VERIFY = "verify";
738
+
739
+ /** Mint a one-time token for `username`, invalidate any prior pending token of the same
740
+ * purpose for that user (only the latest works), and persist its hash + expiry. Returns
741
+ * the raw token (the caller enqueues the send task with it). */
742
+ async function issueEmailToken(ctx: HandlerContext, purpose: string, username: string, email: string, expiresAt: number): Promise<string> {
743
+ const token = mintToken();
744
+ const tokenHash = await sha256Hex(token);
745
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", purpose, username);
746
+ await ctx.db.exec(
747
+ "INSERT INTO auth_email_tokens (tokenHash, purpose, username, email, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)",
748
+ tokenHash,
749
+ purpose,
750
+ username,
751
+ email,
752
+ expiresAt,
753
+ Date.now(),
754
+ );
755
+ return token;
756
+ }
757
+
758
+ /** Validate a token (right purpose, unexpired, unconsumed) and CONSUME it (single-use).
759
+ * Returns the account + address it was minted for. Throws Unauthorized on any failure —
760
+ * the same opaque error for missing / wrong-purpose / expired / already-used, so a caller
761
+ * learns nothing beyond "this token won't work". */
762
+ async function redeemEmailToken(ctx: HandlerContext, purpose: string, token: string): Promise<{ username: string; email: string }> {
763
+ const tokenHash = await sha256Hex(token);
764
+ const rows = await ctx.db.exec(
765
+ "SELECT username, email, expiresAt, consumedAt FROM auth_email_tokens WHERE tokenHash = ? AND purpose = ? LIMIT 1",
766
+ tokenHash,
767
+ purpose,
768
+ );
769
+ const row = rows[0];
770
+ if (!row || row.consumedAt != null || Number(row.expiresAt) < Date.now()) throw new Unauthorized("invalid or expired token");
771
+ await ctx.db.exec("UPDATE auth_email_tokens SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
772
+ return { username: String(row.username), email: String(row.email) };
773
+ }
774
+
775
+ /** Parse `{ token, newPassword }` for `resetPassword`. */
776
+ function parseResetInput(raw: unknown): { token: string; newPassword: string } {
777
+ const o = (raw ?? {}) as Record<string, unknown>;
778
+ if (typeof o.token !== "string" || o.token.length === 0) throw new BadRequest("token is required");
779
+ if (typeof o.newPassword !== "string" || o.newPassword.length < 8) throw new BadRequest("newPassword must be at least 8 characters");
780
+ return { token: o.token, newPassword: o.newPassword };
781
+ }
782
+
783
+ export interface PasswordResetOptions {
784
+ /** Deliver the reset link. Receives the ctx + `{ email, token, username }` — build the
785
+ * URL your app routes to, e.g. `${ctx.env.APP_URL}/reset?token=${token}`. Called from the
786
+ * `sendPasswordResetEmail` TASK (after commit), like magic-link's sendEmail. */
787
+ sendEmail: (ctx: HandlerContext, args: { email: string; token: string; username: string }) => void | Promise<void>;
788
+ /** The users table to reset against (must have `username` PK + `passwordHash`/`email`).
789
+ * Default `auth_users`; pass your own authSchema-shaped table (as with createUserHandlers). */
790
+ table?: string;
791
+ /** How long the reset link stays valid, in seconds. Default 3600 (1h). */
792
+ linkTtlSeconds?: number;
793
+ }
794
+
795
+ /** Build the `requestPasswordReset` / `resetPassword` handler pair + the
796
+ * `sendPasswordResetEmail` task. Both handlers are ANONYMOUS — the emailed token is the
797
+ * capability. `requestPasswordReset` is enumeration-safe (always `{ ok: true }`, sends only
798
+ * when an active account matches the email); `resetPassword` redeems the single-use token
799
+ * and sets the new password. Spread `emailTokenSchema` into your schema and `.tasks` into
800
+ * your task map. */
801
+ export function createPasswordReset(opts: PasswordResetOptions): { handlers: HandlerMap; tasks: AppTaskMap } {
802
+ const table = assertIdentifier(opts.table ?? "auth_users");
803
+ const linkTtlMs = (opts.linkTtlSeconds ?? 3600) * 1000;
804
+
805
+ const handlers: HandlerMap = {
806
+ /** Anonymous: request a reset link for `email`. Resolves the address to an ACTIVE
807
+ * account and, only then, mints a token + enqueues the send — but the response is the
808
+ * same `{ ok: true }` whether or not any account matched (no enumeration). */
809
+ requestPasswordReset: mutation(
810
+ async (ctx, input: { email: string }) => {
811
+ const rows = await ctx.db.exec(`SELECT username, active FROM ${table} WHERE email = ? LIMIT 1`, input.email);
812
+ const u = rows[0];
813
+ if (u && isActive(u.active)) {
814
+ const token = await issueEmailToken(ctx, PURPOSE_RESET, String(u.username), input.email, Date.now() + linkTtlMs);
815
+ await ctx.tasks.enqueue({ kind: "sendPasswordResetEmail", payload: { email: input.email, token, username: String(u.username) } });
816
+ }
817
+ return { ok: true };
818
+ },
819
+ { input: parseEmail },
820
+ ),
821
+
822
+ /** Anonymous: redeem a reset token and set the new password. Single-use (the token is
823
+ * consumed first). The account must still exist + be active. Any other pending reset
824
+ * tokens for the user are dropped on success. */
825
+ resetPassword: mutation(
826
+ async (ctx, input: { token: string; newPassword: string }) => {
827
+ const { username } = await redeemEmailToken(ctx, PURPOSE_RESET, input.token);
828
+ const rows = await ctx.db.exec(`SELECT active FROM ${table} WHERE username = ? LIMIT 1`, username);
829
+ if (!rows[0]) throw new Unauthorized("invalid or expired token");
830
+ if (!isActive(rows[0].active)) throw new Unauthorized("account is deactivated");
831
+ await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(input.newPassword), username);
832
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", PURPOSE_RESET, username);
833
+ return { ok: true };
834
+ },
835
+ { input: parseResetInput },
836
+ ),
837
+ };
838
+
839
+ const tasks: AppTaskMap = {
840
+ sendPasswordResetEmail: async (ctx, payload) => {
841
+ const p = payload as { email: string; token: string; username: string };
842
+ await opts.sendEmail(ctx, p);
843
+ },
844
+ };
845
+
846
+ return { handlers, tasks };
847
+ }
848
+
849
+ export interface EmailVerificationOptions {
850
+ /** Deliver the verification link. Receives the ctx + `{ email, token, username }` — build
851
+ * the URL your app routes to, e.g. `${ctx.env.APP_URL}/verify?token=${token}`. Called from
852
+ * the `sendVerificationEmail` TASK (after commit). */
853
+ sendEmail: (ctx: HandlerContext, args: { email: string; token: string; username: string }) => void | Promise<void>;
854
+ /** The users table (must have `username` PK + `email`/`emailVerified`). Default `auth_users`. */
855
+ table?: string;
856
+ /** How long the verification link stays valid, in seconds. Default 86400 (24h). */
857
+ linkTtlSeconds?: number;
858
+ }
859
+
860
+ /** Build the `requestEmailVerification` / `verifyEmail` handler pair + the
861
+ * `sendVerificationEmail` task. `requestEmailVerification` is AUTHENTICATED (a caller
862
+ * verifies their OWN current email — runs right after signup, when the client already holds
863
+ * the session token); `verifyEmail` is ANONYMOUS (the token is the capability) and stamps
864
+ * `auth_users.emailVerified`. A token is bound to the address current at request time, so a
865
+ * later `changeEmail` invalidates it (verifyEmail rejects a token whose address no longer
866
+ * matches). Spread `emailTokenSchema` into your schema and `.tasks` into your task map. */
867
+ export function createEmailVerification(opts: EmailVerificationOptions): { handlers: HandlerMap; tasks: AppTaskMap } {
868
+ const table = assertIdentifier(opts.table ?? "auth_users");
869
+ const linkTtlMs = (opts.linkTtlSeconds ?? 86_400) * 1000;
870
+
871
+ const handlers: HandlerMap = {
872
+ /** Authenticated: email the caller a verification link for their CURRENT address. A
873
+ * no-op `{ ok: true, alreadyVerified: true }` if already verified; 400 if no email is set. */
874
+ requestEmailVerification: mutation(
875
+ async (ctx) => {
876
+ const userId = requireUserId(ctx);
877
+ const rows = await ctx.db.exec(`SELECT email, emailVerified FROM ${table} WHERE username = ? LIMIT 1`, userId);
878
+ const u = rows[0];
879
+ const email = u && typeof u.email === "string" ? u.email : "";
880
+ if (!email) throw new BadRequest("no email on file — set one with changeEmail first");
881
+ if (u.emailVerified != null) return { ok: true, alreadyVerified: true };
882
+ const token = await issueEmailToken(ctx, PURPOSE_VERIFY, userId, email, Date.now() + linkTtlMs);
883
+ await ctx.tasks.enqueue({ kind: "sendVerificationEmail", payload: { email, token, username: userId } });
884
+ return { ok: true };
885
+ },
886
+ { auth: "authenticated" },
887
+ ),
888
+
889
+ /** Anonymous: redeem a verification token and mark the address verified. Guards that the
890
+ * account's CURRENT email still equals the address the token was minted for — a stale
891
+ * token (email changed since request) is rejected, never verifying the new address. */
892
+ verifyEmail: mutation(
893
+ async (ctx, input: { token: string }) => {
894
+ const { username, email } = await redeemEmailToken(ctx, PURPOSE_VERIFY, input.token);
895
+ const rows = await ctx.db.exec(`SELECT email FROM ${table} WHERE username = ? LIMIT 1`, username);
896
+ const current = rows[0] && typeof rows[0].email === "string" ? String(rows[0].email) : null;
897
+ if (current == null || current !== email) throw new Unauthorized("invalid or expired token");
898
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = ? WHERE username = ?`, Date.now(), username);
899
+ return { ok: true, email };
900
+ },
901
+ { input: parseLinkToken },
902
+ ),
903
+ };
904
+
905
+ const tasks: AppTaskMap = {
906
+ sendVerificationEmail: async (ctx, payload) => {
907
+ const p = payload as { email: string; token: string; username: string };
908
+ await opts.sendEmail(ctx, p);
909
+ },
910
+ };
911
+
912
+ return { handlers, tasks };
913
+ }