@payez/next-mvp 4.4.7 → 4.4.9

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.
@@ -281,19 +281,42 @@ export function buildBetterAuthProviders(
281
281
  clientSecret: oauth.clientSecret,
282
282
  scope: wireScopes.length > 0 ? wireScopes : undefined,
283
283
  /**
284
- * 260419 — CLOSE THE SECOND SELF-SERVE PROVISIONING DOOR. 259805 gated
285
- * the signup BFF on allow_public_registration, but the social lane never
286
- * touches that code: better-auth creates the user through its own
287
- * internalAdapter at the OAuth callback, and its ONLY guard is this flag.
284
+ * PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
288
285
  *
289
- * Wired from the tenant's own policy, explicit-true-opens: the IDP config
290
- * keeps allowPublicRegistration UNDEFINED when the tenant never answered
291
- * (idp-client-config.ts, the no-fabricated-default rule), and an
292
- * unanswered policy question must not provision accounts. That matches
293
- * the FE's silent-default-is-decline posture for the "Create account"
294
- * affordance. A tenant that wants Google self-serve says so.
286
+ * 260419 wired this from `config.authSettings?.allowPublicRegistration` to
287
+ * close a client-side self-serve provisioning door. It cannot do that job
288
+ * correctly, and it BREAKS SIGN-IN:
289
+ *
290
+ * - This app runs better-auth in STATELESS mode (no DB adapter — see the
291
+ * file header). better-auth therefore has NO user store, so at the OAuth
292
+ * callback it cannot tell an EXISTING user from a NEW one. Its
293
+ * handleOAuthUserInfo consults `disableSignUp` for BOTH, so
294
+ * `disableSignUp: true` refuses a returning user exactly like a
295
+ * stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
296
+ * Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
297
+ * once client 12's allow_public_registration reported false.
298
+ *
299
+ * - The REAL registration gate already exists and is correct, at the IDP:
300
+ * PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
301
+ * user up by verified email (:799); an existing user logs in UNTOUCHED
302
+ * (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
303
+ * which refuses fail-closed when the client forbids public registration
304
+ * (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
305
+ * Its own comment states the contract: "existing federated users ...
306
+ * log in untouched, so gating here never blocks login."
307
+ *
308
+ * So this flag was a DUPLICATE gate that could only ever misfire — it cannot
309
+ * distinguish new from existing, and the IDP already refuses new identities.
310
+ * Removing it restores sign-in for existing users WITHOUT reopening
311
+ * self-serve: a NEW identity that completes the better-auth leg still gets
312
+ * REFUSED at the IDP exchange and never receives IDP tokens, which is the
313
+ * portal's actual authorization gate.
314
+ *
315
+ * WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
316
+ * (above) as the backstop, and each app's own front-door sign-up gate
317
+ * (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
318
+ * Both are correct and neither is this flag.
295
319
  */
296
- disableSignUp: config.authSettings?.allowPublicRegistration !== true,
297
320
  // Google is overly eager to reuse the last account unless we
298
321
  // explicitly ask for account selection on each social login.
299
322
  prompt: typeof rawPrompt === 'string'
@@ -984,11 +1007,17 @@ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => P
984
1007
  * header of the response to find the BA Redis key.
985
1008
  *
986
1009
  * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
1010
+ *
1011
+ * RETURNS the IdP's own error code on an IDP refusal, or null otherwise
1012
+ * (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
1013
+ * boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
1014
+ * blip and every cause collapsed to one generic login-page code — a NEW user
1015
+ * refused registration bounced silently, which is the failure Jon named.
987
1016
  */
988
1017
  export async function exchangeOAuthForIdpTokens(
989
1018
  sessionToken: string,
990
1019
  provider: string = 'google'
991
- ): Promise<boolean> {
1020
+ ): Promise<boolean | string> {
992
1021
  try {
993
1022
  const config = await getIDPClientConfig();
994
1023
  const appSlug = config.clientSlug || getAppSlug();
@@ -1081,6 +1110,36 @@ export async function exchangeOAuthForIdpTokens(
1081
1110
  if (!oauthRes.ok) {
1082
1111
  console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
1083
1112
 
1113
+ /**
1114
+ * PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
1115
+ *
1116
+ * Every refusal used to return a bare `false`, so the abort collapsed ALL
1117
+ * causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
1118
+ * registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
1119
+ * bounced silently, indistinguishable from a network blip. Jon named the
1120
+ * clear message explicitly (67856): "(b) a brand-new Google user with
1121
+ * registration OFF is refused by the IDP with a clear message, not a
1122
+ * silent bounce."
1123
+ *
1124
+ * The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
1125
+ * We read ONLY `error.code` and return it VERBATIM — the abort puts it in
1126
+ * the `?error=` param, and the HOST's allowlist decides whether to render
1127
+ * it. The package does NOT interpret the code and does NOT add a message
1128
+ * (that would be a second allowlist). No code in the body -> null (the
1129
+ * generic host code), exactly as before.
1130
+ */
1131
+ let idpErrorCode: string | null = null;
1132
+ try {
1133
+ const parsed = JSON.parse(oauthResText);
1134
+ // FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
1135
+ // unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
1136
+ if (typeof parsed?.error?.code === 'string' && IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
1137
+ idpErrorCode = parsed.error.code;
1138
+ }
1139
+ } catch {
1140
+ /* non-JSON refusal body -> no code (generic abort) */
1141
+ }
1142
+
1084
1143
  /**
1085
1144
  * WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
1086
1145
  *
@@ -1128,7 +1187,7 @@ export async function exchangeOAuthForIdpTokens(
1128
1187
  sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
1129
1188
  idp_url: idpUrl,
1130
1189
  });
1131
- return false;
1190
+ return idpErrorCode ?? false;
1132
1191
  }
1133
1192
 
1134
1193
  let idpData: any;
@@ -1138,6 +1197,7 @@ export async function exchangeOAuthForIdpTokens(
1138
1197
  if (!result?.access_token) {
1139
1198
  console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
1140
1199
  return false;
1200
+
1141
1201
  }
1142
1202
 
1143
1203
  // Build IDP token data
@@ -1248,6 +1308,7 @@ export async function exchangeOAuthForIdpTokens(
1248
1308
  // Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
1249
1309
  const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
1250
1310
  if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
1311
+ // true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
1251
1312
  return stored;
1252
1313
  } catch (err) {
1253
1314
  console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
@@ -1255,6 +1316,7 @@ export async function exchangeOAuthForIdpTokens(
1255
1316
  }
1256
1317
  }
1257
1318
 
1319
+
1258
1320
  /**
1259
1321
  * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
1260
1322
  * function never acquires tokens and never decides who the caller is.
@@ -1351,6 +1413,19 @@ export async function storeIdpTokensInSession(
1351
1413
  */
1352
1414
  export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
1353
1415
 
1416
+ /**
1417
+ * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
1418
+ *
1419
+ * Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
1420
+ * log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
1421
+ * the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
1422
+ * line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
1423
+ * (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
1424
+ * wearing a different number. Add them together, never one alone.
1425
+ */
1426
+ export const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string> = new Set(['REGISTRATION_NOT_ALLOWED']);
1427
+
1428
+
1354
1429
  /**
1355
1430
  * Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
1356
1431
  * WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
@@ -1387,6 +1462,7 @@ async function abortOAuthSignIn(
1387
1462
  sessionToken: string | null,
1388
1463
  cookieHeader: string,
1389
1464
  loginPath: string,
1465
+ errorCode: string = OAUTH_SIGN_IN_FAILED_ERROR,
1390
1466
  ): Promise<Response> {
1391
1467
  const headers = new Headers();
1392
1468
  if (sessionToken) {
@@ -1405,8 +1481,8 @@ async function abortOAuthSignIn(
1405
1481
  }
1406
1482
  }
1407
1483
  for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
1408
- headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${OAUTH_SIGN_IN_FAILED_ERROR}`);
1409
- console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)');
1484
+ headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
1485
+ console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)', 'code=' + errorCode);
1410
1486
  return new Response(null, { status: 302, headers });
1411
1487
  }
1412
1488
 
@@ -1498,7 +1574,9 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1498
1574
  .map((c: string) => c.split(';')[0])
1499
1575
  .join('; ');
1500
1576
  let sessionToken: string | null = null;
1501
- let exchanged = false;
1577
+ // true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
1578
+ // (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
1579
+ let exchange: boolean | string = false;
1502
1580
  try {
1503
1581
  const auth = await getBetterAuthInstance();
1504
1582
  if (auth?.api?.getSession) {
@@ -1509,7 +1587,7 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1509
1587
  if (session?.session?.token) {
1510
1588
  sessionToken = session.session.token;
1511
1589
  console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
1512
- exchanged = await exchangeOAuthForIdpTokens(session.session.token);
1590
+ exchange = await exchangeOAuthForIdpTokens(session.session.token);
1513
1591
  } else {
1514
1592
  console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
1515
1593
  }
@@ -1517,7 +1595,11 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1517
1595
  } catch (err: any) {
1518
1596
  console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
1519
1597
  }
1520
- if (!exchanged) return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath);
1598
+ if (exchange !== true) {
1599
+ // A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
1600
+ const errorCode = typeof exchange === 'string' ? exchange : OAUTH_SIGN_IN_FAILED_ERROR;
1601
+ return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
1602
+ }
1521
1603
  }
1522
1604
 
1523
1605
  return response;
@@ -9,12 +9,44 @@ interface Props {
9
9
  isLoading?: boolean;
10
10
  }
11
11
 
12
- const providerIcons: Record<FederatedProvider, { label: string; svg: React.ReactNode; color: string }> = {
12
+ /**
13
+ * PAY-2104 — PER-PROVIDER BRAND TREATMENT, NOT ONE GENERIC BUTTON.
14
+ *
15
+ * Apple and Facebook both publish binding rules for their sign-in buttons and
16
+ * neither is satisfied by the generic "Continue with X" control this section
17
+ * used to render for every provider:
18
+ * - APPLE (App Store Review Guidelines 4.8 / "Sign in with Apple" HIG): the
19
+ * button MUST carry the Apple logo and one of Apple's own wordings. The
20
+ * approved primary wording is "Sign in with Apple" ("Continue with Apple"
21
+ * is the permitted alternative). "Continue with Apple" with no logo, which
22
+ * is what the generic path produced, is non-compliant and is what App Store
23
+ * review rejects. Black or white only.
24
+ * - FACEBOOK (Meta Platform Terms / brand guidelines): "Continue with
25
+ * Facebook", the Facebook "f" mark, and the brand blue (#1877F2) or white
26
+ * are the sanctioned treatments.
27
+ * Google/Microsoft/GitHub keep the neutral control — they impose no such rule,
28
+ * and inventing brand colours for them would be a change nobody asked for.
29
+ *
30
+ * The mark inherits `currentColor` so it is legible on both the white neutral
31
+ * buttons and the black/blue branded ones without a second SVG per provider.
32
+ */
33
+ const PROVIDER_BRAND: Record<
34
+ FederatedProvider,
35
+ { label: string; background: string; color: string; border: string }
36
+ > = {
37
+ google: { label: 'Continue with Google', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
38
+ microsoft: { label: 'Continue with Microsoft', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
39
+ github: { label: 'Continue with GitHub', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
40
+ // Meta brand blue; white mark on blue, per the brand guidelines.
41
+ facebook: { label: 'Continue with Facebook', background: '#1877F2', color: '#ffffff', border: '#1877F2' },
42
+ // Apple HIG: black (or white) field, "Sign in with Apple", Apple logo.
43
+ apple: { label: 'Sign in with Apple', background: '#000000', color: '#ffffff', border: '#000000' },
44
+ };
45
+
46
+ const providerIcons: Record<FederatedProvider, { svg: React.ReactNode }> = {
13
47
  google: {
14
- label: 'Google',
15
- color: '#4285F4',
16
48
  svg: (
17
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
49
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
18
50
  <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
19
51
  <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
20
52
  <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
@@ -23,10 +55,8 @@ const providerIcons: Record<FederatedProvider, { label: string; svg: React.React
23
55
  ),
24
56
  },
25
57
  microsoft: {
26
- label: 'Microsoft',
27
- color: '#0078D4',
28
58
  svg: (
29
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
59
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
30
60
  <rect x="1" y="1" width="9" height="9" fill="#F25022"/>
31
61
  <rect x="12" y="1" width="9" height="9" fill="#7FBA00"/>
32
62
  <rect x="1" y="12" width="9" height="9" fill="#00A4EF"/>
@@ -35,28 +65,25 @@ const providerIcons: Record<FederatedProvider, { label: string; svg: React.React
35
65
  ),
36
66
  },
37
67
  facebook: {
38
- label: 'Facebook',
39
- color: '#1877F2',
68
+ // currentColor: white on the Meta-blue button. A hardcoded #1877F2 fill on a
69
+ // #1877F2 field is an invisible mark — the exact way a branded button ships
70
+ // looking like a blank one.
40
71
  svg: (
41
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="#1877F2">
72
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
42
73
  <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
43
74
  </svg>
44
75
  ),
45
76
  },
46
77
  github: {
47
- label: 'GitHub',
48
- color: '#000000',
49
78
  svg: (
50
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
79
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
51
80
  <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
52
81
  </svg>
53
82
  ),
54
83
  },
55
84
  apple: {
56
- label: 'Apple',
57
- color: '#000000',
58
85
  svg: (
59
- <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
86
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
60
87
  <path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
61
88
  </svg>
62
89
  ),
@@ -71,22 +98,29 @@ export function FederatedAuthSection({ providers, onProviderClick, isLoading }:
71
98
  return (
72
99
  <div className="space-y-3">
73
100
  {providers.map((provider) => {
74
- const config = providerIcons[provider];
101
+ const icon = providerIcons[provider];
102
+ const brand = PROVIDER_BRAND[provider] ?? {
103
+ label: `Continue with ${provider}`,
104
+ background: 'var(--bg-default, #ffffff)',
105
+ color: 'var(--text-primary, #111827)',
106
+ border: 'var(--border-default, #e5e7eb)',
107
+ };
75
108
  return (
76
109
  <button
77
110
  key={provider}
78
111
  type="button"
79
112
  onClick={() => onProviderClick(provider)}
80
113
  disabled={isLoading}
114
+ data-provider={provider}
81
115
  className={`w-full px-4 py-3 rounded-lg border-2 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed`}
82
116
  style={{
83
- border: '1px solid var(--border-default)',
84
- color: 'var(--text-primary)',
85
- background: 'var(--bg-default)',
117
+ border: `1px solid ${brand.border}`,
118
+ color: brand.color,
119
+ background: brand.background,
86
120
  }}
87
121
  >
88
- {config.svg}
89
- Continue with {config.label}
122
+ {icon.svg}
123
+ {brand.label}
90
124
  </button>
91
125
  );
92
126
  })}
@@ -62,35 +62,58 @@ export function useAvailableProviders(): UseAvailableProvidersResult {
62
62
 
63
63
  async function fetchProviders() {
64
64
  try {
65
- // Fetch available providers from Better Auth
66
- // Better Auth doesn't have a getProviders equivalent, so we use a static list
67
- // based on configured social providers
68
- const result: Record<string, { id: string; name: string }> = {
69
- google: { id: 'google', name: 'Google' },
70
- };
65
+ // PAY-2104: THIS USED TO BE A HARDCODED GOOGLE LITERAL.
66
+ //
67
+ // const result = { google: { id: 'google', name: 'Google' } };
68
+ //
69
+ // under a comment claiming providers were "loaded dynamically". It
70
+ // returned Google for EVERY client whatever the tenant had enabled -
71
+ // a config-shaped constant, so Apple/Facebook (PAY-2104) could never
72
+ // appear and a client that had DISABLED Google would still see its
73
+ // button. The real answer is served by /api/auth/settings (the same
74
+ // route routes/auth/settings.ts builds from the IDP's oauthProviders).
75
+ const response = await fetch('/api/auth/settings', { cache: 'no-store' });
71
76
 
72
77
  if (!mounted) return;
73
78
 
74
- setRawProviders(result);
75
-
76
- if (result) {
77
- // Filter to OAuth providers only (exclude credentials)
78
- // Map to our FederatedProvider type
79
- const oauthProviders = Object.keys(result)
80
- .filter(id => OAUTH_PROVIDERS.includes(id))
81
- .map(id => PROVIDER_MAP[id])
82
- .filter((p): p is FederatedProvider => p !== undefined);
79
+ if (!response.ok) {
80
+ // FAIL CLOSED. An unreadable policy is not an empty provider list
81
+ // that we may then pad with a guess - it is "we do not know". See
82
+ // the PAY-1243 rule: never fabricate a provider list.
83
+ throw new Error(`Failed to fetch auth settings: ${response.status}`);
84
+ }
83
85
 
84
- setProviders(oauthProviders);
85
- } else {
86
- setProviders([]);
86
+ const body = await response.json().catch(() => null);
87
+ const settings = body?.data ?? body;
88
+ if (body?.success === false || !settings) {
89
+ throw new Error(body?.error?.code ?? 'POLICY_UNAVAILABLE');
87
90
  }
88
91
 
92
+ // Keep the raw provider rows (id -> the settings row) for callers that
93
+ // want more than the mapped union type, exactly as before.
94
+ const enabledIds: string[] = Array.isArray(settings.enabledProviders)
95
+ ? settings.enabledProviders.filter((p: unknown): p is string => typeof p === 'string')
96
+ : [];
97
+ const raw: Record<string, { id: string; name: string }> = {};
98
+ for (const id of enabledIds) raw[id] = { id, name: id };
99
+
100
+ if (!mounted) return;
101
+ setRawProviders(raw);
102
+
103
+ // Filter to OAuth providers only (exclude credentials) and map to our
104
+ // FederatedProvider union, in the IDP's order.
105
+ const oauthProviders = enabledIds
106
+ .filter(id => OAUTH_PROVIDERS.includes(id))
107
+ .map(id => PROVIDER_MAP[id])
108
+ .filter((p): p is FederatedProvider => p !== undefined);
109
+
110
+ setProviders(oauthProviders);
89
111
  setError(null);
90
112
  } catch (err) {
91
113
  if (!mounted) return;
92
114
  setError(err instanceof Error ? err : new Error(String(err)));
93
115
  setProviders([]);
116
+ setRawProviders(null); // never leave a stale list behind a failure
94
117
  } finally {
95
118
  if (mounted) {
96
119
  setIsLoading(false);
@@ -0,0 +1,168 @@
1
+ /**
2
+ * resolveNextAuthSecret - COMPATIBILITY SHIM (restored 2026-09-30, PAY-2005 / N7).
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS, AND WHY IT IS A SHIM RATHER THAN A FEATURE
6
+ * ---------------------------------------------------------------------------
7
+ * The NextAuth-era broker secret resolver was DELETED in
8
+ * 4d3d29e6 "feat!: Better Auth 4.0.0 — NextAuth fully removed"
9
+ * and it is intentionally NOT coming back as first-class API: this package is
10
+ * Better Auth now, and new apps must not resolve a NextAuth secret at all.
11
+ *
12
+ * It is restored here ONLY because ONE consumer is still mid-migration and cannot
13
+ * take the 4.4.x bump without it:
14
+ *
15
+ * CONSUMER THAT NEEDS THIS: the customer portal
16
+ * (`payez_idp_admin_web` / VibeID-Customer-Portal). It is pinned at
17
+ * @payez/next-mvp 4.0.46 and still runs BOTH next-auth@4.24 and better-auth; its
18
+ * `src/lib/server-session.ts` and `src/lib/session-token.ts` (and 5 route files)
19
+ * import `resolveNextAuthSecret` from here. Without this export, bumping the
20
+ * portal to 4.4.x breaks its build. BAPert ruled (68003) to ship this scoped
21
+ * shim rather than force the portal's full NextAuth->BetterAuth migration
22
+ * tonight. That migration is tracked as its own card; WHEN IT LANDS, DELETE
23
+ * THIS FILE.
24
+ *
25
+ * DO NOT add new consumers. If you are starting an app, use Better Auth's own
26
+ * secret handling; you do not need this.
27
+ *
28
+ * ---------------------------------------------------------------------------
29
+ * BEHAVIOUR (faithful to the 4.0.46 implementation, with one deliberate change)
30
+ * ---------------------------------------------------------------------------
31
+ * Priority: (1) process.env.NEXTAUTH_SECRET if set; (2) fetch from the IDP via a
32
+ * signed client assertion; (3) cache in-memory 5 min and stamp NEXTAUTH_SECRET.
33
+ *
34
+ * DELIBERATE CHANGE, 4.0.46 -> here: 4.0.46 validated the fetched secret with
35
+ * `validateNextAuthSecret`, which this package's 4.4.x no longer exports (it was
36
+ * renamed to `validateAuthSecret` with an IDENTICAL body). The shim calls
37
+ * `validateAuthSecret` - same predicate, current name - so there is no second
38
+ * copy of the rule. The validation semantics are unchanged.
39
+ *
40
+ * SERVER-ONLY: the IDP handshake and NEXTAUTH_SECRET are server concerns.
41
+ */
42
+ import 'server-only';
43
+ import { randomUUID } from 'node:crypto';
44
+ import { validateAuthSecret } from './secret-validation';
45
+
46
+ let cachedSecret: string | null = null;
47
+ let lastFetchedAt = 0;
48
+
49
+ const CACHE_TTL_MS = 5 * 60 * 1000;
50
+
51
+ interface SigningResponse {
52
+ data?: { client_assertion?: string; clientAssertion?: string; ClientAssertion?: string };
53
+ client_assertion?: string;
54
+ clientAssertion?: string;
55
+ ClientAssertion?: string;
56
+ }
57
+
58
+ interface SecretResponse {
59
+ data?: { secret?: string; configuration?: unknown };
60
+ secret?: string;
61
+ configuration?: unknown;
62
+ }
63
+
64
+ /**
65
+ * Resolve the NextAuth secret (server-only).
66
+ *
67
+ * Priority:
68
+ * 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
69
+ * 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
70
+ * 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
71
+ */
72
+ export async function resolveNextAuthSecret(): Promise<string> {
73
+ // Check if already in environment
74
+ if (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== '') {
75
+ return process.env.NEXTAUTH_SECRET;
76
+ }
77
+
78
+ // Check if cached and fresh (within 5 minutes)
79
+ if (cachedSecret && Date.now() - lastFetchedAt < CACHE_TTL_MS) {
80
+ return cachedSecret;
81
+ }
82
+
83
+ // Broker mode: fetch from the IDP (the IDP handles all Key Vault/signing)
84
+ const base = process.env.IDP_URL;
85
+ if (!base) throw new Error('IDP_URL environment variable is required');
86
+
87
+ const clientIdStr = process.env.CLIENT_ID;
88
+ if (!clientIdStr || clientIdStr.trim() === '') {
89
+ throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
90
+ }
91
+
92
+ // Step 1: ask the IDP to sign a client assertion (the IDP holds the keys, not us)
93
+ const signingUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
94
+ const signingResp = await fetch(signingUrl, {
95
+ method: 'POST',
96
+ headers: {
97
+ Accept: 'application/json',
98
+ 'Content-Type': 'application/json',
99
+ 'X-Client-Id': clientIdStr,
100
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
101
+ },
102
+ body: JSON.stringify({
103
+ issuer: clientIdStr,
104
+ subject: clientIdStr,
105
+ audience: 'urn:payez:externalauth:nextauthsecret',
106
+ expires_in: 60,
107
+ }),
108
+ cache: 'no-store',
109
+ });
110
+
111
+ if (!signingResp.ok) {
112
+ const txt = await signingResp.text().catch(() => 'Unknown error');
113
+ throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
114
+ }
115
+
116
+ const signingBody = (await signingResp.json().catch(() => ({}))) as SigningResponse;
117
+ const clientAssertion =
118
+ signingBody?.data?.client_assertion ??
119
+ signingBody?.data?.clientAssertion ??
120
+ signingBody?.client_assertion ??
121
+ signingBody?.clientAssertion ??
122
+ signingBody?.data?.ClientAssertion ??
123
+ signingBody?.ClientAssertion;
124
+
125
+ if (!clientAssertion || typeof clientAssertion !== 'string') {
126
+ throw new Error('IDP did not return a valid signed client assertion');
127
+ }
128
+
129
+ // Step 2: use the signed assertion to fetch the NextAuth secret
130
+ const proxyUrl = `${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`;
131
+ const proxyResp = await fetch(proxyUrl, {
132
+ method: 'POST',
133
+ headers: {
134
+ Accept: 'application/json',
135
+ 'Content-Type': 'application/json',
136
+ 'X-Client-Id': clientIdStr,
137
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
138
+ },
139
+ body: JSON.stringify({ client_assertion: clientAssertion }),
140
+ cache: 'no-store',
141
+ });
142
+
143
+ if (!proxyResp.ok) {
144
+ const txt = await proxyResp.text().catch(() => 'Unknown error');
145
+ throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
146
+ }
147
+
148
+ const proxyBody = (await proxyResp.json().catch(() => ({}))) as SecretResponse;
149
+ const secret = proxyBody?.data?.secret ?? proxyBody?.secret;
150
+ // `configuration` is available from the IDP but deliberately not logged.
151
+
152
+ if (!secret || typeof secret !== 'string') {
153
+ throw new Error('Proxy did not return a valid NextAuth secret');
154
+ }
155
+
156
+ // Same predicate as 4.0.46's validateNextAuthSecret, under its current name.
157
+ const validation = validateAuthSecret(secret);
158
+ if (!validation.valid) {
159
+ throw new Error(`Fetched NextAuth secret failed validation: ${validation.reason}`);
160
+ }
161
+
162
+ cachedSecret = secret;
163
+ lastFetchedAt = Date.now();
164
+ process.env.NEXTAUTH_SECRET = secret;
165
+
166
+ console.log('[NEXTAUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
167
+ return secret;
168
+ }
@@ -124,3 +124,26 @@ export function projectPasswordPolicyRules(json: unknown): string[] | undefined
124
124
  if (!details || details.code !== 'password_policy') return undefined;
125
125
  return knownPasswordRules(details.rules);
126
126
  }
127
+
128
+ /**
129
+ * PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
130
+ * the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
131
+ * or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
132
+ * (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
133
+ * only the code, never IdP text.
134
+ */
135
+ export function projectPasswordBreached(json: unknown): boolean {
136
+ if (!json || typeof json !== 'object') return false;
137
+ const top = json as Record<string, unknown>;
138
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
139
+ const direct = str(err.code) ?? str(top.code) ?? str(top.error_code);
140
+ if (direct?.toLowerCase() === 'password_breached') return true;
141
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
142
+ if (str(details?.code)?.toLowerCase() === 'password_breached') return true;
143
+ // THE SHAPE THE IdP ACTUALLY SENDS (DotNetPert-Scout, PAY-2103 PR 850caa07a): error.details =
144
+ // { field:'new_password', code:'password_policy', rules:['password_breached'], requirements:[...] }.
145
+ // The breach is a member of rules[], not a code - the house envelope carries every password rule
146
+ // that way. Reading only a code would MISS the real refusal.
147
+ if (Array.isArray(details?.rules) && (details!.rules as unknown[]).some((r) => typeof r === 'string' && r.toLowerCase() === 'password_breached')) return true;
148
+ return Array.isArray(details?.value) && (details!.value as unknown[]).some((v) => !!v && typeof v === 'object' && str((v as Record<string, unknown>).code)?.toLowerCase() === 'password_breached');
149
+ }