@payez/next-mvp 4.5.3 → 4.5.5
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/dist/auth/better-auth.js +5 -1
- package/dist/auth/session-response-sanitizer.d.ts +70 -0
- package/dist/auth/session-response-sanitizer.js +136 -0
- package/dist/middleware/create-middleware.js +16 -2
- package/dist/stores/authStore.js +6 -19
- package/package.json +1 -1
- package/src/auth/better-auth.ts +5 -1
- package/src/auth/session-response-sanitizer.ts +151 -0
- package/src/middleware/create-middleware.ts +16 -2
- package/src/stores/authStore.ts +7 -17
package/dist/auth/better-auth.js
CHANGED
|
@@ -64,6 +64,7 @@ const jose_1 = require("jose"); // better-auth's own JWT dependency
|
|
|
64
64
|
const better_auth_1 = require("better-auth");
|
|
65
65
|
const next_js_1 = require("better-auth/next-js");
|
|
66
66
|
const next_js_2 = require("better-auth/next-js");
|
|
67
|
+
const session_response_sanitizer_1 = require("./session-response-sanitizer");
|
|
67
68
|
const magic_link_1 = require("better-auth/plugins/magic-link");
|
|
68
69
|
const client_key_1 = require("../lib/client-key");
|
|
69
70
|
const generic_oauth_1 = require("better-auth/plugins/generic-oauth");
|
|
@@ -887,7 +888,10 @@ async function getBetterAuthHandler() {
|
|
|
887
888
|
if (!isBetterAuthEnabled())
|
|
888
889
|
return null;
|
|
889
890
|
const auth = await getBetterAuthInstance();
|
|
890
|
-
|
|
891
|
+
// PAY-2217: Better Auth's native endpoints (get-session, list-sessions, sign-in/*, sign-up/*) put the session
|
|
892
|
+
// token in the body. Strip it at the HTTP boundary here, so every consumer that mounts this handler gets it;
|
|
893
|
+
// auth.api (server-side) is untouched and still sees the token. See session-response-sanitizer.ts.
|
|
894
|
+
return (0, session_response_sanitizer_1.wrapBetterAuthHandler)(auth, (0, next_js_2.toNextJsHandler)(auth));
|
|
891
895
|
}
|
|
892
896
|
/**
|
|
893
897
|
* Exchange OAuth identity for IDP tokens and store in the BA Redis session.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2217: no session token reaches browser JS from Better Auth's own HTTP endpoints.
|
|
3
|
+
*
|
|
4
|
+
* WHAT WAS WRONG. PAY-1481 removed token material from the package's own GET /api/auth/session. Better Auth's native
|
|
5
|
+
* endpoints are a separate surface served by the catch-all mount, and their default bodies carry the session
|
|
6
|
+
* credential identifier:
|
|
7
|
+
* GET /get-session -> { session: { id, token, userId, ... }, user }
|
|
8
|
+
* GET /list-sessions -> [ { id, token, ... } ] (EVERY session of the user, not just this one)
|
|
9
|
+
* POST /sign-in/* -> { token, user, ... }
|
|
10
|
+
* POST /sign-up/* -> { token, user }
|
|
11
|
+
* GET /magic-link/verify (no callbackURL) -> { token, user } (NightHawk G4 on PR 1190, measured on a real instance)
|
|
12
|
+
* and any plugin endpoint (passkey verify, two-factor verify, genericOAuth) answers a session object the same way.
|
|
13
|
+
* `token` is the Redis key of the session. The cookie is token.HMAC and Path B verifies the signature, so a bare
|
|
14
|
+
* token is not a replayable cookie (gate rows B1-B4 measure that), but it is a credential identifier readable by any
|
|
15
|
+
* script on the page, for this session and for every other session the user has. It breaks the PAY-1481 rule.
|
|
16
|
+
*
|
|
17
|
+
* EVERY 2xx JSON BODY IS STRIPPED, not a named list of paths. A list protects the endpoints someone remembered and
|
|
18
|
+
* silently misses the next plugin; the strip is a function of the response, so a new endpoint is covered the day it is
|
|
19
|
+
* mounted. A 2xx JSON body that legitimately needs one of these names to reach the browser would have to be
|
|
20
|
+
* allowlisted here on purpose (none is known: no client in this package reads one).
|
|
21
|
+
*
|
|
22
|
+
* WHERE THE FIX LIVES, AND WHY NOT customSession. Server-side code in this package reads session.session.token from
|
|
23
|
+
* auth.api.getSession() (viability, refresh, status, the OAuth exchange). A customSession plugin or an after-hook
|
|
24
|
+
* runs for those calls too and would strip the token from them. So the strip is applied at the HTTP BOUNDARY only:
|
|
25
|
+
* getBetterAuthHandler() returns handlers wrapped by wrapBetterAuthHandler(), and auth.api stays untouched.
|
|
26
|
+
*
|
|
27
|
+
* WHAT THE BROWSER GETS INSTEAD.
|
|
28
|
+
* - list-sessions rows carry `isCurrent` (computed here against the caller's cookie-resolved session) and `id`.
|
|
29
|
+
* - POST /revoke-session-by-id { id }: revokes ONE of the CALLER's sessions by its id. An id that is not one of the
|
|
30
|
+
* caller's own sessions answers 404 (never 403: do not confirm that another user's session id exists).
|
|
31
|
+
* - Better Auth's native POST /revoke-session { token } still exists and still requires a cookie-resolved session
|
|
32
|
+
* that owns the token, but no browser ever holds a token to send it.
|
|
33
|
+
*/
|
|
34
|
+
/** Deep copy of `value` with every credential-named key removed, at any depth, in objects and arrays. */
|
|
35
|
+
export declare function stripSessionCredentials(value: unknown): unknown;
|
|
36
|
+
/** "/api/auth/get-session" -> "/get-session". Anything not under an /auth/ segment is returned as-is. */
|
|
37
|
+
export declare function authSubPath(pathname: string): string;
|
|
38
|
+
interface AuthApi {
|
|
39
|
+
getSession: (opts: {
|
|
40
|
+
headers: Headers;
|
|
41
|
+
}) => Promise<any>;
|
|
42
|
+
listSessions: (opts: {
|
|
43
|
+
headers: Headers;
|
|
44
|
+
}) => Promise<any>;
|
|
45
|
+
revokeSession: (opts: {
|
|
46
|
+
headers: Headers;
|
|
47
|
+
body: {
|
|
48
|
+
token: string;
|
|
49
|
+
};
|
|
50
|
+
asResponse?: boolean;
|
|
51
|
+
}) => Promise<any>;
|
|
52
|
+
}
|
|
53
|
+
/** Sanitise one already-produced Better Auth response. Pure function of (request, response, current token). */
|
|
54
|
+
export declare function sanitizeBetterAuthResponse(req: Request, res: Response, currentToken: () => Promise<string | undefined>): Promise<Response>;
|
|
55
|
+
type Handler = (req: Request) => Promise<Response>;
|
|
56
|
+
/**
|
|
57
|
+
* Wrap Better Auth's Next.js handlers so no response carries a session credential to the browser.
|
|
58
|
+
* `auth` is the Better Auth instance; its `api` is used server-side only (to find the caller's current session and to
|
|
59
|
+
* revoke by id) and is not modified.
|
|
60
|
+
*/
|
|
61
|
+
export declare function wrapBetterAuthHandler(auth: {
|
|
62
|
+
api: AuthApi;
|
|
63
|
+
}, handlers: {
|
|
64
|
+
GET: Handler;
|
|
65
|
+
POST: Handler;
|
|
66
|
+
}): {
|
|
67
|
+
GET: Handler;
|
|
68
|
+
POST: Handler;
|
|
69
|
+
};
|
|
70
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PAY-2217: no session token reaches browser JS from Better Auth's own HTTP endpoints.
|
|
4
|
+
*
|
|
5
|
+
* WHAT WAS WRONG. PAY-1481 removed token material from the package's own GET /api/auth/session. Better Auth's native
|
|
6
|
+
* endpoints are a separate surface served by the catch-all mount, and their default bodies carry the session
|
|
7
|
+
* credential identifier:
|
|
8
|
+
* GET /get-session -> { session: { id, token, userId, ... }, user }
|
|
9
|
+
* GET /list-sessions -> [ { id, token, ... } ] (EVERY session of the user, not just this one)
|
|
10
|
+
* POST /sign-in/* -> { token, user, ... }
|
|
11
|
+
* POST /sign-up/* -> { token, user }
|
|
12
|
+
* GET /magic-link/verify (no callbackURL) -> { token, user } (NightHawk G4 on PR 1190, measured on a real instance)
|
|
13
|
+
* and any plugin endpoint (passkey verify, two-factor verify, genericOAuth) answers a session object the same way.
|
|
14
|
+
* `token` is the Redis key of the session. The cookie is token.HMAC and Path B verifies the signature, so a bare
|
|
15
|
+
* token is not a replayable cookie (gate rows B1-B4 measure that), but it is a credential identifier readable by any
|
|
16
|
+
* script on the page, for this session and for every other session the user has. It breaks the PAY-1481 rule.
|
|
17
|
+
*
|
|
18
|
+
* EVERY 2xx JSON BODY IS STRIPPED, not a named list of paths. A list protects the endpoints someone remembered and
|
|
19
|
+
* silently misses the next plugin; the strip is a function of the response, so a new endpoint is covered the day it is
|
|
20
|
+
* mounted. A 2xx JSON body that legitimately needs one of these names to reach the browser would have to be
|
|
21
|
+
* allowlisted here on purpose (none is known: no client in this package reads one).
|
|
22
|
+
*
|
|
23
|
+
* WHERE THE FIX LIVES, AND WHY NOT customSession. Server-side code in this package reads session.session.token from
|
|
24
|
+
* auth.api.getSession() (viability, refresh, status, the OAuth exchange). A customSession plugin or an after-hook
|
|
25
|
+
* runs for those calls too and would strip the token from them. So the strip is applied at the HTTP BOUNDARY only:
|
|
26
|
+
* getBetterAuthHandler() returns handlers wrapped by wrapBetterAuthHandler(), and auth.api stays untouched.
|
|
27
|
+
*
|
|
28
|
+
* WHAT THE BROWSER GETS INSTEAD.
|
|
29
|
+
* - list-sessions rows carry `isCurrent` (computed here against the caller's cookie-resolved session) and `id`.
|
|
30
|
+
* - POST /revoke-session-by-id { id }: revokes ONE of the CALLER's sessions by its id. An id that is not one of the
|
|
31
|
+
* caller's own sessions answers 404 (never 403: do not confirm that another user's session id exists).
|
|
32
|
+
* - Better Auth's native POST /revoke-session { token } still exists and still requires a cookie-resolved session
|
|
33
|
+
* that owns the token, but no browser ever holds a token to send it.
|
|
34
|
+
*/
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.stripSessionCredentials = stripSessionCredentials;
|
|
37
|
+
exports.authSubPath = authSubPath;
|
|
38
|
+
exports.sanitizeBetterAuthResponse = sanitizeBetterAuthResponse;
|
|
39
|
+
exports.wrapBetterAuthHandler = wrapBetterAuthHandler;
|
|
40
|
+
/** Credential-bearing key names, compared case- and separator-insensitively. */
|
|
41
|
+
const STRIP_KEYS = new Set(['token', 'sessiontoken', 'accesstoken', 'refreshtoken', 'idtoken']);
|
|
42
|
+
const norm = (k) => k.toLowerCase().replace(/[_\-\s]/g, '');
|
|
43
|
+
/** Deep copy of `value` with every credential-named key removed, at any depth, in objects and arrays. */
|
|
44
|
+
function stripSessionCredentials(value) {
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
return value.map(stripSessionCredentials);
|
|
47
|
+
if (value !== null && typeof value === 'object') {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [k, v] of Object.entries(value)) {
|
|
50
|
+
if (STRIP_KEYS.has(norm(k)))
|
|
51
|
+
continue;
|
|
52
|
+
out[k] = stripSessionCredentials(v);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/** "/api/auth/get-session" -> "/get-session". Anything not under an /auth/ segment is returned as-is. */
|
|
59
|
+
function authSubPath(pathname) {
|
|
60
|
+
const i = pathname.indexOf('/api/auth/');
|
|
61
|
+
if (i >= 0)
|
|
62
|
+
return pathname.slice(i + '/api/auth'.length).replace(/\/+$/, '') || '/';
|
|
63
|
+
return pathname.replace(/\/+$/, '') || '/';
|
|
64
|
+
}
|
|
65
|
+
function isJson(res) {
|
|
66
|
+
return (res.headers.get('content-type') || '').toLowerCase().includes('application/json');
|
|
67
|
+
}
|
|
68
|
+
/** A Response with a new JSON body and the original status and headers (content-length recomputed by the runtime). */
|
|
69
|
+
function withJsonBody(res, body) {
|
|
70
|
+
const headers = new Headers(res.headers);
|
|
71
|
+
headers.delete('content-length');
|
|
72
|
+
return new Response(JSON.stringify(body), { status: res.status, statusText: res.statusText, headers });
|
|
73
|
+
}
|
|
74
|
+
/** Sanitise one already-produced Better Auth response. Pure function of (request, response, current token). */
|
|
75
|
+
async function sanitizeBetterAuthResponse(req, res, currentToken) {
|
|
76
|
+
const sub = authSubPath(new URL(req.url).pathname);
|
|
77
|
+
if (!res.ok || !isJson(res))
|
|
78
|
+
return res;
|
|
79
|
+
let body;
|
|
80
|
+
try {
|
|
81
|
+
body = await res.clone().json();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return res; // unreadable body: nothing to strip, and do not turn it into a different failure
|
|
85
|
+
}
|
|
86
|
+
if (sub === '/list-sessions' && Array.isArray(body)) {
|
|
87
|
+
const cur = await currentToken().catch(() => undefined);
|
|
88
|
+
body = body.map((row) => row && typeof row === 'object' ? { ...row, isCurrent: !!cur && row.token === cur } : row);
|
|
89
|
+
}
|
|
90
|
+
return withJsonBody(res, stripSessionCredentials(body));
|
|
91
|
+
}
|
|
92
|
+
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
93
|
+
/** POST /revoke-session-by-id { id }: revoke one of the caller's own sessions by id. */
|
|
94
|
+
async function revokeById(req, api) {
|
|
95
|
+
let id;
|
|
96
|
+
try {
|
|
97
|
+
id = (await req.json())?.id;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return json(400, { code: 'INVALID_REQUEST', message: 'Body must be JSON { id }' });
|
|
101
|
+
}
|
|
102
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
103
|
+
return json(400, { code: 'INVALID_REQUEST', message: 'id is required' });
|
|
104
|
+
}
|
|
105
|
+
const headers = new Headers(req.headers);
|
|
106
|
+
const rows = await api.listSessions({ headers }).catch(() => null);
|
|
107
|
+
if (rows === null)
|
|
108
|
+
return json(401, { code: 'UNAUTHORIZED', message: 'No session' });
|
|
109
|
+
// listSessions returns ONLY the caller's own sessions, so a foreign id cannot match: 404, not 403.
|
|
110
|
+
const row = Array.isArray(rows) ? rows.find((r) => r && r.id === id && typeof r.token === 'string') : undefined;
|
|
111
|
+
if (!row)
|
|
112
|
+
return json(404, { code: 'SESSION_NOT_FOUND', message: 'No such session' });
|
|
113
|
+
const res = await api.revokeSession({ headers, body: { token: row.token }, asResponse: true });
|
|
114
|
+
// Pass Better Auth's own answer (and its Set-Cookie when the current session was the one revoked) through.
|
|
115
|
+
return res instanceof Response ? res : json(200, { status: true });
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Wrap Better Auth's Next.js handlers so no response carries a session credential to the browser.
|
|
119
|
+
* `auth` is the Better Auth instance; its `api` is used server-side only (to find the caller's current session and to
|
|
120
|
+
* revoke by id) and is not modified.
|
|
121
|
+
*/
|
|
122
|
+
function wrapBetterAuthHandler(auth, handlers) {
|
|
123
|
+
const currentTokenFor = (req) => async () => {
|
|
124
|
+
const s = await auth.api.getSession({ headers: new Headers(req.headers) });
|
|
125
|
+
return s?.session?.token;
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
GET: async (req) => sanitizeBetterAuthResponse(req, await handlers.GET(req), currentTokenFor(req)),
|
|
129
|
+
POST: async (req) => {
|
|
130
|
+
const sub = authSubPath(new URL(req.url).pathname);
|
|
131
|
+
if (sub === '/revoke-session-by-id')
|
|
132
|
+
return revokeById(req, auth.api);
|
|
133
|
+
return sanitizeBetterAuthResponse(req, await handlers.POST(req), currentTokenFor(req));
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -575,7 +575,16 @@ async function checkViability(request, endpoint, log, revocationFailMode) {
|
|
|
575
575
|
// booleans for unknown/not_configured. The booleans are read ONLY when
|
|
576
576
|
// revocationStatus is ABSENT (idealresume.online's older boolean route).
|
|
577
577
|
sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
|
|
578
|
-
|
|
578
|
+
// PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
|
|
579
|
+
// pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
|
|
580
|
+
// arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
|
|
581
|
+
// Stale is a POSITIVE signal and needs ALL of: this 2xx read, exists === false, a sessionTokenId (every
|
|
582
|
+
// producer sets it only when a cookie resolved to a token: routes/auth/viability.ts, api-handlers/session/
|
|
583
|
+
// viability.ts, refresh-viability.ts 'session_not_found'), and a session cookie on THIS request (plain or
|
|
584
|
+
// __Secure-). A FAILED read never gets here (exists=null -> service_error, PAY-2147); a store error throws
|
|
585
|
+
// inside every producer and answers 5xx or 'unknown' WITHOUT a sessionTokenId; no cookie -> no_session.
|
|
586
|
+
const stale = sessionStatus.exists === false && !!data.sessionTokenId && hasSessionCookie(request);
|
|
587
|
+
sessionPointer.exists = stale ? true : (sessionStatus.exists ?? false);
|
|
579
588
|
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
580
589
|
sessionPointer.expired = data.accessTokenExpired || false;
|
|
581
590
|
sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
|
|
@@ -620,7 +629,12 @@ const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
|
|
|
620
629
|
/** Handle 'allow' decision - run RBAC if enabled */
|
|
621
630
|
async function handleAllow(request, pathname, sessionPointer, sessionStatus, rbacExemptPaths = []) {
|
|
622
631
|
const isPublic = (0, route_config_1.isUnauthenticatedRoute)(pathname);
|
|
623
|
-
|
|
632
|
+
// PAY-2216: RBAC needs a session that viability CONFIRMED. A stale /api request (pointer.exists only says the
|
|
633
|
+
// cookie is present, status.exists is false) is allowed through so the route handler answers its own 401 - not an
|
|
634
|
+
// RBAC 401 here. TRUTHY, not `=== true` (NightHawk 71960): status.exists carries the producer's JSON value as-is,
|
|
635
|
+
// and a consumer route may answer authenticated: 1 / "true"; `=== true` skipped RBAC for those healthy sessions
|
|
636
|
+
// (fail-OPEN). Truthy keeps 4.5.3's RBAC coverage exactly and still excludes stale (false) and a failed read (null).
|
|
637
|
+
if ((0, rbac_check_1.isRBACEnabled)() && !isPublic && sessionPointer.exists && sessionStatus.exists) {
|
|
624
638
|
// Skip RBAC for error/fallback pages (prevent redirect loops) and app-configured exempt paths
|
|
625
639
|
if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p)) ||
|
|
626
640
|
rbacExemptPaths.some(p => pathname.startsWith(p))) {
|
package/dist/stores/authStore.js
CHANGED
|
@@ -425,25 +425,12 @@ exports.useAuthStore = (0, zustand_1.create)()((0, middleware_1.devtools)((set,
|
|
|
425
425
|
try {
|
|
426
426
|
// COORDINATED REFRESH: The server now handles distributed locking
|
|
427
427
|
// We just need to make the refresh call and let the server coordinate
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
for (let attempt = 1; attempt <= 3 && !token; attempt++) {
|
|
435
|
-
const { data: s } = await better_auth_client_1.authClient.getSession();
|
|
436
|
-
token = s?.sessionToken;
|
|
437
|
-
if (!token) {
|
|
438
|
-
await new Promise(r => setTimeout(r, 150));
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
logger_1.authLogger.warn('[AuthStore] Failed to resolve session token from Better Auth during refresh');
|
|
444
|
-
}
|
|
445
|
-
return token;
|
|
446
|
-
};
|
|
428
|
+
// PAY-2217: the session token is NOT read from Better Auth's getSession() any more. The HTTP body no
|
|
429
|
+
// longer carries it (wrapBetterAuthHandler), and this read was already dead: getSession() returns
|
|
430
|
+
// { session: { token } }, never a top-level `sessionToken`, so the 3 x 150 ms retry loop that used to sit
|
|
431
|
+
// here always ran to exhaustion. The server resolves the session from the cookie and REJECTS an
|
|
432
|
+
// x-session-token header when it cannot (refresh.ts, 246007), so the header is advisory at best.
|
|
433
|
+
const resolveSessionTokenForHeader = async () => get().session?.sessionToken;
|
|
447
434
|
const sessionTokenHeader = await resolveSessionTokenForHeader();
|
|
448
435
|
const refreshResponse = await fetch('/api/auth/refresh', {
|
|
449
436
|
method: 'POST',
|
package/package.json
CHANGED
package/src/auth/better-auth.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
|
|
|
16
16
|
import { betterAuth } from 'better-auth';
|
|
17
17
|
import { nextCookies } from 'better-auth/next-js';
|
|
18
18
|
import { toNextJsHandler } from 'better-auth/next-js';
|
|
19
|
+
import { wrapBetterAuthHandler } from './session-response-sanitizer';
|
|
19
20
|
import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
|
|
20
21
|
import { resolveClientKey } from '../lib/client-key';
|
|
21
22
|
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
|
@@ -996,7 +997,10 @@ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => P
|
|
|
996
997
|
if (!isBetterAuthEnabled()) return null;
|
|
997
998
|
|
|
998
999
|
const auth = await getBetterAuthInstance();
|
|
999
|
-
|
|
1000
|
+
// PAY-2217: Better Auth's native endpoints (get-session, list-sessions, sign-in/*, sign-up/*) put the session
|
|
1001
|
+
// token in the body. Strip it at the HTTP boundary here, so every consumer that mounts this handler gets it;
|
|
1002
|
+
// auth.api (server-side) is untouched and still sees the token. See session-response-sanitizer.ts.
|
|
1003
|
+
return wrapBetterAuthHandler(auth as any, toNextJsHandler(auth));
|
|
1000
1004
|
}
|
|
1001
1005
|
|
|
1002
1006
|
/**
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2217: no session token reaches browser JS from Better Auth's own HTTP endpoints.
|
|
3
|
+
*
|
|
4
|
+
* WHAT WAS WRONG. PAY-1481 removed token material from the package's own GET /api/auth/session. Better Auth's native
|
|
5
|
+
* endpoints are a separate surface served by the catch-all mount, and their default bodies carry the session
|
|
6
|
+
* credential identifier:
|
|
7
|
+
* GET /get-session -> { session: { id, token, userId, ... }, user }
|
|
8
|
+
* GET /list-sessions -> [ { id, token, ... } ] (EVERY session of the user, not just this one)
|
|
9
|
+
* POST /sign-in/* -> { token, user, ... }
|
|
10
|
+
* POST /sign-up/* -> { token, user }
|
|
11
|
+
* GET /magic-link/verify (no callbackURL) -> { token, user } (NightHawk G4 on PR 1190, measured on a real instance)
|
|
12
|
+
* and any plugin endpoint (passkey verify, two-factor verify, genericOAuth) answers a session object the same way.
|
|
13
|
+
* `token` is the Redis key of the session. The cookie is token.HMAC and Path B verifies the signature, so a bare
|
|
14
|
+
* token is not a replayable cookie (gate rows B1-B4 measure that), but it is a credential identifier readable by any
|
|
15
|
+
* script on the page, for this session and for every other session the user has. It breaks the PAY-1481 rule.
|
|
16
|
+
*
|
|
17
|
+
* EVERY 2xx JSON BODY IS STRIPPED, not a named list of paths. A list protects the endpoints someone remembered and
|
|
18
|
+
* silently misses the next plugin; the strip is a function of the response, so a new endpoint is covered the day it is
|
|
19
|
+
* mounted. A 2xx JSON body that legitimately needs one of these names to reach the browser would have to be
|
|
20
|
+
* allowlisted here on purpose (none is known: no client in this package reads one).
|
|
21
|
+
*
|
|
22
|
+
* WHERE THE FIX LIVES, AND WHY NOT customSession. Server-side code in this package reads session.session.token from
|
|
23
|
+
* auth.api.getSession() (viability, refresh, status, the OAuth exchange). A customSession plugin or an after-hook
|
|
24
|
+
* runs for those calls too and would strip the token from them. So the strip is applied at the HTTP BOUNDARY only:
|
|
25
|
+
* getBetterAuthHandler() returns handlers wrapped by wrapBetterAuthHandler(), and auth.api stays untouched.
|
|
26
|
+
*
|
|
27
|
+
* WHAT THE BROWSER GETS INSTEAD.
|
|
28
|
+
* - list-sessions rows carry `isCurrent` (computed here against the caller's cookie-resolved session) and `id`.
|
|
29
|
+
* - POST /revoke-session-by-id { id }: revokes ONE of the CALLER's sessions by its id. An id that is not one of the
|
|
30
|
+
* caller's own sessions answers 404 (never 403: do not confirm that another user's session id exists).
|
|
31
|
+
* - Better Auth's native POST /revoke-session { token } still exists and still requires a cookie-resolved session
|
|
32
|
+
* that owns the token, but no browser ever holds a token to send it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Credential-bearing key names, compared case- and separator-insensitively. */
|
|
36
|
+
const STRIP_KEYS = new Set(['token', 'sessiontoken', 'accesstoken', 'refreshtoken', 'idtoken']);
|
|
37
|
+
|
|
38
|
+
const norm = (k: string): string => k.toLowerCase().replace(/[_\-\s]/g, '');
|
|
39
|
+
|
|
40
|
+
/** Deep copy of `value` with every credential-named key removed, at any depth, in objects and arrays. */
|
|
41
|
+
export function stripSessionCredentials(value: unknown): unknown {
|
|
42
|
+
if (Array.isArray(value)) return value.map(stripSessionCredentials);
|
|
43
|
+
if (value !== null && typeof value === 'object') {
|
|
44
|
+
const out: Record<string, unknown> = {};
|
|
45
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
46
|
+
if (STRIP_KEYS.has(norm(k))) continue;
|
|
47
|
+
out[k] = stripSessionCredentials(v);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "/api/auth/get-session" -> "/get-session". Anything not under an /auth/ segment is returned as-is. */
|
|
55
|
+
export function authSubPath(pathname: string): string {
|
|
56
|
+
const i = pathname.indexOf('/api/auth/');
|
|
57
|
+
if (i >= 0) return pathname.slice(i + '/api/auth'.length).replace(/\/+$/, '') || '/';
|
|
58
|
+
return pathname.replace(/\/+$/, '') || '/';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isJson(res: Response): boolean {
|
|
62
|
+
return (res.headers.get('content-type') || '').toLowerCase().includes('application/json');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A Response with a new JSON body and the original status and headers (content-length recomputed by the runtime). */
|
|
66
|
+
function withJsonBody(res: Response, body: unknown): Response {
|
|
67
|
+
const headers = new Headers(res.headers);
|
|
68
|
+
headers.delete('content-length');
|
|
69
|
+
return new Response(JSON.stringify(body), { status: res.status, statusText: res.statusText, headers });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface AuthApi {
|
|
73
|
+
getSession: (opts: { headers: Headers }) => Promise<any>;
|
|
74
|
+
listSessions: (opts: { headers: Headers }) => Promise<any>;
|
|
75
|
+
revokeSession: (opts: { headers: Headers; body: { token: string }; asResponse?: boolean }) => Promise<any>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Sanitise one already-produced Better Auth response. Pure function of (request, response, current token). */
|
|
79
|
+
export async function sanitizeBetterAuthResponse(
|
|
80
|
+
req: Request,
|
|
81
|
+
res: Response,
|
|
82
|
+
currentToken: () => Promise<string | undefined>,
|
|
83
|
+
): Promise<Response> {
|
|
84
|
+
const sub = authSubPath(new URL(req.url).pathname);
|
|
85
|
+
if (!res.ok || !isJson(res)) return res;
|
|
86
|
+
|
|
87
|
+
let body: unknown;
|
|
88
|
+
try {
|
|
89
|
+
body = await res.clone().json();
|
|
90
|
+
} catch {
|
|
91
|
+
return res; // unreadable body: nothing to strip, and do not turn it into a different failure
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (sub === '/list-sessions' && Array.isArray(body)) {
|
|
95
|
+
const cur = await currentToken().catch(() => undefined);
|
|
96
|
+
body = body.map((row: any) =>
|
|
97
|
+
row && typeof row === 'object' ? { ...row, isCurrent: !!cur && row.token === cur } : row,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return withJsonBody(res, stripSessionCredentials(body));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const json = (status: number, body: unknown): Response =>
|
|
104
|
+
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
105
|
+
|
|
106
|
+
/** POST /revoke-session-by-id { id }: revoke one of the caller's own sessions by id. */
|
|
107
|
+
async function revokeById(req: Request, api: AuthApi): Promise<Response> {
|
|
108
|
+
let id: unknown;
|
|
109
|
+
try {
|
|
110
|
+
id = (await req.json())?.id;
|
|
111
|
+
} catch {
|
|
112
|
+
return json(400, { code: 'INVALID_REQUEST', message: 'Body must be JSON { id }' });
|
|
113
|
+
}
|
|
114
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
115
|
+
return json(400, { code: 'INVALID_REQUEST', message: 'id is required' });
|
|
116
|
+
}
|
|
117
|
+
const headers = new Headers(req.headers);
|
|
118
|
+
const rows = await api.listSessions({ headers }).catch(() => null);
|
|
119
|
+
if (rows === null) return json(401, { code: 'UNAUTHORIZED', message: 'No session' });
|
|
120
|
+
// listSessions returns ONLY the caller's own sessions, so a foreign id cannot match: 404, not 403.
|
|
121
|
+
const row = Array.isArray(rows) ? rows.find((r: any) => r && r.id === id && typeof r.token === 'string') : undefined;
|
|
122
|
+
if (!row) return json(404, { code: 'SESSION_NOT_FOUND', message: 'No such session' });
|
|
123
|
+
const res = await api.revokeSession({ headers, body: { token: row.token }, asResponse: true });
|
|
124
|
+
// Pass Better Auth's own answer (and its Set-Cookie when the current session was the one revoked) through.
|
|
125
|
+
return res instanceof Response ? res : json(200, { status: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type Handler = (req: Request) => Promise<Response>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wrap Better Auth's Next.js handlers so no response carries a session credential to the browser.
|
|
132
|
+
* `auth` is the Better Auth instance; its `api` is used server-side only (to find the caller's current session and to
|
|
133
|
+
* revoke by id) and is not modified.
|
|
134
|
+
*/
|
|
135
|
+
export function wrapBetterAuthHandler(
|
|
136
|
+
auth: { api: AuthApi },
|
|
137
|
+
handlers: { GET: Handler; POST: Handler },
|
|
138
|
+
): { GET: Handler; POST: Handler } {
|
|
139
|
+
const currentTokenFor = (req: Request) => async (): Promise<string | undefined> => {
|
|
140
|
+
const s = await auth.api.getSession({ headers: new Headers(req.headers) });
|
|
141
|
+
return s?.session?.token;
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
GET: async (req) => sanitizeBetterAuthResponse(req, await handlers.GET(req), currentTokenFor(req)),
|
|
145
|
+
POST: async (req) => {
|
|
146
|
+
const sub = authSubPath(new URL(req.url).pathname);
|
|
147
|
+
if (sub === '/revoke-session-by-id') return revokeById(req, auth.api);
|
|
148
|
+
return sanitizeBetterAuthResponse(req, await handlers.POST(req), currentTokenFor(req));
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -741,7 +741,16 @@ async function checkViability(
|
|
|
741
741
|
// booleans for unknown/not_configured. The booleans are read ONLY when
|
|
742
742
|
// revocationStatus is ABSENT (idealresume.online's older boolean route).
|
|
743
743
|
sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
|
|
744
|
-
|
|
744
|
+
// PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
|
|
745
|
+
// pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
|
|
746
|
+
// arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
|
|
747
|
+
// Stale is a POSITIVE signal and needs ALL of: this 2xx read, exists === false, a sessionTokenId (every
|
|
748
|
+
// producer sets it only when a cookie resolved to a token: routes/auth/viability.ts, api-handlers/session/
|
|
749
|
+
// viability.ts, refresh-viability.ts 'session_not_found'), and a session cookie on THIS request (plain or
|
|
750
|
+
// __Secure-). A FAILED read never gets here (exists=null -> service_error, PAY-2147); a store error throws
|
|
751
|
+
// inside every producer and answers 5xx or 'unknown' WITHOUT a sessionTokenId; no cookie -> no_session.
|
|
752
|
+
const stale = sessionStatus.exists === false && !!data.sessionTokenId && hasSessionCookie(request);
|
|
753
|
+
sessionPointer.exists = stale ? true : (sessionStatus.exists ?? false);
|
|
745
754
|
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
746
755
|
sessionPointer.expired = data.accessTokenExpired || false;
|
|
747
756
|
sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
|
|
@@ -815,7 +824,12 @@ async function handleAllow(
|
|
|
815
824
|
): Promise<NextResponse> {
|
|
816
825
|
const isPublic = isUnauthenticatedRoute(pathname);
|
|
817
826
|
|
|
818
|
-
|
|
827
|
+
// PAY-2216: RBAC needs a session that viability CONFIRMED. A stale /api request (pointer.exists only says the
|
|
828
|
+
// cookie is present, status.exists is false) is allowed through so the route handler answers its own 401 - not an
|
|
829
|
+
// RBAC 401 here. TRUTHY, not `=== true` (NightHawk 71960): status.exists carries the producer's JSON value as-is,
|
|
830
|
+
// and a consumer route may answer authenticated: 1 / "true"; `=== true` skipped RBAC for those healthy sessions
|
|
831
|
+
// (fail-OPEN). Truthy keeps 4.5.3's RBAC coverage exactly and still excludes stale (false) and a failed read (null).
|
|
832
|
+
if (isRBACEnabled() && !isPublic && sessionPointer.exists && sessionStatus.exists) {
|
|
819
833
|
// Skip RBAC for error/fallback pages (prevent redirect loops) and app-configured exempt paths
|
|
820
834
|
if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p)) ||
|
|
821
835
|
rbacExemptPaths.some(p => pathname.startsWith(p))) {
|
package/src/stores/authStore.ts
CHANGED
|
@@ -637,23 +637,13 @@ export const useAuthStore = create<AuthStore>()(
|
|
|
637
637
|
// COORDINATED REFRESH: The server now handles distributed locking
|
|
638
638
|
// We just need to make the refresh call and let the server coordinate
|
|
639
639
|
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
token = (s as any)?.sessionToken;
|
|
648
|
-
if (!token) {
|
|
649
|
-
await new Promise(r => setTimeout(r, 150));
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
} catch {
|
|
653
|
-
authLogger.warn('[AuthStore] Failed to resolve session token from Better Auth during refresh');
|
|
654
|
-
}
|
|
655
|
-
return token;
|
|
656
|
-
};
|
|
640
|
+
// PAY-2217: the session token is NOT read from Better Auth's getSession() any more. The HTTP body no
|
|
641
|
+
// longer carries it (wrapBetterAuthHandler), and this read was already dead: getSession() returns
|
|
642
|
+
// { session: { token } }, never a top-level `sessionToken`, so the 3 x 150 ms retry loop that used to sit
|
|
643
|
+
// here always ran to exhaustion. The server resolves the session from the cookie and REJECTS an
|
|
644
|
+
// x-session-token header when it cannot (refresh.ts, 246007), so the header is advisory at best.
|
|
645
|
+
const resolveSessionTokenForHeader = async (): Promise<string | undefined> =>
|
|
646
|
+
get().session?.sessionToken as string | undefined;
|
|
657
647
|
|
|
658
648
|
const sessionTokenHeader = await resolveSessionTokenForHeader();
|
|
659
649
|
|