@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 +107 -8
- package/dist/index.js +252 -18
- package/package.json +2 -2
- package/src/index.ts +312 -20
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"]`)
|
|
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
|
|
152
|
-
*
|
|
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
|
-
},
|
|
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
|
|
200
|
-
*
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
/**
|
|
136
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
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
|
|
398
|
-
*
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.38",
|
|
4
4
|
"description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pramen/server": "0.0.
|
|
37
|
+
"@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
|
-
|
|
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
|
-
/**
|
|
159
|
-
*
|
|
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 (
|
|
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
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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
|
|
515
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|