@payez/next-mvp 4.4.5 → 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 +35 -49
- 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 +66 -45
- 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 +37 -55
- 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 +91 -45
- 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
package/src/auth/better-auth.ts
CHANGED
|
@@ -21,9 +21,11 @@ import { resolveClientKey } from '../lib/client-key';
|
|
|
21
21
|
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
|
22
22
|
import type { IDPClientConfig } from '../lib/idp-client-config';
|
|
23
23
|
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
24
|
-
import { getAppSlug } from '../lib/app-slug';
|
|
24
|
+
import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
|
|
25
25
|
import { getRedis } from '../lib/redis';
|
|
26
|
+
import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
|
|
26
27
|
import { SESSION_TTL } from '../lib/session-store';
|
|
28
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
@@ -518,6 +520,7 @@ export function createBetterAuthInstance(
|
|
|
518
520
|
idpConfig: IDPClientConfig,
|
|
519
521
|
opts: CreateBetterAuthInstanceOptions = {}
|
|
520
522
|
) {
|
|
523
|
+
assertAppSlugMatchesIdp(idpConfig.clientSlug);
|
|
521
524
|
const appSlug = idpConfig.clientSlug || getAppSlug();
|
|
522
525
|
|
|
523
526
|
// 185121 line 1: name the secret this instance PINNED, as a hash, never the
|
|
@@ -867,6 +870,9 @@ interface BetterAuthModuleState {
|
|
|
867
870
|
cachedInstance: any;
|
|
868
871
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
869
872
|
initPromise: Promise<any> | null;
|
|
873
|
+
// PAY-642: the init attempt, single-flighted so a REJECTED attempt clears itself (see getBetterAuthInstance).
|
|
874
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
875
|
+
initFlight?: SingleFlight<any>;
|
|
870
876
|
configuredOpts: CreateBetterAuthInstanceOptions;
|
|
871
877
|
}
|
|
872
878
|
|
|
@@ -902,19 +908,36 @@ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void
|
|
|
902
908
|
state.configuredOpts = opts;
|
|
903
909
|
}
|
|
904
910
|
|
|
911
|
+
/**
|
|
912
|
+
* PAY-642 (card 338215): the init attempt no longer LATCHES A FAILURE.
|
|
913
|
+
*
|
|
914
|
+
* This used to be `state.initPromise ??= getIDPClientConfig(true).then(...)` with no .catch and no reset. One
|
|
915
|
+
* auth request against a cold process while the IdP was unreachable stored a rejected promise, and every later
|
|
916
|
+
* caller replayed it until the process restarted, even after the IdP recovered. This funnel backs every
|
|
917
|
+
* /api/auth/* route, the login GET handler and withAuth. server/auth.ts getAuthInstance() already used
|
|
918
|
+
* createSingleFlight for this exact bug; this sibling funnel was missed.
|
|
919
|
+
*
|
|
920
|
+
* Now the attempt runs through the same helper: concurrent callers share one attempt, a failure still rejects
|
|
921
|
+
* for every caller of THAT attempt (loud), the next call starts a fresh attempt, and a success is cached in
|
|
922
|
+
* state.cachedInstance as before. The flight lives on the globalThis state for the same reason the instance
|
|
923
|
+
* does (duplicated route chunks must share one init).
|
|
924
|
+
*/
|
|
905
925
|
export async function getBetterAuthInstance() {
|
|
906
926
|
if (state.cachedInstance) return state.cachedInstance;
|
|
907
927
|
|
|
908
|
-
|
|
909
|
-
|
|
928
|
+
state.initFlight ??= createSingleFlight(() =>
|
|
929
|
+
getIDPClientConfig(true).then(config => {
|
|
910
930
|
const instance = createBetterAuthInstance(config, state.configuredOpts);
|
|
911
931
|
state.cachedInstance = instance;
|
|
912
932
|
console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
|
|
913
933
|
return instance;
|
|
914
|
-
})
|
|
915
|
-
|
|
934
|
+
})
|
|
935
|
+
);
|
|
916
936
|
|
|
917
|
-
|
|
937
|
+
const attempt = state.initFlight.get();
|
|
938
|
+
// Kept for callers that inspect it; it always points at the CURRENT attempt, never a settled failure.
|
|
939
|
+
state.initPromise = attempt;
|
|
940
|
+
return attempt;
|
|
918
941
|
}
|
|
919
942
|
|
|
920
943
|
/**
|
|
@@ -961,7 +984,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
961
984
|
// Read the BA session from Redis
|
|
962
985
|
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
963
986
|
if (!baRaw) {
|
|
964
|
-
console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken
|
|
987
|
+
console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', tokenLogId(sessionToken));
|
|
965
988
|
return false;
|
|
966
989
|
}
|
|
967
990
|
|
|
@@ -1038,7 +1061,9 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
1038
1061
|
});
|
|
1039
1062
|
|
|
1040
1063
|
const oauthResText = await oauthRes.text();
|
|
1041
|
-
|
|
1064
|
+
// PAY-1427 (QAPert, PR 154 thread 326): this printed the first 500 chars of the oauth-callback TOKEN
|
|
1065
|
+
// envelope on EVERY successful OAuth login (it runs before the !ok check). Status + length only.
|
|
1066
|
+
console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
|
|
1042
1067
|
|
|
1043
1068
|
if (!oauthRes.ok) {
|
|
1044
1069
|
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
@@ -1276,7 +1301,7 @@ export async function storeIdpTokensInSession(
|
|
|
1276
1301
|
// conjured here would carry no Better Auth cookie, so it would be
|
|
1277
1302
|
// unreachable by the browser and invisible to every other consumer —
|
|
1278
1303
|
// failing as "silently signed out" rather than as an error.
|
|
1279
|
-
console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', sessionToken
|
|
1304
|
+
console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', tokenLogId(sessionToken));
|
|
1280
1305
|
return false;
|
|
1281
1306
|
}
|
|
1282
1307
|
|
|
@@ -1383,26 +1408,7 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1383
1408
|
}
|
|
1384
1409
|
}
|
|
1385
1410
|
|
|
1386
|
-
// After successful OAuth callback: exchange
|
|
1387
|
-
//
|
|
1388
|
-
// ⚠ WAS "exchange Google identity" — and it meant it literally. This call
|
|
1389
|
-
// site passed no provider, so it took exchangeOAuthForIdpTokens's
|
|
1390
|
-
// `provider: string = 'google'` default and asserted "google" to
|
|
1391
|
-
// /api/ExternalAuth/oauth-callback for EVERY provider. That was harmless
|
|
1392
|
-
// while Google was the only one wired, and became wrong the moment a second
|
|
1393
|
-
// provider shipped: a GitHub sign-in forwarded a GitHub access_token
|
|
1394
|
-
// labelled `provider: 'google'`, so the IDP tried to verify it as a Google
|
|
1395
|
-
// identity and fail-closed with 401 UNVERIFIED_ASSERTION in
|
|
1396
|
-
// `response_time_ms: 0` — it never had to call anyone to know it was wrong.
|
|
1397
|
-
//
|
|
1398
|
-
// The diagnostics made this maximally confusing: `access_token_forwarded:
|
|
1399
|
-
// true` is correct and the token really did arrive. The FIELD was fine; the
|
|
1400
|
-
// LABEL on it was a lie. Nothing on the client half is broken.
|
|
1401
|
-
//
|
|
1402
|
-
// The provider was never unavailable — it is the path segment this very
|
|
1403
|
-
// `if` matches on (/api/auth/callback/github). Derive it instead of
|
|
1404
|
-
// defaulting, because a default here cannot be right for more than one
|
|
1405
|
-
// provider at a time.
|
|
1411
|
+
// After successful OAuth callback: exchange Google identity for IDP tokens
|
|
1406
1412
|
const url = new URL(request.url);
|
|
1407
1413
|
if (url.pathname.includes('/callback/') && response.status === 302) {
|
|
1408
1414
|
try {
|
|
@@ -1418,32 +1424,8 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1418
1424
|
|
|
1419
1425
|
const session = await auth.api.getSession({ headers });
|
|
1420
1426
|
if (session?.session?.token) {
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
.split('/callback/')[1]
|
|
1424
|
-
?.split('/')[0]
|
|
1425
|
-
?.split('?')[0]
|
|
1426
|
-
?.trim()
|
|
1427
|
-
.toLowerCase() || '';
|
|
1428
|
-
|
|
1429
|
-
if (!callbackProvider) {
|
|
1430
|
-
// Do NOT quietly fall back to 'google'. That silent default is the
|
|
1431
|
-
// exact defect this block fixes, and re-introducing it here would
|
|
1432
|
-
// reproduce it for whatever unparseable shape got us here.
|
|
1433
|
-
console.error(
|
|
1434
|
-
'[BETTER_AUTH] OAuth callback matched /callback/ but no provider could be derived from',
|
|
1435
|
-
url.pathname,
|
|
1436
|
-
'— refusing to assert a guessed provider to the IDP. No exchange performed.'
|
|
1437
|
-
);
|
|
1438
|
-
} else {
|
|
1439
|
-
console.log(
|
|
1440
|
-
'[BETTER_AUTH] Got session token from callback:',
|
|
1441
|
-
session.session.token.substring(0, 10),
|
|
1442
|
-
'| provider:', callbackProvider,
|
|
1443
|
-
'| email:', session.user?.email
|
|
1444
|
-
);
|
|
1445
|
-
await exchangeOAuthForIdpTokens(session.session.token, callbackProvider);
|
|
1446
|
-
}
|
|
1427
|
+
console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
|
|
1428
|
+
await exchangeOAuthForIdpTokens(session.session.token);
|
|
1447
1429
|
} else {
|
|
1448
1430
|
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
1449
1431
|
}
|
|
@@ -215,7 +215,8 @@ export async function idpRefreshToken(
|
|
|
215
215
|
|
|
216
216
|
if (!response.ok) {
|
|
217
217
|
const errorText = await response.text().catch(() => 'Unknown error');
|
|
218
|
-
|
|
218
|
+
// PAY-1427: never the raw upstream body (the refresh.ts:528 twin PR 139 fixed). Status + length only.
|
|
219
|
+
console.error('[IDP_CLIENT] Token refresh failed:', { status: response.status, bodyLength: errorText.length });
|
|
219
220
|
return {
|
|
220
221
|
success: false,
|
|
221
222
|
error: {
|
|
@@ -214,7 +214,10 @@ export function DataBrowserTab({
|
|
|
214
214
|
|
|
215
215
|
if (!response.ok) {
|
|
216
216
|
const errData = await response.json().catch(() => ({}));
|
|
217
|
-
|
|
217
|
+
// PAY-1425: the handler returns a fixed message plus a correlationId (never upstream
|
|
218
|
+
// text). Surface the id so an admin can quote it to an operator.
|
|
219
|
+
const ref = errData.error?.correlationId ? ` (ref ${errData.error.correlationId})` : '';
|
|
220
|
+
throw new Error(`${errData.error?.message || 'Delete failed'}${ref}`);
|
|
218
221
|
}
|
|
219
222
|
|
|
220
223
|
setSuccess(`Record ${id} deleted`);
|
|
@@ -45,6 +45,13 @@ export interface SessionsTabProps {
|
|
|
45
45
|
// COMPONENT
|
|
46
46
|
// -----------------------------------------------------------------------------
|
|
47
47
|
|
|
48
|
+
// PAY-1429: admin handlers return a fixed message plus a correlationId (never upstream or
|
|
49
|
+
// exception text). Surface the id so an admin can quote it to an operator.
|
|
50
|
+
function errorWithRef(data: any, fallback: string): string {
|
|
51
|
+
const msg = typeof data?.error === 'string' ? data.error : fallback;
|
|
52
|
+
return data?.correlationId ? `${msg} (ref ${data.correlationId})` : msg;
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions' }: SessionsTabProps) {
|
|
49
56
|
const [sessions, setSessions] = useState<LoginSession[]>([]);
|
|
50
57
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -80,7 +87,7 @@ export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions'
|
|
|
80
87
|
setSessions(data.sessions || []);
|
|
81
88
|
setLastRefresh(new Date());
|
|
82
89
|
} else {
|
|
83
|
-
setError(data
|
|
90
|
+
setError(errorWithRef(data, 'Failed to fetch sessions'));
|
|
84
91
|
}
|
|
85
92
|
} catch (err) {
|
|
86
93
|
setError('Network error. Please try again.');
|
|
@@ -132,7 +139,7 @@ export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions'
|
|
|
132
139
|
await fetchSessions();
|
|
133
140
|
await fetchStats();
|
|
134
141
|
} else {
|
|
135
|
-
alert(data
|
|
142
|
+
alert(errorWithRef(data, 'Failed to revoke session'));
|
|
136
143
|
}
|
|
137
144
|
} catch (err) {
|
|
138
145
|
alert('Network error. Please try again.');
|
|
@@ -162,7 +169,7 @@ export function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions'
|
|
|
162
169
|
await fetchSessions();
|
|
163
170
|
await fetchStats();
|
|
164
171
|
} else {
|
|
165
|
-
alert(data
|
|
172
|
+
alert(errorWithRef(data, 'Failed to revoke sessions'));
|
|
166
173
|
}
|
|
167
174
|
} catch (err) {
|
|
168
175
|
alert('Network error. Please try again.');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
onGoToLogin: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PAY-1588 (rigpert 60711): what a caller sees when the recovery request produced NO recovery session - an unknown
|
|
11
|
+
* address, a locked account, a throttled caller. ONE sentence, identical for all of them, so the screen cannot be used
|
|
12
|
+
* to tell which. It must not claim a reset happened (that is CompleteStep, reached only after a successful reset) and it
|
|
13
|
+
* must not name a cause.
|
|
14
|
+
*/
|
|
15
|
+
export function RequestedStep({ onGoToLogin }: Props) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="bg-white border border-gray-300 rounded p-8 text-center">
|
|
18
|
+
<h2 className="text-2xl font-semibold text-gray-900 mb-2">Check your email</h2>
|
|
19
|
+
|
|
20
|
+
<p className="text-gray-600 mb-6">If an account exists for that email, we've sent instructions to reset your password.</p>
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
onClick={onGoToLogin}
|
|
24
|
+
className="w-full py-2 px-4 border border-gray-300 rounded text-gray-900 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
|
25
|
+
>
|
|
26
|
+
Back to sign in
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState } from 'react';
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { usePasswordValidation } from '../../hooks/usePasswordValidation';
|
|
5
|
+
import { passwordRulesText } from '../../lib/password-rules';
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
8
|
onSubmit: (password: string, confirmPassword: string) => void;
|
|
@@ -15,6 +17,14 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
|
|
|
15
17
|
|
|
16
18
|
const passwordsMatch = password === confirmPassword && password !== '';
|
|
17
19
|
|
|
20
|
+
// PAY-1591: ADVISORY only. It shows the rules and what is missing; it never disables submit, because the validator
|
|
21
|
+
// may be down and the IdP is the authority (a dead validator must not trap a user who already spent their code).
|
|
22
|
+
const { setPassword: validateAsYouType, policy, failedRequirements } = usePasswordValidation();
|
|
23
|
+
// The policy only comes back with a validation, so ask once on mount with a one-character probe (it never leaves the
|
|
24
|
+
// policy question; the answer is what the rules line is built from). Until it lands the line says the rules are coming.
|
|
25
|
+
useEffect(() => { validateAsYouType('a'); }, [validateAsYouType]);
|
|
26
|
+
const missing = failedRequirements.filter((r) => !/^(Unable to validate|Password validation)/i.test(r));
|
|
27
|
+
|
|
18
28
|
const handleSubmit = (e: React.FormEvent) => {
|
|
19
29
|
e.preventDefault();
|
|
20
30
|
if (passwordsMatch) {
|
|
@@ -25,9 +35,12 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
|
|
|
25
35
|
return (
|
|
26
36
|
<div className="bg-white border border-gray-300 rounded p-8">
|
|
27
37
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">Set New Password</h2>
|
|
28
|
-
<p className="text-sm text-gray-600 mb-
|
|
38
|
+
<p className="text-sm text-gray-600 mb-2">
|
|
29
39
|
Choose a strong password for your account.
|
|
30
40
|
</p>
|
|
41
|
+
<p data-testid="password-rules" className="text-sm text-gray-700 mb-6">
|
|
42
|
+
{passwordRulesText(policy)}
|
|
43
|
+
</p>
|
|
31
44
|
|
|
32
45
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
33
46
|
<div>
|
|
@@ -39,7 +52,7 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
|
|
|
39
52
|
type={showPassword ? 'text' : 'password'}
|
|
40
53
|
id="password"
|
|
41
54
|
value={password}
|
|
42
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
55
|
+
onChange={(e) => { setPassword(e.target.value); validateAsYouType(e.target.value); }}
|
|
43
56
|
required
|
|
44
57
|
disabled={loading}
|
|
45
58
|
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-gray-900 focus:outline-none disabled:opacity-50 disabled:bg-gray-50 pr-10"
|
|
@@ -78,6 +91,14 @@ export function SetPasswordStep({ onSubmit, loading }: Props) {
|
|
|
78
91
|
</div>
|
|
79
92
|
</div>
|
|
80
93
|
|
|
94
|
+
{password && missing.length > 0 && (
|
|
95
|
+
<ul data-testid="password-missing" className="text-sm text-gray-700 list-disc pl-5">
|
|
96
|
+
{missing.map((r) => (
|
|
97
|
+
<li key={r}>{r}</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
)}
|
|
101
|
+
|
|
81
102
|
{!passwordsMatch && confirmPassword && (
|
|
82
103
|
<div className="bg-red-50 border border-red-200 rounded p-3">
|
|
83
104
|
<p className="text-red-700 text-sm">Passwords do not match</p>
|
|
@@ -43,7 +43,8 @@ export interface ViabilityState {
|
|
|
43
43
|
|
|
44
44
|
interface ViabilityResponse {
|
|
45
45
|
authenticated: boolean;
|
|
46
|
-
|
|
46
|
+
/** PAY-1481: tokenLogId of the session token (correlation only); the token itself is never returned. */
|
|
47
|
+
sessionTokenId?: string | null;
|
|
47
48
|
requires2FA?: boolean;
|
|
48
49
|
twoFactorComplete?: boolean;
|
|
49
50
|
accessTokenExpired?: boolean;
|
package/src/lib/app-slug.ts
CHANGED
|
@@ -27,6 +27,56 @@ export function getAppSlug(): string {
|
|
|
27
27
|
return cachedSlug;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The session cookie name has TWO producers: better-auth mints `${idpConfig.clientSlug || getAppSlug()}.session-token`, while
|
|
32
|
+
* the gate and every reader use getSessionCookieName() = getAppSlug() = APP_SLUG || CLIENT_ID. When the IdP's slug is known and
|
|
33
|
+
* differs, the gate looks for a cookie that was never set and every better-auth (Google) session bounces to
|
|
34
|
+
* /sign-in?error=SessionInvalid straight after a successful 2FA (rigpert 60743, live on :4797 with CLIENT_ID=idp-admin-ui-graft
|
|
35
|
+
* against clientSlug idp_admin_ui_graft). FAIL CLOSED: refuse rather than run with two names. No IdP slug -> nothing to compare.
|
|
36
|
+
*/
|
|
37
|
+
export function slugMismatchReason(idpClientSlug: string | null | undefined): string | null {
|
|
38
|
+
if (!idpClientSlug) return null;
|
|
39
|
+
const gateSlug = getAppSlug();
|
|
40
|
+
return idpClientSlug !== gateSlug ? `SLUG_MISMATCH: cookie=${idpClientSlug} gate=${gateSlug}` : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* rigpert 60813: when the IdP returns NO client slug for this app (an unknown client: the config fetch failed or came back
|
|
45
|
+
* without one), startup must fail LOUD. Silently defaulting would run with a cookie name nobody verified against the IdP. The
|
|
46
|
+
* startup path swallows a failed config fetch when BETTER_AUTH_SECRET is supplied by env, so without this an unregistered
|
|
47
|
+
* CLIENT_ID (e.g. payez_idp_customer_web, absent on dev-93) would boot 'fine'.
|
|
48
|
+
*/
|
|
49
|
+
export function assertIdpSlugKnown(idpClientSlug: string | null | undefined): void {
|
|
50
|
+
if (idpClientSlug) return;
|
|
51
|
+
throw new Error(
|
|
52
|
+
`[FATAL] The IdP returned no client slug for this app (gate slug "${getAppSlug()}"). Either CLIENT_ID/APP_SLUG names a client ` +
|
|
53
|
+
`the IdP does not know, or its config could not be fetched. Refusing to start with an unverified session cookie name.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The health-endpoint view: a mismatch is always reportable; an UNKNOWN slug is reported only once startup has actually been
|
|
59
|
+
* attempted (before that nothing is known yet and health must not go red on a pod that has not started initialising).
|
|
60
|
+
*/
|
|
61
|
+
export function slugStateReason(idpClientSlug: string | null | undefined, startupAttempted: boolean): string | null {
|
|
62
|
+
const mismatch = slugMismatchReason(idpClientSlug);
|
|
63
|
+
if (mismatch) return mismatch;
|
|
64
|
+
if (startupAttempted && !idpClientSlug) return `SLUG_UNKNOWN: the IdP returned no client slug for this app gate=${getAppSlug()}`;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function assertAppSlugMatchesIdp(idpClientSlug: string | null | undefined): void {
|
|
69
|
+
if (!idpClientSlug) return;
|
|
70
|
+
const gateSlug = getAppSlug();
|
|
71
|
+
if (slugMismatchReason(idpClientSlug) !== null) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[FATAL] Session cookie name would have two sources: the IdP client slug is "${idpClientSlug}" but this app's slug ` +
|
|
74
|
+
`(APP_SLUG, else CLIENT_ID) is "${gateSlug}". better-auth would set "${idpClientSlug}.session-token" while the gate reads ` +
|
|
75
|
+
`"${gateSlug}.session-token", so every better-auth session would be rejected. Set APP_SLUG (or CLIENT_ID) to "${idpClientSlug}".`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
30
80
|
/**
|
|
31
81
|
* Clears the cached slug (useful for testing).
|
|
32
82
|
*/
|
|
@@ -46,6 +46,32 @@ export type EnsureFreshResult =
|
|
|
46
46
|
accessTokenExpires: number;
|
|
47
47
|
/** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
|
|
48
48
|
refreshed: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* CARD 320072. Is the token in THIS result actually in the session store?
|
|
51
|
+
*
|
|
52
|
+
* ON THIS BRANCH OF THE UNION IT IS ALWAYS `true`, AND THAT IS THE POINT.
|
|
53
|
+
* Every `ok: true` return either read the token back from the store or
|
|
54
|
+
* wrote it and confirmed the write. An unpersisted refresh does not reach
|
|
55
|
+
* an `ok: true` result at all — it returns `ok:false, SESSION_PERSIST_FAILED`
|
|
56
|
+
* from the other branch below.
|
|
57
|
+
*
|
|
58
|
+
* READ THIS BEFORE YOU DELETE THE FIELD. It is declared `boolean` rather
|
|
59
|
+
* than the literal `true` on purpose: it is the assertable witness that the
|
|
60
|
+
* escalation is still wired. If someone later re-weakens the persist site
|
|
61
|
+
* back to reporting success, this field is where that regression becomes
|
|
62
|
+
* visible to a test instead of invisible to everyone. Cases 2 and 7 in
|
|
63
|
+
* test/ensure-fresh-persist-failure.test.cjs assert on it.
|
|
64
|
+
*
|
|
65
|
+
* WHAT THIS COMMENT USED TO SAY, AND WHY IT IS GONE. It documented `false`
|
|
66
|
+
* as a live value on an ok:true result — "we hold a valid token that was
|
|
67
|
+
* not persisted" — because that WAS the contract before this merge, when
|
|
68
|
+
* the dropped write was reported as a success with a flag on it. Blending
|
|
69
|
+
* in the escalation made that sentence describe a state the code can no
|
|
70
|
+
* longer produce. A docblock asserting an impossible semantic is exactly
|
|
71
|
+
* the defect f5ac6dc3 was written to fix, one file over; reproducing it
|
|
72
|
+
* here while merging that fix would be absurd.
|
|
73
|
+
*/
|
|
74
|
+
persisted: boolean;
|
|
49
75
|
}
|
|
50
76
|
| {
|
|
51
77
|
ok: false;
|
|
@@ -113,7 +139,8 @@ export async function ensureFreshAccessToken(
|
|
|
113
139
|
const msUntilExpiry = effectiveExpires - now;
|
|
114
140
|
|
|
115
141
|
if (msUntilExpiry > safetyWindowMs) {
|
|
116
|
-
|
|
142
|
+
// Durable by construction: this token came OUT of the store and we wrote nothing.
|
|
143
|
+
return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted: true };
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
if (!session.idpRefreshToken) {
|
|
@@ -152,6 +179,8 @@ export async function ensureFreshAccessToken(
|
|
|
152
179
|
accessToken: after.idpAccessToken,
|
|
153
180
|
accessTokenExpires: after.idpAccessTokenExpires,
|
|
154
181
|
refreshed: true,
|
|
182
|
+
// Read back from the store after the winner's refresh - durable.
|
|
183
|
+
persisted: true,
|
|
155
184
|
};
|
|
156
185
|
}
|
|
157
186
|
}
|
|
@@ -188,6 +217,8 @@ export async function ensureFreshAccessToken(
|
|
|
188
217
|
accessToken: latest.idpAccessToken,
|
|
189
218
|
accessTokenExpires: latest.idpAccessTokenExpires,
|
|
190
219
|
refreshed: true,
|
|
220
|
+
// Post-lock re-read: another caller already refreshed and stored this.
|
|
221
|
+
persisted: true,
|
|
191
222
|
};
|
|
192
223
|
}
|
|
193
224
|
}
|
|
@@ -276,12 +307,37 @@ export async function ensureFreshAccessToken(
|
|
|
276
307
|
const tokenUnchanged =
|
|
277
308
|
!!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
278
309
|
if (tokenUnchanged) {
|
|
279
|
-
|
|
310
|
+
/**
|
|
311
|
+
* 320072: this is the OTHER updateSession call in this file, and it
|
|
312
|
+
* does NOT escalate on a null return the way the persist site below
|
|
313
|
+
* does. Checked, not overlooked: reaching here requires
|
|
314
|
+
* `tokenUnchanged`, which requires `postRefresh?.idpRefreshToken` to
|
|
315
|
+
* equal the token we sent — so the CAS read three lines up already
|
|
316
|
+
* proved the row exists. A missing row makes tokenUnchanged false and
|
|
317
|
+
* routes to the else branch instead, so the dropped-write path is
|
|
318
|
+
* very nearly unreachable here.
|
|
319
|
+
*
|
|
320
|
+
* "Very nearly" is why the return IS still captured. The residual both
|
|
321
|
+
* halves of this merge identified is real: the row can expire between
|
|
322
|
+
* that CAS read and this write, in which case the dead refresh token is
|
|
323
|
+
* NOT cleared and the "we discarded it" claim in the error below is
|
|
324
|
+
* false. That costs nothing to observe and cannot be inferred from any
|
|
325
|
+
* other line, so it is warned rather than assumed — see `if (!cleared)`
|
|
326
|
+
* immediately below. The RESULT is unchanged either way: the session is
|
|
327
|
+
* unusable and we return the same non-ok payload.
|
|
328
|
+
*/
|
|
329
|
+
const cleared = await updateSession(sessionToken, {
|
|
280
330
|
idpRefreshToken: '',
|
|
281
331
|
idpRefreshTokenExpires: undefined,
|
|
282
332
|
refreshTokenClearedAt: Date.now(),
|
|
283
333
|
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
284
334
|
});
|
|
335
|
+
if (!cleared) {
|
|
336
|
+
console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
|
|
337
|
+
code,
|
|
338
|
+
requestId,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
285
341
|
} else {
|
|
286
342
|
// Concurrent refresh already rotated the token — our 401 is stale. Return the
|
|
287
343
|
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
@@ -293,6 +349,8 @@ export async function ensureFreshAccessToken(
|
|
|
293
349
|
accessToken: postRefresh.idpAccessToken,
|
|
294
350
|
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
295
351
|
refreshed: true,
|
|
352
|
+
// `postRefresh` is a store read, so this token is by definition stored.
|
|
353
|
+
persisted: true,
|
|
296
354
|
};
|
|
297
355
|
}
|
|
298
356
|
}
|
|
@@ -351,7 +409,14 @@ export async function ensureFreshAccessToken(
|
|
|
351
409
|
const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
|
|
352
410
|
const newKid = extractKidFromToken(newAccess);
|
|
353
411
|
|
|
354
|
-
|
|
412
|
+
// *** CARD 320072. CAPTURE THIS RETURN. ***
|
|
413
|
+
// `updateSession` returns null - and writes nothing - when the canonical
|
|
414
|
+
// session row is missing, which happens whenever the BA fallback above is what
|
|
415
|
+
// resolved this session. Discarding the return is what let this function
|
|
416
|
+
// report `ok:true, refreshed:true` for a refresh it had just thrown away.
|
|
417
|
+
// The full rationale for what we DO about it is at the `if (!persisted)`
|
|
418
|
+
// block below; this line only makes the answer available.
|
|
419
|
+
const persisted = await updateSession(sessionToken, {
|
|
355
420
|
...session,
|
|
356
421
|
idpAccessToken: newAccess,
|
|
357
422
|
idpAccessTokenExpires: accessTokenExpires,
|
|
@@ -371,7 +436,71 @@ export async function ensureFreshAccessToken(
|
|
|
371
436
|
mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : session.mfaValidityHours,
|
|
372
437
|
});
|
|
373
438
|
|
|
374
|
-
|
|
439
|
+
/**
|
|
440
|
+
* 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
|
|
441
|
+
*
|
|
442
|
+
* `updateSession` returns null when the canonical session row is absent,
|
|
443
|
+
* which silently throws away EVERYTHING above — the new access token, the
|
|
444
|
+
* rotated refresh token, decodedAccessToken, authenticationMethods,
|
|
445
|
+
* mfaVerified, mfaCompletedAt, mfaExpiresAt. This return value was
|
|
446
|
+
* previously ignored, and the line below then reported
|
|
447
|
+
* `ok: true, refreshed: true`.
|
|
448
|
+
*
|
|
449
|
+
* WHY THAT IS WORSE THAN A PLAIN LOST WRITE. By this point the exchange at
|
|
450
|
+
* the IDP has ALREADY HAPPENED, so if refresh tokens are single-use the
|
|
451
|
+
* token we sent is now spent and its replacement is the thing we just
|
|
452
|
+
* dropped. The stored credential is therefore dead, and every later attempt
|
|
453
|
+
* re-sends that same dead token and fails. The user is bounced to LOGIN —
|
|
454
|
+
* not to verify-code — with nothing in the logs naming the cause.
|
|
455
|
+
*
|
|
456
|
+
* It did not even surface as a wrong answer to our one caller: server/auth.ts
|
|
457
|
+
* calls getRedisSession immediately after us, which is this same `getSession`,
|
|
458
|
+
* so it finds the same missing row and returns a generic NO_SESSION one frame
|
|
459
|
+
* later. The false `ok: true` was thus invisible AND the real cause — a
|
|
460
|
+
* dropped persist — was reported as an unrelated missing session.
|
|
461
|
+
*
|
|
462
|
+
* Naming it here is the point: the failure is reported at its true cause,
|
|
463
|
+
* with a code that says the refresh succeeded and the PERSIST is what
|
|
464
|
+
* failed. Not retryable — retrying burns another refresh token the same way.
|
|
465
|
+
*
|
|
466
|
+
* This is the card's option (b), deliberately. Option (a) — teaching
|
|
467
|
+
* updateSession to write through to whichever store holds the session,
|
|
468
|
+
* mirroring the read fallback above — is the better end state but changes
|
|
469
|
+
* write-path storage semantics for every caller, which is not this card.
|
|
470
|
+
*
|
|
471
|
+
* AND IT IS ALSO LOGGED, WHICH IS NOT THE SAME THING (BAPert R1134 §7,
|
|
472
|
+
* re-affirmed in R1145 §2c). Returning a named code tells the CALLER. §7
|
|
473
|
+
* requires that `persisted:false` reach a log A HUMAN READS, because the one
|
|
474
|
+
* caller — server/auth.ts — converts every non-ok result into the same
|
|
475
|
+
* generic 401 and keeps no record of which code produced it. Without this
|
|
476
|
+
* line the distinction we just bought at the type level is erased one frame
|
|
477
|
+
* later and no operator ever sees the word PERSIST. The escalation is the
|
|
478
|
+
* contract; the warn is the evidence.
|
|
479
|
+
*/
|
|
480
|
+
if (!persisted) {
|
|
481
|
+
console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
|
|
482
|
+
requestId,
|
|
483
|
+
refreshTokenRotated: hasNewRefresh,
|
|
484
|
+
persisted: false,
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
code: 'SESSION_PERSIST_FAILED',
|
|
489
|
+
message:
|
|
490
|
+
'Token refresh succeeded at the IDP but the session could not be persisted; the rotated tokens were discarded.',
|
|
491
|
+
status: 401,
|
|
492
|
+
terminal: true,
|
|
493
|
+
retryable: false,
|
|
494
|
+
discardToken: true,
|
|
495
|
+
resolution: 'REAUTHENTICATE',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// `persisted: true` is not decoration and not a leftover: the `if` above is
|
|
500
|
+
// the only way out for a dropped write, so by construction anything reaching
|
|
501
|
+
// this line WAS written. Keeping the field keeps the ok:true branch of
|
|
502
|
+
// EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
|
|
503
|
+
return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
|
|
375
504
|
} finally {
|
|
376
505
|
if (weHoldLock) {
|
|
377
506
|
await releaseRefreshLock(sessionToken, requestId, releaseVersion);
|