@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.
@@ -72,7 +72,9 @@ async function POST(req) {
72
72
  if (!upstream.ok) {
73
73
  // PAY-1591: a password-policy refusal also carries the KNOWN rule codes so the page can say which rules failed.
74
74
  const rules = (0, safe_error_response_1.projectPasswordPolicyRules)(data);
75
- return server_1.NextResponse.json({ ...(0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...((0, safe_error_response_1.projectResetTokenInvalid)(data) ? { reset_token_invalid: true } : {}) }, { status: upstream.status });
75
+ // PAY-2103: a breached-password refusal is a distinct 400; project a BOOLEAN so the anonymous
76
+ // route carries no code text and the page picks its own sentence.
77
+ return server_1.NextResponse.json({ ...(0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...((0, safe_error_response_1.projectResetTokenInvalid)(data) ? { reset_token_invalid: true } : {}), ...((0, safe_error_response_1.projectPasswordBreached)(data) ? { password_breached: true } : {}) }, { status: upstream.status });
76
78
  }
77
79
  return server_1.NextResponse.json(data, { status: upstream.status });
78
80
  }
@@ -12,6 +12,7 @@ const auth_1 = require("../../server/auth");
12
12
  const startup_init_1 = require("../../lib/startup-init");
13
13
  const idp_client_config_1 = require("../../lib/idp-client-config");
14
14
  const token_log_id_1 = require("../../lib/token-log-id");
15
+ const sessions_1 = require("../../vibe/sessions");
15
16
  async function GET(req) {
16
17
  try {
17
18
  // Ensure initialization is complete (auth signing secret resolved from IDP)
@@ -124,12 +125,43 @@ async function GET(req) {
124
125
  hoursExpiredAgo: ((Date.now() - mfaExpiresAt) / (1000 * 60 * 60)).toFixed(1)
125
126
  });
126
127
  }
128
+ // --- Session revocation status (PAY-1882 step 4 producer half) ---
129
+ // The four-state status (BAPert 66320). The middleware fails CLOSED by
130
+ // default on 'unknown' (a failed Vibe read) and always on 'revoked';
131
+ // 'active' and 'not_configured' stay viable. A consumer that does not
132
+ // read revocationStatus is unaffected - absent is not revoked.
133
+ let revocationStatus = 'not_configured';
134
+ // BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
135
+ // the token's own `sub` claim FIRST (session.userId can be an email).
136
+ const revocationUserId = (0, sessions_1.resolveNumericIdpUserId)(sessionData, sessionData.idpAccessToken);
137
+ if (revocationUserId > 0) {
138
+ try {
139
+ revocationStatus = await (0, sessions_1.getRevocationStatus)(revocationUserId, sessionToken);
140
+ }
141
+ catch {
142
+ // getRevocationStatus does not throw by contract; fail closed if it ever does.
143
+ revocationStatus = 'unknown';
144
+ }
145
+ }
146
+ else {
147
+ // N1 (NightHawk 66392): a session EXISTS but its userId is not a usable
148
+ // numeric IdP id, so the check could not be run. That is 'unknown'
149
+ // (could not determine), NOT 'not_configured' (no revocation source) -
150
+ // the two must not be conflated.
151
+ // BAPert 66480 condition 1: emit ONE WARN (shape only) per session.
152
+ revocationStatus = (0, sessions_1.warnNonNumericUserId)(sessionToken, sessionData.userId);
153
+ }
127
154
  const response = {
128
155
  authenticated: true,
129
156
  sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionToken), // PAY-1481: correlation id for middleware tracking, never the token
130
157
  // 2FA fields - critical for middleware redirect logic
131
158
  requires2FA, // From cached client config (client-wide setting)
132
159
  twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
160
+ // Session revocation (PAY-1882 step 4): the four-state status, plus the
161
+ // boolean the middleware's existing force-invalid branch already reads.
162
+ revocationStatus,
163
+ forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
164
+ revoked: revocationStatus === 'revoked',
133
165
  // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
134
166
  //
135
167
  // THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
@@ -536,8 +536,14 @@ export declare function getBetterAuthHandler(): Promise<{
536
536
  * header of the response to find the BA Redis key.
537
537
  *
538
538
  * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
539
+ *
540
+ * RETURNS the IdP's own error code on an IDP refusal, or null otherwise
541
+ * (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
542
+ * boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
543
+ * blip and every cause collapsed to one generic login-page code — a NEW user
544
+ * refused registration bounced silently, which is the failure Jon named.
539
545
  */
540
- export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean>;
546
+ export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean | string>;
541
547
  /**
542
548
  * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
543
549
  * function never acquires tokens and never decides who the caller is.
@@ -588,6 +594,17 @@ export declare function storeIdpTokensInSession(sessionToken: string, idpTokenDa
588
594
  * Try again or use another method." The cause stays in the server log, never on the page.
589
595
  */
590
596
  export declare const OAUTH_SIGN_IN_FAILED_ERROR = "OAUTH_SIGN_IN_FAILED";
597
+ /**
598
+ * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
599
+ *
600
+ * Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
601
+ * log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
602
+ * the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
603
+ * line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
604
+ * (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
605
+ * wearing a different number. Add them together, never one alone.
606
+ */
607
+ export declare const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string>;
591
608
  /**
592
609
  * Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
593
610
  * WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
@@ -43,7 +43,7 @@ var __importStar = (this && this.__importStar) || (function () {
43
43
  };
44
44
  })();
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
- exports.OAUTH_SIGN_IN_FAILED_ERROR = void 0;
46
+ exports.IDP_ERROR_CODES_TO_FORWARD = exports.OAUTH_SIGN_IN_FAILED_ERROR = void 0;
47
47
  exports.secretFingerprint = secretFingerprint;
48
48
  exports.buildIdpOAuthProviderConfig = buildIdpOAuthProviderConfig;
49
49
  exports.buildBetterAuthProviders = buildBetterAuthProviders;
@@ -286,19 +286,42 @@ function buildBetterAuthProviders(config) {
286
286
  clientSecret: oauth.clientSecret,
287
287
  scope: wireScopes.length > 0 ? wireScopes : undefined,
288
288
  /**
289
- * 260419 — CLOSE THE SECOND SELF-SERVE PROVISIONING DOOR. 259805 gated
290
- * the signup BFF on allow_public_registration, but the social lane never
291
- * touches that code: better-auth creates the user through its own
292
- * internalAdapter at the OAuth callback, and its ONLY guard is this flag.
289
+ * PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
293
290
  *
294
- * Wired from the tenant's own policy, explicit-true-opens: the IDP config
295
- * keeps allowPublicRegistration UNDEFINED when the tenant never answered
296
- * (idp-client-config.ts, the no-fabricated-default rule), and an
297
- * unanswered policy question must not provision accounts. That matches
298
- * the FE's silent-default-is-decline posture for the "Create account"
299
- * affordance. A tenant that wants Google self-serve says so.
291
+ * 260419 wired this from `config.authSettings?.allowPublicRegistration` to
292
+ * close a client-side self-serve provisioning door. It cannot do that job
293
+ * correctly, and it BREAKS SIGN-IN:
294
+ *
295
+ * - This app runs better-auth in STATELESS mode (no DB adapter — see the
296
+ * file header). better-auth therefore has NO user store, so at the OAuth
297
+ * callback it cannot tell an EXISTING user from a NEW one. Its
298
+ * handleOAuthUserInfo consults `disableSignUp` for BOTH, so
299
+ * `disableSignUp: true` refuses a returning user exactly like a
300
+ * stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
301
+ * Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
302
+ * once client 12's allow_public_registration reported false.
303
+ *
304
+ * - The REAL registration gate already exists and is correct, at the IDP:
305
+ * PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
306
+ * user up by verified email (:799); an existing user logs in UNTOUCHED
307
+ * (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
308
+ * which refuses fail-closed when the client forbids public registration
309
+ * (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
310
+ * Its own comment states the contract: "existing federated users ...
311
+ * log in untouched, so gating here never blocks login."
312
+ *
313
+ * So this flag was a DUPLICATE gate that could only ever misfire — it cannot
314
+ * distinguish new from existing, and the IDP already refuses new identities.
315
+ * Removing it restores sign-in for existing users WITHOUT reopening
316
+ * self-serve: a NEW identity that completes the better-auth leg still gets
317
+ * REFUSED at the IDP exchange and never receives IDP tokens, which is the
318
+ * portal's actual authorization gate.
319
+ *
320
+ * WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
321
+ * (above) as the backstop, and each app's own front-door sign-up gate
322
+ * (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
323
+ * Both are correct and neither is this flag.
300
324
  */
301
- disableSignUp: config.authSettings?.allowPublicRegistration !== true,
302
325
  // Google is overly eager to reuse the last account unless we
303
326
  // explicitly ask for account selection on each social login.
304
327
  prompt: typeof rawPrompt === 'string'
@@ -874,6 +897,12 @@ async function getBetterAuthHandler() {
874
897
  * header of the response to find the BA Redis key.
875
898
  *
876
899
  * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
900
+ *
901
+ * RETURNS the IdP's own error code on an IDP refusal, or null otherwise
902
+ * (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
903
+ * boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
904
+ * blip and every cause collapsed to one generic login-page code — a NEW user
905
+ * refused registration bounced silently, which is the failure Jon named.
877
906
  */
878
907
  async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
879
908
  try {
@@ -959,6 +988,36 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
959
988
  console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
960
989
  if (!oauthRes.ok) {
961
990
  console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
991
+ /**
992
+ * PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
993
+ *
994
+ * Every refusal used to return a bare `false`, so the abort collapsed ALL
995
+ * causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
996
+ * registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
997
+ * bounced silently, indistinguishable from a network blip. Jon named the
998
+ * clear message explicitly (67856): "(b) a brand-new Google user with
999
+ * registration OFF is refused by the IDP with a clear message, not a
1000
+ * silent bounce."
1001
+ *
1002
+ * The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
1003
+ * We read ONLY `error.code` and return it VERBATIM — the abort puts it in
1004
+ * the `?error=` param, and the HOST's allowlist decides whether to render
1005
+ * it. The package does NOT interpret the code and does NOT add a message
1006
+ * (that would be a second allowlist). No code in the body -> null (the
1007
+ * generic host code), exactly as before.
1008
+ */
1009
+ let idpErrorCode = null;
1010
+ try {
1011
+ const parsed = JSON.parse(oauthResText);
1012
+ // FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
1013
+ // unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
1014
+ if (typeof parsed?.error?.code === 'string' && exports.IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
1015
+ idpErrorCode = parsed.error.code;
1016
+ }
1017
+ }
1018
+ catch {
1019
+ /* non-JSON refusal body -> no code (generic abort) */
1020
+ }
962
1021
  /**
963
1022
  * WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
964
1023
  *
@@ -1007,7 +1066,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
1007
1066
  sent_client_id: (0, client_key_1.resolveClientKey)(config, 'auth/better-auth:oauth-callback').key ?? null,
1008
1067
  idp_url: idpUrl,
1009
1068
  });
1010
- return false;
1069
+ return idpErrorCode ?? false;
1011
1070
  }
1012
1071
  let idpData;
1013
1072
  try {
@@ -1127,6 +1186,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
1127
1186
  const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
1128
1187
  if (stored)
1129
1188
  console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
1189
+ // true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
1130
1190
  return stored;
1131
1191
  }
1132
1192
  catch (err) {
@@ -1222,6 +1282,17 @@ async function storeIdpTokensInSession(sessionToken, idpTokenData, authProvider)
1222
1282
  * Try again or use another method." The cause stays in the server log, never on the page.
1223
1283
  */
1224
1284
  exports.OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
1285
+ /**
1286
+ * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
1287
+ *
1288
+ * Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
1289
+ * log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
1290
+ * the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
1291
+ * line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
1292
+ * (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
1293
+ * wearing a different number. Add them together, never one alone.
1294
+ */
1295
+ exports.IDP_ERROR_CODES_TO_FORWARD = new Set(['REGISTRATION_NOT_ALLOWED']);
1225
1296
  /**
1226
1297
  * Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
1227
1298
  * WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
@@ -1253,7 +1324,7 @@ function expireSetCookies(setCookies) {
1253
1324
  * expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
1254
1325
  * branch above (328439: an absolute URL built from request.url is a dead tab in a container).
1255
1326
  */
1256
- async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, loginPath) {
1327
+ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, loginPath, errorCode = exports.OAUTH_SIGN_IN_FAILED_ERROR) {
1257
1328
  const headers = new Headers();
1258
1329
  if (sessionToken) {
1259
1330
  try {
@@ -1275,8 +1346,8 @@ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, lo
1275
1346
  }
1276
1347
  for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? []))
1277
1348
  headers.append('set-cookie', sc);
1278
- headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${exports.OAUTH_SIGN_IN_FAILED_ERROR}`);
1279
- console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : '(no session)');
1349
+ headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
1350
+ console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : '(no session)', 'code=' + errorCode);
1280
1351
  return new Response(null, { status: 302, headers });
1281
1352
  }
1282
1353
  /**
@@ -1364,7 +1435,9 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1364
1435
  .map((c) => c.split(';')[0])
1365
1436
  .join('; ');
1366
1437
  let sessionToken = null;
1367
- let exchanged = false;
1438
+ // true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
1439
+ // (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
1440
+ let exchange = false;
1368
1441
  try {
1369
1442
  const auth = await getBetterAuthInstance();
1370
1443
  if (auth?.api?.getSession) {
@@ -1374,7 +1447,7 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1374
1447
  if (session?.session?.token) {
1375
1448
  sessionToken = session.session.token;
1376
1449
  console.log('[BETTER_AUTH] Got session token from callback:', (0, token_log_id_1.tokenLogId)(session.session.token), '| email:', session.user?.email);
1377
- exchanged = await exchangeOAuthForIdpTokens(session.session.token);
1450
+ exchange = await exchangeOAuthForIdpTokens(session.session.token);
1378
1451
  }
1379
1452
  else {
1380
1453
  console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
@@ -1384,8 +1457,11 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1384
1457
  catch (err) {
1385
1458
  console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
1386
1459
  }
1387
- if (!exchanged)
1388
- return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath);
1460
+ if (exchange !== true) {
1461
+ // A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
1462
+ const errorCode = typeof exchange === 'string' ? exchange : exports.OAUTH_SIGN_IN_FAILED_ERROR;
1463
+ return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
1464
+ }
1389
1465
  }
1390
1466
  return response;
1391
1467
  });
@@ -3,31 +3,54 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.FederatedAuthSection = FederatedAuthSection;
5
5
  const jsx_runtime_1 = require("react/jsx-runtime");
6
+ /**
7
+ * PAY-2104 — PER-PROVIDER BRAND TREATMENT, NOT ONE GENERIC BUTTON.
8
+ *
9
+ * Apple and Facebook both publish binding rules for their sign-in buttons and
10
+ * neither is satisfied by the generic "Continue with X" control this section
11
+ * used to render for every provider:
12
+ * - APPLE (App Store Review Guidelines 4.8 / "Sign in with Apple" HIG): the
13
+ * button MUST carry the Apple logo and one of Apple's own wordings. The
14
+ * approved primary wording is "Sign in with Apple" ("Continue with Apple"
15
+ * is the permitted alternative). "Continue with Apple" with no logo, which
16
+ * is what the generic path produced, is non-compliant and is what App Store
17
+ * review rejects. Black or white only.
18
+ * - FACEBOOK (Meta Platform Terms / brand guidelines): "Continue with
19
+ * Facebook", the Facebook "f" mark, and the brand blue (#1877F2) or white
20
+ * are the sanctioned treatments.
21
+ * Google/Microsoft/GitHub keep the neutral control — they impose no such rule,
22
+ * and inventing brand colours for them would be a change nobody asked for.
23
+ *
24
+ * The mark inherits `currentColor` so it is legible on both the white neutral
25
+ * buttons and the black/blue branded ones without a second SVG per provider.
26
+ */
27
+ const PROVIDER_BRAND = {
28
+ google: { label: 'Continue with Google', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
29
+ microsoft: { label: 'Continue with Microsoft', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
30
+ github: { label: 'Continue with GitHub', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
31
+ // Meta brand blue; white mark on blue, per the brand guidelines.
32
+ facebook: { label: 'Continue with Facebook', background: '#1877F2', color: '#ffffff', border: '#1877F2' },
33
+ // Apple HIG: black (or white) field, "Sign in with Apple", Apple logo.
34
+ apple: { label: 'Sign in with Apple', background: '#000000', color: '#ffffff', border: '#000000' },
35
+ };
6
36
  const providerIcons = {
7
37
  google: {
8
- label: 'Google',
9
- color: '#4285F4',
10
- svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })),
38
+ svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("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" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })),
11
39
  },
12
40
  microsoft: {
13
- label: 'Microsoft',
14
- color: '#0078D4',
15
- svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("rect", { x: "1", y: "1", width: "9", height: "9", fill: "#F25022" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "1", width: "9", height: "9", fill: "#7FBA00" }), (0, jsx_runtime_1.jsx)("rect", { x: "1", y: "12", width: "9", height: "9", fill: "#00A4EF" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "12", width: "9", height: "9", fill: "#FFB900" })] })),
41
+ svg: ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("rect", { x: "1", y: "1", width: "9", height: "9", fill: "#F25022" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "1", width: "9", height: "9", fill: "#7FBA00" }), (0, jsx_runtime_1.jsx)("rect", { x: "1", y: "12", width: "9", height: "9", fill: "#00A4EF" }), (0, jsx_runtime_1.jsx)("rect", { x: "12", y: "12", width: "9", height: "9", fill: "#FFB900" })] })),
16
42
  },
17
43
  facebook: {
18
- label: 'Facebook',
19
- color: '#1877F2',
20
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "#1877F2", children: (0, jsx_runtime_1.jsx)("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" }) })),
44
+ // currentColor: white on the Meta-blue button. A hardcoded #1877F2 fill on a
45
+ // #1877F2 field is an invisible mark — the exact way a branded button ships
46
+ // looking like a blank one.
47
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("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" }) })),
21
48
  },
22
49
  github: {
23
- label: 'GitHub',
24
- color: '#000000',
25
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: (0, jsx_runtime_1.jsx)("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" }) })),
50
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("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" }) })),
26
51
  },
27
52
  apple: {
28
- label: 'Apple',
29
- color: '#000000',
30
- svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: (0, jsx_runtime_1.jsx)("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" }) })),
53
+ svg: ((0, jsx_runtime_1.jsx)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("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" }) })),
31
54
  },
32
55
  };
33
56
  function FederatedAuthSection({ providers, onProviderClick, isLoading }) {
@@ -35,11 +58,17 @@ function FederatedAuthSection({ providers, onProviderClick, isLoading }) {
35
58
  return null;
36
59
  }
37
60
  return ((0, jsx_runtime_1.jsx)("div", { className: "space-y-3", children: providers.map((provider) => {
38
- const config = providerIcons[provider];
39
- return ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => onProviderClick(provider), disabled: isLoading, 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`, style: {
40
- border: '1px solid var(--border-default)',
41
- color: 'var(--text-primary)',
42
- background: 'var(--bg-default)',
43
- }, children: [config.svg, "Continue with ", config.label] }, provider));
61
+ const icon = providerIcons[provider];
62
+ const brand = PROVIDER_BRAND[provider] ?? {
63
+ label: `Continue with ${provider}`,
64
+ background: 'var(--bg-default, #ffffff)',
65
+ color: 'var(--text-primary, #111827)',
66
+ border: 'var(--border-default, #e5e7eb)',
67
+ };
68
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => onProviderClick(provider), disabled: isLoading, "data-provider": provider, 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`, style: {
69
+ border: `1px solid ${brand.border}`,
70
+ color: brand.color,
71
+ background: brand.background,
72
+ }, children: [icon.svg, brand.label] }, provider));
44
73
  }) }));
45
74
  }
@@ -57,27 +57,48 @@ function useAvailableProviders() {
57
57
  let mounted = true;
58
58
  async function fetchProviders() {
59
59
  try {
60
- // Fetch available providers from Better Auth
61
- // Better Auth doesn't have a getProviders equivalent, so we use a static list
62
- // based on configured social providers
63
- const result = {
64
- google: { id: 'google', name: 'Google' },
65
- };
60
+ // PAY-2104: THIS USED TO BE A HARDCODED GOOGLE LITERAL.
61
+ //
62
+ // const result = { google: { id: 'google', name: 'Google' } };
63
+ //
64
+ // under a comment claiming providers were "loaded dynamically". It
65
+ // returned Google for EVERY client whatever the tenant had enabled -
66
+ // a config-shaped constant, so Apple/Facebook (PAY-2104) could never
67
+ // appear and a client that had DISABLED Google would still see its
68
+ // button. The real answer is served by /api/auth/settings (the same
69
+ // route routes/auth/settings.ts builds from the IDP's oauthProviders).
70
+ const response = await fetch('/api/auth/settings', { cache: 'no-store' });
66
71
  if (!mounted)
67
72
  return;
68
- setRawProviders(result);
69
- if (result) {
70
- // Filter to OAuth providers only (exclude credentials)
71
- // Map to our FederatedProvider type
72
- const oauthProviders = Object.keys(result)
73
- .filter(id => OAUTH_PROVIDERS.includes(id))
74
- .map(id => PROVIDER_MAP[id])
75
- .filter((p) => p !== undefined);
76
- setProviders(oauthProviders);
73
+ if (!response.ok) {
74
+ // FAIL CLOSED. An unreadable policy is not an empty provider list
75
+ // that we may then pad with a guess - it is "we do not know". See
76
+ // the PAY-1243 rule: never fabricate a provider list.
77
+ throw new Error(`Failed to fetch auth settings: ${response.status}`);
77
78
  }
78
- else {
79
- setProviders([]);
79
+ const body = await response.json().catch(() => null);
80
+ const settings = body?.data ?? body;
81
+ if (body?.success === false || !settings) {
82
+ throw new Error(body?.error?.code ?? 'POLICY_UNAVAILABLE');
80
83
  }
84
+ // Keep the raw provider rows (id -> the settings row) for callers that
85
+ // want more than the mapped union type, exactly as before.
86
+ const enabledIds = Array.isArray(settings.enabledProviders)
87
+ ? settings.enabledProviders.filter((p) => typeof p === 'string')
88
+ : [];
89
+ const raw = {};
90
+ for (const id of enabledIds)
91
+ raw[id] = { id, name: id };
92
+ if (!mounted)
93
+ return;
94
+ setRawProviders(raw);
95
+ // Filter to OAuth providers only (exclude credentials) and map to our
96
+ // FederatedProvider union, in the IDP's order.
97
+ const oauthProviders = enabledIds
98
+ .filter(id => OAUTH_PROVIDERS.includes(id))
99
+ .map(id => PROVIDER_MAP[id])
100
+ .filter((p) => p !== undefined);
101
+ setProviders(oauthProviders);
81
102
  setError(null);
82
103
  }
83
104
  catch (err) {
@@ -85,6 +106,7 @@ function useAvailableProviders() {
85
106
  return;
86
107
  setError(err instanceof Error ? err : new Error(String(err)));
87
108
  setProviders([]);
109
+ setRawProviders(null); // never leave a stale list behind a failure
88
110
  }
89
111
  finally {
90
112
  if (mounted) {
@@ -0,0 +1,51 @@
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
+ /**
44
+ * Resolve the NextAuth secret (server-only).
45
+ *
46
+ * Priority:
47
+ * 1) Use process.env.NEXTAUTH_SECRET if present (allows overrides/production)
48
+ * 2) Fetch from the IDP broker endpoint - the IDP handles all Key Vault/signing
49
+ * 3) Cache result in-memory and set process.env.NEXTAUTH_SECRET for later calls
50
+ */
51
+ export declare function resolveNextAuthSecret(): Promise<string>;