@payez/next-mvp 4.11.0 → 4.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/api-handlers/account/recovery/forward.js +2 -1
  3. package/dist/api-handlers/account/recovery/initiate.js +2 -1
  4. package/dist/api-handlers/account/reset-password.js +2 -1
  5. package/dist/api-handlers/account/send-code.js +2 -1
  6. package/dist/api-handlers/account/update-phone.js +2 -1
  7. package/dist/api-handlers/account/validate-password.js +2 -1
  8. package/dist/api-handlers/account/verify-email.js +2 -1
  9. package/dist/api-handlers/account/verify-sms.js +2 -1
  10. package/dist/api-handlers/anon/preferences.js +3 -2
  11. package/dist/api-handlers/auth/credential-login-email-code.js +3 -2
  12. package/dist/api-handlers/auth/credential-login.js +6 -5
  13. package/dist/api-handlers/auth/refresh.js +2 -1
  14. package/dist/api-handlers/auth/signout.js +43 -6
  15. package/dist/api-handlers/mfa/backup-code-verify.js +2 -1
  16. package/dist/api-handlers/mfa/totp-verify.js +2 -1
  17. package/dist/api-handlers/session/refresh-viability.js +3 -2
  18. package/dist/api-handlers/session/viability.js +2 -1
  19. package/dist/auth/better-auth.js +7 -6
  20. package/dist/auth/idp-oauth.js +4 -2
  21. package/dist/auth/utils/idp-client.js +5 -4
  22. package/dist/auth/utils/token-utils.js +4 -3
  23. package/dist/config/logger.js +2 -1
  24. package/dist/config/logging-config.js +4 -3
  25. package/dist/config/vibe-log-transport.js +2 -1
  26. package/dist/lib/ensure-fresh-access-token.js +17 -11
  27. package/dist/lib/geolocation.js +5 -22
  28. package/dist/lib/idp-client-config.js +4 -3
  29. package/dist/lib/idp-fetch.d.ts +11 -0
  30. package/dist/lib/idp-fetch.js +3 -1
  31. package/dist/lib/idp-logout.d.ts +10 -0
  32. package/dist/lib/idp-logout.js +73 -1
  33. package/dist/lib/jwt-decode-client.js +2 -1
  34. package/dist/lib/jwt-decode.js +2 -1
  35. package/dist/lib/log-safe.d.ts +19 -0
  36. package/dist/lib/log-safe.js +32 -0
  37. package/dist/lib/redis.js +2 -1
  38. package/dist/lib/safe-error-response.d.ts +5 -1
  39. package/dist/lib/safe-error-response.js +7 -2
  40. package/dist/lib/session-store.js +2 -1
  41. package/dist/lib/site-logger.d.ts +2 -8
  42. package/dist/lib/site-logger.js +11 -5
  43. package/dist/lib/standardized-client-api.js +3 -2
  44. package/dist/lib/token-lifecycle.js +3 -2
  45. package/dist/middleware/create-middleware.js +2 -1
  46. package/dist/middleware/rbac-check.d.ts +0 -13
  47. package/dist/middleware/rbac-check.js +6 -5
  48. package/dist/routes/auth/logout.js +6 -2
  49. package/dist/routes/auth/settings.js +2 -1
  50. package/dist/server/auth-guard.js +4 -3
  51. package/dist/server/auth.js +5 -9
  52. package/dist/server/decode-session.js +3 -2
  53. package/dist/server/with-auth.js +2 -1
  54. package/package.json +1 -1
  55. package/src/api-handlers/account/recovery/forward.ts +2 -1
  56. package/src/api-handlers/account/recovery/initiate.ts +2 -1
  57. package/src/api-handlers/account/reset-password.ts +2 -1
  58. package/src/api-handlers/account/send-code.ts +2 -1
  59. package/src/api-handlers/account/update-phone.ts +2 -1
  60. package/src/api-handlers/account/validate-password.ts +2 -1
  61. package/src/api-handlers/account/verify-email.ts +2 -1
  62. package/src/api-handlers/account/verify-sms.ts +2 -1
  63. package/src/api-handlers/anon/preferences.ts +3 -2
  64. package/src/api-handlers/auth/credential-login-email-code.ts +3 -2
  65. package/src/api-handlers/auth/credential-login.ts +6 -5
  66. package/src/api-handlers/auth/refresh.ts +2 -1
  67. package/src/api-handlers/auth/signout.ts +54 -7
  68. package/src/api-handlers/mfa/backup-code-verify.ts +2 -1
  69. package/src/api-handlers/mfa/totp-verify.ts +2 -1
  70. package/src/api-handlers/session/refresh-viability.ts +3 -2
  71. package/src/api-handlers/session/viability.ts +2 -1
  72. package/src/auth/better-auth.ts +7 -6
  73. package/src/auth/idp-oauth.ts +7 -2
  74. package/src/auth/utils/idp-client.ts +5 -4
  75. package/src/auth/utils/token-utils.ts +4 -3
  76. package/src/config/logger.ts +2 -1
  77. package/src/config/logging-config.ts +4 -3
  78. package/src/config/vibe-log-transport.ts +2 -1
  79. package/src/lib/ensure-fresh-access-token.ts +17 -11
  80. package/src/lib/geolocation.ts +5 -4
  81. package/src/lib/idp-client-config.ts +4 -3
  82. package/src/lib/idp-fetch.ts +11 -1
  83. package/src/lib/idp-logout.ts +36 -1
  84. package/src/lib/jwt-decode-client.ts +3 -1
  85. package/src/lib/jwt-decode.ts +2 -1
  86. package/src/lib/log-safe.ts +30 -0
  87. package/src/lib/redis.ts +2 -1
  88. package/src/lib/safe-error-response.ts +7 -2
  89. package/src/lib/session-store.ts +2 -1
  90. package/src/lib/site-logger.ts +13 -7
  91. package/src/lib/standardized-client-api.ts +3 -2
  92. package/src/lib/token-lifecycle.ts +3 -2
  93. package/src/middleware/create-middleware.ts +2 -1
  94. package/src/middleware/rbac-check.ts +2 -1
  95. package/src/routes/auth/logout.ts +9 -2
  96. package/src/routes/auth/settings.ts +2 -1
  97. package/src/server/auth-guard.ts +4 -3
  98. package/src/server/auth.ts +5 -10
  99. package/src/server/decode-session.ts +3 -2
  100. package/src/server/with-auth.ts +2 -1
@@ -11,7 +11,15 @@
11
11
 
12
12
  import { NextRequest, NextResponse } from 'next/server';
13
13
  import { cookies } from 'next/headers';
14
- import { deleteSessionEverywhere } from '../../lib/session-store';
14
+ import { deleteSessionEverywhere, getSession as getStoredSession, getBetterAuthSession as getStoredBetterAuthSession } from '../../lib/session-store';
15
+ import { revokeSessionAtIdp } from '../../lib/idp-logout';
16
+ import { errForLog } from '../../lib/log-safe';
17
+ import {
18
+ SESSION_NOT_ENDED_CODE,
19
+ SESSION_NOT_ENDED_IDP_MESSAGE,
20
+ SESSION_NOT_ENDED_LOCAL_MESSAGE,
21
+ type SessionNotEndedCause,
22
+ } from '../../routes/auth/logout';
15
23
  import { getSession } from '../../server/auth';
16
24
  import {
17
25
  getSessionCookieName,
@@ -147,23 +155,55 @@ export function createSignoutHandler() {
147
155
  }
148
156
  }
149
157
 
150
- // Delete the session from EVERY store (181922): canonical + BA secondary.
151
- // Canonical-only deletion let the ba: record resurrect the session.
158
+ // GOV-023 (BAPert 76687): ending the session ends the IdP GRANT first, the same IdP-first path as routes/auth/logout. Before this, this
159
+ // handler only deleted the local record, so a consumer who mounted it left the IdP refresh token live until it expired.
160
+ // A failed or unresolved revoke is the same typed failure as a failed local delete (SESSION_NOT_ENDED + cause); the local delete still
161
+ // runs and the cookies are cleared either way.
162
+ let idpRevokeFailed = false;
163
+ let localDeleteFailed = false;
152
164
  if (redisSessionToken) {
165
+ // Does the stored session hold an IdP refresh token? (revokeSessionAtIdp reads the canonical row, then the Better Auth record.)
166
+ let heldRefreshToken = false;
167
+ try {
168
+ const stored = (await getStoredSession(redisSessionToken)) ?? (await getStoredBetterAuthSession(redisSessionToken));
169
+ heldRefreshToken = !!stored?.idpRefreshToken;
170
+ } catch {
171
+ // unknown: treated as "none held", so an unreadable store does not turn a clean sign-out into a failure on its own
172
+ }
173
+ try {
174
+ const outcome = await revokeSessionAtIdp(req);
175
+ // 'nothing-to-revoke' while a refresh token IS held means the helper could not resolve this session from the request (this
176
+ // handler also finds the session by decoding the cookie when Better Auth cannot): the grant is NOT ended, so say so.
177
+ if (outcome === 'nothing-to-revoke' && heldRefreshToken) {
178
+ idpRevokeFailed = true;
179
+ console.error('[SIGNOUT] IdP revoke could not resolve the session - the IdP refresh token is STILL LIVE');
180
+ }
181
+ } catch (revokeError) {
182
+ idpRevokeFailed = true;
183
+ console.error(
184
+ (revokeError as { grantLive?: boolean } | null)?.grantLive === true
185
+ ? '[SIGNOUT] IdP revoke failed - the IdP refresh token is STILL LIVE:'
186
+ : '[SIGNOUT] IdP revoke could not be confirmed (the grant may already be dead):',
187
+ errForLog(revokeError),
188
+ );
189
+ }
190
+
191
+ // Delete the session from EVERY store (181922): canonical + BA secondary.
192
+ // Canonical-only deletion let the ba: record resurrect the session.
153
193
  try {
154
194
  await deleteSessionEverywhere(redisSessionToken);
155
195
  console.info('[SIGNOUT] Redis session cleanup successful', {
156
196
  sessionToken: tokenLogId(redisSessionToken)
157
197
  });
158
198
  } catch (sessionError) {
159
- // Log error but don't fail the signout process
160
- console.error('[SIGNOUT] Redis session cleanup failed', { error: sessionError });
199
+ localDeleteFailed = true;
200
+ console.error('[SIGNOUT] Redis session cleanup failed - the server session is STILL LIVE:', errForLog(sessionError));
161
201
  }
162
202
  } else {
163
203
  console.warn('[SIGNOUT] No Redis session token (UUID) extracted from cookie; skipping Redis deletion');
164
204
  }
165
205
 
166
- // Build response
206
+ // Build response. A sign-out that did not end the session must not say it did (same typed answer as routes/auth/logout).
167
207
  const responseData: SignoutResponse = {
168
208
  success: true,
169
209
  message: sessionToken ? 'Session deleted successfully' : 'No active session found',
@@ -171,7 +211,14 @@ export function createSignoutHandler() {
171
211
  chunkCookiesDeleted: chunkCookies.length
172
212
  };
173
213
 
174
- const response = NextResponse.json(responseData);
214
+ const response = (localDeleteFailed || idpRevokeFailed)
215
+ ? NextResponse.json(
216
+ localDeleteFailed
217
+ ? { success: false, code: SESSION_NOT_ENDED_CODE, cause: 'local_delete_failed' as SessionNotEndedCause, message: SESSION_NOT_ENDED_LOCAL_MESSAGE }
218
+ : { success: false, code: SESSION_NOT_ENDED_CODE, cause: 'idp_unconfirmed' as SessionNotEndedCause, message: SESSION_NOT_ENDED_IDP_MESSAGE },
219
+ { status: 500 },
220
+ )
221
+ : NextResponse.json(responseData);
175
222
 
176
223
  // Always clear cookies, regardless of success/failure (using app-slug prefixed names)
177
224
  try {
@@ -60,6 +60,7 @@ import { getTokenTestAware } from '../../lib/test-aware-get-token';
60
60
  import { transitionTo2FASession } from '../../lib/session-store';
61
61
  import { jwtDecode } from '../../lib/jwt-decode';
62
62
  import { twoFactorVerifyFailure } from '../../lib/two-factor-verify-failure';
63
+ import { errForLog } from '../../lib/log-safe';
63
64
 
64
65
  export async function POST(req: NextRequest) {
65
66
  try {
@@ -157,7 +158,7 @@ export async function POST(req: NextRequest) {
157
158
  { status: 200 }
158
159
  );
159
160
  } catch (error) {
160
- console.error('[VERIFY_BACKUP_CODE] Error:', error);
161
+ console.error('[VERIFY_BACKUP_CODE] Error:', errForLog(error));
161
162
  return NextResponse.json(
162
163
  {
163
164
  success: false,
@@ -35,6 +35,7 @@ import { getTokenTestAware } from '../../lib/test-aware-get-token';
35
35
  import { transitionTo2FASession } from '../../lib/session-store';
36
36
  import { jwtDecode } from '../../lib/jwt-decode';
37
37
  import { twoFactorVerifyFailure } from '../../lib/two-factor-verify-failure';
38
+ import { errForLog } from '../../lib/log-safe';
38
39
 
39
40
  export async function POST(req: NextRequest) {
40
41
  try {
@@ -159,7 +160,7 @@ export async function POST(req: NextRequest) {
159
160
  { status: 200 }
160
161
  );
161
162
  } catch (error) {
162
- console.error('[VERIFY_TOTP] Error:', error);
163
+ console.error('[VERIFY_TOTP] Error:', errForLog(error));
163
164
  return NextResponse.json(
164
165
  {
165
166
  success: false,
@@ -4,6 +4,7 @@ import { checkRefreshViability } from '../../lib/refresh-token-validator';
4
4
  import { getTokenTestAware } from '../../lib/test-aware-get-token';
5
5
  import { logger } from '../../config/logger';
6
6
  import { tokenLogId } from '../../lib/token-log-id';
7
+ import { errForLog } from '../../lib/log-safe';
7
8
  import { resolveSessionState } from '../../server/auth';
8
9
 
9
10
  export async function GET(req: NextRequest) {
@@ -49,8 +50,8 @@ export async function GET(req: NextRequest) {
49
50
  return NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionTokenId: tokenLogId(finalSessionToken), twoFactorComplete: sessionData.mfaVerified ?? false, userId: sessionData.userId, refreshInProgress: false });
50
51
  } catch (error) {
51
52
  const correlationId = globalThis.crypto.randomUUID();
52
- logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { correlationId, error: error instanceof Error ? error.message : String(error) });
53
- // PAY-1438: the exception text stays in the log above; the caller gets the id.
53
+ logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { correlationId, error: errForLog(error) });
54
+ // PAY-1438: the caller gets the id; the log line names the error only (errForLog), never its message (PII-in-logs group 1, BAPert 76553).
54
55
  return NextResponse.json({ canRefresh: false, reason: 'check_error', error: 'Failed to check refresh viability', code: 'INTERNAL_ERROR', correlationId }, { status: 500 });
55
56
  }
56
57
  }
@@ -12,6 +12,7 @@ import { isInitializationFailed, ensureInitialized } from '../../lib/startup-ini
12
12
  import { getIDPClientConfig } from '../../lib/idp-client-config';
13
13
  import { tokenLogId } from '../../lib/token-log-id';
14
14
  import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
15
+ import { errForLog } from '../../lib/log-safe';
15
16
 
16
17
  export async function GET(req: NextRequest) {
17
18
  try {
@@ -233,7 +234,7 @@ export async function GET(req: NextRequest) {
233
234
  return NextResponse.json({ authenticated: false });
234
235
 
235
236
  } catch (error) {
236
- console.error('[API Viability] Error checking session viability:', error);
237
+ console.error('[API Viability] Error checking session viability:', errForLog(error));
237
238
  return NextResponse.json({ authenticated: false }, { status: 500 });
238
239
  }
239
240
  }
@@ -33,6 +33,7 @@ import { getRedis } from '../lib/redis';
33
33
  import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
34
34
  import { SESSION_TTL } from '../lib/session-store';
35
35
  import { tokenLogId } from '../lib/token-log-id';
36
+ import { errForLog } from '../lib/log-safe';
36
37
 
37
38
  /**
38
39
  * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
@@ -855,7 +856,7 @@ export function createBetterAuthInstance(
855
856
  const { deleteSession } = await import('../lib/session-store');
856
857
  await deleteSession(key);
857
858
  } catch (err) {
858
- console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
859
+ console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', errForLog(err));
859
860
  }
860
861
  },
861
862
  },
@@ -1356,7 +1357,7 @@ export async function exchangeOAuthForIdpTokens(
1356
1357
  // true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
1357
1358
  return stored;
1358
1359
  } catch (err) {
1359
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
1360
+ console.error('[BETTER_AUTH] IDP token exchange failed:', errForLog(err));
1360
1361
  return false;
1361
1362
  }
1362
1363
  }
@@ -1446,7 +1447,7 @@ export async function storeIdpTokensInSession(
1446
1447
 
1447
1448
  return true;
1448
1449
  } catch (err) {
1449
- console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
1450
+ console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', errForLog(err));
1450
1451
  return false;
1451
1452
  }
1452
1453
  }
@@ -1547,13 +1548,13 @@ async function abortOAuthSignIn(
1547
1548
  const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
1548
1549
  for (const sc of (out as Response | undefined)?.headers?.getSetCookie?.() ?? []) headers.append('set-cookie', sc);
1549
1550
  } catch (err) {
1550
- console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
1551
+ console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', errForLog(err));
1551
1552
  }
1552
1553
  try {
1553
1554
  const config = await getIDPClientConfig();
1554
1555
  await getRedis().del(`ba:${config.clientSlug || getAppSlug()}:${sessionToken}`);
1555
1556
  } catch (err) {
1556
- console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), err instanceof Error ? err.message : String(err));
1557
+ console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), errForLog(err));
1557
1558
  }
1558
1559
  }
1559
1560
  for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
@@ -1673,7 +1674,7 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1673
1674
  }
1674
1675
  }
1675
1676
  } catch (err: any) {
1676
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
1677
+ console.error('[BETTER_AUTH] IDP token exchange failed:', errForLog(err));
1677
1678
  }
1678
1679
  if (exchange !== true) {
1679
1680
  // A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
@@ -19,6 +19,7 @@
19
19
  * case-insensitively, at the authorize step). Both come from ONE value: the `redirectURI` of the provider config.
20
20
  */
21
21
  import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify, type JWTVerifyGetKey } from 'jose';
22
+ import { errForLog } from '../lib/log-safe';
22
23
 
23
24
  export interface IdpTokenGrant {
24
25
  accessToken: string;
@@ -281,7 +282,11 @@ export function createIdpOAuthHooks(opts: IdpOAuthHookOptions) {
281
282
  if (responseMasked) console.warn('[BETTER_AUTH] payez-idp token response address is masked or unusable; ignored');
282
283
  const email = resolveEmail(payload as Record<string, unknown>, responseEmailUsable ? responseEmailRaw : undefined);
283
284
  if (!email) {
284
- throw new Error(responseMasked ? 'response address is masked' : 'no usable email (needs data.user.email or an email claim, address-shaped, equal when both are present)');
285
+ // A closed `code` rides on the error so the refusal log tells an operator WHY (errForLog prints a closed UPPER_SNAKE code) with no value (BAPert 76998 E11).
286
+ throw Object.assign(
287
+ new Error(responseMasked ? 'response address is masked' : 'no usable email (needs data.user.email or an email claim, address-shaped, equal when both are present)'),
288
+ { code: responseMasked ? 'RESPONSE_ADDRESS_MASKED' : 'NO_USABLE_EMAIL' },
289
+ );
285
290
  }
286
291
  const name =
287
292
  (typeof payload.name === 'string' && payload.name.trim()) ||
@@ -301,7 +306,7 @@ export function createIdpOAuthHooks(opts: IdpOAuthHookOptions) {
301
306
  return { id: claims.sub, email: claims.email, name: claims.name, emailVerified: claims.emailVerified };
302
307
  } catch (err) {
303
308
  // Reason only, never the token.
304
- console.warn('[BETTER_AUTH] payez-idp access token refused:', err instanceof Error ? err.message : String(err));
309
+ console.warn('[BETTER_AUTH] payez-idp access token refused:', errForLog(err));
305
310
  return null;
306
311
  }
307
312
  }
@@ -18,6 +18,7 @@ import type {
18
18
  IdpRefreshResponse,
19
19
  LoginCredentials,
20
20
  } from '../types/auth-types';
21
+ import { errForLog } from '../../lib/log-safe';
21
22
 
22
23
  // ============================================================================
23
24
  // CONFIGURATION
@@ -109,7 +110,7 @@ export async function idpLogin(
109
110
  result: responseData.result,
110
111
  };
111
112
  } catch (error) {
112
- console.error('[IDP_CLIENT] Login request failed:', error);
113
+ console.error('[IDP_CLIENT] Login request failed:', errForLog(error));
113
114
  return {
114
115
  success: false,
115
116
  error: {
@@ -246,7 +247,7 @@ export async function idpRefreshToken(
246
247
  },
247
248
  };
248
249
  } catch (error) {
249
- console.error('[IDP_CLIENT] Token refresh request failed:', error);
250
+ console.error('[IDP_CLIENT] Token refresh request failed:', errForLog(error));
250
251
  return {
251
252
  success: false,
252
253
  error: {
@@ -304,7 +305,7 @@ export async function idpVerify2FA(
304
305
 
305
306
  return { success: true };
306
307
  } catch (error) {
307
- console.error('[IDP_CLIENT] 2FA verification failed:', error);
308
+ console.error('[IDP_CLIENT] 2FA verification failed:', errForLog(error));
308
309
  return {
309
310
  success: false,
310
311
  error: {
@@ -352,7 +353,7 @@ export async function idpSend2FACode(
352
353
 
353
354
  return { success: true };
354
355
  } catch (error) {
355
- console.error('[IDP_CLIENT] Send 2FA code failed:', error);
356
+ console.error('[IDP_CLIENT] Send 2FA code failed:', errForLog(error));
356
357
  return {
357
358
  success: false,
358
359
  error: {
@@ -10,6 +10,7 @@
10
10
  import { jwtDecode, decodeJwtHeader, extractKidFromToken, JwtHeader } from '../../lib/jwt-decode';
11
11
  import type { DecodedIdpAccessToken } from '../types/auth-types';
12
12
  import { REFRESH_WINDOW_MS } from '../../lib/refresh-window';
13
+ import { errForLog } from '../../lib/log-safe';
13
14
 
14
15
  // Re-export header utilities for consumers
15
16
  export { decodeJwtHeader, extractKidFromToken, type JwtHeader } from '../../lib/jwt-decode';
@@ -28,7 +29,7 @@ export function decodeIdpAccessToken(token: string): DecodedIdpAccessToken | nul
28
29
  try {
29
30
  return jwtDecode<DecodedIdpAccessToken>(token);
30
31
  } catch (error) {
31
- console.error('[TOKEN_UTILS] Failed to decode access token:', error);
32
+ console.error('[TOKEN_UTILS] Failed to decode access token:', errForLog(error));
32
33
  return null;
33
34
  }
34
35
  }
@@ -59,7 +60,7 @@ export function decodeIdpAccessTokenFull(token: string): {
59
60
  bearerKeyId: header.kid,
60
61
  };
61
62
  } catch (error) {
62
- console.error('[TOKEN_UTILS] Failed to decode access token (full):', error);
63
+ console.error('[TOKEN_UTILS] Failed to decode access token (full):', errForLog(error));
63
64
  return null;
64
65
  }
65
66
  }
@@ -238,7 +239,7 @@ export function validateTokenExpiry(
238
239
  mismatch,
239
240
  };
240
241
  } catch (error) {
241
- console.error('[TOKEN_UTILS] Failed to validate token expiry:', error);
242
+ console.error('[TOKEN_UTILS] Failed to validate token expiry:', errForLog(error));
242
243
  return { valid: false, actualExpiresAt: null, mismatch: false };
243
244
  }
244
245
  }
@@ -1,6 +1,7 @@
1
1
  import { getLoggingConfigSync, startConfigWatching, addConfigWatcher } from './logging-config';
2
2
  import { LoggingConfig } from '../types/logging';
3
3
  import { createVibeLogTransport } from './vibe-log-transport';
4
+ import { errForLog } from '../lib/log-safe';
4
5
 
5
6
  let loggingConfig: LoggingConfig = getLoggingConfigSync();
6
7
  startConfigWatching();
@@ -41,7 +42,7 @@ const createLogger = (): any => {
41
42
  console.log('[LOGGER] Vibe Redis transport DISABLED - no REDIS_URL configured');
42
43
  }
43
44
  return winston.createLogger({ level: loggingConfig.logLevel, levels: loggingConfig.levels, format: winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json()), transports, exitOnError: false });
44
- } catch (error) { console.warn('Failed to initialize Winston logger, falling back to EdgeLogger:', error); return new EdgeLogger(); }
45
+ } catch (error) { console.warn('Failed to initialize Winston logger, falling back to EdgeLogger:', errForLog(error)); return new EdgeLogger(); }
45
46
  };
46
47
 
47
48
  let logger: any;
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import { watch, existsSync } from 'fs';
3
3
  import { LoggingConfig, LogLevel } from '../types/logging';
4
+ import { errForLog } from '../lib/log-safe';
4
5
 
5
6
  const isEdgeRuntime = typeof (globalThis as any).EdgeRuntime !== 'undefined' || process.env.NEXT_RUNTIME === 'edge';
6
7
 
@@ -35,7 +36,7 @@ class LoggingConfigManager {
35
36
  const config = this.validateAndMergeConfig(rawConfig);
36
37
  this.config = config; this.notifyWatchers(config); return config;
37
38
  } catch (error) {
38
- console.warn(`Failed to load logging config from ${this.configPath}:`, error);
39
+ console.warn(`Failed to load logging config from ${this.configPath}:`, errForLog(error));
39
40
  return this.getDefaultConfig();
40
41
  }
41
42
  }
@@ -43,11 +44,11 @@ class LoggingConfigManager {
43
44
  async getConfig(): Promise<LoggingConfig> { if (!this.config) { return await this.loadConfig(); } return this.config; }
44
45
  getConfigSync(): LoggingConfig { return this.config || this.getDefaultConfig(); }
45
46
 
46
- startWatching(): void { if (this.isEdgeRuntime || this.isBrowser) { return; } if (this.fileWatcher) { return; } try { if (!existsSync(this.configPath)) { console.warn(`Logging config file not found at ${this.configPath} — watch disabled`); return; } this.fileWatcher = watch(this.configPath, { persistent: false }, (eventType) => { if (eventType === 'change') { this.loadConfig().catch(console.error); } }); } catch (error) { console.warn('Failed to start watching logging config file:', error); } }
47
+ startWatching(): void { if (this.isEdgeRuntime || this.isBrowser) { return; } if (this.fileWatcher) { return; } try { if (!existsSync(this.configPath)) { console.warn(`Logging config file not found at ${this.configPath} — watch disabled`); return; } this.fileWatcher = watch(this.configPath, { persistent: false }, (eventType) => { if (eventType === 'change') { this.loadConfig().catch(console.error); } }); } catch (error) { console.warn('Failed to start watching logging config file:', errForLog(error)); } }
47
48
  stopWatching(): void { if (this.isEdgeRuntime || this.isBrowser) { return; } if (this.fileWatcher) { this.fileWatcher.close(); this.fileWatcher = null; } }
48
49
  addWatcher(callback: (config: LoggingConfig) => void): void { this.watchers.add(callback); }
49
50
  removeWatcher(callback: (config: LoggingConfig) => void): void { this.watchers.delete(callback); }
50
- private notifyWatchers(config: LoggingConfig): void { this.watchers.forEach(cb => { try { cb(config); } catch (error) { console.error('Error in logging config watcher:', error); } }); }
51
+ private notifyWatchers(config: LoggingConfig): void { this.watchers.forEach(cb => { try { cb(config); } catch (error) { console.error('Error in logging config watcher:', errForLog(error)); } }); }
51
52
 
52
53
  private validateAndMergeConfig(rawConfig: any): LoggingConfig {
53
54
  const defaults = this.getDefaultConfig();
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { getRedis } from '../lib/redis';
15
+ import { errForLog } from '../lib/log-safe';
15
16
 
16
17
  // Dynamic import — winston is a peerDependency and may not be installed.
17
18
  // We resolve the base class lazily so builds don't break without it.
@@ -188,7 +189,7 @@ export class VibeLogTransport extends TransportBase {
188
189
  this.batch = [...entries, ...this.batch].slice(0, 100);
189
190
  }
190
191
  // Log to console as fallback
191
- console.error('[VibeLogTransport] Failed to flush logs:', err);
192
+ console.error('[VibeLogTransport] Failed to flush logs:', errForLog(err));
192
193
  } finally {
193
194
  this.isFlushing = false;
194
195
  }
@@ -35,6 +35,7 @@ import {
35
35
  isRefreshInDoubt,
36
36
  requestNeverLeft,
37
37
  } from './refresh-in-doubt';
38
+ import { errForLog } from './log-safe';
38
39
 
39
40
  export interface EnsureFreshConfig {
40
41
  idpBaseUrl: string;
@@ -348,19 +349,20 @@ export async function ensureFreshAccessToken(
348
349
  } else if (typeof session.authenticationMethods === 'string') {
349
350
  try { authMethods = JSON.parse(session.authenticationMethods); } catch { /* fall through */ }
350
351
  }
351
- const isOAuthSession = !!session.oauthProvider;
352
- if (authMethods.length === 0 && isOAuthSession) {
353
- authMethods = ['pwd', 'mfa'];
354
- }
352
+ // PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
353
+ // (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
354
+ // a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
355
+ // could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
356
+ // 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
357
+ // the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
358
+ // re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
359
+ // (e6e3b6c3, e3b3bb12) and was never merged.
355
360
  const twoFactorMethod =
356
361
  authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
357
362
  session.mfaMethod ||
358
- (isOAuthSession ? 'oauth' : null);
363
+ null;
359
364
 
360
- let acrValue = String(session.authenticationLevel ?? '1');
361
- if (isOAuthSession && session.mfaVerified && acrValue === '1') {
362
- acrValue = '2';
363
- }
365
+ const acrValue = String(session.authenticationLevel ?? '1');
364
366
 
365
367
  // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
366
368
  // `session` was read before we acquired the lock; a concurrent refresh that
@@ -396,7 +398,11 @@ export async function ensureFreshAccessToken(
396
398
  amr: authMethods,
397
399
  acr: acrValue,
398
400
  };
399
- if (session.mfaVerified) body.two_factor_verified = true;
401
+ // PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
402
+ // bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
403
+ // poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
404
+ const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
405
+ if (session.mfaVerified && methodsAttestSecondFactor) body.two_factor_verified = true;
400
406
  if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
401
407
  if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
402
408
 
@@ -412,7 +418,7 @@ export async function ensureFreshAccessToken(
412
418
  try {
413
419
  await updateSession(sessionToken, inDoubtPatch(sentRefreshToken as string));
414
420
  } catch (markError) {
415
- console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: markError instanceof Error ? markError.message : String(markError) });
421
+ console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: errForLog(markError) });
416
422
  }
417
423
  return {
418
424
  ok: false,
@@ -1,3 +1,4 @@
1
+ import { errForLog } from './log-safe';
1
2
  /**
2
3
  * =============================================================================
3
4
  * GEOLOCATION UTILITY
@@ -103,7 +104,7 @@ export async function getPublicIP(): Promise<string | null> {
103
104
  // Validate it looks like an IP
104
105
  if (ip && /^[\d.]+$/.test(ip) || /^[a-f0-9:]+$/i.test(ip)) {
105
106
  cachedPublicIP = { ip, timestamp: Date.now() };
106
- console.log(`[geolocation] Public IP resolved: ${ip}`);
107
+ console.log("[geolocation] Public IP resolved");
107
108
  return ip;
108
109
  }
109
110
  }
@@ -163,7 +164,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
163
164
  if (resolvePublicIP) {
164
165
  const publicIP = await getPublicIP();
165
166
  if (publicIP && !isPrivateIP(publicIP)) {
166
- console.log(`[geolocation] Private IP ${ip} -> using public IP ${publicIP}`);
167
+ console.log("[geolocation] Private IP -> using the resolved public IP");
167
168
  // Recursively call with public IP (but don't resolve again to prevent loops)
168
169
  return getLocationFromIP(publicIP, false);
169
170
  }
@@ -201,7 +202,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
201
202
  const data = await response.json();
202
203
 
203
204
  if (data.status !== 'success') {
204
- console.warn(`[geolocation] Lookup failed for ${ip}: ${data.message}`);
205
+ console.warn("[geolocation] Lookup failed (upstream refused)");
205
206
  // Cache the failure to avoid repeated lookups
206
207
  geoCache.set(ip, { location: null, timestamp: Date.now() });
207
208
  return null;
@@ -224,7 +225,7 @@ export async function getLocationFromIP(ip: string, resolvePublicIP: boolean = t
224
225
  return location;
225
226
 
226
227
  } catch (error) {
227
- console.error(`[geolocation] Error looking up ${ip}:`, error);
228
+ console.error("[geolocation] lookup failed:", errForLog(error)); // no IP on a routine line (BAPert 76553 Q1)
228
229
  return null;
229
230
  }
230
231
  }
@@ -43,6 +43,7 @@
43
43
  import 'server-only';
44
44
  import { createHash, randomUUID } from 'crypto';
45
45
  import { markNeverInResponse } from './never-in-response';
46
+ import { errForLog } from './log-safe';
46
47
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
47
48
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
48
49
  // touches this file - including instrumentation.ts. Next compiles the instrumentation
@@ -360,7 +361,7 @@ async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
360
361
 
361
362
  return parsed.config;
362
363
  } catch (error) {
363
- console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
364
+ console.warn('[IDP_CONFIG] Failed to read from Redis cache:', errForLog(error));
364
365
  return null;
365
366
  }
366
367
  }
@@ -377,7 +378,7 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
377
378
  // Store with TTL slightly longer than the logical expiry to allow for clock skew
378
379
  await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
379
380
  } catch (error) {
380
- console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
381
+ console.warn('[IDP_CONFIG] Failed to write to Redis cache:', errForLog(error));
381
382
  }
382
383
  }
383
384
 
@@ -633,7 +634,7 @@ export async function clearConfigRedisCache(): Promise<void> {
633
634
  await (await getRedis()).del(key);
634
635
  console.log('[IDP_CONFIG] Redis cache cleared:', key);
635
636
  } catch (error) {
636
- console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
637
+ console.warn('[IDP_CONFIG] Failed to clear Redis cache:', errForLog(error));
637
638
  }
638
639
  }
639
640
 
@@ -46,6 +46,14 @@ export interface IdpFetchOptions {
46
46
  * `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
47
47
  */
48
48
  initFor?: (sessionToken: string) => Promise<RequestInit | null>;
49
+ /**
50
+ * 4.12: the refresh that follows a 401/403 is done by THIS function instead of the HTTP self-call to /api/auth/refresh. Sign-out supplies one that
51
+ * calls `ensureFreshAccessToken` in-process with a window that cannot be "fresh": a logout 401 with a locally-fresh token usually means Core
52
+ * invalidated the token (password change), and only the IdP's answer to a real refresh (401 + the discard marker = the grant is dead; success =
53
+ * the grant is live) can say which. No header on the public refresh route, so nothing a browser sends can force a refresh (BAPert 77022).
54
+ * Returns the status the refresh route would have answered. Absent: `internalRefresh` as before.
55
+ */
56
+ refreshOnAuthFailure?: (sessionToken: string) => Promise<{ ok: boolean; status: number }>;
49
57
  }
50
58
 
51
59
  function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
@@ -184,7 +192,9 @@ export async function idpFetchJSON<T = any>(
184
192
  attemptedRefresh = true;
185
193
  try {
186
194
  // Use centralized internal API helper for server-to-server calls
187
- const rf = await internalRefresh(req.headers.get('cookie') || '', sessionToken);
195
+ const rf = opts.refreshOnAuthFailure
196
+ ? await opts.refreshOnAuthFailure(sessionToken)
197
+ : await internalRefresh(req.headers.get('cookie') || '', sessionToken);
188
198
  refreshStatus = rf.status;
189
199
  if (rf.ok) {
190
200
  const fresh = await getSession(sessionToken);
@@ -26,6 +26,19 @@ import { ENV_CONFIG } from '../config/env';
26
26
  */
27
27
  export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
28
28
 
29
+ /**
30
+ * The revoke could not be confirmed. `grantLive` is true ONLY when the IdP itself showed the grant is alive: the forced refresh SUCCEEDED (it issued
31
+ * new tokens) and the logout still did not end it. Every other unknown (a transport error, a 5xx, a refresh that threw or answered something
32
+ * else) leaves it false: the grant may be live or dead, and the log must not claim either (BAPert 77013). The closed `code` is what errForLog prints.
33
+ */
34
+ export class IdpLogoutUnconfirmedError extends Error {
35
+ readonly code = 'IDP_LOGOUT_UNCONFIRMED';
36
+ constructor(status: number, readonly grantLive: boolean) {
37
+ super(`IdP logout answered ${status}`);
38
+ this.name = 'IdpLogoutUnconfirmedError';
39
+ }
40
+ }
41
+
29
42
  export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome> {
30
43
  let resolvedSession: string | undefined;
31
44
  const result = await idpFetchJSON(
@@ -33,6 +46,9 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
33
46
  `${ENV_CONFIG.IDP_URL}/api/Account/logout`,
34
47
  {},
35
48
  {
49
+ // A logout 401 with a locally-fresh token must reach a REAL refresh, so the IdP's answer can prove the grant dead (BAPert 77013). Done
50
+ // in-process, not through the public refresh route, so no request header can force it (NightHawk 77021, BAPert 77022).
51
+ refreshOnAuthFailure: forceRefreshAtIdp,
36
52
  initFor: async (token) => {
37
53
  resolvedSession = token;
38
54
  let session = await getSession(token);
@@ -50,7 +66,26 @@ export async function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOut
50
66
  if (result.skipped) return 'nothing-to-revoke';
51
67
  if (result.ok) return 'revoked';
52
68
  if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession))) return 'grant-already-dead';
53
- throw new Error(`IdP logout answered ${result.status}`);
69
+ const refreshSucceeded = result.attemptedRefresh && typeof result.refreshStatus === 'number' && result.refreshStatus >= 200 && result.refreshStatus < 300;
70
+ throw new IdpLogoutUnconfirmedError(result.status, refreshSucceeded);
71
+ }
72
+
73
+ /**
74
+ * Refresh the session NOW, ignoring "the stored access token looks fresh", by calling `ensureFreshAccessToken` in this process (the one
75
+ * implementation that talks to the IdP, holds the lock and writes the discard marker). Config is read the way `oauth/resume` reads it
76
+ * (IDP_URL, CLIENT_ID, the default refresh endpoint). It answers with the status the refresh route would have: 200 on success, 401 for
77
+ * NO_SESSION / NO_REFRESH_TOKEN, 409 for a lost lock, otherwise E's own status (a discarded IdP 401 is 401 with the marker written).
78
+ * Missing config or a throw is an UNKNOWN outcome: the caller treats a thrown refresh as `refreshStatus: null`.
79
+ */
80
+ async function forceRefreshAtIdp(sessionToken: string): Promise<{ ok: boolean; status: number }> {
81
+ const idpBaseUrl = process.env.IDP_URL;
82
+ const clientId = process.env.CLIENT_ID;
83
+ if (!idpBaseUrl || !clientId) throw new Error('IDP_URL and CLIENT_ID are required to prove a grant dead');
84
+ const { ensureFreshAccessToken } = await import('./ensure-fresh-access-token');
85
+ const r = await ensureFreshAccessToken(sessionToken, { idpBaseUrl, clientId }, { requireRefreshToken: true, safetyWindowMs: Number.MAX_SAFE_INTEGER });
86
+ if (r.ok) return { ok: true, status: 200 };
87
+ const status = r.code === 'NO_SESSION' || r.code === 'NO_REFRESH_TOKEN' ? 401 : r.code === 'CONFLICT' ? 409 : r.status;
88
+ return { ok: false, status };
54
89
  }
55
90
 
56
91
  /**