@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 +35 -0
- package/package.json +4 -4
- package/src/account.ts +44 -0
- package/src/index.ts +4 -51
- package/src/models/functions.ts +38 -1
- package/src/models/index.ts +2 -2
- package/src/postgres.ts +3 -13
- package/src/sql.ts +31 -1
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.
|
|
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.
|
|
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.
|
|
141
|
-
"@everystack/api": "0.4.
|
|
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 {
|
|
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
|
-
|
|
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' },
|
package/src/models/functions.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/models/index.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 './
|
|
21
|
+
import { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './account';
|
|
22
22
|
import type { OAuthProvider } from './oauth';
|
|
23
|
-
import {
|
|
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
|
-
|
|
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 [
|
|
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
|
}
|