@payez/next-mvp 4.4.4 → 4.4.6
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/README.md +10 -3
- package/dist/api/auth-handler.js +5 -3
- package/dist/api-handlers/account/change-password.js +6 -3
- package/dist/api-handlers/account/masked-info.js +2 -1
- package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
- package/dist/api-handlers/account/recovery/forward.js +105 -0
- package/dist/api-handlers/account/recovery/initiate.js +4 -0
- package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
- package/dist/api-handlers/account/recovery/send-code.js +11 -84
- package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
- package/dist/api-handlers/account/recovery/verify-code.js +10 -84
- package/dist/api-handlers/account/reset-password.js +24 -1
- package/dist/api-handlers/account/send-code.d.ts +1 -15
- package/dist/api-handlers/account/send-code.js +3 -6
- package/dist/api-handlers/account/update-phone.js +6 -1
- package/dist/api-handlers/account/validate-password.js +3 -1
- package/dist/api-handlers/account/verify-email.d.ts +1 -17
- package/dist/api-handlers/account/verify-email.js +4 -6
- package/dist/api-handlers/account/verify-sms.d.ts +1 -17
- package/dist/api-handlers/account/verify-sms.js +4 -6
- package/dist/api-handlers/admin/analytics.js +12 -4
- package/dist/api-handlers/admin/audit.js +12 -4
- package/dist/api-handlers/admin/redis-sessions.js +16 -8
- package/dist/api-handlers/admin/sessions.js +14 -6
- package/dist/api-handlers/admin/site-logs.js +39 -16
- package/dist/api-handlers/admin/stats.js +14 -4
- package/dist/api-handlers/admin/users.js +14 -6
- package/dist/api-handlers/admin/vibe-data.js +19 -11
- package/dist/api-handlers/auth/jwks.js +2 -1
- package/dist/api-handlers/auth/login.js +13 -4
- package/dist/api-handlers/auth/passkey-signin.js +6 -0
- package/dist/api-handlers/auth/refresh.js +38 -11
- package/dist/api-handlers/auth/signout.js +2 -1
- package/dist/api-handlers/auth/status.js +4 -1
- package/dist/api-handlers/auth/update-session.d.ts +25 -4
- package/dist/api-handlers/auth/update-session.js +38 -11
- package/dist/api-handlers/auth/validate.js +4 -2
- package/dist/api-handlers/auth/verify-code.js +3 -2
- package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
- package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
- package/dist/api-handlers/mfa/backup-codes.js +2 -1
- package/dist/api-handlers/mfa/methods.js +2 -1
- package/dist/api-handlers/mfa/totp-confirm.js +2 -1
- package/dist/api-handlers/mfa/totp-enroll.js +2 -1
- package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
- package/dist/api-handlers/mfa/totp-verify.js +4 -6
- package/dist/api-handlers/passkeys/list.js +2 -1
- package/dist/api-handlers/passkeys/manage.js +2 -1
- package/dist/api-handlers/passkeys/register-options.js +2 -1
- package/dist/api-handlers/passkeys/register-verify.js +2 -1
- package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
- package/dist/api-handlers/session/refresh-viability.js +10 -7
- package/dist/api-handlers/session/viability.js +4 -3
- package/dist/api-handlers/test/force-expire.js +5 -3
- package/dist/auth/auth-decision.js +2 -1
- package/dist/auth/better-auth.d.ts +14 -0
- package/dist/auth/better-auth.js +33 -13
- package/dist/auth/utils/idp-client.js +2 -1
- package/dist/components/admin/DataBrowserTab.js +4 -1
- package/dist/components/admin/SessionsTab.js +9 -3
- package/dist/components/recovery/RequestedStep.d.ts +11 -0
- package/dist/components/recovery/RequestedStep.js +14 -0
- package/dist/components/recovery/SetPasswordStep.js +10 -1
- package/dist/lib/app-slug.d.ts +21 -0
- package/dist/lib/app-slug.js +51 -0
- package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
- package/dist/lib/ensure-fresh-access-token.js +105 -4
- package/dist/lib/idp-client-config.d.ts +20 -0
- package/dist/lib/idp-client-config.js +65 -14
- package/dist/lib/idp-fetch.js +1 -1
- package/dist/lib/password-rules.d.ts +22 -0
- package/dist/lib/password-rules.js +43 -0
- package/dist/lib/safe-error-response.d.ts +67 -0
- package/dist/lib/safe-error-response.js +130 -0
- package/dist/lib/session-cookie-shape.d.ts +22 -19
- package/dist/lib/session-cookie-shape.js +14 -18
- package/dist/lib/session-store.d.ts +8 -1
- package/dist/lib/session-store.js +170 -24
- package/dist/lib/startup-init.d.ts +6 -0
- package/dist/lib/startup-init.js +30 -4
- package/dist/lib/token-lifecycle.js +2 -1
- package/dist/lib/token-log-id.d.ts +18 -0
- package/dist/lib/token-log-id.js +84 -0
- package/dist/lib/two-factor-verify-failure.d.ts +45 -0
- package/dist/lib/two-factor-verify-failure.js +143 -0
- package/dist/middleware/create-middleware.d.ts +2 -1
- package/dist/middleware/create-middleware.js +43 -8
- package/dist/pages/recovery/page.js +14 -4
- package/dist/pages/test-env/JwtInspectPage.js +7 -149
- package/dist/pages/test-env/RefreshTokenPage.js +4 -1
- package/dist/pages/test-env/redact-credentials.d.ts +6 -0
- package/dist/pages/test-env/redact-credentials.js +101 -0
- package/dist/pages/verify-code/page.js +2 -1
- package/dist/routes/auth/logout.d.ts +2 -1
- package/dist/routes/auth/logout.js +4 -2
- package/dist/routes/auth/session.d.ts +0 -3
- package/dist/routes/auth/session.js +16 -10
- package/dist/routes/auth/viability.d.ts +3 -2
- package/dist/routes/auth/viability.js +11 -7
- package/dist/server/auth.js +10 -6
- package/dist/server/decode-session.js +15 -26
- package/dist/server/verified-session-token.d.ts +13 -0
- package/dist/server/verified-session-token.js +103 -0
- package/dist/stores/authStore.js +14 -3
- package/dist/types/recovery.d.ts +1 -1
- package/dist/utils/api.d.ts +18 -0
- package/dist/utils/api.js +51 -9
- package/dist/vibe/sessions.js +2 -1
- package/package.json +2 -1
- package/scripts/verify-release-provenance.mjs +410 -0
- package/src/api/auth-handler.ts +5 -3
- package/src/api-handlers/account/change-password.ts +6 -3
- package/src/api-handlers/account/masked-info.ts +2 -1
- package/src/api-handlers/account/recovery/forward.ts +117 -0
- package/src/api-handlers/account/recovery/initiate.ts +3 -0
- package/src/api-handlers/account/recovery/send-code.ts +13 -99
- package/src/api-handlers/account/recovery/verify-code.ts +12 -99
- package/src/api-handlers/account/reset-password.ts +30 -1
- package/src/api-handlers/account/send-code.ts +3 -9
- package/src/api-handlers/account/update-phone.ts +6 -1
- package/src/api-handlers/account/validate-password.ts +3 -1
- package/src/api-handlers/account/verify-email.ts +4 -9
- package/src/api-handlers/account/verify-sms.ts +4 -9
- package/src/api-handlers/admin/analytics.ts +13 -5
- package/src/api-handlers/admin/audit.ts +13 -5
- package/src/api-handlers/admin/redis-sessions.ts +16 -8
- package/src/api-handlers/admin/sessions.ts +15 -7
- package/src/api-handlers/admin/site-logs.ts +39 -16
- package/src/api-handlers/admin/stats.ts +15 -5
- package/src/api-handlers/admin/users.ts +15 -7
- package/src/api-handlers/admin/vibe-data.ts +20 -12
- package/src/api-handlers/auth/jwks.ts +2 -1
- package/src/api-handlers/auth/login.ts +13 -4
- package/src/api-handlers/auth/passkey-signin.ts +4 -0
- package/src/api-handlers/auth/refresh.ts +38 -11
- package/src/api-handlers/auth/signout.ts +2 -1
- package/src/api-handlers/auth/status.ts +4 -1
- package/src/api-handlers/auth/update-session.ts +45 -11
- package/src/api-handlers/auth/validate.ts +4 -2
- package/src/api-handlers/auth/verify-code.ts +3 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
- package/src/api-handlers/mfa/backup-codes.ts +2 -1
- package/src/api-handlers/mfa/methods.ts +2 -1
- package/src/api-handlers/mfa/totp-confirm.ts +2 -1
- package/src/api-handlers/mfa/totp-enroll.ts +2 -1
- package/src/api-handlers/mfa/totp-verify.ts +4 -9
- package/src/api-handlers/passkeys/list.ts +2 -1
- package/src/api-handlers/passkeys/manage.ts +2 -1
- package/src/api-handlers/passkeys/register-options.ts +2 -1
- package/src/api-handlers/passkeys/register-verify.ts +2 -1
- package/src/api-handlers/session/refresh-viability.ts +10 -7
- package/src/api-handlers/session/viability.ts +4 -3
- package/src/api-handlers/test/force-expire.ts +5 -3
- package/src/auth/auth-decision.ts +2 -1
- package/src/auth/better-auth.ts +35 -10
- package/src/auth/utils/idp-client.ts +2 -1
- package/src/components/admin/DataBrowserTab.tsx +4 -1
- package/src/components/admin/SessionsTab.tsx +10 -3
- package/src/components/recovery/RequestedStep.tsx +30 -0
- package/src/components/recovery/SetPasswordStep.tsx +24 -3
- package/src/hooks/useViabilitySession.ts +2 -1
- package/src/lib/app-slug.ts +50 -0
- package/src/lib/ensure-fresh-access-token.ts +133 -4
- package/src/lib/idp-client-config.ts +90 -14
- package/src/lib/idp-fetch.ts +1 -1
- package/src/lib/password-rules.ts +50 -0
- package/src/lib/safe-error-response.ts +126 -0
- package/src/lib/session-cookie-shape.ts +23 -22
- package/src/lib/session-store.ts +167 -24
- package/src/lib/startup-init.ts +29 -4
- package/src/lib/token-lifecycle.ts +2 -1
- package/src/lib/token-log-id.ts +69 -0
- package/src/lib/two-factor-verify-failure.ts +167 -0
- package/src/middleware/create-middleware.ts +55 -9
- package/src/pages/recovery/page.tsx +21 -4
- package/src/pages/test-env/JwtInspectPage.tsx +11 -233
- package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
- package/src/pages/test-env/redact-credentials.ts +96 -0
- package/src/pages/verify-code/page.tsx +2 -1
- package/src/routes/auth/logout.ts +4 -2
- package/src/routes/auth/session.ts +16 -10
- package/src/routes/auth/viability.ts +11 -7
- package/src/server/auth.ts +10 -6
- package/src/server/decode-session.ts +16 -26
- package/src/server/verified-session-token.ts +103 -0
- package/src/stores/authStore.ts +16 -5
- package/src/types/recovery.ts +1 -1
- package/src/utils/api.ts +47 -8
- package/src/vibe/sessions.ts +2 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* RELEASE PROVENANCE VERIFIER — 340024 / BAPert R1037 s2.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Published 4.4.5 ships source that EXISTS IN NO COMMIT. Measured
|
|
6
|
+
* three ways; the decisive instrument was `git cat-file -e`, which resolves loose AND
|
|
7
|
+
* packed objects regardless of reachability — and 4.4.5's `src/auth/better-auth.ts`
|
|
8
|
+
* blob DOES NOT EXIST in the object store. Not a dangling object a reflog walk could
|
|
9
|
+
* recover: the bytes were never committed. The only shape consistent with that is a
|
|
10
|
+
* PUBLISH FROM A WORKING TREE WITH UNCOMMITTED EDITS.
|
|
11
|
+
*
|
|
12
|
+
* You cannot fix that after the fact — the source is gone. So R1037 s2 replaced the
|
|
13
|
+
* unanswerable question ("where did 4.4.5 come from?") with a pass/fail check on the
|
|
14
|
+
* artefact you are ABOUT to ship, which is this script:
|
|
15
|
+
*
|
|
16
|
+
* Every shipped file that is supposed to come from the repo must hash-match the
|
|
17
|
+
* blob recorded at the release commit FOR THAT PATH. Anything else = do not ship.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------------
|
|
20
|
+
* DESIGN NOTES (deliberate; do not "helpfully" soften these)
|
|
21
|
+
*
|
|
22
|
+
* 1. IT IS PATH-ANCHORED, NOT EXISTENCE-BASED. The gate was specified as "confirm the
|
|
23
|
+
* blob hash RESOLVES IN THE STORE". That is too weak for a pre-ship gate: a file
|
|
24
|
+
* from a DIFFERENT commit resolves perfectly and is still the wrong bytes. We
|
|
25
|
+
* compare against `<commit>:<path>` — the blob recorded at that path at that
|
|
26
|
+
* commit. Store existence is still computed, but only as a DIAGNOSIS on failures,
|
|
27
|
+
* because it separates two faults with different remediations:
|
|
28
|
+
*
|
|
29
|
+
* mismatch + hash EXISTS in store -> packed from the WRONG REVISION (re-checkout)
|
|
30
|
+
* mismatch + hash ABSENT from store -> UNCOMMITTED EDITS (this is the 4.4.5 disease)
|
|
31
|
+
*
|
|
32
|
+
* 2. IT CLASSIFIES, BECAUSE THE TARBALL IS NOT ALL SOURCE. Measured on real 4.4.5:
|
|
33
|
+
* 744 entries = 488 dist/ + 246 src/ + 7 scripts/ + package.json + 2 docs. `dist/`
|
|
34
|
+
* is GITIGNORED BUILD OUTPUT and can never hash-match a commit. A gate that failed
|
|
35
|
+
* on every unmatched file would report 488 failures on a PERFECTLY CLEAN publish,
|
|
36
|
+
* and a control that always fires is a control that gets switched off. So each
|
|
37
|
+
* shipped path is assigned to a declared class and checked accordingly.
|
|
38
|
+
*
|
|
39
|
+
* 3. UNKNOWN PATHS FAIL. If someone adds an entry to package.json "files", it lands in
|
|
40
|
+
* no class and this EXITS 1 rather than ignoring it. A verifier that silently skips
|
|
41
|
+
* what it does not recognise cannot be trusted to have checked anything. Fail closed.
|
|
42
|
+
*
|
|
43
|
+
* 4. MISSING FILES FAIL TOO. A file committed under a verified prefix but ABSENT from
|
|
44
|
+
* the tarball is a truncated publish — the same class of defect, opposite sign.
|
|
45
|
+
* Checked in both directions.
|
|
46
|
+
*
|
|
47
|
+
* 5. LINE ENDINGS ARE NORMALISED CRLF->LF BEFORE HASHING, and this is load-bearing:
|
|
48
|
+
* tarballs packed on Windows ship CRLF (measured: 1416 CRLF in the 4.4.5 tarball)
|
|
49
|
+
* while the repo blobs are LF (measured: 0). Comparing raw bytes reports every text
|
|
50
|
+
* file as a mismatch. Binary files (NUL byte present) are NEVER normalised.
|
|
51
|
+
*
|
|
52
|
+
* 6. `dist/` IS NOT PROVENANCE-VERIFIED AND THIS SCRIPT SAYS SO OUT LOUD. It is
|
|
53
|
+
* ENTAILED — built by prepack from sources this script proved clean — not verified.
|
|
54
|
+
* Anyone who wants dist verified needs a reproducible-build comparison, which is a
|
|
55
|
+
* different and more expensive control. Stating the limit is the point: a gate that
|
|
56
|
+
* overstates its coverage is worse than one that admits a gap, because the gap then
|
|
57
|
+
* goes unstaffed.
|
|
58
|
+
*
|
|
59
|
+
* 7. NO NETWORK, NO DEPENDENCIES. Reads a local .tgz and a local git object store.
|
|
60
|
+
* Same reasoning as check-publish-health.mjs's refusal to enter prepack: a release
|
|
61
|
+
* control that needs the network trades one outage for another.
|
|
62
|
+
*
|
|
63
|
+
* ---------------------------------------------------------------------------------
|
|
64
|
+
* Usage:
|
|
65
|
+
* npm run check:provenance -- --tarball <file.tgz> --commit <sha>
|
|
66
|
+
* node scripts/verify-release-provenance.mjs --tarball <file.tgz> --commit <sha>
|
|
67
|
+
* [--repo <path>] [--pkg-dir packages/next-mvp] [--json] [--quiet]
|
|
68
|
+
*
|
|
69
|
+
* Runs correctly from ANY cwd (repo root, package dir, or outside the repo):
|
|
70
|
+
* --repo/--pkg-dir default to this script's own location, not process.cwd().
|
|
71
|
+
*
|
|
72
|
+
* Exit: 0 = PASS every verifiable shipped file matches the commit
|
|
73
|
+
* 1 = FAIL mismatch / missing / unclassified -> RE-RUN, DO NOT SHIP
|
|
74
|
+
* 2 = INCONCLUSIVE could not read tarball or git -> NOT a pass
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import { createHash } from 'node:crypto';
|
|
78
|
+
import { gunzipSync } from 'node:zlib';
|
|
79
|
+
import { readFileSync } from 'node:fs';
|
|
80
|
+
import { execFileSync } from 'node:child_process';
|
|
81
|
+
import { fileURLToPath } from 'node:url';
|
|
82
|
+
import path from 'node:path';
|
|
83
|
+
|
|
84
|
+
const EXIT_PASS = 0;
|
|
85
|
+
const EXIT_FAIL = 1;
|
|
86
|
+
const EXIT_INCONCLUSIVE = 2;
|
|
87
|
+
|
|
88
|
+
/* ------------------------------------------------------------------ classification */
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Paths are relative to the tarball's "package/" root.
|
|
92
|
+
* Order matters: first match wins, so put specific paths before prefixes.
|
|
93
|
+
*/
|
|
94
|
+
const CLASSES = [
|
|
95
|
+
// Rewritten by npm at pack time (_id, dist, gitHead, field normalisation).
|
|
96
|
+
// Never byte-matches its committed blob. Checked semantically instead.
|
|
97
|
+
{ cls: 'MANIFEST', test: (p) => p === 'package.json' },
|
|
98
|
+
|
|
99
|
+
// Gitignored build output. See design note 6.
|
|
100
|
+
{ cls: 'DERIVED', test: (p) => p.startsWith('dist/') },
|
|
101
|
+
|
|
102
|
+
// Committed source — the class this gate exists to protect.
|
|
103
|
+
{ cls: 'VERIFIED', test: (p) => p.startsWith('src/') },
|
|
104
|
+
{ cls: 'VERIFIED', test: (p) => p.startsWith('scripts/') },
|
|
105
|
+
{ cls: 'VERIFIED', test: (p) => p === 'POST-INSTALL.md' },
|
|
106
|
+
// npm always ships README regardless of "files"; it is committed, so verify it.
|
|
107
|
+
{ cls: 'VERIFIED', test: (p) => p === 'README.md' },
|
|
108
|
+
{ cls: 'VERIFIED', test: (p) => p === 'LICENSE' || p === 'LICENCE' },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
function classify(path) {
|
|
112
|
+
for (const c of CLASSES) if (c.test(path)) return c.cls;
|
|
113
|
+
return 'UNCLASSIFIED'; // design note 3 — fail closed
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ------------------------------------------------------------------------ tar read */
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Minimal USTAR/GNU reader. We parse rather than shell out to `tar` so the check
|
|
120
|
+
* never writes a temp tree it might then read back differently (and so it behaves
|
|
121
|
+
* identically on the Windows seat and the Linux agent).
|
|
122
|
+
*/
|
|
123
|
+
function readTar(buf) {
|
|
124
|
+
const entries = new Map();
|
|
125
|
+
let off = 0;
|
|
126
|
+
let longName = null;
|
|
127
|
+
|
|
128
|
+
const str = (b, s, n) => {
|
|
129
|
+
const slice = b.subarray(s, s + n);
|
|
130
|
+
const z = slice.indexOf(0);
|
|
131
|
+
return slice.subarray(0, z === -1 ? slice.length : z).toString('utf8');
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
while (off + 512 <= buf.length) {
|
|
135
|
+
const hdr = buf.subarray(off, off + 512);
|
|
136
|
+
if (hdr.every((b) => b === 0)) break; // end-of-archive
|
|
137
|
+
|
|
138
|
+
const name = str(hdr, 0, 100);
|
|
139
|
+
const sizeField = str(hdr, 124, 12).trim();
|
|
140
|
+
const size = parseInt(sizeField, 8) || 0;
|
|
141
|
+
const type = String.fromCharCode(hdr[156]);
|
|
142
|
+
const prefix = str(hdr, 345, 155);
|
|
143
|
+
|
|
144
|
+
off += 512;
|
|
145
|
+
const data = buf.subarray(off, off + size);
|
|
146
|
+
off += Math.ceil(size / 512) * 512;
|
|
147
|
+
|
|
148
|
+
if (type === 'L') {
|
|
149
|
+
// GNU long name: applies to the NEXT header.
|
|
150
|
+
longName = data.toString('utf8').replace(/\0+$/, '');
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let full = longName ?? (prefix ? `${prefix}/${name}` : name);
|
|
155
|
+
longName = null;
|
|
156
|
+
|
|
157
|
+
if (type === '0' || type === '\0' || type === '') {
|
|
158
|
+
entries.set(full.replace(/\\/g, '/'), Buffer.from(data));
|
|
159
|
+
}
|
|
160
|
+
// directories (5), links, pax headers: not content, ignored deliberately
|
|
161
|
+
}
|
|
162
|
+
return entries;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ------------------------------------------------------------------------- hashing */
|
|
166
|
+
|
|
167
|
+
const isBinary = (buf) => buf.includes(0);
|
|
168
|
+
|
|
169
|
+
/** CRLF -> LF, text only. See design note 5. */
|
|
170
|
+
function normalise(buf) {
|
|
171
|
+
if (isBinary(buf)) return buf;
|
|
172
|
+
return Buffer.from(buf.toString('binary').replace(/\r\n/g, '\n'), 'binary');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** git blob id = sha1("blob <bytelen>\0" + content) */
|
|
176
|
+
function gitBlobId(buf) {
|
|
177
|
+
return createHash('sha1')
|
|
178
|
+
.update(`blob ${buf.length}\0`, 'binary')
|
|
179
|
+
.update(buf)
|
|
180
|
+
.digest('hex');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* ----------------------------------------------------------------------------- git */
|
|
184
|
+
|
|
185
|
+
function git(repo, args) {
|
|
186
|
+
return execFileSync('git', ['-C', repo, ...args], {
|
|
187
|
+
encoding: 'utf8',
|
|
188
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
189
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** path (relative to pkgDir) -> blob sha, for one commit. ONE git call. */
|
|
194
|
+
function treeAtCommit(repo, commit, pkgDir) {
|
|
195
|
+
const out = git(repo, ['ls-tree', '-r', commit, '--', pkgDir]);
|
|
196
|
+
const map = new Map();
|
|
197
|
+
for (const line of out.split('\n')) {
|
|
198
|
+
if (!line.trim()) continue;
|
|
199
|
+
const m = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.*)$/);
|
|
200
|
+
if (!m) continue;
|
|
201
|
+
const [, , objType, sha, fullPath] = m;
|
|
202
|
+
if (objType !== 'blob') continue;
|
|
203
|
+
const rel = fullPath.startsWith(pkgDir + '/') ? fullPath.slice(pkgDir.length + 1) : null;
|
|
204
|
+
if (rel) map.set(rel, sha);
|
|
205
|
+
}
|
|
206
|
+
return map;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function existsInStore(repo, sha) {
|
|
210
|
+
try {
|
|
211
|
+
git(repo, ['cat-file', '-e', `${sha}^{object}`]);
|
|
212
|
+
return true;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ---------------------------------------------------------------------------- main */
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Defaults must NOT come from process.cwd().
|
|
222
|
+
*
|
|
223
|
+
* MEASURED 2026-09-14: with repo defaulting to cwd, running this from the package
|
|
224
|
+
* directory -- which is exactly what an `npm run` script does -- makes git resolve
|
|
225
|
+
* `ls-tree -- packages/next-mvp` against packages/next-mvp itself, matching nothing.
|
|
226
|
+
* Result: "INCONCLUSIVE: no blobs under packages/next-mvp", exit 2, on a tarball that
|
|
227
|
+
* is in fact clean. A gate whose most natural invoker always answers INCONCLUSIVE is
|
|
228
|
+
* a gate that gets switched off.
|
|
229
|
+
*
|
|
230
|
+
* So anchor both defaults to THIS FILE's location: the repo is whatever toplevel the
|
|
231
|
+
* script lives in, and the package prefix is the script's parent relative to that
|
|
232
|
+
* toplevel. Correct from any cwd, and still correct if the package is ever moved.
|
|
233
|
+
* Both remain overridable by --repo / --pkg-dir.
|
|
234
|
+
*/
|
|
235
|
+
function locateSelf() {
|
|
236
|
+
const fallback = { repo: process.cwd(), pkgDir: 'packages/next-mvp' };
|
|
237
|
+
try {
|
|
238
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
239
|
+
const pkgRoot = path.resolve(scriptDir, '..');
|
|
240
|
+
const toplevel = execFileSync('git', ['-C', pkgRoot, 'rev-parse', '--show-toplevel'], {
|
|
241
|
+
encoding: 'utf8',
|
|
242
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
243
|
+
}).trim();
|
|
244
|
+
if (!toplevel) return fallback;
|
|
245
|
+
const rel = path.relative(toplevel, pkgRoot).split(path.sep).join('/');
|
|
246
|
+
// rel === '' would mean the package IS the repo root; keep the explicit default.
|
|
247
|
+
return { repo: toplevel, pkgDir: rel || fallback.pkgDir };
|
|
248
|
+
} catch {
|
|
249
|
+
return fallback;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseArgs(argv) {
|
|
254
|
+
const self = locateSelf();
|
|
255
|
+
const a = { repo: self.repo, pkgDir: self.pkgDir, json: false, quiet: false };
|
|
256
|
+
for (let i = 0; i < argv.length; i++) {
|
|
257
|
+
const k = argv[i];
|
|
258
|
+
if (k === '--tarball') a.tarball = argv[++i];
|
|
259
|
+
else if (k === '--commit') a.commit = argv[++i];
|
|
260
|
+
else if (k === '--repo') a.repo = argv[++i];
|
|
261
|
+
else if (k === '--pkg-dir') a.pkgDir = argv[++i];
|
|
262
|
+
else if (k === '--json') a.json = true;
|
|
263
|
+
else if (k === '--quiet') a.quiet = true;
|
|
264
|
+
else if (k === '--help' || k === '-h') a.help = true;
|
|
265
|
+
else throw new Error(`unknown argument: ${k}`);
|
|
266
|
+
}
|
|
267
|
+
return a;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function main() {
|
|
271
|
+
let args;
|
|
272
|
+
try {
|
|
273
|
+
args = parseArgs(process.argv.slice(2));
|
|
274
|
+
} catch (e) {
|
|
275
|
+
console.error(`[provenance] ${e.message}`);
|
|
276
|
+
return EXIT_INCONCLUSIVE;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (args.help || !args.tarball || !args.commit) {
|
|
280
|
+
console.error(
|
|
281
|
+
'usage: node scripts/verify-release-provenance.mjs --tarball <file.tgz> --commit <sha>\n' +
|
|
282
|
+
' [--repo <path>] [--pkg-dir packages/next-mvp] [--json] [--quiet]'
|
|
283
|
+
);
|
|
284
|
+
return EXIT_INCONCLUSIVE;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- read inputs. Any failure here is INCONCLUSIVE, never a pass.
|
|
288
|
+
let entries, committed, resolvedCommit;
|
|
289
|
+
try {
|
|
290
|
+
entries = readTar(gunzipSync(readFileSync(args.tarball)));
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.error(`[provenance] INCONCLUSIVE: cannot read tarball ${args.tarball}: ${e.message}`);
|
|
293
|
+
return EXIT_INCONCLUSIVE;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
resolvedCommit = git(args.repo, ['rev-parse', `${args.commit}^{commit}`]).trim();
|
|
297
|
+
committed = treeAtCommit(args.repo, resolvedCommit, args.pkgDir);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
console.error(`[provenance] INCONCLUSIVE: cannot read git at ${args.repo}: ${e.message}`);
|
|
300
|
+
return EXIT_INCONCLUSIVE;
|
|
301
|
+
}
|
|
302
|
+
if (entries.size === 0) {
|
|
303
|
+
console.error('[provenance] INCONCLUSIVE: tarball contained zero file entries');
|
|
304
|
+
return EXIT_INCONCLUSIVE;
|
|
305
|
+
}
|
|
306
|
+
if (committed.size === 0) {
|
|
307
|
+
console.error(`[provenance] INCONCLUSIVE: no blobs under ${args.pkgDir} at ${args.commit}`);
|
|
308
|
+
return EXIT_INCONCLUSIVE;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- classify + check
|
|
312
|
+
const res = { matched: [], mismatched: [], unclassified: [], derived: [], manifest: [], missing: [] };
|
|
313
|
+
let normalisedCount = 0;
|
|
314
|
+
|
|
315
|
+
for (const [tarPath, raw] of entries) {
|
|
316
|
+
if (!tarPath.startsWith('package/')) {
|
|
317
|
+
res.unclassified.push({ path: tarPath, why: 'outside package/ root' });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const p = tarPath.slice('package/'.length);
|
|
321
|
+
const cls = classify(p);
|
|
322
|
+
|
|
323
|
+
if (cls === 'DERIVED') { res.derived.push(p); continue; }
|
|
324
|
+
if (cls === 'MANIFEST') { res.manifest.push(p); continue; }
|
|
325
|
+
if (cls === 'UNCLASSIFIED') {
|
|
326
|
+
res.unclassified.push({ path: p, why: 'shipped but in no declared class' });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const norm = normalise(raw);
|
|
331
|
+
if (norm.length !== raw.length) normalisedCount++;
|
|
332
|
+
const actual = gitBlobId(norm);
|
|
333
|
+
const expected = committed.get(p);
|
|
334
|
+
|
|
335
|
+
if (!expected) {
|
|
336
|
+
res.mismatched.push({ path: p, actual, expected: null, diagnosis: 'SHIPPED BUT NOT COMMITTED AT THIS COMMIT' });
|
|
337
|
+
} else if (actual === expected) {
|
|
338
|
+
res.matched.push(p);
|
|
339
|
+
} else {
|
|
340
|
+
res.mismatched.push({
|
|
341
|
+
path: p,
|
|
342
|
+
actual,
|
|
343
|
+
expected,
|
|
344
|
+
diagnosis: existsInStore(args.repo, actual)
|
|
345
|
+
? 'WRONG REVISION (bytes exist in store, but not at this path/commit)'
|
|
346
|
+
: 'DIRTY TREE (bytes exist in NO commit — uncommitted edit)',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- reverse direction: committed under a verified prefix but not shipped
|
|
352
|
+
for (const [p] of committed) {
|
|
353
|
+
if (classify(p) !== 'VERIFIED') continue;
|
|
354
|
+
if (!entries.has(`package/${p}`)) res.missing.push(p);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const verifiable = res.matched.length + res.mismatched.length;
|
|
358
|
+
const failed = res.mismatched.length + res.unclassified.length + res.missing.length;
|
|
359
|
+
const pass = failed === 0;
|
|
360
|
+
|
|
361
|
+
if (args.json) {
|
|
362
|
+
console.log(JSON.stringify({ pass, commit: resolvedCommit, tarball: args.tarball, ...res }, null, 2));
|
|
363
|
+
return pass ? EXIT_PASS : EXIT_FAIL;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const log = args.quiet ? () => {} : (...a) => console.log(...a);
|
|
367
|
+
log('');
|
|
368
|
+
log(' RELEASE PROVENANCE — tarball vs commit');
|
|
369
|
+
log(` tarball : ${args.tarball}`);
|
|
370
|
+
log(` commit : ${resolvedCommit}`);
|
|
371
|
+
log(` pkg dir : ${args.pkgDir}`);
|
|
372
|
+
log(' ' + '-'.repeat(70));
|
|
373
|
+
log(` VERIFIED ${String(res.matched.length).padStart(4)} / ${verifiable} shipped source files match the commit`);
|
|
374
|
+
log(` DERIVED ${String(res.derived.length).padStart(4)} dist/ build output — NOT provenance-verified (see note 6)`);
|
|
375
|
+
log(` MANIFEST ${String(res.manifest.length).padStart(4)} package.json — rewritten by npm, not byte-comparable`);
|
|
376
|
+
log(` normalised ${String(normalisedCount).padStart(4)} text files had CRLF stripped before hashing`);
|
|
377
|
+
|
|
378
|
+
if (res.mismatched.length) {
|
|
379
|
+
log('');
|
|
380
|
+
log(` *** ${res.mismatched.length} FILE(S) DO NOT MATCH THE COMMIT ***`);
|
|
381
|
+
for (const m of res.mismatched.slice(0, 40)) {
|
|
382
|
+
log(` ${m.path}`);
|
|
383
|
+
log(` shipped ${m.actual}`);
|
|
384
|
+
log(` commit ${m.expected ?? '(no blob at this path)'}`);
|
|
385
|
+
log(` -> ${m.diagnosis}`);
|
|
386
|
+
}
|
|
387
|
+
if (res.mismatched.length > 40) log(` ... and ${res.mismatched.length - 40} more`);
|
|
388
|
+
}
|
|
389
|
+
if (res.missing.length) {
|
|
390
|
+
log('');
|
|
391
|
+
log(` *** ${res.missing.length} COMMITTED FILE(S) MISSING FROM TARBALL (truncated publish) ***`);
|
|
392
|
+
for (const p of res.missing.slice(0, 20)) log(` ${p}`);
|
|
393
|
+
if (res.missing.length > 20) log(` ... and ${res.missing.length - 20} more`);
|
|
394
|
+
}
|
|
395
|
+
if (res.unclassified.length) {
|
|
396
|
+
log('');
|
|
397
|
+
log(` *** ${res.unclassified.length} SHIPPED FILE(S) IN NO DECLARED CLASS ***`);
|
|
398
|
+
log(' (fail closed — add them to CLASSES or remove them from "files")');
|
|
399
|
+
for (const u of res.unclassified.slice(0, 20)) log(` ${u.path} — ${u.why}`);
|
|
400
|
+
if (res.unclassified.length > 20) log(` ... and ${res.unclassified.length - 20} more`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
log('');
|
|
404
|
+
log(pass ? ' PASS — every verifiable shipped file traces to this commit.'
|
|
405
|
+
: ' FAIL — RE-RUN THE PUBLISH FROM A CLEAN CHECKOUT. DO NOT SHIP.');
|
|
406
|
+
log('');
|
|
407
|
+
return pass ? EXIT_PASS : EXIT_FAIL;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
process.exit(main());
|
package/src/api/auth-handler.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
checkRefreshLock,
|
|
25
25
|
type SessionData
|
|
26
26
|
} from '../lib/session-store';
|
|
27
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
export interface AuthContext {
|
|
@@ -268,7 +269,7 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
|
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
console.info('[AUTH_HANDLER] Executing refresh against IDP', {
|
|
271
|
-
sessionToken: sessionToken
|
|
272
|
+
sessionToken: tokenLogId(sessionToken),
|
|
272
273
|
hasRefreshToken: true
|
|
273
274
|
});
|
|
274
275
|
|
|
@@ -283,7 +284,8 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
|
|
|
283
284
|
|
|
284
285
|
if (!response.ok) {
|
|
285
286
|
const errorText = await response.text().catch(() => 'Unknown error');
|
|
286
|
-
|
|
287
|
+
// PAY-1427: never the raw upstream body (the refresh.ts:528 twin PR 139 fixed). Status + length only.
|
|
288
|
+
console.error('[AUTH_HANDLER] Refresh failed:', { status: response.status, bodyLength: errorText.length });
|
|
287
289
|
return { success: false, error: `Refresh failed: ${response.status}` };
|
|
288
290
|
}
|
|
289
291
|
|
|
@@ -441,7 +443,7 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
|
|
|
441
443
|
const requestId = generateRequestId();
|
|
442
444
|
console.info('[AUTH_HANDLER] Token near expiry, initiating coordinated refresh', {
|
|
443
445
|
requestId,
|
|
444
|
-
sessionToken: authContext.sessionToken
|
|
446
|
+
sessionToken: tokenLogId(authContext.sessionToken)
|
|
445
447
|
});
|
|
446
448
|
|
|
447
449
|
const refreshResult = await performCoordinatedRefresh(
|
|
@@ -9,6 +9,7 @@ interface ChangePasswordRequest {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
import { nanoid } from 'nanoid';
|
|
12
|
+
import { projectPasswordPolicyDetails } from '../../lib/safe-error-response';
|
|
12
13
|
|
|
13
14
|
// ...
|
|
14
15
|
|
|
@@ -115,7 +116,8 @@ export async function POST(req: NextRequest) {
|
|
|
115
116
|
message: errorMessage,
|
|
116
117
|
error_code: responseData.error_code || 'CHANGE_PASSWORD_FAILED',
|
|
117
118
|
request_id: requestId,
|
|
118
|
-
|
|
119
|
+
// PAY-1438: only the password-policy messages the security pages display, never the raw envelope.
|
|
120
|
+
details: projectPasswordPolicyDetails(responseData.details),
|
|
119
121
|
},
|
|
120
122
|
{ status: idpResponse.status }
|
|
121
123
|
);
|
|
@@ -127,12 +129,13 @@ export async function POST(req: NextRequest) {
|
|
|
127
129
|
request_id: requestId,
|
|
128
130
|
});
|
|
129
131
|
} catch (error) {
|
|
130
|
-
console.error('[CHANGE_PASSWORD] Error:', error);
|
|
131
132
|
const requestId = req.headers.get('x-request-id') ?? crypto.randomUUID();
|
|
133
|
+
// PAY-1438: the exception text stays in the log, keyed by the request_id the caller receives.
|
|
134
|
+
console.error('[CHANGE_PASSWORD] Error:', { request_id: requestId, error });
|
|
132
135
|
return NextResponse.json(
|
|
133
136
|
{
|
|
134
137
|
success: false,
|
|
135
|
-
message:
|
|
138
|
+
message: 'Failed to change password',
|
|
136
139
|
error_code: 'INTERNAL_ERROR',
|
|
137
140
|
request_id: requestId,
|
|
138
141
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
3
|
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
+
import { projectIdpError } from '../../lib/safe-error-response';
|
|
4
5
|
|
|
5
6
|
// IDP masked-info is POST and uses capital 'A' in /api/Account
|
|
6
7
|
export async function POST(req: NextRequest) {
|
|
@@ -24,7 +25,7 @@ export async function POST(req: NextRequest) {
|
|
|
24
25
|
{
|
|
25
26
|
success: false,
|
|
26
27
|
message: 'Upstream error',
|
|
27
|
-
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
|
|
28
|
+
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
|
|
28
29
|
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
29
30
|
},
|
|
30
31
|
{ status: result.status }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { ENV_CONFIG } from '../../../config/env';
|
|
3
|
+
import { getIDPClientConfig } from '../../../lib/idp-client-config';
|
|
4
|
+
import { resolveClientKey } from '../../../lib/client-key';
|
|
5
|
+
import { idpErrorEnvelope } from '../../../lib/safe-error-response';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Account recovery - the ONE forwarder behind /recovery/send-code and /recovery/verify-code (PAY-1588).
|
|
9
|
+
*
|
|
10
|
+
* THE DEFECT (QAPert, W1 walk on :4797 -> dev-93 IdP 90f766bd0, 2026-09-19): portal /account-auth/recovery,
|
|
11
|
+
* initiate -> 200, send-code -> 401 "Upstream error". The browser sends `Authorization: Bearer <recovery session
|
|
12
|
+
* token>` (the token `initiate` minted); these handlers went through idpFetchJSON, which sets Authorization ONLY from
|
|
13
|
+
* the server-side SESSION and never forwards the inbound header, so the IdP got no Bearer at all
|
|
14
|
+
* (AccountController.cs:872-885 = 401 MISSING_TOKEN). Positive control direct to the IdP: without Authorization 401,
|
|
15
|
+
* with it 200 and the email is sent.
|
|
16
|
+
*
|
|
17
|
+
* WHY THE 2026-08-02 REWRITE GOT IT WRONG. It reasoned "browsers send a session COOKIE and no Authorization header"
|
|
18
|
+
* and moved both handlers onto idpFetchJSON. That is true of every OTHER authenticated route. It is false of this
|
|
19
|
+
* flow: the caller is LOCKED OUT, so there is no session to resolve - the recovery session token IS the credential,
|
|
20
|
+
* and the browser is the only party that holds it. Resolving a token from a session that does not exist is why the
|
|
21
|
+
* entire recovery path, both halves, has been dead: it fails exactly when it is the only thing left that could help.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THIS DOES INSTEAD
|
|
24
|
+
* - Forwards the inbound `Authorization: Bearer <token>` to the IdP and nothing else from the caller's headers.
|
|
25
|
+
* - Uses a direct fetch, NOT idpFetchJSON: a stray session belonging to someone else on the same browser must not
|
|
26
|
+
* be able to override the recovery credential with its own bearer (idpFetchJSON's session bearer wins over any
|
|
27
|
+
* caller-supplied Authorization).
|
|
28
|
+
* - FAILS LOUD when there is no usable bearer: 401 `missing_recovery_token`, without calling upstream. The old
|
|
29
|
+
* behaviour returned the IdP's opaque rejection, so the user saw recovery "not working" and the log showed an
|
|
30
|
+
* upstream error rather than the auth fault it was.
|
|
31
|
+
* - Shape-checks the token (a bearer scheme, one opaque token, sane length) before forwarding, so the header cannot
|
|
32
|
+
* be used to smuggle anything but a credential to the IdP. It is never logged.
|
|
33
|
+
* - Anonymous route (PAY-1438): on failure only the whitelisted envelope, never the IdP body.
|
|
34
|
+
*
|
|
35
|
+
* SERVER VALUE WINS for client_id (cards 268012 / 268410): applied AFTER the caller's payload spread, because these are
|
|
36
|
+
* unauthenticated endpoints and a browser-supplied client_id would be a tenant-targeting lever on a password-reset path.
|
|
37
|
+
*
|
|
38
|
+
* @package @payez/next-mvp
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** One opaque token: JWT / base64url / opaque-hex alphabet, no whitespace or control characters, bounded length. */
|
|
42
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9\-._~+/]+=*$/;
|
|
43
|
+
|
|
44
|
+
/** The recovery bearer from the inbound request, or null when absent / malformed. Never throws. */
|
|
45
|
+
export function inboundRecoveryBearer(req: Pick<NextRequest, 'headers'>): string | null {
|
|
46
|
+
const raw = req.headers.get('authorization');
|
|
47
|
+
if (!raw) return null;
|
|
48
|
+
const m = /^Bearer[ ]+(\S+)$/i.exec(raw.trim());
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const token = m[1];
|
|
51
|
+
if (token.length < 16 || token.length > 4096) return null;
|
|
52
|
+
return TOKEN_SHAPE.test(token) ? token : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function recoveryClientKey(where: string): Promise<string | null> {
|
|
56
|
+
try {
|
|
57
|
+
const config = await getIDPClientConfig();
|
|
58
|
+
return resolveClientKey(config, where).key;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(`[${where}] could not resolve IDP client config for client_id:`, e);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RecoveryForward {
|
|
66
|
+
/** IdP path after the host, e.g. /api/Account/recovery/send-code */
|
|
67
|
+
upstreamPath: string;
|
|
68
|
+
/** log/route label used for the client-key resolver */
|
|
69
|
+
where: string;
|
|
70
|
+
/** stable `error` code for an upstream that could not be reached */
|
|
71
|
+
failureCode: string;
|
|
72
|
+
/** user-facing fallback when the IdP gave no message */
|
|
73
|
+
fallbackMessage: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function forwardRecoveryCall(req: NextRequest, spec: RecoveryForward): Promise<NextResponse> {
|
|
77
|
+
let payload: unknown = {};
|
|
78
|
+
try {
|
|
79
|
+
payload = await req.json();
|
|
80
|
+
} catch {
|
|
81
|
+
return NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const bearer = inboundRecoveryBearer(req);
|
|
85
|
+
if (!bearer) {
|
|
86
|
+
return NextResponse.json(
|
|
87
|
+
{
|
|
88
|
+
success: false,
|
|
89
|
+
error: 'missing_recovery_token',
|
|
90
|
+
message: 'Your recovery session could not be found. Start the recovery again from the beginning.',
|
|
91
|
+
},
|
|
92
|
+
{ status: 401 },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const clientKey = await recoveryClientKey(spec.where);
|
|
97
|
+
|
|
98
|
+
let upstream: Response;
|
|
99
|
+
try {
|
|
100
|
+
upstream = await fetch(`${ENV_CONFIG.IDP_URL}${spec.upstreamPath}`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
'Content-Type': 'application/json',
|
|
104
|
+
Accept: 'application/json',
|
|
105
|
+
Authorization: `Bearer ${bearer}`,
|
|
106
|
+
...(clientKey ? { 'X-Client-Id': clientKey } : {}),
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify({ ...(payload as Record<string, unknown>), ...(clientKey ? { client_id: clientKey } : {}) }),
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
return NextResponse.json({ success: false, error: spec.failureCode }, { status: 502 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const data = await upstream.json().catch(() => ({}));
|
|
115
|
+
if (!upstream.ok) return NextResponse.json(idpErrorEnvelope(data, spec.fallbackMessage), { status: upstream.status });
|
|
116
|
+
return NextResponse.json(data, { status: 200 });
|
|
117
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { getIDPClientConfig } from '../../../lib/idp-client-config';
|
|
3
3
|
import { resolveClientKey } from '../../../lib/client-key';
|
|
4
|
+
import { idpErrorEnvelope } from '../../../lib/safe-error-response';
|
|
4
5
|
|
|
5
6
|
function getIdpUrl(): string {
|
|
6
7
|
const url = process.env.IDP_URL;
|
|
@@ -49,6 +50,8 @@ export async function POST(req: NextRequest) {
|
|
|
49
50
|
body: JSON.stringify({ email, ...(clientKey ? { client_id: clientKey } : {}) }),
|
|
50
51
|
});
|
|
51
52
|
const data = await upstream.json().catch(() => ({}));
|
|
53
|
+
// PAY-1438: anonymous route - on failure only the whitelisted envelope, never the IDP body.
|
|
54
|
+
if (!upstream.ok) return NextResponse.json(idpErrorEnvelope(data, 'Failed to initiate recovery'), { status: upstream.status });
|
|
52
55
|
return NextResponse.json(data, { status: upstream.status });
|
|
53
56
|
} catch (e) {
|
|
54
57
|
return NextResponse.json({ success: false, error: 'initiate_failed' }, { status: 500 });
|