@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 +6 -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 +79 -16
- package/src/postgres.ts +27 -7
- package/src/refresh.ts +14 -0
- package/src/sql.ts +93 -19
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.
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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()) +
|
|
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
|
-
|
|
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()) +
|
|
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
|
-
|
|
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()) +
|
|
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
|
-
/**
|
|
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 ?
|
|
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 +
|
|
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
|
|
@@ -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()) +
|
|
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()) +
|
|
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()) +
|
|
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
|
-
/**
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|