@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 +25 -0
- package/README.md +4 -1
- package/package.json +3 -3
- package/src/cookies.ts +14 -0
- package/src/index.ts +68 -4
- package/src/models/functions.ts +117 -17
- package/src/models/index.ts +2 -2
- package/src/postgres.ts +27 -7
- package/src/refresh.ts +14 -0
- package/src/sql.ts +123 -19
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/src/models/functions.ts
CHANGED
|
@@ -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
|
-
|
|
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()) +
|
|
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
|
-
|
|
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()) +
|
|
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
|
-
|
|
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()) +
|
|
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
|
-
/**
|
|
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 ?
|
|
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
|
+
}
|
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,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 +
|
|
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:
|
|
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()) +
|
|
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()) +
|
|
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()) +
|
|
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 [
|
|
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
|
-
/**
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|