@everystack/auth 0.4.7 → 0.4.9

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,41 @@ 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.9] - 2026-10-14
8
+
9
+ ### Fixed
10
+
11
+ - break the model and auth import cycles; gate new ones
12
+
13
+ ### Changed
14
+
15
+ - share refresh token extraction
16
+
17
+ ## [Unreleased]
18
+
19
+ ### Changed
20
+
21
+ - share refresh token extraction
22
+
23
+ ## [0.4.8] - 2026-09-26
24
+
25
+ ### Fixed
26
+
27
+ - revoke sessions when users are blocked
28
+
29
+ ## [0.4.7] - 2026-09-26
30
+
31
+ ### Fixed
32
+
33
+ - enforce bans on refresh and signin, check password before ban
34
+ - make the access token lifetime configurable
35
+ - signout revokes the presented refresh token
36
+ - refuse banned users on OAuth sign-in; signout never fails on revoke
37
+
38
+ ### Documentation
39
+
40
+ - document signout revocation, accessTokenTtlSec and ban enforcement
41
+
7
42
  ## [0.4.6] - 2026-09-22
8
43
 
9
44
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/auth",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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.20"
111
+ "@everystack/model": "0.4.21"
112
112
  },
113
113
  "peerDependencies": {
114
114
  "@everystack/api": ">=0.1.0",
@@ -137,8 +137,8 @@
137
137
  "postgres": "3.4.9",
138
138
  "ts-jest": "29.4.9",
139
139
  "typescript": "5.9.3",
140
- "@everystack/server": "0.4.33",
141
- "@everystack/api": "0.4.2"
140
+ "@everystack/server": "0.4.35",
141
+ "@everystack/api": "0.4.3"
142
142
  },
143
143
  "scripts": {
144
144
  "test": "jest",
package/src/account.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Account-state and password-column helpers shared by index.ts and postgres.ts.
3
+ * A leaf module: index.ts imports postgres.ts, so these cannot live in index.ts.
4
+ */
5
+
6
+ /**
7
+ * Why a user row may not hold a session, or null when it may. Reads row fields, not table
8
+ * columns, so an app schema without `bannedUntil`/`deletedAt` simply never trips it.
9
+ * Mirrors the SQL side: auth.sign_in filters deleted_at, auth.validate_refresh_token
10
+ * rejects deleted_at (P0004) and an unexpired banned_until (P0007).
11
+ */
12
+ export function accountBlock(user: any): 'deleted' | 'banned' | null {
13
+ if (user.deletedAt) return 'deleted';
14
+ if (user.bannedUntil && new Date(user.bannedUntil).getTime() > Date.now()) return 'banned';
15
+ return null;
16
+ }
17
+
18
+ /** The refusal for a sign-in path that already proved identity (OAuth), or null to proceed.
19
+ * A missing row is treated as a deleted account. */
20
+ export function accountBlockedResponse(user: any): Response | null {
21
+ const block = user ? accountBlock(user) : 'deleted';
22
+ if (block === 'banned') return Response.json({ message: 'Account suspended' }, { status: 403 });
23
+ if (block === 'deleted') return Response.json({ message: 'Account disabled' }, { status: 401 });
24
+ return null;
25
+ }
26
+
27
+ /** Signout must always clear cookies. A failed server-side revoke is logged, never thrown:
28
+ * only the error's code or message is logged, never the token. */
29
+ export function warnRevokeFailed(err: unknown): void {
30
+ const e = err as { code?: string; message?: string } | undefined;
31
+ // eslint-disable-next-line no-console
32
+ console.warn('[everystack/auth] signout: refresh token revocation failed:', e?.code ?? e?.message ?? 'unknown error');
33
+ }
34
+
35
+ /**
36
+ * Build insert values with the correct password column name.
37
+ * Checks which column exists on the Drizzle table definition.
38
+ */
39
+ export function withPassword(users: any, values: Record<string, any>, hash: string | null): Record<string, any> {
40
+ if ('encryptedPassword' in users) {
41
+ return { ...values, encryptedPassword: hash };
42
+ }
43
+ return { ...values, passwordHash: hash };
44
+ }
package/src/index.ts CHANGED
@@ -38,7 +38,9 @@ import type { OAuthProvider, OAuthUserInfo } from './oauth';
38
38
  export type { OAuthProvider, OAuthUserInfo } from './oauth';
39
39
  export { createAppleProvider, createGoogleProvider } from './oauth';
40
40
  import { createPostgresAuthHandlers } from './postgres';
41
- import { getRefreshFromCookies, readPresentedRefreshToken } from './cookies';
41
+ import { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './account';
42
+ export { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './account';
43
+ import { readPresentedRefreshToken } from './cookies';
42
44
  import { authResponse, signoutResponse } from './response';
43
45
  import type { CookieConfig } from './response';
44
46
  import type { EnrichClaimsFn } from './plugin';
@@ -63,45 +65,6 @@ export function getPasswordHash(user: any): string | null {
63
65
  return user.encryptedPassword ?? user.passwordHash ?? null;
64
66
  }
65
67
 
66
- /**
67
- * Build insert values with the correct password column name.
68
- * Checks which column exists on the Drizzle table definition.
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
-
99
- export function withPassword(users: any, values: Record<string, any>, hash: string | null): Record<string, any> {
100
- if ('encryptedPassword' in users) {
101
- return { ...values, encryptedPassword: hash };
102
- }
103
- return { ...values, passwordHash: hash };
104
- }
105
68
 
106
69
  export interface TokenPayload {
107
70
  sub: string;
@@ -656,17 +619,7 @@ export function createAuthHandlers(
656
619
  const csrfError = csrfCheck(request, options?.cookies);
657
620
  if (csrfError) return csrfError;
658
621
 
659
- // Read refresh token from JSON body (mobile) or cookie (web SSR)
660
- let refreshToken: string | undefined;
661
- try {
662
- const body = await request.clone().json();
663
- refreshToken = body?.refreshToken;
664
- } catch {
665
- // Empty body is fine — fall through to cookie
666
- }
667
- if (!refreshToken) {
668
- refreshToken = getRefreshFromCookies(request.headers.get('cookie')) ?? undefined;
669
- }
622
+ const refreshToken = await readPresentedRefreshToken(request);
670
623
  if (!refreshToken) {
671
624
  return Response.json(
672
625
  { message: 'refreshToken is required' },
@@ -39,8 +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
43
  import { DEFAULT_ACCESS_TOKEN_TTL_SEC, resolveAccessTokenTtl } from '../sql';
44
+ import { User } from './user';
44
45
 
45
46
  export const changePasswordFn = defineFunction('auth.change_password', {
46
47
  args: [arg('p_current_password', 'text'), arg('p_new_password', 'text')],
@@ -849,6 +850,36 @@ END;
849
850
  `,
850
851
  });
851
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
+
852
883
  export const verifyJwtFn = defineFunction('auth.verify_jwt', {
853
884
  args: [arg('p_token', 'text')],
854
885
  returns: 'jsonb',
@@ -979,6 +1010,7 @@ export const coreFunctions = [hashPasswordFn, verifyPasswordFn, signJwtFn, verif
979
1010
  /** The refresh-token family (RFC 6819 rotation). Always on. */
980
1011
  export const refreshTokenFunctions = [
981
1012
  issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, signOutFn, cleanupRefreshTokensFn,
1013
+ revokeRefreshTokensOnUserBlockFn,
982
1014
  ];
983
1015
 
984
1016
  /** `auth.uid()` / `auth.role()` / `auth.jwt()` — called FROM RLS policies, so granted to the
@@ -1041,3 +1073,8 @@ export function authFunctions(opts: AuthFunctionOptions = {}) {
1041
1073
  ...(opts.jwtWrappers === true ? jwtWrapperFunctionsFor(opts.accessTokenTtlSec) : []),
1042
1074
  ];
1043
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,9 +18,9 @@ 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 { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './index';
21
+ import { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './account';
22
22
  import type { OAuthProvider } from './oauth';
23
- import { getRefreshFromCookies, readPresentedRefreshToken } from './cookies';
23
+ import { readPresentedRefreshToken } from './cookies';
24
24
  import { authResponse, signoutResponse } from './response';
25
25
  import type { CookieConfig } from './response';
26
26
  import { validateCsrfOrigin } from './csrf';
@@ -436,17 +436,7 @@ export function createPostgresAuthHandlers(
436
436
  const csrfError = csrfCheck(request, options?.cookies);
437
437
  if (csrfError) return csrfError;
438
438
 
439
- // Read refresh token from JSON body (mobile) or cookie (web SSR)
440
- let refreshToken: string | undefined;
441
- try {
442
- const body = await request.clone().json();
443
- refreshToken = body?.refreshToken;
444
- } catch {
445
- // Empty body is fine — fall through to cookie
446
- }
447
- if (!refreshToken) {
448
- refreshToken = getRefreshFromCookies(request.headers.get('cookie')) ?? undefined;
449
- }
439
+ const refreshToken = await readPresentedRefreshToken(request);
450
440
  if (!refreshToken) {
451
441
  return Response.json(
452
442
  { message: 'refreshToken is required' },
package/src/sql.ts CHANGED
@@ -358,6 +358,33 @@ $$;
358
358
 
359
359
  REVOKE EXECUTE ON FUNCTION auth.validate_refresh_token FROM PUBLIC;`;
360
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
+
361
388
  const REVOKE_REFRESH_TOKENS = `\
362
389
  CREATE OR REPLACE FUNCTION auth.revoke_refresh_tokens(
363
390
  p_user_id UUID,
@@ -1091,7 +1118,10 @@ export function generateCoreSQL(): string {
1091
1118
 
1092
1119
  /** issue + validate + revoke + sign_out + cleanup refresh tokens (RFC 6819) */
1093
1120
  export function generateRefreshTokenSQL(): string {
1094
- return [ISSUE_REFRESH_TOKEN, '', VALIDATE_REFRESH_TOKEN, '', REVOKE_REFRESH_TOKENS, '', SIGN_OUT, '', 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
+ ]
1095
1125
  .join('\n')
1096
1126
  .trimEnd() + '\n';
1097
1127
  }