@pramen/auth 0.0.36 → 0.0.37

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
  } & {
@@ -40,11 +43,13 @@ 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<{
@@ -80,6 +85,33 @@ export declare const magicLinkSchema: {
80
85
  };
81
86
  }, Record<string, never>>;
82
87
  };
88
+ export declare const emailTokenSchema: {
89
+ auth_email_tokens: import("@pramen/server").EntityDef<{
90
+ tokenHash: {
91
+ readonly type: "text";
92
+ readonly primaryKey: true;
93
+ readonly notNull: true;
94
+ };
95
+ purpose: {
96
+ readonly type: "text";
97
+ };
98
+ username: {
99
+ readonly type: "text";
100
+ };
101
+ email: {
102
+ readonly type: "text";
103
+ };
104
+ expiresAt: {
105
+ readonly type: "integer";
106
+ };
107
+ consumedAt: {
108
+ readonly type: "integer";
109
+ };
110
+ createdAt: {
111
+ readonly type: "integer";
112
+ };
113
+ }, Record<string, never>>;
114
+ };
83
115
  export interface MagicLinkOptions {
84
116
  /** Deliver the link to the recipient. Receives the handler ctx and the raw token —
85
117
  * build the URL however your app routes it, e.g. `${ctx.env.APP_URL}/auth?token=${token}`.
@@ -166,7 +198,9 @@ export declare function createUserHandlers(opts?: {
166
198
  * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
167
199
  changeEmail: import("@pramen/server").Handler<{
168
200
  email: string;
169
- }, Record<string, unknown>>;
201
+ }, {
202
+ emailVerified: null;
203
+ }>;
170
204
  /** Self-service: change the caller's password. A credential op — it reads the
171
205
  * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
172
206
  * password, then writes the new one. Self-scoped by the verified identity, so it
@@ -214,7 +248,9 @@ export declare const userHandlers: {
214
248
  * so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
215
249
  changeEmail: import("@pramen/server").Handler<{
216
250
  email: string;
217
- }, Record<string, unknown>>;
251
+ }, {
252
+ emailVerified: null;
253
+ }>;
218
254
  /** Self-service: change the caller's password. A credential op — it reads the
219
255
  * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
220
256
  * password, then writes the new one. Self-scoped by the verified identity, so it
@@ -253,3 +289,53 @@ export declare function authPolicies(opts?: {
253
289
  admin: Policy[];
254
290
  self: Policy[];
255
291
  };
292
+ export interface PasswordResetOptions {
293
+ /** Deliver the reset link. Receives the ctx + `{ email, token, username }` — build the
294
+ * URL your app routes to, e.g. `${ctx.env.APP_URL}/reset?token=${token}`. Called from the
295
+ * `sendPasswordResetEmail` TASK (after commit), like magic-link's sendEmail. */
296
+ sendEmail: (ctx: HandlerContext, args: {
297
+ email: string;
298
+ token: string;
299
+ username: string;
300
+ }) => void | Promise<void>;
301
+ /** The users table to reset against (must have `username` PK + `passwordHash`/`email`).
302
+ * Default `auth_users`; pass your own authSchema-shaped table (as with createUserHandlers). */
303
+ table?: string;
304
+ /** How long the reset link stays valid, in seconds. Default 3600 (1h). */
305
+ linkTtlSeconds?: number;
306
+ }
307
+ /** Build the `requestPasswordReset` / `resetPassword` handler pair + the
308
+ * `sendPasswordResetEmail` task. Both handlers are ANONYMOUS — the emailed token is the
309
+ * capability. `requestPasswordReset` is enumeration-safe (always `{ ok: true }`, sends only
310
+ * when an active account matches the email); `resetPassword` redeems the single-use token
311
+ * and sets the new password. Spread `emailTokenSchema` into your schema and `.tasks` into
312
+ * your task map. */
313
+ export declare function createPasswordReset(opts: PasswordResetOptions): {
314
+ handlers: HandlerMap;
315
+ tasks: AppTaskMap;
316
+ };
317
+ export interface EmailVerificationOptions {
318
+ /** Deliver the verification link. Receives the ctx + `{ email, token, username }` — build
319
+ * the URL your app routes to, e.g. `${ctx.env.APP_URL}/verify?token=${token}`. Called from
320
+ * the `sendVerificationEmail` TASK (after commit). */
321
+ sendEmail: (ctx: HandlerContext, args: {
322
+ email: string;
323
+ token: string;
324
+ username: string;
325
+ }) => void | Promise<void>;
326
+ /** The users table (must have `username` PK + `email`/`emailVerified`). Default `auth_users`. */
327
+ table?: string;
328
+ /** How long the verification link stays valid, in seconds. Default 86400 (24h). */
329
+ linkTtlSeconds?: number;
330
+ }
331
+ /** Build the `requestEmailVerification` / `verifyEmail` handler pair + the
332
+ * `sendVerificationEmail` task. `requestEmailVerification` is AUTHENTICATED (a caller
333
+ * verifies their OWN current email — runs right after signup, when the client already holds
334
+ * the session token); `verifyEmail` is ANONYMOUS (the token is the capability) and stamps
335
+ * `auth_users.emailVerified`. A token is bound to the address current at request time, so a
336
+ * later `changeEmail` invalidates it (verifyEmail rejects a token whose address no longer
337
+ * matches). Spread `emailTokenSchema` into your schema and `.tasks` into your task map. */
338
+ export declare function createEmailVerification(opts: EmailVerificationOptions): {
339
+ handlers: HandlerMap;
340
+ tasks: AppTaskMap;
341
+ };
package/dist/index.js CHANGED
@@ -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,13 +125,26 @@ 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
149
  /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
136
150
  * client never picks its own roles. Spread into your handler map. */
@@ -151,11 +165,21 @@ export const authHandlers = {
151
165
  await hashPassword(input.password);
152
166
  throw new BadRequest("username is taken");
153
167
  }
168
+ // A supplied email must be free (the column is unique). Same clean-400 shape as
169
+ // changeEmail rather than surfacing the DB constraint as a 500.
170
+ if (input.email) {
171
+ const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
172
+ if (emailTaken.length > 0)
173
+ throw new BadRequest("email already in use");
174
+ }
154
175
  const roles = DEFAULT_ROLES;
155
176
  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());
177
+ // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
178
+ // createEmailVerification (requestEmailVerification runs right after signup — the
179
+ // client already holds the returned session token).
180
+ 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
181
  const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
158
- return { token, user: { username: input.username, roles } };
182
+ return { token, user: { username: input.username, roles, email: input.email ?? null } };
159
183
  }, { input: parseCreds }),
160
184
  login: mutation(async (ctx, input) => {
161
185
  const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
@@ -206,6 +230,23 @@ export const magicLinkSchema = {
206
230
  createdAt: t.int(),
207
231
  })),
208
232
  };
233
+ // One-time email-token table shared by password reset AND email verification (spread it
234
+ // once if you use EITHER `createPasswordReset` or `createEmailVerification`). Rows are
235
+ // discriminated by `purpose` ("reset" | "verify"); only a SHA-256 hash of the token is
236
+ // stored, so a DB leak never exposes a live token. `username` binds the token to the
237
+ // account it acts on; `email` pins the address it was minted for (verification rejects a
238
+ // token whose address the user has since changed).
239
+ export const emailTokenSchema = {
240
+ auth_email_tokens: Entity((t) => ({
241
+ tokenHash: t.textId(), // PK = sha256(token)
242
+ purpose: t.text(), // "reset" | "verify"
243
+ username: t.text(), // the account (JWT sub) the token acts on
244
+ email: t.text(), // the address at mint time
245
+ expiresAt: t.int(), // epoch ms
246
+ consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
247
+ createdAt: t.int(),
248
+ })),
249
+ };
209
250
  async function sha256Hex(s) {
210
251
  const digest = await crypto.subtle.digest("SHA-256", enc(s));
211
252
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -217,7 +258,7 @@ function mintToken() {
217
258
  function parseEmail(raw) {
218
259
  const o = (raw ?? {});
219
260
  const email = typeof o.email === "string" ? o.email.trim().toLowerCase() : "";
220
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
261
+ if (!EMAIL_RE.test(email))
221
262
  throw new BadRequest("a valid email is required");
222
263
  return { email };
223
264
  }
@@ -433,7 +474,13 @@ export function createUserHandlers(opts = {}) {
433
474
  const updated = await usersDb(ctx).update(table, userId, { email });
434
475
  if (!updated)
435
476
  throw new Unauthorized("authentication required");
436
- return updated;
477
+ // The new address is UNVERIFIED — clear any prior verification so `emailVerified`
478
+ // never claims an unconfirmed address. Raw (ACL-bypassing) but self-scoped by the
479
+ // verified identity, and it only ever CLEARS the flag (routing it through the self
480
+ // update policy would instead let a user set their own verified state). Any pending
481
+ // verify token for the old address is now dead (verifyEmail's current-email guard).
482
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = NULL WHERE username = ?`, userId);
483
+ return { ...updated, emailVerified: null };
437
484
  }),
438
485
  /** Self-service: change the caller's password. A credential op — it reads the
439
486
  * caller's OWN hash (passwordHash is never ACL-readable) to verify the current
@@ -460,9 +507,9 @@ export function createUserHandlers(opts = {}) {
460
507
  * `createUserHandlers()`; spread alongside `authHandlers`. */
461
508
  export const userHandlers = createUserHandlers();
462
509
  // Fields a self-service caller may see of their own row (never passwordHash/roles).
463
- const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
510
+ const SELF_READ_FIELDS = ["username", "email", "emailVerified", "active", "createdAt"];
464
511
  // Fields an admin may see of any user (never passwordHash).
465
- const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
512
+ const ADMIN_READ_FIELDS = ["username", "roles", "email", "emailVerified", "active", "createdAt"];
466
513
  /** ACL policy fragments that turn on the user-management handlers. Spread `admin`
467
514
  * into your admin role and `self` into your authenticated-user role:
468
515
  *
@@ -497,3 +544,138 @@ export function authPolicies(opts = {}) {
497
544
  ],
498
545
  };
499
546
  }
547
+ // --- password reset + email verification -------------------------------------
548
+ //
549
+ // Two one-time-email-token flows, built on the same machinery as magic-link: mint a
550
+ // random token, persist only its SHA-256 HASH + an expiry (in the shared
551
+ // `auth_email_tokens` table, spread `emailTokenSchema`), email the raw token from a TASK
552
+ // (off the mutation's storage transaction — a slow send can't hold the store lock), and
553
+ // redeem it once. Both are transport-agnostic: you supply `sendEmail`; pramen owns the
554
+ // token lifecycle. Wire the returned `tasks` into your app's task map, or the token is
555
+ // written but the email never sends.
556
+ const PURPOSE_RESET = "reset";
557
+ const PURPOSE_VERIFY = "verify";
558
+ /** Mint a one-time token for `username`, invalidate any prior pending token of the same
559
+ * purpose for that user (only the latest works), and persist its hash + expiry. Returns
560
+ * the raw token (the caller enqueues the send task with it). */
561
+ async function issueEmailToken(ctx, purpose, username, email, expiresAt) {
562
+ const token = mintToken();
563
+ const tokenHash = await sha256Hex(token);
564
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", purpose, username);
565
+ await ctx.db.exec("INSERT INTO auth_email_tokens (tokenHash, purpose, username, email, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)", tokenHash, purpose, username, email, expiresAt, Date.now());
566
+ return token;
567
+ }
568
+ /** Validate a token (right purpose, unexpired, unconsumed) and CONSUME it (single-use).
569
+ * Returns the account + address it was minted for. Throws Unauthorized on any failure —
570
+ * the same opaque error for missing / wrong-purpose / expired / already-used, so a caller
571
+ * learns nothing beyond "this token won't work". */
572
+ async function redeemEmailToken(ctx, purpose, token) {
573
+ const tokenHash = await sha256Hex(token);
574
+ const rows = await ctx.db.exec("SELECT username, email, expiresAt, consumedAt FROM auth_email_tokens WHERE tokenHash = ? AND purpose = ? LIMIT 1", tokenHash, purpose);
575
+ const row = rows[0];
576
+ if (!row || row.consumedAt != null || Number(row.expiresAt) < Date.now())
577
+ throw new Unauthorized("invalid or expired token");
578
+ await ctx.db.exec("UPDATE auth_email_tokens SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
579
+ return { username: String(row.username), email: String(row.email) };
580
+ }
581
+ /** Parse `{ token, newPassword }` for `resetPassword`. */
582
+ function parseResetInput(raw) {
583
+ const o = (raw ?? {});
584
+ if (typeof o.token !== "string" || o.token.length === 0)
585
+ throw new BadRequest("token is required");
586
+ if (typeof o.newPassword !== "string" || o.newPassword.length < 8)
587
+ throw new BadRequest("newPassword must be at least 8 characters");
588
+ return { token: o.token, newPassword: o.newPassword };
589
+ }
590
+ /** Build the `requestPasswordReset` / `resetPassword` handler pair + the
591
+ * `sendPasswordResetEmail` task. Both handlers are ANONYMOUS — the emailed token is the
592
+ * capability. `requestPasswordReset` is enumeration-safe (always `{ ok: true }`, sends only
593
+ * when an active account matches the email); `resetPassword` redeems the single-use token
594
+ * and sets the new password. Spread `emailTokenSchema` into your schema and `.tasks` into
595
+ * your task map. */
596
+ export function createPasswordReset(opts) {
597
+ const table = assertIdentifier(opts.table ?? "auth_users");
598
+ const linkTtlMs = (opts.linkTtlSeconds ?? 3600) * 1000;
599
+ const handlers = {
600
+ /** Anonymous: request a reset link for `email`. Resolves the address to an ACTIVE
601
+ * account and, only then, mints a token + enqueues the send — but the response is the
602
+ * same `{ ok: true }` whether or not any account matched (no enumeration). */
603
+ requestPasswordReset: mutation(async (ctx, input) => {
604
+ const rows = await ctx.db.exec(`SELECT username, active FROM ${table} WHERE email = ? LIMIT 1`, input.email);
605
+ const u = rows[0];
606
+ if (u && isActive(u.active)) {
607
+ const token = await issueEmailToken(ctx, PURPOSE_RESET, String(u.username), input.email, Date.now() + linkTtlMs);
608
+ await ctx.tasks.enqueue({ kind: "sendPasswordResetEmail", payload: { email: input.email, token, username: String(u.username) } });
609
+ }
610
+ return { ok: true };
611
+ }, { input: parseEmail }),
612
+ /** Anonymous: redeem a reset token and set the new password. Single-use (the token is
613
+ * consumed first). The account must still exist + be active. Any other pending reset
614
+ * tokens for the user are dropped on success. */
615
+ resetPassword: mutation(async (ctx, input) => {
616
+ const { username } = await redeemEmailToken(ctx, PURPOSE_RESET, input.token);
617
+ const rows = await ctx.db.exec(`SELECT active FROM ${table} WHERE username = ? LIMIT 1`, username);
618
+ if (!rows[0])
619
+ throw new Unauthorized("invalid or expired token");
620
+ if (!isActive(rows[0].active))
621
+ throw new Unauthorized("account is deactivated");
622
+ await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(input.newPassword), username);
623
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", PURPOSE_RESET, username);
624
+ return { ok: true };
625
+ }, { input: parseResetInput }),
626
+ };
627
+ const tasks = {
628
+ sendPasswordResetEmail: async (ctx, payload) => {
629
+ const p = payload;
630
+ await opts.sendEmail(ctx, p);
631
+ },
632
+ };
633
+ return { handlers, tasks };
634
+ }
635
+ /** Build the `requestEmailVerification` / `verifyEmail` handler pair + the
636
+ * `sendVerificationEmail` task. `requestEmailVerification` is AUTHENTICATED (a caller
637
+ * verifies their OWN current email — runs right after signup, when the client already holds
638
+ * the session token); `verifyEmail` is ANONYMOUS (the token is the capability) and stamps
639
+ * `auth_users.emailVerified`. A token is bound to the address current at request time, so a
640
+ * later `changeEmail` invalidates it (verifyEmail rejects a token whose address no longer
641
+ * matches). Spread `emailTokenSchema` into your schema and `.tasks` into your task map. */
642
+ export function createEmailVerification(opts) {
643
+ const table = assertIdentifier(opts.table ?? "auth_users");
644
+ const linkTtlMs = (opts.linkTtlSeconds ?? 86_400) * 1000;
645
+ const handlers = {
646
+ /** Authenticated: email the caller a verification link for their CURRENT address. A
647
+ * no-op `{ ok: true, alreadyVerified: true }` if already verified; 400 if no email is set. */
648
+ requestEmailVerification: mutation(async (ctx) => {
649
+ const userId = requireUserId(ctx);
650
+ const rows = await ctx.db.exec(`SELECT email, emailVerified FROM ${table} WHERE username = ? LIMIT 1`, userId);
651
+ const u = rows[0];
652
+ const email = u && typeof u.email === "string" ? u.email : "";
653
+ if (!email)
654
+ throw new BadRequest("no email on file — set one with changeEmail first");
655
+ if (u.emailVerified != null)
656
+ return { ok: true, alreadyVerified: true };
657
+ const token = await issueEmailToken(ctx, PURPOSE_VERIFY, userId, email, Date.now() + linkTtlMs);
658
+ await ctx.tasks.enqueue({ kind: "sendVerificationEmail", payload: { email, token, username: userId } });
659
+ return { ok: true };
660
+ }, { auth: "authenticated" }),
661
+ /** Anonymous: redeem a verification token and mark the address verified. Guards that the
662
+ * account's CURRENT email still equals the address the token was minted for — a stale
663
+ * token (email changed since request) is rejected, never verifying the new address. */
664
+ verifyEmail: mutation(async (ctx, input) => {
665
+ const { username, email } = await redeemEmailToken(ctx, PURPOSE_VERIFY, input.token);
666
+ const rows = await ctx.db.exec(`SELECT email FROM ${table} WHERE username = ? LIMIT 1`, username);
667
+ const current = rows[0] && typeof rows[0].email === "string" ? String(rows[0].email) : null;
668
+ if (current == null || current !== email)
669
+ throw new Unauthorized("invalid or expired token");
670
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = ? WHERE username = ?`, Date.now(), username);
671
+ return { ok: true, email };
672
+ }, { input: parseLinkToken }),
673
+ };
674
+ const tasks = {
675
+ sendVerificationEmail: async (ctx, payload) => {
676
+ const p = payload;
677
+ await opts.sendEmail(ctx, p);
678
+ },
679
+ };
680
+ return { handlers, tasks };
681
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.36",
3
+ "version": "0.0.37",
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.37"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -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,11 +149,24 @@ 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
172
  /** signup / login / me. Roles are assigned server-side (default `["user"]`) — the
@@ -167,7 +181,7 @@ export const authHandlers = {
167
181
  // The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
168
182
  // which keys on the email and always returns the same `{ ok: true }`.
169
183
  signup: mutation(
170
- async (ctx, input: { username: string; password: string }) => {
184
+ async (ctx, input: { username: string; password: string; email?: string }) => {
171
185
  const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
172
186
  if (existing.length > 0) {
173
187
  // Equalize timing with the available path (which hashes below) so the taken vs.
@@ -175,17 +189,27 @@ export const authHandlers = {
175
189
  await hashPassword(input.password);
176
190
  throw new BadRequest("username is taken");
177
191
  }
192
+ // A supplied email must be free (the column is unique). Same clean-400 shape as
193
+ // changeEmail rather than surfacing the DB constraint as a 500.
194
+ if (input.email) {
195
+ const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
196
+ if (emailTaken.length > 0) throw new BadRequest("email already in use");
197
+ }
178
198
  const roles = DEFAULT_ROLES;
179
199
  const passwordHash = await hashPassword(input.password);
200
+ // Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
201
+ // createEmailVerification (requestEmailVerification runs right after signup — the
202
+ // client already holds the returned session token).
180
203
  await ctx.db.exec(
181
- "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
204
+ "INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
182
205
  input.username,
183
206
  passwordHash,
184
207
  JSON.stringify(roles),
208
+ input.email ?? null,
185
209
  Date.now(),
186
210
  );
187
211
  const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
188
- return { token, user: { username: input.username, roles } };
212
+ return { token, user: { username: input.username, roles, email: input.email ?? null } };
189
213
  },
190
214
  { input: parseCreds },
191
215
  ),
@@ -248,6 +272,24 @@ export const magicLinkSchema = {
248
272
  })),
249
273
  };
250
274
 
275
+ // One-time email-token table shared by password reset AND email verification (spread it
276
+ // once if you use EITHER `createPasswordReset` or `createEmailVerification`). Rows are
277
+ // discriminated by `purpose` ("reset" | "verify"); only a SHA-256 hash of the token is
278
+ // stored, so a DB leak never exposes a live token. `username` binds the token to the
279
+ // account it acts on; `email` pins the address it was minted for (verification rejects a
280
+ // token whose address the user has since changed).
281
+ export const emailTokenSchema = {
282
+ auth_email_tokens: Entity((t) => ({
283
+ tokenHash: t.textId(), // PK = sha256(token)
284
+ purpose: t.text(), // "reset" | "verify"
285
+ username: t.text(), // the account (JWT sub) the token acts on
286
+ email: t.text(), // the address at mint time
287
+ expiresAt: t.int(), // epoch ms
288
+ consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
289
+ createdAt: t.int(),
290
+ })),
291
+ };
292
+
251
293
  async function sha256Hex(s: string): Promise<string> {
252
294
  const digest = await crypto.subtle.digest("SHA-256", enc(s));
253
295
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -261,7 +303,7 @@ function mintToken(): string {
261
303
  function parseEmail(raw: unknown): { email: string } {
262
304
  const o = (raw ?? {}) as Record<string, unknown>;
263
305
  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");
306
+ if (!EMAIL_RE.test(email)) throw new BadRequest("a valid email is required");
265
307
  return { email };
266
308
  }
267
309
 
@@ -544,7 +586,13 @@ export function createUserHandlers(opts: { table?: string } = {}) {
544
586
  if (taken.length > 0) throw new BadRequest("email already in use");
545
587
  const updated = await usersDb(ctx).update(table, userId, { email });
546
588
  if (!updated) throw new Unauthorized("authentication required");
547
- return updated;
589
+ // The new address is UNVERIFIED — clear any prior verification so `emailVerified`
590
+ // never claims an unconfirmed address. Raw (ACL-bypassing) but self-scoped by the
591
+ // verified identity, and it only ever CLEARS the flag (routing it through the self
592
+ // update policy would instead let a user set their own verified state). Any pending
593
+ // verify token for the old address is now dead (verifyEmail's current-email guard).
594
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = NULL WHERE username = ?`, userId);
595
+ return { ...updated, emailVerified: null };
548
596
  }),
549
597
 
550
598
  /** Self-service: change the caller's password. A credential op — it reads the
@@ -573,9 +621,9 @@ export function createUserHandlers(opts: { table?: string } = {}) {
573
621
  export const userHandlers = createUserHandlers();
574
622
 
575
623
  // Fields a self-service caller may see of their own row (never passwordHash/roles).
576
- const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
624
+ const SELF_READ_FIELDS = ["username", "email", "emailVerified", "active", "createdAt"];
577
625
  // Fields an admin may see of any user (never passwordHash).
578
- const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
626
+ const ADMIN_READ_FIELDS = ["username", "roles", "email", "emailVerified", "active", "createdAt"];
579
627
 
580
628
  /** ACL policy fragments that turn on the user-management handlers. Spread `admin`
581
629
  * into your admin role and `self` into your authenticated-user role:
@@ -619,3 +667,192 @@ export function authPolicies(opts: {
619
667
  ],
620
668
  };
621
669
  }
670
+
671
+ // --- password reset + email verification -------------------------------------
672
+ //
673
+ // Two one-time-email-token flows, built on the same machinery as magic-link: mint a
674
+ // random token, persist only its SHA-256 HASH + an expiry (in the shared
675
+ // `auth_email_tokens` table, spread `emailTokenSchema`), email the raw token from a TASK
676
+ // (off the mutation's storage transaction — a slow send can't hold the store lock), and
677
+ // redeem it once. Both are transport-agnostic: you supply `sendEmail`; pramen owns the
678
+ // token lifecycle. Wire the returned `tasks` into your app's task map, or the token is
679
+ // written but the email never sends.
680
+
681
+ const PURPOSE_RESET = "reset";
682
+ const PURPOSE_VERIFY = "verify";
683
+
684
+ /** Mint a one-time token for `username`, invalidate any prior pending token of the same
685
+ * purpose for that user (only the latest works), and persist its hash + expiry. Returns
686
+ * the raw token (the caller enqueues the send task with it). */
687
+ async function issueEmailToken(ctx: HandlerContext, purpose: string, username: string, email: string, expiresAt: number): Promise<string> {
688
+ const token = mintToken();
689
+ const tokenHash = await sha256Hex(token);
690
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", purpose, username);
691
+ await ctx.db.exec(
692
+ "INSERT INTO auth_email_tokens (tokenHash, purpose, username, email, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)",
693
+ tokenHash,
694
+ purpose,
695
+ username,
696
+ email,
697
+ expiresAt,
698
+ Date.now(),
699
+ );
700
+ return token;
701
+ }
702
+
703
+ /** Validate a token (right purpose, unexpired, unconsumed) and CONSUME it (single-use).
704
+ * Returns the account + address it was minted for. Throws Unauthorized on any failure —
705
+ * the same opaque error for missing / wrong-purpose / expired / already-used, so a caller
706
+ * learns nothing beyond "this token won't work". */
707
+ async function redeemEmailToken(ctx: HandlerContext, purpose: string, token: string): Promise<{ username: string; email: string }> {
708
+ const tokenHash = await sha256Hex(token);
709
+ const rows = await ctx.db.exec(
710
+ "SELECT username, email, expiresAt, consumedAt FROM auth_email_tokens WHERE tokenHash = ? AND purpose = ? LIMIT 1",
711
+ tokenHash,
712
+ purpose,
713
+ );
714
+ const row = rows[0];
715
+ if (!row || row.consumedAt != null || Number(row.expiresAt) < Date.now()) throw new Unauthorized("invalid or expired token");
716
+ await ctx.db.exec("UPDATE auth_email_tokens SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
717
+ return { username: String(row.username), email: String(row.email) };
718
+ }
719
+
720
+ /** Parse `{ token, newPassword }` for `resetPassword`. */
721
+ function parseResetInput(raw: unknown): { token: string; newPassword: string } {
722
+ const o = (raw ?? {}) as Record<string, unknown>;
723
+ if (typeof o.token !== "string" || o.token.length === 0) throw new BadRequest("token is required");
724
+ if (typeof o.newPassword !== "string" || o.newPassword.length < 8) throw new BadRequest("newPassword must be at least 8 characters");
725
+ return { token: o.token, newPassword: o.newPassword };
726
+ }
727
+
728
+ export interface PasswordResetOptions {
729
+ /** Deliver the reset link. Receives the ctx + `{ email, token, username }` — build the
730
+ * URL your app routes to, e.g. `${ctx.env.APP_URL}/reset?token=${token}`. Called from the
731
+ * `sendPasswordResetEmail` TASK (after commit), like magic-link's sendEmail. */
732
+ sendEmail: (ctx: HandlerContext, args: { email: string; token: string; username: string }) => void | Promise<void>;
733
+ /** The users table to reset against (must have `username` PK + `passwordHash`/`email`).
734
+ * Default `auth_users`; pass your own authSchema-shaped table (as with createUserHandlers). */
735
+ table?: string;
736
+ /** How long the reset link stays valid, in seconds. Default 3600 (1h). */
737
+ linkTtlSeconds?: number;
738
+ }
739
+
740
+ /** Build the `requestPasswordReset` / `resetPassword` handler pair + the
741
+ * `sendPasswordResetEmail` task. Both handlers are ANONYMOUS — the emailed token is the
742
+ * capability. `requestPasswordReset` is enumeration-safe (always `{ ok: true }`, sends only
743
+ * when an active account matches the email); `resetPassword` redeems the single-use token
744
+ * and sets the new password. Spread `emailTokenSchema` into your schema and `.tasks` into
745
+ * your task map. */
746
+ export function createPasswordReset(opts: PasswordResetOptions): { handlers: HandlerMap; tasks: AppTaskMap } {
747
+ const table = assertIdentifier(opts.table ?? "auth_users");
748
+ const linkTtlMs = (opts.linkTtlSeconds ?? 3600) * 1000;
749
+
750
+ const handlers: HandlerMap = {
751
+ /** Anonymous: request a reset link for `email`. Resolves the address to an ACTIVE
752
+ * account and, only then, mints a token + enqueues the send — but the response is the
753
+ * same `{ ok: true }` whether or not any account matched (no enumeration). */
754
+ requestPasswordReset: mutation(
755
+ async (ctx, input: { email: string }) => {
756
+ const rows = await ctx.db.exec(`SELECT username, active FROM ${table} WHERE email = ? LIMIT 1`, input.email);
757
+ const u = rows[0];
758
+ if (u && isActive(u.active)) {
759
+ const token = await issueEmailToken(ctx, PURPOSE_RESET, String(u.username), input.email, Date.now() + linkTtlMs);
760
+ await ctx.tasks.enqueue({ kind: "sendPasswordResetEmail", payload: { email: input.email, token, username: String(u.username) } });
761
+ }
762
+ return { ok: true };
763
+ },
764
+ { input: parseEmail },
765
+ ),
766
+
767
+ /** Anonymous: redeem a reset token and set the new password. Single-use (the token is
768
+ * consumed first). The account must still exist + be active. Any other pending reset
769
+ * tokens for the user are dropped on success. */
770
+ resetPassword: mutation(
771
+ async (ctx, input: { token: string; newPassword: string }) => {
772
+ const { username } = await redeemEmailToken(ctx, PURPOSE_RESET, input.token);
773
+ const rows = await ctx.db.exec(`SELECT active FROM ${table} WHERE username = ? LIMIT 1`, username);
774
+ if (!rows[0]) throw new Unauthorized("invalid or expired token");
775
+ if (!isActive(rows[0].active)) throw new Unauthorized("account is deactivated");
776
+ await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(input.newPassword), username);
777
+ await ctx.db.exec("DELETE FROM auth_email_tokens WHERE purpose = ? AND username = ?", PURPOSE_RESET, username);
778
+ return { ok: true };
779
+ },
780
+ { input: parseResetInput },
781
+ ),
782
+ };
783
+
784
+ const tasks: AppTaskMap = {
785
+ sendPasswordResetEmail: async (ctx, payload) => {
786
+ const p = payload as { email: string; token: string; username: string };
787
+ await opts.sendEmail(ctx, p);
788
+ },
789
+ };
790
+
791
+ return { handlers, tasks };
792
+ }
793
+
794
+ export interface EmailVerificationOptions {
795
+ /** Deliver the verification link. Receives the ctx + `{ email, token, username }` — build
796
+ * the URL your app routes to, e.g. `${ctx.env.APP_URL}/verify?token=${token}`. Called from
797
+ * the `sendVerificationEmail` TASK (after commit). */
798
+ sendEmail: (ctx: HandlerContext, args: { email: string; token: string; username: string }) => void | Promise<void>;
799
+ /** The users table (must have `username` PK + `email`/`emailVerified`). Default `auth_users`. */
800
+ table?: string;
801
+ /** How long the verification link stays valid, in seconds. Default 86400 (24h). */
802
+ linkTtlSeconds?: number;
803
+ }
804
+
805
+ /** Build the `requestEmailVerification` / `verifyEmail` handler pair + the
806
+ * `sendVerificationEmail` task. `requestEmailVerification` is AUTHENTICATED (a caller
807
+ * verifies their OWN current email — runs right after signup, when the client already holds
808
+ * the session token); `verifyEmail` is ANONYMOUS (the token is the capability) and stamps
809
+ * `auth_users.emailVerified`. A token is bound to the address current at request time, so a
810
+ * later `changeEmail` invalidates it (verifyEmail rejects a token whose address no longer
811
+ * matches). Spread `emailTokenSchema` into your schema and `.tasks` into your task map. */
812
+ export function createEmailVerification(opts: EmailVerificationOptions): { handlers: HandlerMap; tasks: AppTaskMap } {
813
+ const table = assertIdentifier(opts.table ?? "auth_users");
814
+ const linkTtlMs = (opts.linkTtlSeconds ?? 86_400) * 1000;
815
+
816
+ const handlers: HandlerMap = {
817
+ /** Authenticated: email the caller a verification link for their CURRENT address. A
818
+ * no-op `{ ok: true, alreadyVerified: true }` if already verified; 400 if no email is set. */
819
+ requestEmailVerification: mutation(
820
+ async (ctx) => {
821
+ const userId = requireUserId(ctx);
822
+ const rows = await ctx.db.exec(`SELECT email, emailVerified FROM ${table} WHERE username = ? LIMIT 1`, userId);
823
+ const u = rows[0];
824
+ const email = u && typeof u.email === "string" ? u.email : "";
825
+ if (!email) throw new BadRequest("no email on file — set one with changeEmail first");
826
+ if (u.emailVerified != null) return { ok: true, alreadyVerified: true };
827
+ const token = await issueEmailToken(ctx, PURPOSE_VERIFY, userId, email, Date.now() + linkTtlMs);
828
+ await ctx.tasks.enqueue({ kind: "sendVerificationEmail", payload: { email, token, username: userId } });
829
+ return { ok: true };
830
+ },
831
+ { auth: "authenticated" },
832
+ ),
833
+
834
+ /** Anonymous: redeem a verification token and mark the address verified. Guards that the
835
+ * account's CURRENT email still equals the address the token was minted for — a stale
836
+ * token (email changed since request) is rejected, never verifying the new address. */
837
+ verifyEmail: mutation(
838
+ async (ctx, input: { token: string }) => {
839
+ const { username, email } = await redeemEmailToken(ctx, PURPOSE_VERIFY, input.token);
840
+ const rows = await ctx.db.exec(`SELECT email FROM ${table} WHERE username = ? LIMIT 1`, username);
841
+ const current = rows[0] && typeof rows[0].email === "string" ? String(rows[0].email) : null;
842
+ if (current == null || current !== email) throw new Unauthorized("invalid or expired token");
843
+ await ctx.db.exec(`UPDATE ${table} SET emailVerified = ? WHERE username = ?`, Date.now(), username);
844
+ return { ok: true, email };
845
+ },
846
+ { input: parseLinkToken },
847
+ ),
848
+ };
849
+
850
+ const tasks: AppTaskMap = {
851
+ sendVerificationEmail: async (ctx, payload) => {
852
+ const p = payload as { email: string; token: string; username: string };
853
+ await opts.sendEmail(ctx, p);
854
+ },
855
+ };
856
+
857
+ return { handlers, tasks };
858
+ }