@payez/next-mvp 4.4.3 → 4.4.5

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,
@@ -1127,7 +1256,26 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1127
1256
  return new Response(null, { status: 302, headers: { location: loginPath } });
1128
1257
  }
1129
1258
  }
1130
- // After successful OAuth callback: exchange Google identity for IDP tokens
1259
+ // After successful OAuth callback: exchange the PROVIDER identity for IDP tokens.
1260
+ //
1261
+ // ⚠ WAS "exchange Google identity" — and it meant it literally. This call
1262
+ // site passed no provider, so it took exchangeOAuthForIdpTokens's
1263
+ // `provider: string = 'google'` default and asserted "google" to
1264
+ // /api/ExternalAuth/oauth-callback for EVERY provider. That was harmless
1265
+ // while Google was the only one wired, and became wrong the moment a second
1266
+ // provider shipped: a GitHub sign-in forwarded a GitHub access_token
1267
+ // labelled `provider: 'google'`, so the IDP tried to verify it as a Google
1268
+ // identity and fail-closed with 401 UNVERIFIED_ASSERTION in
1269
+ // `response_time_ms: 0` — it never had to call anyone to know it was wrong.
1270
+ //
1271
+ // The diagnostics made this maximally confusing: `access_token_forwarded:
1272
+ // true` is correct and the token really did arrive. The FIELD was fine; the
1273
+ // LABEL on it was a lie. Nothing on the client half is broken.
1274
+ //
1275
+ // The provider was never unavailable — it is the path segment this very
1276
+ // `if` matches on (/api/auth/callback/github). Derive it instead of
1277
+ // defaulting, because a default here cannot be right for more than one
1278
+ // provider at a time.
1131
1279
  const url = new URL(request.url);
1132
1280
  if (url.pathname.includes('/callback/') && response.status === 302) {
1133
1281
  try {
@@ -1141,8 +1289,23 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1141
1289
  headers.set('cookie', cookieHeader);
1142
1290
  const session = await auth.api.getSession({ headers });
1143
1291
  if (session?.session?.token) {
1144
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
1145
- await exchangeOAuthForIdpTokens(session.session.token);
1292
+ // /api/auth/callback/<provider>[?query] -> "<provider>"
1293
+ const callbackProvider = url.pathname
1294
+ .split('/callback/')[1]
1295
+ ?.split('/')[0]
1296
+ ?.split('?')[0]
1297
+ ?.trim()
1298
+ .toLowerCase() || '';
1299
+ if (!callbackProvider) {
1300
+ // Do NOT quietly fall back to 'google'. That silent default is the
1301
+ // exact defect this block fixes, and re-introducing it here would
1302
+ // reproduce it for whatever unparseable shape got us here.
1303
+ console.error('[BETTER_AUTH] OAuth callback matched /callback/ but no provider could be derived from', url.pathname, '— refusing to assert a guessed provider to the IDP. No exchange performed.');
1304
+ }
1305
+ else {
1306
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| provider:', callbackProvider, '| email:', session.user?.email);
1307
+ await exchangeOAuthForIdpTokens(session.session.token, callbackProvider);
1308
+ }
1146
1309
  }
1147
1310
  else {
1148
1311
  console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
@@ -673,7 +673,37 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
673
673
  'X-Client-Id': clientIdStr,
674
674
  'X-Correlation-Id': (0, crypto_1.randomUUID)().replace(/-/g, ''),
675
675
  },
676
- body: JSON.stringify({ client_assertion }),
676
+ // 217917 PHASE 1: explicit opt-in for provider secrets.
677
+ //
678
+ // This broker is the ONLY consumer of oauth_providers[].client_secret
679
+ // (DotNetPert enumerated it: this file feeds auth/providers/oauth.ts,
680
+ // which performs the server-side code exchange). Every OTHER caller of
681
+ // ExternalAuth/client-config - notably every fielded acp-desktop - is
682
+ // pushed the same secrets today and reads none of them: the desktop is
683
+ // PKCE and dropped clientSecret from OAuthProviderConfig entirely.
684
+ //
685
+ // The IDP IGNORES this field today, so shipping it is a no-op and needs
686
+ // no coordination window. Phase 2 (DotNetPert, IDP side) makes
687
+ // client-config emit the secrets ONLY when the opt-in is present and
688
+ // redact otherwise - which is why phase 1 MUST be live first, or the
689
+ // broker loses the field and tenant OAuth sign-in dies.
690
+ //
691
+ // SCOPE, deliberately narrow - do NOT read this as a partial migration:
692
+ // the sibling path fetchConfigFromInternalIDP() hits a DIFFERENT
693
+ // endpoint (InternalClientConfig/{id}, GET) that is already
694
+ // authenticated with CONTAINERS_KEY, is not reachable by a desktop, and
695
+ // is therefore not part of 217917's exposure. It deliberately sends no
696
+ // opt-in. If phase 2's redaction is implemented in shared config-
697
+ // assembly code rather than in ExternalAuthController.GetClientConfig,
698
+ // it will ALSO strip the internal route and break this broker's other
699
+ // path with no opt-in to save it. Phase 2 must gate on the request.
700
+ //
701
+ // NOT A FIX ON ITS OWN: an attacker can set this flag too. Phase 2 ends
702
+ // the AUTOMATIC delivery of every tenant's provider secrets to every
703
+ // user laptop; it does not close disclosure to a determined caller.
704
+ // That residual is card 175047 (unauthenticated mint, global partner
705
+ // key) and 217917 does not close until 175047 lands or Jon accepts it.
706
+ body: JSON.stringify({ client_assertion, include_provider_secrets: true }),
677
707
  cache: 'no-store'
678
708
  });
679
709
  if (!configResp.ok) {
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.5",
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,
@@ -1237,7 +1383,26 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1237
1383
  }
1238
1384
  }
1239
1385
 
1240
- // After successful OAuth callback: exchange Google identity for IDP tokens
1386
+ // After successful OAuth callback: exchange the PROVIDER identity for IDP tokens.
1387
+ //
1388
+ // ⚠ WAS "exchange Google identity" — and it meant it literally. This call
1389
+ // site passed no provider, so it took exchangeOAuthForIdpTokens's
1390
+ // `provider: string = 'google'` default and asserted "google" to
1391
+ // /api/ExternalAuth/oauth-callback for EVERY provider. That was harmless
1392
+ // while Google was the only one wired, and became wrong the moment a second
1393
+ // provider shipped: a GitHub sign-in forwarded a GitHub access_token
1394
+ // labelled `provider: 'google'`, so the IDP tried to verify it as a Google
1395
+ // identity and fail-closed with 401 UNVERIFIED_ASSERTION in
1396
+ // `response_time_ms: 0` — it never had to call anyone to know it was wrong.
1397
+ //
1398
+ // The diagnostics made this maximally confusing: `access_token_forwarded:
1399
+ // true` is correct and the token really did arrive. The FIELD was fine; the
1400
+ // LABEL on it was a lie. Nothing on the client half is broken.
1401
+ //
1402
+ // The provider was never unavailable — it is the path segment this very
1403
+ // `if` matches on (/api/auth/callback/github). Derive it instead of
1404
+ // defaulting, because a default here cannot be right for more than one
1405
+ // provider at a time.
1241
1406
  const url = new URL(request.url);
1242
1407
  if (url.pathname.includes('/callback/') && response.status === 302) {
1243
1408
  try {
@@ -1253,8 +1418,32 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1253
1418
 
1254
1419
  const session = await auth.api.getSession({ headers });
1255
1420
  if (session?.session?.token) {
1256
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
1257
- await exchangeOAuthForIdpTokens(session.session.token);
1421
+ // /api/auth/callback/<provider>[?query] -> "<provider>"
1422
+ const callbackProvider = url.pathname
1423
+ .split('/callback/')[1]
1424
+ ?.split('/')[0]
1425
+ ?.split('?')[0]
1426
+ ?.trim()
1427
+ .toLowerCase() || '';
1428
+
1429
+ if (!callbackProvider) {
1430
+ // Do NOT quietly fall back to 'google'. That silent default is the
1431
+ // exact defect this block fixes, and re-introducing it here would
1432
+ // reproduce it for whatever unparseable shape got us here.
1433
+ console.error(
1434
+ '[BETTER_AUTH] OAuth callback matched /callback/ but no provider could be derived from',
1435
+ url.pathname,
1436
+ '— refusing to assert a guessed provider to the IDP. No exchange performed.'
1437
+ );
1438
+ } else {
1439
+ console.log(
1440
+ '[BETTER_AUTH] Got session token from callback:',
1441
+ session.session.token.substring(0, 10),
1442
+ '| provider:', callbackProvider,
1443
+ '| email:', session.user?.email
1444
+ );
1445
+ await exchangeOAuthForIdpTokens(session.session.token, callbackProvider);
1446
+ }
1258
1447
  } else {
1259
1448
  console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
1260
1449
  }
@@ -783,7 +783,37 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
783
783
  'X-Client-Id': clientIdStr,
784
784
  'X-Correlation-Id': randomUUID().replace(/-/g, ''),
785
785
  },
786
- body: JSON.stringify({ client_assertion }),
786
+ // 217917 PHASE 1: explicit opt-in for provider secrets.
787
+ //
788
+ // This broker is the ONLY consumer of oauth_providers[].client_secret
789
+ // (DotNetPert enumerated it: this file feeds auth/providers/oauth.ts,
790
+ // which performs the server-side code exchange). Every OTHER caller of
791
+ // ExternalAuth/client-config - notably every fielded acp-desktop - is
792
+ // pushed the same secrets today and reads none of them: the desktop is
793
+ // PKCE and dropped clientSecret from OAuthProviderConfig entirely.
794
+ //
795
+ // The IDP IGNORES this field today, so shipping it is a no-op and needs
796
+ // no coordination window. Phase 2 (DotNetPert, IDP side) makes
797
+ // client-config emit the secrets ONLY when the opt-in is present and
798
+ // redact otherwise - which is why phase 1 MUST be live first, or the
799
+ // broker loses the field and tenant OAuth sign-in dies.
800
+ //
801
+ // SCOPE, deliberately narrow - do NOT read this as a partial migration:
802
+ // the sibling path fetchConfigFromInternalIDP() hits a DIFFERENT
803
+ // endpoint (InternalClientConfig/{id}, GET) that is already
804
+ // authenticated with CONTAINERS_KEY, is not reachable by a desktop, and
805
+ // is therefore not part of 217917's exposure. It deliberately sends no
806
+ // opt-in. If phase 2's redaction is implemented in shared config-
807
+ // assembly code rather than in ExternalAuthController.GetClientConfig,
808
+ // it will ALSO strip the internal route and break this broker's other
809
+ // path with no opt-in to save it. Phase 2 must gate on the request.
810
+ //
811
+ // NOT A FIX ON ITS OWN: an attacker can set this flag too. Phase 2 ends
812
+ // the AUTOMATIC delivery of every tenant's provider secrets to every
813
+ // user laptop; it does not close disclosure to a determined caller.
814
+ // That residual is card 175047 (unauthenticated mint, global partner
815
+ // key) and 217917 does not close until 175047 lands or Jon accepts it.
816
+ body: JSON.stringify({ client_assertion, include_provider_secrets: true }),
787
817
  cache: 'no-store'
788
818
  } as RequestInit);
789
819
 
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
- }