@payez/next-mvp 4.4.3 → 4.4.4

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.
@@ -17,7 +17,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.createAuthHandler = createAuthHandler;
18
18
  const server_1 = require("next/server");
19
19
  const auth_1 = require("../server/auth");
20
- const acr_1 = require("../lib/acr");
21
20
  const nanoid_1 = require("nanoid");
22
21
  const session_store_1 = require("../lib/session-store");
23
22
  /**
@@ -159,18 +158,10 @@ function createAuthHandler(options = {}) {
159
158
  console.error('[AUTH_HANDLER] No refresh token available');
160
159
  return { success: false, error: 'No refresh token' };
161
160
  }
162
- // Extract authentication methods from session.
163
- //
164
- // 375639: these two lines used to read
165
- // authMethods = ... : ['pwd', 'mfa']
166
- // authLevel = String(... || ... || '2')
167
- // so a session that knew NOTHING about how the user authenticated told
168
- // the IDP that a second factor had happened AND named 'mfa' as the
169
- // method. The two fabrications corroborated each other, so a downstream
170
- // check cross-validating amr against acr would have accepted the pair.
171
- // Absent is not satisfied; both now resolve fail-closed, in one place.
172
- const authMethods = (0, acr_1.resolveAuthMethods)(currentSession.authMethods, currentSession.token?.amr);
173
- const authLevel = (0, acr_1.resolveAcrLevel)(currentSession.authenticationLevel, currentSession.token?.acr);
161
+ // Extract authentication methods from session
162
+ const authMethods = currentSession.authMethods ||
163
+ (currentSession.token?.amr ? JSON.parse(currentSession.token.amr) : ['pwd', 'mfa']);
164
+ const authLevel = String(currentSession.authenticationLevel || currentSession.token?.acr || '2');
174
165
  const twoFactorMethod = currentSession.twoFactorMethod || 'authenticator';
175
166
  // Build refresh request body
176
167
  const refreshRequestBody = {
@@ -147,6 +147,20 @@ function isPlainObject(value) {
147
147
  * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
148
148
  * carrier needs to be reachable from every copy that might run inside the request.
149
149
  */
150
+ /**
151
+ * THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
152
+ * THINGS. Renaming it would give a copy on the old name and a copy on the new
153
+ * name two DIFFERENT stores in the same process — which is precisely the
154
+ * double-load failure documented above, reintroduced by a cosmetic rename. The
155
+ * name is a wire contract between copies, not a description.
156
+ *
157
+ * accessToken is here for providers that do NOT issue an id_token. GitHub is
158
+ * not an OIDC provider: it has no id_token, no JWKS and no discovery document,
159
+ * so `id_token` can never be the field that proves a GitHub identity. The IDP
160
+ * re-derives that identity by calling GitHub itself with this access token
161
+ * (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
162
+ * verifies for itself and never trusts an asserted email.
163
+ */
150
164
  const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
151
165
  const gScope = globalThis;
152
166
  gScope[ID_TOKEN_SCOPE_KEY] ??= new node_async_hooks_1.AsyncLocalStorage();
@@ -248,9 +262,21 @@ function buildBetterAuthProviders(config) {
248
262
  * remains, so the provider emits exactly its default set.
249
263
  */
250
264
  const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
265
+ /**
266
+ * Same defect, same fix, different provider: github's createAuthorizationURL
267
+ * also seeds ['read:user','user:email'] unconditionally and then pushes ours
268
+ * on top (@better-auth/core social-providers/github.ts), so the stored row
269
+ * "read:user user:email" reached GitHub as
270
+ * "read:user user:email read:user user:email". Measured on :4799, not
271
+ * assumed. GitHub dedupes, so this is legibility rather than function — but
272
+ * a scope list is the first thing a security reviewer reads off that URL.
273
+ */
274
+ const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
251
275
  const wireScopes = name === 'google'
252
276
  ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
253
- : scopes;
277
+ : name === 'github'
278
+ ? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
279
+ : scopes;
254
280
  providers[name] = {
255
281
  clientId: oauth.clientId,
256
282
  clientSecret: oauth.clientSecret,
@@ -351,6 +377,82 @@ function buildBetterAuthProviders(config) {
351
377
  };
352
378
  };
353
379
  }
380
+ /**
381
+ * GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
382
+ *
383
+ * GitHub is not an OIDC provider — it issues no id_token — so the google
384
+ * branch above can never fire for it and the exchange would post an
385
+ * assertion with nothing that proves identity. That is exactly the
386
+ * 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
387
+ * `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
388
+ * the IDP rejected it instantly, without ever calling GitHub.
389
+ *
390
+ * getUserInfo is the ONLY provider hook that receives the token set (see the
391
+ * google note above), and @better-auth/core's github provider calls
392
+ * `options.getUserInfo(token)` BEFORE its own body — so installing this
393
+ * REPLACES the default rather than wrapping it, and the default's work has
394
+ * to be reproduced here or the sign-in loses its profile entirely.
395
+ *
396
+ * Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
397
+ * the version the container actually runs (better-auth 1.5.6 — the graft's
398
+ * node_modules says 1.6.29 and is NOT what executes). Kept deliberately
399
+ * dumb: it must not "improve" on upstream, because every divergence here is
400
+ * a behaviour change disguised as a token capture.
401
+ *
402
+ * This is NOT the identity check. The IDP re-derives the GitHub identity
403
+ * itself from this access token and enforces primary && verified server-side
404
+ * (GitHubIdentityVerifier); what we return here only shapes the local
405
+ * better-auth user record.
406
+ */
407
+ if (name === 'github') {
408
+ providers[name].getUserInfo = async (token) => {
409
+ const store = idTokenScope.getStore();
410
+ if (store) {
411
+ store.accessToken = token?.accessToken;
412
+ }
413
+ else {
414
+ console.warn('[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
415
+ 'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
416
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.');
417
+ }
418
+ const ghHeaders = {
419
+ 'User-Agent': 'better-auth',
420
+ Authorization: `Bearer ${token?.accessToken}`,
421
+ };
422
+ const profileRes = await fetch('https://api.github.com/user', {
423
+ headers: ghHeaders,
424
+ });
425
+ // Upstream returns null on profile error, which fails the sign-in
426
+ // closed. Preserved rather than softened.
427
+ if (!profileRes.ok) {
428
+ console.warn(`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`);
429
+ return null;
430
+ }
431
+ const profile = (await profileRes.json());
432
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
433
+ headers: ghHeaders,
434
+ });
435
+ const emails = emailsRes.ok
436
+ ? (await emailsRes.json())
437
+ : undefined;
438
+ // A GitHub account with a private email returns profile.email = null;
439
+ // upstream falls back to the primary address here.
440
+ if (!profile.email && emails) {
441
+ profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
442
+ }
443
+ const emailVerified = emails?.find((e) => e.email === profile.email)?.verified ?? false;
444
+ return {
445
+ user: {
446
+ id: profile.id,
447
+ name: profile.name || profile.login || '',
448
+ email: profile.email,
449
+ image: profile.avatar_url,
450
+ emailVerified,
451
+ },
452
+ data: profile,
453
+ };
454
+ };
455
+ }
354
456
  }
355
457
  return providers;
356
458
  }
@@ -802,6 +904,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
802
904
  * distinguishable from "the provider issued none".
803
905
  */
804
906
  ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
907
+ /**
908
+ * The same field for providers that have no id_token to give.
909
+ *
910
+ * Identical doctrine to the id_token above: RAW, forwarded verbatim,
911
+ * NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
912
+ * "never captured" stays distinguishable from "provider issued none".
913
+ * The IDP verifies for itself by calling GitHub with this token and
914
+ * enforcing primary && verified; we assert nothing about the identity.
915
+ *
916
+ * Scoped to the provider access token captured during THIS callback, so
917
+ * a google login continues to send id_token only and is unaffected.
918
+ */
919
+ ...(idTokenScope.getStore()?.accessToken
920
+ ? { access_token: idTokenScope.getStore().accessToken }
921
+ : {}),
805
922
  }),
806
923
  });
807
924
  const oauthResText = await oauthRes.text();
@@ -833,9 +950,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
833
950
  claims = { decode: 'FAILED — not a well-formed JWT' };
834
951
  }
835
952
  }
953
+ const forwardedAccess = idTokenScope.getStore()?.accessToken;
836
954
  console.error('[BETTER_AUTH] exchange diagnostics:', {
837
955
  id_token_forwarded: !!forwarded,
838
956
  id_token_length: forwarded ? forwarded.length : 0,
957
+ /**
958
+ * Read these two for github, and IGNORE the id_token pair above: github
959
+ * issues no id_token, so `id_token_forwarded: false` is its NORMAL state
960
+ * and not the fault. Presence and length only — an access token is
961
+ * credential material and this log is persistent. A github failure with
962
+ * access_token_forwarded:true means the IDP called GitHub and refused
963
+ * the answer (unverified or non-primary email, or app not installed);
964
+ * false means the capture hook never ran and the IDP never called out.
965
+ */
966
+ access_token_forwarded: !!forwardedAccess,
967
+ access_token_length: forwardedAccess ? forwardedAccess.length : 0,
839
968
  // Absent id_token => the IDP had nothing to verify. Present => compare these
840
969
  // against the client's registered Google credentials on the IDP.
841
970
  aud: claims.aud ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.4.3",
3
+ "version": "4.4.4",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -967,4 +967,4 @@
967
967
  "typescript": "^5.4.5",
968
968
  "zustand": "^4.5.5"
969
969
  }
970
- }
970
+ }
@@ -117,7 +117,13 @@ function walkJs(dir, acc = []) {
117
117
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
118
118
  const p = path.join(dir, e.name);
119
119
  if (e.isDirectory()) walkJs(p, acc);
120
- else if (e.isFile() && /\.(js|mjs|cjs)$/.test(e.name)) acc.push(p);
120
+ // `__`-prefixed files in dist are TEST MUTANTS, never tsc output (measured:
121
+ // 0 of 245 in a clean dist). Several suites write one next to the real
122
+ // artifact — so its relative requires resolve — and delete it straight
123
+ // after, while `node --test` runs other files CONCURRENTLY. Counting one
124
+ // would make this census non-reproducible (the property the LIVE test
125
+ // asserts) and reading one after its delete lands is an ENOENT crash.
126
+ else if (e.isFile() && !e.name.startsWith('__') && /\.(js|mjs|cjs)$/.test(e.name)) acc.push(p);
121
127
  }
122
128
  return acc;
123
129
  }
@@ -15,7 +15,6 @@
15
15
 
16
16
  import { NextRequest, NextResponse } from 'next/server';
17
17
  import { getSession as getBetterAuthSession } from '../server/auth';
18
- import { resolveAcrLevel, resolveAuthMethods } from '../lib/acr';
19
18
  import { nanoid } from 'nanoid';
20
19
  import {
21
20
  getSession,
@@ -245,24 +244,10 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
245
244
  return { success: false, error: 'No refresh token' };
246
245
  }
247
246
 
248
- // Extract authentication methods from session.
249
- //
250
- // 375639: these two lines used to read
251
- // authMethods = ... : ['pwd', 'mfa']
252
- // authLevel = String(... || ... || '2')
253
- // so a session that knew NOTHING about how the user authenticated told
254
- // the IDP that a second factor had happened AND named 'mfa' as the
255
- // method. The two fabrications corroborated each other, so a downstream
256
- // check cross-validating amr against acr would have accepted the pair.
257
- // Absent is not satisfied; both now resolve fail-closed, in one place.
258
- const authMethods = resolveAuthMethods(
259
- (currentSession as any).authMethods,
260
- (currentSession as any).token?.amr
261
- );
262
- const authLevel = resolveAcrLevel(
263
- (currentSession as any).authenticationLevel,
264
- (currentSession as any).token?.acr
265
- );
247
+ // Extract authentication methods from session
248
+ const authMethods = (currentSession as any).authMethods ||
249
+ ((currentSession as any).token?.amr ? JSON.parse((currentSession as any).token.amr) : ['pwd', 'mfa']);
250
+ const authLevel = String((currentSession as any).authenticationLevel || (currentSession as any).token?.acr || '2');
266
251
  const twoFactorMethod = (currentSession as any).twoFactorMethod || 'authenticator';
267
252
 
268
253
  // Build refresh request body
@@ -125,12 +125,27 @@ export interface BetterAuthSocialProvider {
125
125
  * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
126
126
  * carrier needs to be reachable from every copy that might run inside the request.
127
127
  */
128
+ /**
129
+ * THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
130
+ * THINGS. Renaming it would give a copy on the old name and a copy on the new
131
+ * name two DIFFERENT stores in the same process — which is precisely the
132
+ * double-load failure documented above, reintroduced by a cosmetic rename. The
133
+ * name is a wire contract between copies, not a description.
134
+ *
135
+ * accessToken is here for providers that do NOT issue an id_token. GitHub is
136
+ * not an OIDC provider: it has no id_token, no JWKS and no discovery document,
137
+ * so `id_token` can never be the field that proves a GitHub identity. The IDP
138
+ * re-derives that identity by calling GitHub itself with this access token
139
+ * (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
140
+ * verifies for itself and never trusts an asserted email.
141
+ */
128
142
  const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
143
+ type ProviderTokenScope = { idToken?: string; accessToken?: string };
129
144
  const gScope = globalThis as unknown as Record<
130
145
  string,
131
- AsyncLocalStorage<{ idToken?: string }> | undefined
146
+ AsyncLocalStorage<ProviderTokenScope> | undefined
132
147
  >;
133
- gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<{ idToken?: string }>();
148
+ gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
134
149
  const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
135
150
 
136
151
  /**
@@ -242,10 +257,22 @@ export function buildBetterAuthProviders(
242
257
  * remains, so the provider emits exactly its default set.
243
258
  */
244
259
  const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
260
+ /**
261
+ * Same defect, same fix, different provider: github's createAuthorizationURL
262
+ * also seeds ['read:user','user:email'] unconditionally and then pushes ours
263
+ * on top (@better-auth/core social-providers/github.ts), so the stored row
264
+ * "read:user user:email" reached GitHub as
265
+ * "read:user user:email read:user user:email". Measured on :4799, not
266
+ * assumed. GitHub dedupes, so this is legibility rather than function — but
267
+ * a scope list is the first thing a security reviewer reads off that URL.
268
+ */
269
+ const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
245
270
  const wireScopes =
246
271
  name === 'google'
247
272
  ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
248
- : scopes;
273
+ : name === 'github'
274
+ ? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
275
+ : scopes;
249
276
 
250
277
  providers[name] = {
251
278
  clientId: oauth.clientId,
@@ -350,6 +377,98 @@ export function buildBetterAuthProviders(
350
377
  };
351
378
  };
352
379
  }
380
+
381
+ /**
382
+ * GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
383
+ *
384
+ * GitHub is not an OIDC provider — it issues no id_token — so the google
385
+ * branch above can never fire for it and the exchange would post an
386
+ * assertion with nothing that proves identity. That is exactly the
387
+ * 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
388
+ * `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
389
+ * the IDP rejected it instantly, without ever calling GitHub.
390
+ *
391
+ * getUserInfo is the ONLY provider hook that receives the token set (see the
392
+ * google note above), and @better-auth/core's github provider calls
393
+ * `options.getUserInfo(token)` BEFORE its own body — so installing this
394
+ * REPLACES the default rather than wrapping it, and the default's work has
395
+ * to be reproduced here or the sign-in loses its profile entirely.
396
+ *
397
+ * Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
398
+ * the version the container actually runs (better-auth 1.5.6 — the graft's
399
+ * node_modules says 1.6.29 and is NOT what executes). Kept deliberately
400
+ * dumb: it must not "improve" on upstream, because every divergence here is
401
+ * a behaviour change disguised as a token capture.
402
+ *
403
+ * This is NOT the identity check. The IDP re-derives the GitHub identity
404
+ * itself from this access token and enforces primary && verified server-side
405
+ * (GitHubIdentityVerifier); what we return here only shapes the local
406
+ * better-auth user record.
407
+ */
408
+ if (name === 'github') {
409
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
410
+ accessToken?: string;
411
+ }) => {
412
+ const store = idTokenScope.getStore();
413
+ if (store) {
414
+ store.accessToken = token?.accessToken;
415
+ } else {
416
+ console.warn(
417
+ '[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
418
+ 'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
419
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
420
+ );
421
+ }
422
+
423
+ const ghHeaders = {
424
+ 'User-Agent': 'better-auth',
425
+ Authorization: `Bearer ${token?.accessToken}`,
426
+ };
427
+
428
+ const profileRes = await fetch('https://api.github.com/user', {
429
+ headers: ghHeaders,
430
+ });
431
+ // Upstream returns null on profile error, which fails the sign-in
432
+ // closed. Preserved rather than softened.
433
+ if (!profileRes.ok) {
434
+ console.warn(
435
+ `[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
436
+ );
437
+ return null;
438
+ }
439
+ const profile = (await profileRes.json()) as Record<string, any>;
440
+
441
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
442
+ headers: ghHeaders,
443
+ });
444
+ const emails = emailsRes.ok
445
+ ? ((await emailsRes.json()) as Array<{
446
+ email: string;
447
+ primary: boolean;
448
+ verified: boolean;
449
+ }>)
450
+ : undefined;
451
+
452
+ // A GitHub account with a private email returns profile.email = null;
453
+ // upstream falls back to the primary address here.
454
+ if (!profile.email && emails) {
455
+ profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
456
+ }
457
+ const emailVerified =
458
+ emails?.find((e) => e.email === profile.email)?.verified ?? false;
459
+
460
+ return {
461
+ user: {
462
+ id: profile.id,
463
+ name: profile.name || profile.login || '',
464
+ email: profile.email,
465
+ image: profile.avatar_url,
466
+ emailVerified,
467
+ },
468
+ data: profile,
469
+ };
470
+ };
471
+ }
353
472
  }
354
473
 
355
474
  return providers;
@@ -900,6 +1019,21 @@ export async function exchangeOAuthForIdpTokens(
900
1019
  * distinguishable from "the provider issued none".
901
1020
  */
902
1021
  ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
1022
+ /**
1023
+ * The same field for providers that have no id_token to give.
1024
+ *
1025
+ * Identical doctrine to the id_token above: RAW, forwarded verbatim,
1026
+ * NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
1027
+ * "never captured" stays distinguishable from "provider issued none".
1028
+ * The IDP verifies for itself by calling GitHub with this token and
1029
+ * enforcing primary && verified; we assert nothing about the identity.
1030
+ *
1031
+ * Scoped to the provider access token captured during THIS callback, so
1032
+ * a google login continues to send id_token only and is unaffected.
1033
+ */
1034
+ ...(idTokenScope.getStore()?.accessToken
1035
+ ? { access_token: idTokenScope.getStore()!.accessToken }
1036
+ : {}),
903
1037
  }),
904
1038
  });
905
1039
 
@@ -933,9 +1067,21 @@ export async function exchangeOAuthForIdpTokens(
933
1067
  claims = { decode: 'FAILED — not a well-formed JWT' };
934
1068
  }
935
1069
  }
1070
+ const forwardedAccess = idTokenScope.getStore()?.accessToken;
936
1071
  console.error('[BETTER_AUTH] exchange diagnostics:', {
937
1072
  id_token_forwarded: !!forwarded,
938
1073
  id_token_length: forwarded ? forwarded.length : 0,
1074
+ /**
1075
+ * Read these two for github, and IGNORE the id_token pair above: github
1076
+ * issues no id_token, so `id_token_forwarded: false` is its NORMAL state
1077
+ * and not the fault. Presence and length only — an access token is
1078
+ * credential material and this log is persistent. A github failure with
1079
+ * access_token_forwarded:true means the IDP called GitHub and refused
1080
+ * the answer (unverified or non-primary email, or app not installed);
1081
+ * false means the capture hook never ran and the IDP never called out.
1082
+ */
1083
+ access_token_forwarded: !!forwardedAccess,
1084
+ access_token_length: forwardedAccess ? forwardedAccess.length : 0,
939
1085
  // Absent id_token => the IDP had nothing to verify. Present => compare these
940
1086
  // against the client's registered Google credentials on the IDP.
941
1087
  aud: claims.aud ?? null,
package/dist/lib/acr.d.ts DELETED
@@ -1,64 +0,0 @@
1
- /**
2
- * CARD 375639 - the one place this package decides what an ABSENT assurance
3
- * signal means.
4
- *
5
- * Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
6
- * d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
7
- *
8
- * "fallbacks on the stored `session.authenticationLevel` (refresh lane)
9
- * are deliberately NOT covered ... That gap is 375276's remaining half."
10
- *
11
- * `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
12
- * package rather than in one app, so every consumer of the published
13
- * `@payez/next-mvp/api/auth-handler` export inherits it.
14
- *
15
- * WHY BOTH SIGNALS LIVE IN ONE MODULE
16
- * -----------------------------------
17
- * `acr` (how strong the authentication was) and `amr` (which methods were
18
- * used) are sent to the IDP together in a single refresh body. Defaulting one
19
- * without the other produces an INCOHERENT assertion - "no second factor
20
- * happened, and the method used was mfa" - so they are resolved side by side
21
- * and a change to one is forced to confront the other.
22
- *
23
- * MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
24
- * -------------------------------------------------------
25
- * acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
26
- * amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
27
- *
28
- * Both fail-open values were outliers at a single site, not the convention.
29
- * These constants adopt the majority, fail-closed convention.
30
- */
31
- /**
32
- * The assurance level used when the session and token say NOTHING about it.
33
- *
34
- * '1' = a single factor. It does not assert that a second factor happened.
35
- * It must never be '2' (or higher): that is the value the IDP reads as
36
- * "a second factor was completed", and an absent claim is not evidence of one.
37
- */
38
- export declare const ACR_WHEN_ABSENT = "1";
39
- /**
40
- * The authentication-methods list used when nothing is known.
41
- *
42
- * Empty asserts nothing. It must never contain 'mfa' (or any second-factor
43
- * method): claiming a method that was never exercised is the same fabrication
44
- * as ACR '2', and because the two corroborate each other a downstream check
45
- * that cross-validates amr against acr would accept the fabricated pair.
46
- */
47
- export declare const AMR_WHEN_ABSENT: readonly string[];
48
- /**
49
- * Resolve an assurance level from any number of candidate sources, in
50
- * priority order, falling back to the level that asserts no second factor.
51
- *
52
- * Accepts the candidates the refresh lane actually has: the stored
53
- * `session.authenticationLevel` first, then the `acr` claim on the token.
54
- */
55
- export declare function resolveAcrLevel(...candidates: unknown[]): string;
56
- /**
57
- * Resolve the authentication-methods list.
58
- *
59
- * `raw` may arrive as a JSON string (the shape stored on the token) or as an
60
- * array (the shape held on the session). Anything unparseable resolves to the
61
- * empty list rather than to an assumed set of methods - a malformed claim is
62
- * not evidence that mfa happened.
63
- */
64
- export declare function resolveAuthMethods(...candidates: unknown[]): string[];
package/dist/lib/acr.js DELETED
@@ -1,109 +0,0 @@
1
- "use strict";
2
- /**
3
- * CARD 375639 - the one place this package decides what an ABSENT assurance
4
- * signal means.
5
- *
6
- * Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
7
- * d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
8
- *
9
- * "fallbacks on the stored `session.authenticationLevel` (refresh lane)
10
- * are deliberately NOT covered ... That gap is 375276's remaining half."
11
- *
12
- * `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
13
- * package rather than in one app, so every consumer of the published
14
- * `@payez/next-mvp/api/auth-handler` export inherits it.
15
- *
16
- * WHY BOTH SIGNALS LIVE IN ONE MODULE
17
- * -----------------------------------
18
- * `acr` (how strong the authentication was) and `amr` (which methods were
19
- * used) are sent to the IDP together in a single refresh body. Defaulting one
20
- * without the other produces an INCOHERENT assertion - "no second factor
21
- * happened, and the method used was mfa" - so they are resolved side by side
22
- * and a change to one is forced to confront the other.
23
- *
24
- * MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
25
- * -------------------------------------------------------
26
- * acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
27
- * amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
28
- *
29
- * Both fail-open values were outliers at a single site, not the convention.
30
- * These constants adopt the majority, fail-closed convention.
31
- */
32
- Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.AMR_WHEN_ABSENT = exports.ACR_WHEN_ABSENT = void 0;
34
- exports.resolveAcrLevel = resolveAcrLevel;
35
- exports.resolveAuthMethods = resolveAuthMethods;
36
- /**
37
- * The assurance level used when the session and token say NOTHING about it.
38
- *
39
- * '1' = a single factor. It does not assert that a second factor happened.
40
- * It must never be '2' (or higher): that is the value the IDP reads as
41
- * "a second factor was completed", and an absent claim is not evidence of one.
42
- */
43
- exports.ACR_WHEN_ABSENT = '1';
44
- /**
45
- * The authentication-methods list used when nothing is known.
46
- *
47
- * Empty asserts nothing. It must never contain 'mfa' (or any second-factor
48
- * method): claiming a method that was never exercised is the same fabrication
49
- * as ACR '2', and because the two corroborate each other a downstream check
50
- * that cross-validates amr against acr would accept the fabricated pair.
51
- */
52
- exports.AMR_WHEN_ABSENT = [];
53
- /**
54
- * True when an assurance signal carries no information.
55
- *
56
- * Deliberately NOT `!value`. The `||` idiom this replaces cannot tell
57
- * "the token said nothing" from "the token said 0", and numeric 0 is a
58
- * meaningful assurance level (unauthenticated) that must survive as '0'
59
- * rather than being promoted to the fallback.
60
- */
61
- function isAbsent(value) {
62
- return value === undefined || value === null || value === '';
63
- }
64
- /**
65
- * Resolve an assurance level from any number of candidate sources, in
66
- * priority order, falling back to the level that asserts no second factor.
67
- *
68
- * Accepts the candidates the refresh lane actually has: the stored
69
- * `session.authenticationLevel` first, then the `acr` claim on the token.
70
- */
71
- function resolveAcrLevel(...candidates) {
72
- for (const candidate of candidates) {
73
- if (!isAbsent(candidate)) {
74
- return String(candidate);
75
- }
76
- }
77
- return exports.ACR_WHEN_ABSENT;
78
- }
79
- /**
80
- * Resolve the authentication-methods list.
81
- *
82
- * `raw` may arrive as a JSON string (the shape stored on the token) or as an
83
- * array (the shape held on the session). Anything unparseable resolves to the
84
- * empty list rather than to an assumed set of methods - a malformed claim is
85
- * not evidence that mfa happened.
86
- */
87
- function resolveAuthMethods(...candidates) {
88
- for (const candidate of candidates) {
89
- if (isAbsent(candidate))
90
- continue;
91
- if (Array.isArray(candidate)) {
92
- return candidate.map(String);
93
- }
94
- if (typeof candidate === 'string') {
95
- try {
96
- const parsed = JSON.parse(candidate);
97
- if (Array.isArray(parsed)) {
98
- return parsed.map(String);
99
- }
100
- }
101
- catch {
102
- // A malformed amr claim asserts nothing. Fall through to the next
103
- // candidate rather than inventing methods.
104
- continue;
105
- }
106
- }
107
- }
108
- return [...exports.AMR_WHEN_ABSENT];
109
- }
package/src/lib/acr.ts DELETED
@@ -1,109 +0,0 @@
1
- /**
2
- * CARD 375639 - the one place this package decides what an ABSENT assurance
3
- * signal means.
4
- *
5
- * Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
6
- * d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
7
- *
8
- * "fallbacks on the stored `session.authenticationLevel` (refresh lane)
9
- * are deliberately NOT covered ... That gap is 375276's remaining half."
10
- *
11
- * `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
12
- * package rather than in one app, so every consumer of the published
13
- * `@payez/next-mvp/api/auth-handler` export inherits it.
14
- *
15
- * WHY BOTH SIGNALS LIVE IN ONE MODULE
16
- * -----------------------------------
17
- * `acr` (how strong the authentication was) and `amr` (which methods were
18
- * used) are sent to the IDP together in a single refresh body. Defaulting one
19
- * without the other produces an INCOHERENT assertion - "no second factor
20
- * happened, and the method used was mfa" - so they are resolved side by side
21
- * and a change to one is forced to confront the other.
22
- *
23
- * MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
24
- * -------------------------------------------------------
25
- * acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
26
- * amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
27
- *
28
- * Both fail-open values were outliers at a single site, not the convention.
29
- * These constants adopt the majority, fail-closed convention.
30
- */
31
-
32
- /**
33
- * The assurance level used when the session and token say NOTHING about it.
34
- *
35
- * '1' = a single factor. It does not assert that a second factor happened.
36
- * It must never be '2' (or higher): that is the value the IDP reads as
37
- * "a second factor was completed", and an absent claim is not evidence of one.
38
- */
39
- export const ACR_WHEN_ABSENT = '1';
40
-
41
- /**
42
- * The authentication-methods list used when nothing is known.
43
- *
44
- * Empty asserts nothing. It must never contain 'mfa' (or any second-factor
45
- * method): claiming a method that was never exercised is the same fabrication
46
- * as ACR '2', and because the two corroborate each other a downstream check
47
- * that cross-validates amr against acr would accept the fabricated pair.
48
- */
49
- export const AMR_WHEN_ABSENT: readonly string[] = [];
50
-
51
- /**
52
- * True when an assurance signal carries no information.
53
- *
54
- * Deliberately NOT `!value`. The `||` idiom this replaces cannot tell
55
- * "the token said nothing" from "the token said 0", and numeric 0 is a
56
- * meaningful assurance level (unauthenticated) that must survive as '0'
57
- * rather than being promoted to the fallback.
58
- */
59
- function isAbsent(value: unknown): boolean {
60
- return value === undefined || value === null || value === '';
61
- }
62
-
63
- /**
64
- * Resolve an assurance level from any number of candidate sources, in
65
- * priority order, falling back to the level that asserts no second factor.
66
- *
67
- * Accepts the candidates the refresh lane actually has: the stored
68
- * `session.authenticationLevel` first, then the `acr` claim on the token.
69
- */
70
- export function resolveAcrLevel(...candidates: unknown[]): string {
71
- for (const candidate of candidates) {
72
- if (!isAbsent(candidate)) {
73
- return String(candidate);
74
- }
75
- }
76
- return ACR_WHEN_ABSENT;
77
- }
78
-
79
- /**
80
- * Resolve the authentication-methods list.
81
- *
82
- * `raw` may arrive as a JSON string (the shape stored on the token) or as an
83
- * array (the shape held on the session). Anything unparseable resolves to the
84
- * empty list rather than to an assumed set of methods - a malformed claim is
85
- * not evidence that mfa happened.
86
- */
87
- export function resolveAuthMethods(...candidates: unknown[]): string[] {
88
- for (const candidate of candidates) {
89
- if (isAbsent(candidate)) continue;
90
-
91
- if (Array.isArray(candidate)) {
92
- return candidate.map(String);
93
- }
94
-
95
- if (typeof candidate === 'string') {
96
- try {
97
- const parsed = JSON.parse(candidate);
98
- if (Array.isArray(parsed)) {
99
- return parsed.map(String);
100
- }
101
- } catch {
102
- // A malformed amr claim asserts nothing. Fall through to the next
103
- // candidate rather than inventing methods.
104
- continue;
105
- }
106
- }
107
- }
108
- return [...AMR_WHEN_ABSENT];
109
- }