@payez/next-mvp 4.9.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 +136 -0
- package/dist/api-handlers/auth/refresh.d.ts +17 -56
- package/dist/api-handlers/auth/refresh.js +71 -696
- 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 +255 -29
- package/dist/lib/idp-fetch.js +3 -2
- package/dist/lib/idp-logout.d.ts +4 -2
- package/dist/lib/refresh-in-doubt.d.ts +31 -0
- package/dist/lib/refresh-in-doubt.js +81 -0
- package/dist/lib/refresh-rotation.d.ts +19 -0
- package/dist/lib/refresh-rotation.js +18 -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.d.ts +0 -5
- package/dist/lib/session-store.js +28 -1
- package/dist/lib/token-lifecycle.js +3 -2
- package/dist/models/SessionModel.d.ts +7 -0
- package/dist/routes/auth/refresh.d.ts +1 -14
- package/dist/server/auth.d.ts +1 -1
- package/dist/stores/authStore.js +3 -2
- package/package.json +3 -12
- package/scripts/verify-release-provenance.mjs +1 -0
- package/src/api-handlers/auth/refresh.ts +72 -736
- 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 +275 -30
- package/src/lib/idp-fetch.ts +3 -2
- package/src/lib/idp-logout.ts +4 -2
- package/src/lib/refresh-in-doubt.ts +79 -0
- package/src/lib/refresh-rotation.ts +18 -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 +31 -1
- 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
- package/dist/api/auth-handler.d.ts +0 -64
- package/dist/api/auth-handler.js +0 -402
- package/dist/api/index.d.ts +0 -10
- package/dist/api/index.js +0 -19
- package/src/api/auth-handler.ts +0 -551
- package/src/api/index.ts +0 -18
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.9.0 (BAPert 76359): ONE predicate for "a concurrent refresh already rotated the refresh token, and the row it left is usable", shared by
|
|
3
|
+
* all four places that decide it (api-handlers/auth/refresh.ts and lib/ensure-fresh-access-token.ts, each for the HTTP-401 branch and the
|
|
4
|
+
* 200 success:false branch). Before, two said "usable" meant PRESENT (an access token and an expiry exist) and two said UNEXPIRED, so a rotated
|
|
5
|
+
* row whose access token had already expired was answered as a successful refresh by some branches and not by others.
|
|
6
|
+
*
|
|
7
|
+
* Usable = an access token exists AND its recorded expiry is in the future. A row that is rotated but NOT usable is answered with the
|
|
8
|
+
* retryable 502, its (rotated) refresh token KEPT and NO discard marker, in every branch. The CAS that decides "rotated" (a different
|
|
9
|
+
* stored refresh token than the one we sent) is unchanged: a different token is never discarded.
|
|
10
|
+
*
|
|
11
|
+
* A leaf module: no imports.
|
|
12
|
+
*/
|
|
13
|
+
export function rotatedRowIsUsable<T extends { idpAccessToken?: string | null; idpAccessTokenExpires?: number | null }>(
|
|
14
|
+
session: T | null | undefined,
|
|
15
|
+
now: number = Date.now(),
|
|
16
|
+
): session is T & { idpAccessToken: string; idpAccessTokenExpires: number } {
|
|
17
|
+
return !!(session && session.idpAccessToken && session.idpAccessTokenExpires && session.idpAccessTokenExpires > now);
|
|
18
|
+
}
|
|
@@ -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 =
|
|
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;
|
|
@@ -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
|
+
}
|
package/src/lib/session-store.ts
CHANGED
|
@@ -14,6 +14,7 @@ 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';
|
|
17
18
|
|
|
18
19
|
// Re-export SessionData for consumers
|
|
19
20
|
export type { SessionData } from '../models/SessionModel';
|
|
@@ -284,7 +285,36 @@ export async function deleteSessionEverywhere(sessionToken: string, appSlug?: st
|
|
|
284
285
|
* @param sessionToken The session token.
|
|
285
286
|
* @param data The session data.
|
|
286
287
|
*/
|
|
288
|
+
/**
|
|
289
|
+
* 4.9.0 (BAPert 76308 item 2): A LEFTOVER DISCARD MARKER MUST NEVER OUTLIVE A TOKEN. A discard (`refresh.ts`, `ensure-fresh-access-token.ts`)
|
|
290
|
+
* writes `idpRefreshToken: ''` TOGETHER WITH `refreshTokenClearedReason: 'IDP_DISCARD_TOKEN:<code>'`; logout's dead-grant check reads that marker
|
|
291
|
+
* as proof the IdP's grant is gone. If a later write stores a NEW refresh token on the same row (a successful refresh, a re-sign-in into the
|
|
292
|
+
* same row) and the marker stays, a later own-auth 401 would read as "grant already dead" while the grant is live. So any write that leaves a
|
|
293
|
+
* non-empty refresh token on the row drops the marker, in the same write. No code path was found that gives a discarded row a new token
|
|
294
|
+
* today; this is the defence BAPert ruled to keep anyway.
|
|
295
|
+
*/
|
|
296
|
+
function dropStaleDiscardMarker<T extends object>(session: T): T {
|
|
297
|
+
let s = session as any;
|
|
298
|
+
if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
|
|
299
|
+
const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
|
|
300
|
+
s = rest;
|
|
301
|
+
}
|
|
302
|
+
// 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
|
|
303
|
+
// whose identity differs from the one left in doubt. A write that stores the SAME token (any caller spreading the whole row back) does
|
|
304
|
+
// NOT clear it: that token is still the in-doubt one.
|
|
305
|
+
if (
|
|
306
|
+
typeof s.refreshInDoubtTokenHash === 'string' &&
|
|
307
|
+
typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 &&
|
|
308
|
+
refreshTokenIdentity(s.idpRefreshToken) !== s.refreshInDoubtTokenHash
|
|
309
|
+
) {
|
|
310
|
+
const { refreshInDoubtTokenHash: _hash, refreshInDoubtAt: _doubtAt, ...rest } = s;
|
|
311
|
+
s = rest;
|
|
312
|
+
}
|
|
313
|
+
return s as T;
|
|
314
|
+
}
|
|
315
|
+
|
|
287
316
|
export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
|
|
317
|
+
data = dropStaleDiscardMarker(data);
|
|
288
318
|
const key = getSessionKey(sessionToken);
|
|
289
319
|
const versionKey = getSessionVersionKey(sessionToken);
|
|
290
320
|
await redis.multi()
|
|
@@ -424,7 +454,7 @@ export async function updateSession(
|
|
|
424
454
|
}
|
|
425
455
|
|
|
426
456
|
// Merge current session with updates
|
|
427
|
-
const updatedSession = { ...currentSession, ...updates };
|
|
457
|
+
const updatedSession = dropStaleDiscardMarker({ ...currentSession, ...updates });
|
|
428
458
|
|
|
429
459
|
// Write the entire updated session back to Redis with version increment
|
|
430
460
|
await redis.multi()
|
|
@@ -26,9 +26,10 @@ 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';
|
|
29
30
|
|
|
30
|
-
//
|
|
31
|
-
const REFRESH_THRESHOLD_MS =
|
|
31
|
+
// "needs refresh" threshold: THE one freshness window (lib/refresh-window), the same value the refresh route, ensureFreshAccessToken and idp-fetch use.
|
|
32
|
+
const REFRESH_THRESHOLD_MS = REFRESH_WINDOW_MS;
|
|
32
33
|
|
|
33
34
|
// Concurrent refresh handling configuration
|
|
34
35
|
const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
|
|
@@ -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;
|
package/src/server/auth.ts
CHANGED
|
@@ -407,7 +407,7 @@ export interface FreshIdpTokenConfig {
|
|
|
407
407
|
idpBaseUrl: string;
|
|
408
408
|
clientId: string;
|
|
409
409
|
refreshEndpoint?: string;
|
|
410
|
-
/** Refresh if the access token is within this many ms of expiry. Default
|
|
410
|
+
/** Refresh if the access token is within this many ms of expiry. Default REFRESH_WINDOW_MS (5 min, lib/refresh-window). */
|
|
411
411
|
safetyWindowMs?: number;
|
|
412
412
|
}
|
|
413
413
|
|
package/src/stores/authStore.ts
CHANGED
|
@@ -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
|
|
756
|
-
if (timeUntilExpiry <
|
|
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
|
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Enhanced Auth Handler with Coordinated Token Refresh
|
|
3
|
-
*
|
|
4
|
-
* Provides a middleware wrapper that automatically handles token lifecycle:
|
|
5
|
-
* - Checks token expiry before each request
|
|
6
|
-
* - Automatically refreshes expired or near-expired tokens
|
|
7
|
-
* - Uses Redis locks for coordinated refresh (prevents race conditions)
|
|
8
|
-
* - Retries requests on 401 responses with fresh tokens
|
|
9
|
-
*
|
|
10
|
-
* Pattern ported from website-membership simple-api-handler.ts
|
|
11
|
-
*
|
|
12
|
-
* @version 2.1.0
|
|
13
|
-
* @since auth-ready-v2
|
|
14
|
-
*/
|
|
15
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
16
|
-
export interface AuthContext {
|
|
17
|
-
token: any;
|
|
18
|
-
accessToken: string;
|
|
19
|
-
userId: string;
|
|
20
|
-
sessionToken: string;
|
|
21
|
-
refreshToken?: string;
|
|
22
|
-
}
|
|
23
|
-
export interface AuthHandlerOptions {
|
|
24
|
-
/** Whether authentication is required for this route (default: true) */
|
|
25
|
-
requireAuth?: boolean;
|
|
26
|
-
/** Automatically refresh expired or near-expired tokens (default: true) */
|
|
27
|
-
autoRefresh?: boolean;
|
|
28
|
-
/** Buffer time in seconds before token expiry to trigger refresh (default: 300 = 5 minutes) */
|
|
29
|
-
refreshBuffer?: number;
|
|
30
|
-
/** Retry request on 401 response after refreshing token (default: true) */
|
|
31
|
-
retryOn401?: boolean;
|
|
32
|
-
/** Maximum number of retry attempts on 401 (default: 1) */
|
|
33
|
-
maxRetries?: number;
|
|
34
|
-
/** IDP base URL for refresh requests */
|
|
35
|
-
idpBaseUrl?: string;
|
|
36
|
-
/** OAuth client ID */
|
|
37
|
-
clientId?: string;
|
|
38
|
-
}
|
|
39
|
-
export type HandlerFunction = (req: NextRequest, context: any, auth: AuthContext) => Promise<NextResponse | Response>;
|
|
40
|
-
/**
|
|
41
|
-
* Creates an auth-aware handler with automatic token refresh
|
|
42
|
-
*
|
|
43
|
-
* @example
|
|
44
|
-
* ```typescript
|
|
45
|
-
* import { createAuthHandler } from '@payez/next-mvp/api';
|
|
46
|
-
*
|
|
47
|
-
* const handler = createAuthHandler({ requireAuth: true });
|
|
48
|
-
*
|
|
49
|
-
* export const GET = handler.handle(async (req, context, auth) => {
|
|
50
|
-
* // auth.accessToken is guaranteed to be fresh
|
|
51
|
-
* const response = await fetch('https://api.example.com/data', {
|
|
52
|
-
* headers: { 'Authorization': `Bearer ${auth.accessToken}` }
|
|
53
|
-
* });
|
|
54
|
-
* return NextResponse.json(await response.json());
|
|
55
|
-
* });
|
|
56
|
-
* ```
|
|
57
|
-
*/
|
|
58
|
-
export declare function createAuthHandler(options?: AuthHandlerOptions): {
|
|
59
|
-
handle: (handler: HandlerFunction) => (req: NextRequest, context?: any) => Promise<Response>;
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* Default export for convenience
|
|
63
|
-
*/
|
|
64
|
-
export default createAuthHandler;
|