@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.
- package/CHANGELOG.md +31 -0
- package/dist/api-handlers/auth/refresh.d.ts +17 -58
- package/dist/api-handlers/auth/refresh.js +71 -772
- package/dist/auth/utils/token-utils.d.ts +1 -1
- package/dist/auth/utils/token-utils.js +3 -2
- package/dist/lib/api-handler.js +4 -3
- package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
- package/dist/lib/ensure-fresh-access-token.js +194 -28
- package/dist/lib/idp-fetch.js +3 -2
- package/dist/lib/refresh-in-doubt.d.ts +31 -0
- package/dist/lib/refresh-in-doubt.js +81 -0
- package/dist/lib/refresh-token-validator.js +2 -1
- package/dist/lib/refresh-window.d.ts +16 -0
- package/dist/lib/refresh-window.js +19 -0
- package/dist/lib/safe-trace-id.d.ts +11 -0
- package/dist/lib/safe-trace-id.js +23 -0
- package/dist/lib/session-store.js +14 -4
- package/dist/lib/token-lifecycle.js +3 -2
- package/dist/models/SessionModel.d.ts +7 -0
- package/dist/routes/auth/refresh.d.ts +1 -15
- package/dist/server/auth.d.ts +1 -1
- package/dist/stores/authStore.js +3 -2
- package/package.json +1 -1
- package/scripts/verify-release-provenance.mjs +1 -0
- package/src/api-handlers/auth/refresh.ts +72 -812
- package/src/auth/utils/token-utils.ts +3 -2
- package/src/lib/api-handler.ts +4 -3
- package/src/lib/ensure-fresh-access-token.ts +213 -29
- package/src/lib/idp-fetch.ts +3 -2
- package/src/lib/refresh-in-doubt.ts +79 -0
- package/src/lib/refresh-token-validator.ts +2 -1
- package/src/lib/refresh-window.ts +16 -0
- package/src/lib/safe-trace-id.ts +21 -0
- package/src/lib/session-store.ts +15 -3
- package/src/lib/token-lifecycle.ts +3 -2
- package/src/models/SessionModel.ts +7 -0
- package/src/server/auth.ts +1 -1
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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>>;
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/stores/authStore.js
CHANGED
|
@@ -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
|
|
524
|
-
if (timeUntilExpiry <
|
|
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
|
@@ -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' },
|