@payez/next-mvp 4.10.0 → 4.12.0

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 (123) hide show
  1. package/CHANGELOG.md +44 -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.d.ts +17 -58
  14. package/dist/api-handlers/auth/refresh.js +73 -773
  15. package/dist/api-handlers/auth/signout.js +43 -6
  16. package/dist/api-handlers/mfa/backup-code-verify.js +2 -1
  17. package/dist/api-handlers/mfa/totp-verify.js +2 -1
  18. package/dist/api-handlers/session/refresh-viability.js +3 -2
  19. package/dist/api-handlers/session/viability.js +2 -1
  20. package/dist/auth/better-auth.js +7 -6
  21. package/dist/auth/idp-oauth.js +4 -2
  22. package/dist/auth/utils/idp-client.js +5 -4
  23. package/dist/auth/utils/token-utils.d.ts +1 -1
  24. package/dist/auth/utils/token-utils.js +7 -5
  25. package/dist/config/logger.js +2 -1
  26. package/dist/config/logging-config.js +4 -3
  27. package/dist/config/vibe-log-transport.js +2 -1
  28. package/dist/lib/api-handler.js +4 -3
  29. package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
  30. package/dist/lib/ensure-fresh-access-token.js +195 -28
  31. package/dist/lib/geolocation.js +5 -22
  32. package/dist/lib/idp-client-config.js +4 -3
  33. package/dist/lib/idp-fetch.d.ts +11 -0
  34. package/dist/lib/idp-fetch.js +6 -3
  35. package/dist/lib/idp-logout.d.ts +10 -0
  36. package/dist/lib/idp-logout.js +73 -1
  37. package/dist/lib/jwt-decode-client.js +2 -1
  38. package/dist/lib/jwt-decode.js +2 -1
  39. package/dist/lib/log-safe.d.ts +19 -0
  40. package/dist/lib/log-safe.js +32 -0
  41. package/dist/lib/redis.js +2 -1
  42. package/dist/lib/refresh-in-doubt.d.ts +31 -0
  43. package/dist/lib/refresh-in-doubt.js +81 -0
  44. package/dist/lib/refresh-token-validator.js +2 -1
  45. package/dist/lib/refresh-window.d.ts +16 -0
  46. package/dist/lib/refresh-window.js +19 -0
  47. package/dist/lib/safe-error-response.d.ts +5 -1
  48. package/dist/lib/safe-error-response.js +7 -2
  49. package/dist/lib/safe-trace-id.d.ts +11 -0
  50. package/dist/lib/safe-trace-id.js +23 -0
  51. package/dist/lib/session-store.js +16 -5
  52. package/dist/lib/site-logger.d.ts +2 -8
  53. package/dist/lib/site-logger.js +11 -5
  54. package/dist/lib/standardized-client-api.js +3 -2
  55. package/dist/lib/token-lifecycle.js +6 -4
  56. package/dist/middleware/create-middleware.js +2 -1
  57. package/dist/middleware/rbac-check.d.ts +0 -13
  58. package/dist/middleware/rbac-check.js +6 -5
  59. package/dist/models/SessionModel.d.ts +7 -0
  60. package/dist/routes/auth/logout.js +6 -2
  61. package/dist/routes/auth/refresh.d.ts +1 -15
  62. package/dist/routes/auth/settings.js +2 -1
  63. package/dist/server/auth-guard.js +4 -3
  64. package/dist/server/auth.d.ts +1 -1
  65. package/dist/server/auth.js +5 -9
  66. package/dist/server/decode-session.js +3 -2
  67. package/dist/server/with-auth.js +2 -1
  68. package/dist/stores/authStore.js +3 -2
  69. package/package.json +1 -1
  70. package/scripts/verify-release-provenance.mjs +1 -0
  71. package/src/api-handlers/account/recovery/forward.ts +2 -1
  72. package/src/api-handlers/account/recovery/initiate.ts +2 -1
  73. package/src/api-handlers/account/reset-password.ts +2 -1
  74. package/src/api-handlers/account/send-code.ts +2 -1
  75. package/src/api-handlers/account/update-phone.ts +2 -1
  76. package/src/api-handlers/account/validate-password.ts +2 -1
  77. package/src/api-handlers/account/verify-email.ts +2 -1
  78. package/src/api-handlers/account/verify-sms.ts +2 -1
  79. package/src/api-handlers/anon/preferences.ts +3 -2
  80. package/src/api-handlers/auth/credential-login-email-code.ts +3 -2
  81. package/src/api-handlers/auth/credential-login.ts +6 -5
  82. package/src/api-handlers/auth/refresh.ts +74 -813
  83. package/src/api-handlers/auth/signout.ts +54 -7
  84. package/src/api-handlers/mfa/backup-code-verify.ts +2 -1
  85. package/src/api-handlers/mfa/totp-verify.ts +2 -1
  86. package/src/api-handlers/session/refresh-viability.ts +3 -2
  87. package/src/api-handlers/session/viability.ts +2 -1
  88. package/src/auth/better-auth.ts +7 -6
  89. package/src/auth/idp-oauth.ts +7 -2
  90. package/src/auth/utils/idp-client.ts +5 -4
  91. package/src/auth/utils/token-utils.ts +7 -5
  92. package/src/config/logger.ts +2 -1
  93. package/src/config/logging-config.ts +4 -3
  94. package/src/config/vibe-log-transport.ts +2 -1
  95. package/src/lib/api-handler.ts +4 -3
  96. package/src/lib/ensure-fresh-access-token.ts +214 -29
  97. package/src/lib/geolocation.ts +5 -4
  98. package/src/lib/idp-client-config.ts +4 -3
  99. package/src/lib/idp-fetch.ts +14 -3
  100. package/src/lib/idp-logout.ts +36 -1
  101. package/src/lib/jwt-decode-client.ts +3 -1
  102. package/src/lib/jwt-decode.ts +2 -1
  103. package/src/lib/log-safe.ts +30 -0
  104. package/src/lib/redis.ts +2 -1
  105. package/src/lib/refresh-in-doubt.ts +79 -0
  106. package/src/lib/refresh-token-validator.ts +2 -1
  107. package/src/lib/refresh-window.ts +16 -0
  108. package/src/lib/safe-error-response.ts +7 -2
  109. package/src/lib/safe-trace-id.ts +21 -0
  110. package/src/lib/session-store.ts +17 -4
  111. package/src/lib/site-logger.ts +13 -7
  112. package/src/lib/standardized-client-api.ts +3 -2
  113. package/src/lib/token-lifecycle.ts +6 -4
  114. package/src/middleware/create-middleware.ts +2 -1
  115. package/src/middleware/rbac-check.ts +2 -1
  116. package/src/models/SessionModel.ts +7 -0
  117. package/src/routes/auth/logout.ts +9 -2
  118. package/src/routes/auth/settings.ts +2 -1
  119. package/src/server/auth-guard.ts +4 -3
  120. package/src/server/auth.ts +6 -11
  121. package/src/server/decode-session.ts +3 -2
  122. package/src/server/with-auth.ts +2 -1
  123. package/src/stores/authStore.ts +3 -2
@@ -0,0 +1,30 @@
1
+ /**
2
+ * log-safe: the ONLY way a caught error or an upstream `code` goes into a server log (PII-in-logs census, BAPert 76553 group 1; ported from the
3
+ * portal's api-error-response.ts, BAPert 76470).
4
+ *
5
+ * Server logs persist and are read beyond the person who made the request. An error's `message`, `stack`, `cause` and `details` can carry an
6
+ * email, a phone number, a code, a token, a SQL fragment or a host: an IdP error's message is the upstream's own free text, and V8's JSON parse
7
+ * error quotes its input. So a log line gets the error's NAME and a closed STATUS / CODE, nothing else. Every field is matched against a closed
8
+ * shape; anything that does not match is dropped, not truncated.
9
+ *
10
+ * No imports on purpose: this is a leaf, loaded by modules that must not pull in anything else.
11
+ */
12
+
13
+ /** An upstream `code` is loggable only if it is a closed token (UPPER_SNAKE). Anything else, including free text, becomes null. */
14
+ export function closedCodeForLog(code: unknown): string | null {
15
+ return typeof code === 'string' && /^[A-Z0-9_]{1,64}$/.test(code) ? code : null;
16
+ }
17
+
18
+ /**
19
+ * Describe a caught error for a log line: `error=<Name> [status=<n>] [code=<CLOSED_CODE>]`.
20
+ * It NEVER carries `message`, `stack`, `cause` or `details`. A name that is not a short identifier (a hostile or free-text `name`) becomes
21
+ * `NonError`; a status outside 100-599 is dropped; a code outside the closed token shape is dropped.
22
+ */
23
+ export function errForLog(err: unknown): string {
24
+ const e = (err !== null && typeof err === 'object' ? err : {}) as { name?: unknown; status?: unknown; code?: unknown };
25
+ const name = typeof e.name === 'string' && /^[A-Za-z0-9_]{1,48}$/.test(e.name) ? e.name : 'NonError';
26
+ const status = typeof e.status === 'number' && Number.isInteger(e.status) && e.status >= 100 && e.status <= 599 ? ` status=${e.status}` : '';
27
+ const closed = closedCodeForLog(e.code);
28
+ const code = closed ? ` code=${closed}` : '';
29
+ return `error=${name}${status}${code}`;
30
+ }
package/src/lib/redis.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // E:\Repos\PayEz-Next-MVP\packages\next-mvp\src\lib\redis.ts
2
2
  import Redis, { RedisOptions } from 'ioredis';
3
+ import { errForLog } from './log-safe';
3
4
 
4
5
  let client: Redis | null = null;
5
6
 
@@ -76,7 +77,7 @@ function createClient(): Redis {
76
77
  // unhandled EventEmitter error, which can take the whole PROCESS down -
77
78
  // a Redis blip becoming a dead server is worse than the spinner was.
78
79
  instance.on('error', (err) => {
79
- console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${err instanceof Error ? err.message : String(err)}`);
80
+ console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${errForLog(err)}`);
80
81
  });
81
82
 
82
83
  // 185121 line 3: the 16:05 event left no named trigger because ONLY 'error'
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The IdP fetch timeout and the "refresh in doubt" marker (BAPert 76397 decision 3, corrected by 76403; Core facts DotNetPert 76400).
3
+ *
4
+ * THE PROBLEM. Core consumes a refresh token with one atomic UPDATE and has NO reuse grace: re-presenting a used token revokes every newer
5
+ * refresh token the USER holds, on every device (RevokeDescendantTokens). So when OUR fetch to the IdP times out, we do not know whether Core
6
+ * consumed the token and minted a replacement whose answer we never saw. The store still holds the token we SENT (nothing was persisted).
7
+ * Holding the refresh lock only delays the next request by its 60 s TTL; after that the next request re-reads the same token and replays it.
8
+ *
9
+ * THE RULE. A timed-out refresh answers 504 UPSTREAM_TIMEOUT and records, on the session row, an IDENTITY (a SHA-256, never the token and
10
+ * never a prefix of it) of the token that was sent. Any later attempt whose token-to-send matches that identity refuses WITHOUT calling the
11
+ * IdP: terminal REFRESH_OUTCOME_UNKNOWN / REAUTHENTICATE. One login on one session is better than a user-wide revocation. The marker is
12
+ * dropped by the same write that stores a DIFFERENT refresh token (session-store `dropStaleDiscardMarker`); nothing tries to resolve the doubt
13
+ * without a login.
14
+ *
15
+ * Leaf module: no imports from the rest of the package, so session-store can use it without a cycle.
16
+ */
17
+ import { createHash } from 'crypto';
18
+
19
+ /** How long a refresh fetch (connect, response and body) may take. 5 s lock wait + this + the persist stays well inside the 60 s lock TTL. */
20
+ export const REFRESH_FETCH_TIMEOUT_MS = 10_000;
21
+
22
+ /** The typed terminal answer for an attempt that would re-send an in-doubt token. */
23
+ export const REFRESH_OUTCOME_UNKNOWN_CODE = 'REFRESH_OUTCOME_UNKNOWN';
24
+
25
+ /** The typed answer for the timed-out call itself. */
26
+ export const UPSTREAM_TIMEOUT_CODE = 'UPSTREAM_TIMEOUT';
27
+
28
+ /** A one-way identity for a refresh token. Not reversible and not a prefix. */
29
+ export function refreshTokenIdentity(token: string): string {
30
+ return createHash('sha256').update(token, 'utf8').digest('hex');
31
+ }
32
+
33
+ /** The row fields recorded at a timeout. */
34
+ export function inDoubtPatch(sentToken: string): { refreshInDoubtTokenHash: string; refreshInDoubtAt: number } {
35
+ return { refreshInDoubtTokenHash: refreshTokenIdentity(sentToken), refreshInDoubtAt: Date.now() };
36
+ }
37
+
38
+ /** True when the token we are about to send is the one a previous attempt left in doubt. */
39
+ export function isRefreshInDoubt(
40
+ session: { refreshInDoubtTokenHash?: string | null } | null | undefined,
41
+ tokenToSend: string | null | undefined,
42
+ ): boolean {
43
+ if (!session || typeof session.refreshInDoubtTokenHash !== 'string' || !session.refreshInDoubtTokenHash) return false;
44
+ if (typeof tokenToSend !== 'string' || tokenToSend.length === 0) return false;
45
+ return refreshTokenIdentity(tokenToSend) === session.refreshInDoubtTokenHash;
46
+ }
47
+
48
+ /** True for the error `AbortSignal.timeout` produces (a DOMException named TimeoutError); AbortError is treated the same. */
49
+ export function isTimeoutError(err: unknown): boolean {
50
+ const name = (err as { name?: string } | null | undefined)?.name;
51
+ return name === 'TimeoutError' || name === 'AbortError';
52
+ }
53
+
54
+ /**
55
+ * The ONLY transport error codes that PROVE the request never left this process (BAPert 76433 item 4): the connection was refused, or the
56
+ * name did not resolve. Anything else (ECONNRESET, UND_ERR_SOCKET, a timeout, an error with no code) may have happened AFTER the request was
57
+ * written, so the IdP may have consumed the token: outcome unknown. The cost is lopsided on purpose: a false in-doubt costs one re-login, a
58
+ * missed one costs a user-wide revocation. Do NOT add a code here without proof it can only occur before the request is sent.
59
+ */
60
+ export const REQUEST_NEVER_LEFT_CODES: ReadonlySet<string> = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
61
+
62
+ /** Every `code` on the error, its `cause`, and the members of an AggregateError (undici wraps the socket error in `cause`). */
63
+ function transportCodes(err: unknown, depth = 0, out: string[] = []): string[] {
64
+ if (!err || typeof err !== 'object' || depth > 4) return out;
65
+ const e = err as { code?: unknown; cause?: unknown; errors?: unknown };
66
+ if (typeof e.code === 'string' && e.code) out.push(e.code);
67
+ if (e.cause) transportCodes(e.cause, depth + 1, out);
68
+ if (Array.isArray(e.errors)) for (const inner of e.errors) transportCodes(inner, depth + 1, out);
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * True only when the error carries at least one cause code and EVERY code it carries is in the never-left allowlist. An error with no code
74
+ * is NOT proof, so it is in-doubt.
75
+ */
76
+ export function requestNeverLeft(err: unknown): boolean {
77
+ const codes = transportCodes(err);
78
+ return codes.length > 0 && codes.every((c) => REQUEST_NEVER_LEFT_CODES.has(c));
79
+ }
@@ -1,5 +1,6 @@
1
1
  import { jwtDecode } from './jwt-decode';
2
2
  import { tokenRefreshLogger } from '../config/logger';
3
+ import { REFRESH_WINDOW_MS } from './refresh-window';
3
4
 
4
5
  interface RefreshTokenPayload { exp: number; iat: number; jti: string; user_id: string; token_type: string; binding: string; }
5
6
 
@@ -38,7 +39,7 @@ export function checkRefreshViability(sessionData: any): RefreshViabilityCheck {
38
39
  if (typeof expiresAtMs === 'string') expiresAtMs = parseInt(expiresAtMs, 10);
39
40
  if (expiresAtMs < 1000000000000) expiresAtMs = expiresAtMs * 1000;
40
41
  accessTokenTimeRemaining = Math.floor((expiresAtMs - now) / 1000);
41
- const bufferSec = 5 * 60; // 5 minutes pre-expiry buffer
42
+ const bufferSec = REFRESH_WINDOW_MS / 1000; // the one refresh window (lib/refresh-window), in seconds
42
43
  accessTokenExpired = accessTokenTimeRemaining <= bufferSec;
43
44
  tokenRefreshLogger.debug('[REFRESH_VIABILITY] Access token expiration check', { now, expiresAtMs, accessTokenTimeRemaining, bufferSec, accessTokenExpired });
44
45
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * THE freshness window for an IdP access token (BAPert 76397 decision 1; Core facts DotNetPert 76400).
3
+ *
4
+ * An access token with this much life left or less is treated as stale and refreshed; with more, it is served as it is. One value for
5
+ * every path that decides: the mounted refresh route (`api-handlers/auth/refresh`), `ensureFreshAccessToken`, and `lib/idp-fetch`'s own
6
+ * pre-check. They used to disagree (5 min, 60 s, 5 min), so the same session was "stale" to one path and "fresh" to another, which is how
7
+ * two callers each rotated a token the other had just rotated.
8
+ *
9
+ * WHY 5 MINUTES: Core mints access tokens with a fixed 15-minute lifetime (a code constant, not configuration: Core
10
+ * `TokenConstants.ACCESS_TOKEN_LIFETIME_MINUTES = 15`, read-only in `TokenSettings`). The ruling is 5 minutes when the lifetime is at least 15
11
+ * minutes, and lifetime / 3 otherwise. If Core ever shortens the lifetime, this value MUST be revisited: a window at or above the lifetime
12
+ * makes every request refresh.
13
+ *
14
+ * A caller that needs another value passes it explicitly (`ensureFreshAccessToken`'s `safetyWindowMs` option still exists and still wins).
15
+ */
16
+ export const REFRESH_WINDOW_MS = 5 * 60 * 1000;
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { knownPasswordRules } from './password-rules';
18
+ import { errForLog } from './log-safe';
18
19
 
19
20
  export interface IdpErrorProjection {
20
21
  code?: string;
@@ -25,10 +26,14 @@ export interface IdpErrorProjection {
25
26
 
26
27
  const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
27
28
 
28
- /** Log an exception with a fresh correlationId and return the id for the response body. */
29
+ /**
30
+ * Log an exception with a fresh correlationId and return the id for the response body.
31
+ * The log line carries the error's NAME and closed code/status only (errForLog), never its message, stack or cause: those can hold an
32
+ * email, a phone number, a token or a quoted request body (PII-in-logs, BAPert 76553 group 1). The correlationId still ties the response to the line.
33
+ */
29
34
  export function logExceptionWithCorrelation(tag: string, err: unknown): string {
30
35
  const correlationId = globalThis.crypto.randomUUID();
31
- console.error(tag, { correlationId, error: err });
36
+ console.error(tag, { correlationId, error: errForLog(err) });
32
37
  return correlationId;
33
38
  }
34
39
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A caller-supplied trace id (the `x-request-id` header) is attacker-chosen text, and it goes to the log sink (PAY-2376; BAPert 76321 item 4,
3
+ * NightHawk 76316). Two problems if it is logged as it arrives: a value with a newline forges a log line, and an unbounded value is a log
4
+ * flood. This is the one place that makes it safe to log: control characters are removed, the length is capped, and anything that is not a
5
+ * non-empty string becomes `undefined` (so the log carries no trace id rather than a forged one).
6
+ *
7
+ * It is for LOG CORRELATION ONLY. It is never a lock identity and never a security decision (the lock id is minted server-side).
8
+ */
9
+
10
+ /** Longest trace id that is logged. 64 covers a UUID (36) and common trace formats; longer input is cut, not rejected. */
11
+ export const MAX_TRACE_ID_LENGTH = 64;
12
+
13
+ // C0 controls (incl. CR, LF, TAB, ESC), DEL, C1 controls (incl. NEL U+0085), and the Unicode line/paragraph separators U+2028 / U+2029.
14
+ // eslint-disable-next-line no-control-regex
15
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
16
+
17
+ export function sanitizeTraceId(value: unknown): string | undefined {
18
+ if (typeof value !== 'string') return undefined;
19
+ const cleaned = value.replace(CONTROL_CHARS, '').slice(0, MAX_TRACE_ID_LENGTH);
20
+ return cleaned.length > 0 ? cleaned : undefined;
21
+ }
@@ -14,6 +14,8 @@ import { SessionData } from '../models/SessionModel';
14
14
  import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
15
15
  import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
16
16
  import { tokenLogId } from './token-log-id';
17
+ import { refreshTokenIdentity } from './refresh-in-doubt';
18
+ import { errForLog } from './log-safe';
17
19
 
18
20
  // Re-export SessionData for consumers
19
21
  export type { SessionData } from '../models/SessionModel';
@@ -64,7 +66,7 @@ export async function createSession(data: SessionData): Promise<string> {
64
66
  .setex(versionKey, SESSION_TTL, '1')
65
67
  .exec();
66
68
  } catch (error) {
67
- console.error('[SESSION-STORE] Failed to create session:', error);
69
+ console.error('[SESSION-STORE] Failed to create session:', errForLog(error));
68
70
  throw error;
69
71
  }
70
72
 
@@ -293,12 +295,23 @@ export async function deleteSessionEverywhere(sessionToken: string, appSlug?: st
293
295
  * today; this is the defence BAPert ruled to keep anyway.
294
296
  */
295
297
  function dropStaleDiscardMarker<T extends object>(session: T): T {
296
- const s = session as any;
298
+ let s = session as any;
297
299
  if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
298
300
  const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
299
- return rest as T;
301
+ s = rest;
300
302
  }
301
- return session;
303
+ // BAPert 76403: the refresh-IN-DOUBT marker (lib/refresh-in-doubt) is cleared by the same write that stores a NEW refresh token, i.e. one
304
+ // whose identity differs from the one left in doubt. A write that stores the SAME token (any caller spreading the whole row back) does
305
+ // NOT clear it: that token is still the in-doubt one.
306
+ if (
307
+ typeof s.refreshInDoubtTokenHash === 'string' &&
308
+ typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 &&
309
+ refreshTokenIdentity(s.idpRefreshToken) !== s.refreshInDoubtTokenHash
310
+ ) {
311
+ const { refreshInDoubtTokenHash: _hash, refreshInDoubtAt: _doubtAt, ...rest } = s;
312
+ s = rest;
313
+ }
314
+ return s as T;
302
315
  }
303
316
 
304
317
  export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { getRedis } from './redis';
14
+ import { closedCodeForLog, errForLog } from './log-safe';
14
15
 
15
16
  // Redis key for site logs (separate from data_logs)
16
17
  const REDIS_SITE_LOG_KEY = 'vibe:site-logs:pending';
@@ -74,11 +75,11 @@ export async function logSiteEvent(entry: SiteLogEntry): Promise<void> {
74
75
  console.log('[site-logger] Redis push success, queue length:', result);
75
76
  redis.expire(REDIS_SITE_LOG_KEY, REDIS_LOG_TTL).catch(() => {});
76
77
  }).catch(err => {
77
- console.error('[site-logger] Redis push failed:', err.message);
78
+ console.error('[site-logger] Redis push failed:', errForLog(err));
78
79
  });
79
80
  } catch (error) {
80
81
  // Fail silently - logging should never break the app
81
- console.error('[site-logger] Failed to log event:', error);
82
+ console.error('[site-logger] Failed to log event:', errForLog(error));
82
83
  }
83
84
  }
84
85
 
@@ -120,12 +121,14 @@ export const siteEvents = {
120
121
  });
121
122
  },
122
123
 
123
- loginFailed: (opts: { reason: string; email?: string; url?: string; user_agent?: string; ip_address?: string }) => {
124
+ // BREAKING (PII-in-logs group 3b, BAPert 76553 Q2): no `email` parameter. A failed login is told apart by a CLOSED reason code (UPPER_SNAKE),
125
+ // never by the address that was tried; anything that is not a closed code is stored as UNSPECIFIED. A caller that still passes `email` fails to compile.
126
+ loginFailed: (opts: { reason: string; url?: string; user_agent?: string; ip_address?: string }) => {
124
127
  logSiteEvent({
125
128
  level: 'warn',
126
129
  category: 'auth',
127
130
  message: 'Login failed',
128
- context: { reason: opts.reason, email: opts.email },
131
+ context: { reason: closedCodeForLog(opts.reason) ?? 'UNSPECIFIED' },
129
132
  url: opts.url || '/auth/login',
130
133
  user_agent: opts.user_agent,
131
134
  ip_address: opts.ip_address,
@@ -214,12 +217,15 @@ export const siteEvents = {
214
217
  });
215
218
  },
216
219
 
217
- error: (opts: { message: string; error?: string; stack?: string; user_id?: string | number; url?: string; user_agent?: string; ip_address?: string }) => {
220
+ // BREAKING (PII-in-logs group 3b, BAPert 76553 Q2): takes a CLOSED code, not caller text, an error string or a stack. The stored message is the fixed
221
+ // sentence 'Application error'; the code (UPPER_SNAKE, else UNSPECIFIED) goes in context. A caller that still passes `message`, `error` or `stack`
222
+ // fails to compile.
223
+ error: (opts: { code: string; user_id?: string | number; url?: string; user_agent?: string; ip_address?: string }) => {
218
224
  logSiteEvent({
219
225
  level: 'error',
220
226
  category: 'error',
221
- message: opts.message,
222
- context: { error: opts.error, stack: opts.stack },
227
+ message: 'Application error',
228
+ context: { code: closedCodeForLog(opts.code) ?? 'UNSPECIFIED' },
223
229
  user_id: opts.user_id,
224
230
  url: opts.url,
225
231
  user_agent: opts.user_agent,
@@ -9,6 +9,7 @@
9
9
  // ========================================================================================
10
10
 
11
11
  import { authClient } from '../client/better-auth-client';
12
+ import { errForLog } from './log-safe';
12
13
 
13
14
  /** Unwrap Better Auth getSession() to a flat session-like object for backward compat */
14
15
  async function getSession(): Promise<any> {
@@ -147,7 +148,7 @@ function scheduleLoginRedirect(isImmediate = false) {
147
148
  console.warn(`🔄 Redirecting to login with return URL: ${returnUrl}`);
148
149
  window.location.href = `/account-auth/login?returnUrl=${returnUrl}`;
149
150
  } catch (error) {
150
- console.error('❌ Error during login redirect:', error);
151
+ console.error('❌ Error during login redirect:', errForLog(error));
151
152
  // Final fallback
152
153
  try {
153
154
  window.location.href = '/account-auth/login';
@@ -721,7 +722,7 @@ class StandardizedClientApiService {
721
722
  }
722
723
 
723
724
  // Wrap unknown errors as network errors
724
- console.error('❌ API request failed:', error);
725
+ console.error('❌ API request failed:', errForLog(error));
725
726
  throw new ApiNetworkError(
726
727
  error instanceof Error ? error.message : 'Network error',
727
728
  0,
@@ -26,9 +26,11 @@ import { getSession as getBetterAuthSession } from '../server/auth';
26
26
  import { getRedis } from './redis';
27
27
  import { getAppSlug } from './app-slug';
28
28
  import { tokenLogId } from './token-log-id';
29
+ import { REFRESH_WINDOW_MS } from './refresh-window';
30
+ import { errForLog } from './log-safe';
29
31
 
30
- // 5 minute threshold for "needs refresh" - matches refresh handler pattern
31
- const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
32
+ // "needs refresh" threshold: THE one freshness window (lib/refresh-window), the same value the refresh route, ensureFreshAccessToken and idp-fetch use.
33
+ const REFRESH_THRESHOLD_MS = REFRESH_WINDOW_MS;
32
34
 
33
35
  // Concurrent refresh handling configuration
34
36
  const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
@@ -433,7 +435,7 @@ export async function ensureFreshToken(
433
435
  }
434
436
  } catch (e) {
435
437
  // If we can't decode, proceed with normal logic
436
- console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
438
+ console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', errForLog(e));
437
439
  }
438
440
  }
439
441
 
@@ -511,7 +513,7 @@ export async function ensureFreshToken(
511
513
  sessionData,
512
514
  };
513
515
  } catch (error) {
514
- console.error('[TOKEN_LIFECYCLE] Error:', error);
516
+ console.error('[TOKEN_LIFECYCLE] Error:', errForLog(error));
515
517
  return {
516
518
  success: false,
517
519
  error: 'NO_SESSION',
@@ -32,6 +32,7 @@ import {
32
32
  resetSecret,
33
33
  signedResetHref,
34
34
  } from '../auth/session-reset-link';
35
+ import { errForLog } from '../lib/log-safe';
35
36
 
36
37
  // =============================================================================
37
38
  // TYPES
@@ -1037,7 +1038,7 @@ async function handleAllow(
1037
1038
  );
1038
1039
  }
1039
1040
  } catch (error) {
1040
- console.error('[MIDDLEWARE] RBAC error:', error);
1041
+ console.error('[MIDDLEWARE] RBAC error:', errForLog(error));
1041
1042
 
1042
1043
  // In development, fail open
1043
1044
  if (process.env.NODE_ENV !== 'production') {
@@ -1,3 +1,4 @@
1
+ import { errForLog } from '../lib/log-safe';
1
2
  /**
2
3
  * Page RBAC Check Module
3
4
  *
@@ -262,7 +263,7 @@ export async function checkPagePermission(
262
263
  };
263
264
  }
264
265
 
265
- console.error('[RBAC] Proxy error:', error);
266
+ console.error('[RBAC] Proxy error:', errForLog(error));
266
267
  return {
267
268
  allowed: false,
268
269
  reason: 'rbac_service_unavailable',
@@ -59,6 +59,13 @@ export interface SessionData {
59
59
 
60
60
  /** When the IDP refresh token expires (Unix timestamp ms) */
61
61
  idpRefreshTokenExpires?: number;
62
+ /**
63
+ * BAPert 76403 (lib/refresh-in-doubt): set when a refresh fetch TIMED OUT. A SHA-256 identity of the refresh token that was sent (never the
64
+ * token), and when. A later attempt whose token-to-send matches refuses without calling the IdP. Cleared by the write that stores a different
65
+ * refresh token.
66
+ */
67
+ refreshInDoubtTokenHash?: string;
68
+ refreshInDoubtAt?: number;
62
69
 
63
70
  /** Decoded IDP access token claims (for quick access without re-decoding) */
64
71
  decodedAccessToken?: any;
@@ -27,6 +27,7 @@ import {
27
27
  } from '../../lib/app-slug';
28
28
  import { siteEvents, getClientIp } from '../../lib/site-logger';
29
29
  import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
30
+ import { errForLog } from '../../lib/log-safe';
30
31
 
31
32
  /**
32
33
  * 4.8.9: answered (status 500) when a sign-out did not finish. 4.8.10 (BAPert 76297): TWO sentences, one per cause, each true for its
@@ -79,7 +80,13 @@ export async function POST(req: NextRequest) {
79
80
  await revokeSessionAtIdp(req);
80
81
  } catch (error) {
81
82
  idpRevokeFailed = true;
82
- console.error('[LOGOUT_ROUTE] IdP revoke failed - the IdP refresh token is STILL LIVE:', error);
83
+ // "STILL LIVE" only when the IdP showed it (a forced refresh succeeded); otherwise say what is known: it was not confirmed.
84
+ console.error(
85
+ (error as { grantLive?: boolean } | null)?.grantLive === true
86
+ ? '[LOGOUT_ROUTE] IdP revoke failed - the IdP refresh token is STILL LIVE:'
87
+ : '[LOGOUT_ROUTE] IdP revoke could not be confirmed (the grant may already be dead):',
88
+ errForLog(error),
89
+ );
83
90
  }
84
91
  }
85
92
 
@@ -91,7 +98,7 @@ export async function POST(req: NextRequest) {
91
98
  console.info('[LOGOUT_ROUTE] Session deleted from store');
92
99
  } catch (error) {
93
100
  localDeleteFailed = true;
94
- console.error('[LOGOUT_ROUTE] Failed to delete session - the server session is STILL LIVE:', error);
101
+ console.error('[LOGOUT_ROUTE] Failed to delete session - the server session is STILL LIVE:', errForLog(error));
95
102
  }
96
103
  }
97
104
 
@@ -30,6 +30,7 @@
30
30
  import { NextResponse } from 'next/server';
31
31
  import { getIDPClientConfig } from '../../lib/idp-client-config';
32
32
  import { secretSafeJson } from '../../lib/never-in-response';
33
+ import { errForLog } from '../../lib/log-safe';
33
34
 
34
35
  export interface PublicAuthSettings {
35
36
  // OAuth providers enabled for this client
@@ -95,7 +96,7 @@ export async function GET() {
95
96
  config = await getIDPClientConfig();
96
97
  } catch (error) {
97
98
  // Keep the loud line - it is what finally unmasked this in production.
98
- console.error('[AUTH_SETTINGS] Failed to get settings:', error);
99
+ console.error('[AUTH_SETTINGS] Failed to get settings:', errForLog(error));
99
100
  // The `degraded: true` shape that used to live here (240447) is gone
100
101
  // deliberately. It still answered `success: true` with a full invented
101
102
  // policy and merely LABELLED the guess - which BAPert v32 rules out:
@@ -14,6 +14,7 @@ import { decodeSession } from './decode-session';
14
14
  import { getIDPClientConfig } from '../lib/idp-client-config';
15
15
  import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
16
16
  import type { SessionData } from '../lib/session-store';
17
+ import { errForLog } from '../lib/log-safe';
17
18
 
18
19
  // =============================================================================
19
20
  // TYPES
@@ -89,7 +90,7 @@ export async function authGuard(options?: AuthGuardOptions): Promise<AuthGuardRe
89
90
  decoded = await decodeSession();
90
91
  } catch (error) {
91
92
  // Redis unreachable or startup failure → fail closed
92
- console.error('[AUTH-GUARD] Session decode failed (service error):', error instanceof Error ? error.message : String(error));
93
+ console.error('[AUTH-GUARD] Session decode failed (service error):', errForLog(error));
93
94
  redirect(serviceUnavailableUrl);
94
95
  }
95
96
 
@@ -122,7 +123,7 @@ export async function authGuard(options?: AuthGuardOptions): Promise<AuthGuardRe
122
123
  requires2FA = config.authSettings?.require2FA ?? true;
123
124
  } catch (error) {
124
125
  // If we can't check 2FA config, fail closed
125
- console.error('[AUTH-GUARD] 2FA config check failed:', error instanceof Error ? error.message : String(error));
126
+ console.error('[AUTH-GUARD] 2FA config check failed:', errForLog(error));
126
127
  redirect(serviceUnavailableUrl);
127
128
  }
128
129
 
@@ -186,7 +187,7 @@ export async function authGuard(options?: AuthGuardOptions): Promise<AuthGuardRe
186
187
  if (error && typeof error === 'object' && 'digest' in error) {
187
188
  throw error;
188
189
  }
189
- console.error(`[AUTH-GUARD] Custom check "${check.name}" error:`, error instanceof Error ? error.message : String(error));
190
+ console.error(`[AUTH-GUARD] Custom check "${check.name}" error:`, errForLog(error));
190
191
  redirect(serviceUnavailableUrl);
191
192
  }
192
193
  }
@@ -14,6 +14,7 @@ import {
14
14
  type SessionData,
15
15
  } from '../lib/session-store';
16
16
  import { tokenLogId } from '../lib/token-log-id';
17
+ import { errForLog } from '../lib/log-safe';
17
18
  import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
18
19
 
19
20
  export type IdpTokenResult =
@@ -134,20 +135,14 @@ export async function getAuthInstance() {
134
135
  * `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
135
136
  * the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
136
137
  * a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
137
- * cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
138
+ * cookie NAMES, booleans, and an error NAME with its closed code/status (errForLog; never the message).
138
139
  * -------------------------------------------------------------------------------------------------------------- */
139
140
 
140
- /** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
141
- function scrubForLog(v: unknown): string {
142
- const raw = v instanceof Error ? v.message : String(v ?? '');
143
- return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
144
- }
145
-
146
- /** `catch {}` sites on the session read path: still swallowed, now audible. */
141
+ /** `catch {}` sites on the session read path: still swallowed, now audible. The line names the error and its closed code/status only: the
142
+ * message is dropped (BAPert 76553: a redacted-and-truncated message still leaked host names, ports and quoted input). */
147
143
  function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
148
144
  try {
149
- const name = scrubForLog(err instanceof Error ? err.name : typeof err);
150
- console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
145
+ console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} ${errForLog(err)}`);
151
146
  } catch {
152
147
  /* a diagnostic must never change the outcome */
153
148
  }
@@ -407,7 +402,7 @@ export interface FreshIdpTokenConfig {
407
402
  idpBaseUrl: string;
408
403
  clientId: string;
409
404
  refreshEndpoint?: string;
410
- /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
405
+ /** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
411
406
  safetyWindowMs?: number;
412
407
  }
413
408
 
@@ -15,6 +15,7 @@ import { ensureInitialized } from '../lib/startup-init';
15
15
  import { extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
16
16
  import { resolveVerifiedSessionCookie } from './verified-session-token';
17
17
  import { tokenLogId } from '../lib/token-log-id';
18
+ import { errForLog } from '../lib/log-safe';
18
19
 
19
20
  export interface DecodedSession {
20
21
  sessionData: SessionData;
@@ -197,7 +198,7 @@ async function tryBetterAuthSession(
197
198
 
198
199
  return { sessionData, jwtPayload };
199
200
  } catch (error) {
200
- console.warn('[DECODE-SESSION] Better Auth session check failed:', error instanceof Error ? error.message : String(error));
201
+ console.warn('[DECODE-SESSION] Better Auth session check failed:', errForLog(error));
201
202
  return null;
202
203
  }
203
204
  }
@@ -303,7 +304,7 @@ export async function decodeSession(
303
304
  jwtPayload: (payload ?? { sessionToken }) as DecodedSession['jwtPayload'],
304
305
  };
305
306
  } catch (error) {
306
- console.error('[DECODE-SESSION] Error:', error instanceof Error ? error.message : String(error));
307
+ console.error('[DECODE-SESSION] Error:', errForLog(error));
307
308
  return null;
308
309
  }
309
310
  }
@@ -17,6 +17,7 @@ import 'server-only';
17
17
  import { NextRequest, NextResponse } from 'next/server';
18
18
  import { decodeSession } from './decode-session';
19
19
  import type { SessionData } from '../lib/session-store';
20
+ import { errForLog } from '../lib/log-safe';
20
21
 
21
22
  // =============================================================================
22
23
  // TYPES
@@ -83,7 +84,7 @@ export function withAuth(
83
84
 
84
85
  return handler(req, auth);
85
86
  } catch (error) {
86
- console.error('[WITH-AUTH] Error:', error instanceof Error ? error.message : String(error));
87
+ console.error('[WITH-AUTH] Error:', errForLog(error));
87
88
  return NextResponse.json(
88
89
  { error: 'Internal Server Error', message: 'Auth check failed' },
89
90
  { status: 500 }
@@ -27,6 +27,7 @@ import {
27
27
  getSecureCsrfCookieName
28
28
  } from '../lib/app-slug';
29
29
  import { tokenLogId } from '../lib/token-log-id';
30
+ import { REFRESH_WINDOW_MS } from '../lib/refresh-window';
30
31
 
31
32
  // ===============================
32
33
  // INTERFACES & TYPES
@@ -752,8 +753,8 @@ export const useAuthStore = create<AuthStore>()(
752
753
  const now = Date.now();
753
754
  const timeUntilExpiry = tokenExpiry - now;
754
755
 
755
- // Token should be fresh (not expiring in next 5 minutes)
756
- if (timeUntilExpiry < (5 * 60 * 1000)) {
756
+ // Token should be fresh (not expiring within the one refresh window)
757
+ if (timeUntilExpiry < REFRESH_WINDOW_MS) {
757
758
  throw new Error(`Fresh token still expires soon: ${timeUntilExpiry}ms`);
758
759
  }
759
760