@everystack/auth 0.2.5 → 0.2.6
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/LICENSE +681 -0
- package/README.md +5 -1
- package/package.json +19 -9
- package/src/index.ts +23 -6
- package/src/postgres.ts +458 -151
- package/src/sql.ts +219 -0
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 {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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: '
|
|
214
|
-
{ status:
|
|
319
|
+
{ message: 'Email already registered' },
|
|
320
|
+
{ status: 409 },
|
|
215
321
|
);
|
|
216
322
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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:
|
|
254
|
-
email:
|
|
255
|
-
username
|
|
256
|
-
displayName
|
|
257
|
-
role:
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
304
|
-
.
|
|
305
|
-
.
|
|
306
|
-
.
|
|
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:
|
|
332
|
-
email:
|
|
423
|
+
id: session.sub,
|
|
424
|
+
email: session.email,
|
|
333
425
|
username: profile?.username,
|
|
334
426
|
displayName: profile?.displayName,
|
|
335
|
-
role:
|
|
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
|
-
//
|
|
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
|
|
374
|
-
.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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:
|
|
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
|
}
|