@payez/next-mvp 4.4.1 → 4.4.3

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.
Files changed (39) hide show
  1. package/dist/api/auth-handler.js +13 -4
  2. package/dist/api-handlers/mfa/totp-verify.d.ts +3 -1
  3. package/dist/api-handlers/mfa/totp-verify.js +22 -3
  4. package/dist/api-handlers/session/refresh-viability.js +2 -2
  5. package/dist/api-handlers/session/viability.js +1 -1
  6. package/dist/auth/auth-decision.js +27 -8
  7. package/dist/auth/better-auth.js +123 -1
  8. package/dist/auth/safe-callback.d.ts +92 -0
  9. package/dist/auth/safe-callback.js +183 -0
  10. package/dist/hooks/useProfile.d.ts +0 -1
  11. package/dist/lib/acr.d.ts +64 -0
  12. package/dist/lib/acr.js +109 -0
  13. package/dist/lib/session-store.js +56 -7
  14. package/dist/middleware/create-middleware.js +45 -13
  15. package/dist/pages/profile/EnhancedProfilePage.js +38 -13
  16. package/dist/pages/verify-code/page.js +26 -1
  17. package/dist/routes/auth/viability.d.ts +2 -2
  18. package/dist/routes/auth/viability.js +2 -2
  19. package/dist/server/auth-guard.js +26 -1
  20. package/package.json +2 -1
  21. package/scripts/module-reach.mjs +280 -0
  22. package/src/api/auth-handler.ts +19 -4
  23. package/src/api-handlers/mfa/totp-verify.ts +24 -3
  24. package/src/api-handlers/session/refresh-viability.ts +2 -2
  25. package/src/api-handlers/session/viability.ts +1 -1
  26. package/src/auth/auth-decision.ts +28 -8
  27. package/src/auth/better-auth.ts +128 -1
  28. package/src/auth/safe-callback.ts +185 -0
  29. package/src/hooks/useProfile.ts +8 -1
  30. package/src/lib/acr.ts +109 -0
  31. package/src/lib/session-store.ts +66 -7
  32. package/src/middleware/create-middleware.ts +45 -13
  33. package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
  34. package/src/pages/verify-code/page.tsx +26 -1
  35. package/src/routes/auth/viability.ts +2 -2
  36. package/src/server/auth-guard.ts +27 -1
  37. package/dist/middleware/auth-decision.d.ts +0 -33
  38. package/dist/middleware/auth-decision.js +0 -65
  39. package/src/middleware/auth-decision.ts +0 -43
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * module-reach.mjs - module-graph reachability census over the BUILT dist/.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * Cards 372358 / 373665 rest on numbers like "40 consumer-mountable entry points can
8
+ * reach server/auth". Those numbers were originally produced by a script that was run
9
+ * inline and never committed, so nobody could reproduce them. A number nobody can
10
+ * re-derive is a rumour. This file is that script, made durable and self-testing.
11
+ *
12
+ * WHAT IT MEASURES
13
+ * ----------------
14
+ * Over dist/**\/*.js (the artifact a consumer actually installs, NOT src/):
15
+ * - the module graph (nodes + edges)
16
+ * - which modules transitively REACH a target module (reverse reachability)
17
+ * - which of those are MOUNTABLE - i.e. named in package.json "exports", so a
18
+ * consumer can import them directly and thereby pull the target in
19
+ *
20
+ * HOW EDGES ARE FOUND - and why this shape was chosen
21
+ * ---------------------------------------------------
22
+ * We do NOT pattern-match `require(...)`. Three real defects came from doing that:
23
+ * 1. QUOTE STYLE - a pattern written with " misses ' (tsc emits either).
24
+ * 2. SIBLING PATHS - a pattern anchored on `./lib/` misses a plain `./auth`.
25
+ * 3. DYNAMIC IMPORT - `await import('x')`, and tsc's downlevel
26
+ * `Promise.resolve().then(() => __importStar(require('x')))`, are not `require(` calls
27
+ * in the shape a naive pattern expects.
28
+ * Instead: extract EVERY string literal (', ", and substitution-free `), and keep the ones
29
+ * that RESOLVE to a real file on disk. Keyword-agnostic and quote-agnostic by construction,
30
+ * so all three modes above are caught without enumerating them.
31
+ *
32
+ * THE HONESTY SIGNAL
33
+ * ------------------
34
+ * A relative specifier that does NOT resolve is counted as UNRESOLVED. Unresolved > 0 means
35
+ * the graph is incomplete and every count derived from it is a lower bound. The tool says so
36
+ * loudly rather than quietly under-reporting. Counts are reported over CALL SITES where
37
+ * relevant, never deduplicated to "files that mention it" - that conflation is its own defect.
38
+ *
39
+ * BOUND: this is a STATIC graph over emitted JS. It proves a module is REACHABLE, not that it
40
+ * is EXECUTED at runtime, and not that reaching it has any security consequence. Reachability
41
+ * is necessary for consequence, never sufficient. Do not quote this tool as proof of impact.
42
+ *
43
+ * USAGE
44
+ * node scripts/module-reach.mjs --to server/auth
45
+ * node scripts/module-reach.mjs --to server/auth --json
46
+ * node scripts/module-reach.mjs --graph-only
47
+ * node scripts/module-reach.mjs --to server/auth --pkg <dir> (default: package root)
48
+ */
49
+
50
+ import fs from 'node:fs';
51
+ import path from 'node:path';
52
+
53
+ const CANDIDATE_EXTS = ['', '.js', '.mjs', '.cjs', '.json'];
54
+ const INDEX_FILES = ['index.js', 'index.mjs', 'index.cjs'];
55
+
56
+ /** Extract every string literal. Handles ' " and substitution-free `. Escape-aware. */
57
+ export function extractStringLiterals(src) {
58
+ const out = [];
59
+ const n = src.length;
60
+ let i = 0;
61
+ while (i < n) {
62
+ const ch = src[i];
63
+ // skip line comments
64
+ if (ch === '/' && src[i + 1] === '/') {
65
+ while (i < n && src[i] !== '\n') i++;
66
+ continue;
67
+ }
68
+ // skip block comments
69
+ if (ch === '/' && src[i + 1] === '*') {
70
+ i += 2;
71
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (ch === '"' || ch === "'" || ch === '`') {
76
+ const quote = ch;
77
+ let j = i + 1;
78
+ let buf = '';
79
+ let sawSubstitution = false;
80
+ while (j < n) {
81
+ const c = src[j];
82
+ if (c === '\\') { buf += src[j + 1] ?? ''; j += 2; continue; }
83
+ if (c === quote) break;
84
+ if (quote === '`' && c === '$' && src[j + 1] === '{') sawSubstitution = true;
85
+ if (quote !== '`' && (c === '\n' || c === '\r')) break; // unterminated
86
+ buf += c;
87
+ j++;
88
+ }
89
+ // A template literal WITH ${...} is not a static specifier - we cannot resolve it,
90
+ // and pretending otherwise would invent an edge. Dropped, and counted by the caller.
91
+ if (!sawSubstitution) out.push(buf);
92
+ i = j + 1;
93
+ continue;
94
+ }
95
+ i++;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** Resolve a relative specifier from `fromFile` to a real file, or null. */
101
+ export function resolveSpecifier(fromFile, spec, exists) {
102
+ const base = path.resolve(path.dirname(fromFile), spec);
103
+ for (const ext of CANDIDATE_EXTS) {
104
+ const cand = base + ext;
105
+ if (exists(cand)) return cand;
106
+ }
107
+ for (const idx of INDEX_FILES) {
108
+ const cand = path.join(base, idx);
109
+ if (exists(cand)) return cand;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ const isRelative = (s) => s === '.' || s === '..' || s.startsWith('./') || s.startsWith('../');
115
+
116
+ function walkJs(dir, acc = []) {
117
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
118
+ const p = path.join(dir, e.name);
119
+ if (e.isDirectory()) walkJs(p, acc);
120
+ else if (e.isFile() && /\.(js|mjs|cjs)$/.test(e.name)) acc.push(p);
121
+ }
122
+ return acc;
123
+ }
124
+
125
+ export function buildGraph(distDir, opts = {}) {
126
+ const readFile = opts.readFile || ((f) => fs.readFileSync(f, 'utf8'));
127
+ const exists = opts.exists || ((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
128
+ const files = (opts.files || walkJs(distDir)).map((f) => path.resolve(f));
129
+
130
+ const rel = (f) => path.relative(distDir, f).split(path.sep).join('/');
131
+ const forward = new Map(); // module -> Set(module)
132
+ const reverse = new Map(); // module -> Set(module)
133
+ const unresolved = []; // {from, spec} - CALL SITES, not deduped
134
+ let edgeSites = 0;
135
+ let externalSites = 0;
136
+ let dotValueSites = 0;
137
+
138
+ for (const f of files) { forward.set(rel(f), new Set()); reverse.set(rel(f), new Set()); }
139
+
140
+ for (const f of files) {
141
+ const from = rel(f);
142
+ for (const spec of extractStringLiterals(readFile(f))) {
143
+ if (!isRelative(spec)) {
144
+ // bare specifier: 'next/server', 'jose'. External to this package, not a defect.
145
+ if (/^[@a-zA-Z]/.test(spec) && !spec.includes(' ') && spec.length < 100) externalSites++;
146
+ continue;
147
+ }
148
+ const target = resolveSpecifier(f, spec, exists);
149
+ if (!target) {
150
+ // A LONE "." or ".." that does not resolve is the CHARACTER, not a specifier:
151
+ // verified in this dist - every occurrence is `token.split('.')`. `require('.')`
152
+ // is still legal, so we only discount it AFTER resolution has actually failed.
153
+ if (spec === '.' || spec === '..') { dotValueSites++; continue; }
154
+ unresolved.push({ from, spec });
155
+ continue;
156
+ }
157
+ const to = rel(target);
158
+ if (!forward.has(to)) continue; // resolved outside the scanned set
159
+ edgeSites++;
160
+ forward.get(from).add(to);
161
+ reverse.get(to).add(from);
162
+ }
163
+ }
164
+
165
+ let edgeCount = 0;
166
+ for (const s of forward.values()) edgeCount += s.size;
167
+
168
+ return { distDir, modules: [...forward.keys()].sort(), forward, reverse, unresolved, edgeCount, edgeSites, externalSites, dotValueSites };
169
+ }
170
+
171
+ /** Reverse-transitive closure: every module that can reach `target`. */
172
+ export function reachers(graph, target) {
173
+ if (!graph.reverse.has(target)) return null;
174
+ const depth = new Map([[target, 0]]);
175
+ const queue = [target];
176
+ while (queue.length) {
177
+ const cur = queue.shift();
178
+ for (const prev of graph.reverse.get(cur) || []) {
179
+ if (!depth.has(prev)) { depth.set(prev, depth.get(cur) + 1); queue.push(prev); }
180
+ }
181
+ }
182
+ depth.delete(target);
183
+ return depth;
184
+ }
185
+
186
+ /** Every dist/*.js named as a target in package.json "exports". */
187
+ export function exportTargets(pkgJson) {
188
+ const out = new Map(); // distRelPath -> Set(subpath)
189
+ const visit = (subpath, node) => {
190
+ if (typeof node === 'string') {
191
+ if (!node.endsWith('.js')) return;
192
+ // normalise './dist/lib/x.js' and './lib/x.js' (both appear) to 'lib/x.js'
193
+ let p = node.replace(/^\.\//, '');
194
+ p = p.replace(/^dist\//, '');
195
+ if (!out.has(p)) out.set(p, new Set());
196
+ out.get(p).add(subpath);
197
+ return;
198
+ }
199
+ if (node && typeof node === 'object') for (const v of Object.values(node)) visit(subpath, v);
200
+ };
201
+ for (const [subpath, node] of Object.entries(pkgJson.exports || {})) visit(subpath, node);
202
+ if (typeof pkgJson.main === 'string') visit('. (main)', './' + pkgJson.main);
203
+ return out;
204
+ }
205
+
206
+ function main(argv) {
207
+ const arg = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
208
+ const pkgDir = path.resolve(arg('--pkg') || path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..'));
209
+ const distDir = path.join(pkgDir, 'dist');
210
+ const json = argv.includes('--json');
211
+ const to = arg('--to');
212
+
213
+ if (!fs.existsSync(distDir)) {
214
+ console.error(`[module-reach] FATAL: no dist at ${distDir}. Run \`npm run build\` first.`);
215
+ console.error('[module-reach] Refusing to report a census over a tree that does not exist.');
216
+ process.exit(2);
217
+ }
218
+
219
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
220
+ const graph = buildGraph(distDir);
221
+ const exports_ = exportTargets(pkgJson);
222
+
223
+ const report = {
224
+ package: pkgJson.name, version: pkgJson.version, distDir,
225
+ modules: graph.modules.length, edges: graph.edgeCount, edgeSites: graph.edgeSites,
226
+ externalSites: graph.externalSites,
227
+ unresolvedSites: graph.unresolved.length,
228
+ exportSubpaths: Object.keys(pkgJson.exports || {}).length,
229
+ exportTargetFiles: exports_.size,
230
+ };
231
+
232
+ if (to) {
233
+ const candidates = [to, `${to}.js`, `${to}/index.js`].map((t) => t.replace(/^\.\//, ''));
234
+ const target = candidates.find((c) => graph.reverse.has(c));
235
+ if (!target) {
236
+ console.error(`[module-reach] FATAL: target not in dist graph: ${to}`);
237
+ console.error(`[module-reach] tried: ${candidates.join(', ')}`);
238
+ process.exit(2);
239
+ }
240
+ const depth = reachers(graph, target);
241
+ const all = [...depth.keys()].sort();
242
+ const direct = all.filter((m) => depth.get(m) === 1);
243
+ const mountable = all.filter((m) => exports_.has(m));
244
+ report.target = target;
245
+ report.directRequirers = direct.length;
246
+ report.reaching = all.length;
247
+ report.mountable = mountable.length;
248
+ report.targetIsItselfMountable = exports_.has(target);
249
+ report.directRequirerList = direct;
250
+ report.mountableList = mountable.map((m) => ({ module: m, subpaths: [...exports_.get(m)] }));
251
+ }
252
+
253
+ if (json) { console.log(JSON.stringify(report, null, 2)); return; }
254
+
255
+ console.log(`[module-reach] ${report.package}@${report.version}`);
256
+ console.log(` dist modules ......... ${report.modules}`);
257
+ console.log(` edges (unique) ....... ${report.edges}`);
258
+ console.log(` edge call sites ...... ${report.edgeSites}`);
259
+ console.log(` external specifiers .. ${report.externalSites} (bare, outside this package)`);
260
+ console.log(` export subpaths ...... ${report.exportSubpaths} -> ${report.exportTargetFiles} distinct dist files`);
261
+ if (report.unresolvedSites === 0) {
262
+ console.log(` unresolved ........... 0 <- graph is complete; counts below are exact`);
263
+ } else {
264
+ console.log(` unresolved ........... ${report.unresolvedSites} *** GRAPH INCOMPLETE - ALL COUNTS BELOW ARE LOWER BOUNDS ***`);
265
+ for (const u of graph.unresolved.slice(0, 20)) console.log(` ${u.from} -> ${u.spec}`);
266
+ }
267
+ if (report.target) {
268
+ console.log('');
269
+ console.log(` TARGET: ${report.target}${report.targetIsItselfMountable ? ' (itself a public export)' : ''}`);
270
+ console.log(` direct requirers ..... ${report.directRequirers}`);
271
+ console.log(` transitively reaching ${report.reaching}`);
272
+ console.log(` ...of which MOUNTABLE ${report.mountable} <- consumer-reachable entry points`);
273
+ console.log('');
274
+ console.log(' BOUND: static reachability over emitted JS. Proves reachable, NOT executed,');
275
+ console.log(' and NOT that reaching it has any consequence. Do not quote as proof of impact.');
276
+ }
277
+ }
278
+
279
+ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]));
280
+ if (invokedDirectly) main(process.argv.slice(2));
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { NextRequest, NextResponse } from 'next/server';
17
17
  import { getSession as getBetterAuthSession } from '../server/auth';
18
+ import { resolveAcrLevel, resolveAuthMethods } from '../lib/acr';
18
19
  import { nanoid } from 'nanoid';
19
20
  import {
20
21
  getSession,
@@ -244,10 +245,24 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
244
245
  return { success: false, error: 'No refresh token' };
245
246
  }
246
247
 
247
- // Extract authentication methods from session
248
- const authMethods = (currentSession as any).authMethods ||
249
- ((currentSession as any).token?.amr ? JSON.parse((currentSession as any).token.amr) : ['pwd', 'mfa']);
250
- const authLevel = String((currentSession as any).authenticationLevel || (currentSession as any).token?.acr || '2');
248
+ // Extract authentication methods from session.
249
+ //
250
+ // 375639: these two lines used to read
251
+ // authMethods = ... : ['pwd', 'mfa']
252
+ // authLevel = String(... || ... || '2')
253
+ // so a session that knew NOTHING about how the user authenticated told
254
+ // the IDP that a second factor had happened AND named 'mfa' as the
255
+ // method. The two fabrications corroborated each other, so a downstream
256
+ // check cross-validating amr against acr would have accepted the pair.
257
+ // Absent is not satisfied; both now resolve fail-closed, in one place.
258
+ const authMethods = resolveAuthMethods(
259
+ (currentSession as any).authMethods,
260
+ (currentSession as any).token?.amr
261
+ );
262
+ const authLevel = resolveAcrLevel(
263
+ (currentSession as any).authenticationLevel,
264
+ (currentSession as any).token?.acr
265
+ );
251
266
  const twoFactorMethod = (currentSession as any).twoFactorMethod || 'authenticator';
252
267
 
253
268
  // Build refresh request body
@@ -82,6 +82,10 @@ export async function POST(req: NextRequest) {
82
82
  // session. No tokens => no transition: we must not mark 2FA complete on the
83
83
  // strength of a 200 alone.
84
84
  let sessionTransitioned = false;
85
+ // R908(e): "we could not establish MFA" is a THIRD outcome, distinct from
86
+ // both success and failure. It must reach the client as a challenge, not as
87
+ // a silent success (fail-open) and not as a rejection (fail-closed).
88
+ let requiresChallenge = false;
85
89
 
86
90
  if (unwrappedData.access_token && unwrappedData.refresh_token) {
87
91
  const token = await getTokenTestAware(req);
@@ -104,7 +108,11 @@ export async function POST(req: NextRequest) {
104
108
  // Refresh token may carry no exp claim; default stands.
105
109
  }
106
110
 
107
- await transitionTo2FASession(
111
+ // R908(e): the transition can now decline to grant. Read what was
112
+ // actually written instead of assuming the call granted - the store
113
+ // withholds mfaVerified when the stepped-up token carries no
114
+ // mfa_expires, and reports the re-challenge via nextStep.
115
+ const transitioned = await transitionTo2FASession(
108
116
  sessionToken,
109
117
  {
110
118
  accessToken: unwrappedData.access_token,
@@ -115,8 +123,18 @@ export async function POST(req: NextRequest) {
115
123
  'totp' // 2FA method, used by the refresh-token flow
116
124
  );
117
125
 
118
- sessionTransitioned = true;
119
- console.log('[VERIFY_TOTP] Session transitioned to 2FA-verified');
126
+ sessionTransitioned = transitioned?.mfaVerified === true;
127
+ requiresChallenge = transitioned?.nextStep === 'mfa_verify';
128
+
129
+ if (sessionTransitioned) {
130
+ console.log('[VERIFY_TOTP] Session transitioned to 2FA-verified');
131
+ } else {
132
+ console.warn(
133
+ '[VERIFY_TOTP] R908(e): IDP verified the code but the session was NOT ' +
134
+ 'marked MFA-verified (no mfa_expires on the returned token). Reporting ' +
135
+ 'requiresChallenge so the client re-challenges rather than proceeding.'
136
+ );
137
+ }
120
138
  } else {
121
139
  console.warn('[VERIFY_TOTP] IDP verified the code but no session token was resolvable — session NOT transitioned');
122
140
  }
@@ -138,6 +156,9 @@ export async function POST(req: NextRequest) {
138
156
  success: true,
139
157
  verificationSuccessful: true,
140
158
  twoFactorSessionVerified: sessionTransitioned,
159
+ // R908(e): tells the client to re-challenge. Present only when the code
160
+ // verified at the IDP but produced no evidence we could record.
161
+ ...(requiresChallenge ? { requiresChallenge: true, nextStep: 'mfa_verify' as const } : {}),
141
162
  message: unwrappedData.message || 'Authenticator code verified successfully',
142
163
  },
143
164
  { status: 200 }
@@ -21,14 +21,14 @@ export async function GET(req: NextRequest) {
21
21
  // Still need to get session data for twoFactorComplete even when refresh is in progress
22
22
  const sessionData = await getSession(finalSessionToken);
23
23
  logger.info('[REFRESH-VIABILITY] Refresh already in progress, telling middleware to wait', { sessionToken: finalSessionToken.substring(0, 8) + '...' });
24
- return NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ?? (sessionData as any)?.twoFactorComplete ?? false }, { status: 200 });
24
+ return NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ?? false }, { status: 200 });
25
25
  }
26
26
  const sessionData = await getSession(finalSessionToken);
27
27
  if (!sessionData) {
28
28
  return NextResponse.json({ canRefresh: false, reason: 'session_not_found', sessionToken: finalSessionToken }, { status: 200 });
29
29
  }
30
30
  const viabilityCheck = checkRefreshViability(sessionData);
31
- return NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionToken: finalSessionToken, twoFactorComplete: sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false, userId: sessionData.userId, refreshInProgress: false });
31
+ return NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionToken: finalSessionToken, twoFactorComplete: sessionData.mfaVerified ?? false, userId: sessionData.userId, refreshInProgress: false });
32
32
  } catch (error) {
33
33
  logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { error: error instanceof Error ? error.message : String(error) });
34
34
  return NextResponse.json({ canRefresh: false, reason: 'check_error', error: error instanceof Error ? error.message : String(error) }, { status: 500 });
@@ -119,7 +119,7 @@ export async function GET(req: NextRequest) {
119
119
  const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
120
120
  const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
121
121
  // Check both field names for compatibility (mfaVerified is the normalized name)
122
- const sessionMfaComplete = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
122
+ const sessionMfaComplete = sessionData.mfaVerified ?? false;
123
123
  const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
124
124
 
125
125
  console.log('[VIABILITY] Session 2FA check:', {
@@ -5,6 +5,8 @@
5
5
  * All auth flow logic is centralized here for testability and maintainability.
6
6
  */
7
7
 
8
+ import { safeCallbackPath } from './safe-callback';
9
+
8
10
  // Input types for the decision engine
9
11
  export interface AuthContext {
10
12
  pathname: string;
@@ -45,8 +47,12 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
45
47
  circuitBreakerOpen
46
48
  } = context;
47
49
 
48
- // SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops
49
- const safeCallbackUrl = pathname.startsWith('/account-auth/') ? '/' : pathname;
50
+ // SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops.
51
+ // 375656: `pathname` is our own request path rather than an attacker-supplied
52
+ // parameter, so this site was not the open redirect - but it is routed through
53
+ // the same guard so the package has ONE definition of an acceptable callback,
54
+ // and so the value is parser-normalised before being carried into a redirect.
55
+ const safeCallbackUrl = safeCallbackPath(pathname);
50
56
 
51
57
  // Public routes are allowed, but we need to check login page separately
52
58
  if (isPublicRoute && !isLoginPage) {
@@ -145,8 +151,17 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
145
151
  // If user is on the login page, send them to verify-code with the original callback
146
152
  if (isLoginPage) {
147
153
  const callbackUrl = searchParams.get('callbackUrl') || '/';
148
- // CRITICAL FIX: Never use auth pages as callback URLs
149
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
154
+ // 375656: this previously tested the raw parameter with a prefix check against
155
+ // the auth path, and returned the parameter itself when it did not match.
156
+ // (The vulnerable expression is deliberately NOT reproduced verbatim here: a
157
+ // comment carrying it puts the string into dist, and a reviewer grepping for
158
+ // that pattern would score this FIXED build as vulnerable - the exact
159
+ // sink-counting inversion recorded on 375936.)
160
+ // That is a redirect-LOOP guard in the seat where an ORIGIN check belongs:
161
+ // `https://evil.example.com` does not start with `/account-auth/`, so it passed
162
+ // through untouched. safeCallbackPath() asks the URL parser instead, so every
163
+ // spelling that escapes the origin fails one comparison. See ./safe-callback.
164
+ const safeCallbackUrl = safeCallbackPath(callbackUrl);
150
165
  console.log('[MIDDLEWARE-DECISION] On login page with 2FA incomplete, redirecting to verify-code', {
151
166
  originalCallback: callbackUrl,
152
167
  safeCallback: safeCallbackUrl,
@@ -193,8 +208,8 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
193
208
  // For login page, redirect to 2FA with original callback
194
209
  if (isLoginPage) {
195
210
  const callbackUrl = searchParams.get('callbackUrl') || '/';
196
- // CRITICAL FIX: Never use auth pages as callback URLs
197
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
211
+ // 375656: origin-checked, not prefix-checked. See ./safe-callback.
212
+ const safeCallbackUrl = safeCallbackPath(callbackUrl);
198
213
  return {
199
214
  type: 'redirect',
200
215
  location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`,
@@ -212,8 +227,13 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
212
227
  // Authenticated user on login page - redirect to dashboard or callback
213
228
  if (isLoginPage) {
214
229
  const callbackUrl = searchParams.get('callbackUrl') || '/';
215
- // CRITICAL FIX: Never redirect back to login page (prevents infinite loop)
216
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
230
+ // 375656 - THE PUBLISHED OPEN REDIRECT. This value becomes `location` VERBATIM
231
+ // below (not a query parameter on a relative path, as at the two sites above),
232
+ // so the old `startsWith('/account-auth/')` test sent an authenticated user
233
+ // straight to any absolute URL an attacker put in `?callbackUrl=`. Its own
234
+ // comment says "prevents infinite loop", which is what it was written for -
235
+ // loop prevention, never origin validation. See ./safe-callback.
236
+ const safeCallbackUrl = safeCallbackPath(callbackUrl);
217
237
  console.log('[MIDDLEWARE-DECISION] Authenticated user on login page, redirecting', {
218
238
  originalCallback: callbackUrl,
219
239
  safeCallback: safeCallbackUrl
@@ -34,6 +34,26 @@ export function secretFingerprint(value: string): string {
34
34
  return createHash('sha256').update(value).digest('hex').slice(0, 12);
35
35
  }
36
36
 
37
+ /**
38
+ * 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
39
+ * than Better Auth, which must survive a Better Auth session write.
40
+ *
41
+ * Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
42
+ * list is a named constant rather than a hardcoded check because the shape of
43
+ * the bug is "a second author appeared and the adapter did not know" — the next
44
+ * enrichment key should be added here, not rediscovered as a strip.
45
+ */
46
+ const PRESERVED_SESSION_KEYS = ['idpTokens'] as const;
47
+
48
+ /**
49
+ * True for a JSON object we can safely merge into — not null, not an array.
50
+ * `typeof null === 'object'` and arrays are objects, and merging into either
51
+ * would corrupt the record rather than preserve it.
52
+ */
53
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
55
+ }
56
+
37
57
  /**
38
58
  * Better Auth social provider config shape.
39
59
  */
@@ -498,7 +518,114 @@ export function createBetterAuthInstance(
498
518
  */
499
519
  set: async (key: string, value: string, ttl?: number) => {
500
520
  const redis = getRedis();
501
- await redis.setex(`ba:${appSlug}:${key}`, ttl ?? SESSION_TTL, value);
521
+ const redisKey = `ba:${appSlug}:${key}`;
522
+
523
+ /**
524
+ * 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
525
+ * (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
526
+ * evidence under BAPert's R920(a). Reproduced here before fixing.)
527
+ *
528
+ * Better Auth owns `{session, user}` and serialises exactly that. But
529
+ * `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
530
+ * `idpTokens` via its own read-modify-write. This setter did a blind
531
+ * `setex` of Better Auth's payload, so every write it makes — including
532
+ * each routine `updateAge` session rewrite, not merely an expiry —
533
+ * STRIPPED `idpTokens` back off the record.
534
+ *
535
+ * The consumer then falls back to an identity husk: `mfaVerified:false`,
536
+ * `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
537
+ * per BAPert it is a public-test blocker on its own terms, because a
538
+ * tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
539
+ * AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
540
+ * failure regardless of which direction it fails in.
541
+ *
542
+ * WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
543
+ * does a read-modify-write; it was correct. The bug is that ONE of the
544
+ * two authors of this key wrote as though it were the only one. Fixing
545
+ * the adapter fixes it for every future enrichment key, whereas moving
546
+ * `idpTokens` elsewhere would leave the next one to rediscover this.
547
+ *
548
+ * ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
549
+ * payload does not set them — so Better Auth still wins on everything it
550
+ * owns, and an explicit incoming value is never overridden by a stale one.
551
+ *
552
+ * NOT ATOMIC, and deliberately not: an enrichment landing between this
553
+ * read and this write is still lost. Closing that needs a WATCH/MULTI or
554
+ * a Lua script, which is a larger change than tonight allows. This
555
+ * narrows the window from "every single write" to "a genuine race", and
556
+ * the residual is recorded here rather than left to be re-found.
557
+ */
558
+ let payload = value;
559
+ try {
560
+ const existingRaw = await redis.get(redisKey);
561
+ if (existingRaw) {
562
+ const existing = JSON.parse(existingRaw);
563
+ const incoming = JSON.parse(value);
564
+ if (isPlainObject(existing) && isPlainObject(incoming)) {
565
+ let carried = false;
566
+ for (const preserved of PRESERVED_SESSION_KEYS) {
567
+ if (preserved in existing && !(preserved in incoming)) {
568
+ incoming[preserved] = existing[preserved];
569
+ carried = true;
570
+ }
571
+ }
572
+ if (carried) payload = JSON.stringify(incoming);
573
+ }
574
+ }
575
+ } catch (mergeErr) {
576
+ /**
577
+ * This adapter also stores `verification:*` and `active-sessions-*`
578
+ * records, which are not necessarily session-shaped JSON. A record we
579
+ * cannot parse is not a session to enrich, so writing through unchanged
580
+ * is correct rather than merely tolerable.
581
+ *
582
+ * WARNED, NOT THROWN, and the asymmetry is the point: this block is an
583
+ * ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
584
+ * degraded read into a lost session — the precise inversion 170033 fix 1
585
+ * above exists to prevent. The `setex` below is outside this try and is
586
+ * still unswallowed, so a genuine write failure still propagates.
587
+ */
588
+ console.warn(
589
+ `[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
590
+ `ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
591
+ `as-is. If this record held idpTokens they are now gone (273183):`,
592
+ mergeErr instanceof Error ? mergeErr.message : String(mergeErr)
593
+ );
594
+ }
595
+
596
+ /**
597
+ * QA 6952 / R913 - CLAMP, DO NOT DEFER.
598
+ *
599
+ * This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
600
+ * source of session TTL" - what session-store.ts claims, and the mechanism
601
+ * by which the 7d-vs-3d zombie window was supposedly closed for good.
602
+ *
603
+ * IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
604
+ * eight secondaryStorage.set call sites in
605
+ * better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
606
+ * :180, :189, :309, :320, :344, :553, :622). `??` fires only on
607
+ * null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
608
+ * Auth's 7d default - `session.expiresIn` is not configured in this file -
609
+ * while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
610
+ * the zombie window the constant exists to prevent: the guard was bypassed
611
+ * by the very parameter written to defer to it.
612
+ *
613
+ * `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
614
+ * `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
615
+ * records whose TTLs are legitimately SHORTER than 3d. Overwriting those
616
+ * would EXTEND them - turning a fix for over-long sessions into a bug that
617
+ * keeps verification codes alive for three days. Clamp DOWN, never up.
618
+ *
619
+ * Clamped down to 3d rather than raising the canonical store to 7d because
620
+ * SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
621
+ * the refresh token sustaining it cannot be refreshed, so the extra days are
622
+ * a husk that reads as authenticated. Fail-closed beats ambiguous.
623
+ *
624
+ * DO NOT "fix" this by making storeIdpTokensInSession preserve Better
625
+ * Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
626
+ * universal. See test/ba-session-ttl-clamp.test.cjs.
627
+ */
628
+ await redis.setex(redisKey, Math.min(ttl ?? SESSION_TTL, SESSION_TTL), payload);
502
629
  },
503
630
  delete: async (key: string) => {
504
631
  try {