@everystack/auth 0.4.6 → 0.4.7

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,12 @@ 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.6] - 2026-09-22
8
+
9
+ ### Fixed
10
+
11
+ - refresh expired JWT instead of silently failing every request
12
+
7
13
  ## [0.4.5] - 2026-09-16
8
14
 
9
15
  ### 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.7",
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
 
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { defineFunction, arg, can, sql } from '@everystack/model';
43
+ import { DEFAULT_ACCESS_TOKEN_TTL_SEC, resolveAccessTokenTtl } from '../sql';
43
44
 
44
45
  export const changePasswordFn = defineFunction('auth.change_password', {
45
46
  args: [arg('p_current_password', 'text'), arg('p_new_password', 'text')],
@@ -286,6 +287,10 @@ BEGIN
286
287
  IF NOT FOUND THEN
287
288
  RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
288
289
  END IF;
290
+ -- The provider proved identity; a ban still ends the road here.
291
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
292
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
293
+ END IF;
289
294
 
290
295
  UPDATE auth.identities
291
296
  SET identity_data = p_identity_data, last_sign_in_at = now(), updated_at = now()
@@ -307,6 +312,11 @@ BEGIN
307
312
  WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
308
313
 
309
314
  IF FOUND THEN
315
+ -- Never link a provider to (or sign in) a banned account.
316
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
317
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
318
+ END IF;
319
+
310
320
  -- Link the new provider to existing user
311
321
  v_providers := COALESCE(v_user.raw_app_meta_data->'providers', '[]'::jsonb);
312
322
  IF NOT v_providers ? p_provider THEN
@@ -357,7 +367,7 @@ END;
357
367
  `,
358
368
  });
359
369
 
360
- export const oauthSignInJwtFn = defineFunction('auth.oauth_sign_in_jwt', {
370
+ const oauthSignInJwt = (ttl: number) => defineFunction('auth.oauth_sign_in_jwt', {
361
371
  args: [arg('p_provider', 'text'), arg('p_provider_id', 'text'), arg('p_email', 'text'), arg('p_identity_data', 'jsonb', { default: sql`'{}'::jsonb` })],
362
372
  returns: 'jsonb',
363
373
  language: 'plpgsql',
@@ -375,7 +385,7 @@ BEGIN
375
385
 
376
386
  v_claims := v_payload || jsonb_build_object(
377
387
  'iat', extract(epoch FROM now())::bigint,
378
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
388
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
379
389
  );
380
390
  v_token := auth.sign_jwt(v_claims);
381
391
 
@@ -481,16 +491,17 @@ BEGIN
481
491
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
482
492
  END IF;
483
493
 
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
494
  v_valid := auth.verify_password(p_password, v_user.encrypted_password);
490
495
  IF NOT v_valid THEN
491
496
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
492
497
  END IF;
493
498
 
499
+ -- Check banned only AFTER the password is proven. Every path above pays for bcrypt and
500
+ -- answers invalid_credentials, so a ban is never visible to someone without the password.
501
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
502
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
503
+ END IF;
504
+
494
505
  -- Update last sign in
495
506
  UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
496
507
  WHERE id = v_user.id;
@@ -504,7 +515,7 @@ END;
504
515
  `,
505
516
  });
506
517
 
507
- export const signInJwtFn = defineFunction('auth.sign_in_jwt', {
518
+ const signInJwt = (ttl: number) => defineFunction('auth.sign_in_jwt', {
508
519
  args: [arg('p_email', 'text'), arg('p_password', 'text')],
509
520
  returns: 'jsonb',
510
521
  language: 'plpgsql',
@@ -522,7 +533,7 @@ BEGIN
522
533
 
523
534
  v_claims := v_payload || jsonb_build_object(
524
535
  'iat', extract(epoch FROM now())::bigint,
525
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
536
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
526
537
  );
527
538
  v_token := auth.sign_jwt(v_claims);
528
539
 
@@ -605,6 +616,37 @@ END;
605
616
  `,
606
617
  });
607
618
 
619
+ export const signOutFn = defineFunction('auth.sign_out', {
620
+ args: [arg('p_token', 'text')],
621
+ returns: 'integer',
622
+ language: 'plpgsql',
623
+ security: 'definer',
624
+ searchPath: ['auth', 'public', 'pg_temp'],
625
+ abilities: [can('execute', { role: 'anon' })],
626
+ body: sql`
627
+ DECLARE
628
+ v_family_id UUID;
629
+ v_count INTEGER;
630
+ BEGIN
631
+ -- The presented refresh token is the credential: only its holder can end its session.
632
+ SELECT family_id INTO v_family_id
633
+ FROM auth.refresh_tokens
634
+ WHERE token_hash = encode(digest(p_token, 'sha256'), 'hex');
635
+
636
+ IF NOT FOUND THEN
637
+ RETURN 0;
638
+ END IF;
639
+
640
+ -- Revoke the whole family (every rotation of this session), leaving the user's other
641
+ -- sessions alone.
642
+ DELETE FROM auth.refresh_tokens WHERE family_id = v_family_id;
643
+
644
+ GET DIAGNOSTICS v_count = ROW_COUNT;
645
+ RETURN v_count;
646
+ END;
647
+ `,
648
+ });
649
+
608
650
  export const signUpFn = defineFunction('auth.sign_up', {
609
651
  args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
610
652
  returns: 'jsonb',
@@ -650,7 +692,7 @@ END;
650
692
  `,
651
693
  });
652
694
 
653
- export const signUpJwtFn = defineFunction('auth.sign_up_jwt', {
695
+ const signUpJwt = (ttl: number) => defineFunction('auth.sign_up_jwt', {
654
696
  args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
655
697
  returns: 'jsonb',
656
698
  language: 'plpgsql',
@@ -668,7 +710,7 @@ BEGIN
668
710
 
669
711
  v_claims := v_payload || jsonb_build_object(
670
712
  'iat', extract(epoch FROM now())::bigint,
671
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
713
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
672
714
  );
673
715
  v_token := auth.sign_jwt(v_claims);
674
716
 
@@ -731,6 +773,7 @@ DECLARE
731
773
  v_token_hash TEXT;
732
774
  v_row RECORD;
733
775
  v_user_deleted TIMESTAMPTZ;
776
+ v_user_banned_until TIMESTAMPTZ;
734
777
  v_new_token JSONB;
735
778
  BEGIN
736
779
  -- 1. Hash the input token
@@ -762,8 +805,8 @@ BEGIN
762
805
  RAISE EXCEPTION 'token_expired' USING ERRCODE = 'P0003';
763
806
  END IF;
764
807
 
765
- -- 5. Check user is not deleted
766
- SELECT deleted_at INTO v_user_deleted
808
+ -- 5. Check user is not deleted or banned
809
+ SELECT deleted_at, banned_until INTO v_user_deleted, v_user_banned_until
767
810
  FROM auth.users
768
811
  WHERE id = v_row.user_id;
769
812
 
@@ -772,6 +815,12 @@ BEGIN
772
815
  RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
773
816
  END IF;
774
817
 
818
+ -- A ban blocks rotation for as long as it holds. The RAISE rolls back this statement,
819
+ -- so the token row survives; it simply cannot rotate until banned_until has passed.
820
+ IF v_user_banned_until IS NOT NULL AND v_user_banned_until > now() THEN
821
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0007';
822
+ END IF;
823
+
775
824
  -- 6. Device binding: if token was issued to a specific device, verify it
776
825
  IF v_row.device_id IS NOT NULL AND p_device_id IS NOT NULL
777
826
  AND v_row.device_id IS DISTINCT FROM p_device_id THEN
@@ -929,7 +978,7 @@ export const coreFunctions = [hashPasswordFn, verifyPasswordFn, signJwtFn, verif
929
978
 
930
979
  /** The refresh-token family (RFC 6819 rotation). Always on. */
931
980
  export const refreshTokenFunctions = [
932
- issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, cleanupRefreshTokensFn,
981
+ issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, signOutFn, cleanupRefreshTokensFn,
933
982
  ];
934
983
 
935
984
  /** `auth.uid()` / `auth.role()` / `auth.jwt()` — called FROM RLS policies, so granted to the
@@ -948,7 +997,20 @@ export const sessionWrapperFunctions = [signInSessionFn, signUpSessionFn];
948
997
  /** The raw sign_in/sign_up/oauth minters. Default on; never granted to anon. */
949
998
  export const defaultAuthFunctions = [signUpFn, signInFn, oauthSignInFn];
950
999
 
951
- /** JWT convenience wrappers. Default OFF, matching `generateAuthSQL`. */
1000
+ /**
1001
+ * JWT convenience wrappers for a given access-token lifetime (seconds), the descriptor twin of
1002
+ * `generateJwtWrappersSQL({ accessTokenTtlSec })`. Default OFF in {@link authFunctions}.
1003
+ */
1004
+ export function jwtWrapperFunctionsFor(accessTokenTtlSec?: number) {
1005
+ const ttl = resolveAccessTokenTtl(accessTokenTtlSec);
1006
+ return [signInJwt(ttl), signUpJwt(ttl), oauthSignInJwt(ttl)];
1007
+ }
1008
+
1009
+ export const signInJwtFn = signInJwt(DEFAULT_ACCESS_TOKEN_TTL_SEC);
1010
+ export const signUpJwtFn = signUpJwt(DEFAULT_ACCESS_TOKEN_TTL_SEC);
1011
+ export const oauthSignInJwtFn = oauthSignInJwt(DEFAULT_ACCESS_TOKEN_TTL_SEC);
1012
+
1013
+ /** JWT convenience wrappers at the default lifetime. Default OFF, matching `generateAuthSQL`. */
952
1014
  export const jwtWrapperFunctions = [signInJwtFn, signUpJwtFn, oauthSignInJwtFn];
953
1015
 
954
1016
  /** Mirrors {@link AuthSQLOptions} in sql.ts — same defaults, same meanings. */
@@ -959,6 +1021,7 @@ export interface AuthFunctionOptions {
959
1021
  sessionWrappers?: boolean;
960
1022
  defaultAuthFunctions?: boolean;
961
1023
  jwtWrappers?: boolean;
1024
+ accessTokenTtlSec?: number;
962
1025
  }
963
1026
 
964
1027
  /**
@@ -975,6 +1038,6 @@ export function authFunctions(opts: AuthFunctionOptions = {}) {
975
1038
  ...(opts.tokenActions !== false ? tokenActionFunctions : []),
976
1039
  ...(opts.sessionWrappers !== false ? sessionWrapperFunctions : []),
977
1040
  ...(opts.defaultAuthFunctions !== false ? defaultAuthFunctions : []),
978
- ...(opts.jwtWrappers === true ? jwtWrapperFunctions : []),
1041
+ ...(opts.jwtWrappers === true ? jwtWrapperFunctionsFor(opts.accessTokenTtlSec) : []),
979
1042
  ];
980
1043
  }
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
@@ -368,6 +390,40 @@ $$;
368
390
 
369
391
  REVOKE EXECUTE ON FUNCTION auth.revoke_refresh_tokens FROM PUBLIC;`;
370
392
 
393
+ // auth.sign_out — the anon-safe signout. Token-gated like validate_refresh_token: the caller
394
+ // proves possession of a refresh token and may revoke only that token's family. The by-user
395
+ // revoke (auth.revoke_refresh_tokens) stays owner-only.
396
+ const SIGN_OUT = `\
397
+ CREATE OR REPLACE FUNCTION auth.sign_out(p_token TEXT)
398
+ RETURNS INTEGER
399
+ LANGUAGE plpgsql
400
+ SECURITY DEFINER
401
+ SET search_path = auth, public, pg_temp
402
+ AS $$
403
+ DECLARE
404
+ v_family_id UUID;
405
+ v_count INTEGER;
406
+ BEGIN
407
+ -- The presented refresh token is the credential: only its holder can end its session.
408
+ SELECT family_id INTO v_family_id
409
+ FROM auth.refresh_tokens
410
+ WHERE token_hash = encode(digest(p_token, 'sha256'), 'hex');
411
+
412
+ IF NOT FOUND THEN
413
+ RETURN 0;
414
+ END IF;
415
+
416
+ -- Revoke the whole family (every rotation of this session), leaving the user's other
417
+ -- sessions alone.
418
+ DELETE FROM auth.refresh_tokens WHERE family_id = v_family_id;
419
+
420
+ GET DIAGNOSTICS v_count = ROW_COUNT;
421
+ RETURN v_count;
422
+ END;
423
+ $$;
424
+
425
+ REVOKE EXECUTE ON FUNCTION auth.sign_out FROM PUBLIC;`;
426
+
371
427
  const CLEANUP_REFRESH_TOKENS = `\
372
428
  CREATE OR REPLACE FUNCTION auth.cleanup_refresh_tokens(
373
429
  p_consumed_retention INTERVAL DEFAULT '7 days'
@@ -707,16 +763,17 @@ BEGIN
707
763
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
708
764
  END IF;
709
765
 
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
766
  v_valid := auth.verify_password(p_password, v_user.encrypted_password);
716
767
  IF NOT v_valid THEN
717
768
  RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
718
769
  END IF;
719
770
 
771
+ -- Check banned only AFTER the password is proven. Every path above pays for bcrypt and
772
+ -- answers invalid_credentials, so a ban is never visible to someone without the password.
773
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
774
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
775
+ END IF;
776
+
720
777
  -- Update last sign in
721
778
  UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
722
779
  WHERE id = v_user.id;
@@ -758,6 +815,10 @@ BEGIN
758
815
  IF NOT FOUND THEN
759
816
  RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
760
817
  END IF;
818
+ -- The provider proved identity; a ban still ends the road here.
819
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
820
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
821
+ END IF;
761
822
 
762
823
  UPDATE auth.identities
763
824
  SET identity_data = p_identity_data, last_sign_in_at = now(), updated_at = now()
@@ -779,6 +840,11 @@ BEGIN
779
840
  WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
780
841
 
781
842
  IF FOUND THEN
843
+ -- Never link a provider to (or sign in) a banned account.
844
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
845
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
846
+ END IF;
847
+
782
848
  -- Link the new provider to existing user
783
849
  v_providers := COALESCE(v_user.raw_app_meta_data->'providers', '[]'::jsonb);
784
850
  IF NOT v_providers ? p_provider THEN
@@ -834,7 +900,7 @@ REVOKE EXECUTE ON FUNCTION auth.oauth_sign_in FROM PUBLIC;`;
834
900
  // JWT CONVENIENCE WRAPPERS
835
901
  // =============================================================================
836
902
 
837
- const SIGN_IN_JWT = `\
903
+ const SIGN_IN_JWT = (ttl: number): string => `\
838
904
  CREATE OR REPLACE FUNCTION auth.sign_in_jwt(p_email TEXT, p_password TEXT)
839
905
  RETURNS JSONB
840
906
  LANGUAGE plpgsql
@@ -851,7 +917,7 @@ BEGIN
851
917
 
852
918
  v_claims := v_payload || jsonb_build_object(
853
919
  'iat', extract(epoch FROM now())::bigint,
854
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
920
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
855
921
  );
856
922
  v_token := auth.sign_jwt(v_claims);
857
923
 
@@ -867,7 +933,7 @@ $$;
867
933
 
868
934
  REVOKE EXECUTE ON FUNCTION auth.sign_in_jwt FROM PUBLIC;`;
869
935
 
870
- const SIGN_UP_JWT = `\
936
+ const SIGN_UP_JWT = (ttl: number): string => `\
871
937
  CREATE OR REPLACE FUNCTION auth.sign_up_jwt(
872
938
  p_email TEXT,
873
939
  p_password TEXT,
@@ -888,7 +954,7 @@ BEGIN
888
954
 
889
955
  v_claims := v_payload || jsonb_build_object(
890
956
  'iat', extract(epoch FROM now())::bigint,
891
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
957
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
892
958
  );
893
959
  v_token := auth.sign_jwt(v_claims);
894
960
 
@@ -904,7 +970,7 @@ $$;
904
970
 
905
971
  REVOKE EXECUTE ON FUNCTION auth.sign_up_jwt FROM PUBLIC;`;
906
972
 
907
- const OAUTH_SIGN_IN_JWT = `\
973
+ const OAUTH_SIGN_IN_JWT = (ttl: number): string => `\
908
974
  CREATE OR REPLACE FUNCTION auth.oauth_sign_in_jwt(
909
975
  p_provider TEXT,
910
976
  p_provider_id TEXT,
@@ -926,7 +992,7 @@ BEGIN
926
992
 
927
993
  v_claims := v_payload || jsonb_build_object(
928
994
  'iat', extract(epoch FROM now())::bigint,
929
- 'exp', (extract(epoch FROM now()) + 3600)::bigint
995
+ 'exp', (extract(epoch FROM now()) + ${ttl})::bigint
930
996
  );
931
997
  v_token := auth.sign_jwt(v_claims);
932
998
 
@@ -1023,9 +1089,9 @@ export function generateCoreSQL(): string {
1023
1089
  .trimEnd() + '\n';
1024
1090
  }
1025
1091
 
1026
- /** issue + validate + revoke + cleanup refresh tokens (RFC 6819) */
1092
+ /** issue + validate + revoke + sign_out + cleanup refresh tokens (RFC 6819) */
1027
1093
  export function generateRefreshTokenSQL(): string {
1028
- return [ISSUE_REFRESH_TOKEN, '', VALIDATE_REFRESH_TOKEN, '', REVOKE_REFRESH_TOKENS, '', CLEANUP_REFRESH_TOKENS]
1094
+ return [ISSUE_REFRESH_TOKEN, '', VALIDATE_REFRESH_TOKEN, '', REVOKE_REFRESH_TOKENS, '', SIGN_OUT, '', CLEANUP_REFRESH_TOKENS]
1029
1095
  .join('\n')
1030
1096
  .trimEnd() + '\n';
1031
1097
  }
@@ -1062,9 +1128,13 @@ export function generateDefaultAuthSQL(): string {
1062
1128
  .trimEnd() + '\n';
1063
1129
  }
1064
1130
 
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]
1131
+ /**
1132
+ * auth.sign_in_jwt, auth.sign_up_jwt, auth.oauth_sign_in_jwt. `accessTokenTtlSec` (default
1133
+ * 3600) is baked into the generated bodies as the access token's `exp` offset.
1134
+ */
1135
+ export function generateJwtWrappersSQL(opts: { accessTokenTtlSec?: number } = {}): string {
1136
+ const ttl = resolveAccessTokenTtl(opts.accessTokenTtlSec);
1137
+ return [SIGN_IN_JWT(ttl), '', SIGN_UP_JWT(ttl), '', OAUTH_SIGN_IN_JWT(ttl)]
1068
1138
  .join('\n')
1069
1139
  .trimEnd() + '\n';
1070
1140
  }
@@ -1096,6 +1166,8 @@ export function generateAuthGrantsSQL(opts: AuthSQLOptions = {}): string {
1096
1166
  const lines = ['GRANT USAGE ON SCHEMA auth TO anon, authenticated, admin;'];
1097
1167
  // validate_refresh_token is always generated (the refresh path) and anon-callable.
1098
1168
  lines.push('GRANT EXECUTE ON FUNCTION auth.validate_refresh_token(text, uuid, text, text, interval) TO anon;');
1169
+ // sign_out is generated with the refresh functions and is token-gated the same way.
1170
+ lines.push('GRANT EXECUTE ON FUNCTION auth.sign_out(text) TO anon;');
1099
1171
  if (opts.tokenActions !== false) {
1100
1172
  lines.push('GRANT EXECUTE ON FUNCTION auth.confirm_email(text) TO anon;');
1101
1173
  lines.push('GRANT EXECUTE ON FUNCTION auth.reset_password(text, text) TO anon;');
@@ -1125,6 +1197,8 @@ export interface AuthSQLOptions {
1125
1197
  defaultAuthFunctions?: boolean;
1126
1198
  /** Include JWT convenience wrappers (default: false) */
1127
1199
  jwtWrappers?: boolean;
1200
+ /** Access-token lifetime baked into the JWT wrappers, in seconds (default: 3600) */
1201
+ accessTokenTtlSec?: number;
1128
1202
  }
1129
1203
 
1130
1204
  /**
@@ -1157,7 +1231,7 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
1157
1231
  parts.push('', generateDefaultAuthSQL());
1158
1232
  }
1159
1233
  if (options?.jwtWrappers) {
1160
- parts.push('', generateJwtWrappersSQL());
1234
+ parts.push('', generateJwtWrappersSQL({ accessTokenTtlSec: options.accessTokenTtlSec }));
1161
1235
  }
1162
1236
 
1163
1237
  // The role grants come LAST — every GRANT EXECUTE target now exists. This is what makes the