@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/src/sql.ts CHANGED
@@ -1,19 +1,28 @@
1
1
  /**
2
- * @everystack/auth/sql — Generate SQL for PostgreSQL auth functions.
2
+ * @everystack/auth/sql — Composable SQL generators for PostgreSQL auth functions.
3
3
  *
4
- * Creates 7 functions in the `auth` schema using pgcrypto:
5
- * - auth.hash_password — bcrypt via crypt()/gen_salt()
6
- * - auth.verify_password — bcrypt verification
7
- * - auth.sign_jwt — HMAC-SHA256 JWT signing
8
- * - auth.verify_jwt — JWT verification
9
- * - auth.generate_refresh_token — opaque token with SHA-256 hash storage
10
- * - auth.validate_refresh_token — one-time-use rotation
11
- * - auth.revoke_refresh_tokens — delete all tokens for a user
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
- const GENERATE_REFRESH_TOKEN = `\
149
- CREATE OR REPLACE FUNCTION auth.generate_refresh_token(p_user_id UUID)
150
- RETURNS TEXT
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
- v_token TEXT;
156
- v_hash TEXT;
157
- v_family TEXT;
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 AND expires_at < now();
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 token (32 bytes, base64url encoded)
164
- v_token := rtrim(translate(
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
- v_hash := encode(digest(v_token, 'sha256'), 'hex');
171
- v_family := gen_random_uuid()::text;
172
-
173
- INSERT INTO auth.refresh_tokens (user_id, token_hash, family_id, expires_at)
174
- VALUES (p_user_id, v_hash, v_family, now() + interval '7 days');
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 v_token;
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(p_token TEXT)
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
- v_hash TEXT;
188
- v_existing RECORD;
189
- v_new_token TEXT;
190
- v_new_hash TEXT;
258
+ v_token_hash TEXT;
259
+ v_row RECORD;
260
+ v_user_deleted TIMESTAMPTZ;
261
+ v_new_token JSONB;
191
262
  BEGIN
192
- v_hash := encode(digest(p_token, 'sha256'), 'hex');
263
+ -- 1. Hash the input token
264
+ v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
193
265
 
194
- SELECT * INTO v_existing
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 = v_hash
197
- FOR UPDATE
198
- LIMIT 1;
270
+ WHERE token_hash = v_token_hash
271
+ FOR UPDATE;
199
272
 
200
- IF v_existing IS NULL THEN
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
- IF v_existing.expires_at < now() THEN
205
- DELETE FROM auth.refresh_tokens WHERE id = v_existing.id;
206
- RAISE EXCEPTION 'token_expired';
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
- -- Delete old token (one-time use)
210
- DELETE FROM auth.refresh_tokens WHERE id = v_existing.id;
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
- -- Generate new token in same family
213
- v_new_token := rtrim(translate(
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
- INSERT INTO auth.refresh_tokens (user_id, token_hash, family_id, expires_at)
220
- VALUES (v_existing.user_id, v_new_hash, v_existing.family_id, now() + interval '7 days');
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', v_existing.user_id,
224
- 'token', v_new_token,
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
- const REVOKE_REFRESH_TOKENS = `\
231
- CREATE OR REPLACE FUNCTION auth.revoke_refresh_tokens(p_user_id UUID)
232
- RETURNS VOID
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
- DELETE FROM auth.refresh_tokens WHERE user_id = p_user_id;
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
- export function generateAuthSQL(): string {
242
- const parts = [
243
- PGCRYPTO,
244
- '',
245
- HASH_PASSWORD,
246
- '',
247
- VERIFY_PASSWORD,
248
- '',
249
- SIGN_JWT,
250
- '',
251
- VERIFY_JWT,
252
- '',
253
- GENERATE_REFRESH_TOKEN,
254
- '',
255
- VALIDATE_REFRESH_TOKEN,
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
- REVOKE_REFRESH_TOKENS,
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
  }