@payez/next-mvp 4.12.0 → 4.12.1

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog — @payez/next-mvp
2
2
 
3
+ ## 4.12.1 (to be published under dist-tag `next` only, after 4.12.0; `latest` stays 4.8.9 until Jon says otherwise)
4
+
5
+ A patch cut from the 4.12.0 tree carrying ONE change: the PAY-2425 hygiene fix below (PR 1500).
6
+
7
+ - **CHANGE (PAY-2425, hygiene): the refresh no longer invents authentication claims for an OAuth (Google) session.** `lib/ensure-fresh-access-token` answered an OAuth session whose stored authentication methods were empty with `amr ['pwd','mfa']`, `two_factor_method 'oauth'` and an `acr` raised from '1' to '2' ("OAuth IS a form of multi-factor auth"), and echoed a stored `mfaVerified` as `two_factor_verified` whatever the session's methods said. OAuth is federated sign-in, not a second factor (BAPert 77404). The refresh body now carries only what the session records: empty stays empty, `acr` is the session's own level, no method is named from silence, and `two_factor_verified` is sent only when the session's recorded methods contain `mfa`, `sms`, `totp` or `email`. A session that really attests `mfa` still sends it. The IdP ignores these request fields and mints `amr`/`acr` from the stored refresh row, so this removes a false claim from the wire; it does not establish that any bypass existed or is closed. **What changes for a caller:** a Google user on a `require2FA` client is asked for a code, as before, because the IdP decides that; a Google-only client that wants no second factor sets `require2FA = false` explicitly. An earlier version of this fix on `nextpert/375639-amr-fabrication` (e6e3b6c3, e3b3bb12) was never merged; the refresh path has since moved into this helper. Rows: `test/next-mvp-pay-2425-no-fabricated-mfa.test.cjs` (R1/R2/R3, C1, N1 and four mutants).
8
+
3
9
  ## 4.12.0 (to be published under dist-tag `next` only, after 4.11.0; `latest` stays 4.8.9 until Jon says otherwise)
4
10
 
5
11
  A minor: PII-in-logs groups 1, 2, 3a and 3b, the GOV-023 signout BREAKING change, the logout-401 FIX (PAY-2396), and the BREAKING site-logger signatures (3b: callers of `siteEvents.loginFailed` / `siteEvents.error` must change).
@@ -257,17 +257,18 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
257
257
  }
258
258
  catch { /* fall through */ }
259
259
  }
260
- const isOAuthSession = !!session.oauthProvider;
261
- if (authMethods.length === 0 && isOAuthSession) {
262
- authMethods = ['pwd', 'mfa'];
263
- }
260
+ // PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
261
+ // (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
262
+ // a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
263
+ // could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
264
+ // 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
265
+ // the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
266
+ // re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
267
+ // (e6e3b6c3, e3b3bb12) and was never merged.
264
268
  const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
265
269
  session.mfaMethod ||
266
- (isOAuthSession ? 'oauth' : null);
267
- let acrValue = String(session.authenticationLevel ?? '1');
268
- if (isOAuthSession && session.mfaVerified && acrValue === '1') {
269
- acrValue = '2';
270
- }
270
+ null;
271
+ const acrValue = String(session.authenticationLevel ?? '1');
271
272
  // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
272
273
  // `session` was read before we acquired the lock; a concurrent refresh that
273
274
  // completed while we waited rotates the token, so the pre-lock value is stale
@@ -300,7 +301,11 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
300
301
  amr: authMethods,
301
302
  acr: acrValue,
302
303
  };
303
- if (session.mfaVerified)
304
+ // PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
305
+ // bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
306
+ // poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
307
+ const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
308
+ if (session.mfaVerified && methodsAttestSecondFactor)
304
309
  body.two_factor_verified = true;
305
310
  if (twoFactorMethod)
306
311
  body.two_factor_method = twoFactorMethod;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.12.0",
3
+ "version": "4.12.1",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -349,19 +349,20 @@ export async function ensureFreshAccessToken(
349
349
  } else if (typeof session.authenticationMethods === 'string') {
350
350
  try { authMethods = JSON.parse(session.authenticationMethods); } catch { /* fall through */ }
351
351
  }
352
- const isOAuthSession = !!session.oauthProvider;
353
- if (authMethods.length === 0 && isOAuthSession) {
354
- authMethods = ['pwd', 'mfa'];
355
- }
352
+ // PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
353
+ // (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
354
+ // a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
355
+ // could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
356
+ // 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
357
+ // the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
358
+ // re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
359
+ // (e6e3b6c3, e3b3bb12) and was never merged.
356
360
  const twoFactorMethod =
357
361
  authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
358
362
  session.mfaMethod ||
359
- (isOAuthSession ? 'oauth' : null);
363
+ null;
360
364
 
361
- let acrValue = String(session.authenticationLevel ?? '1');
362
- if (isOAuthSession && session.mfaVerified && acrValue === '1') {
363
- acrValue = '2';
364
- }
365
+ const acrValue = String(session.authenticationLevel ?? '1');
365
366
 
366
367
  // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
367
368
  // `session` was read before we acquired the lock; a concurrent refresh that
@@ -397,7 +398,11 @@ export async function ensureFreshAccessToken(
397
398
  amr: authMethods,
398
399
  acr: acrValue,
399
400
  };
400
- if (session.mfaVerified) body.two_factor_verified = true;
401
+ // PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
402
+ // bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
403
+ // poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
404
+ const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
405
+ if (session.mfaVerified && methodsAttestSecondFactor) body.two_factor_verified = true;
401
406
  if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
402
407
  if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
403
408