@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.
@@ -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
- const originalRefreshToken = currentSession.idpRefreshToken as string;
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?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
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
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
636
- reason: 'IDP_SUCCESS_FALSE_DISCARD',
637
- errorCode,
638
- userId,
639
- sessionToken: tokenLogId(sessionToken),
640
- hadRefreshToken: !!currentSession.idpRefreshToken,
641
- stack: new Error().stack
642
- });
643
- await updateSession(sessionToken, {
644
- idpRefreshToken: '',
645
- idpRefreshTokenExpires: undefined,
646
- refreshTokenClearedAt: Date.now(),
647
- refreshTokenClearedReason: `IDP_SUCCESS_FALSE_DISCARD:${errorCode}`
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
- }, { status: refreshResponse.status || 400 });
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 = currentSession.authenticationMethods || [];
780
+ amrClaims = baseSession.authenticationMethods || [];
705
781
  }
706
782
  } else {
707
- amrClaims = currentSession.authenticationMethods || [];
783
+ amrClaims = baseSession.authenticationMethods || [];
708
784
  }
709
- const acrLevel = String(decodedAccessToken.acr || currentSession.authenticationLevel || '1');
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 : currentSession.mfaCompletedAt;
713
- const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : currentSession.mfaExpiresAt;
714
- const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : currentSession.mfaValidityHours;
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 && currentSession.idpRefreshToken) {
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
- ...currentSession,
826
+ ...baseSession,
751
827
  idpAccessToken: accessToken,
752
828
  idpAccessTokenExpires: accessTokenExpires,
753
- idpRefreshToken: hasNewRefresh ? (refreshToken as string) : currentSession.idpRefreshToken,
754
- idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : currentSession.idpRefreshTokenExpires,
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 || currentSession.bearerKeyId,
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?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
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' ||
@@ -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 (401/403) and the refresh that followed was answered 401: refresh.ts calls that
18
- * "definitively invalid", the token is expired or revoked, so there is nothing left to end (BAPert 76289 item 3).
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
+ }
@@ -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;