@everystack/auth 0.1.0 → 0.2.1
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/README.md +1 -7
- package/package.json +33 -22
- package/src/api.ts +42 -8
- package/src/client/index.tsx +39 -7
- package/src/cookies.ts +3 -0
- package/src/csrf.ts +57 -0
- package/src/index.ts +276 -46
- package/src/jwt/index.ts +4 -3
- package/src/passwords.ts +1 -1
- package/src/plugin.ts +264 -0
- package/src/postgres.ts +310 -59
- package/src/refresh.ts +121 -57
- package/src/schema.ts +222 -130
- package/src/sql.ts +715 -73
- package/src/testing.ts +232 -0
package/src/sql.ts
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @everystack/auth/sql —
|
|
2
|
+
* @everystack/auth/sql — Composable SQL generators for PostgreSQL auth functions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Generators (composable, independent):
|
|
5
|
+
* generateCoreSQL() — pgcrypto + hash/verify/sign/verify_jwt
|
|
6
|
+
* generateRefreshTokenSQL() — issue/validate/revoke/cleanup refresh tokens
|
|
7
|
+
* generateAccessorSQL() — auth.uid(), auth.role(), auth.jwt() (Supabase-compatible)
|
|
8
|
+
* generateOneTimeTokenSQL() — create/verify one-time tokens (email confirm, password reset)
|
|
9
|
+
* generateDefaultAuthSQL() — auth.sign_in, auth.sign_up, auth.oauth_sign_in
|
|
10
|
+
* generateJwtWrappersSQL() — sign_in_jwt, sign_up_jwt, oauth_sign_in_jwt
|
|
11
|
+
*
|
|
12
|
+
* Main entry point:
|
|
13
|
+
* generateAuthSQL(options?) — composes all of the above
|
|
14
|
+
*
|
|
15
|
+
* Apps that replace the schema keep core + refresh + accessors,
|
|
16
|
+
* replace the auth functions. The function signatures are the stable API.
|
|
12
17
|
*
|
|
13
18
|
* Usage:
|
|
14
19
|
* import { generateAuthSQL } from '@everystack/auth/sql';
|
|
15
20
|
* const sql = generateAuthSQL();
|
|
16
21
|
* // Write to a migration file
|
|
22
|
+
*
|
|
23
|
+
* // Or pick just what you need:
|
|
24
|
+
* import { generateCoreSQL, generateAccessorSQL } from '@everystack/auth/sql';
|
|
25
|
+
* const sql = generateCoreSQL() + '\n' + generateAccessorSQL();
|
|
17
26
|
*/
|
|
18
27
|
|
|
19
28
|
const PGCRYPTO = `CREATE EXTENSION IF NOT EXISTS pgcrypto;`;
|
|
@@ -145,117 +154,750 @@ BEGIN
|
|
|
145
154
|
END;
|
|
146
155
|
$$;`;
|
|
147
156
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
// =============================================================================
|
|
158
|
+
// REFRESH TOKEN FUNCTIONS (RFC 6819)
|
|
159
|
+
// =============================================================================
|
|
160
|
+
|
|
161
|
+
const ISSUE_REFRESH_TOKEN = `\
|
|
162
|
+
CREATE OR REPLACE FUNCTION auth.issue_refresh_token(
|
|
163
|
+
p_user_id UUID,
|
|
164
|
+
p_family_id UUID DEFAULT NULL,
|
|
165
|
+
p_ip_address TEXT DEFAULT NULL,
|
|
166
|
+
p_user_agent TEXT DEFAULT NULL,
|
|
167
|
+
p_device_id UUID DEFAULT NULL,
|
|
168
|
+
p_repository_id UUID DEFAULT NULL,
|
|
169
|
+
p_ttl INTERVAL DEFAULT '30 days'
|
|
170
|
+
)
|
|
171
|
+
RETURNS JSONB
|
|
151
172
|
LANGUAGE plpgsql
|
|
152
173
|
SECURITY DEFINER
|
|
174
|
+
SET search_path = auth, public, pg_temp
|
|
153
175
|
AS $$
|
|
154
176
|
DECLARE
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
177
|
+
v_raw_token TEXT;
|
|
178
|
+
v_token_hash TEXT;
|
|
179
|
+
v_family_id UUID;
|
|
180
|
+
v_expires_at TIMESTAMPTZ;
|
|
181
|
+
v_family_count INTEGER;
|
|
182
|
+
v_oldest_family UUID;
|
|
158
183
|
BEGIN
|
|
159
|
-
-- Clean up expired tokens for this user
|
|
184
|
+
-- Clean up expired tokens for this user (scoped by repository)
|
|
160
185
|
DELETE FROM auth.refresh_tokens
|
|
161
|
-
WHERE user_id = p_user_id
|
|
186
|
+
WHERE user_id = p_user_id
|
|
187
|
+
AND repository_id IS NOT DISTINCT FROM p_repository_id
|
|
188
|
+
AND expires_at < now();
|
|
189
|
+
|
|
190
|
+
-- Enforce per-user family ceiling (only for new families)
|
|
191
|
+
IF p_family_id IS NULL THEN
|
|
192
|
+
SELECT count(DISTINCT family_id) INTO v_family_count
|
|
193
|
+
FROM auth.refresh_tokens
|
|
194
|
+
WHERE user_id = p_user_id
|
|
195
|
+
AND repository_id IS NOT DISTINCT FROM p_repository_id
|
|
196
|
+
AND status = 'active';
|
|
197
|
+
|
|
198
|
+
IF v_family_count >= 10 THEN
|
|
199
|
+
-- Revoke the oldest family
|
|
200
|
+
SELECT family_id INTO v_oldest_family
|
|
201
|
+
FROM auth.refresh_tokens
|
|
202
|
+
WHERE user_id = p_user_id
|
|
203
|
+
AND repository_id IS NOT DISTINCT FROM p_repository_id
|
|
204
|
+
AND status = 'active'
|
|
205
|
+
GROUP BY family_id
|
|
206
|
+
ORDER BY MIN(created_at) ASC
|
|
207
|
+
LIMIT 1;
|
|
208
|
+
|
|
209
|
+
DELETE FROM auth.refresh_tokens
|
|
210
|
+
WHERE user_id = p_user_id AND family_id = v_oldest_family;
|
|
211
|
+
END IF;
|
|
212
|
+
END IF;
|
|
162
213
|
|
|
163
|
-
-- Generate random
|
|
164
|
-
|
|
214
|
+
-- Generate 32 random bytes, base64url encoded
|
|
215
|
+
v_raw_token := rtrim(translate(
|
|
165
216
|
encode(gen_random_bytes(32), 'base64'),
|
|
166
217
|
'+/=', '-_ '
|
|
167
218
|
));
|
|
168
219
|
|
|
169
220
|
-- Store SHA-256 hash, not the raw token
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
221
|
+
v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex');
|
|
222
|
+
v_family_id := COALESCE(p_family_id, gen_random_uuid());
|
|
223
|
+
v_expires_at := now() + p_ttl;
|
|
224
|
+
|
|
225
|
+
INSERT INTO auth.refresh_tokens (
|
|
226
|
+
repository_id, user_id, token_hash, family_id, status,
|
|
227
|
+
ip_address, user_agent, device_id, expires_at
|
|
228
|
+
)
|
|
229
|
+
VALUES (
|
|
230
|
+
p_repository_id, p_user_id, v_token_hash, v_family_id, 'active',
|
|
231
|
+
p_ip_address, p_user_agent, p_device_id, v_expires_at
|
|
232
|
+
);
|
|
175
233
|
|
|
176
|
-
RETURN
|
|
234
|
+
RETURN jsonb_build_object(
|
|
235
|
+
'token', v_raw_token,
|
|
236
|
+
'familyId', v_family_id,
|
|
237
|
+
'expiresAt', v_expires_at
|
|
238
|
+
);
|
|
177
239
|
END;
|
|
178
|
-
|
|
240
|
+
$$;
|
|
241
|
+
|
|
242
|
+
REVOKE EXECUTE ON FUNCTION auth.issue_refresh_token FROM PUBLIC;`;
|
|
179
243
|
|
|
180
244
|
const VALIDATE_REFRESH_TOKEN = `\
|
|
181
|
-
CREATE OR REPLACE FUNCTION auth.validate_refresh_token(
|
|
245
|
+
CREATE OR REPLACE FUNCTION auth.validate_refresh_token(
|
|
246
|
+
p_token TEXT,
|
|
247
|
+
p_device_id UUID DEFAULT NULL,
|
|
248
|
+
p_ip_address TEXT DEFAULT NULL,
|
|
249
|
+
p_user_agent TEXT DEFAULT NULL,
|
|
250
|
+
p_ttl INTERVAL DEFAULT '30 days'
|
|
251
|
+
)
|
|
182
252
|
RETURNS JSONB
|
|
183
253
|
LANGUAGE plpgsql
|
|
184
254
|
SECURITY DEFINER
|
|
255
|
+
SET search_path = auth, public, pg_temp
|
|
185
256
|
AS $$
|
|
186
257
|
DECLARE
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
258
|
+
v_token_hash TEXT;
|
|
259
|
+
v_row RECORD;
|
|
260
|
+
v_user_deleted TIMESTAMPTZ;
|
|
261
|
+
v_new_token JSONB;
|
|
191
262
|
BEGIN
|
|
192
|
-
|
|
263
|
+
-- 1. Hash the input token
|
|
264
|
+
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
|
193
265
|
|
|
194
|
-
|
|
266
|
+
-- 2. Look up with row lock (prevents concurrent refresh races)
|
|
267
|
+
SELECT id, user_id, family_id, status, expires_at, device_id, repository_id
|
|
268
|
+
INTO v_row
|
|
195
269
|
FROM auth.refresh_tokens
|
|
196
|
-
WHERE token_hash =
|
|
197
|
-
FOR UPDATE
|
|
198
|
-
LIMIT 1;
|
|
270
|
+
WHERE token_hash = v_token_hash
|
|
271
|
+
FOR UPDATE;
|
|
199
272
|
|
|
200
|
-
IF
|
|
201
|
-
RAISE EXCEPTION 'invalid_token';
|
|
273
|
+
IF NOT FOUND THEN
|
|
274
|
+
RAISE EXCEPTION 'invalid_token' USING ERRCODE = 'P0001';
|
|
202
275
|
END IF;
|
|
203
276
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
277
|
+
-- 3. Consumed token reuse = theft detected -> revoke entire family
|
|
278
|
+
-- Return error as JSONB instead of RAISE because PostgreSQL rolls back
|
|
279
|
+
-- the DELETE when an exception propagates. Returning normally lets the
|
|
280
|
+
-- family deletion commit. The caller checks for result.error.
|
|
281
|
+
IF v_row.status = 'consumed' THEN
|
|
282
|
+
DELETE FROM auth.refresh_tokens WHERE family_id = v_row.family_id;
|
|
283
|
+
RETURN jsonb_build_object('error', 'token_reused');
|
|
284
|
+
END IF;
|
|
285
|
+
|
|
286
|
+
-- 4. Check expiry
|
|
287
|
+
IF v_row.expires_at < now() THEN
|
|
288
|
+
DELETE FROM auth.refresh_tokens WHERE id = v_row.id;
|
|
289
|
+
RAISE EXCEPTION 'token_expired' USING ERRCODE = 'P0003';
|
|
290
|
+
END IF;
|
|
291
|
+
|
|
292
|
+
-- 5. Check user is not deleted
|
|
293
|
+
SELECT deleted_at INTO v_user_deleted
|
|
294
|
+
FROM auth.users
|
|
295
|
+
WHERE id = v_row.user_id;
|
|
296
|
+
|
|
297
|
+
IF v_user_deleted IS NOT NULL THEN
|
|
298
|
+
DELETE FROM auth.refresh_tokens WHERE user_id = v_row.user_id;
|
|
299
|
+
RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
|
|
300
|
+
END IF;
|
|
301
|
+
|
|
302
|
+
-- 6. Device binding: if token was issued to a specific device, verify it
|
|
303
|
+
IF v_row.device_id IS NOT NULL AND p_device_id IS NOT NULL
|
|
304
|
+
AND v_row.device_id IS DISTINCT FROM p_device_id THEN
|
|
305
|
+
DELETE FROM auth.refresh_tokens WHERE family_id = v_row.family_id;
|
|
306
|
+
RAISE EXCEPTION 'device_mismatch' USING ERRCODE = 'P0005';
|
|
307
|
+
END IF;
|
|
308
|
+
|
|
309
|
+
-- 7. Rotate: mark consumed, issue new token in same family
|
|
310
|
+
UPDATE auth.refresh_tokens
|
|
311
|
+
SET status = 'consumed'
|
|
312
|
+
WHERE id = v_row.id;
|
|
313
|
+
|
|
314
|
+
-- Inherit repository_id from the original token
|
|
315
|
+
v_new_token := auth.issue_refresh_token(
|
|
316
|
+
v_row.user_id,
|
|
317
|
+
v_row.family_id,
|
|
318
|
+
p_ip_address,
|
|
319
|
+
p_user_agent,
|
|
320
|
+
v_row.device_id,
|
|
321
|
+
v_row.repository_id,
|
|
322
|
+
p_ttl
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
RETURN v_new_token || jsonb_build_object('userId', v_row.user_id);
|
|
326
|
+
END;
|
|
327
|
+
$$;
|
|
328
|
+
|
|
329
|
+
REVOKE EXECUTE ON FUNCTION auth.validate_refresh_token FROM PUBLIC;`;
|
|
330
|
+
|
|
331
|
+
const REVOKE_REFRESH_TOKENS = `\
|
|
332
|
+
CREATE OR REPLACE FUNCTION auth.revoke_refresh_tokens(
|
|
333
|
+
p_user_id UUID,
|
|
334
|
+
p_family_id UUID DEFAULT NULL,
|
|
335
|
+
p_repository_id UUID DEFAULT NULL
|
|
336
|
+
)
|
|
337
|
+
RETURNS INTEGER
|
|
338
|
+
LANGUAGE plpgsql
|
|
339
|
+
SECURITY DEFINER
|
|
340
|
+
SET search_path = auth, public, pg_temp
|
|
341
|
+
AS $$
|
|
342
|
+
DECLARE
|
|
343
|
+
v_count INTEGER;
|
|
344
|
+
BEGIN
|
|
345
|
+
IF p_family_id IS NOT NULL THEN
|
|
346
|
+
DELETE FROM auth.refresh_tokens
|
|
347
|
+
WHERE user_id = p_user_id AND family_id = p_family_id;
|
|
348
|
+
ELSIF p_repository_id IS NOT NULL THEN
|
|
349
|
+
DELETE FROM auth.refresh_tokens
|
|
350
|
+
WHERE user_id = p_user_id AND repository_id = p_repository_id;
|
|
351
|
+
ELSE
|
|
352
|
+
DELETE FROM auth.refresh_tokens
|
|
353
|
+
WHERE user_id = p_user_id;
|
|
207
354
|
END IF;
|
|
208
355
|
|
|
209
|
-
|
|
210
|
-
|
|
356
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
357
|
+
RETURN v_count;
|
|
358
|
+
END;
|
|
359
|
+
$$;
|
|
360
|
+
|
|
361
|
+
REVOKE EXECUTE ON FUNCTION auth.revoke_refresh_tokens FROM PUBLIC;`;
|
|
362
|
+
|
|
363
|
+
const CLEANUP_REFRESH_TOKENS = `\
|
|
364
|
+
CREATE OR REPLACE FUNCTION auth.cleanup_refresh_tokens(
|
|
365
|
+
p_consumed_retention INTERVAL DEFAULT '7 days'
|
|
366
|
+
)
|
|
367
|
+
RETURNS INTEGER
|
|
368
|
+
LANGUAGE plpgsql
|
|
369
|
+
SECURITY DEFINER
|
|
370
|
+
SET search_path = auth, public, pg_temp
|
|
371
|
+
AS $$
|
|
372
|
+
DECLARE
|
|
373
|
+
v_count INTEGER;
|
|
374
|
+
BEGIN
|
|
375
|
+
DELETE FROM auth.refresh_tokens
|
|
376
|
+
WHERE expires_at < now()
|
|
377
|
+
OR (status = 'consumed' AND created_at < now() - p_consumed_retention);
|
|
378
|
+
|
|
379
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
380
|
+
RETURN v_count;
|
|
381
|
+
END;
|
|
382
|
+
$$;
|
|
383
|
+
|
|
384
|
+
REVOKE EXECUTE ON FUNCTION auth.cleanup_refresh_tokens FROM PUBLIC;`;
|
|
385
|
+
|
|
386
|
+
// =============================================================================
|
|
387
|
+
// SUPABASE-COMPATIBLE ACCESSOR FUNCTIONS
|
|
388
|
+
// =============================================================================
|
|
211
389
|
|
|
212
|
-
|
|
213
|
-
|
|
390
|
+
const AUTH_UID = `\
|
|
391
|
+
CREATE OR REPLACE FUNCTION auth.uid()
|
|
392
|
+
RETURNS uuid
|
|
393
|
+
LANGUAGE sql STABLE
|
|
394
|
+
AS $$
|
|
395
|
+
SELECT COALESCE(
|
|
396
|
+
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
|
|
397
|
+
NULL
|
|
398
|
+
);
|
|
399
|
+
$$;`;
|
|
400
|
+
|
|
401
|
+
const AUTH_ROLE = `\
|
|
402
|
+
CREATE OR REPLACE FUNCTION auth.role()
|
|
403
|
+
RETURNS text
|
|
404
|
+
LANGUAGE sql STABLE
|
|
405
|
+
AS $$
|
|
406
|
+
SELECT COALESCE(
|
|
407
|
+
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
|
|
408
|
+
'anon'
|
|
409
|
+
);
|
|
410
|
+
$$;`;
|
|
411
|
+
|
|
412
|
+
const AUTH_JWT = `\
|
|
413
|
+
CREATE OR REPLACE FUNCTION auth.jwt()
|
|
414
|
+
RETURNS jsonb
|
|
415
|
+
LANGUAGE sql STABLE
|
|
416
|
+
AS $$
|
|
417
|
+
SELECT COALESCE(
|
|
418
|
+
nullif(current_setting('request.jwt.claims', true), '')::jsonb,
|
|
419
|
+
'{}'::jsonb
|
|
420
|
+
);
|
|
421
|
+
$$;`;
|
|
422
|
+
|
|
423
|
+
// =============================================================================
|
|
424
|
+
// ONE-TIME TOKEN FUNCTIONS
|
|
425
|
+
// =============================================================================
|
|
426
|
+
|
|
427
|
+
const CREATE_ONE_TIME_TOKEN = `\
|
|
428
|
+
CREATE OR REPLACE FUNCTION auth.create_one_time_token(
|
|
429
|
+
p_user_id UUID,
|
|
430
|
+
p_token_type TEXT,
|
|
431
|
+
p_relates_to TEXT
|
|
432
|
+
)
|
|
433
|
+
RETURNS TEXT
|
|
434
|
+
LANGUAGE plpgsql
|
|
435
|
+
SECURITY DEFINER
|
|
436
|
+
SET search_path = auth, public, pg_temp
|
|
437
|
+
AS $$
|
|
438
|
+
DECLARE
|
|
439
|
+
v_raw_token TEXT;
|
|
440
|
+
v_token_hash TEXT;
|
|
441
|
+
BEGIN
|
|
442
|
+
-- Delete any existing token of this type for this user
|
|
443
|
+
DELETE FROM auth.one_time_tokens
|
|
444
|
+
WHERE user_id = p_user_id AND token_type = p_token_type;
|
|
445
|
+
|
|
446
|
+
-- Generate 32 random bytes, base64url encoded
|
|
447
|
+
v_raw_token := rtrim(translate(
|
|
214
448
|
encode(gen_random_bytes(32), 'base64'),
|
|
215
449
|
'+/=', '-_ '
|
|
216
450
|
));
|
|
217
|
-
v_new_hash := encode(digest(v_new_token, 'sha256'), 'hex');
|
|
218
451
|
|
|
219
|
-
|
|
220
|
-
|
|
452
|
+
v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex');
|
|
453
|
+
|
|
454
|
+
INSERT INTO auth.one_time_tokens (user_id, token_type, token_hash, relates_to)
|
|
455
|
+
VALUES (p_user_id, p_token_type, v_token_hash, p_relates_to);
|
|
456
|
+
|
|
457
|
+
RETURN v_raw_token;
|
|
458
|
+
END;
|
|
459
|
+
$$;
|
|
460
|
+
|
|
461
|
+
REVOKE EXECUTE ON FUNCTION auth.create_one_time_token FROM PUBLIC;`;
|
|
462
|
+
|
|
463
|
+
const VERIFY_ONE_TIME_TOKEN = `\
|
|
464
|
+
CREATE OR REPLACE FUNCTION auth.verify_one_time_token(
|
|
465
|
+
p_token TEXT,
|
|
466
|
+
p_token_type TEXT
|
|
467
|
+
)
|
|
468
|
+
RETURNS JSONB
|
|
469
|
+
LANGUAGE plpgsql
|
|
470
|
+
SECURITY DEFINER
|
|
471
|
+
SET search_path = auth, public, pg_temp
|
|
472
|
+
AS $$
|
|
473
|
+
DECLARE
|
|
474
|
+
v_token_hash TEXT;
|
|
475
|
+
v_row RECORD;
|
|
476
|
+
BEGIN
|
|
477
|
+
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
|
478
|
+
|
|
479
|
+
SELECT user_id, relates_to INTO v_row
|
|
480
|
+
FROM auth.one_time_tokens
|
|
481
|
+
WHERE token_hash = v_token_hash
|
|
482
|
+
AND token_type = p_token_type
|
|
483
|
+
FOR UPDATE;
|
|
484
|
+
|
|
485
|
+
IF NOT FOUND THEN
|
|
486
|
+
RAISE EXCEPTION 'invalid_token' USING ERRCODE = 'P0001';
|
|
487
|
+
END IF;
|
|
488
|
+
|
|
489
|
+
-- Delete the token (one-time use)
|
|
490
|
+
DELETE FROM auth.one_time_tokens
|
|
491
|
+
WHERE token_hash = v_token_hash AND token_type = p_token_type;
|
|
221
492
|
|
|
222
493
|
RETURN jsonb_build_object(
|
|
223
|
-
'userId',
|
|
224
|
-
'
|
|
225
|
-
'familyId', v_existing.family_id
|
|
494
|
+
'userId', v_row.user_id,
|
|
495
|
+
'relatesTo', v_row.relates_to
|
|
226
496
|
);
|
|
227
497
|
END;
|
|
228
|
-
|
|
498
|
+
$$;
|
|
229
499
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
500
|
+
REVOKE EXECUTE ON FUNCTION auth.verify_one_time_token FROM PUBLIC;`;
|
|
501
|
+
|
|
502
|
+
// =============================================================================
|
|
503
|
+
// DEFAULT AUTH FUNCTIONS (work against default auth.users schema)
|
|
504
|
+
// =============================================================================
|
|
505
|
+
|
|
506
|
+
const SIGN_UP = `\
|
|
507
|
+
CREATE OR REPLACE FUNCTION auth.sign_up(
|
|
508
|
+
p_email TEXT,
|
|
509
|
+
p_password TEXT,
|
|
510
|
+
p_user_meta_data JSONB DEFAULT '{}'
|
|
511
|
+
)
|
|
512
|
+
RETURNS JSONB
|
|
233
513
|
LANGUAGE plpgsql
|
|
234
514
|
SECURITY DEFINER
|
|
515
|
+
SET search_path = auth, public, pg_temp
|
|
235
516
|
AS $$
|
|
517
|
+
DECLARE
|
|
518
|
+
v_user RECORD;
|
|
519
|
+
v_encrypted TEXT;
|
|
236
520
|
BEGIN
|
|
237
|
-
|
|
521
|
+
-- Check if email already exists
|
|
522
|
+
SELECT id INTO v_user FROM auth.users
|
|
523
|
+
WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
|
|
524
|
+
|
|
525
|
+
IF FOUND THEN
|
|
526
|
+
RAISE EXCEPTION 'email_exists' USING ERRCODE = 'P0001';
|
|
527
|
+
END IF;
|
|
528
|
+
|
|
529
|
+
v_encrypted := auth.hash_password(p_password);
|
|
530
|
+
|
|
531
|
+
INSERT INTO auth.users (
|
|
532
|
+
email, encrypted_password, role, aud,
|
|
533
|
+
raw_user_meta_data, raw_app_meta_data
|
|
534
|
+
)
|
|
535
|
+
VALUES (
|
|
536
|
+
lower(trim(p_email)), v_encrypted, 'authenticated', 'authenticated',
|
|
537
|
+
p_user_meta_data, jsonb_build_object('provider', 'email', 'providers', jsonb_build_array('email'))
|
|
538
|
+
)
|
|
539
|
+
RETURNING * INTO v_user;
|
|
540
|
+
|
|
541
|
+
-- Create email identity
|
|
542
|
+
INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
|
|
543
|
+
VALUES (v_user.id, 'email', v_user.id::text, p_user_meta_data, v_user.email);
|
|
544
|
+
|
|
545
|
+
RETURN jsonb_build_object(
|
|
546
|
+
'sub', v_user.id,
|
|
547
|
+
'email', v_user.email,
|
|
548
|
+
'role', v_user.role
|
|
549
|
+
);
|
|
238
550
|
END;
|
|
239
|
-
|
|
551
|
+
$$;
|
|
240
552
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
553
|
+
REVOKE EXECUTE ON FUNCTION auth.sign_up FROM PUBLIC;`;
|
|
554
|
+
|
|
555
|
+
const SIGN_IN = `\
|
|
556
|
+
CREATE OR REPLACE FUNCTION auth.sign_in(
|
|
557
|
+
p_email TEXT,
|
|
558
|
+
p_password TEXT
|
|
559
|
+
)
|
|
560
|
+
RETURNS JSONB
|
|
561
|
+
LANGUAGE plpgsql
|
|
562
|
+
SECURITY DEFINER
|
|
563
|
+
SET search_path = auth, public, pg_temp
|
|
564
|
+
AS $$
|
|
565
|
+
DECLARE
|
|
566
|
+
v_user RECORD;
|
|
567
|
+
v_valid BOOLEAN;
|
|
568
|
+
BEGIN
|
|
569
|
+
SELECT * INTO v_user FROM auth.users
|
|
570
|
+
WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
|
|
571
|
+
|
|
572
|
+
IF NOT FOUND THEN
|
|
573
|
+
-- Timing-safe: still run bcrypt to prevent user enumeration
|
|
574
|
+
PERFORM auth.verify_password(p_password,
|
|
575
|
+
'$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012345');
|
|
576
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
577
|
+
END IF;
|
|
578
|
+
|
|
579
|
+
-- Check banned
|
|
580
|
+
IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
|
|
581
|
+
RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
|
|
582
|
+
END IF;
|
|
583
|
+
|
|
584
|
+
v_valid := auth.verify_password(p_password, v_user.encrypted_password);
|
|
585
|
+
IF NOT v_valid THEN
|
|
586
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
587
|
+
END IF;
|
|
588
|
+
|
|
589
|
+
-- Update last sign in
|
|
590
|
+
UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
|
|
591
|
+
WHERE id = v_user.id;
|
|
592
|
+
|
|
593
|
+
RETURN jsonb_build_object(
|
|
594
|
+
'sub', v_user.id,
|
|
595
|
+
'email', v_user.email,
|
|
596
|
+
'role', v_user.role
|
|
597
|
+
);
|
|
598
|
+
END;
|
|
599
|
+
$$;
|
|
600
|
+
|
|
601
|
+
REVOKE EXECUTE ON FUNCTION auth.sign_in FROM PUBLIC;`;
|
|
602
|
+
|
|
603
|
+
const OAUTH_SIGN_IN = `\
|
|
604
|
+
CREATE OR REPLACE FUNCTION auth.oauth_sign_in(
|
|
605
|
+
p_provider TEXT,
|
|
606
|
+
p_provider_id TEXT,
|
|
607
|
+
p_email TEXT,
|
|
608
|
+
p_identity_data JSONB DEFAULT '{}'
|
|
609
|
+
)
|
|
610
|
+
RETURNS JSONB
|
|
611
|
+
LANGUAGE plpgsql
|
|
612
|
+
SECURITY DEFINER
|
|
613
|
+
SET search_path = auth, public, pg_temp
|
|
614
|
+
AS $$
|
|
615
|
+
DECLARE
|
|
616
|
+
v_identity RECORD;
|
|
617
|
+
v_user RECORD;
|
|
618
|
+
v_providers JSONB;
|
|
619
|
+
BEGIN
|
|
620
|
+
-- Look up existing identity
|
|
621
|
+
SELECT * INTO v_identity FROM auth.identities
|
|
622
|
+
WHERE provider = p_provider AND provider_id = p_provider_id;
|
|
623
|
+
|
|
624
|
+
IF FOUND THEN
|
|
625
|
+
-- Existing user, update identity data and sign in
|
|
626
|
+
SELECT * INTO v_user FROM auth.users WHERE id = v_identity.user_id AND deleted_at IS NULL;
|
|
627
|
+
IF NOT FOUND THEN
|
|
628
|
+
RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
|
|
629
|
+
END IF;
|
|
630
|
+
|
|
631
|
+
UPDATE auth.identities
|
|
632
|
+
SET identity_data = p_identity_data, last_sign_in_at = now(), updated_at = now()
|
|
633
|
+
WHERE id = v_identity.id;
|
|
634
|
+
|
|
635
|
+
UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
|
|
636
|
+
WHERE id = v_user.id;
|
|
637
|
+
|
|
638
|
+
RETURN jsonb_build_object(
|
|
639
|
+
'sub', v_user.id,
|
|
640
|
+
'email', v_user.email,
|
|
641
|
+
'role', v_user.role,
|
|
642
|
+
'isNewUser', false
|
|
643
|
+
);
|
|
644
|
+
END IF;
|
|
645
|
+
|
|
646
|
+
-- Check if a user with this email already exists (link account)
|
|
647
|
+
SELECT * INTO v_user FROM auth.users
|
|
648
|
+
WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
|
|
649
|
+
|
|
650
|
+
IF FOUND THEN
|
|
651
|
+
-- Link the new provider to existing user
|
|
652
|
+
v_providers := COALESCE(v_user.raw_app_meta_data->'providers', '[]'::jsonb);
|
|
653
|
+
IF NOT v_providers ? p_provider THEN
|
|
654
|
+
v_providers := v_providers || to_jsonb(p_provider);
|
|
655
|
+
END IF;
|
|
656
|
+
|
|
657
|
+
UPDATE auth.users
|
|
658
|
+
SET raw_app_meta_data = COALESCE(raw_app_meta_data, '{}'::jsonb)
|
|
659
|
+
|| jsonb_build_object('providers', v_providers),
|
|
660
|
+
last_sign_in_at = now(),
|
|
661
|
+
updated_at = now()
|
|
662
|
+
WHERE id = v_user.id;
|
|
663
|
+
|
|
664
|
+
INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
|
|
665
|
+
VALUES (v_user.id, p_provider, p_provider_id, p_identity_data, lower(trim(p_email)));
|
|
666
|
+
|
|
667
|
+
RETURN jsonb_build_object(
|
|
668
|
+
'sub', v_user.id,
|
|
669
|
+
'email', v_user.email,
|
|
670
|
+
'role', v_user.role,
|
|
671
|
+
'isNewUser', false
|
|
672
|
+
);
|
|
673
|
+
END IF;
|
|
674
|
+
|
|
675
|
+
-- New user: create auth.users + auth.identities
|
|
676
|
+
INSERT INTO auth.users (
|
|
677
|
+
email, role, aud, raw_user_meta_data, raw_app_meta_data,
|
|
678
|
+
email_confirmed_at, last_sign_in_at
|
|
679
|
+
)
|
|
680
|
+
VALUES (
|
|
681
|
+
lower(trim(p_email)), 'authenticated', 'authenticated',
|
|
682
|
+
p_identity_data,
|
|
683
|
+
jsonb_build_object('provider', p_provider, 'providers', jsonb_build_array(p_provider)),
|
|
684
|
+
now(), now()
|
|
685
|
+
)
|
|
686
|
+
RETURNING * INTO v_user;
|
|
687
|
+
|
|
688
|
+
INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
|
|
689
|
+
VALUES (v_user.id, p_provider, p_provider_id, p_identity_data, v_user.email);
|
|
690
|
+
|
|
691
|
+
RETURN jsonb_build_object(
|
|
692
|
+
'sub', v_user.id,
|
|
693
|
+
'email', v_user.email,
|
|
694
|
+
'role', v_user.role,
|
|
695
|
+
'isNewUser', true
|
|
696
|
+
);
|
|
697
|
+
END;
|
|
698
|
+
$$;
|
|
699
|
+
|
|
700
|
+
REVOKE EXECUTE ON FUNCTION auth.oauth_sign_in FROM PUBLIC;`;
|
|
701
|
+
|
|
702
|
+
// =============================================================================
|
|
703
|
+
// JWT CONVENIENCE WRAPPERS
|
|
704
|
+
// =============================================================================
|
|
705
|
+
|
|
706
|
+
const SIGN_IN_JWT = `\
|
|
707
|
+
CREATE OR REPLACE FUNCTION auth.sign_in_jwt(p_email TEXT, p_password TEXT)
|
|
708
|
+
RETURNS JSONB
|
|
709
|
+
LANGUAGE plpgsql
|
|
710
|
+
SECURITY DEFINER
|
|
711
|
+
SET search_path = auth, public, pg_temp
|
|
712
|
+
AS $$
|
|
713
|
+
DECLARE
|
|
714
|
+
v_payload JSONB;
|
|
715
|
+
v_claims JSONB;
|
|
716
|
+
v_token TEXT;
|
|
717
|
+
v_refresh JSONB;
|
|
718
|
+
BEGIN
|
|
719
|
+
v_payload := auth.sign_in(p_email, p_password);
|
|
720
|
+
|
|
721
|
+
v_claims := v_payload || jsonb_build_object(
|
|
722
|
+
'iat', extract(epoch FROM now())::bigint,
|
|
723
|
+
'exp', (extract(epoch FROM now()) + 3600)::bigint
|
|
724
|
+
);
|
|
725
|
+
v_token := auth.sign_jwt(v_claims);
|
|
726
|
+
|
|
727
|
+
v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
|
|
728
|
+
|
|
729
|
+
RETURN jsonb_build_object(
|
|
730
|
+
'access_token', v_token,
|
|
731
|
+
'refresh_token', v_refresh->>'token',
|
|
732
|
+
'user', v_payload
|
|
733
|
+
);
|
|
734
|
+
END;
|
|
735
|
+
$$;
|
|
736
|
+
|
|
737
|
+
REVOKE EXECUTE ON FUNCTION auth.sign_in_jwt FROM PUBLIC;`;
|
|
738
|
+
|
|
739
|
+
const SIGN_UP_JWT = `\
|
|
740
|
+
CREATE OR REPLACE FUNCTION auth.sign_up_jwt(
|
|
741
|
+
p_email TEXT,
|
|
742
|
+
p_password TEXT,
|
|
743
|
+
p_user_meta_data JSONB DEFAULT '{}'
|
|
744
|
+
)
|
|
745
|
+
RETURNS JSONB
|
|
746
|
+
LANGUAGE plpgsql
|
|
747
|
+
SECURITY DEFINER
|
|
748
|
+
SET search_path = auth, public, pg_temp
|
|
749
|
+
AS $$
|
|
750
|
+
DECLARE
|
|
751
|
+
v_payload JSONB;
|
|
752
|
+
v_claims JSONB;
|
|
753
|
+
v_token TEXT;
|
|
754
|
+
v_refresh JSONB;
|
|
755
|
+
BEGIN
|
|
756
|
+
v_payload := auth.sign_up(p_email, p_password, p_user_meta_data);
|
|
757
|
+
|
|
758
|
+
v_claims := v_payload || jsonb_build_object(
|
|
759
|
+
'iat', extract(epoch FROM now())::bigint,
|
|
760
|
+
'exp', (extract(epoch FROM now()) + 3600)::bigint
|
|
761
|
+
);
|
|
762
|
+
v_token := auth.sign_jwt(v_claims);
|
|
763
|
+
|
|
764
|
+
v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
|
|
765
|
+
|
|
766
|
+
RETURN jsonb_build_object(
|
|
767
|
+
'access_token', v_token,
|
|
768
|
+
'refresh_token', v_refresh->>'token',
|
|
769
|
+
'user', v_payload
|
|
770
|
+
);
|
|
771
|
+
END;
|
|
772
|
+
$$;
|
|
773
|
+
|
|
774
|
+
REVOKE EXECUTE ON FUNCTION auth.sign_up_jwt FROM PUBLIC;`;
|
|
775
|
+
|
|
776
|
+
const OAUTH_SIGN_IN_JWT = `\
|
|
777
|
+
CREATE OR REPLACE FUNCTION auth.oauth_sign_in_jwt(
|
|
778
|
+
p_provider TEXT,
|
|
779
|
+
p_provider_id TEXT,
|
|
780
|
+
p_email TEXT,
|
|
781
|
+
p_identity_data JSONB DEFAULT '{}'
|
|
782
|
+
)
|
|
783
|
+
RETURNS JSONB
|
|
784
|
+
LANGUAGE plpgsql
|
|
785
|
+
SECURITY DEFINER
|
|
786
|
+
SET search_path = auth, public, pg_temp
|
|
787
|
+
AS $$
|
|
788
|
+
DECLARE
|
|
789
|
+
v_payload JSONB;
|
|
790
|
+
v_claims JSONB;
|
|
791
|
+
v_token TEXT;
|
|
792
|
+
v_refresh JSONB;
|
|
793
|
+
BEGIN
|
|
794
|
+
v_payload := auth.oauth_sign_in(p_provider, p_provider_id, p_email, p_identity_data);
|
|
795
|
+
|
|
796
|
+
v_claims := v_payload || jsonb_build_object(
|
|
797
|
+
'iat', extract(epoch FROM now())::bigint,
|
|
798
|
+
'exp', (extract(epoch FROM now()) + 3600)::bigint
|
|
799
|
+
);
|
|
800
|
+
v_token := auth.sign_jwt(v_claims);
|
|
801
|
+
|
|
802
|
+
v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
|
|
803
|
+
|
|
804
|
+
RETURN jsonb_build_object(
|
|
805
|
+
'access_token', v_token,
|
|
806
|
+
'refresh_token', v_refresh->>'token',
|
|
807
|
+
'user', v_payload
|
|
808
|
+
);
|
|
809
|
+
END;
|
|
810
|
+
$$;
|
|
811
|
+
|
|
812
|
+
REVOKE EXECUTE ON FUNCTION auth.oauth_sign_in_jwt FROM PUBLIC;`;
|
|
813
|
+
|
|
814
|
+
// =============================================================================
|
|
815
|
+
// COMPOSABLE GENERATORS
|
|
816
|
+
// =============================================================================
|
|
817
|
+
|
|
818
|
+
/** pgcrypto + hash_password + verify_password + sign_jwt + verify_jwt */
|
|
819
|
+
export function generateCoreSQL(): string {
|
|
820
|
+
return [PGCRYPTO, '', HASH_PASSWORD, '', VERIFY_PASSWORD, '', SIGN_JWT, '', VERIFY_JWT]
|
|
821
|
+
.join('\n')
|
|
822
|
+
.trimEnd() + '\n';
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** issue + validate + revoke + cleanup refresh tokens (RFC 6819) */
|
|
826
|
+
export function generateRefreshTokenSQL(): string {
|
|
827
|
+
return [ISSUE_REFRESH_TOKEN, '', VALIDATE_REFRESH_TOKEN, '', REVOKE_REFRESH_TOKENS, '', CLEANUP_REFRESH_TOKENS]
|
|
828
|
+
.join('\n')
|
|
829
|
+
.trimEnd() + '\n';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** auth.uid(), auth.role(), auth.jwt() — Supabase-compatible RLS accessors */
|
|
833
|
+
export function generateAccessorSQL(): string {
|
|
834
|
+
return [AUTH_UID, '', AUTH_ROLE, '', AUTH_JWT]
|
|
835
|
+
.join('\n')
|
|
836
|
+
.trimEnd() + '\n';
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** auth.create_one_time_token, auth.verify_one_time_token */
|
|
840
|
+
export function generateOneTimeTokenSQL(): string {
|
|
841
|
+
return [CREATE_ONE_TIME_TOKEN, '', VERIFY_ONE_TIME_TOKEN]
|
|
842
|
+
.join('\n')
|
|
843
|
+
.trimEnd() + '\n';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** auth.sign_in, auth.sign_up, auth.oauth_sign_in (work against default schema) */
|
|
847
|
+
export function generateDefaultAuthSQL(): string {
|
|
848
|
+
return [SIGN_UP, '', SIGN_IN, '', OAUTH_SIGN_IN]
|
|
849
|
+
.join('\n')
|
|
850
|
+
.trimEnd() + '\n';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** auth.sign_in_jwt, auth.sign_up_jwt, auth.oauth_sign_in_jwt */
|
|
854
|
+
export function generateJwtWrappersSQL(): string {
|
|
855
|
+
return [SIGN_IN_JWT, '', SIGN_UP_JWT, '', OAUTH_SIGN_IN_JWT]
|
|
856
|
+
.join('\n')
|
|
857
|
+
.trimEnd() + '\n';
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// =============================================================================
|
|
861
|
+
// MAIN ENTRY POINT
|
|
862
|
+
// =============================================================================
|
|
863
|
+
|
|
864
|
+
export interface AuthSQLOptions {
|
|
865
|
+
/** Include Supabase-compatible accessor functions (default: true) */
|
|
866
|
+
accessors?: boolean;
|
|
867
|
+
/** Include one-time token functions (default: true) */
|
|
868
|
+
oneTimeTokens?: boolean;
|
|
869
|
+
/** Include default sign_in/sign_up/oauth_sign_in functions (default: true) */
|
|
870
|
+
defaultAuthFunctions?: boolean;
|
|
871
|
+
/** Include JWT convenience wrappers (default: false) */
|
|
872
|
+
jwtWrappers?: boolean;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Generate all auth SQL functions. Composable via options.
|
|
877
|
+
*
|
|
878
|
+
* Always includes: core (pgcrypto, hash, verify, JWT) + refresh tokens.
|
|
879
|
+
* Toggle the rest with options. Apps that replace the schema typically call
|
|
880
|
+
* generateAuthSQL({ defaultAuthFunctions: false }) and provide their own.
|
|
881
|
+
*/
|
|
882
|
+
export function generateAuthSQL(options?: AuthSQLOptions): string {
|
|
883
|
+
const parts: string[] = [
|
|
884
|
+
generateCoreSQL(),
|
|
256
885
|
'',
|
|
257
|
-
|
|
886
|
+
generateRefreshTokenSQL(),
|
|
258
887
|
];
|
|
259
888
|
|
|
889
|
+
if (options?.accessors !== false) {
|
|
890
|
+
parts.push('', generateAccessorSQL());
|
|
891
|
+
}
|
|
892
|
+
if (options?.oneTimeTokens !== false) {
|
|
893
|
+
parts.push('', generateOneTimeTokenSQL());
|
|
894
|
+
}
|
|
895
|
+
if (options?.defaultAuthFunctions !== false) {
|
|
896
|
+
parts.push('', generateDefaultAuthSQL());
|
|
897
|
+
}
|
|
898
|
+
if (options?.jwtWrappers) {
|
|
899
|
+
parts.push('', generateJwtWrappersSQL());
|
|
900
|
+
}
|
|
901
|
+
|
|
260
902
|
return parts.join('\n').trimEnd() + '\n';
|
|
261
903
|
}
|