@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.
- package/dist/api/auth-handler.js +13 -4
- package/dist/api-handlers/mfa/totp-verify.d.ts +3 -1
- package/dist/api-handlers/mfa/totp-verify.js +22 -3
- package/dist/api-handlers/session/refresh-viability.js +2 -2
- package/dist/api-handlers/session/viability.js +1 -1
- package/dist/auth/auth-decision.js +27 -8
- package/dist/auth/better-auth.js +123 -1
- package/dist/auth/safe-callback.d.ts +92 -0
- package/dist/auth/safe-callback.js +183 -0
- package/dist/hooks/useProfile.d.ts +0 -1
- package/dist/lib/acr.d.ts +64 -0
- package/dist/lib/acr.js +109 -0
- package/dist/lib/session-store.js +56 -7
- package/dist/middleware/create-middleware.js +45 -13
- package/dist/pages/profile/EnhancedProfilePage.js +38 -13
- package/dist/pages/verify-code/page.js +26 -1
- package/dist/routes/auth/viability.d.ts +2 -2
- package/dist/routes/auth/viability.js +2 -2
- package/dist/server/auth-guard.js +26 -1
- package/package.json +2 -1
- package/scripts/module-reach.mjs +280 -0
- package/src/api/auth-handler.ts +19 -4
- package/src/api-handlers/mfa/totp-verify.ts +24 -3
- package/src/api-handlers/session/refresh-viability.ts +2 -2
- package/src/api-handlers/session/viability.ts +1 -1
- package/src/auth/auth-decision.ts +28 -8
- package/src/auth/better-auth.ts +128 -1
- package/src/auth/safe-callback.ts +185 -0
- package/src/hooks/useProfile.ts +8 -1
- package/src/lib/acr.ts +109 -0
- package/src/lib/session-store.ts +66 -7
- package/src/middleware/create-middleware.ts +45 -13
- package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
- package/src/pages/verify-code/page.tsx +26 -1
- package/src/routes/auth/viability.ts +2 -2
- package/src/server/auth-guard.ts +27 -1
- package/dist/middleware/auth-decision.d.ts +0 -33
- package/dist/middleware/auth-decision.js +0 -65
- 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));
|
package/src/api/auth-handler.ts
CHANGED
|
@@ -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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
|
|
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
|
-
//
|
|
197
|
-
const safeCallbackUrl = 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
|
-
//
|
|
216
|
-
|
|
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
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|