@everystack/auth 0.2.5 → 0.3.0

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/src/postgres.ts CHANGED
@@ -13,11 +13,12 @@
13
13
  */
14
14
 
15
15
  import { and, eq } from 'drizzle-orm';
16
+ import { getTableConfig } from 'drizzle-orm/pg-core';
16
17
  import { createJwt } from './jwt';
17
18
  import { hmacNode } from './jwt/hmac-node';
18
19
  import type { RefreshTokenOptions } from './refresh';
19
20
  import type { AuthHandlers, AuthOptions, TokenPayload } from './index';
20
- import { getPasswordHash, withPassword } from './index';
21
+ import { withPassword } from './index';
21
22
  import type { OAuthProvider } from './oauth';
22
23
  import { getRefreshFromCookies } from './cookies';
23
24
  import { authResponse, signoutResponse } from './response';
@@ -26,12 +27,27 @@ import { validateCsrfOrigin } from './csrf';
26
27
 
27
28
  const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
28
29
 
29
- /**
30
- * Dummy bcrypt hash for timing-safe user enumeration protection.
31
- * When a user is not found, we still run bcrypt against this hash
32
- * so response time is constant regardless of whether the email exists.
33
- */
34
- const DUMMY_BCRYPT_HASH = '$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
30
+ // Postgres identifier rule — the only role name ever interpolated into SET LOCAL ROLE.
31
+ const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
32
+
33
+ /** Quote a Drizzle-derived SQL identifier (table/column) for safe interpolation. */
34
+ function quoteIdent(name: string): string {
35
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
36
+ throw new Error(`Unsafe SQL identifier: ${name}`);
37
+ }
38
+ return `"${name}"`;
39
+ }
40
+
41
+ /** Schema-qualified, quoted table name from a Drizzle table (e.g. "auth"."users"). */
42
+ function qualifiedTable(table: any): string {
43
+ const { schema, name } = getTableConfig(table);
44
+ return schema ? `${quoteIdent(schema)}.${quoteIdent(name)}` : quoteIdent(name);
45
+ }
46
+
47
+ /** postgres.js returns jsonb as an object or a string depending on the column path. */
48
+ function parseJsonb(value: unknown): any {
49
+ return typeof value === 'string' ? JSON.parse(value) : value;
50
+ }
35
51
 
36
52
  /**
37
53
  * Extract client IP and user agent from a request for refresh token metadata.
@@ -73,6 +89,88 @@ export function createPostgresAuthHandlers(
73
89
  const refreshTable = schema.refreshTokens;
74
90
  const jwtSecretString = new TextDecoder().decode(jwtSecret);
75
91
 
92
+ /**
93
+ * Run `fn` inside a transaction as `role`, with request.jwt.claims set for RLS.
94
+ * The single access primitive for the least-privilege path: SET LOCAL ROLE and
95
+ * set_config are transaction-scoped, so they reset when the transaction ends.
96
+ * `role` is validated (never user input) before interpolation.
97
+ */
98
+ async function withRole<T>(
99
+ role: string,
100
+ claims: Record<string, unknown> | null,
101
+ fn: (sql: any) => Promise<T>,
102
+ ): Promise<T> {
103
+ if (!ROLE_NAME.test(role)) {
104
+ throw new Error(`Invalid role name: ${role}`);
105
+ }
106
+ const claimsJson = JSON.stringify(claims ?? { role });
107
+ return pgSql.begin(async (sql: any) => {
108
+ await sql.unsafe(`SET LOCAL ROLE ${role}`);
109
+ await sql`SELECT set_config('request.jwt.claims', ${claimsJson}, true)`;
110
+ return fn(sql);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Read the public profile under the caller's role, using the column names from
116
+ * the app's own Drizzle schema so the handler stays schema-agnostic.
117
+ */
118
+ async function fetchProfileAsAnon(
119
+ sql: any,
120
+ id: string,
121
+ ): Promise<{ username?: string; displayName?: string } | undefined> {
122
+ const idCol = quoteIdent(profiles.id.name);
123
+ const usernameCol = quoteIdent(profiles.username.name);
124
+ const displayNameCol = quoteIdent(profiles.displayName.name);
125
+ const rows = await sql.unsafe(
126
+ `SELECT ${usernameCol} AS username, ${displayNameCol} AS "displayName" `
127
+ + `FROM ${qualifiedTable(profiles)} WHERE ${idCol} = $1 LIMIT 1`,
128
+ [id],
129
+ );
130
+ return rows[0];
131
+ }
132
+
133
+ /**
134
+ * Create the caller's own profile as the authenticated user — a plain INSERT,
135
+ * SECURITY INVOKER, gated by an RLS self-insert policy (WITH CHECK id = auth.uid()).
136
+ * No SECDEF, no trigger: profile creation is ordinary data access. Column names come
137
+ * from the app's own Drizzle schema so the handler stays schema-agnostic.
138
+ */
139
+ async function insertProfileSelf(
140
+ sql: any,
141
+ id: string,
142
+ data: { username: string; displayName: string },
143
+ ): Promise<void> {
144
+ const idCol = quoteIdent(profiles.id.name);
145
+ const usernameCol = quoteIdent(profiles.username.name);
146
+ const displayNameCol = quoteIdent(profiles.displayName.name);
147
+ await sql.unsafe(
148
+ `INSERT INTO ${qualifiedTable(profiles)} (${idCol}, ${usernameCol}, ${displayNameCol}) `
149
+ + `VALUES ($1, $2, $3)`,
150
+ [id, data.username, data.displayName],
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Read the caller's own auth.users row (id, email, role) as the authenticated
156
+ * user — SECURITY INVOKER under RLS self-visibility (id = auth.uid()). No SECDEF:
157
+ * the caller has already proven they are `id`, and RLS gates the read.
158
+ */
159
+ async function fetchAuthUserSelf(
160
+ sql: any,
161
+ id: string,
162
+ ): Promise<{ id: string; email: string; role?: string } | undefined> {
163
+ const idCol = quoteIdent(users.id.name);
164
+ const emailCol = quoteIdent(users.email.name);
165
+ const roleCol = quoteIdent(users.role.name);
166
+ const rows = await sql.unsafe(
167
+ `SELECT ${idCol} AS id, ${emailCol} AS email, ${roleCol} AS role `
168
+ + `FROM ${qualifiedTable(users)} WHERE ${idCol} = $1 LIMIT 1`,
169
+ [id],
170
+ );
171
+ return rows[0];
172
+ }
173
+
76
174
  /**
77
175
  * Sign JWT via PostgreSQL pgcrypto hmac().
78
176
  * Requires GUC app.jwt_secret — uses pgSql.begin() for transaction-local setting.
@@ -100,22 +198,6 @@ export function createPostgresAuthHandlers(
100
198
  return rows[0].sign_jwt;
101
199
  }
102
200
 
103
- /**
104
- * Hash password via PostgreSQL pgcrypto crypt()/gen_salt('bf').
105
- */
106
- async function hashPasswordPg(password: string): Promise<string> {
107
- const rows = await pgSql`SELECT auth.hash_password(${password}) as hash_password`;
108
- return rows[0].hash_password;
109
- }
110
-
111
- /**
112
- * Verify password via PostgreSQL pgcrypto crypt().
113
- */
114
- async function verifyPasswordPg(password: string, hash: string): Promise<boolean> {
115
- const rows = await pgSql`SELECT auth.verify_password(${password}, ${hash}) as verify_password`;
116
- return rows[0].verify_password;
117
- }
118
-
119
201
  /**
120
202
  * Issue refresh token via PostgreSQL SECURITY DEFINER function.
121
203
  * Handles family ceiling, SHA-256 hashing, and token generation atomically.
@@ -140,33 +222,6 @@ export function createPostgresAuthHandlers(
140
222
  return { token: result.token, familyId: result.familyId };
141
223
  }
142
224
 
143
- /**
144
- * Validate and rotate refresh token via PostgreSQL.
145
- * The SQL function handles SELECT FOR UPDATE, reuse detection, user state check.
146
- */
147
- async function validateRefreshTokenPg(
148
- token: string,
149
- deviceId?: string,
150
- ipAddress?: string,
151
- userAgent?: string,
152
- ): Promise<{ userId: string; token: string; familyId: string }> {
153
- const rows = await pgSql`SELECT auth.validate_refresh_token(
154
- ${token},
155
- ${deviceId ?? null}::uuid,
156
- ${ipAddress ?? null},
157
- ${userAgent ?? null}
158
- ) as result`;
159
- const result = typeof rows[0].result === 'string' ? JSON.parse(rows[0].result) : rows[0].result;
160
- // token_reused returns as JSONB { error: 'token_reused' } instead of RAISE
161
- // because PostgreSQL rolls back DELETEs when exceptions propagate.
162
- if (result.error === 'token_reused') {
163
- const err: any = new Error('token_reused');
164
- err.code = 'P0002';
165
- throw err;
166
- }
167
- return { userId: result.userId, token: result.token, familyId: result.familyId };
168
- }
169
-
170
225
  /**
171
226
  * Revoke all refresh tokens for a user, optionally scoped by repository.
172
227
  */
@@ -182,7 +237,23 @@ export function createPostgresAuthHandlers(
182
237
  ttlSec: TOKEN_EXPIRY_SECONDS,
183
238
  });
184
239
 
185
- return {
240
+ /**
241
+ * Mint the access JWT in JS (HS256), preserving app-level enrichClaims. Used by
242
+ * the least-privilege paths where the SQL minter auth.sign_jwt stays owner-only
243
+ * and the API runs as anon — so it can't call auth.sign_jwt.
244
+ */
245
+ async function signJwtJs(payload: Record<string, unknown>, request?: Request): Promise<string> {
246
+ let claims = payload;
247
+ if (options?.enrichClaims && request) {
248
+ const extra = await options.enrichClaims(claims, request);
249
+ if (extra && Object.keys(extra).length > 0) {
250
+ claims = { ...claims, ...extra };
251
+ }
252
+ }
253
+ return verifier.sign(claims);
254
+ }
255
+
256
+ const handlers: AuthHandlers = {
186
257
  verifyToken: async (token: string): Promise<TokenPayload | null> => {
187
258
  const claims = await verifier.verify(token);
188
259
  return (claims as TokenPayload | null) ?? null;
@@ -202,59 +273,79 @@ export function createPostgresAuthHandlers(
202
273
  );
203
274
  }
204
275
 
205
- const existing = await db
206
- .select()
207
- .from(users)
208
- .where(eq(users.email, email))
209
- .limit(1);
210
- if (existing.length > 0) {
211
- if (options?.uniformSignupResponse) {
276
+ const meta = extractRequestMeta(request, options?.repositoryId);
277
+
278
+ // Least-privilege signup, in ONE transaction across two role contexts so the
279
+ // whole thing is atomic (a failed profile insert rolls back the new user — no
280
+ // orphaned auth.users row):
281
+ // 1. As `anon`, call the credential-gated SECDEF wrapper auth.sign_up_session —
282
+ // the irreducible privileged step: it hashes the password, inserts auth.users
283
+ // (raising email_exists on a duplicate), and issues the refresh token, all as
284
+ // the definer. anon holds EXECUTE; the raw minters stay owner-only.
285
+ // 2. As the `authenticated` user it just created, INSERT the public profile —
286
+ // a plain invoker write gated by RLS self-insert (WITH CHECK id = auth.uid()).
287
+ // The access JWT is minted in JS so enrichClaims is preserved.
288
+ const userMetaData = { username, displayName };
289
+ let session: { sub: string; email: string; role?: string; refresh_token: string };
290
+ try {
291
+ session = await pgSql.begin(async (sql: any) => {
292
+ await sql.unsafe('SET LOCAL ROLE anon');
293
+ await sql`SELECT set_config('request.jwt.claims', ${JSON.stringify({ role: 'anon' })}, true)`;
294
+ const rows = await sql`SELECT auth.sign_up_session(
295
+ ${email},
296
+ ${password},
297
+ ${JSON.stringify(userMetaData)}::jsonb,
298
+ ${meta.ipAddress ?? null},
299
+ ${meta.userAgent ?? null},
300
+ ${null}::uuid
301
+ ) as result`;
302
+ const s = parseJsonb(rows[0].result);
303
+
304
+ await sql.unsafe('SET LOCAL ROLE authenticated');
305
+ await sql`SELECT set_config('request.jwt.claims', ${JSON.stringify({ sub: s.sub, role: 'authenticated' })}, true)`;
306
+ await insertProfileSelf(sql, s.sub, { username, displayName });
307
+ return s;
308
+ });
309
+ } catch (err: any) {
310
+ if (err?.code === 'P0001') {
311
+ // email_exists — uniform response avoids leaking which emails are registered
312
+ if (options?.uniformSignupResponse) {
313
+ return Response.json(
314
+ { message: 'Check your email to continue.' },
315
+ { status: 200 },
316
+ );
317
+ }
212
318
  return Response.json(
213
- { message: 'Check your email to continue.' },
214
- { status: 200 },
319
+ { message: 'Email already registered' },
320
+ { status: 409 },
215
321
  );
216
322
  }
217
- return Response.json(
218
- { message: 'Email already registered' },
219
- { status: 409 },
220
- );
323
+ if (err?.code === '23505') {
324
+ // unique_violation — username already taken (the whole signup rolled back)
325
+ return Response.json(
326
+ { message: 'Username already taken' },
327
+ { status: 409 },
328
+ );
329
+ }
330
+ throw err;
221
331
  }
222
332
 
223
- const hash = await hashPasswordPg(password);
224
- const { user, profile } = await db.transaction(async (tx: any) => {
225
- const [u] = await tx.insert(users).values(withPassword(users, { email }, hash)).returning();
226
- const [p] = await tx.insert(profiles).values({ id: u.id, username, displayName }).returning();
227
- return { user: u, profile: p };
228
- });
229
-
230
- const token = await signJwtPg({
231
- sub: user.id,
232
- email: user.email,
233
- role: user.role ?? 'authenticated',
333
+ const token = await signJwtJs({
334
+ sub: session.sub,
335
+ email: session.email,
336
+ role: session.role ?? 'authenticated',
234
337
  }, enrichRequest);
235
338
 
236
- const meta = extractRequestMeta(request, options?.repositoryId);
237
- let refreshToken: string;
238
- if (refreshTable) {
239
- // Prefer PG function path when sql client is available
240
- const result = await issueRefreshTokenPg(
241
- user.id, meta.repositoryId, meta.ipAddress, meta.userAgent,
242
- );
243
- refreshToken = result.token;
244
- } else {
245
- refreshToken = await signJwtPg({ sub: user.id });
246
- }
247
-
248
339
  return authResponse(
249
340
  {
250
341
  token,
251
- refreshToken,
342
+ refreshToken: session.refresh_token,
252
343
  user: {
253
- id: user.id,
254
- email: user.email,
255
- username: profile.username,
256
- displayName: profile.displayName,
257
- role: user.role ?? 'authenticated',
344
+ id: session.sub,
345
+ email: session.email,
346
+ username,
347
+ displayName,
348
+ role: session.role ?? 'authenticated',
258
349
  },
259
350
  },
260
351
  201,
@@ -276,63 +367,64 @@ export function createPostgresAuthHandlers(
276
367
  );
277
368
  }
278
369
 
279
- const [user] = await db
280
- .select()
281
- .from(users)
282
- .where(eq(users.email, email))
283
- .limit(1);
284
-
285
- if (!user) {
286
- // Timing-safe: run bcrypt even when user doesn't exist
287
- // so response time is constant regardless of email existence
288
- await verifyPasswordPg(password, DUMMY_BCRYPT_HASH);
289
- return Response.json(
290
- { message: 'Invalid email or password' },
291
- { status: 401 },
292
- );
293
- }
370
+ const meta = extractRequestMeta(request, options?.repositoryId);
294
371
 
295
- const valid = await verifyPasswordPg(password, getPasswordHash(user)!);
296
- if (!valid) {
297
- return Response.json(
298
- { message: 'Invalid email or password' },
299
- { status: 401 },
300
- );
372
+ // Least-privilege login: everything runs as `anon` over RLS. The credential-gated
373
+ // SECURITY DEFINER wrapper verifies the password (timing-safe — it runs bcrypt
374
+ // against a dummy hash for an unknown email) and issues the refresh token
375
+ // internally, so the raw minters (auth.sign_jwt, auth.issue_refresh_token) stay
376
+ // owner-only and never need a grant to anon. We read the public profile as anon and
377
+ // mint the access JWT here so app-level enrichClaims is preserved. The handler never
378
+ // touches auth.users directly.
379
+ let session: { sub: string; email: string; role?: string; refresh_token: string };
380
+ let profile: { username?: string; displayName?: string } | undefined;
381
+ try {
382
+ const out = await withRole('anon', { role: 'anon' }, async (sql: any) => {
383
+ const rows = await sql`SELECT auth.sign_in_session(
384
+ ${email},
385
+ ${password},
386
+ ${meta.ipAddress ?? null},
387
+ ${meta.userAgent ?? null},
388
+ ${null}::uuid
389
+ ) as result`;
390
+ const s = parseJsonb(rows[0].result);
391
+ const p = await fetchProfileAsAnon(sql, s.sub);
392
+ return { s, p };
393
+ });
394
+ session = out.s;
395
+ profile = out.p;
396
+ } catch (err: any) {
397
+ const code = err?.code;
398
+ if (code === 'P0001') {
399
+ // invalid_credentials — raised timing-safe inside auth.sign_in
400
+ return Response.json(
401
+ { message: 'Invalid email or password' },
402
+ { status: 401 },
403
+ );
404
+ }
405
+ if (code === 'P0006') {
406
+ // user_banned
407
+ return Response.json({ message: 'Account suspended' }, { status: 403 });
408
+ }
409
+ throw err;
301
410
  }
302
411
 
303
- const [profile] = await db
304
- .select()
305
- .from(profiles)
306
- .where(eq(profiles.id, user.id))
307
- .limit(1);
308
-
309
- const token = await signJwtPg({
310
- sub: user.id,
311
- email: user.email,
312
- role: user.role ?? 'authenticated',
412
+ const token = await signJwtJs({
413
+ sub: session.sub,
414
+ email: session.email,
415
+ role: session.role ?? 'authenticated',
313
416
  }, enrichRequest);
314
417
 
315
- const meta = extractRequestMeta(request, options?.repositoryId);
316
- let refreshToken: string;
317
- if (refreshTable) {
318
- const result = await issueRefreshTokenPg(
319
- user.id, meta.repositoryId, meta.ipAddress, meta.userAgent,
320
- );
321
- refreshToken = result.token;
322
- } else {
323
- refreshToken = await signJwtPg({ sub: user.id });
324
- }
325
-
326
418
  return authResponse(
327
419
  {
328
420
  token,
329
- refreshToken,
421
+ refreshToken: session.refresh_token,
330
422
  user: {
331
- id: user.id,
332
- email: user.email,
423
+ id: session.sub,
424
+ email: session.email,
333
425
  username: profile?.username,
334
426
  displayName: profile?.displayName,
335
- role: user.role ?? 'authenticated',
427
+ role: session.role ?? 'authenticated',
336
428
  },
337
429
  },
338
430
  200,
@@ -363,31 +455,51 @@ export function createPostgresAuthHandlers(
363
455
  }
364
456
 
365
457
  if (refreshTable) {
366
- // DB-backed rotation via PG SECURITY DEFINER functions (preferred)
458
+ // Least-privilege rotation. Two role contexts, no new SECDEF:
459
+ // 1. As `anon`, call auth.validate_refresh_token — the one legitimately
460
+ // privileged step (SELECT FOR UPDATE + reuse detection + family revoke).
461
+ // It is SECURITY DEFINER, EXECUTE-granted to anon; the refresh token is
462
+ // the credential. token_reused comes back as JSONB (the family delete must
463
+ // commit), not a RAISE.
464
+ // 2. As the `authenticated` user it just identified, read their OWN auth.users
465
+ // row for email/role — SECURITY INVOKER, gated by RLS self-visibility
466
+ // (id = auth.uid()). A row hidden by RLS (deleted/banned) yields no result.
367
467
  try {
368
468
  const meta = extractRequestMeta(request, options?.repositoryId);
369
- const result = await validateRefreshTokenPg(
370
- refreshToken, undefined, meta.ipAddress, meta.userAgent,
371
- );
372
469
 
373
- const [user] = await db
374
- .select()
375
- .from(users)
376
- .where(eq(users.id, result.userId))
377
- .limit(1);
470
+ const rotated = await withRole('anon', { role: 'anon' }, async (sql: any) => {
471
+ const rows = await sql`SELECT auth.validate_refresh_token(
472
+ ${refreshToken},
473
+ ${null}::uuid,
474
+ ${meta.ipAddress ?? null},
475
+ ${meta.userAgent ?? null}
476
+ ) as result`;
477
+ return parseJsonb(rows[0].result);
478
+ });
479
+ if (rotated?.error === 'token_reused') {
480
+ return Response.json(
481
+ { message: 'Invalid or expired refresh token' },
482
+ { status: 401 },
483
+ );
484
+ }
485
+
486
+ const user = await withRole(
487
+ 'authenticated',
488
+ { sub: rotated.userId, role: 'authenticated' },
489
+ (sql: any) => fetchAuthUserSelf(sql, rotated.userId),
490
+ );
378
491
  if (!user) {
379
- await revokeRefreshTokensPg(result.userId, options?.repositoryId);
380
492
  return Response.json({ message: 'User not found' }, { status: 401 });
381
493
  }
382
494
 
383
- const token = await signJwtPg({
495
+ const token = await signJwtJs({
384
496
  sub: user.id,
385
497
  email: user.email,
386
498
  role: user.role ?? 'authenticated',
387
499
  }, request);
388
500
 
389
501
  return authResponse(
390
- { token, refreshToken: result.token },
502
+ { token, refreshToken: rotated.token },
391
503
  200,
392
504
  options?.cookies,
393
505
  );
@@ -442,6 +554,32 @@ export function createPostgresAuthHandlers(
442
554
  }
443
555
  },
444
556
 
557
+ // ⚠️ NOT YET LEAST-PRIVILEGE — owner-connection path, deferred (see below).
558
+ //
559
+ // Unlike signin/signup/refresh, this handler still does its user lookups and
560
+ // creation directly on the Drizzle `db` connection (db.select / db.transaction)
561
+ // and mints tokens via signJwtPg / issueRefreshTokenPg. After the credential-split
562
+ // cutover (API runs as `authenticator`, no ambient table privileges) THIS WILL BREAK:
563
+ // those db reads/writes run with no SET ROLE and will be denied by RLS/grants.
564
+ //
565
+ // To convert it to the same model as signin/signup, it needs:
566
+ // 1. A new credential-gated SECDEF wrapper `auth.oauth_sign_in_session(provider,
567
+ // provider_id, email, identity_data, ip, ua, device)` that wraps the existing
568
+ // auth.oauth_sign_in (find/link/create user) + issues the refresh token
569
+ // internally, returns { sub, email, role, isNewUser, refresh_token }, and is
570
+ // GRANT EXECUTE'd to anon. (The OAuth token verified by provider.verify() is
571
+ // the credential gate.)
572
+ // 2. Handler rewrite: provider.verify() in JS -> withRole('anon') over the wrapper
573
+ // -> for isNewUser, INSERT the profile as withRole('authenticated', {sub})
574
+ // (invoker, like signup) -> mint the access JWT in JS.
575
+ // 3. The app-custom `oauthCallbacks` path (findByProvider/createFromOAuth) and the
576
+ // legacy identities/credentials path both run arbitrary JS on the owner
577
+ // connection — they must either move behind the SECDEF wrapper or be documented
578
+ // as requiring a privileged context. This is the main reason this is deferred:
579
+ // it's not a clean single-wrapper swap.
580
+ // Deferred because the example app does not configure oauth, so this can't be
581
+ // verified end-to-end here; convert it with its own integration test. See memory
582
+ // secure-by-default-db.md "REMAINING" item 1.
445
583
  oauth: options?.oauth?.providers.length
446
584
  ? async (request: Request): Promise<Response> => {
447
585
  const csrfError = csrfCheck(request, options?.cookies);
@@ -752,4 +890,173 @@ export function createPostgresAuthHandlers(
752
890
  }
753
891
  },
754
892
  };
893
+
894
+ // Token-gated flows: run the SECDEF wrappers as `anon` over RLS. The one-time token
895
+ // is the credential; the token MINTING (auth.create_one_time_token) stays owner-only on
896
+ // the worker (see forgot-password / send-confirmation). These wrappers touch only
897
+ // auth.users. Always provided in postgres mode (the wrappers are part of the default auth
898
+ // schema); they take precedence over the generic owner-connection versions
899
+ // (addEmailPasswordFlows uses `??=`). Apps choose whether to route to them.
900
+ {
901
+ handlers.confirmEmail = async (request: Request): Promise<Response> => {
902
+ const csrfError = csrfCheck(request, options?.cookies);
903
+ if (csrfError) return csrfError;
904
+
905
+ let body: any;
906
+ try {
907
+ body = await request.json();
908
+ } catch {
909
+ return Response.json({ message: 'Invalid request body' }, { status: 400 });
910
+ }
911
+ if (!body?.token) {
912
+ return Response.json({ message: 'token is required' }, { status: 400 });
913
+ }
914
+
915
+ let result: any;
916
+ try {
917
+ result = await withRole('anon', { role: 'anon' }, async (sql: any) => {
918
+ const rows = await sql`SELECT auth.confirm_email(${body.token}) as result`;
919
+ return parseJsonb(rows[0].result);
920
+ });
921
+ } catch {
922
+ // Invalid/expired/consumed token — the wrapper raises (fails closed).
923
+ return Response.json({ message: 'Invalid or expired token' }, { status: 401 });
924
+ }
925
+
926
+ if (options.onEmailConfirmed && result?.userId) {
927
+ await options.onEmailConfirmed(result.userId);
928
+ }
929
+ return Response.json({ confirmed: true });
930
+ };
931
+
932
+ handlers.resetPassword = async (request: Request): Promise<Response> => {
933
+ const csrfError = csrfCheck(request, options?.cookies);
934
+ if (csrfError) return csrfError;
935
+
936
+ let body: any;
937
+ try {
938
+ body = await request.json();
939
+ } catch {
940
+ return Response.json({ message: 'Invalid request body' }, { status: 400 });
941
+ }
942
+ const { token, password } = body ?? {};
943
+ if (!token || !password) {
944
+ return Response.json(
945
+ { message: 'token and password are required' },
946
+ { status: 400 },
947
+ );
948
+ }
949
+
950
+ try {
951
+ // The wrapper verifies the recovery token, sets the new password, and revokes
952
+ // every refresh token for the user — all as the definer, in one statement.
953
+ await withRole('anon', { role: 'anon' }, async (sql: any) => {
954
+ await sql`SELECT auth.reset_password(${token}, ${password}) as result`;
955
+ });
956
+ } catch {
957
+ return Response.json({ message: 'Invalid or expired token' }, { status: 401 });
958
+ }
959
+
960
+ return Response.json({ reset: true });
961
+ };
962
+ }
963
+
964
+ // changePassword: authenticated self-service. Runs auth.change_password as the
965
+ // authenticated user (claims from the verified access token); the wrapper verifies the
966
+ // current password (reading the hash as the definer — authenticated may never) before
967
+ // setting the new one and revoking every session. No owner-connection access.
968
+ handlers.changePassword = async (request: Request): Promise<Response> => {
969
+ const csrfError = csrfCheck(request, options?.cookies);
970
+ if (csrfError) return csrfError;
971
+
972
+ const authHeader = request.headers.get('authorization');
973
+ if (!authHeader?.startsWith('Bearer ')) {
974
+ return Response.json({ message: 'Authentication required' }, { status: 401 });
975
+ }
976
+ const claims = await verifier.verify(authHeader.slice(7)) as TokenPayload | null;
977
+ if (!claims?.sub) {
978
+ return Response.json({ message: 'Invalid token' }, { status: 401 });
979
+ }
980
+
981
+ let body: any;
982
+ try {
983
+ body = await request.json();
984
+ } catch {
985
+ return Response.json({ message: 'Invalid request body' }, { status: 400 });
986
+ }
987
+ const { currentPassword, newPassword } = body ?? {};
988
+ if (!currentPassword || !newPassword) {
989
+ return Response.json(
990
+ { message: 'currentPassword and newPassword are required' },
991
+ { status: 400 },
992
+ );
993
+ }
994
+
995
+ try {
996
+ await withRole(
997
+ 'authenticated',
998
+ { sub: claims.sub, role: (claims as any).role ?? 'authenticated' },
999
+ async (sql: any) => {
1000
+ await sql`SELECT auth.change_password(${currentPassword}, ${newPassword}) as result`;
1001
+ },
1002
+ );
1003
+ } catch (err: any) {
1004
+ if (err?.code === 'P0001') {
1005
+ // invalid_credentials / not_authenticated — current password wrong
1006
+ return Response.json({ message: 'Current password is incorrect' }, { status: 401 });
1007
+ }
1008
+ throw err;
1009
+ }
1010
+
1011
+ return Response.json({ changed: true });
1012
+ };
1013
+
1014
+ // Least-privilege forgot-password: the API never reads the user table or mints a token
1015
+ // (both owner-only). It enqueues; the privileged worker looks up the user, mints the
1016
+ // recovery token, and sends the email. Always responds {sent:true} (no enumeration).
1017
+ if (options?.onRecoveryRequested) {
1018
+ handlers.forgotPassword = async (request: Request): Promise<Response> => {
1019
+ const csrfError = csrfCheck(request, options?.cookies);
1020
+ if (csrfError) return csrfError;
1021
+
1022
+ let body: any;
1023
+ try {
1024
+ body = await request.json();
1025
+ } catch {
1026
+ return Response.json({ message: 'Invalid request body' }, { status: 400 });
1027
+ }
1028
+ if (!body?.email) {
1029
+ return Response.json({ message: 'email is required' }, { status: 400 });
1030
+ }
1031
+
1032
+ try {
1033
+ await options.onRecoveryRequested!(body.email);
1034
+ } catch {
1035
+ // Never reveal whether the email exists or the enqueue failed.
1036
+ }
1037
+ return Response.json({ sent: true });
1038
+ };
1039
+ }
1040
+
1041
+ // Least-privilege signup confirmation: on success, enqueue a confirmation job — the
1042
+ // privileged worker mints the one-time token and sends the email. The API never mints.
1043
+ if (options?.onConfirmationRequested) {
1044
+ const baseSignup = handlers.signup;
1045
+ handlers.signup = async (request: Request): Promise<Response> => {
1046
+ const response = await baseSignup(request);
1047
+ if (response.status === 201) {
1048
+ try {
1049
+ const body = await response.clone().json();
1050
+ if (body?.user?.id && body?.user?.email) {
1051
+ await options.onConfirmationRequested!({ id: body.user.id, email: body.user.email });
1052
+ }
1053
+ } catch {
1054
+ // Don't fail signup if the confirmation enqueue fails.
1055
+ }
1056
+ }
1057
+ return response;
1058
+ };
1059
+ }
1060
+
1061
+ return handlers;
755
1062
  }