@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 +88 -2
- package/dist/index.js +189 -7
- package/package.json +2 -2
- package/src/index.ts +246 -9
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|