@payez/next-mvp 4.4.6 → 4.4.8

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.
Files changed (54) hide show
  1. package/dist/api-handlers/account/change-password.js +28 -6
  2. package/dist/auth/better-auth.d.ts +62 -15
  3. package/dist/auth/better-auth.js +180 -19
  4. package/dist/client/better-auth-client.d.ts +101 -956
  5. package/dist/config/env.d.ts +0 -2
  6. package/dist/config/env.js +0 -2
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +8 -1
  9. package/dist/lib/idp-client-config.js +8 -1
  10. package/dist/lib/never-in-response.d.ts +30 -0
  11. package/dist/lib/never-in-response.js +71 -0
  12. package/dist/lib/nextauth-secret.d.ts +51 -0
  13. package/dist/lib/nextauth-secret.js +141 -0
  14. package/dist/lib/safe-error-response.d.ts +6 -6
  15. package/dist/lib/safe-error-response.js +6 -6
  16. package/dist/lib/two-factor-verify-failure.d.ts +36 -3
  17. package/dist/lib/two-factor-verify-failure.js +79 -2
  18. package/dist/middleware/create-middleware.d.ts +2 -0
  19. package/dist/middleware/create-middleware.js +36 -0
  20. package/dist/pages/login/page.js +5 -0
  21. package/dist/pages/verify-code/page.js +4 -2
  22. package/dist/routes/auth/settings.js +4 -1
  23. package/dist/server/auth.d.ts +36 -294
  24. package/dist/server/auth.js +22 -25
  25. package/dist/vibe/client.d.ts +132 -16
  26. package/dist/vibe/client.js +288 -82
  27. package/dist/vibe/document-unwrap.d.ts +50 -0
  28. package/dist/vibe/document-unwrap.js +78 -0
  29. package/dist/vibe/errors.d.ts +17 -0
  30. package/dist/vibe/errors.js +21 -1
  31. package/dist/vibe/generic.d.ts +2 -49
  32. package/dist/vibe/generic.js +17 -78
  33. package/dist/vibe/index.d.ts +1 -1
  34. package/dist/vibe/index.js +2 -1
  35. package/package.json +17 -2
  36. package/src/api-handlers/account/change-password.ts +31 -6
  37. package/src/auth/better-auth.ts +188 -21
  38. package/src/config/env.ts +0 -2
  39. package/src/index.ts +4 -0
  40. package/src/lib/idp-client-config.ts +8 -1
  41. package/src/lib/never-in-response.ts +64 -0
  42. package/src/lib/nextauth-secret.ts +168 -0
  43. package/src/lib/safe-error-response.ts +6 -6
  44. package/src/lib/two-factor-verify-failure.ts +86 -5
  45. package/src/middleware/create-middleware.ts +42 -0
  46. package/src/pages/login/page.tsx +4 -0
  47. package/src/pages/verify-code/page.tsx +4 -2
  48. package/src/routes/auth/settings.ts +5 -1
  49. package/src/server/auth.ts +23 -29
  50. package/src/vibe/client.ts +378 -91
  51. package/src/vibe/document-unwrap.ts +103 -0
  52. package/src/vibe/errors.ts +24 -0
  53. package/src/vibe/generic.ts +8 -100
  54. package/src/vibe/index.ts +1 -0
@@ -5,6 +5,25 @@ const server_1 = require("next/server");
5
5
  const auth_1 = require("../../server/auth");
6
6
  const session_store_1 = require("../../lib/session-store");
7
7
  const nanoid_1 = require("nanoid");
8
+ /**
9
+ * PAY-1596: the failure code comes from the IdP's REAL envelope { success, error: { code, message, details } } - the handler
10
+ * used to read a top-level `error_code` the IdP never sends, so every refusal was CHANGE_PASSWORD_FAILED and "this account has
11
+ * no password" / "wrong current password" could not be told apart. Allowlisted: nothing but these codes ever leaves here.
12
+ * - error.code NO_PASSWORD_SET / INVALID_CURRENT_PASSWORD (IdP PR 412) / VALIDATION_ERROR
13
+ * - the pre-412 wrong-current shape: VALIDATION_ERROR + details.value[] { code: InvalidCurrentPassword, field_name: currentPassword }
14
+ * - a legacy top-level error_code, for older IdPs
15
+ */
16
+ const CHANGE_PASSWORD_CODES = new Set(['NO_PASSWORD_SET', 'INVALID_CURRENT_PASSWORD', 'VALIDATION_ERROR']);
17
+ function changePasswordFailureCode(body) {
18
+ const err = body && typeof body.error === 'object' && body.error ? body.error : null;
19
+ const code = typeof err?.code === 'string' ? err.code : typeof body?.error_code === 'string' ? body.error_code : '';
20
+ if (code === 'VALIDATION_ERROR' && Array.isArray(err?.details?.value)) {
21
+ const wrongCurrent = err.details.value.some((e) => e && typeof e === 'object' && (String(e.field_name ?? e.fieldName ?? '').toLowerCase() === 'currentpassword' || String(e.code ?? '').toLowerCase() === 'invalidcurrentpassword'));
22
+ if (wrongCurrent)
23
+ return 'INVALID_CURRENT_PASSWORD';
24
+ }
25
+ return CHANGE_PASSWORD_CODES.has(code) ? code : 'CHANGE_PASSWORD_FAILED';
26
+ }
8
27
  const safe_error_response_1 = require("../../lib/safe-error-response");
9
28
  // ...
10
29
  async function POST(req) {
@@ -75,22 +94,25 @@ async function POST(req) {
75
94
  if (!idpResponse.ok) {
76
95
  // Extract error message from IDP response
77
96
  let errorMessage = 'Failed to change password';
97
+ const failureCode = changePasswordFailureCode(responseData);
98
+ // The real envelope nests details under `error`; older IdPs put them at the top level.
99
+ const idpDetails = (responseData.error && typeof responseData.error === 'object' ? responseData.error.details : undefined) ?? responseData.details;
78
100
  if (responseData.message) {
79
101
  errorMessage = responseData.message;
80
102
  }
81
- else if (responseData.details?.value && Array.isArray(responseData.details.value) && responseData.details.value.length > 0) {
82
- errorMessage = responseData.details.value[0].message || errorMessage;
103
+ else if (idpDetails?.value && Array.isArray(idpDetails.value) && idpDetails.value.length > 0) {
104
+ errorMessage = idpDetails.value[0].message || errorMessage;
83
105
  }
84
- else if (responseData.details?.message) {
85
- errorMessage = responseData.details.message;
106
+ else if (idpDetails?.message) {
107
+ errorMessage = idpDetails.message;
86
108
  }
87
109
  return server_1.NextResponse.json({
88
110
  success: false,
89
111
  message: errorMessage,
90
- error_code: responseData.error_code || 'CHANGE_PASSWORD_FAILED',
112
+ error_code: failureCode,
91
113
  request_id: requestId,
92
114
  // PAY-1438: only the password-policy messages the security pages display, never the raw envelope.
93
- details: (0, safe_error_response_1.projectPasswordPolicyDetails)(responseData.details),
115
+ details: (0, safe_error_response_1.projectPasswordPolicyDetails)(idpDetails),
94
116
  }, { status: idpResponse.status });
95
117
  }
96
118
  return server_1.NextResponse.json({
@@ -182,6 +182,9 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
182
182
  refreshCache: false;
183
183
  };
184
184
  };
185
+ account: {
186
+ storeStateStrategy: "cookie";
187
+ };
185
188
  advanced: {
186
189
  cookiePrefix: string;
187
190
  cookies: {
@@ -192,22 +195,25 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
192
195
  };
193
196
  plugins: [{
194
197
  id: "next-cookies";
198
+ version: string;
195
199
  hooks: {
196
200
  before: {
197
201
  matcher(ctx: import("better-auth").HookEndpointContext): boolean;
198
- handler: (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>;
202
+ handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
199
203
  }[];
200
204
  after: {
201
205
  matcher(ctx: import("better-auth").HookEndpointContext): true;
202
- handler: (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>;
206
+ handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
203
207
  }[];
204
208
  };
205
- }, ...(import("better-auth").BetterAuthPlugin | {
209
+ }, ...({
206
210
  id: "magic-link";
211
+ version: string;
207
212
  endpoints: {
208
213
  signInMagicLink: import("better-auth").StrictEndpoint<"/sign-in/magic-link", {
209
214
  method: "POST";
210
215
  requireHeaders: true;
216
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>[];
211
217
  body: import("better-auth").ZodObject<{
212
218
  email: import("better-auth").ZodEmail;
213
219
  name: import("better-auth").ZodOptional<import("better-auth").ZodString>;
@@ -215,7 +221,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
215
221
  newUserCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
216
222
  errorCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
217
223
  metadata: import("better-auth").ZodOptional<import("better-auth").ZodRecord<import("better-auth").ZodString, import("better-auth").ZodAny>>;
218
- }, import("better-auth").$strip>;
224
+ }, import("zod/v4/core").$strip>;
219
225
  metadata: {
220
226
  openapi: {
221
227
  operationId: string;
@@ -249,8 +255,8 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
249
255
  callbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
250
256
  errorCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
251
257
  newUserCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
252
- }, import("better-auth").$strip>;
253
- use: ((inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>)[];
258
+ }, import("zod/v4/core").$strip>;
259
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>[];
254
260
  requireHeaders: true;
255
261
  metadata: {
256
262
  openapi: {
@@ -289,6 +295,16 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
289
295
  name: string;
290
296
  image?: string | null | undefined;
291
297
  };
298
+ session: {
299
+ id: string;
300
+ createdAt: Date;
301
+ updatedAt: Date;
302
+ userId: string;
303
+ expiresAt: Date;
304
+ token: string;
305
+ ipAddress?: string | null | undefined;
306
+ userAgent?: string | null | undefined;
307
+ };
292
308
  }>;
293
309
  };
294
310
  rateLimit: {
@@ -297,8 +313,9 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
297
313
  max: number;
298
314
  }[];
299
315
  options: MagicLinkOptions;
300
- } | {
316
+ } | import("better-auth").BetterAuthPlugin | {
301
317
  id: "generic-oauth";
318
+ version: string;
302
319
  init: (ctx: import("better-auth").AuthContext) => {
303
320
  context: {
304
321
  socialProviders: import("better-auth").OAuthProvider<Record<string, any>, Partial<import("better-auth").ProviderOptions<any>>>[];
@@ -316,7 +333,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
316
333
  scopes: import("better-auth").ZodOptional<import("better-auth").ZodArray<import("better-auth").ZodString>>;
317
334
  requestSignUp: import("better-auth").ZodOptional<import("better-auth").ZodBoolean>;
318
335
  additionalData: import("better-auth").ZodOptional<import("better-auth").ZodRecord<import("better-auth").ZodString, import("better-auth").ZodAny>>;
319
- }, import("better-auth").$strip>;
336
+ }, import("zod/v4/core").$strip>;
320
337
  metadata: {
321
338
  openapi: {
322
339
  description: string;
@@ -354,7 +371,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
354
371
  error_description: import("better-auth").ZodOptional<import("better-auth").ZodString>;
355
372
  state: import("better-auth").ZodOptional<import("better-auth").ZodString>;
356
373
  iss: import("better-auth").ZodOptional<import("better-auth").ZodString>;
357
- }, import("better-auth").$strip>;
374
+ }, import("zod/v4/core").$strip>;
358
375
  metadata: {
359
376
  allowedMediaTypes: string[];
360
377
  openapi: {
@@ -379,7 +396,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
379
396
  };
380
397
  scope: "server";
381
398
  };
382
- }, void>;
399
+ }, never>;
383
400
  oAuth2LinkAccount: import("better-auth").StrictEndpoint<"/oauth2/link", {
384
401
  method: "POST";
385
402
  body: import("better-auth").ZodObject<{
@@ -387,8 +404,8 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
387
404
  callbackURL: import("better-auth").ZodString;
388
405
  scopes: import("better-auth").ZodOptional<import("better-auth").ZodArray<import("better-auth").ZodString>>;
389
406
  errorCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
390
- }, import("better-auth").$strip>;
391
- use: ((inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
407
+ }, import("zod/v4/core").$strip>;
408
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
392
409
  session: {
393
410
  session: Record<string, any> & {
394
411
  id: string;
@@ -410,7 +427,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
410
427
  image?: string | null | undefined;
411
428
  };
412
429
  };
413
- }>)[];
430
+ }>>[];
414
431
  metadata: {
415
432
  openapi: {
416
433
  description: string;
@@ -519,8 +536,14 @@ export declare function getBetterAuthHandler(): Promise<{
519
536
  * header of the response to find the BA Redis key.
520
537
  *
521
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.
522
545
  */
523
- export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean>;
546
+ export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean | string>;
524
547
  /**
525
548
  * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
526
549
  * function never acquires tokens and never decides who the caller is.
@@ -565,12 +588,36 @@ export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider
565
588
  * state what actually authenticated ('passkey', 'google').
566
589
  */
567
590
  export declare function storeIdpTokensInSession(sessionToken: string, idpTokenData: Record<string, unknown>, authProvider: string): Promise<boolean>;
591
+ /**
592
+ * The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
593
+ * store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
594
+ * Try again or use another method." The cause stays in the server log, never on the page.
595
+ */
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>;
608
+ /**
609
+ * Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
610
+ * WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
611
+ * accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
612
+ */
613
+ export declare function expireSetCookies(setCookies: readonly string[]): string[];
568
614
  /**
569
615
  * Create a production-ready GET handler for the auth catch-all route.
570
616
  *
571
617
  * Wraps better-auth's GET handler with:
572
618
  * - OAuth state error recovery (redirects to login instead of error page)
573
- * - IDP token exchange after successful OAuth callback
619
+ * - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
620
+ * (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
574
621
  *
575
622
  * Usage in host app:
576
623
  * ```ts
@@ -43,6 +43,7 @@ var __importStar = (this && this.__importStar) || (function () {
43
43
  };
44
44
  })();
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.IDP_ERROR_CODES_TO_FORWARD = exports.OAUTH_SIGN_IN_FAILED_ERROR = void 0;
46
47
  exports.secretFingerprint = secretFingerprint;
47
48
  exports.buildIdpOAuthProviderConfig = buildIdpOAuthProviderConfig;
48
49
  exports.buildBetterAuthProviders = buildBetterAuthProviders;
@@ -54,6 +55,7 @@ exports.getBetterAuthInstance = getBetterAuthInstance;
54
55
  exports.getBetterAuthHandler = getBetterAuthHandler;
55
56
  exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
56
57
  exports.storeIdpTokensInSession = storeIdpTokensInSession;
58
+ exports.expireSetCookies = expireSetCookies;
57
59
  exports.createAuthGetHandler = createAuthGetHandler;
58
60
  require("server-only");
59
61
  const node_async_hooks_1 = require("node:async_hooks");
@@ -284,19 +286,42 @@ function buildBetterAuthProviders(config) {
284
286
  clientSecret: oauth.clientSecret,
285
287
  scope: wireScopes.length > 0 ? wireScopes : undefined,
286
288
  /**
287
- * 260419 — CLOSE THE SECOND SELF-SERVE PROVISIONING DOOR. 259805 gated
288
- * the signup BFF on allow_public_registration, but the social lane never
289
- * touches that code: better-auth creates the user through its own
290
- * 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.
291
290
  *
292
- * Wired from the tenant's own policy, explicit-true-opens: the IDP config
293
- * keeps allowPublicRegistration UNDEFINED when the tenant never answered
294
- * (idp-client-config.ts, the no-fabricated-default rule), and an
295
- * unanswered policy question must not provision accounts. That matches
296
- * the FE's silent-default-is-decline posture for the "Create account"
297
- * 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.
298
324
  */
299
- disableSignUp: config.authSettings?.allowPublicRegistration !== true,
300
325
  // Google is overly eager to reuse the last account unless we
301
326
  // explicitly ask for account selection on each social login.
302
327
  prompt: typeof rawPrompt === 'string'
@@ -748,6 +773,18 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
748
773
  refreshCache: false,
749
774
  },
750
775
  },
776
+ // PAY-1781 B: KEEP OAUTH STATE IN THE ENCRYPTED COOKIE, as it was on better-auth 1.5.6.
777
+ // 1.5.6 resolved the default as `options.database ? 'database' : 'cookie'` - we have no
778
+ // database, so: cookie. 1.6.13+ resolves it as `(database || secondaryStorage) ? 'database'
779
+ // : 'cookie'`, and we DO have secondaryStorage, so the upgrade alone would silently move every
780
+ // OAuth state record into Redis through the adapter above (its enrichment read-modify-write
781
+ // and key handling were built for session records, not state). Pinned so the upgrade changes
782
+ // the version, not the design. The cookie branch is where GHSA-wxw3-q3m9-c3jr lived; 1.6.2+
783
+ // compares the returned `state` with the nonce sealed in the cookie, and our provider is also
784
+ // pkce:true, which the advisory required to be false. Test: oauth-state-strategy-cookie.
785
+ account: {
786
+ storeStateStrategy: 'cookie',
787
+ },
751
788
  // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
752
789
  advanced: {
753
790
  cookiePrefix: appSlug,
@@ -860,6 +897,12 @@ async function getBetterAuthHandler() {
860
897
  * header of the response to find the BA Redis key.
861
898
  *
862
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.
863
906
  */
864
907
  async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
865
908
  try {
@@ -945,6 +988,36 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
945
988
  console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
946
989
  if (!oauthRes.ok) {
947
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
+ }
948
1021
  /**
949
1022
  * WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
950
1023
  *
@@ -993,7 +1066,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
993
1066
  sent_client_id: (0, client_key_1.resolveClientKey)(config, 'auth/better-auth:oauth-callback').key ?? null,
994
1067
  idp_url: idpUrl,
995
1068
  });
996
- return false;
1069
+ return idpErrorCode ?? false;
997
1070
  }
998
1071
  let idpData;
999
1072
  try {
@@ -1113,6 +1186,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
1113
1186
  const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
1114
1187
  if (stored)
1115
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).
1116
1190
  return stored;
1117
1191
  }
1118
1192
  catch (err) {
@@ -1202,12 +1276,87 @@ async function storeIdpTokensInSession(sessionToken, idpTokenData, authProvider)
1202
1276
  return false;
1203
1277
  }
1204
1278
  }
1279
+ /**
1280
+ * The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
1281
+ * store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
1282
+ * Try again or use another method." The cause stays in the server log, never on the page.
1283
+ */
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']);
1296
+ /**
1297
+ * Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
1298
+ * WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
1299
+ * accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
1300
+ */
1301
+ function expireSetCookies(setCookies) {
1302
+ const out = [];
1303
+ for (const sc of setCookies) {
1304
+ const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
1305
+ const eq = pair.indexOf('=');
1306
+ const name = eq > 0 ? pair.slice(0, eq) : '';
1307
+ if (!name)
1308
+ continue;
1309
+ const kept = attrs.filter((a) => /^(path|domain|secure|samesite|httponly)\b/i.test(a));
1310
+ out.push([`${name}=`, ...kept, 'Max-Age=0'].join('; '));
1311
+ }
1312
+ return out;
1313
+ }
1314
+ /**
1315
+ * PAY-1733 (Jon, live on :4797): ABORT an OAuth sign-in whose IdP exchange did not store tokens.
1316
+ *
1317
+ * Before this, a failed exchange was a console.error and the user kept the Better Auth session the callback had
1318
+ * just created: signed in, holding NO IdP tokens. The host then routed that session to 2FA, which painted the
1319
+ * user's email and failed every call - a red "sign in again" screen about an account the app could no longer
1320
+ * authenticate. The IdP's 401 was CORRECT (it fail-closes unverified assertions); keeping the session was the bug.
1321
+ *
1322
+ * So: no session without IdP tokens. The Better Auth session is destroyed (its own signOut, best effort, then the
1323
+ * `ba:<slug>:<token>` record directly - that record is what makes it resolvable), every cookie the callback set is
1324
+ * expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
1325
+ * branch above (328439: an absolute URL built from request.url is a dead tab in a container).
1326
+ */
1327
+ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, loginPath, errorCode = exports.OAUTH_SIGN_IN_FAILED_ERROR) {
1328
+ const headers = new Headers();
1329
+ if (sessionToken) {
1330
+ try {
1331
+ const auth = await getBetterAuthInstance();
1332
+ const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
1333
+ for (const sc of out?.headers?.getSetCookie?.() ?? [])
1334
+ headers.append('set-cookie', sc);
1335
+ }
1336
+ catch (err) {
1337
+ console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
1338
+ }
1339
+ try {
1340
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
1341
+ await (0, redis_1.getRedis)().del(`ba:${config.clientSlug || (0, app_slug_1.getAppSlug)()}:${sessionToken}`);
1342
+ }
1343
+ catch (err) {
1344
+ console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', (0, token_log_id_1.tokenLogId)(sessionToken), err instanceof Error ? err.message : String(err));
1345
+ }
1346
+ }
1347
+ for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? []))
1348
+ headers.append('set-cookie', sc);
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);
1351
+ return new Response(null, { status: 302, headers });
1352
+ }
1205
1353
  /**
1206
1354
  * Create a production-ready GET handler for the auth catch-all route.
1207
1355
  *
1208
1356
  * Wraps better-auth's GET handler with:
1209
1357
  * - OAuth state error recovery (redirects to login instead of error page)
1210
- * - IDP token exchange after successful OAuth callback
1358
+ * - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
1359
+ * (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
1211
1360
  *
1212
1361
  * Usage in host app:
1213
1362
  * ```ts
@@ -1276,22 +1425,29 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1276
1425
  return new Response(null, { status: 302, headers: { location: loginPath } });
1277
1426
  }
1278
1427
  }
1279
- // After successful OAuth callback: exchange Google identity for IDP tokens
1428
+ // After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
1429
+ // IdP refused (401), no session was found, or anything threw - the sign-in is ABORTED (PAY-1733). A session is
1430
+ // kept only when it holds IdP tokens.
1280
1431
  const url = new URL(request.url);
1281
1432
  if (url.pathname.includes('/callback/') && response.status === 302) {
1433
+ const setCookies = response.headers.getSetCookie?.() || [];
1434
+ const cookieHeader = setCookies
1435
+ .map((c) => c.split(';')[0])
1436
+ .join('; ');
1437
+ let sessionToken = null;
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;
1282
1441
  try {
1283
1442
  const auth = await getBetterAuthInstance();
1284
1443
  if (auth?.api?.getSession) {
1285
- const setCookies = response.headers.getSetCookie?.() || [];
1286
- const cookieHeader = setCookies
1287
- .map((c) => c.split(';')[0])
1288
- .join('; ');
1289
1444
  const headers = new Headers();
1290
1445
  headers.set('cookie', cookieHeader);
1291
1446
  const session = await auth.api.getSession({ headers });
1292
1447
  if (session?.session?.token) {
1448
+ sessionToken = session.session.token;
1293
1449
  console.log('[BETTER_AUTH] Got session token from callback:', (0, token_log_id_1.tokenLogId)(session.session.token), '| email:', session.user?.email);
1294
- await exchangeOAuthForIdpTokens(session.session.token);
1450
+ exchange = await exchangeOAuthForIdpTokens(session.session.token);
1295
1451
  }
1296
1452
  else {
1297
1453
  console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
@@ -1301,6 +1457,11 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1301
1457
  catch (err) {
1302
1458
  console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
1303
1459
  }
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
+ }
1304
1465
  }
1305
1466
  return response;
1306
1467
  });