@payez/next-mvp 4.4.6 → 4.4.8
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-handlers/account/change-password.js +28 -6
- package/dist/auth/better-auth.d.ts +62 -15
- package/dist/auth/better-auth.js +180 -19
- package/dist/client/better-auth-client.d.ts +101 -956
- package/dist/config/env.d.ts +0 -2
- package/dist/config/env.js +0 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -1
- package/dist/lib/idp-client-config.js +8 -1
- package/dist/lib/never-in-response.d.ts +30 -0
- package/dist/lib/never-in-response.js +71 -0
- package/dist/lib/nextauth-secret.d.ts +51 -0
- package/dist/lib/nextauth-secret.js +141 -0
- package/dist/lib/safe-error-response.d.ts +6 -6
- package/dist/lib/safe-error-response.js +6 -6
- package/dist/lib/two-factor-verify-failure.d.ts +36 -3
- package/dist/lib/two-factor-verify-failure.js +79 -2
- package/dist/middleware/create-middleware.d.ts +2 -0
- package/dist/middleware/create-middleware.js +36 -0
- package/dist/pages/login/page.js +5 -0
- package/dist/pages/verify-code/page.js +4 -2
- package/dist/routes/auth/settings.js +4 -1
- package/dist/server/auth.d.ts +36 -294
- package/dist/server/auth.js +22 -25
- package/dist/vibe/client.d.ts +132 -16
- package/dist/vibe/client.js +288 -82
- package/dist/vibe/document-unwrap.d.ts +50 -0
- package/dist/vibe/document-unwrap.js +78 -0
- package/dist/vibe/errors.d.ts +17 -0
- package/dist/vibe/errors.js +21 -1
- package/dist/vibe/generic.d.ts +2 -49
- package/dist/vibe/generic.js +17 -78
- package/dist/vibe/index.d.ts +1 -1
- package/dist/vibe/index.js +2 -1
- package/package.json +17 -2
- package/src/api-handlers/account/change-password.ts +31 -6
- package/src/auth/better-auth.ts +188 -21
- package/src/config/env.ts +0 -2
- package/src/index.ts +4 -0
- package/src/lib/idp-client-config.ts +8 -1
- package/src/lib/never-in-response.ts +64 -0
- package/src/lib/nextauth-secret.ts +168 -0
- package/src/lib/safe-error-response.ts +6 -6
- package/src/lib/two-factor-verify-failure.ts +86 -5
- package/src/middleware/create-middleware.ts +42 -0
- package/src/pages/login/page.tsx +4 -0
- package/src/pages/verify-code/page.tsx +4 -2
- package/src/routes/auth/settings.ts +5 -1
- package/src/server/auth.ts +23 -29
- package/src/vibe/client.ts +378 -91
- package/src/vibe/document-unwrap.ts +103 -0
- package/src/vibe/errors.ts +24 -0
- package/src/vibe/generic.ts +8 -100
- package/src/vibe/index.ts +1 -0
package/src/auth/better-auth.ts
CHANGED
|
@@ -281,19 +281,42 @@ export function buildBetterAuthProviders(
|
|
|
281
281
|
clientSecret: oauth.clientSecret,
|
|
282
282
|
scope: wireScopes.length > 0 ? wireScopes : undefined,
|
|
283
283
|
/**
|
|
284
|
-
*
|
|
285
|
-
* the signup BFF on allow_public_registration, but the social lane never
|
|
286
|
-
* touches that code: better-auth creates the user through its own
|
|
287
|
-
* internalAdapter at the OAuth callback, and its ONLY guard is this flag.
|
|
284
|
+
* PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
|
|
288
285
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
286
|
+
* 260419 wired this from `config.authSettings?.allowPublicRegistration` to
|
|
287
|
+
* close a client-side self-serve provisioning door. It cannot do that job
|
|
288
|
+
* correctly, and it BREAKS SIGN-IN:
|
|
289
|
+
*
|
|
290
|
+
* - This app runs better-auth in STATELESS mode (no DB adapter — see the
|
|
291
|
+
* file header). better-auth therefore has NO user store, so at the OAuth
|
|
292
|
+
* callback it cannot tell an EXISTING user from a NEW one. Its
|
|
293
|
+
* handleOAuthUserInfo consults `disableSignUp` for BOTH, so
|
|
294
|
+
* `disableSignUp: true` refuses a returning user exactly like a
|
|
295
|
+
* stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
|
|
296
|
+
* Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
|
|
297
|
+
* once client 12's allow_public_registration reported false.
|
|
298
|
+
*
|
|
299
|
+
* - The REAL registration gate already exists and is correct, at the IDP:
|
|
300
|
+
* PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
|
|
301
|
+
* user up by verified email (:799); an existing user logs in UNTOUCHED
|
|
302
|
+
* (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
|
|
303
|
+
* which refuses fail-closed when the client forbids public registration
|
|
304
|
+
* (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
|
|
305
|
+
* Its own comment states the contract: "existing federated users ...
|
|
306
|
+
* log in untouched, so gating here never blocks login."
|
|
307
|
+
*
|
|
308
|
+
* So this flag was a DUPLICATE gate that could only ever misfire — it cannot
|
|
309
|
+
* distinguish new from existing, and the IDP already refuses new identities.
|
|
310
|
+
* Removing it restores sign-in for existing users WITHOUT reopening
|
|
311
|
+
* self-serve: a NEW identity that completes the better-auth leg still gets
|
|
312
|
+
* REFUSED at the IDP exchange and never receives IDP tokens, which is the
|
|
313
|
+
* portal's actual authorization gate.
|
|
314
|
+
*
|
|
315
|
+
* WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
|
|
316
|
+
* (above) as the backstop, and each app's own front-door sign-up gate
|
|
317
|
+
* (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
|
|
318
|
+
* Both are correct and neither is this flag.
|
|
295
319
|
*/
|
|
296
|
-
disableSignUp: config.authSettings?.allowPublicRegistration !== true,
|
|
297
320
|
// Google is overly eager to reuse the last account unless we
|
|
298
321
|
// explicitly ask for account selection on each social login.
|
|
299
322
|
prompt: typeof rawPrompt === 'string'
|
|
@@ -820,6 +843,19 @@ export function createBetterAuthInstance(
|
|
|
820
843
|
},
|
|
821
844
|
},
|
|
822
845
|
|
|
846
|
+
// PAY-1781 B: KEEP OAUTH STATE IN THE ENCRYPTED COOKIE, as it was on better-auth 1.5.6.
|
|
847
|
+
// 1.5.6 resolved the default as `options.database ? 'database' : 'cookie'` - we have no
|
|
848
|
+
// database, so: cookie. 1.6.13+ resolves it as `(database || secondaryStorage) ? 'database'
|
|
849
|
+
// : 'cookie'`, and we DO have secondaryStorage, so the upgrade alone would silently move every
|
|
850
|
+
// OAuth state record into Redis through the adapter above (its enrichment read-modify-write
|
|
851
|
+
// and key handling were built for session records, not state). Pinned so the upgrade changes
|
|
852
|
+
// the version, not the design. The cookie branch is where GHSA-wxw3-q3m9-c3jr lived; 1.6.2+
|
|
853
|
+
// compares the returned `state` with the nonce sealed in the cookie, and our provider is also
|
|
854
|
+
// pkce:true, which the advisory required to be false. Test: oauth-state-strategy-cookie.
|
|
855
|
+
account: {
|
|
856
|
+
storeStateStrategy: 'cookie',
|
|
857
|
+
},
|
|
858
|
+
|
|
823
859
|
// Cookie prefix must match slim-middleware expectations ({slug}.session-token)
|
|
824
860
|
advanced: {
|
|
825
861
|
cookiePrefix: appSlug,
|
|
@@ -971,11 +1007,17 @@ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => P
|
|
|
971
1007
|
* header of the response to find the BA Redis key.
|
|
972
1008
|
*
|
|
973
1009
|
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
1010
|
+
*
|
|
1011
|
+
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
1012
|
+
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
1013
|
+
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
1014
|
+
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
1015
|
+
* refused registration bounced silently, which is the failure Jon named.
|
|
974
1016
|
*/
|
|
975
1017
|
export async function exchangeOAuthForIdpTokens(
|
|
976
1018
|
sessionToken: string,
|
|
977
1019
|
provider: string = 'google'
|
|
978
|
-
): Promise<boolean> {
|
|
1020
|
+
): Promise<boolean | string> {
|
|
979
1021
|
try {
|
|
980
1022
|
const config = await getIDPClientConfig();
|
|
981
1023
|
const appSlug = config.clientSlug || getAppSlug();
|
|
@@ -1068,6 +1110,36 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1068
1110
|
if (!oauthRes.ok) {
|
|
1069
1111
|
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
1070
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
|
|
1115
|
+
*
|
|
1116
|
+
* Every refusal used to return a bare `false`, so the abort collapsed ALL
|
|
1117
|
+
* causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
|
|
1118
|
+
* registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
|
|
1119
|
+
* bounced silently, indistinguishable from a network blip. Jon named the
|
|
1120
|
+
* clear message explicitly (67856): "(b) a brand-new Google user with
|
|
1121
|
+
* registration OFF is refused by the IDP with a clear message, not a
|
|
1122
|
+
* silent bounce."
|
|
1123
|
+
*
|
|
1124
|
+
* The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
|
|
1125
|
+
* We read ONLY `error.code` and return it VERBATIM — the abort puts it in
|
|
1126
|
+
* the `?error=` param, and the HOST's allowlist decides whether to render
|
|
1127
|
+
* it. The package does NOT interpret the code and does NOT add a message
|
|
1128
|
+
* (that would be a second allowlist). No code in the body -> null (the
|
|
1129
|
+
* generic host code), exactly as before.
|
|
1130
|
+
*/
|
|
1131
|
+
let idpErrorCode: string | null = null;
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = JSON.parse(oauthResText);
|
|
1134
|
+
// FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
|
|
1135
|
+
// unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
|
|
1136
|
+
if (typeof parsed?.error?.code === 'string' && IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
|
|
1137
|
+
idpErrorCode = parsed.error.code;
|
|
1138
|
+
}
|
|
1139
|
+
} catch {
|
|
1140
|
+
/* non-JSON refusal body -> no code (generic abort) */
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1071
1143
|
/**
|
|
1072
1144
|
* WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
|
|
1073
1145
|
*
|
|
@@ -1115,7 +1187,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1115
1187
|
sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
|
|
1116
1188
|
idp_url: idpUrl,
|
|
1117
1189
|
});
|
|
1118
|
-
return false;
|
|
1190
|
+
return idpErrorCode ?? false;
|
|
1119
1191
|
}
|
|
1120
1192
|
|
|
1121
1193
|
let idpData: any;
|
|
@@ -1125,6 +1197,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1125
1197
|
if (!result?.access_token) {
|
|
1126
1198
|
console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
|
|
1127
1199
|
return false;
|
|
1200
|
+
|
|
1128
1201
|
}
|
|
1129
1202
|
|
|
1130
1203
|
// Build IDP token data
|
|
@@ -1235,6 +1308,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1235
1308
|
// Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
|
|
1236
1309
|
const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
|
|
1237
1310
|
if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
1311
|
+
// true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
|
|
1238
1312
|
return stored;
|
|
1239
1313
|
} catch (err) {
|
|
1240
1314
|
console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
|
|
@@ -1242,6 +1316,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1242
1316
|
}
|
|
1243
1317
|
}
|
|
1244
1318
|
|
|
1319
|
+
|
|
1245
1320
|
/**
|
|
1246
1321
|
* Write IDP tokens into an EXISTING Better Auth session. Storage only — this
|
|
1247
1322
|
* function never acquires tokens and never decides who the caller is.
|
|
@@ -1331,12 +1406,93 @@ export async function storeIdpTokensInSession(
|
|
|
1331
1406
|
}
|
|
1332
1407
|
}
|
|
1333
1408
|
|
|
1409
|
+
/**
|
|
1410
|
+
* The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
|
|
1411
|
+
* store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
|
|
1412
|
+
* Try again or use another method." The cause stays in the server log, never on the page.
|
|
1413
|
+
*/
|
|
1414
|
+
export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
1418
|
+
*
|
|
1419
|
+
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
1420
|
+
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
1421
|
+
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
1422
|
+
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
1423
|
+
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
1424
|
+
* wearing a different number. Add them together, never one alone.
|
|
1425
|
+
*/
|
|
1426
|
+
export const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string> = new Set(['REGISTRATION_NOT_ALLOWED']);
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
1431
|
+
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
|
1432
|
+
* accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
|
|
1433
|
+
*/
|
|
1434
|
+
export function expireSetCookies(setCookies: readonly string[]): string[] {
|
|
1435
|
+
const out: string[] = [];
|
|
1436
|
+
for (const sc of setCookies) {
|
|
1437
|
+
const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
|
|
1438
|
+
const eq = pair.indexOf('=');
|
|
1439
|
+
const name = eq > 0 ? pair.slice(0, eq) : '';
|
|
1440
|
+
if (!name) continue;
|
|
1441
|
+
const kept = attrs.filter((a) => /^(path|domain|secure|samesite|httponly)\b/i.test(a));
|
|
1442
|
+
out.push([`${name}=`, ...kept, 'Max-Age=0'].join('; '));
|
|
1443
|
+
}
|
|
1444
|
+
return out;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* PAY-1733 (Jon, live on :4797): ABORT an OAuth sign-in whose IdP exchange did not store tokens.
|
|
1449
|
+
*
|
|
1450
|
+
* Before this, a failed exchange was a console.error and the user kept the Better Auth session the callback had
|
|
1451
|
+
* just created: signed in, holding NO IdP tokens. The host then routed that session to 2FA, which painted the
|
|
1452
|
+
* user's email and failed every call - a red "sign in again" screen about an account the app could no longer
|
|
1453
|
+
* authenticate. The IdP's 401 was CORRECT (it fail-closes unverified assertions); keeping the session was the bug.
|
|
1454
|
+
*
|
|
1455
|
+
* So: no session without IdP tokens. The Better Auth session is destroyed (its own signOut, best effort, then the
|
|
1456
|
+
* `ba:<slug>:<token>` record directly - that record is what makes it resolvable), every cookie the callback set is
|
|
1457
|
+
* expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
|
|
1458
|
+
* branch above (328439: an absolute URL built from request.url is a dead tab in a container).
|
|
1459
|
+
*/
|
|
1460
|
+
async function abortOAuthSignIn(
|
|
1461
|
+
callbackResponse: Response,
|
|
1462
|
+
sessionToken: string | null,
|
|
1463
|
+
cookieHeader: string,
|
|
1464
|
+
loginPath: string,
|
|
1465
|
+
errorCode: string = OAUTH_SIGN_IN_FAILED_ERROR,
|
|
1466
|
+
): Promise<Response> {
|
|
1467
|
+
const headers = new Headers();
|
|
1468
|
+
if (sessionToken) {
|
|
1469
|
+
try {
|
|
1470
|
+
const auth = await getBetterAuthInstance();
|
|
1471
|
+
const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
|
|
1472
|
+
for (const sc of (out as Response | undefined)?.headers?.getSetCookie?.() ?? []) headers.append('set-cookie', sc);
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
const config = await getIDPClientConfig();
|
|
1478
|
+
await getRedis().del(`ba:${config.clientSlug || getAppSlug()}:${sessionToken}`);
|
|
1479
|
+
} catch (err) {
|
|
1480
|
+
console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), err instanceof Error ? err.message : String(err));
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
|
|
1484
|
+
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
|
|
1485
|
+
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)', 'code=' + errorCode);
|
|
1486
|
+
return new Response(null, { status: 302, headers });
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1334
1489
|
/**
|
|
1335
1490
|
* Create a production-ready GET handler for the auth catch-all route.
|
|
1336
1491
|
*
|
|
1337
1492
|
* Wraps better-auth's GET handler with:
|
|
1338
1493
|
* - OAuth state error recovery (redirects to login instead of error page)
|
|
1339
|
-
* - IDP token exchange after successful OAuth callback
|
|
1494
|
+
* - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
|
|
1495
|
+
* (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
|
|
1340
1496
|
*
|
|
1341
1497
|
* Usage in host app:
|
|
1342
1498
|
* ```ts
|
|
@@ -1408,24 +1564,30 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1408
1564
|
}
|
|
1409
1565
|
}
|
|
1410
1566
|
|
|
1411
|
-
// After
|
|
1567
|
+
// After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
|
|
1568
|
+
// IdP refused (401), no session was found, or anything threw - the sign-in is ABORTED (PAY-1733). A session is
|
|
1569
|
+
// kept only when it holds IdP tokens.
|
|
1412
1570
|
const url = new URL(request.url);
|
|
1413
1571
|
if (url.pathname.includes('/callback/') && response.status === 302) {
|
|
1572
|
+
const setCookies = response.headers.getSetCookie?.() || [];
|
|
1573
|
+
const cookieHeader = setCookies
|
|
1574
|
+
.map((c: string) => c.split(';')[0])
|
|
1575
|
+
.join('; ');
|
|
1576
|
+
let sessionToken: string | null = null;
|
|
1577
|
+
// true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
|
|
1578
|
+
// (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
|
|
1579
|
+
let exchange: boolean | string = false;
|
|
1414
1580
|
try {
|
|
1415
1581
|
const auth = await getBetterAuthInstance();
|
|
1416
1582
|
if (auth?.api?.getSession) {
|
|
1417
|
-
const setCookies = response.headers.getSetCookie?.() || [];
|
|
1418
|
-
const cookieHeader = setCookies
|
|
1419
|
-
.map((c: string) => c.split(';')[0])
|
|
1420
|
-
.join('; ');
|
|
1421
|
-
|
|
1422
1583
|
const headers = new Headers();
|
|
1423
1584
|
headers.set('cookie', cookieHeader);
|
|
1424
1585
|
|
|
1425
1586
|
const session = await auth.api.getSession({ headers });
|
|
1426
1587
|
if (session?.session?.token) {
|
|
1588
|
+
sessionToken = session.session.token;
|
|
1427
1589
|
console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
|
|
1428
|
-
await exchangeOAuthForIdpTokens(session.session.token);
|
|
1590
|
+
exchange = await exchangeOAuthForIdpTokens(session.session.token);
|
|
1429
1591
|
} else {
|
|
1430
1592
|
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
1431
1593
|
}
|
|
@@ -1433,6 +1595,11 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1433
1595
|
} catch (err: any) {
|
|
1434
1596
|
console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
|
|
1435
1597
|
}
|
|
1598
|
+
if (exchange !== true) {
|
|
1599
|
+
// A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
|
|
1600
|
+
const errorCode = typeof exchange === 'string' ? exchange : OAUTH_SIGN_IN_FAILED_ERROR;
|
|
1601
|
+
return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
|
|
1602
|
+
}
|
|
1436
1603
|
}
|
|
1437
1604
|
|
|
1438
1605
|
return response;
|
package/src/config/env.ts
CHANGED
|
@@ -29,8 +29,6 @@ export const API_ENDPOINTS = {
|
|
|
29
29
|
profile: '/api/account/profile',
|
|
30
30
|
updateProfile: '/api/account/profile',
|
|
31
31
|
maskedInfo: '/api/account/masked-info',
|
|
32
|
-
sendResetCode: '/api/account/send-reset-code',
|
|
33
|
-
verifyResetCode: '/api/account/verify-reset-code',
|
|
34
32
|
resetPassword: '/api/account/reset-password',
|
|
35
33
|
changePassword: '/api/account/change-password',
|
|
36
34
|
validatePassword: '/api/account/validate-password'
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,10 @@ export type { AuthConfig } from './types/auth';
|
|
|
23
23
|
// Route configuration (client-safe)
|
|
24
24
|
export { makeAuthDecision } from './auth/auth-decision';
|
|
25
25
|
export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from './auth/route-config';
|
|
26
|
+
|
|
27
|
+
// The ONE callback-target guard (N7 / PAY-1989): apps import this instead of
|
|
28
|
+
// copying it. Exposed from the main index AND as the subpath ./auth/safe-callback.
|
|
29
|
+
export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
|
|
26
30
|
export { createMvpMiddleware } from './middleware/create-middleware';
|
|
27
31
|
|
|
28
32
|
// Account Components
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
import 'server-only';
|
|
44
44
|
import { createHash, randomUUID } from 'crypto';
|
|
45
|
+
import { markNeverInResponse } from './never-in-response';
|
|
45
46
|
// REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
|
|
46
47
|
// A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
|
|
47
48
|
// touches this file - including instrumentation.ts. Next compiles the instrumentation
|
|
@@ -211,6 +212,10 @@ function resolveAuthSecret(fromConfig: string | undefined): { value: string; sou
|
|
|
211
212
|
function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
|
|
212
213
|
const auth = resolveAuthSecret(config.authSecret);
|
|
213
214
|
logSecretSource('authSecret', auth.source);
|
|
215
|
+
// PAY-1529: every secret this module resolves is registered as never-in-response HERE, the one place
|
|
216
|
+
// both fetch paths and the Redis read pass through, so the runtime backstop knows the value whichever
|
|
217
|
+
// handler later tries to serialize it.
|
|
218
|
+
markNeverInResponse(auth.value);
|
|
214
219
|
return {
|
|
215
220
|
...config,
|
|
216
221
|
authSecret: auth.value,
|
|
@@ -219,7 +224,9 @@ function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
|
|
|
219
224
|
const fromEnv = process.env[envVar];
|
|
220
225
|
const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
|
|
221
226
|
logSecretSource(`${p.provider} clientSecret`, source);
|
|
222
|
-
|
|
227
|
+
const clientSecret = fromEnv || p.clientSecret;
|
|
228
|
+
markNeverInResponse(clientSecret);
|
|
229
|
+
return { ...p, clientSecret };
|
|
223
230
|
}),
|
|
224
231
|
};
|
|
225
232
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1529 runtime backstop: the signing secret and the OAuth provider client secrets must never be
|
|
3
|
+
* serialized into an HTTP response body.
|
|
4
|
+
*
|
|
5
|
+
* The build-time guard (test/secrets-never-in-response-source-guard.test.cjs) follows values through the
|
|
6
|
+
* source it can see. This is the layer for what it cannot see — a value that crosses a module boundary,
|
|
7
|
+
* a structuredClone / Object.assign copy, a helper in a consumer app. It tracks the secret by VALUE:
|
|
8
|
+
* the loader registers every resolved secret here at the moment it resolves it (applyEnvSecrets), and a
|
|
9
|
+
* response built with `secretSafeJson` / checked with `assertNoServerSecret` FAILS CLOSED — it throws,
|
|
10
|
+
* and the route answers 500 — if any registered value appears anywhere in the serialized body, under any
|
|
11
|
+
* key, at any depth, whatever the code that built it called it.
|
|
12
|
+
*
|
|
13
|
+
* Bounds, stated rather than implied:
|
|
14
|
+
* - Values shorter than MIN_SECRET_LENGTH are not registered. A short string matches innocent bodies and
|
|
15
|
+
* would turn this into an outage generator; real signing and provider secrets are far longer.
|
|
16
|
+
* - It protects responses that go through it. Adoption is per handler; the build-time guard is what
|
|
17
|
+
* covers the handlers that do not.
|
|
18
|
+
* - Nothing here ever logs, returns or throws a secret value.
|
|
19
|
+
*/
|
|
20
|
+
import { NextResponse } from 'next/server';
|
|
21
|
+
|
|
22
|
+
export const MIN_SECRET_LENGTH = 16;
|
|
23
|
+
|
|
24
|
+
const registered = new Set<string>();
|
|
25
|
+
|
|
26
|
+
/** Record a value that must never appear in a response body. Non-strings and short strings are ignored. */
|
|
27
|
+
export function markNeverInResponse(value: unknown): void {
|
|
28
|
+
if (typeof value !== 'string' || value.length < MIN_SECRET_LENGTH) return;
|
|
29
|
+
registered.add(value);
|
|
30
|
+
// JSON escapes some characters, so the serialized form of a secret containing `"` or `\` differs from
|
|
31
|
+
// the raw value. Register that form too, or such a secret would pass through a JSON body unseen.
|
|
32
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
33
|
+
if (escaped !== value) registered.add(escaped);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SecretInResponseError extends Error {
|
|
37
|
+
constructor(where: string) {
|
|
38
|
+
// The message names the route, never the value.
|
|
39
|
+
super(`[PAY-1529] Refused to serialize a server secret into the response of ${where}`);
|
|
40
|
+
this.name = 'SecretInResponseError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Throws SecretInResponseError if any registered secret appears in `body` once serialized. */
|
|
45
|
+
export function assertNoServerSecret(body: unknown, where: string): void {
|
|
46
|
+
if (registered.size === 0 || body === undefined || body === null) return;
|
|
47
|
+
let text: string;
|
|
48
|
+
try {
|
|
49
|
+
text = typeof body === 'string' ? body : JSON.stringify(body);
|
|
50
|
+
} catch {
|
|
51
|
+
// Unserializable: NextResponse.json would throw on it anyway. Fail closed rather than guess.
|
|
52
|
+
throw new SecretInResponseError(where);
|
|
53
|
+
}
|
|
54
|
+
if (!text) return;
|
|
55
|
+
for (const secret of registered) {
|
|
56
|
+
if (text.includes(secret)) throw new SecretInResponseError(where);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** NextResponse.json, refusing any body that carries a registered secret. */
|
|
61
|
+
export function secretSafeJson<T>(body: T, init: ResponseInit | undefined, where: string): NextResponse<T> {
|
|
62
|
+
assertNoServerSecret(body, where);
|
|
63
|
+
return NextResponse.json(body, init);
|
|
64
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* The NextAuth-era broker secret resolver was DELETED in
|
|
8
|
+
* 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
|
|
9
|
+
* and it is intentionally NOT coming back as first-class API: this package is
|
|
10
|
+
* Better Auth now, and new apps must not resolve a NextAuth secret at all.
|
|
11
|
+
*
|
|
12
|
+
* It is restored here ONLY because ONE consumer is still mid-migration and cannot
|
|
13
|
+
* take the 4.4.x bump without it:
|
|
14
|
+
*
|
|
15
|
+
* CONSUMER THAT NEEDS THIS: the customer portal
|
|
16
|
+
* (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
|
|
17
|
+
* @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
|
|
18
|
+
* `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
|
|
19
|
+
* import `resolveNextAuthSecret` from here. Without this export, bumping the
|
|
20
|
+
* portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
|
|
21
|
+
* shim rather than force the portal's full NextAuth->BetterAuth migration
|
|
22
|
+
* tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
|
|
23
|
+
* THIS FILE.
|
|
24
|
+
*
|
|
25
|
+
* DO NOT add new consumers. If you are starting an app, use Better Auth's own
|
|
26
|
+
* secret handling; you do not need this.
|
|
27
|
+
*
|
|
28
|
+
* ---------------------------------------------------------------------------
|
|
29
|
+
* BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
|
|
30
|
+
* ---------------------------------------------------------------------------
|
|
31
|
+
* Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
|
|
32
|
+
* signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
|
|
33
|
+
*
|
|
34
|
+
* DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
|
|
35
|
+
* `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
|
|
36
|
+
* renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
|
|
37
|
+
* `validateAuthSecret` - same predicate, current name - so there is no second
|
|
38
|
+
* copy of the rule. The validation semantics are unchanged.
|
|
39
|
+
*
|
|
40
|
+
* SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
|
|
41
|
+
*/
|
|
42
|
+
import 'server-only';
|
|
43
|
+
import { randomUUID } from 'node:crypto';
|
|
44
|
+
import { validateAuthSecret } from './secret-validation';
|
|
45
|
+
|
|
46
|
+
let cachedSecret: string | null = null;
|
|
47
|
+
let lastFetchedAt = 0;
|
|
48
|
+
|
|
49
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
interface SigningResponse {
|
|
52
|
+
data?: { client_assertion?: string; clientAssertion?: string; ClientAssertion?: string };
|
|
53
|
+
client_assertion?: string;
|
|
54
|
+
clientAssertion?: string;
|
|
55
|
+
ClientAssertion?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SecretResponse {
|
|
59
|
+
data?: { secret?: string; configuration?: unknown };
|
|
60
|
+
secret?: string;
|
|
61
|
+
configuration?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the NextAuth secret (server-only).
|
|
66
|
+
*
|
|
67
|
+
* Priority:
|
|
68
|
+
* 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
|
|
69
|
+
* 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
|
|
70
|
+
* 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveNextAuthSecret(): Promise<string> {
|
|
73
|
+
// Check if already in environment
|
|
74
|
+
if (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== '') {
|
|
75
|
+
return process.env.NEXTAUTH_SECRET;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if cached and fresh (within 5 minutes)
|
|
79
|
+
if (cachedSecret && Date.now() - lastFetchedAt < CACHE_TTL_MS) {
|
|
80
|
+
return cachedSecret;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Broker mode: fetch from the IDP (the IDP handles all Key Vault/signing)
|
|
84
|
+
const base = process.env.IDP_URL;
|
|
85
|
+
if (!base) throw new Error('IDP_URL environment variable is required');
|
|
86
|
+
|
|
87
|
+
const clientIdStr = process.env.CLIENT_ID;
|
|
88
|
+
if (!clientIdStr || clientIdStr.trim() === '') {
|
|
89
|
+
throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 1: ask the IDP to sign a client assertion (the IDP holds the keys, not us)
|
|
93
|
+
const signingUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
|
|
94
|
+
const signingResp = await fetch(signingUrl, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {
|
|
97
|
+
Accept: 'application/json',
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
'X-Client-Id': clientIdStr,
|
|
100
|
+
'X-Correlation-Id': randomUUID().replace(/-/g, ''),
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
issuer: clientIdStr,
|
|
104
|
+
subject: clientIdStr,
|
|
105
|
+
audience: 'urn:payez:externalauth:nextauthsecret',
|
|
106
|
+
expires_in: 60,
|
|
107
|
+
}),
|
|
108
|
+
cache: 'no-store',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!signingResp.ok) {
|
|
112
|
+
const txt = await signingResp.text().catch(() => 'Unknown error');
|
|
113
|
+
throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const signingBody = (await signingResp.json().catch(() => ({}))) as SigningResponse;
|
|
117
|
+
const clientAssertion =
|
|
118
|
+
signingBody?.data?.client_assertion ??
|
|
119
|
+
signingBody?.data?.clientAssertion ??
|
|
120
|
+
signingBody?.client_assertion ??
|
|
121
|
+
signingBody?.clientAssertion ??
|
|
122
|
+
signingBody?.data?.ClientAssertion ??
|
|
123
|
+
signingBody?.ClientAssertion;
|
|
124
|
+
|
|
125
|
+
if (!clientAssertion || typeof clientAssertion !== 'string') {
|
|
126
|
+
throw new Error('IDP did not return a valid signed client assertion');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Step 2: use the signed assertion to fetch the NextAuth secret
|
|
130
|
+
const proxyUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`;
|
|
131
|
+
const proxyResp = await fetch(proxyUrl, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
Accept: 'application/json',
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
'X-Client-Id': clientIdStr,
|
|
137
|
+
'X-Correlation-Id': randomUUID().replace(/-/g, ''),
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({ client_assertion: clientAssertion }),
|
|
140
|
+
cache: 'no-store',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!proxyResp.ok) {
|
|
144
|
+
const txt = await proxyResp.text().catch(() => 'Unknown error');
|
|
145
|
+
throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const proxyBody = (await proxyResp.json().catch(() => ({}))) as SecretResponse;
|
|
149
|
+
const secret = proxyBody?.data?.secret ?? proxyBody?.secret;
|
|
150
|
+
// `configuration` is available from the IDP but deliberately not logged.
|
|
151
|
+
|
|
152
|
+
if (!secret || typeof secret !== 'string') {
|
|
153
|
+
throw new Error('Proxy did not return a valid NextAuth secret');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Same predicate as 4.0.46's validateNextAuthSecret, under its current name.
|
|
157
|
+
const validation = validateAuthSecret(secret);
|
|
158
|
+
if (!validation.valid) {
|
|
159
|
+
throw new Error(`Fetched NextAuth secret failed validation: ${validation.reason}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
cachedSecret = secret;
|
|
163
|
+
lastFetchedAt = Date.now();
|
|
164
|
+
process.env.NEXTAUTH_SECRET = secret;
|
|
165
|
+
|
|
166
|
+
console.log('[NEXTAUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
|
|
167
|
+
return secret;
|
|
168
|
+
}
|
|
@@ -93,12 +93,6 @@ export function idpErrorEnvelope(json: unknown, fallbackMessage = 'Request faile
|
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
97
|
-
* PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
|
|
98
|
-
* [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
|
|
99
|
-
* unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
|
|
100
|
-
* (the caller must be able to tell a refused password from a dead recovery session).
|
|
101
|
-
*/
|
|
102
96
|
/**
|
|
103
97
|
* True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
|
|
104
98
|
* ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
|
|
@@ -116,6 +110,12 @@ export function projectResetTokenInvalid(json: unknown): boolean {
|
|
|
116
110
|
return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && (v as Record<string, unknown>).code === 'InvalidToken');
|
|
117
111
|
}
|
|
118
112
|
|
|
113
|
+
/**
|
|
114
|
+
* PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
|
|
115
|
+
* [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
|
|
116
|
+
* unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
|
|
117
|
+
* (the caller must be able to tell a refused password from a dead recovery session).
|
|
118
|
+
*/
|
|
119
119
|
export function projectPasswordPolicyRules(json: unknown): string[] | undefined {
|
|
120
120
|
if (!json || typeof json !== 'object') return undefined;
|
|
121
121
|
const top = json as Record<string, unknown>;
|