@payez/next-mvp 4.10.0 → 4.11.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 (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/api-handlers/auth/refresh.d.ts +17 -58
  3. package/dist/api-handlers/auth/refresh.js +71 -772
  4. package/dist/auth/utils/token-utils.d.ts +1 -1
  5. package/dist/auth/utils/token-utils.js +3 -2
  6. package/dist/lib/api-handler.js +4 -3
  7. package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
  8. package/dist/lib/ensure-fresh-access-token.js +194 -28
  9. package/dist/lib/idp-fetch.js +3 -2
  10. package/dist/lib/refresh-in-doubt.d.ts +31 -0
  11. package/dist/lib/refresh-in-doubt.js +81 -0
  12. package/dist/lib/refresh-token-validator.js +2 -1
  13. package/dist/lib/refresh-window.d.ts +16 -0
  14. package/dist/lib/refresh-window.js +19 -0
  15. package/dist/lib/safe-trace-id.d.ts +11 -0
  16. package/dist/lib/safe-trace-id.js +23 -0
  17. package/dist/lib/session-store.js +14 -4
  18. package/dist/lib/token-lifecycle.js +3 -2
  19. package/dist/models/SessionModel.d.ts +7 -0
  20. package/dist/routes/auth/refresh.d.ts +1 -15
  21. package/dist/server/auth.d.ts +1 -1
  22. package/dist/stores/authStore.js +3 -2
  23. package/package.json +1 -1
  24. package/scripts/verify-release-provenance.mjs +1 -0
  25. package/src/api-handlers/auth/refresh.ts +72 -812
  26. package/src/auth/utils/token-utils.ts +3 -2
  27. package/src/lib/api-handler.ts +4 -3
  28. package/src/lib/ensure-fresh-access-token.ts +213 -29
  29. package/src/lib/idp-fetch.ts +3 -2
  30. package/src/lib/refresh-in-doubt.ts +79 -0
  31. package/src/lib/refresh-token-validator.ts +2 -1
  32. package/src/lib/refresh-window.ts +16 -0
  33. package/src/lib/safe-trace-id.ts +21 -0
  34. package/src/lib/session-store.ts +15 -3
  35. package/src/lib/token-lifecycle.ts +3 -2
  36. package/src/models/SessionModel.ts +7 -0
  37. package/src/server/auth.ts +1 -1
  38. package/src/stores/authStore.ts +3 -2
@@ -7,6 +7,7 @@ exports.getRefreshTokenTimeRemaining = getRefreshTokenTimeRemaining;
7
7
  exports.checkRefreshViability = checkRefreshViability;
8
8
  const jwt_decode_1 = require("./jwt-decode");
9
9
  const logger_1 = require("../config/logger");
10
+ const refresh_window_1 = require("./refresh-window");
10
11
  function isRefreshTokenValid(token) {
11
12
  if (!token)
12
13
  return false;
@@ -81,7 +82,7 @@ function checkRefreshViability(sessionData) {
81
82
  if (expiresAtMs < 1000000000000)
82
83
  expiresAtMs = expiresAtMs * 1000;
83
84
  accessTokenTimeRemaining = Math.floor((expiresAtMs - now) / 1000);
84
- const bufferSec = 5 * 60; // 5 minutes pre-expiry buffer
85
+ const bufferSec = refresh_window_1.REFRESH_WINDOW_MS / 1000; // the one refresh window (lib/refresh-window), in seconds
85
86
  accessTokenExpired = accessTokenTimeRemaining <= bufferSec;
86
87
  logger_1.tokenRefreshLogger.debug('[REFRESH_VIABILITY] Access token expiration check', { now, expiresAtMs, accessTokenTimeRemaining, bufferSec, accessTokenExpired });
87
88
  }
@@ -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 declare const REFRESH_WINDOW_MS: number;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REFRESH_WINDOW_MS = void 0;
4
+ /**
5
+ * THE freshness window for an IdP access token (BAPert 76397 decision 1; Core facts DotNetPert 76400).
6
+ *
7
+ * 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
8
+ * every path that decides: the mounted refresh route (`api-handlers/auth/refresh`), `ensureFreshAccessToken`, and `lib/idp-fetch`'s own
9
+ * 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
10
+ * two callers each rotated a token the other had just rotated.
11
+ *
12
+ * WHY 5 MINUTES: Core mints access tokens with a fixed 15-minute lifetime (a code constant, not configuration: Core
13
+ * `TokenConstants.ACCESS_TOKEN_LIFETIME_MINUTES = 15`, read-only in `TokenSettings`). The ruling is 5 minutes when the lifetime is at least 15
14
+ * minutes, and lifetime / 3 otherwise. If Core ever shortens the lifetime, this value MUST be revisited: a window at or above the lifetime
15
+ * makes every request refresh.
16
+ *
17
+ * A caller that needs another value passes it explicitly (`ensureFreshAccessToken`'s `safetyWindowMs` option still exists and still wins).
18
+ */
19
+ exports.REFRESH_WINDOW_MS = 5 * 60 * 1000;
@@ -0,0 +1,11 @@
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
+ /** Longest trace id that is logged. 64 covers a UUID (36) and common trace formats; longer input is cut, not rejected. */
10
+ export declare const MAX_TRACE_ID_LENGTH = 64;
11
+ export declare function sanitizeTraceId(value: unknown): string | undefined;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ /**
3
+ * 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,
4
+ * 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
5
+ * 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
6
+ * non-empty string becomes `undefined` (so the log carries no trace id rather than a forged one).
7
+ *
8
+ * It is for LOG CORRELATION ONLY. It is never a lock identity and never a security decision (the lock id is minted server-side).
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.MAX_TRACE_ID_LENGTH = void 0;
12
+ exports.sanitizeTraceId = sanitizeTraceId;
13
+ /** Longest trace id that is logged. 64 covers a UUID (36) and common trace formats; longer input is cut, not rejected. */
14
+ exports.MAX_TRACE_ID_LENGTH = 64;
15
+ // C0 controls (incl. CR, LF, TAB, ESC), DEL, C1 controls (incl. NEL U+0085), and the Unicode line/paragraph separators U+2028 / U+2029.
16
+ // eslint-disable-next-line no-control-regex
17
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
18
+ function sanitizeTraceId(value) {
19
+ if (typeof value !== 'string')
20
+ return undefined;
21
+ const cleaned = value.replace(CONTROL_CHARS, '').slice(0, exports.MAX_TRACE_ID_LENGTH);
22
+ return cleaned.length > 0 ? cleaned : undefined;
23
+ }
@@ -43,6 +43,7 @@ const crypto_1 = require("crypto");
43
43
  const app_slug_1 = require("./app-slug");
44
44
  const token_utils_1 = require("../auth/utils/token-utils");
45
45
  const token_log_id_1 = require("./token-log-id");
46
+ const refresh_in_doubt_1 = require("./refresh-in-doubt");
46
47
  // Use app-slug prefixes for multi-app isolation
47
48
  const getSessionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}${token}`;
48
49
  const getRefreshLockKey = (token) => `${(0, app_slug_1.getRefreshLockPrefix)()}${token}`;
@@ -280,12 +281,21 @@ async function deleteSessionEverywhere(sessionToken, appSlug) {
280
281
  * today; this is the defence BAPert ruled to keep anyway.
281
282
  */
282
283
  function dropStaleDiscardMarker(session) {
283
- const s = session;
284
+ let s = session;
284
285
  if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
285
286
  const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
286
- return rest;
287
- }
288
- return session;
287
+ s = rest;
288
+ }
289
+ // 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
290
+ // whose identity differs from the one left in doubt. A write that stores the SAME token (any caller spreading the whole row back) does
291
+ // NOT clear it: that token is still the in-doubt one.
292
+ if (typeof s.refreshInDoubtTokenHash === 'string' &&
293
+ typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 &&
294
+ (0, refresh_in_doubt_1.refreshTokenIdentity)(s.idpRefreshToken) !== s.refreshInDoubtTokenHash) {
295
+ const { refreshInDoubtTokenHash: _hash, refreshInDoubtAt: _doubtAt, ...rest } = s;
296
+ s = rest;
297
+ }
298
+ return s;
289
299
  }
290
300
  async function setSession(sessionToken, data) {
291
301
  data = dropStaleDiscardMarker(data);
@@ -28,8 +28,9 @@ const auth_1 = require("../server/auth");
28
28
  const redis_1 = require("./redis");
29
29
  const app_slug_1 = require("./app-slug");
30
30
  const token_log_id_1 = require("./token-log-id");
31
- // 5 minute threshold for "needs refresh" - matches refresh handler pattern
32
- const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
31
+ const refresh_window_1 = require("./refresh-window");
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_1.REFRESH_WINDOW_MS;
33
34
  // Concurrent refresh handling configuration
34
35
  const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
35
36
  const CONCURRENT_REFRESH_MAX_WAIT_MS = 8000; // Max time to wait for concurrent refresh to complete
@@ -38,6 +38,13 @@ export interface SessionData {
38
38
  idpAccessTokenExpires: number;
39
39
  /** When the IDP refresh token expires (Unix timestamp ms) */
40
40
  idpRefreshTokenExpires?: number;
41
+ /**
42
+ * 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
43
+ * token), and when. A later attempt whose token-to-send matches refuses without calling the IdP. Cleared by the write that stores a different
44
+ * refresh token.
45
+ */
46
+ refreshInDoubtTokenHash?: string;
47
+ refreshInDoubtAt?: number;
41
48
  /** Decoded IDP access token claims (for quick access without re-decoding) */
42
49
  decodedAccessToken?: any;
43
50
  /**
@@ -14,18 +14,4 @@
14
14
  * @since auth-ready-v2
15
15
  */
16
16
  import { NextRequest } from 'next/server';
17
- export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<{
18
- error: string;
19
- code: string;
20
- }> | import("next/server").NextResponse<{
21
- error: any;
22
- code: any;
23
- discardToken: boolean;
24
- retryable: boolean;
25
- resolution: any;
26
- status: number;
27
- }> | import("next/server").NextResponse<{
28
- refreshed: boolean;
29
- accessTokenExpires: number;
30
- hasRefreshToken: boolean;
31
- }>>;
17
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -153,7 +153,7 @@ export interface FreshIdpTokenConfig {
153
153
  idpBaseUrl: string;
154
154
  clientId: string;
155
155
  refreshEndpoint?: string;
156
- /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
156
+ /** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
157
157
  safetyWindowMs?: number;
158
158
  }
159
159
  /**
@@ -56,6 +56,7 @@ const signalr_1 = require("@microsoft/signalr");
56
56
  const signalrActivityService_1 = require("../services/signalrActivityService");
57
57
  const app_slug_1 = require("../lib/app-slug");
58
58
  const token_log_id_1 = require("../lib/token-log-id");
59
+ const refresh_window_1 = require("../lib/refresh-window");
59
60
  // ===============================
60
61
  // CACHE CONFIGURATION
61
62
  // ===============================
@@ -520,8 +521,8 @@ exports.useAuthStore = (0, zustand_1.create)()((0, middleware_1.devtools)((set,
520
521
  const tokenExpiry = decoded.exp * 1000;
521
522
  const now = Date.now();
522
523
  const timeUntilExpiry = tokenExpiry - now;
523
- // Token should be fresh (not expiring in next 5 minutes)
524
- if (timeUntilExpiry < (5 * 60 * 1000)) {
524
+ // Token should be fresh (not expiring within the one refresh window)
525
+ if (timeUntilExpiry < refresh_window_1.REFRESH_WINDOW_MS) {
525
526
  throw new Error(`Fresh token still expires soon: ${timeUntilExpiry}ms`);
526
527
  }
527
528
  logger_1.authLogger.info('[AuthStore] Session rehydration successful', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -103,6 +103,7 @@ const CLASSES = [
103
103
  { cls: 'VERIFIED', test: (p) => p.startsWith('src/') },
104
104
  { cls: 'VERIFIED', test: (p) => p.startsWith('scripts/') },
105
105
  { cls: 'VERIFIED', test: (p) => p === 'POST-INSTALL.md' },
106
+ { cls: 'VERIFIED', test: (p) => p === 'CHANGELOG.md' },
106
107
  // npm always ships README regardless of "files"; it is committed, so verify it.
107
108
  { cls: 'VERIFIED', test: (p) => p === 'README.md' },
108
109
  { cls: 'VERIFIED', test: (p) => p === 'LICENSE' || p === 'LICENCE' },