@payez/next-mvp 4.1.21 → 4.4.0
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/POST-INSTALL.md +408 -0
- package/README.md +7 -0
- package/dist/api-handlers/account/recovery/initiate.js +35 -2
- package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/send-code.js +35 -2
- package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/verify-code.js +35 -2
- package/dist/api-handlers/account/reset-password.js +35 -2
- package/dist/api-handlers/account/validate-password.js +21 -3
- package/dist/api-handlers/admin/analytics.js +2 -1
- package/dist/api-handlers/admin/audit.js +2 -1
- package/dist/api-handlers/admin/sessions.js +2 -1
- package/dist/api-handlers/admin/stats.js +2 -1
- package/dist/api-handlers/admin/users.js +2 -1
- package/dist/api-handlers/admin/vibe-data.js +2 -1
- package/dist/api-handlers/auth/jwks.js +10 -0
- package/dist/api-handlers/auth/login.js +5 -2
- package/dist/api-handlers/auth/passkey-signin.js +16 -1
- package/dist/api-handlers/auth/refresh.d.ts +23 -3
- package/dist/api-handlers/auth/refresh.js +107 -16
- package/dist/api-handlers/auth/signout.js +3 -2
- package/dist/api-handlers/auth/verify-code.js +5 -2
- package/dist/api-handlers/session/viability.js +27 -1
- package/dist/api-handlers/test/force-expire.d.ts +29 -15
- package/dist/api-handlers/test/force-expire.js +109 -14
- package/dist/auth/better-auth.d.ts +263 -6
- package/dist/auth/better-auth.js +437 -59
- package/dist/auth/utils/idp-client.js +1 -1
- package/dist/client/AuthContext.js +49 -17
- package/dist/client/better-auth-client.d.ts +62 -62
- package/dist/components/account/UserAvatarMenu.js +12 -0
- package/dist/components/auth/ModeAwareLoginPage.js +10 -8
- package/dist/components/auth/ModeAwareSignupPage.js +5 -2
- package/dist/components/auth/TraditionalAuthSection.js +5 -1
- package/dist/hooks/useAuthSettings.js +14 -2
- package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
- package/dist/hooks/usePublicAuthSettings.js +28 -14
- package/dist/lib/client-key.d.ts +61 -0
- package/dist/lib/client-key.js +72 -0
- package/dist/lib/demo-mode.js +2 -1
- package/dist/lib/ensure-fresh-access-token.js +5 -1
- package/dist/lib/idp-client-config.d.ts +25 -2
- package/dist/lib/idp-client-config.js +381 -36
- package/dist/lib/idp-fetch.d.ts +2 -0
- package/dist/lib/idp-fetch.js +31 -1
- package/dist/lib/idp-url-guard.d.ts +29 -0
- package/dist/lib/idp-url-guard.js +38 -0
- package/dist/lib/redis.js +77 -5
- package/dist/lib/session-cookie-shape.d.ts +69 -0
- package/dist/lib/session-cookie-shape.js +126 -0
- package/dist/lib/session-store.d.ts +35 -2
- package/dist/lib/session-store.js +135 -4
- package/dist/lib/single-flight.d.ts +35 -0
- package/dist/lib/single-flight.js +30 -0
- package/dist/lib/token-lifecycle.js +12 -0
- package/dist/middleware/create-middleware.js +12 -0
- package/dist/models/SessionModel.d.ts +41 -0
- package/dist/models/SessionModel.js +9 -0
- package/dist/pages/admin-login/page.d.ts +1 -1
- package/dist/pages/admin-login/page.js +5 -41
- package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
- package/dist/pages/security/EnhancedSecurityPage.js +42 -2
- package/dist/pages/security/index.d.ts +1 -0
- package/dist/pages/security/index.js +3 -0
- package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
- package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
- package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
- package/dist/pages/test-env/JwtInspectPage.js +166 -7
- package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
- package/dist/pages/test-env/RefreshTokenPage.js +22 -6
- package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
- package/dist/pages/test-env/TestEnvPage.js +16 -4
- package/dist/pages/test-env/guarded.d.ts +4 -0
- package/dist/pages/test-env/guarded.js +86 -0
- package/dist/pages/test-env/index.d.ts +50 -7
- package/dist/pages/test-env/index.js +58 -12
- package/dist/routes/auth/logout.js +3 -2
- package/dist/routes/auth/refresh.js +27 -1
- package/dist/routes/auth/session.d.ts +12 -3
- package/dist/routes/auth/session.js +92 -9
- package/dist/routes/auth/settings.d.ts +34 -4
- package/dist/routes/auth/settings.js +87 -33
- package/dist/routes/auth/viability.d.ts +1 -1
- package/dist/routes/auth/viability.js +122 -11
- package/dist/server/auth-guard.js +22 -15
- package/dist/server/auth.d.ts +177 -20
- package/dist/server/auth.js +34 -75
- package/dist/server/decode-session.js +84 -10
- package/dist/types/auth.d.ts +18 -0
- package/package.json +969 -958
- package/scripts/check-internal-url-usage.sh +73 -73
- package/scripts/check-publish-health.mjs +120 -0
- package/scripts/dev-broker.ps1 +71 -6
- package/scripts/print-client-config.mjs +162 -0
- package/scripts/server-only-stub.cjs +4 -0
- package/src/api-handlers/account/recovery/initiate.ts +35 -2
- package/src/api-handlers/account/recovery/send-code.ts +36 -2
- package/src/api-handlers/account/recovery/verify-code.ts +36 -2
- package/src/api-handlers/account/reset-password.ts +35 -2
- package/src/api-handlers/account/validate-password.ts +22 -3
- package/src/api-handlers/admin/analytics.ts +2 -1
- package/src/api-handlers/admin/audit.ts +2 -1
- package/src/api-handlers/admin/sessions.ts +2 -1
- package/src/api-handlers/admin/stats.ts +2 -1
- package/src/api-handlers/admin/users.ts +2 -1
- package/src/api-handlers/admin/vibe-data.ts +2 -1
- package/src/api-handlers/auth/jwks.ts +10 -0
- package/src/api-handlers/auth/login.ts +5 -2
- package/src/api-handlers/auth/passkey-signin.ts +112 -98
- package/src/api-handlers/auth/refresh.ts +112 -15
- package/src/api-handlers/auth/signout.ts +4 -3
- package/src/api-handlers/auth/verify-code.ts +5 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
- package/src/api-handlers/mfa/backup-codes.ts +41 -41
- package/src/api-handlers/mfa/methods.ts +46 -46
- package/src/api-handlers/mfa/totp-confirm.ts +42 -42
- package/src/api-handlers/mfa/totp-enroll.ts +42 -42
- package/src/api-handlers/passkeys/list.ts +31 -31
- package/src/api-handlers/passkeys/manage.ts +54 -54
- package/src/api-handlers/passkeys/register-options.ts +42 -42
- package/src/api-handlers/passkeys/register-verify.ts +41 -41
- package/src/api-handlers/session/viability.ts +29 -1
- package/src/api-handlers/test/force-expire.ts +118 -13
- package/src/auth/better-auth.ts +486 -51
- package/src/auth/utils/idp-client.ts +1 -1
- package/src/client/AuthContext.tsx +55 -19
- package/src/components/account/UserAvatarMenu.tsx +11 -0
- package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
- package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
- package/src/components/auth/TraditionalAuthSection.tsx +5 -1
- package/src/hooks/useAuthSettings.ts +14 -2
- package/src/hooks/usePublicAuthSettings.ts +38 -16
- package/src/lib/client-key.ts +88 -0
- package/src/lib/demo-mode.ts +2 -1
- package/src/lib/ensure-fresh-access-token.ts +5 -1
- package/src/lib/idp-client-config.ts +399 -36
- package/src/lib/idp-fetch.ts +36 -1
- package/src/lib/idp-url-guard.ts +37 -0
- package/src/lib/redis.ts +84 -6
- package/src/lib/session-cookie-shape.ts +184 -0
- package/src/lib/session-store.ts +152 -6
- package/src/lib/single-flight.ts +64 -0
- package/src/lib/token-lifecycle.ts +12 -0
- package/src/middleware/create-middleware.ts +11 -0
- package/src/models/SessionModel.ts +49 -0
- package/src/pages/admin-login/page.tsx +17 -127
- package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
- package/src/pages/security/index.ts +3 -0
- package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
- package/src/pages/test-env/JwtInspectPage.tsx +235 -20
- package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
- package/src/pages/test-env/TestEnvPage.tsx +16 -4
- package/src/pages/test-env/guarded.tsx +81 -0
- package/src/pages/test-env/index.ts +59 -7
- package/src/routes/auth/logout.ts +4 -3
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +30 -1
- package/src/routes/auth/session.ts +98 -9
- package/src/routes/auth/settings.ts +144 -42
- package/src/routes/auth/viability.ts +129 -11
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/server/auth-guard.ts +24 -17
- package/src/server/auth.ts +37 -73
- package/src/server/decode-session.ts +95 -11
- package/src/templates/instrumentation.ts +21 -2
- package/src/types/auth.ts +20 -0
- package/dist/lib/auth-secret.d.ts +0 -17
- package/dist/lib/auth-secret.js +0 -116
- package/src/lib/auth-secret.ts +0 -137
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Name the missing variable instead of letting undici say "Failed to parse URL".
|
|
3
|
+
*
|
|
4
|
+
* Every caller builds its target as `${ENV_CONFIG.IDP_URL}/api/...`. IDP_URL is a
|
|
5
|
+
* BARE PASSTHROUGH in src/config/env.ts - no fallback, no validation - so when it
|
|
6
|
+
* is missing the interpolation yields a url that is not absolute. BOTH spellings
|
|
7
|
+
* of "missing" are caught here, which is why this tests for an absolute url
|
|
8
|
+
* rather than for a leading slash:
|
|
9
|
+
* IDP_URL= (empty, as the quickstart now ships) -> "/api/Account/profile"
|
|
10
|
+
* IDP_URL unset (absent from the env entirely) -> "undefined/api/Account/profile"
|
|
11
|
+
* A leading-slash test would have caught only the first.
|
|
12
|
+
*
|
|
13
|
+
* This already fails CLOSED: undici rejects a relative url, so the guard changes
|
|
14
|
+
* no outcome - it only makes the existing failure self-describing. It does NOT
|
|
15
|
+
* silently retarget the app's own origin. (QAPert tested that hypothesis and
|
|
16
|
+
* reported it disproved, mail 48139.)
|
|
17
|
+
*
|
|
18
|
+
* WHY THIS IS A LEAF MODULE WITH NO IMPORTS, AND NOT A THROW IN env.ts:
|
|
19
|
+
* - env.ts is the wrong home: ENV_CONFIG is imported by 26 modules and
|
|
20
|
+
* re-exported from the package index, so a module-load throw would crash
|
|
21
|
+
* consumers that only render components and never call the IDP. QAPert
|
|
22
|
+
* proposed exactly that (48139); it is the one shape this cannot take.
|
|
23
|
+
* - idp-fetch.ts is the wrong home for the SHARED copy: importing it pulls
|
|
24
|
+
* server-only, the session store and the logger, so the three direct-fetch
|
|
25
|
+
* callers would drag that whole chain in for one regex - and the predicate
|
|
26
|
+
* could not be unit-tested without a live server environment.
|
|
27
|
+
* Keep this file dependency-free so both stay true.
|
|
28
|
+
*/
|
|
29
|
+
export function assertAbsoluteIdpUrl(targetUrl: string): void {
|
|
30
|
+
if (!/^https?:\/\//i.test(targetUrl)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`IDP_URL is not set. Refusing to call the IDP with the non-absolute URL "${targetUrl}". ` +
|
|
33
|
+
`Set IDP_URL in your environment (dev: http://10.0.0.93:32785). ` +
|
|
34
|
+
`Never set it to a production host in a development worktree.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/lib/redis.ts
CHANGED
|
@@ -3,16 +3,94 @@ import Redis, { RedisOptions } from 'ioredis';
|
|
|
3
3
|
|
|
4
4
|
let client: Redis | null = null;
|
|
5
5
|
|
|
6
|
+
// ── 183496: fail FAST and NAMED, never a 20s spinner ───────────────────────
|
|
7
|
+
// The DEFAULTS were the defect (Nextpert-Scout's killable-proxy measurement,
|
|
8
|
+
// BAPert 22626): no commandTimeout, so a command against a cut Redis path
|
|
9
|
+
// hung 20s+, and maxRetriesPerRequest defaults to 20, so every command was
|
|
10
|
+
// retried through every reconnect attempt. In the room that reads as "the
|
|
11
|
+
// app hung", not "Redis blipped" - and self-heal was instant all along, so
|
|
12
|
+
// the failure MODE was the whole defect.
|
|
13
|
+
//
|
|
14
|
+
// commandTimeout covers OFFLINE-QUEUED commands too (ioredis 5.8.2 Redis.js:
|
|
15
|
+
// command.setTimeout runs BEFORE the queue/writable check), which makes it
|
|
16
|
+
// the bounded offline queue the card asks for: no command, queued or sent,
|
|
17
|
+
// outlives 3s. The queue itself stays ENABLED - a transient blip still
|
|
18
|
+
// self-heals inside the window; only the failure mode changes. The throw
|
|
19
|
+
// lands in with-auth's catch and comes out a fast 5xx, not a spinner.
|
|
20
|
+
const REDIS_OPTIONS: RedisOptions = {
|
|
21
|
+
commandTimeout: 3000,
|
|
22
|
+
maxRetriesPerRequest: 1,
|
|
23
|
+
};
|
|
24
|
+
|
|
6
25
|
function createClient(): Redis {
|
|
7
26
|
const url = process.env.REDIS_URL;
|
|
8
27
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
28
|
+
// ── THE COMMENT THAT USED TO BE HERE WAS FALSE (Nextpert-Scout 35159). ──
|
|
29
|
+
//
|
|
30
|
+
// It said: "No REDIS_URL set, create a client that will fail fast."
|
|
31
|
+
// IT DOES NOT FAIL. Measured, not read:
|
|
32
|
+
//
|
|
33
|
+
// new Redis({ commandTimeout, maxRetriesPerRequest, lazyConnect: true })
|
|
34
|
+
// -> options.host === 'localhost', options.port === 6379
|
|
35
|
+
// (ioredis DEFAULT_REDIS_OPTIONS: port 6379, host "localhost")
|
|
36
|
+
//
|
|
37
|
+
// `lazyConnect` only DEFERS the connection to the first command; it does not
|
|
38
|
+
// prevent one. So with REDIS_URL absent this client silently targets
|
|
39
|
+
// localhost:6379. Whether that is a loud failure or a SILENT CONNECTION TO
|
|
40
|
+
// THE WRONG STORE depends entirely on what happens to be listening on the
|
|
41
|
+
// local loopback - which is not a property this module controls, and is
|
|
42
|
+
// exactly the kind of thing that differs between a container and a dev box.
|
|
43
|
+
//
|
|
44
|
+
// This mattered operationally: I told the team that deleting REDIS_URL from a
|
|
45
|
+
// configmap would be fail-closed, and recommended it as a hardening step for
|
|
46
|
+
// the 249808 remediation. That advice was wrong and rested on this comment.
|
|
47
|
+
// A loopback-exempt firewall rule also does not stop a client that has
|
|
48
|
+
// fallen back to loopback.
|
|
49
|
+
//
|
|
50
|
+
// Corrected rather than deleted, because the comment's INTENT was right and
|
|
51
|
+
// is worth preserving as a stated goal: an absent REDIS_URL SHOULD fail
|
|
52
|
+
// loudly. Making it actually do so means changing behaviour at module load
|
|
53
|
+
// (`const redis = getRedis()` at the bottom of this file runs on import), so
|
|
54
|
+
// it is carded rather than changed at speed - throwing on import would break
|
|
55
|
+
// every consumer that imports this module without needing Redis.
|
|
56
|
+
//
|
|
57
|
+
// What IS done here: the misconfiguration is now ANNOUNCED, so it can never
|
|
58
|
+
// again be silent even though it is not yet fatal.
|
|
59
|
+
if (!url || url.trim() === '') {
|
|
60
|
+
console.error(
|
|
61
|
+
'[redis] MISCONFIGURED: REDIS_URL is unset or empty. ' +
|
|
62
|
+
'This client will fall back to ioredis defaults (localhost:6379) and may ' +
|
|
63
|
+
'connect to whatever is listening there, or fail on first command. ' +
|
|
64
|
+
'It does NOT fail fast. Set REDIS_URL explicitly.',
|
|
65
|
+
);
|
|
12
66
|
}
|
|
13
67
|
|
|
14
|
-
|
|
15
|
-
|
|
68
|
+
const instance = url && url.trim() !== ''
|
|
69
|
+
// Use a standard configuration for better Docker compatibility
|
|
70
|
+
? new Redis(url, REDIS_OPTIONS)
|
|
71
|
+
// NOTE: this does NOT fail fast - see the block above. It targets
|
|
72
|
+
// localhost:6379 by ioredis default.
|
|
73
|
+
: new Redis({ ...REDIS_OPTIONS, lazyConnect: true });
|
|
74
|
+
|
|
75
|
+
// Loud AND load-bearing: an ioredis 'error' event with NO listener is an
|
|
76
|
+
// unhandled EventEmitter error, which can take the whole PROCESS down -
|
|
77
|
+
// a Redis blip becoming a dead server is worse than the spinner was.
|
|
78
|
+
instance.on('error', (err) => {
|
|
79
|
+
console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${err instanceof Error ? err.message : String(err)}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// 185121 line 3: the 16:05 event left no named trigger because ONLY 'error'
|
|
83
|
+
// was listened for. A reconnect or a clean end is silent in the artifact,
|
|
84
|
+
// so a session-death window could not be correlated with a redis event.
|
|
85
|
+
// One line per event; a recurrence now says whether redis moved at all.
|
|
86
|
+
instance.on('reconnecting', (delayMs: number) => {
|
|
87
|
+
console.warn(`[redis] RECONNECTING ${new Date().toISOString()}: next attempt in ${delayMs}ms`);
|
|
88
|
+
});
|
|
89
|
+
instance.on('end', () => {
|
|
90
|
+
console.error(`[redis] CONNECTION ENDED ${new Date().toISOString()}: no further reconnects — every session read now fails or misses`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return instance;
|
|
16
94
|
}
|
|
17
95
|
|
|
18
96
|
export function getRedis(): Redis {
|
|
@@ -24,4 +102,4 @@ export function getRedis(): Redis {
|
|
|
24
102
|
|
|
25
103
|
const redis = getRedis();
|
|
26
104
|
export { redis };
|
|
27
|
-
export default redis;
|
|
105
|
+
export default redis;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getJwtCookieName,
|
|
3
|
+
getSecureSessionCookieName,
|
|
4
|
+
getSessionCookieName,
|
|
5
|
+
} from './app-slug';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Session-cookie SHAPE detection — shared by every fallback path that must
|
|
9
|
+
* recover a Redis session token from a raw session-cookie value without
|
|
10
|
+
* going through Better Auth's `auth.api.getSession()`.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS EXISTS (183047 post-mortem, BAPert/QAPert/Scout, 2026-08-08):
|
|
13
|
+
*
|
|
14
|
+
* Two cookie shapes exist in this codebase's history, and until now no
|
|
15
|
+
* fallback path recognized both:
|
|
16
|
+
*
|
|
17
|
+
* - JWS (3 dot-separated segments): the legacy NextAuth-era format. The
|
|
18
|
+
* session token lives in the payload's `sessionToken` claim, base64-JSON
|
|
19
|
+
* encoded in the middle segment.
|
|
20
|
+
* - BA-hmac (2 segments — exactly ONE dot): Better Auth's own hand-rolled
|
|
21
|
+
* cookie, `{token}.{base64(hmac)}` (see better-auth.ts's
|
|
22
|
+
* CreateBetterAuthInstanceOptions.plugins doc comment). The token here
|
|
23
|
+
* IS the first segment verbatim — there is no claim to decode.
|
|
24
|
+
*
|
|
25
|
+
* Every fallback that existed before this file assumed the JWS shape
|
|
26
|
+
* unconditionally:
|
|
27
|
+
* - decode-session.ts's legacy path called jose's `jwtVerify()` directly on
|
|
28
|
+
* the cookie value. `jwtVerify` requires a genuine compact JWS and throws
|
|
29
|
+
* "Invalid Compact JWS" on a 2-segment value — secret-independent, before
|
|
30
|
+
* any signature is even checked.
|
|
31
|
+
* - auth.ts's `decodeJwtBody()` required `parts.length === 3` and returned
|
|
32
|
+
* null otherwise.
|
|
33
|
+
*
|
|
34
|
+
* Since Better Auth is the only session minter in production, EVERY
|
|
35
|
+
* BA-shaped cookie hit both fallbacks' shape gate and failed structurally.
|
|
36
|
+
* That meant any single BA-primary miss (secondary-storage eviction,
|
|
37
|
+
* in-flight secret divergence, cookie-cache disabled) had no working
|
|
38
|
+
* fallback at all — a single cookie's failure read as total session death
|
|
39
|
+
* instead of degrading to one clean Redis-canonical lookup (or a genuine
|
|
40
|
+
* 401 if the record really is gone).
|
|
41
|
+
*
|
|
42
|
+
* No signature is verified for the BA-hmac shape here — same posture the
|
|
43
|
+
* pre-existing JWS fallback already took (see auth.ts's `getSession` Path B
|
|
44
|
+
* comment): Redis is the liveness authority downstream, so a forged token
|
|
45
|
+
* still has to land on a live session record to do anything. This function
|
|
46
|
+
* only recovers the CANDIDATE token; callers must still look it up.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export type SessionCookieShape = 'jws' | 'ba-hmac';
|
|
50
|
+
|
|
51
|
+
export interface ExtractedSessionToken {
|
|
52
|
+
token: string;
|
|
53
|
+
shape: SessionCookieShape;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract a candidate session token from a raw session-cookie value,
|
|
58
|
+
* regardless of which minter produced it.
|
|
59
|
+
*
|
|
60
|
+
* Returns null if the value doesn't match either known shape, or (for the
|
|
61
|
+
* JWS shape) if the payload segment doesn't decode to a `sessionToken`
|
|
62
|
+
* claim.
|
|
63
|
+
*/
|
|
64
|
+
export function extractSessionTokenFromCookieValue(rawValue: string | null | undefined): ExtractedSessionToken | null {
|
|
65
|
+
if (!rawValue) return null;
|
|
66
|
+
|
|
67
|
+
const segments = rawValue.split('.');
|
|
68
|
+
|
|
69
|
+
if (segments.length === 3) {
|
|
70
|
+
// JWS shape: base64-decode the payload segment (part 1), read the
|
|
71
|
+
// sessionToken claim. No signature check — see file header.
|
|
72
|
+
try {
|
|
73
|
+
const decoded = Buffer.from(segments[1], 'base64').toString('utf-8');
|
|
74
|
+
const payload = JSON.parse(decoded);
|
|
75
|
+
if (payload && typeof payload === 'object' && typeof payload.sessionToken === 'string') {
|
|
76
|
+
return { token: payload.sessionToken, shape: 'jws' };
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// fall through to null
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (segments.length === 2 && segments[0] && segments[1]) {
|
|
85
|
+
// BA-hmac shape: `{token}.{base64(hmac)}` — the token is the first
|
|
86
|
+
// segment verbatim, no decoding required.
|
|
87
|
+
return { token: segments[0], shape: 'ba-hmac' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Extract the session token from the Better Auth session cookie WITHOUT
|
|
94
|
+
* going through Better Auth's `auth.api.getSession()`. Handles both
|
|
95
|
+
* single-cookie and chunked-cookie cases (Better Auth chunks long values
|
|
96
|
+
* across `{name}.0`, `{name}.1`, …), and BOTH cookie shapes the codebase
|
|
97
|
+
* has minted — JWS (legacy NextAuth) and BA-hmac (current Better Auth,
|
|
98
|
+
* `{token}.{hmac}`) — via `extractSessionTokenFromCookieValue` (183047:
|
|
99
|
+
* the shape-blind version of this function is what turned a single
|
|
100
|
+
* BA-primary miss into total, secret-independent fallback failure for
|
|
101
|
+
* every current cookie, since Better Auth never mints the JWS shape this
|
|
102
|
+
* used to require). No signature check — Redis is the liveness gate, so
|
|
103
|
+
* an attacker forging a token still has to land on a real Redis session
|
|
104
|
+
* (infeasible against high-entropy UUIDs). Returns null if no cookie is
|
|
105
|
+
* present or neither known shape matches. */
|
|
106
|
+
export function extractSessionTokenFromCookie(request: Request): string | null {
|
|
107
|
+
const cookieHeader = request.headers.get('cookie');
|
|
108
|
+
if (!cookieHeader) return null;
|
|
109
|
+
|
|
110
|
+
// 240654: read EVERY name this session cookie is minted under, not just the
|
|
111
|
+
// bare one.
|
|
112
|
+
//
|
|
113
|
+
// getSessionCookieName() is ALWAYS `{slug}.session-token`. But the package's
|
|
114
|
+
// own read contract is getJwtCookieName() — "the cookie name that getToken()
|
|
115
|
+
// should use to READ" — and in production that resolves to the
|
|
116
|
+
// `__Secure-`-prefixed name. The admin-login door (238225) mints the legacy
|
|
117
|
+
// JWS session under the __Secure- name in prod, exactly per that contract.
|
|
118
|
+
//
|
|
119
|
+
// So this resolver — the Path-B fallback, and the ONLY resolver for legacy
|
|
120
|
+
// JWS sessions — could not see a production session at all. Measured live on
|
|
121
|
+
// portal.keelauth.com: the session was minted and present in Redis at
|
|
122
|
+
// `idp_admin_ui_graft:sess:{token}`, yet invisible to viability and
|
|
123
|
+
// middleware, so every page bounced to /sign-in. Dev survived because in
|
|
124
|
+
// development both helpers return the SAME string, which is precisely why
|
|
125
|
+
// the dev-93 walks passed while prod died.
|
|
126
|
+
//
|
|
127
|
+
// Checking BOTH names rather than just swapping to getJwtCookieName(): the
|
|
128
|
+
// graft currently double-writes both cookie names as an app-local mitigation
|
|
129
|
+
// (graft 8a5ac21), and a rollout necessarily spans sessions minted under
|
|
130
|
+
// either name. Reading both is correct before, during and after that
|
|
131
|
+
// double-write is retired, and costs one extra map lookup.
|
|
132
|
+
const candidateNames = Array.from(
|
|
133
|
+
new Set([getJwtCookieName(), getSecureSessionCookieName(), getSessionCookieName()]),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Parse the header ONCE, then resolve per candidate. Chunked cookies are
|
|
137
|
+
// kept per-name: Better Auth splits long values across `{name}.0/.1/.2…`,
|
|
138
|
+
// and chunks from different cookie names must never be concatenated.
|
|
139
|
+
const direct = new Map<string, string>();
|
|
140
|
+
const chunksByName = new Map<string, Array<{ idx: number; value: string }>>();
|
|
141
|
+
|
|
142
|
+
for (const part of cookieHeader.split(';')) {
|
|
143
|
+
const trimmed = part.trim();
|
|
144
|
+
const eq = trimmed.indexOf('=');
|
|
145
|
+
if (eq === -1) continue;
|
|
146
|
+
const k = trimmed.slice(0, eq);
|
|
147
|
+
const v = trimmed.slice(eq + 1);
|
|
148
|
+
|
|
149
|
+
for (const name of candidateNames) {
|
|
150
|
+
if (k === name) {
|
|
151
|
+
if (!direct.has(name)) direct.set(name, decodeURIComponent(v));
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (k.startsWith(`${name}.`)) {
|
|
155
|
+
const idx = parseInt(k.split('.').pop() || '0', 10);
|
|
156
|
+
if (Number.isFinite(idx)) {
|
|
157
|
+
const list = chunksByName.get(name) ?? [];
|
|
158
|
+
list.push({ idx, value: decodeURIComponent(v) });
|
|
159
|
+
chunksByName.set(name, list);
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let rawJwt: string | null = null;
|
|
167
|
+
for (const name of candidateNames) {
|
|
168
|
+
const whole = direct.get(name);
|
|
169
|
+
if (whole) {
|
|
170
|
+
rawJwt = whole;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
const parts = chunksByName.get(name);
|
|
174
|
+
if (parts && parts.length > 0) {
|
|
175
|
+
parts.sort((a, b) => a.idx - b.idx);
|
|
176
|
+
rawJwt = parts.map(c => c.value).join('');
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!rawJwt) return null;
|
|
182
|
+
|
|
183
|
+
return extractSessionTokenFromCookieValue(rawJwt)?.token ?? null;
|
|
184
|
+
}
|
package/src/lib/session-store.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
import redis from './redis';
|
|
12
12
|
import { randomBytes } from 'crypto';
|
|
13
13
|
import { SessionData } from '../models/SessionModel';
|
|
14
|
-
import { getSessionPrefix, getRefreshLockPrefix } from './app-slug';
|
|
15
|
-
import { extractKidFromToken } from '../auth/utils/token-utils';
|
|
14
|
+
import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
|
|
15
|
+
import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
16
16
|
|
|
17
17
|
// Re-export SessionData for consumers
|
|
18
18
|
export type { SessionData } from '../models/SessionModel';
|
|
@@ -98,15 +98,37 @@ export async function getSession(sessionToken: string): Promise<SessionData | nu
|
|
|
98
98
|
* Better Auth uses key format: ba:{appSlug}:{token}
|
|
99
99
|
*
|
|
100
100
|
* @param sessionToken The session token to look up.
|
|
101
|
-
* @param appSlug The app slug
|
|
101
|
+
* @param appSlug The app slug. Defaults to getAppSlug() - the SAME source Better Auth
|
|
102
|
+
* writes under - so reader and writer cannot disagree. Pass explicitly
|
|
103
|
+
* only if a consumer sets createBetterAuthInstance's clientSlug to
|
|
104
|
+
* something other than getAppSlug().
|
|
102
105
|
* @returns The session data, or null if not found.
|
|
103
106
|
*/
|
|
104
107
|
export async function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null> {
|
|
105
108
|
if (!sessionToken) {
|
|
106
109
|
return null;
|
|
107
110
|
}
|
|
108
|
-
//
|
|
109
|
-
|
|
111
|
+
// 241875 / 213416 (partially-applied fix, completed here): this line used to be
|
|
112
|
+
// appSlug || process.env.CLIENT_ID || 'idealvibe_online'
|
|
113
|
+
// which is a THIRD slug resolution, disagreeing with the two canonical ones in
|
|
114
|
+
// this same file - getSessionKey() uses getSessionPrefix() (i.e. getAppSlug()),
|
|
115
|
+
// and deleteSessionEverywhere() uses `appSlug || getAppSlug()`.
|
|
116
|
+
//
|
|
117
|
+
// WRITER vs READER, same keyspace, different answers. Better Auth WRITES
|
|
118
|
+
// ba:{getAppSlug()}:{token} - deleteSessionEverywhere's own docstring records
|
|
119
|
+
// that it mirrors createBetterAuthInstance's `config.clientSlug || getAppSlug()`.
|
|
120
|
+
// This function READ ba:{CLIENT_ID or 'idealvibe_online'}:{token}.
|
|
121
|
+
//
|
|
122
|
+
// MEASURED IN PRODUCTION (keelauth-portal-config, 2026-08-24): APP_SLUG is
|
|
123
|
+
// declared and CLIENT_ID is NOT. So the old chain fell through to the literal and
|
|
124
|
+
// every one of this function's 14 call sites looked under a key Better Auth never
|
|
125
|
+
// writes. It could not match, and it returned null silently rather than failing -
|
|
126
|
+
// so the BA-session fallback documented in 4.1.3 (viability falling back to BA
|
|
127
|
+
// Redis when the canonical store misses) has been a safety net with a hole in it.
|
|
128
|
+
//
|
|
129
|
+
// 213416 fixed this exact writer/reader split at deleteSessionEverywhere and did
|
|
130
|
+
// not reach here. Using getAppSlug() makes the two agree by construction.
|
|
131
|
+
const slug = appSlug || getAppSlug();
|
|
110
132
|
const key = getBetterAuthSessionKey(sessionToken, slug);
|
|
111
133
|
const json = await redis.get(key);
|
|
112
134
|
if (!json) {
|
|
@@ -125,6 +147,11 @@ export async function getBetterAuthSession(sessionToken: string, appSlug?: strin
|
|
|
125
147
|
idpRefreshToken: data.idpTokens?.idpRefreshToken,
|
|
126
148
|
idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
|
|
127
149
|
mfaVerified: data.idpTokens?.mfaVerified ?? false,
|
|
150
|
+
// R807 (362550): the `?? false` above is CORRECT and stays - fail-closed for
|
|
151
|
+
// every security consumer. This records the bit it destroys, for viability
|
|
152
|
+
// only. Note `!== undefined`, not a truthiness test: an explicit `false` IS
|
|
153
|
+
// a signal, and is precisely the case viability needs to see.
|
|
154
|
+
mfaSignalPresent: data.idpTokens?.mfaVerified !== undefined,
|
|
128
155
|
// Cards 141688/141741 — the IDP's routing decision, carried not derived.
|
|
129
156
|
// `null` is a real value here (IDP said nothing is outstanding), so it
|
|
130
157
|
// must stay distinguishable from `undefined` (we never captured it —
|
|
@@ -226,6 +253,31 @@ export async function deleteSession(sessionToken: string): Promise<void> {
|
|
|
226
253
|
await redis.del(key, versionKey);
|
|
227
254
|
}
|
|
228
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Deletes a session from EVERY store it can live in (181922): the canonical
|
|
258
|
+
* {slug}:sess:{token} + version key AND the Better Auth secondary
|
|
259
|
+
* ba:{slug}:{token}. Deleting only one lets the survivor resurrect the
|
|
260
|
+
* session — getSession's Path B reads the ba: record when the canonical is
|
|
261
|
+
* gone, so a replayed cookie still authenticated after "logout" (QAPert-
|
|
262
|
+
* NightHawk, exercised on :4702). Every sign-out entry point must use THIS,
|
|
263
|
+
* not deleteSession.
|
|
264
|
+
*
|
|
265
|
+
* The ba: key slug mirrors createBetterAuthInstance's
|
|
266
|
+
* `config.clientSlug || getAppSlug()` — on every current consumer those are
|
|
267
|
+
* the same value (the client-id aliases make it so). If a consumer ever sets
|
|
268
|
+
* them differently, pass appSlug explicitly.
|
|
269
|
+
*/
|
|
270
|
+
export async function deleteSessionEverywhere(sessionToken: string, appSlug?: string): Promise<void> {
|
|
271
|
+
if (!sessionToken) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await redis.del(
|
|
275
|
+
getSessionKey(sessionToken),
|
|
276
|
+
getSessionVersionKey(sessionToken),
|
|
277
|
+
getBetterAuthSessionKey(sessionToken, appSlug || getAppSlug()),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
229
281
|
/**
|
|
230
282
|
* Sets a session directly (for testing or migrations).
|
|
231
283
|
* @param sessionToken The session token.
|
|
@@ -292,6 +344,56 @@ export async function updateSession(
|
|
|
292
344
|
return updatedSession;
|
|
293
345
|
}
|
|
294
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Derives the MFA lifetime fields from an IDP access token.
|
|
349
|
+
*
|
|
350
|
+
* *** CARD 339563. THE FLAG AND THE EXPIRY ARE ONE UNIT - WRITE THEM TOGETHER. ***
|
|
351
|
+
*
|
|
352
|
+
* The 2FA-completion writers below used to set `mfaVerified: true` and never
|
|
353
|
+
* write `mfaExpiresAt`, while every consumer reads the expiry as
|
|
354
|
+
* const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
355
|
+
* const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
356
|
+
* The `> 0` guard makes a MISSING expiry unfalsifiable, so `mfaExpired` is
|
|
357
|
+
* forced false forever and the session's MFA state never ages out. The IDP is
|
|
358
|
+
* NOT at fault: IdentityBearerTokenService emits mfa_time + mfa_expires +
|
|
359
|
+
* mfa_validity_hours together inside one `if` block, so the claims arrive as an
|
|
360
|
+
* atomic set or not at all. We were dropping them on the floor.
|
|
361
|
+
*
|
|
362
|
+
* *** THIS RETURNS `{}` WHEN THE TOKEN CARRIES NO mfa_expires, AND THAT IS
|
|
363
|
+
* *** DELIBERATE - DO NOT DEFAULT IT TO `Date.now()` OR TO A CONSTANT. ***
|
|
364
|
+
* The step-up policy omits mfa_time on purpose when the only factor was a
|
|
365
|
+
* primary credential (e.g. passwordless email login): inventing a timestamp
|
|
366
|
+
* because one was missing is forging the evidence. A lane with no claim is a
|
|
367
|
+
* lane where we do not KNOW the expiry, and the honest fix for that case is to
|
|
368
|
+
* make the consumers fail CLOSED on a missing expiry - a separate change, on
|
|
369
|
+
* this same card, which MUST land after this one (the card's option (b)).
|
|
370
|
+
*
|
|
371
|
+
* Mirrors ensure-fresh-access-token.ts and api-handlers/auth/refresh.ts, which
|
|
372
|
+
* already derive these three fields off the token on every exchange (170007
|
|
373
|
+
* rule 2: re-derive from the token, never carry forward).
|
|
374
|
+
*/
|
|
375
|
+
function mfaLifetimeFromAccessToken(accessToken?: string): Partial<SessionData> {
|
|
376
|
+
if (!accessToken) return {};
|
|
377
|
+
|
|
378
|
+
const decoded = decodeIdpAccessToken(accessToken);
|
|
379
|
+
if (!decoded) return {};
|
|
380
|
+
|
|
381
|
+
const fields: Partial<SessionData> = {};
|
|
382
|
+
|
|
383
|
+
// Claims are Unix SECONDS; SessionData stores milliseconds.
|
|
384
|
+
if (decoded.mfa_expires !== undefined && decoded.mfa_expires !== null) {
|
|
385
|
+
fields.mfaExpiresAt = Number(decoded.mfa_expires) * 1000;
|
|
386
|
+
}
|
|
387
|
+
if (decoded.mfa_time !== undefined && decoded.mfa_time !== null) {
|
|
388
|
+
fields.mfaCompletedAt = Number(decoded.mfa_time) * 1000;
|
|
389
|
+
}
|
|
390
|
+
if (decoded.mfa_validity_hours !== undefined && decoded.mfa_validity_hours !== null) {
|
|
391
|
+
fields.mfaValidityHours = Number(decoded.mfa_validity_hours);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return fields;
|
|
395
|
+
}
|
|
396
|
+
|
|
295
397
|
/**
|
|
296
398
|
* Transitions a session to a MFA-completed state.
|
|
297
399
|
* @param sessionToken The session token to update.
|
|
@@ -344,6 +446,9 @@ export async function transitionTo2FASession(
|
|
|
344
446
|
mfaMethod: mfaMethod,
|
|
345
447
|
// Update bearerKeyId if extracted from new token
|
|
346
448
|
...(bearerKeyId && { bearerKeyId }),
|
|
449
|
+
// 339563: write the expiry in the SAME update that sets the flag. We already
|
|
450
|
+
// hold the stepped-up token here and already decode its header above.
|
|
451
|
+
...mfaLifetimeFromAccessToken(newAccessToken),
|
|
347
452
|
};
|
|
348
453
|
|
|
349
454
|
const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
|
|
@@ -356,6 +461,12 @@ export async function transitionTo2FASession(
|
|
|
356
461
|
twoFactorComplete: (updates as any).twoFactorComplete,
|
|
357
462
|
mfaMethod: updates.mfaMethod,
|
|
358
463
|
hasIdpAccessToken: !!updates.idpAccessToken,
|
|
464
|
+
// 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
|
|
465
|
+
// the passwordless/primary-credential lane (token carried no mfa_expires),
|
|
466
|
+
// NOT a bug - but it IS the case the consumers currently fail OPEN on, so
|
|
467
|
+
// it needs to be visible in the log rather than inferred from its absence.
|
|
468
|
+
mfaExpiresAt: updates.mfaExpiresAt,
|
|
469
|
+
mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
|
|
359
470
|
});
|
|
360
471
|
|
|
361
472
|
const result = await updateSession(sessionToken, updates);
|
|
@@ -394,12 +505,47 @@ export async function updateTokens(
|
|
|
394
505
|
|
|
395
506
|
/**
|
|
396
507
|
* Marks a session as having completed MFA.
|
|
508
|
+
*
|
|
509
|
+
* *** 339563: PASS `accessToken` - THE STEPPED-UP ONE THE IDP JUST RETURNED. ***
|
|
510
|
+
* Without it this writes a `mfaVerified: true` that no consumer can ever expire
|
|
511
|
+
* (see mfaLifetimeFromAccessToken above). The parameter is OPTIONAL only so
|
|
512
|
+
* that this published package keeps its existing signature for external
|
|
513
|
+
* callers; every in-tree caller supplies it, and omitting it re-opens the
|
|
514
|
+
* fail-open for that call site.
|
|
515
|
+
*
|
|
516
|
+
* It is NOT defaulted to the session's stored token on purpose. Reading it back
|
|
517
|
+
* would work today only because the sole caller happens to run `updateTokens()`
|
|
518
|
+
* first, and depending on undocumented call ORDERING is how this class of bug
|
|
519
|
+
* gets reintroduced. The token is an input, so take it as one.
|
|
520
|
+
*
|
|
397
521
|
* @param sessionToken The session token to update.
|
|
522
|
+
* @param accessToken The access token minted AFTER MFA completion, carrying the
|
|
523
|
+
* mfa_time / mfa_expires / mfa_validity_hours claim set.
|
|
398
524
|
* @returns The updated session data.
|
|
399
525
|
*/
|
|
400
|
-
export async function mark2FAComplete(
|
|
526
|
+
export async function mark2FAComplete(
|
|
527
|
+
sessionToken: string,
|
|
528
|
+
accessToken?: string
|
|
529
|
+
): Promise<SessionData | null> {
|
|
530
|
+
const mfaLifetime = mfaLifetimeFromAccessToken(accessToken);
|
|
531
|
+
|
|
532
|
+
if (!accessToken) {
|
|
533
|
+
console.warn(
|
|
534
|
+
'[mark2FAComplete] 339563: called with NO access token, so no mfaExpiresAt ' +
|
|
535
|
+
'can be derived. This session\'s MFA state will not expire until its next ' +
|
|
536
|
+
'token refresh repairs it. Pass the stepped-up access token.'
|
|
537
|
+
);
|
|
538
|
+
} else if (mfaLifetime.mfaExpiresAt === undefined) {
|
|
539
|
+
console.warn(
|
|
540
|
+
'[mark2FAComplete] 339563: access token carries NO mfa_expires claim, so ' +
|
|
541
|
+
'this session has no MFA expiry. Expected on a primary-credential lane ' +
|
|
542
|
+
'(e.g. passwordless email); unexpected on an ordinary 2FA completion.'
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
401
546
|
return updateSession(sessionToken, {
|
|
402
547
|
mfaVerified: true,
|
|
548
|
+
...mfaLifetime,
|
|
403
549
|
});
|
|
404
550
|
}
|
|
405
551
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-flight memoisation that does NOT memoise failure — card 244549.
|
|
3
|
+
*
|
|
4
|
+
* The bug this exists to prevent: caching an in-flight promise to de-duplicate
|
|
5
|
+
* concurrent callers is correct, but if the cached promise REJECTS and is never
|
|
6
|
+
* cleared, the rejection is memoised for the life of the process. Every later
|
|
7
|
+
* caller re-awaits the same settled rejection and re-throws the same stale
|
|
8
|
+
* error, long after the underlying dependency has recovered. Recovery becomes
|
|
9
|
+
* restart-only, and — because the error text is identical every time — it looks
|
|
10
|
+
* exactly like the dependency is still down.
|
|
11
|
+
*
|
|
12
|
+
* Measured instance: @payez/next-mvp getAuthInstance() memoised a failed IDP
|
|
13
|
+
* config load. Re-injecting correct config into three running rigs on
|
|
14
|
+
* 2026-08-23 did not heal them; the processes were replaying a cached
|
|
15
|
+
* rejection rather than retrying.
|
|
16
|
+
*
|
|
17
|
+
* Contract:
|
|
18
|
+
* - concurrent callers during a successful load share ONE underlying call
|
|
19
|
+
* - a rejection is re-thrown to every caller awaiting that attempt (failures
|
|
20
|
+
* stay LOUD — this never converts an error into a silent retry)
|
|
21
|
+
* - the NEXT call after a failure starts a fresh attempt
|
|
22
|
+
* - a slow failure never clears a newer in-flight attempt
|
|
23
|
+
*
|
|
24
|
+
* Zero imports on purpose: this must be unit-testable without loading the
|
|
25
|
+
* server auth stack, which is why the bug it fixes went untested for so long.
|
|
26
|
+
*/
|
|
27
|
+
export interface SingleFlight<T> {
|
|
28
|
+
/** Run (or join) the in-flight attempt. Rejects if the attempt rejects. */
|
|
29
|
+
get(): Promise<T>;
|
|
30
|
+
/** Test/diagnostic seam: is an attempt currently cached? */
|
|
31
|
+
isPending(): boolean;
|
|
32
|
+
/** Drop any cached attempt. */
|
|
33
|
+
reset(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createSingleFlight<T>(factory: () => Promise<T>): SingleFlight<T> {
|
|
37
|
+
let pending: Promise<T> | null = null;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
get(): Promise<T> {
|
|
41
|
+
if (pending) return pending;
|
|
42
|
+
|
|
43
|
+
const attempt = factory();
|
|
44
|
+
|
|
45
|
+
// Cleanup runs on a SIDE branch so the promise handed to callers is the
|
|
46
|
+
// original one and still rejects. The identity guard means only the
|
|
47
|
+
// attempt that actually failed clears itself: without it, a slow failure
|
|
48
|
+
// could evict a newer attempt a later caller had already installed, and
|
|
49
|
+
// that caller would be de-duplicated against a promise nobody owns.
|
|
50
|
+
attempt.catch(() => {
|
|
51
|
+
if (pending === attempt) pending = null;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pending = attempt;
|
|
55
|
+
return pending;
|
|
56
|
+
},
|
|
57
|
+
isPending(): boolean {
|
|
58
|
+
return pending !== null;
|
|
59
|
+
},
|
|
60
|
+
reset(): void {
|
|
61
|
+
pending = null;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -351,6 +351,10 @@ export async function ensureFreshToken(
|
|
|
351
351
|
* `mfaVerified || ... || false`).
|
|
352
352
|
*/
|
|
353
353
|
mfaVerified: idpTokens?.mfaVerified ?? false,
|
|
354
|
+
// R807 (362550): the fail-closed default above stays exactly as documented;
|
|
355
|
+
// this records whether it was a measurement or a default. `!== undefined`
|
|
356
|
+
// deliberately - an explicit `false` is a signal.
|
|
357
|
+
mfaSignalPresent: idpTokens?.mfaVerified !== undefined,
|
|
354
358
|
oauthProvider: idpTokens?.oauthProvider ?? baSession.oauthProvider ?? undefined,
|
|
355
359
|
idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId ?? baSession.idpClientId,
|
|
356
360
|
merchantId: idpTokens?.merchantId ?? baSession.merchantId,
|
|
@@ -377,6 +381,14 @@ export async function ensureFreshToken(
|
|
|
377
381
|
* UNSET is the only honest provider. Both are fail-closed.
|
|
378
382
|
*/
|
|
379
383
|
mfaVerified: false,
|
|
384
|
+
/**
|
|
385
|
+
* R807 (362550) - FIFTH construction site, not in the ruling's list of four.
|
|
386
|
+
* This path has NO IDP TOKENS AT ALL, so there is no producer statement to
|
|
387
|
+
* record: the `false` above is a DEFAULT, never a measurement. `false` is
|
|
388
|
+
* therefore the honest signal value, and it is the conservative one for
|
|
389
|
+
* viability (absent/false -> do not force a re-challenge).
|
|
390
|
+
*/
|
|
391
|
+
mfaSignalPresent: false,
|
|
380
392
|
oauthProvider: undefined,
|
|
381
393
|
} as SessionData;
|
|
382
394
|
}
|
|
@@ -292,6 +292,17 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
|
|
|
292
292
|
if (sessionPointer.hasRefreshToken) {
|
|
293
293
|
return { type: 'refresh_needed' };
|
|
294
294
|
}
|
|
295
|
+
// 307290: this is the TERMINAL arm of the expired branch - refresh is not
|
|
296
|
+
// possible here, so the only outcomes are "redirect" or "allow". Every other
|
|
297
|
+
// terminal branch in this function (:242 no_session, :259 force_invalidated,
|
|
298
|
+
// :271 stale_session, :318 2FA-incomplete) already exempts /api/ so the route
|
|
299
|
+
// can answer with a real 401 JSON; this one did not, and redirecting an API
|
|
300
|
+
// request to an HTML login page makes the failure INVISIBLE - fetch() follows
|
|
301
|
+
// the 307, gets 200 HTML, resolves successfully and parses garbage.
|
|
302
|
+
// MUST stay BELOW the hasRefreshToken check above: placed any higher it would
|
|
303
|
+
// intercept 'refresh_needed' and disable middleware token refresh for the
|
|
304
|
+
// entire API surface.
|
|
305
|
+
if (pathname.startsWith('/api/')) return { type: 'allow' }; // Let API return proper 401 JSON
|
|
295
306
|
return {
|
|
296
307
|
type: 'redirect',
|
|
297
308
|
location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=SessionExpired`,
|