@everystack/auth 0.4.6 → 0.4.8

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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to `@everystack/auth` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.8] - 2026-09-26
8
+
9
+ ### Fixed
10
+
11
+ - revoke sessions when users are blocked
12
+
13
+ ## [0.4.7] - 2026-09-26
14
+
15
+ ### Fixed
16
+
17
+ - enforce bans on refresh and signin, check password before ban
18
+ - make the access token lifetime configurable
19
+ - signout revokes the presented refresh token
20
+ - refuse banned users on OAuth sign-in; signout never fails on revoke
21
+
22
+ ### Documentation
23
+
24
+ - document signout revocation, accessTokenTtlSec and ban enforcement
25
+
26
+ ## [0.4.6] - 2026-09-22
27
+
28
+ ### Fixed
29
+
30
+ - refresh expired JWT instead of silently failing every request
31
+
7
32
  ## [0.4.5] - 2026-09-16
8
33
 
9
34
  ### Added
package/README.md CHANGED
@@ -111,11 +111,14 @@ const auth = createAuthHandlers(
111
111
  | `signup` | POST | `{ email, password, username, displayName }` | `{ token, refreshToken, user }` |
112
112
  | `signin` | POST | `{ email, password }` | `{ token, refreshToken, user }` |
113
113
  | `refresh` | POST | `{ refreshToken }` | `{ token, refreshToken }` |
114
+ | `signout` | POST | `{ refreshToken }` (or the refresh cookie) | revokes that session's refresh tokens, clears cookies |
114
115
  | `oauth` | POST | `{ provider, token }` | `{ token, refreshToken, user }` |
115
116
  | `verifyToken` | — | token string | `TokenPayload \| null` |
116
117
  | `revokeRefreshTokens` | — | `userId` | `void` |
117
118
 
118
- Defaults: HS256, access TTL 1h, refresh TTL 7d.
119
+ Defaults: HS256, access TTL 1h, refresh TTL 7d. Set the access TTL with `accessTokenTtlSec` (seconds) in the options. If you install the SQL `_jwt` wrappers, pass the same value to `generateAuthSQL({ jwtWrappers: true, accessTokenTtlSec })`.
120
+
121
+ Banned (`bannedUntil` in the future) and soft-deleted (`deletedAt`) users cannot sign in or refresh, in either mode. A banned user gets 403 only after the correct password; any wrong password gets the usual 401.
119
122
 
120
123
  ## OAuth (RS256 + JWKS)
121
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/auth",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "JWT authentication handlers, password hashing, and React auth context",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -108,7 +108,7 @@
108
108
  },
109
109
  "dependencies": {
110
110
  "bcryptjs": "3.0.3",
111
- "@everystack/model": "0.4.19"
111
+ "@everystack/model": "0.4.20"
112
112
  },
113
113
  "peerDependencies": {
114
114
  "@everystack/api": ">=0.1.0",
@@ -137,7 +137,7 @@
137
137
  "postgres": "3.4.9",
138
138
  "ts-jest": "29.4.9",
139
139
  "typescript": "5.9.3",
140
- "@everystack/server": "0.4.32",
140
+ "@everystack/server": "0.4.33",
141
141
  "@everystack/api": "0.4.2"
142
142
  },
143
143
  "scripts": {
package/src/cookies.ts CHANGED
@@ -98,3 +98,17 @@ export function getRefreshFromCookies(cookieHeader: string | null): string | nul
98
98
  const cookies = parseCookies(cookieHeader);
99
99
  return cookies[COOKIE_NAMES.refresh] ?? null;
100
100
  }
101
+
102
+ /**
103
+ * The refresh token a request presents: the JSON body's `refreshToken` (mobile), else the
104
+ * refresh cookie (web). Null when neither is present or the body is not JSON.
105
+ */
106
+ export async function readPresentedRefreshToken(request: Request): Promise<string | null> {
107
+ try {
108
+ const body = await request.clone().json();
109
+ if (typeof body?.refreshToken === 'string' && body.refreshToken) return body.refreshToken;
110
+ } catch {
111
+ // No or non-JSON body: fall through to the cookie.
112
+ }
113
+ return getRefreshFromCookies(request.headers.get('cookie'));
114
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  createRefreshToken,
31
31
  rotateRefreshToken,
32
32
  revokeAllRefreshTokens,
33
+ revokeRefreshTokenFamilyByToken,
33
34
  TokenError,
34
35
  } from './refresh';
35
36
  import type { RefreshTokenOptions } from './refresh';
@@ -37,14 +38,14 @@ import type { OAuthProvider, OAuthUserInfo } from './oauth';
37
38
  export type { OAuthProvider, OAuthUserInfo } from './oauth';
38
39
  export { createAppleProvider, createGoogleProvider } from './oauth';
39
40
  import { createPostgresAuthHandlers } from './postgres';
40
- import { getRefreshFromCookies } from './cookies';
41
+ import { getRefreshFromCookies, readPresentedRefreshToken } from './cookies';
41
42
  import { authResponse, signoutResponse } from './response';
42
43
  import type { CookieConfig } from './response';
43
44
  import type { EnrichClaimsFn } from './plugin';
44
45
  export type { EnrichClaimsFn } from './plugin';
45
46
  import { validateCsrfOrigin } from './csrf';
47
+ import { resolveAccessTokenTtl } from './sql';
46
48
 
47
- const TOKEN_EXPIRY_SEC = 3600; // 1h
48
49
  const REFRESH_EXPIRY_SEC = 30 * 24 * 3600; // 30d
49
50
 
50
51
  /**
@@ -66,6 +67,35 @@ export function getPasswordHash(user: any): string | null {
66
67
  * Build insert values with the correct password column name.
67
68
  * Checks which column exists on the Drizzle table definition.
68
69
  */
70
+ /**
71
+ * Why a user row may not hold a session, or null when it may. Reads row fields, not table
72
+ * columns, so an app schema without `bannedUntil`/`deletedAt` simply never trips it.
73
+ * Mirrors the SQL side: auth.sign_in filters deleted_at, auth.validate_refresh_token
74
+ * rejects deleted_at (P0004) and an unexpired banned_until (P0007).
75
+ */
76
+ export function accountBlock(user: any): 'deleted' | 'banned' | null {
77
+ if (user.deletedAt) return 'deleted';
78
+ if (user.bannedUntil && new Date(user.bannedUntil).getTime() > Date.now()) return 'banned';
79
+ return null;
80
+ }
81
+
82
+ /** The refusal for a sign-in path that already proved identity (OAuth), or null to proceed.
83
+ * A missing row is treated as a deleted account. */
84
+ export function accountBlockedResponse(user: any): Response | null {
85
+ const block = user ? accountBlock(user) : 'deleted';
86
+ if (block === 'banned') return Response.json({ message: 'Account suspended' }, { status: 403 });
87
+ if (block === 'deleted') return Response.json({ message: 'Account disabled' }, { status: 401 });
88
+ return null;
89
+ }
90
+
91
+ /** Signout must always clear cookies. A failed server-side revoke is logged, never thrown:
92
+ * only the error's code or message is logged, never the token. */
93
+ export function warnRevokeFailed(err: unknown): void {
94
+ const e = err as { code?: string; message?: string } | undefined;
95
+ // eslint-disable-next-line no-console
96
+ console.warn('[everystack/auth] signout: refresh token revocation failed:', e?.code ?? e?.message ?? 'unknown error');
97
+ }
98
+
69
99
  export function withPassword(users: any, values: Record<string, any>, hash: string | null): Record<string, any> {
70
100
  if ('encryptedPassword' in users) {
71
101
  return { ...values, encryptedPassword: hash };
@@ -136,6 +166,9 @@ export interface AuthOptions {
136
166
  * Return true if valid, false to reject. */
137
167
  validateNonce?: (nonce: string, userInfo: OAuthUserInfo) => Promise<boolean>;
138
168
  };
169
+ /** Access-token (JWT) lifetime in seconds. Positive integer; default 3600. For the SQL
170
+ * `_jwt` wrappers, pass the same value to `generateAuthSQL({ accessTokenTtlSec })`. */
171
+ accessTokenTtlSec?: number;
139
172
  /** When set, auth responses also set HttpOnly cookies. `true` uses defaults. */
140
173
  cookies?: CookieConfig;
141
174
  /** When true, signup returns the same 200 response whether the email exists or not.
@@ -433,6 +466,7 @@ export function createAuthHandlers(
433
466
  options?: AuthOptions
434
467
  ): AuthHandlers {
435
468
  assertSafeCookieConfig(options?.cookies);
469
+ const accessTokenTtlSec = resolveAccessTokenTtl(options?.accessTokenTtlSec);
436
470
 
437
471
  if (options?.mode === 'postgres') {
438
472
  const handlers = createPostgresAuthHandlers(db, schema, jwtSecret, options);
@@ -446,7 +480,7 @@ export function createAuthHandlers(
446
480
  const accessJwt = createJwt({
447
481
  secret: jwtSecret,
448
482
  hmac: hmacNode,
449
- ttlSec: TOKEN_EXPIRY_SEC,
483
+ ttlSec: accessTokenTtlSec,
450
484
  });
451
485
  const refreshJwt = createJwt({
452
486
  secret: jwtSecret,
@@ -571,13 +605,19 @@ export function createAuthHandlers(
571
605
  );
572
606
  }
573
607
 
608
+ // Password first, account status second: every outcome pays for bcrypt, and only a
609
+ // caller who proved the password learns the account is suspended.
574
610
  const valid = await verifyPassword(password, getPasswordHash(user)!);
575
- if (!valid) {
611
+ const block = accountBlock(user);
612
+ if (!valid || block === 'deleted') {
576
613
  return Response.json(
577
614
  { message: 'Invalid email or password' },
578
615
  { status: 401 }
579
616
  );
580
617
  }
618
+ if (block === 'banned') {
619
+ return Response.json({ message: 'Account suspended' }, { status: 403 });
620
+ }
581
621
 
582
622
  const [profile] = await db
583
623
  .select()
@@ -638,9 +678,15 @@ export function createAuthHandlers(
638
678
  // DB-backed rotation path
639
679
  try {
640
680
  const meta = extractRequestMeta(request, options?.repositoryId);
681
+ // checkUser runs before rotation: a banned/deleted account gets no new token, and
682
+ // rotateRefreshToken revokes every token it holds.
641
683
  const result = await rotateRefreshToken(db, refreshTable, refreshToken, {
642
684
  ipAddress: meta.ipAddress,
643
685
  userAgent: meta.userAgent,
686
+ checkUser: async (userId: string) => {
687
+ const [u] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
688
+ return !!u && accountBlock(u) === null;
689
+ },
644
690
  });
645
691
 
646
692
  const [user] = await db
@@ -690,6 +736,9 @@ export function createAuthHandlers(
690
736
  if (!user) {
691
737
  return Response.json({ message: 'User not found' }, { status: 401 });
692
738
  }
739
+ if (accountBlock(user)) {
740
+ return Response.json({ message: 'Invalid or expired refresh token' }, { status: 401 });
741
+ }
693
742
 
694
743
  const token = await signToken({
695
744
  sub: user.id,
@@ -793,6 +842,8 @@ export function createAuthHandlers(
793
842
  .from(users)
794
843
  .where(eq(users.id, existing.userId))
795
844
  .limit(1);
845
+ const blocked = accountBlockedResponse(existingUser);
846
+ if (blocked) return blocked;
796
847
 
797
848
  const [profile] = await db
798
849
  .select()
@@ -879,6 +930,8 @@ export function createAuthHandlers(
879
930
  .from(users)
880
931
  .where(eq(users.id, existingAccount.userId))
881
932
  .limit(1);
933
+ const blocked = accountBlockedResponse(existingUser);
934
+ if (blocked) return blocked;
882
935
 
883
936
  const [profile] = await db
884
937
  .select()
@@ -989,6 +1042,17 @@ export function createAuthHandlers(
989
1042
  const csrfError = csrfCheck(request, options?.cookies);
990
1043
  if (csrfError) return csrfError;
991
1044
 
1045
+ // End the session server-side, not just in the browser: revoke the presented token's
1046
+ // family. Stateless JWT refresh tokens have nothing to revoke.
1047
+ const presented = await readPresentedRefreshToken(request);
1048
+ if (refreshTable && presented) {
1049
+ try {
1050
+ await revokeRefreshTokenFamilyByToken(db, refreshTable, presented);
1051
+ } catch (err) {
1052
+ warnRevokeFailed(err);
1053
+ }
1054
+ }
1055
+
992
1056
  return signoutResponse(options?.cookies);
993
1057
  },
994
1058
 
@@ -39,7 +39,9 @@
39
39
  * hand-patch a body here.
40
40
  */
41
41
 
42
- import { defineFunction, arg, can, sql } from '@everystack/model';
42
+ import { defineFunction, defineTrigger, arg, can, sql } from '@everystack/model';
43
+ import { DEFAULT_ACCESS_TOKEN_TTL_SEC, resolveAccessTokenTtl } from '../sql';
44
+ import { User } from './user';
43
45
 
44
46
  export const changePasswordFn = defineFunction('auth.change_password', {
45
47
  args: [arg('p_current_password', 'text'), arg('p_new_password', 'text')],
@@ -286,6 +288,10 @@ BEGIN
286
288
  IF NOT FOUND THEN
287
289
  RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
288
290
  END IF;
291
+ -- The provider proved identity; a ban still ends the road here.
292
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
293
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
294
+ END IF;
289
295
 
290
296
  UPDATE auth.identities
291
297
  SET identity_data = p_identity_data, last_sign_in_at = now(), updated_at = now()
@@ -307,6 +313,11 @@ BEGIN
307
313
  WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
308
314
 
309
315
  IF FOUND THEN
316
+ -- Never link a provider to (or sign in) a banned account.
317
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
318
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
319
+ END IF;
320
+
310
321
  -- Link the new provider to existing user
311
322
  v_providers := COALESCE(v_user.raw_app_meta_data->'providers', '[]'::jsonb);
312
323
  IF NOT v_providers ? p_provider THEN
@@ -357,7 +368,7 @@ END;
357
368
  `,
358
369
  });
359
370
 
360
- export const oauthSignInJwtFn = defineFunction('auth.oauth_sign_in_jwt', {
371
+ const oauthSignInJwt = (ttl: number) => defineFunction('auth.oauth_sign_in_jwt', {
361
372
  args: [arg('p_provider', 'text'), arg('p_provider_id', 'text'), arg('p_email', 'text'), arg('p_identity_data', 'jsonb', { default: sql`'{}'::jsonb` })],
362
373
  returns: 'jsonb',
363
374
  language: 'plpgsql',
@@ -375,7 +386,7 @@ BEGIN
375
386
 
376
387
  v_claims := v_payload || jsonb_build_object(
377
388
  'iat', extract(epoch FROM now())::bigint,
378
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
389
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
379
390
  );
380
391
  v_token := auth.sign_jwt(v_claims);
381
392
 
@@ -481,16 +492,17 @@ BEGIN
481
492
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
482
493
  END IF;
483
494
 
484
- -- Check banned
485
- IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
486
- RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
487
- END IF;
488
-
489
495
  v_valid := auth.verify_password(p_password, v_user.encrypted_password);
490
496
  IF NOT v_valid THEN
491
497
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
492
498
  END IF;
493
499
 
500
+ -- Check banned only AFTER the password is proven. Every path above pays for bcrypt and
501
+ -- answers invalid_credentials, so a ban is never visible to someone without the password.
502
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
503
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
504
+ END IF;
505
+
494
506
  -- Update last sign in
495
507
  UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
496
508
  WHERE id = v_user.id;
@@ -504,7 +516,7 @@ END;
504
516
  `,
505
517
  });
506
518
 
507
- export const signInJwtFn = defineFunction('auth.sign_in_jwt', {
519
+ const signInJwt = (ttl: number) => defineFunction('auth.sign_in_jwt', {
508
520
  args: [arg('p_email', 'text'), arg('p_password', 'text')],
509
521
  returns: 'jsonb',
510
522
  language: 'plpgsql',
@@ -522,7 +534,7 @@ BEGIN
522
534
 
523
535
  v_claims := v_payload || jsonb_build_object(
524
536
  'iat', extract(epoch FROM now())::bigint,
525
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
537
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
526
538
  );
527
539
  v_token := auth.sign_jwt(v_claims);
528
540
 
@@ -605,6 +617,37 @@ END;
605
617
  `,
606
618
  });
607
619
 
620
+ export const signOutFn = defineFunction('auth.sign_out', {
621
+ args: [arg('p_token', 'text')],
622
+ returns: 'integer',
623
+ language: 'plpgsql',
624
+ security: 'definer',
625
+ searchPath: ['auth', 'public', 'pg_temp'],
626
+ abilities: [can('execute', { role: 'anon' })],
627
+ body: sql`
628
+ DECLARE
629
+ v_family_id UUID;
630
+ v_count INTEGER;
631
+ BEGIN
632
+ -- The presented refresh token is the credential: only its holder can end its session.
633
+ SELECT family_id INTO v_family_id
634
+ FROM auth.refresh_tokens
635
+ WHERE token_hash = encode(digest(p_token, 'sha256'), 'hex');
636
+
637
+ IF NOT FOUND THEN
638
+ RETURN 0;
639
+ END IF;
640
+
641
+ -- Revoke the whole family (every rotation of this session), leaving the user's other
642
+ -- sessions alone.
643
+ DELETE FROM auth.refresh_tokens WHERE family_id = v_family_id;
644
+
645
+ GET DIAGNOSTICS v_count = ROW_COUNT;
646
+ RETURN v_count;
647
+ END;
648
+ `,
649
+ });
650
+
608
651
  export const signUpFn = defineFunction('auth.sign_up', {
609
652
  args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
610
653
  returns: 'jsonb',
@@ -650,7 +693,7 @@ END;
650
693
  `,
651
694
  });
652
695
 
653
- export const signUpJwtFn = defineFunction('auth.sign_up_jwt', {
696
+ const signUpJwt = (ttl: number) => defineFunction('auth.sign_up_jwt', {
654
697
  args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
655
698
  returns: 'jsonb',
656
699
  language: 'plpgsql',
@@ -668,7 +711,7 @@ BEGIN
668
711
 
669
712
  v_claims := v_payload || jsonb_build_object(
670
713
  'iat', extract(epoch FROM now())::bigint,
671
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
714
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
672
715
  );
673
716
  v_token := auth.sign_jwt(v_claims);
674
717
 
@@ -731,6 +774,7 @@ DECLARE
731
774
  v_token_hash TEXT;
732
775
  v_row RECORD;
733
776
  v_user_deleted TIMESTAMPTZ;
777
+ v_user_banned_until TIMESTAMPTZ;
734
778
  v_new_token JSONB;
735
779
  BEGIN
736
780
  -- 1. Hash the input token
@@ -762,8 +806,8 @@ BEGIN
762
806
  RAISE EXCEPTION 'token_expired' USING ERRCODE = 'P0003';
763
807
  END IF;
764
808
 
765
- -- 5. Check user is not deleted
766
- SELECT deleted_at INTO v_user_deleted
809
+ -- 5. Check user is not deleted or banned
810
+ SELECT deleted_at, banned_until INTO v_user_deleted, v_user_banned_until
767
811
  FROM auth.users
768
812
  WHERE id = v_row.user_id;
769
813
 
@@ -772,6 +816,12 @@ BEGIN
772
816
  RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
773
817
  END IF;
774
818
 
819
+ -- A ban blocks rotation for as long as it holds. The RAISE rolls back this statement,
820
+ -- so the token row survives; it simply cannot rotate until banned_until has passed.
821
+ IF v_user_banned_until IS NOT NULL AND v_user_banned_until > now() THEN
822
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0007';
823
+ END IF;
824
+
775
825
  -- 6. Device binding: if token was issued to a specific device, verify it
776
826
  IF v_row.device_id IS NOT NULL AND p_device_id IS NOT NULL
777
827
  AND v_row.device_id IS DISTINCT FROM p_device_id THEN
@@ -800,6 +850,36 @@ END;
800
850
  `,
801
851
  });
802
852
 
853
+ /** Removes every session as soon as an account becomes unavailable. */
854
+ export const revokeRefreshTokensOnUserBlockFn = defineFunction('auth.revoke_refresh_tokens_on_user_block', {
855
+ returns: 'trigger',
856
+ language: 'plpgsql',
857
+ security: 'definer',
858
+ searchPath: ['auth', 'public', 'pg_temp'],
859
+ body: sql`
860
+ BEGIN
861
+ DELETE FROM auth.refresh_tokens WHERE user_id = NEW.id;
862
+ RETURN NEW;
863
+ END;`,
864
+ });
865
+
866
+ /** The blocking transition is the durable revocation point; validation keeps its raise contract. */
867
+ export const revokeRefreshTokensOnUserBlockTrigger = defineTrigger('users_revoke_refresh_tokens_on_block', {
868
+ on: User,
869
+ timing: 'after',
870
+ events: ['update'],
871
+ of: ['banned_until', 'deleted_at'],
872
+ forEach: 'row',
873
+ condition: sql`
874
+ (NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL)
875
+ OR (
876
+ NEW.banned_until > now()
877
+ AND (OLD.banned_until IS NULL OR OLD.banned_until <= now())
878
+ )
879
+ `,
880
+ execute: revokeRefreshTokensOnUserBlockFn,
881
+ });
882
+
803
883
  export const verifyJwtFn = defineFunction('auth.verify_jwt', {
804
884
  args: [arg('p_token', 'text')],
805
885
  returns: 'jsonb',
@@ -929,7 +1009,8 @@ export const coreFunctions = [hashPasswordFn, verifyPasswordFn, signJwtFn, verif
929
1009
 
930
1010
  /** The refresh-token family (RFC 6819 rotation). Always on. */
931
1011
  export const refreshTokenFunctions = [
932
- issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, cleanupRefreshTokensFn,
1012
+ issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, signOutFn, cleanupRefreshTokensFn,
1013
+ revokeRefreshTokensOnUserBlockFn,
933
1014
  ];
934
1015
 
935
1016
  /** `auth.uid()` / `auth.role()` / `auth.jwt()` — called FROM RLS policies, so granted to the
@@ -948,7 +1029,20 @@ export const sessionWrapperFunctions = [signInSessionFn, signUpSessionFn];
948
1029
  /** The raw sign_in/sign_up/oauth minters. Default on; never granted to anon. */
949
1030
  export const defaultAuthFunctions = [signUpFn, signInFn, oauthSignInFn];
950
1031
 
951
- /** JWT convenience wrappers. Default OFF, matching `generateAuthSQL`. */
1032
+ /**
1033
+ * JWT convenience wrappers for a given access-token lifetime (seconds), the descriptor twin of
1034
+ * `generateJwtWrappersSQL({ accessTokenTtlSec })`. Default OFF in {@link authFunctions}.
1035
+ */
1036
+ export function jwtWrapperFunctionsFor(accessTokenTtlSec?: number) {
1037
+ const ttl = resolveAccessTokenTtl(accessTokenTtlSec);
1038
+ return [signInJwt(ttl), signUpJwt(ttl), oauthSignInJwt(ttl)];
1039
+ }
1040
+
1041
+ export const signInJwtFn = signInJwt(DEFAULT_ACCESS_TOKEN_TTL_SEC);
1042
+ export const signUpJwtFn = signUpJwt(DEFAULT_ACCESS_TOKEN_TTL_SEC);
1043
+ export const oauthSignInJwtFn = oauthSignInJwt(DEFAULT_ACCESS_TOKEN_TTL_SEC);
1044
+
1045
+ /** JWT convenience wrappers at the default lifetime. Default OFF, matching `generateAuthSQL`. */
952
1046
  export const jwtWrapperFunctions = [signInJwtFn, signUpJwtFn, oauthSignInJwtFn];
953
1047
 
954
1048
  /** Mirrors {@link AuthSQLOptions} in sql.ts — same defaults, same meanings. */
@@ -959,6 +1053,7 @@ export interface AuthFunctionOptions {
959
1053
  sessionWrappers?: boolean;
960
1054
  defaultAuthFunctions?: boolean;
961
1055
  jwtWrappers?: boolean;
1056
+ accessTokenTtlSec?: number;
962
1057
  }
963
1058
 
964
1059
  /**
@@ -975,6 +1070,11 @@ export function authFunctions(opts: AuthFunctionOptions = {}) {
975
1070
  ...(opts.tokenActions !== false ? tokenActionFunctions : []),
976
1071
  ...(opts.sessionWrappers !== false ? sessionWrapperFunctions : []),
977
1072
  ...(opts.defaultAuthFunctions !== false ? defaultAuthFunctions : []),
978
- ...(opts.jwtWrappers === true ? jwtWrapperFunctions : []),
1073
+ ...(opts.jwtWrappers === true ? jwtWrapperFunctionsFor(opts.accessTokenTtlSec) : []),
979
1074
  ];
980
1075
  }
1076
+
1077
+ /** The auth module's complete derived layer: functions plus lifecycle bindings. */
1078
+ export function authDerived(opts: AuthFunctionOptions = {}) {
1079
+ return [...authFunctions(opts), revokeRefreshTokensOnUserBlockTrigger];
1080
+ }
@@ -25,7 +25,7 @@ import { Device } from './device';
25
25
  import { MfaFactor } from './mfa-factor';
26
26
  import { MfaChallenge } from './mfa-challenge';
27
27
  import { OneTimeToken } from './one-time-token';
28
- import { authFunctions, type AuthFunctionOptions } from './functions';
28
+ import { authDerived, type AuthFunctionOptions } from './functions';
29
29
 
30
30
  export * from './functions';
31
31
 
@@ -51,7 +51,7 @@ export function defineAuthModule(opts: AuthFunctionOptions = {}): Module {
51
51
  return defineModule({
52
52
  models: authModels,
53
53
  extensions: ['pgcrypto'],
54
- derived: authFunctions(opts),
54
+ derived: authDerived(opts),
55
55
  });
56
56
  }
57
57
 
package/src/postgres.ts CHANGED
@@ -18,14 +18,13 @@ import { createJwt } from './jwt';
18
18
  import { hmacNode } from './jwt/hmac-node';
19
19
  import type { RefreshTokenOptions } from './refresh';
20
20
  import type { AuthHandlers, AuthOptions, TokenPayload } from './index';
21
- import { withPassword } from './index';
21
+ import { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './index';
22
22
  import type { OAuthProvider } from './oauth';
23
- import { getRefreshFromCookies } from './cookies';
23
+ import { getRefreshFromCookies, readPresentedRefreshToken } from './cookies';
24
24
  import { authResponse, signoutResponse } from './response';
25
25
  import type { CookieConfig } from './response';
26
26
  import { validateCsrfOrigin } from './csrf';
27
-
28
- const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
27
+ import { resolveAccessTokenTtl } from './sql';
29
28
 
30
29
  // Postgres identifier rule — the only role name ever interpolated into SET LOCAL ROLE.
31
30
  const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
@@ -85,6 +84,7 @@ export function createPostgresAuthHandlers(
85
84
  throw new Error("mode: 'postgres' requires options.sql (postgres.js client)");
86
85
  }
87
86
 
87
+ const accessTokenTtlSec = resolveAccessTokenTtl(options?.accessTokenTtlSec);
88
88
  const { users, profiles } = schema;
89
89
  const refreshTable = schema.refreshTokens;
90
90
  const jwtSecretString = new TextDecoder().decode(jwtSecret);
@@ -188,7 +188,7 @@ export function createPostgresAuthHandlers(
188
188
  const fullPayload = {
189
189
  ...claims,
190
190
  iat: now,
191
- exp: now + TOKEN_EXPIRY_SECONDS,
191
+ exp: now + accessTokenTtlSec,
192
192
  };
193
193
  const payloadJson = JSON.stringify(fullPayload);
194
194
  const rows = await pgSql.begin(async (sql: any) => {
@@ -234,7 +234,7 @@ export function createPostgresAuthHandlers(
234
234
  const verifier = createJwt({
235
235
  secret: jwtSecret,
236
236
  hmac: hmacNode,
237
- ttlSec: TOKEN_EXPIRY_SECONDS,
237
+ ttlSec: accessTokenTtlSec,
238
238
  });
239
239
 
240
240
  /**
@@ -506,8 +506,9 @@ export function createPostgresAuthHandlers(
506
506
  } catch (err: any) {
507
507
  // Map PG error codes to HTTP responses
508
508
  const pgCode = err?.code;
509
+ // P0004 user_disabled (deleted), P0007 user_banned — both revoke the tokens in SQL.
509
510
  if (pgCode === 'P0001' || pgCode === 'P0002' || pgCode === 'P0003'
510
- || pgCode === 'P0004' || pgCode === 'P0005') {
511
+ || pgCode === 'P0004' || pgCode === 'P0005' || pgCode === 'P0007') {
511
512
  return Response.json(
512
513
  { message: 'Invalid or expired refresh token' },
513
514
  { status: 401 },
@@ -532,6 +533,9 @@ export function createPostgresAuthHandlers(
532
533
  if (!user) {
533
534
  return Response.json({ message: 'User not found' }, { status: 401 });
534
535
  }
536
+ if (accountBlock(user)) {
537
+ return Response.json({ message: 'Invalid or expired refresh token' }, { status: 401 });
538
+ }
535
539
 
536
540
  const token = await signJwtPg({
537
541
  sub: user.id,
@@ -661,6 +665,8 @@ export function createPostgresAuthHandlers(
661
665
  .from(users)
662
666
  .where(eq(users.id, existing.userId))
663
667
  .limit(1);
668
+ const blocked = accountBlockedResponse(existingUser);
669
+ if (blocked) return blocked;
664
670
 
665
671
  const [profile] = await db
666
672
  .select()
@@ -759,6 +765,8 @@ export function createPostgresAuthHandlers(
759
765
  .from(users)
760
766
  .where(eq(users.id, existingAccount.userId))
761
767
  .limit(1);
768
+ const blocked = accountBlockedResponse(existingUser);
769
+ if (blocked) return blocked;
762
770
 
763
771
  const [profile] = await db
764
772
  .select()
@@ -881,6 +889,18 @@ export function createPostgresAuthHandlers(
881
889
  const csrfError = csrfCheck(request, options?.cookies);
882
890
  if (csrfError) return csrfError;
883
891
 
892
+ // End the session server-side: revoke the presented token's family through the
893
+ // token-gated SECDEF auth.sign_out, as anon (the token is the credential). Covers
894
+ // tokens from signin and signup alike. Stateless JWT refresh tokens have nothing to revoke.
895
+ const presented = await readPresentedRefreshToken(request);
896
+ if (refreshTable && presented) {
897
+ try {
898
+ await withRole('anon', { role: 'anon' }, (sql: any) => sql`SELECT auth.sign_out(${presented})`);
899
+ } catch (err) {
900
+ warnRevokeFailed(err);
901
+ }
902
+ }
903
+
884
904
  return signoutResponse(options?.cookies);
885
905
  },
886
906
 
package/src/refresh.ts CHANGED
@@ -240,6 +240,20 @@ export async function revokeTokenFamily(
240
240
  await db.delete(table).where(eq(table.familyId, familyId));
241
241
  }
242
242
 
243
+ /**
244
+ * Revoke the family of a presented raw refresh token (signout). Unknown tokens are a no-op.
245
+ * Revokes every rotation of that session and leaves the user's other sessions alone.
246
+ */
247
+ export async function revokeRefreshTokenFamilyByToken(
248
+ db: any,
249
+ table: any,
250
+ rawToken: string,
251
+ ): Promise<void> {
252
+ const tokenHash = await hashToken(rawToken);
253
+ const [row] = await db.select().from(table).where(eq(table.tokenHash, tokenHash)).limit(1);
254
+ if (row) await revokeTokenFamily(db, table, row.familyId);
255
+ }
256
+
243
257
  /**
244
258
  * Delete all expired and stale consumed tokens.
245
259
  * Call periodically (e.g., daily cron via pg_cron).
package/src/sql.ts CHANGED
@@ -25,6 +25,21 @@
25
25
  * const sql = generateCoreSQL() + '\n' + generateAccessorSQL();
26
26
  */
27
27
 
28
+ /** Access-token lifetime when none is configured. */
29
+ export const DEFAULT_ACCESS_TOKEN_TTL_SEC = 3600;
30
+
31
+ /**
32
+ * The configured access-token lifetime, or the default. This value is interpolated into
33
+ * generated SQL, so anything but a positive safe integer is refused rather than coerced.
34
+ */
35
+ export function resolveAccessTokenTtl(ttl?: number): number {
36
+ if (ttl === undefined) return DEFAULT_ACCESS_TOKEN_TTL_SEC;
37
+ if (!Number.isSafeInteger(ttl) || ttl <= 0) {
38
+ throw new Error(`accessTokenTtlSec must be a positive integer number of seconds, got ${String(ttl)}`);
39
+ }
40
+ return ttl;
41
+ }
42
+
28
43
  const PGCRYPTO = `CREATE EXTENSION IF NOT EXISTS pgcrypto;`;
29
44
 
30
45
  const HASH_PASSWORD = `\
@@ -266,6 +281,7 @@ DECLARE
266
281
  v_token_hash TEXT;
267
282
  v_row RECORD;
268
283
  v_user_deleted TIMESTAMPTZ;
284
+ v_user_banned_until TIMESTAMPTZ;
269
285
  v_new_token JSONB;
270
286
  BEGIN
271
287
  -- 1. Hash the input token
@@ -297,8 +313,8 @@ BEGIN
297
313
  RAISE EXCEPTION 'token_expired' USING ERRCODE = 'P0003';
298
314
  END IF;
299
315
 
300
- -- 5. Check user is not deleted
301
- SELECT deleted_at INTO v_user_deleted
316
+ -- 5. Check user is not deleted or banned
317
+ SELECT deleted_at, banned_until INTO v_user_deleted, v_user_banned_until
302
318
  FROM auth.users
303
319
  WHERE id = v_row.user_id;
304
320
 
@@ -307,6 +323,12 @@ BEGIN
307
323
  RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
308
324
  END IF;
309
325
 
326
+ -- A ban blocks rotation for as long as it holds. The RAISE rolls back this statement,
327
+ -- so the token row survives; it simply cannot rotate until banned_until has passed.
328
+ IF v_user_banned_until IS NOT NULL AND v_user_banned_until > now() THEN
329
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0007';
330
+ END IF;
331
+
310
332
  -- 6. Device binding: if token was issued to a specific device, verify it
311
333
  IF v_row.device_id IS NOT NULL AND p_device_id IS NOT NULL
312
334
  AND v_row.device_id IS DISTINCT FROM p_device_id THEN
@@ -336,6 +358,33 @@ $$;
336
358
 
337
359
  REVOKE EXECUTE ON FUNCTION auth.validate_refresh_token FROM PUBLIC;`;
338
360
 
361
+ const REVOKE_REFRESH_TOKENS_ON_USER_BLOCK = `\
362
+ CREATE OR REPLACE FUNCTION auth.revoke_refresh_tokens_on_user_block()
363
+ RETURNS TRIGGER
364
+ LANGUAGE plpgsql
365
+ SECURITY DEFINER
366
+ SET search_path = auth, public, pg_temp
367
+ AS $$
368
+ BEGIN
369
+ DELETE FROM auth.refresh_tokens WHERE user_id = NEW.id;
370
+ RETURN NEW;
371
+ END;
372
+ $$;
373
+
374
+ REVOKE EXECUTE ON FUNCTION auth.revoke_refresh_tokens_on_user_block FROM PUBLIC;
375
+
376
+ CREATE TRIGGER users_revoke_refresh_tokens_on_block
377
+ AFTER UPDATE OF banned_until, deleted_at ON auth.users
378
+ FOR EACH ROW
379
+ WHEN (
380
+ (NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL)
381
+ OR (
382
+ NEW.banned_until > now()
383
+ AND (OLD.banned_until IS NULL OR OLD.banned_until <= now())
384
+ )
385
+ )
386
+ EXECUTE FUNCTION auth.revoke_refresh_tokens_on_user_block();`;
387
+
339
388
  const REVOKE_REFRESH_TOKENS = `\
340
389
  CREATE OR REPLACE FUNCTION auth.revoke_refresh_tokens(
341
390
  p_user_id UUID,
@@ -368,6 +417,40 @@ $$;
368
417
 
369
418
  REVOKE EXECUTE ON FUNCTION auth.revoke_refresh_tokens FROM PUBLIC;`;
370
419
 
420
+ // auth.sign_out — the anon-safe signout. Token-gated like validate_refresh_token: the caller
421
+ // proves possession of a refresh token and may revoke only that token's family. The by-user
422
+ // revoke (auth.revoke_refresh_tokens) stays owner-only.
423
+ const SIGN_OUT = `\
424
+ CREATE OR REPLACE FUNCTION auth.sign_out(p_token TEXT)
425
+ RETURNS INTEGER
426
+ LANGUAGE plpgsql
427
+ SECURITY DEFINER
428
+ SET search_path = auth, public, pg_temp
429
+ AS $$
430
+ DECLARE
431
+ v_family_id UUID;
432
+ v_count INTEGER;
433
+ BEGIN
434
+ -- The presented refresh token is the credential: only its holder can end its session.
435
+ SELECT family_id INTO v_family_id
436
+ FROM auth.refresh_tokens
437
+ WHERE token_hash = encode(digest(p_token, 'sha256'), 'hex');
438
+
439
+ IF NOT FOUND THEN
440
+ RETURN 0;
441
+ END IF;
442
+
443
+ -- Revoke the whole family (every rotation of this session), leaving the user's other
444
+ -- sessions alone.
445
+ DELETE FROM auth.refresh_tokens WHERE family_id = v_family_id;
446
+
447
+ GET DIAGNOSTICS v_count = ROW_COUNT;
448
+ RETURN v_count;
449
+ END;
450
+ $$;
451
+
452
+ REVOKE EXECUTE ON FUNCTION auth.sign_out FROM PUBLIC;`;
453
+
371
454
  const CLEANUP_REFRESH_TOKENS = `\
372
455
  CREATE OR REPLACE FUNCTION auth.cleanup_refresh_tokens(
373
456
  p_consumed_retention INTERVAL DEFAULT '7 days'
@@ -707,16 +790,17 @@ BEGIN
707
790
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
708
791
  END IF;
709
792
 
710
- -- Check banned
711
- IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
712
- RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
713
- END IF;
714
-
715
793
  v_valid := auth.verify_password(p_password, v_user.encrypted_password);
716
794
  IF NOT v_valid THEN
717
795
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
718
796
  END IF;
719
797
 
798
+ -- Check banned only AFTER the password is proven. Every path above pays for bcrypt and
799
+ -- answers invalid_credentials, so a ban is never visible to someone without the password.
800
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
801
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
802
+ END IF;
803
+
720
804
  -- Update last sign in
721
805
  UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
722
806
  WHERE id = v_user.id;
@@ -758,6 +842,10 @@ BEGIN
758
842
  IF NOT FOUND THEN
759
843
  RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
760
844
  END IF;
845
+ -- The provider proved identity; a ban still ends the road here.
846
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
847
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
848
+ END IF;
761
849
 
762
850
  UPDATE auth.identities
763
851
  SET identity_data = p_identity_data, last_sign_in_at = now(), updated_at = now()
@@ -779,6 +867,11 @@ BEGIN
779
867
  WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
780
868
 
781
869
  IF FOUND THEN
870
+ -- Never link a provider to (or sign in) a banned account.
871
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
872
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
873
+ END IF;
874
+
782
875
  -- Link the new provider to existing user
783
876
  v_providers := COALESCE(v_user.raw_app_meta_data->'providers', '[]'::jsonb);
784
877
  IF NOT v_providers ? p_provider THEN
@@ -834,7 +927,7 @@ REVOKE EXECUTE ON FUNCTION auth.oauth_sign_in FROM PUBLIC;`;
834
927
  // JWT CONVENIENCE WRAPPERS
835
928
  // =============================================================================
836
929
 
837
- const SIGN_IN_JWT = `\
930
+ const SIGN_IN_JWT = (ttl: number): string => `\
838
931
  CREATE OR REPLACE FUNCTION auth.sign_in_jwt(p_email TEXT, p_password TEXT)
839
932
  RETURNS JSONB
840
933
  LANGUAGE plpgsql
@@ -851,7 +944,7 @@ BEGIN
851
944
 
852
945
  v_claims := v_payload || jsonb_build_object(
853
946
  'iat', extract(epoch FROM now())::bigint,
854
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
947
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
855
948
  );
856
949
  v_token := auth.sign_jwt(v_claims);
857
950
 
@@ -867,7 +960,7 @@ $$;
867
960
 
868
961
  REVOKE EXECUTE ON FUNCTION auth.sign_in_jwt FROM PUBLIC;`;
869
962
 
870
- const SIGN_UP_JWT = `\
963
+ const SIGN_UP_JWT = (ttl: number): string => `\
871
964
  CREATE OR REPLACE FUNCTION auth.sign_up_jwt(
872
965
  p_email TEXT,
873
966
  p_password TEXT,
@@ -888,7 +981,7 @@ BEGIN
888
981
 
889
982
  v_claims := v_payload || jsonb_build_object(
890
983
  'iat', extract(epoch FROM now())::bigint,
891
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
984
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
892
985
  );
893
986
  v_token := auth.sign_jwt(v_claims);
894
987
 
@@ -904,7 +997,7 @@ $$;
904
997
 
905
998
  REVOKE EXECUTE ON FUNCTION auth.sign_up_jwt FROM PUBLIC;`;
906
999
 
907
- const OAUTH_SIGN_IN_JWT = `\
1000
+ const OAUTH_SIGN_IN_JWT = (ttl: number): string => `\
908
1001
  CREATE OR REPLACE FUNCTION auth.oauth_sign_in_jwt(
909
1002
  p_provider TEXT,
910
1003
  p_provider_id TEXT,
@@ -926,7 +1019,7 @@ BEGIN
926
1019
 
927
1020
  v_claims := v_payload || jsonb_build_object(
928
1021
  'iat', extract(epoch FROM now())::bigint,
929
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
1022
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
930
1023
  );
931
1024
  v_token := auth.sign_jwt(v_claims);
932
1025
 
@@ -1023,9 +1116,12 @@ export function generateCoreSQL(): string {
1023
1116
  .trimEnd() + '\n';
1024
1117
  }
1025
1118
 
1026
- /** issue + validate + revoke + cleanup refresh tokens (RFC 6819) */
1119
+ /** issue + validate + revoke + sign_out + cleanup refresh tokens (RFC 6819) */
1027
1120
  export function generateRefreshTokenSQL(): string {
1028
- return [ISSUE_REFRESH_TOKEN, '', VALIDATE_REFRESH_TOKEN, '', REVOKE_REFRESH_TOKENS, '', CLEANUP_REFRESH_TOKENS]
1121
+ return [
1122
+ ISSUE_REFRESH_TOKEN, '', VALIDATE_REFRESH_TOKEN, '', REVOKE_REFRESH_TOKENS_ON_USER_BLOCK, '',
1123
+ REVOKE_REFRESH_TOKENS, '', SIGN_OUT, '', CLEANUP_REFRESH_TOKENS,
1124
+ ]
1029
1125
  .join('\n')
1030
1126
  .trimEnd() + '\n';
1031
1127
  }
@@ -1062,9 +1158,13 @@ export function generateDefaultAuthSQL(): string {
1062
1158
  .trimEnd() + '\n';
1063
1159
  }
1064
1160
 
1065
- /** auth.sign_in_jwt, auth.sign_up_jwt, auth.oauth_sign_in_jwt */
1066
- export function generateJwtWrappersSQL(): string {
1067
- return [SIGN_IN_JWT, '', SIGN_UP_JWT, '', OAUTH_SIGN_IN_JWT]
1161
+ /**
1162
+ * auth.sign_in_jwt, auth.sign_up_jwt, auth.oauth_sign_in_jwt. `accessTokenTtlSec` (default
1163
+ * 3600) is baked into the generated bodies as the access token's `exp` offset.
1164
+ */
1165
+ export function generateJwtWrappersSQL(opts: { accessTokenTtlSec?: number } = {}): string {
1166
+ const ttl = resolveAccessTokenTtl(opts.accessTokenTtlSec);
1167
+ return [SIGN_IN_JWT(ttl), '', SIGN_UP_JWT(ttl), '', OAUTH_SIGN_IN_JWT(ttl)]
1068
1168
  .join('\n')
1069
1169
  .trimEnd() + '\n';
1070
1170
  }
@@ -1096,6 +1196,8 @@ export function generateAuthGrantsSQL(opts: AuthSQLOptions = {}): string {
1096
1196
  const lines = ['GRANT USAGE ON SCHEMA auth TO anon, authenticated, admin;'];
1097
1197
  // validate_refresh_token is always generated (the refresh path) and anon-callable.
1098
1198
  lines.push('GRANT EXECUTE ON FUNCTION auth.validate_refresh_token(text, uuid, text, text, interval) TO anon;');
1199
+ // sign_out is generated with the refresh functions and is token-gated the same way.
1200
+ lines.push('GRANT EXECUTE ON FUNCTION auth.sign_out(text) TO anon;');
1099
1201
  if (opts.tokenActions !== false) {
1100
1202
  lines.push('GRANT EXECUTE ON FUNCTION auth.confirm_email(text) TO anon;');
1101
1203
  lines.push('GRANT EXECUTE ON FUNCTION auth.reset_password(text, text) TO anon;');
@@ -1125,6 +1227,8 @@ export interface AuthSQLOptions {
1125
1227
  defaultAuthFunctions?: boolean;
1126
1228
  /** Include JWT convenience wrappers (default: false) */
1127
1229
  jwtWrappers?: boolean;
1230
+ /** Access-token lifetime baked into the JWT wrappers, in seconds (default: 3600) */
1231
+ accessTokenTtlSec?: number;
1128
1232
  }
1129
1233
 
1130
1234
  /**
@@ -1157,7 +1261,7 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
1157
1261
  parts.push('', generateDefaultAuthSQL());
1158
1262
  }
1159
1263
  if (options?.jwtWrappers) {
1160
- parts.push('', generateJwtWrappersSQL());
1264
+ parts.push('', generateJwtWrappersSQL({ accessTokenTtlSec: options.accessTokenTtlSec }));
1161
1265
  }
1162
1266
 
1163
1267
  // The role grants come LAST — every GRANT EXECUTE target now exists. This is what makes the