@payez/next-mvp 4.9.0 → 4.10.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 +105 -0
- package/dist/api-handlers/auth/refresh.d.ts +2 -0
- package/dist/api-handlers/auth/refresh.js +107 -31
- package/dist/lib/ensure-fresh-access-token.js +61 -1
- package/dist/lib/idp-logout.d.ts +4 -2
- package/dist/lib/refresh-rotation.d.ts +19 -0
- package/dist/lib/refresh-rotation.js +18 -0
- package/dist/lib/session-store.d.ts +0 -5
- package/dist/lib/session-store.js +18 -1
- package/dist/routes/auth/refresh.d.ts +1 -0
- package/package.json +3 -12
- package/src/api-handlers/auth/refresh.ts +107 -31
- package/src/lib/ensure-fresh-access-token.ts +62 -1
- package/src/lib/idp-logout.ts +4 -2
- package/src/lib/refresh-rotation.ts +18 -0
- package/src/lib/session-store.ts +19 -1
- 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
|
@@ -41,6 +41,7 @@ import { getSession, updateSession, acquireRefreshLock, releaseRefreshLock, chec
|
|
|
41
41
|
import { computeTokenExpiries } from '../../lib/token-expiry';
|
|
42
42
|
import { extractKidFromToken } from '../../auth/utils/token-utils';
|
|
43
43
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
44
|
+
import { rotatedRowIsUsable } from '../../lib/refresh-rotation';
|
|
44
45
|
|
|
45
46
|
interface RefreshConfig {
|
|
46
47
|
idpBaseUrl: string;
|
|
@@ -333,7 +334,43 @@ export function createRefreshHandler(config: RefreshConfig) {
|
|
|
333
334
|
// Note: Token is NOT cleared preemptively - it will be replaced on success.
|
|
334
335
|
// The lock mechanism prevents concurrent refresh attempts within the same session.
|
|
335
336
|
// If IDP call fails, user can retry with the same token.
|
|
336
|
-
|
|
337
|
+
// 4.9.0 (BAPert 76298 (b2), 76330): the token we SEND is the one read AFTER the lock was taken (`latestAfterLock`), and it is the ONLY
|
|
338
|
+
// source: there is NO fallback to `currentSession`, which was read BEFORE the lock. Between those reads another request can rotate the
|
|
339
|
+
// refresh token (sending the pre-lock value would send a SPENT token) or DISCARD it (the IdP answered 401 for it; re-sending it is a
|
|
340
|
+
// reuse). The IdP treats reuse as compromise and revokes descendants across devices. So:
|
|
341
|
+
// - the post-lock read has NO ROW: the session vanished while we waited; it is not ours to refresh -> the 'No session' 401, no IdP call
|
|
342
|
+
// - the row has NO refresh token: -> the same terminal NO_REFRESH_TOKEN as the pre-lock guard, no IdP call; the row is left exactly as
|
|
343
|
+
// it is (a discard marker stays on it)
|
|
344
|
+
// (A row whose access token is still fresh was answered `already_fresh` above and never reaches here.)
|
|
345
|
+
if (!latestAfterLock) {
|
|
346
|
+
console.warn('[AUTH_REFRESH] Session vanished while waiting for the refresh lock; not refreshing', { userId });
|
|
347
|
+
if (weAcquiredLock) {
|
|
348
|
+
await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
|
|
349
|
+
weAcquiredLock = false;
|
|
350
|
+
}
|
|
351
|
+
return NextResponse.json({
|
|
352
|
+
error: 'No session available for refresh',
|
|
353
|
+
code: 'UNAUTHORIZED'
|
|
354
|
+
}, { status: 401 });
|
|
355
|
+
}
|
|
356
|
+
if (!latestAfterLock.idpRefreshToken) {
|
|
357
|
+
console.warn('[AUTH_REFRESH] No refresh token after the lock was taken; not calling the IdP', { userId });
|
|
358
|
+
if (weAcquiredLock) {
|
|
359
|
+
await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
|
|
360
|
+
weAcquiredLock = false;
|
|
361
|
+
}
|
|
362
|
+
return NextResponse.json({
|
|
363
|
+
error: 'No refresh token available',
|
|
364
|
+
code: 'NO_REFRESH_TOKEN',
|
|
365
|
+
terminal: true,
|
|
366
|
+
resolution: 'User must re-authenticate'
|
|
367
|
+
}, { status: 401 });
|
|
368
|
+
}
|
|
369
|
+
const originalRefreshToken = latestAfterLock.idpRefreshToken as string;
|
|
370
|
+
// The base of everything WRITTEN BACK after a successful exchange is the same post-lock read (BAPert 76318 item 4): writing
|
|
371
|
+
// `...currentSession` over a rotated row would put a SPENT token back. With no new refresh token from the IdP the stored token is the
|
|
372
|
+
// one that was SENT (`originalRefreshToken`).
|
|
373
|
+
const baseSession = latestAfterLock;
|
|
337
374
|
|
|
338
375
|
try {
|
|
339
376
|
// Extract 2FA claims from current session
|
|
@@ -565,7 +602,7 @@ export function createRefreshHandler(config: RefreshConfig) {
|
|
|
565
602
|
// (race-fix follow-up #1).
|
|
566
603
|
discardToken = false;
|
|
567
604
|
console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
|
|
568
|
-
if (postRefresh
|
|
605
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
569
606
|
return NextResponse.json({
|
|
570
607
|
refreshed: true,
|
|
571
608
|
reason: 'completed_by_concurrent_request',
|
|
@@ -573,6 +610,16 @@ export function createRefreshHandler(config: RefreshConfig) {
|
|
|
573
610
|
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
574
611
|
});
|
|
575
612
|
}
|
|
613
|
+
// Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept,
|
|
614
|
+
// no marker. Same meaning in every branch (lib/refresh-rotation).
|
|
615
|
+
return NextResponse.json({
|
|
616
|
+
error: errorMessage,
|
|
617
|
+
code: errorCode,
|
|
618
|
+
discardToken: false,
|
|
619
|
+
retryable: true,
|
|
620
|
+
resolution,
|
|
621
|
+
status: 502
|
|
622
|
+
}, { status: 502 });
|
|
576
623
|
}
|
|
577
624
|
}
|
|
578
625
|
|
|
@@ -631,30 +678,59 @@ export function createRefreshHandler(config: RefreshConfig) {
|
|
|
631
678
|
errorKeys: Object.keys(idpError),
|
|
632
679
|
});
|
|
633
680
|
|
|
681
|
+
// 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations. This branch used to answer the upstream
|
|
682
|
+
// status, i.e. HTTP 200, with an error body, so `internalRefresh` reported `ok` and idpFetchJSON's 401-retry trusted a refresh that
|
|
683
|
+
// had failed; and it wrote a DIFFERENT marker (IDP_SUCCESS_FALSE_DISCARD) than the IdP-401 branch, which the logout dead-grant check
|
|
684
|
+
// does not recognise. Now:
|
|
685
|
+
// success:false + discard_token -> the stored token is discarded (same CAS as the 401 branch) with the IDP_DISCARD_TOKEN marker, and the
|
|
686
|
+
// answer is 401 (never 200 with an error body)
|
|
687
|
+
// success:false, no discard_token -> 502 retryable, the token is KEPT
|
|
634
688
|
if (discardToken) {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
689
|
+
const postRefresh = await getSession(sessionToken);
|
|
690
|
+
const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
|
|
691
|
+
if (tokenUnchanged) {
|
|
692
|
+
console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
|
|
693
|
+
reason: 'IDP_DISCARD_TOKEN',
|
|
694
|
+
errorCode,
|
|
695
|
+
userId,
|
|
696
|
+
sessionToken: tokenLogId(sessionToken),
|
|
697
|
+
hadRefreshToken: !!currentSession.idpRefreshToken,
|
|
698
|
+
stack: new Error().stack
|
|
699
|
+
});
|
|
700
|
+
await updateSession(sessionToken, {
|
|
701
|
+
idpRefreshToken: '',
|
|
702
|
+
idpRefreshTokenExpires: undefined,
|
|
703
|
+
refreshTokenClearedAt: Date.now(),
|
|
704
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
|
|
705
|
+
});
|
|
706
|
+
return NextResponse.json({
|
|
707
|
+
error: idpError.message || 'Token refresh failed',
|
|
708
|
+
code: errorCode,
|
|
709
|
+
discardToken: true,
|
|
710
|
+
retryable: false,
|
|
711
|
+
resolution: idpError.resolution || null,
|
|
712
|
+
status: 401
|
|
713
|
+
}, { status: 401 });
|
|
714
|
+
}
|
|
715
|
+
// A concurrent refresh already rotated the token: ours is stale. Return the fresh tokens if there are any (as the 401 branch does).
|
|
716
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
717
|
+
return NextResponse.json({
|
|
718
|
+
refreshed: true,
|
|
719
|
+
reason: 'completed_by_concurrent_request',
|
|
720
|
+
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
721
|
+
hasRefreshToken: !!postRefresh.idpRefreshToken,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
649
724
|
}
|
|
650
725
|
|
|
651
726
|
return NextResponse.json({
|
|
652
727
|
error: idpError.message || 'Token refresh failed',
|
|
653
728
|
code: errorCode,
|
|
654
|
-
discardToken,
|
|
655
|
-
retryable,
|
|
656
|
-
resolution: idpError.resolution || null
|
|
657
|
-
|
|
729
|
+
discardToken: false,
|
|
730
|
+
retryable: true,
|
|
731
|
+
resolution: idpError.resolution || null,
|
|
732
|
+
status: 502
|
|
733
|
+
}, { status: 502 });
|
|
658
734
|
}
|
|
659
735
|
|
|
660
736
|
// Extract tokens from response
|
|
@@ -701,24 +777,24 @@ export function createRefreshHandler(config: RefreshConfig) {
|
|
|
701
777
|
: decodedAccessToken.amr;
|
|
702
778
|
} catch (e) {
|
|
703
779
|
console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
|
|
704
|
-
amrClaims =
|
|
780
|
+
amrClaims = baseSession.authenticationMethods || [];
|
|
705
781
|
}
|
|
706
782
|
} else {
|
|
707
|
-
amrClaims =
|
|
783
|
+
amrClaims = baseSession.authenticationMethods || [];
|
|
708
784
|
}
|
|
709
|
-
const acrLevel = String(decodedAccessToken.acr ||
|
|
785
|
+
const acrLevel = String(decodedAccessToken.acr || baseSession.authenticationLevel || '1');
|
|
710
786
|
|
|
711
787
|
// Extract MFA timing claims
|
|
712
|
-
const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 :
|
|
713
|
-
const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 :
|
|
714
|
-
const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) :
|
|
788
|
+
const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : baseSession.mfaCompletedAt;
|
|
789
|
+
const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : baseSession.mfaExpiresAt;
|
|
790
|
+
const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : baseSession.mfaValidityHours;
|
|
715
791
|
|
|
716
792
|
// Update session with new tokens
|
|
717
793
|
const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
|
|
718
794
|
const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
|
|
719
795
|
|
|
720
796
|
// CRITICAL: Log if we're about to clear the refresh token due to missing new token
|
|
721
|
-
if (!hasNewRefresh &&
|
|
797
|
+
if (!hasNewRefresh && baseSession.idpRefreshToken) {
|
|
722
798
|
console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
|
|
723
799
|
reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
|
|
724
800
|
userId,
|
|
@@ -747,14 +823,14 @@ export function createRefreshHandler(config: RefreshConfig) {
|
|
|
747
823
|
}
|
|
748
824
|
|
|
749
825
|
const sessionUpdate: any = {
|
|
750
|
-
...
|
|
826
|
+
...baseSession,
|
|
751
827
|
idpAccessToken: accessToken,
|
|
752
828
|
idpAccessTokenExpires: accessTokenExpires,
|
|
753
|
-
idpRefreshToken: hasNewRefresh ? (refreshToken as string) :
|
|
754
|
-
idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires :
|
|
829
|
+
idpRefreshToken: hasNewRefresh ? (refreshToken as string) : originalRefreshToken,
|
|
830
|
+
idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : baseSession.idpRefreshTokenExpires,
|
|
755
831
|
decodedAccessToken: decodedAccessToken,
|
|
756
832
|
// Bearer key ID from JWT header (may change on key rotation)
|
|
757
|
-
bearerKeyId: newBearerKeyId ||
|
|
833
|
+
bearerKeyId: newBearerKeyId || baseSession.bearerKeyId,
|
|
758
834
|
authenticationMethods: amrClaims,
|
|
759
835
|
authenticationLevel: acrLevel,
|
|
760
836
|
// 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './session-store';
|
|
25
25
|
import { computeTokenExpiries } from './token-expiry';
|
|
26
26
|
import { extractKidFromToken } from '../auth/utils/token-utils';
|
|
27
|
+
import { rotatedRowIsUsable } from './refresh-rotation';
|
|
27
28
|
|
|
28
29
|
export interface EnsureFreshConfig {
|
|
29
30
|
idpBaseUrl: string;
|
|
@@ -353,7 +354,7 @@ export async function ensureFreshAccessToken(
|
|
|
353
354
|
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
354
355
|
// (race-fix follow-up #1).
|
|
355
356
|
discardToken = false;
|
|
356
|
-
if (postRefresh
|
|
357
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
357
358
|
return {
|
|
358
359
|
ok: true,
|
|
359
360
|
accessToken: postRefresh.idpAccessToken,
|
|
@@ -363,6 +364,17 @@ export async function ensureFreshAccessToken(
|
|
|
363
364
|
persisted: true,
|
|
364
365
|
};
|
|
365
366
|
}
|
|
367
|
+
// Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept, no
|
|
368
|
+
// marker. Same meaning in every branch (lib/refresh-rotation).
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
code,
|
|
372
|
+
message: idpError.message || 'Token refresh failed',
|
|
373
|
+
status: 502,
|
|
374
|
+
discardToken: false,
|
|
375
|
+
retryable: true,
|
|
376
|
+
resolution: idpError.resolution,
|
|
377
|
+
};
|
|
366
378
|
}
|
|
367
379
|
}
|
|
368
380
|
return {
|
|
@@ -378,6 +390,55 @@ export async function ensureFreshAccessToken(
|
|
|
378
390
|
}
|
|
379
391
|
|
|
380
392
|
// Validate canonical envelope.
|
|
393
|
+
// 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations (api-handlers/auth/refresh.ts has the twin of
|
|
394
|
+
// this block). A 200 envelope with success:false used to be a generic 502 here, with discard_token ignored, while the other twin
|
|
395
|
+
// honoured it. Now: success:false + discard_token discards the stored token (same CAS as the HTTP-401 branch above, same
|
|
396
|
+
// IDP_DISCARD_TOKEN marker) and answers terminal 401; success:false without discard_token is a retryable 502 and the token is KEPT.
|
|
397
|
+
if (responseData && typeof responseData === 'object' && responseData.success === false) {
|
|
398
|
+
const idpError = responseData.error || {};
|
|
399
|
+
const code = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
|
|
400
|
+
if (idpError.discard_token === true) {
|
|
401
|
+
const postRefresh = await getSession(sessionToken);
|
|
402
|
+
const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
403
|
+
if (tokenUnchanged) {
|
|
404
|
+
await updateSession(sessionToken, {
|
|
405
|
+
idpRefreshToken: '',
|
|
406
|
+
idpRefreshTokenExpires: undefined,
|
|
407
|
+
refreshTokenClearedAt: Date.now(),
|
|
408
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
code,
|
|
413
|
+
message: idpError.message || 'Token refresh failed',
|
|
414
|
+
status: 401,
|
|
415
|
+
discardToken: true,
|
|
416
|
+
retryable: false,
|
|
417
|
+
resolution: idpError.resolution,
|
|
418
|
+
terminal: true,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
if (rotatedRowIsUsable(postRefresh)) {
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
accessToken: postRefresh.idpAccessToken,
|
|
425
|
+
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
426
|
+
refreshed: true,
|
|
427
|
+
persisted: true,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
code,
|
|
434
|
+
message: idpError.message || 'Token refresh failed',
|
|
435
|
+
status: 502,
|
|
436
|
+
discardToken: false,
|
|
437
|
+
retryable: true,
|
|
438
|
+
resolution: idpError.resolution,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
381
442
|
if (
|
|
382
443
|
!responseData ||
|
|
383
444
|
typeof responseData !== 'object' ||
|
package/src/lib/idp-logout.ts
CHANGED
|
@@ -14,8 +14,10 @@ import { ENV_CONFIG } from '../config/env';
|
|
|
14
14
|
* 'revoked' the IdP answered 2xx.
|
|
15
15
|
* 'nothing-to-revoke' the session holds no IdP refresh token (a provisional pre-2FA session has none). No call is made, because
|
|
16
16
|
* without a token Core would pick the newest session.
|
|
17
|
-
* 'grant-already-dead' the revoke was refused
|
|
18
|
-
*
|
|
17
|
+
* 'grant-already-dead' the revoke was refused AND the refresh that followed was answered 401 AND the session carries the IdP's own
|
|
18
|
+
* discard marker (refreshTokenClearedReason starting 'IDP_DISCARD_TOKEN'). A refresh 401 alone is NOT proof
|
|
19
|
+
* (refresh.ts also answers 401 for 'no session', the x-session-token rejection and NO_REFRESH_TOKEN), and neither
|
|
20
|
+
* is an empty token (BAPert 76297). Anything else is UNKNOWN.
|
|
19
21
|
* THROWS for an UNKNOWN outcome: a transport error, a 5xx, a refresh that could not be attempted or failed some other way. The caller
|
|
20
22
|
* turns that into SESSION_NOT_ENDED.
|
|
21
23
|
*
|
|
@@ -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
|
+
}
|
package/src/lib/session-store.ts
CHANGED
|
@@ -284,7 +284,25 @@ export async function deleteSessionEverywhere(sessionToken: string, appSlug?: st
|
|
|
284
284
|
* @param sessionToken The session token.
|
|
285
285
|
* @param data The session data.
|
|
286
286
|
*/
|
|
287
|
+
/**
|
|
288
|
+
* 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`)
|
|
289
|
+
* writes `idpRefreshToken: ''` TOGETHER WITH `refreshTokenClearedReason: 'IDP_DISCARD_TOKEN:<code>'`; logout's dead-grant check reads that marker
|
|
290
|
+
* 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
|
|
291
|
+
* 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
|
|
292
|
+
* 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
|
|
293
|
+
* today; this is the defence BAPert ruled to keep anyway.
|
|
294
|
+
*/
|
|
295
|
+
function dropStaleDiscardMarker<T extends object>(session: T): T {
|
|
296
|
+
const s = session as any;
|
|
297
|
+
if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
|
|
298
|
+
const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
|
|
299
|
+
return rest as T;
|
|
300
|
+
}
|
|
301
|
+
return session;
|
|
302
|
+
}
|
|
303
|
+
|
|
287
304
|
export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
|
|
305
|
+
data = dropStaleDiscardMarker(data);
|
|
288
306
|
const key = getSessionKey(sessionToken);
|
|
289
307
|
const versionKey = getSessionVersionKey(sessionToken);
|
|
290
308
|
await redis.multi()
|
|
@@ -424,7 +442,7 @@ export async function updateSession(
|
|
|
424
442
|
}
|
|
425
443
|
|
|
426
444
|
// Merge current session with updates
|
|
427
|
-
const updatedSession = { ...currentSession, ...updates };
|
|
445
|
+
const updatedSession = dropStaleDiscardMarker({ ...currentSession, ...updates });
|
|
428
446
|
|
|
429
447
|
// Write the entire updated session back to Redis with version increment
|
|
430
448
|
await redis.multi()
|
|
@@ -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;
|