@payez/next-mvp 4.1.21 → 4.4.1

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 (178) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/client/better-auth-client.d.ts +62 -62
  31. package/dist/components/account/UserAvatarMenu.js +12 -0
  32. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  33. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  34. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  35. package/dist/hooks/useAuthSettings.js +14 -2
  36. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  37. package/dist/hooks/usePublicAuthSettings.js +28 -14
  38. package/dist/lib/client-key.d.ts +61 -0
  39. package/dist/lib/client-key.js +72 -0
  40. package/dist/lib/demo-mode.js +2 -1
  41. package/dist/lib/ensure-fresh-access-token.js +5 -1
  42. package/dist/lib/idp-client-config.d.ts +25 -2
  43. package/dist/lib/idp-client-config.js +381 -36
  44. package/dist/lib/idp-fetch.d.ts +2 -0
  45. package/dist/lib/idp-fetch.js +31 -1
  46. package/dist/lib/idp-url-guard.d.ts +29 -0
  47. package/dist/lib/idp-url-guard.js +38 -0
  48. package/dist/lib/redis.js +77 -5
  49. package/dist/lib/session-cookie-shape.d.ts +69 -0
  50. package/dist/lib/session-cookie-shape.js +126 -0
  51. package/dist/lib/session-store.d.ts +35 -2
  52. package/dist/lib/session-store.js +135 -4
  53. package/dist/lib/single-flight.d.ts +35 -0
  54. package/dist/lib/single-flight.js +30 -0
  55. package/dist/lib/token-lifecycle.js +12 -0
  56. package/dist/middleware/create-middleware.js +12 -0
  57. package/dist/models/SessionModel.d.ts +41 -0
  58. package/dist/models/SessionModel.js +9 -0
  59. package/dist/pages/admin-login/page.d.ts +1 -1
  60. package/dist/pages/admin-login/page.js +5 -41
  61. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  62. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  63. package/dist/pages/security/index.d.ts +1 -0
  64. package/dist/pages/security/index.js +3 -0
  65. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  66. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  67. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  68. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  69. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  70. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  71. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  72. package/dist/pages/test-env/TestEnvPage.js +16 -4
  73. package/dist/pages/test-env/guarded.d.ts +4 -0
  74. package/dist/pages/test-env/guarded.js +86 -0
  75. package/dist/pages/test-env/index.d.ts +50 -7
  76. package/dist/pages/test-env/index.js +58 -12
  77. package/dist/routes/auth/logout.js +3 -2
  78. package/dist/routes/auth/refresh.js +27 -1
  79. package/dist/routes/auth/session.d.ts +12 -3
  80. package/dist/routes/auth/session.js +92 -9
  81. package/dist/routes/auth/settings.d.ts +34 -4
  82. package/dist/routes/auth/settings.js +87 -33
  83. package/dist/routes/auth/viability.d.ts +1 -1
  84. package/dist/routes/auth/viability.js +122 -11
  85. package/dist/server/auth-guard.js +22 -15
  86. package/dist/server/auth.d.ts +177 -20
  87. package/dist/server/auth.js +34 -75
  88. package/dist/server/decode-session.js +84 -10
  89. package/dist/types/auth.d.ts +18 -0
  90. package/package.json +969 -958
  91. package/scripts/check-internal-url-usage.sh +73 -73
  92. package/scripts/check-publish-health.mjs +162 -0
  93. package/scripts/dev-broker.ps1 +71 -6
  94. package/scripts/print-client-config.mjs +162 -0
  95. package/scripts/server-only-stub.cjs +4 -0
  96. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  97. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  98. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  99. package/src/api-handlers/account/reset-password.ts +35 -2
  100. package/src/api-handlers/account/validate-password.ts +22 -3
  101. package/src/api-handlers/admin/analytics.ts +2 -1
  102. package/src/api-handlers/admin/audit.ts +2 -1
  103. package/src/api-handlers/admin/sessions.ts +2 -1
  104. package/src/api-handlers/admin/stats.ts +2 -1
  105. package/src/api-handlers/admin/users.ts +2 -1
  106. package/src/api-handlers/admin/vibe-data.ts +2 -1
  107. package/src/api-handlers/auth/jwks.ts +10 -0
  108. package/src/api-handlers/auth/login.ts +5 -2
  109. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  110. package/src/api-handlers/auth/refresh.ts +112 -15
  111. package/src/api-handlers/auth/signout.ts +4 -3
  112. package/src/api-handlers/auth/verify-code.ts +5 -2
  113. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  114. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  115. package/src/api-handlers/mfa/methods.ts +46 -46
  116. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  117. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  118. package/src/api-handlers/passkeys/list.ts +31 -31
  119. package/src/api-handlers/passkeys/manage.ts +54 -54
  120. package/src/api-handlers/passkeys/register-options.ts +42 -42
  121. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  122. package/src/api-handlers/session/viability.ts +29 -1
  123. package/src/api-handlers/test/force-expire.ts +118 -13
  124. package/src/auth/better-auth.ts +486 -51
  125. package/src/auth/utils/idp-client.ts +1 -1
  126. package/src/client/AuthContext.tsx +55 -19
  127. package/src/components/account/UserAvatarMenu.tsx +11 -0
  128. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  129. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  130. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  131. package/src/hooks/useAuthSettings.ts +14 -2
  132. package/src/hooks/usePublicAuthSettings.ts +38 -16
  133. package/src/lib/client-key.ts +88 -0
  134. package/src/lib/demo-mode.ts +2 -1
  135. package/src/lib/ensure-fresh-access-token.ts +5 -1
  136. package/src/lib/idp-client-config.ts +399 -36
  137. package/src/lib/idp-fetch.ts +36 -1
  138. package/src/lib/idp-url-guard.ts +37 -0
  139. package/src/lib/redis.ts +84 -6
  140. package/src/lib/session-cookie-shape.ts +184 -0
  141. package/src/lib/session-store.ts +152 -6
  142. package/src/lib/single-flight.ts +64 -0
  143. package/src/lib/token-lifecycle.ts +12 -0
  144. package/src/middleware/create-middleware.ts +11 -0
  145. package/src/models/SessionModel.ts +49 -0
  146. package/src/pages/admin-login/page.tsx +17 -127
  147. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  148. package/src/pages/security/index.ts +3 -0
  149. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  150. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  151. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  152. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  153. package/src/pages/test-env/guarded.tsx +81 -0
  154. package/src/pages/test-env/index.ts +59 -7
  155. package/src/routes/auth/logout.ts +4 -3
  156. package/src/routes/auth/passkey-signin.ts +13 -13
  157. package/src/routes/auth/refresh.ts +30 -1
  158. package/src/routes/auth/session.ts +98 -9
  159. package/src/routes/auth/settings.ts +144 -42
  160. package/src/routes/auth/viability.ts +129 -11
  161. package/src/routes/mfa/backup-code-verify.ts +1 -1
  162. package/src/routes/mfa/backup-codes.ts +10 -10
  163. package/src/routes/mfa/methods.ts +22 -22
  164. package/src/routes/mfa/totp-confirm.ts +10 -10
  165. package/src/routes/mfa/totp-enroll.ts +10 -10
  166. package/src/routes/mfa/totp-verify.ts +14 -14
  167. package/src/routes/passkeys/list.ts +10 -10
  168. package/src/routes/passkeys/manage.ts +23 -23
  169. package/src/routes/passkeys/register-options.ts +10 -10
  170. package/src/routes/passkeys/register-verify.ts +10 -10
  171. package/src/server/auth-guard.ts +24 -17
  172. package/src/server/auth.ts +37 -73
  173. package/src/server/decode-session.ts +95 -11
  174. package/src/templates/instrumentation.ts +21 -2
  175. package/src/types/auth.ts +20 -0
  176. package/dist/lib/auth-secret.d.ts +0 -17
  177. package/dist/lib/auth-secret.js +0 -116
  178. package/src/lib/auth-secret.ts +0 -137
@@ -43,10 +43,12 @@ var __importStar = (this && this.__importStar) || (function () {
43
43
  };
44
44
  })();
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
- exports.__betterAuthInstance = void 0;
46
+ exports.secretFingerprint = secretFingerprint;
47
+ exports.buildIdpOAuthProviderConfig = buildIdpOAuthProviderConfig;
47
48
  exports.buildBetterAuthProviders = buildBetterAuthProviders;
48
49
  exports.createBetterAuthInstance = createBetterAuthInstance;
49
50
  exports.isBetterAuthEnabled = isBetterAuthEnabled;
51
+ exports.__getBetterAuthInstance = __getBetterAuthInstance;
50
52
  exports.configureBetterAuth = configureBetterAuth;
51
53
  exports.getBetterAuthInstance = getBetterAuthInstance;
52
54
  exports.getBetterAuthHandler = getBetterAuthHandler;
@@ -55,15 +57,26 @@ exports.storeIdpTokensInSession = storeIdpTokensInSession;
55
57
  exports.createAuthGetHandler = createAuthGetHandler;
56
58
  require("server-only");
57
59
  const node_async_hooks_1 = require("node:async_hooks");
60
+ const crypto_1 = require("crypto");
58
61
  const jose_1 = require("jose"); // better-auth's own JWT dependency
59
62
  const better_auth_1 = require("better-auth");
60
63
  const next_js_1 = require("better-auth/next-js");
61
64
  const next_js_2 = require("better-auth/next-js");
62
65
  const magic_link_1 = require("better-auth/plugins/magic-link");
66
+ const client_key_1 = require("../lib/client-key");
67
+ const generic_oauth_1 = require("better-auth/plugins/generic-oauth");
63
68
  const idp_client_config_1 = require("../lib/idp-client-config");
64
69
  const app_slug_1 = require("../lib/app-slug");
65
70
  const redis_1 = require("../lib/redis");
66
71
  const session_store_1 = require("../lib/session-store");
72
+ /**
73
+ * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
74
+ * to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
75
+ * = 48 bits; collision-tellable across a deployment, useless as a credential.
76
+ */
77
+ function secretFingerprint(value) {
78
+ return (0, crypto_1.createHash)('sha256').update(value).digest('hex').slice(0, 12);
79
+ }
67
80
  /**
68
81
  * Build Better Auth social providers from IDP config.
69
82
  */
@@ -90,7 +103,107 @@ const session_store_1 = require("../lib/session-store");
90
103
  * function. Outside that scope the store is undefined and the body simply omits
91
104
  * id_token, which is the correct additive behaviour.
92
105
  */
93
- const idTokenScope = new node_async_hooks_1.AsyncLocalStorage();
106
+ /**
107
+ * ON globalThis FOR THE SAME REASON THE BETTER AUTH INSTANCE IS (181814, see STATE_KEY
108
+ * below) — and it was NOT, which is the whole defect.
109
+ *
110
+ * This module is loaded more than once per process: instrumentation and the auth route
111
+ * are different chunks with their own module registries. `state` was moved to globalThis
112
+ * so both copies share the Better Auth INSTANCE. This AsyncLocalStorage was left in
113
+ * module scope, so each copy got its own.
114
+ *
115
+ * The result, measured on UMI 2026-08-26 against idp.payez.net:
116
+ *
117
+ * copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
118
+ * copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
119
+ * getUserInfo fires, reads A -> getStore() is undefined, `if (scope)` skips
120
+ * the exchange reads B -> empty, so id_token is omitted from the body
121
+ *
122
+ * Sign-in SUCCEEDS at every visible step. The user gets a session, an avatar, an email.
123
+ * The IDP then answers 401 UNVERIFIED_ASSERTION with reason="id_token missing"
124
+ * (confirmed in its own logs, correlationId 0HNO2755RESR1) — so the client half of 138635
125
+ * has been shipping as a no-op wherever the module double-loads, and the security fix it
126
+ * exists to deliver never arrived. A shared instance plus a per-copy scope is the worst
127
+ * combination: everything looks wired, and the one field that proves identity is dropped.
128
+ *
129
+ * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
130
+ * carrier needs to be reachable from every copy that might run inside the request.
131
+ */
132
+ const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
133
+ const gScope = globalThis;
134
+ gScope[ID_TOKEN_SCOPE_KEY] ??= new node_async_hooks_1.AsyncLocalStorage();
135
+ const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY];
136
+ /**
137
+ * EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
138
+ * provider credentials in the app.
139
+ *
140
+ * ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
141
+ * Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
142
+ * is uniform with the ratified desktop leg. The IDP holds the provider secret
143
+ * and performs the provider exchange server-side.
144
+ *
145
+ * ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
146
+ * QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
147
+ * clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
148
+ * on whether we need a secret. This builds the generic/custom shape
149
+ * deliberately; reaching for a preset because it looks tidier would silently
150
+ * kill keyless-on-web and nobody would connect the two changes.
151
+ *
152
+ * ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
153
+ * Returns null unless the IDP's OAuth endpoints are explicitly configured. That
154
+ * makes this ADDITIVE: the existing socialProviders path keeps working until an
155
+ * operator turns this on. It is NOT a runtime failover - nothing silently
156
+ * chooses between them, because a silent chooser is how a migration ends up
157
+ * half-applied and undetectable.
158
+ *
159
+ * ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
160
+ * the app stops talking to Google, so it can no longer capture the raw
161
+ * id_token that oauth-callback verifies today, and the per-provider settings
162
+ * below stop being ours to set - profile scope, prompt=select_account,
163
+ * accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
164
+ * exchange must verify signature/iss/aud/exp itself and preserve those four, or
165
+ * they are lost silently.
166
+ */
167
+ function buildIdpOAuthProviderConfig() {
168
+ const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
169
+ const clientId = process.env.CLIENT_ID;
170
+ if (!discoveryUrl || !clientId)
171
+ return null;
172
+ return {
173
+ providerId: 'payez-idp',
174
+ discoveryUrl,
175
+ clientId,
176
+ // NO clientSecret. The whole point: this app is not a credential holder.
177
+ pkce: true,
178
+ scopes: ['openid', 'email', 'profile'],
179
+ /**
180
+ * ⚠ DO NOT ADD `authentication: 'basic'` HERE. IT SILENTLY BREAKS KEYLESS.
181
+ *
182
+ * Found by QAPert-NightHawk (21348) — the second config trap in this plugin
183
+ * after the preset one, and the same shape: a field that looks like a
184
+ * harmless transport detail and quietly changes whether we are a public
185
+ * client at all.
186
+ *
187
+ * @better-auth/core validate-authorization-code.mjs:
188
+ * if (authentication === "basic") {
189
+ * headers.authorization = `Basic ${base64(`${clientId}:${clientSecret ?? ""}`)}`;
190
+ * } else {
191
+ * body.set("client_id", clientId);
192
+ * if (options.clientSecret) body.set("client_secret", options.clientSecret);
193
+ * }
194
+ *
195
+ * Note what the 'basic' branch does with NO secret: it sends an EMPTY
196
+ * password, `Basic base64("<clientId>:")`. That is not "no authentication",
197
+ * it is WRONG authentication — we would be presenting ourselves as a
198
+ * confidential client with a blank credential. The default branch is the
199
+ * one that produces a genuine public-client PKCE request.
200
+ *
201
+ * Leaving this field UNSET is load-bearing, which is exactly why it needs a
202
+ * comment: nothing about an absent field advertises that its absence is a
203
+ * decision.
204
+ */
205
+ };
206
+ }
94
207
  function buildBetterAuthProviders(config) {
95
208
  const providers = {};
96
209
  for (const oauth of config.oauthProviders || []) {
@@ -106,10 +219,38 @@ function buildBetterAuthProviders(config) {
106
219
  if (name === 'google' && !scopes.includes('profile')) {
107
220
  scopes.push('profile');
108
221
  }
222
+ /**
223
+ * 224721: better-auth's google provider UNCONDITIONALLY seeds its default
224
+ * scopes ['email','profile','openid'] and then pushes our `scope` verbatim
225
+ * (@better-auth/core social-providers/google.mjs createAuthorizationURL) —
226
+ * the row's "openid email profile" reached Google as
227
+ * "email profile openid openid email profile". Semantically harmless
228
+ * (Google dedupes) but sloppy on a URL a demo audience can read. Hand over
229
+ * only what the defaults do not already cover; undefined when nothing
230
+ * remains, so the provider emits exactly its default set.
231
+ */
232
+ const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
233
+ const wireScopes = name === 'google'
234
+ ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
235
+ : scopes;
109
236
  providers[name] = {
110
237
  clientId: oauth.clientId,
111
238
  clientSecret: oauth.clientSecret,
112
- scope: scopes.length > 0 ? scopes : undefined,
239
+ scope: wireScopes.length > 0 ? wireScopes : undefined,
240
+ /**
241
+ * 260419 — CLOSE THE SECOND SELF-SERVE PROVISIONING DOOR. 259805 gated
242
+ * the signup BFF on allow_public_registration, but the social lane never
243
+ * touches that code: better-auth creates the user through its own
244
+ * internalAdapter at the OAuth callback, and its ONLY guard is this flag.
245
+ *
246
+ * Wired from the tenant's own policy, explicit-true-opens: the IDP config
247
+ * keeps allowPublicRegistration UNDEFINED when the tenant never answered
248
+ * (idp-client-config.ts, the no-fabricated-default rule), and an
249
+ * unanswered policy question must not provision accounts. That matches
250
+ * the FE's silent-default-is-decline posture for the "Create account"
251
+ * affordance. A tenant that wants Google self-serve says so.
252
+ */
253
+ disableSignUp: config.authSettings?.allowPublicRegistration !== true,
113
254
  // Google is overly eager to reuse the last account unless we
114
255
  // explicitly ask for account selection on each social login.
115
256
  prompt: typeof rawPrompt === 'string'
@@ -161,8 +302,21 @@ function buildBetterAuthProviders(config) {
161
302
  return null;
162
303
  // The only new behaviour: hand the raw token to this request's scope.
163
304
  const scope = idTokenScope.getStore();
164
- if (scope)
305
+ if (scope) {
165
306
  scope.idToken = token.idToken;
307
+ }
308
+ else {
309
+ // NOT a silent skip. A missing scope here means 138635 degrades to a no-op:
310
+ // the login still succeeds, and the IDP rejects the exchange with
311
+ // reason="id_token missing" a second later, with nothing connecting the two.
312
+ // That cost a day on UMI. If this fires, the OAuth callback is running outside
313
+ // createAuthGetHandler — either the route mounts the bare
314
+ // `routes/auth/nextauth` re-export instead of createAuthGetHandler, or this
315
+ // module has double-loaded and the scope is not shared (see 181814 above).
316
+ console.warn('[BETTER_AUTH] id_token captured but NO REQUEST SCOPE to put it in — it will ' +
317
+ 'NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
318
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.');
319
+ }
166
320
  // Mirrors the upstream default (google.mjs) verbatim, minus the hd
167
321
  // branch that cannot fire here — so overriding the hook changes WHAT WE
168
322
  // KEEP, never what we ACCEPT.
@@ -190,12 +344,21 @@ function buildBetterAuthProviders(config) {
190
344
  */
191
345
  function createBetterAuthInstance(idpConfig, opts = {}) {
192
346
  const appSlug = idpConfig.clientSlug || (0, app_slug_1.getAppSlug)();
347
+ // 185121 line 1: name the secret this instance PINNED, as a hash, never the
348
+ // secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
349
+ // caused by a divergent secret is only diagnosable if every instance
350
+ // creation logged WHICH secret it bound. Both init paths (this module's
351
+ // getBetterAuthInstance and server/auth.ts getAuthInstance) funnel through
352
+ // here, so this one line covers both; getAuthInstance adds its own line
353
+ // naming the path, because it previously created an instance INVISIBLY.
354
+ console.log(`[BETTER_AUTH] createBetterAuthInstance: slug=${appSlug} authSecret ${idpConfig.authSecret ? `sha256:${secretFingerprint(idpConfig.authSecret)}` : 'ABSENT'}`);
193
355
  // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
194
356
  // Must include /api/auth since that's where the catch-all route is mounted
195
357
  const rawBaseURL = process.env.BETTER_AUTH_URL
196
358
  || idpConfig.baseClientUrl
197
359
  || `http://localhost:${process.env.PORT || '3000'}`;
198
360
  const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
361
+ const idpOAuth = buildIdpOAuthProviderConfig();
199
362
  return (0, better_auth_1.betterAuth)({
200
363
  baseURL,
201
364
  secret: idpConfig.authSecret,
@@ -205,37 +368,133 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
205
368
  rawBaseURL,
206
369
  baseURL,
207
370
  ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
208
- 'http://localhost:3000',
209
- 'http://localhost:3400',
210
- 'http://localhost:3600',
371
+ // MEASURED 2026-08-26: this list ended with the literals localhost:3000, :3400
372
+ // and :3600. payez_idp_admin_web runs on :3200 - correctly configured, with
373
+ // BETTER_AUTH_URL=http://localhost:3200 - and every GET /api/auth/get-session
374
+ // returned 400, because the port it actually runs on was not one of the three
375
+ // someone had typed in. A config-shaped constant is not configuration.
376
+ //
377
+ // The app's own origin is already covered by rawBaseURL/baseURL above; the dev
378
+ // origin is DERIVED from PORT, and anything genuinely external comes from
379
+ // BETTER_AUTH_TRUSTED_ORIGINS (comma-separated).
380
+ `http://localhost:${process.env.PORT || '3000'}`,
381
+ ...(process.env.BETTER_AUTH_TRUSTED_ORIGINS
382
+ ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
383
+ : []),
211
384
  ],
212
385
  // Redis-backed session storage via secondaryStorage
213
386
  secondaryStorage: {
387
+ /**
388
+ * 170033 (QAPert 22654, load-bearing precondition for 183483): DO NOT
389
+ * swallow a Redis failure into `null` here.
390
+ *
391
+ * `null` from `get` is Better Auth's OWN signal for "no session at this
392
+ * key" (see node_modules/better-auth db/internal-adapter.mjs
393
+ * `findSession`: `if (!sessionStringified ...) return null`). Catching
394
+ * a Redis error and returning `null` therefore told better-auth "this
395
+ * session does not exist" when the true state was "the store could not
396
+ * be asked" — indistinguishable from a genuine sign-out. With
397
+ * `cookieCache` disabled (session.cookieCache.enabled above), EVERY
398
+ * `getSession()` call hits this path for real, so a Redis blip
399
+ * manufactured a `200 { session: null }` for every live session at
400
+ * once — the exact channel 183483's client-side guard (verify-code
401
+ * page's session-liveness check) was built to survive, and the one it
402
+ * could not survive while this swallow stood: a fabricated "no
403
+ * session" 200 is not distinguishable from a real one by any client.
404
+ *
405
+ * Letting the error propagate is safe: better-auth's own router
406
+ * (better-call/dist/router.mjs) wraps every endpoint handler in a
407
+ * top-level try/catch and converts an uncaught throw to a genuine
408
+ * `500`, not a crash. A `500` IS the "inconclusive" signal 183483's
409
+ * corrected client guard already knows how to hold a session through —
410
+ * so a Redis outage now surfaces as "couldn't check" instead of
411
+ * silently lying "confirmed logged out".
412
+ */
214
413
  get: async (key) => {
215
- try {
216
- return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
217
- }
218
- catch {
219
- return null;
414
+ const value = await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
415
+ // 185121 line 2: the 183047 discriminator. A null here means
416
+ // RECORD-MISS — the store was reachable and the key is absent. If the
417
+ // record IS present but BA still rejects the session, this line does
418
+ // NOT fire and the failure is upstream of storage (HMAC/secret gate).
419
+ // BA-primary logs nothing on failure, so without this line the two
420
+ // surviving forks are indistinguishable in the artifact. Key is
421
+ // fingerprinted: it embeds the session token.
422
+ if (value === null) {
423
+ console.warn(`[BETTER_AUTH] secondaryStorage.get MISS ba:${appSlug}:sha256:${secretFingerprint(key)} — record absent, store reachable`);
220
424
  }
425
+ return value;
221
426
  },
427
+ /**
428
+ * 170033 fix 1 — DO NOT SWALLOW A FAILED WRITE. A SESSION THAT WAS NOT
429
+ * PERSISTED IS NOT A SESSION.
430
+ *
431
+ * This previously caught every Redis error and returned normally, so
432
+ * better-auth was told the session had been stored when nothing had been
433
+ * written. The user got a successful login, a session cookie, and a
434
+ * rendered signed-in shell on top of a store that holds no session — and
435
+ * the very next `getSession()` reads `null` and signs them straight back
436
+ * out. Nothing logged, nothing thrown, no banner: a login that "worked"
437
+ * and a sign-out nobody can explain.
438
+ *
439
+ * Letting it propagate is the same contract `get` above already relies on:
440
+ * better-auth's router (better-call/dist/router.mjs) wraps every endpoint
441
+ * handler and converts an uncaught throw into a genuine 500. A 500 on the
442
+ * login write is honest — "we could not store your session" — and it is
443
+ * distinguishable by any client from a successful login, which a swallowed
444
+ * failure is not.
445
+ *
446
+ * THE COMMENT THIS REPLACES SAID "Redis unavailable — cookie cache still
447
+ * works". It does not: `session.cookieCache.enabled` is FALSE (see below).
448
+ * The comment described a safety net that had been removed, which is why
449
+ * this swallow looked survivable to every reader who trusted it (170033
450
+ * fix 3).
451
+ */
222
452
  set: async (key, value, ttl) => {
223
- try {
224
- const redis = (0, redis_1.getRedis)();
225
- if (ttl) {
226
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
227
- }
228
- else {
229
- await redis.setex(`ba:${appSlug}:${key}`, session_store_1.SESSION_TTL, value);
230
- }
231
- }
232
- catch { /* Redis unavailable — cookie cache still works */ }
453
+ const redis = (0, redis_1.getRedis)();
454
+ await redis.setex(`ba:${appSlug}:${key}`, ttl ?? session_store_1.SESSION_TTL, value);
233
455
  },
234
456
  delete: async (key) => {
235
457
  try {
236
458
  await (0, redis_1.getRedis)().del(`ba:${appSlug}:${key}`);
237
459
  }
238
- catch { /* ignore */ }
460
+ catch (err) {
461
+ /**
462
+ * 170033 fix 2 — a failed delete must be VISIBLE, not `/* ignore *\/`.
463
+ *
464
+ * Deliberately logged rather than thrown, and the distinction is not
465
+ * timidity: the CANONICAL store is what decides session liveness (see
466
+ * 181922 below), and its delete runs immediately after this one and is
467
+ * NOT swallowed. So a BA record that outlives its canonical twin does
468
+ * not authenticate anyone — throwing here would fail a sign-out that
469
+ * actually succeeded where it counts.
470
+ *
471
+ * What was unacceptable is that it happened SILENTLY. A revocation
472
+ * that half-failed left no trace anywhere, so the leftover key could
473
+ * only ever be found by someone already reading the keyspace for
474
+ * another reason. `error`, not `warn`: this is a revocation that did
475
+ * not complete, not a retryable blip.
476
+ */
477
+ console.error(`[BETTER_AUTH] secondary delete FAILED for ba:${appSlug}:${key} — ` +
478
+ `the better-auth session record may still exist. Canonical delete ` +
479
+ `below is what governs liveness; if it also failed, the session is ` +
480
+ `NOT revoked:`, err);
481
+ }
482
+ /**
483
+ * 181922 — teardown is BOTH stores. BA's sign-out deletes only its
484
+ * secondary record; the canonical {slug}:sess:{token} would survive
485
+ * and keep the session alive (a replayed cookie still authenticates).
486
+ * `key` here IS the session token (the ba: prefix is applied above),
487
+ * so the canonical delete is one call. This also fires on session
488
+ * rotation/expiry deletions, which is correct: a session BA drops
489
+ * must not live on in the canonical store.
490
+ */
491
+ try {
492
+ const { deleteSession } = await Promise.resolve().then(() => __importStar(require('../lib/session-store')));
493
+ await deleteSession(key);
494
+ }
495
+ catch (err) {
496
+ console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
497
+ }
239
498
  },
240
499
  },
241
500
  session: {
@@ -273,6 +532,9 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
273
532
  },
274
533
  plugins: [
275
534
  (0, next_js_1.nextCookies)(),
535
+ // Exchange-move: present ONLY when the IDP's OAuth endpoints are
536
+ // configured. Absent otherwise, so this cannot alter existing behaviour.
537
+ ...(idpOAuth ? [(0, generic_oauth_1.genericOAuth)({ config: [idpOAuth] })] : []),
276
538
  ...(opts.magicLink ? [(0, magic_link_1.magicLink)(opts.magicLink)] : []),
277
539
  // App-supplied plugins last: they may depend on the built-ins above, and
278
540
  // ordering is BA's own concern once they are in the array.
@@ -286,16 +548,15 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
286
548
  function isBetterAuthEnabled() {
287
549
  return true;
288
550
  }
289
- /**
290
- * Get Better Auth Next.js route handlers (GET, POST).
291
- * Initializes Better Auth from IDP config on first call, caches the instance.
292
- */
293
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
294
- let cachedInstance = null;
295
- exports.__betterAuthInstance = cachedInstance;
296
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
297
- let initPromise = null;
298
- let configuredOpts = {};
551
+ const STATE_KEY = '__payezBetterAuthState';
552
+ const g = globalThis;
553
+ g[STATE_KEY] ??= { cachedInstance: null, initPromise: null, configuredOpts: {} };
554
+ const state = g[STATE_KEY];
555
+ // Expose for server-side session access (decode-session.ts). A getter so the
556
+ // export tracks the SHARED state, not a chunk-local snapshot.
557
+ function __getBetterAuthInstance() {
558
+ return state.cachedInstance;
559
+ }
299
560
  /**
300
561
  * Configure Better Auth instance options for this process.
301
562
  *
@@ -308,24 +569,24 @@ let configuredOpts = {};
308
569
  * cannot be applied retroactively.
309
570
  */
310
571
  function configureBetterAuth(opts) {
311
- if (cachedInstance) {
572
+ if (state.cachedInstance) {
312
573
  throw new Error('[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
313
574
  'Call it in Next.js instrumentation.ts or an equivalent startup hook.');
314
575
  }
315
- configuredOpts = opts;
576
+ state.configuredOpts = opts;
316
577
  }
317
578
  async function getBetterAuthInstance() {
318
- if (cachedInstance)
319
- return cachedInstance;
320
- if (!initPromise) {
321
- initPromise = (0, idp_client_config_1.getIDPClientConfig)(true).then(config => {
322
- const instance = createBetterAuthInstance(config, configuredOpts);
323
- exports.__betterAuthInstance = cachedInstance = instance;
579
+ if (state.cachedInstance)
580
+ return state.cachedInstance;
581
+ if (!state.initPromise) {
582
+ state.initPromise = (0, idp_client_config_1.getIDPClientConfig)(true).then(config => {
583
+ const instance = createBetterAuthInstance(config, state.configuredOpts);
584
+ state.cachedInstance = instance;
324
585
  console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
325
586
  return instance;
326
587
  });
327
588
  }
328
- return initPromise;
589
+ return state.initPromise;
329
590
  }
330
591
  /**
331
592
  * Get flag-gated auth handler for Next.js route.
@@ -394,7 +655,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
394
655
  email,
395
656
  name,
396
657
  image,
397
- client_id: config.clientSlug || String(config.clientId),
658
+ client_id: (0, client_key_1.resolveClientKey)(config, 'auth/better-auth:oauth-callback').key ?? '',
398
659
  /**
399
660
  * Card 138635 — the RAW provider id_token, forwarded verbatim.
400
661
  *
@@ -425,6 +686,42 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
425
686
  console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
426
687
  if (!oauthRes.ok) {
427
688
  console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
689
+ /**
690
+ * WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
691
+ *
692
+ * UNVERIFIED_ASSERTION has exactly two causes and the response body cannot tell
693
+ * them apart: we sent no id_token for the IDP to verify, or we sent one whose
694
+ * signature/aud/iss the IDP rejected. Without this line the operator sees a 401 on
695
+ * a login that visibly succeeded and has nowhere to go — which is where UMI sat on
696
+ * 2026-08-26.
697
+ *
698
+ * NEVER THE TOKEN ITSELF. An id_token is credential material and this whole code
699
+ * path exists to keep it out of anything persistent; a log file is persistent. We
700
+ * print presence, length, and the three claims that decide verification. `aud` is
701
+ * the Google client id — not a secret, and the single most likely mismatch, since
702
+ * the IDP checks it against what IT has registered for this client.
703
+ */
704
+ const forwarded = idTokenScope.getStore()?.idToken;
705
+ let claims = {};
706
+ if (forwarded) {
707
+ try {
708
+ claims = (0, jose_1.decodeJwt)(forwarded);
709
+ }
710
+ catch {
711
+ claims = { decode: 'FAILED — not a well-formed JWT' };
712
+ }
713
+ }
714
+ console.error('[BETTER_AUTH] exchange diagnostics:', {
715
+ id_token_forwarded: !!forwarded,
716
+ id_token_length: forwarded ? forwarded.length : 0,
717
+ // Absent id_token => the IDP had nothing to verify. Present => compare these
718
+ // against the client's registered Google credentials on the IDP.
719
+ aud: claims.aud ?? null,
720
+ iss: claims.iss ?? null,
721
+ email_verified: claims.email_verified ?? null,
722
+ sent_client_id: (0, client_key_1.resolveClientKey)(config, 'auth/better-auth:oauth-callback').key ?? null,
723
+ idp_url: idpUrl,
724
+ });
428
725
  return false;
429
726
  }
430
727
  let idpData;
@@ -440,22 +737,67 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
440
737
  return false;
441
738
  }
442
739
  // Build IDP token data
443
- // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
444
- // !hasRefreshToken was a reliable "2FA required" signal.
445
740
  //
446
- // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
447
- // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
448
- // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
449
- // bypassing the 2FA gate entirely.
741
+ // 170007 CONTRACT (BAPert ruling on 121350, agreed with DotNetPert
742
+ // 2026-08-08 — see mail thread, not re-derived here):
743
+ // 1. The IDP emits `requires_two_factor` (snake_case, both serializers,
744
+ // per the estate law) as a NON-NULLABLE bool on OAuthCallbackResponse
745
+ // (PayEz.DTOs OAuthCallbackResponse.cs) — ALWAYS present. No IDP
746
+ // change needed or wanted: emitting a second, camelCase name for the
747
+ // same fact would be the dialect problem the estate law exists to
748
+ // prevent, not a fix.
749
+ // 2. Read requires_two_factor (snake) ONLY. CORRECTED mid-review:
750
+ // BAPert's first draft of this contract said "read both casings,
751
+ // defensively" — DotNetPert rejected that: a dual-casing client-side
752
+ // read gives one fact two wire names just as surely as a dual-casing
753
+ // IDP emit would, and it is the exact audience-dialect mistake
754
+ // (186443) made deliberately instead of by accident. This package
755
+ // previously read camel ONLY (card 170083, now corrected — it must
756
+ // NOT record "emit both casings" as the IDP-side fix), which is why
757
+ // this line silently fell through to the heuristic below on every
758
+ // real call. The fix is reading the one name the IDP actually sends,
759
+ // not tolerating both.
760
+ // 3. The old !hasRefreshToken fallback (IDP-2FA-BUG below) is RETIRED,
761
+ // not patched. DotNetPert traced ExternalAuthController's
762
+ // oauth-callback: IssueRefreshToken and RequiresTwoFactor both derive
763
+ // from the SAME isFullyAuthenticated variable, so they cannot
764
+ // disagree on this path — the divergence this fallback existed for
765
+ // is not present here. Worse, the heuristic would currently produce
766
+ // the RIGHT answer by coincidence, which hides the day it stops
767
+ // being derived from one variable.
768
+ // 4. requires_two_factor is non-nullable, so reaching the `true`
769
+ // default below means the response ISN'T an OAuthCallbackResponse at
770
+ // all (wrong endpoint / error body / failed parse) — logged as that,
771
+ // not as "2FA status unknown". Fail-closed to requiresTwoFactor=true
772
+ // either way (rule 3).
773
+ // 5. mfaVerified = !requiresTwoFactor, computed fresh from THIS
774
+ // response — no hardcoding, no carrying a prior session value
775
+ // forward (that half of the monotonic-OR defect lived in
776
+ // refresh.ts / ensure-fresh-access-token.ts, fixed separately).
450
777
  //
451
- // FIX: Always set mfaVerified=false on the initial OAuth exchange.
452
- // The tenant's require_2fa setting is enforced by the viability endpoint
453
- // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
454
- // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
455
- // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
456
- // IDP correctly withholds refresh_token until post-2FA.
457
- const hasRefreshToken = !!result.refresh_token;
458
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
778
+ // PRIOR STATE, for the record: this used to hardcode mfaVerified=false on
779
+ // every OAuth exchange as a blunt workaround for a since-disproven IDP
780
+ // bug, which itself caused every Google user to be sent to /mfa even on
781
+ // require_2fa=FALSE tenants (NextPert 13450's blocker, failing T4 on the
782
+ // demo tenant). That workaround is removed along with the bug it worked
783
+ // around.
784
+ const requiresTwoFactor = (() => {
785
+ // SNAKE ONLY (BAPert's corrected ruling, 2026-08-08): reading camel as
786
+ // a "defensive" secondary would give one fact two wire names — exactly
787
+ // the audience-dialect mistake (186443) being made deliberately. The
788
+ // estate law is explicit-snake-per-field-both-serializers; the IDP
789
+ // already complies (OAuthCallbackResponse.cs, both System.Text.Json
790
+ // and Newtonsoft). No IDP change, no camel read, no dual-casing.
791
+ const v = result.requires_two_factor ?? result.user?.requires_two_factor;
792
+ if (v === undefined) {
793
+ console.warn('[BETTER_AUTH] oauth-callback response has no requires_two_factor field at all — ' +
794
+ 'unexpected response shape (requires_two_factor is non-nullable on OAuthCallbackResponse; ' +
795
+ 'this is not "2FA status unknown", it means we are not looking at an oauth-callback response). ' +
796
+ 'Defaulting requiresTwoFactor=true (fail closed).');
797
+ return true;
798
+ }
799
+ return !!v;
800
+ })();
459
801
  const idpTokenData = {
460
802
  idpAccessToken: result.access_token,
461
803
  idpRefreshToken: result.refresh_token,
@@ -467,7 +809,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
467
809
  name: result.user?.full_name || result.user?.name || result.name || name,
468
810
  image: image,
469
811
  roles: result.user?.roles || result.roles || [],
470
- mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
812
+ mfaVerified: !requiresTwoFactor, // re-derived fresh — see 170007 contract above
471
813
  // Cards 141688/141741 — RELAYED, NOT DERIVED.
472
814
  //
473
815
  // The IDP already decides this (ExternalAuthController:943 and :2024,
@@ -624,7 +966,43 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
624
966
  const location = response.headers.get('location') || '';
625
967
  if (location.includes('/api/auth/error') || location.includes('please_restart')) {
626
968
  console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
627
- return Response.redirect(new URL(loginPath, request.url), 302);
969
+ // *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
970
+ // Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
971
+ // runtime and again on node 24:
972
+ // Response.redirect('/account-auth/login', 302)
973
+ // -> TypeError: Failed to parse URL from /account-auth/login
974
+ // That requirement is the whole cause of the bug this replaces. The old line
975
+ // was:
976
+ // return Response.redirect(new URL(loginPath, request.url), 302);
977
+ // `request.url` inside a containerised Next standalone server carries the
978
+ // LISTEN address, not the address the browser used, so a container bound to
979
+ // 0.0.0.0:8080 handed the browser `http://0.0.0.0:8080/sign-in` — an address
980
+ // that can NEVER load (net::ERR_ADDRESS_INVALID, confirmed in a real browser
981
+ // by QAPert). Reload cannot recover it, because the address is invalid rather
982
+ // than down. It fired on the ORDINARY path: the oauth_state cookie is
983
+ // Max-Age=600, so anyone spending >10 minutes at Google's account chooser
984
+ // landed on a dead tab.
985
+ //
986
+ // A RELATIVE Location header is legal (RFC 7231 §7.1.2) and the browser
987
+ // resolves it against the origin it is ACTUALLY on, so no origin has to be
988
+ // guessed, configured, or derived from a header. This is the same shape the
989
+ // app's own middleware already emits successfully (307 + relative), which is
990
+ // why page redirects survived while this one did not.
991
+ //
992
+ // WHY NOT REBUILD FROM BETTER_AUTH_URL / rawBaseURL, the other proposal:
993
+ // (a) rawBaseURL is NOT in scope here — it is a local inside
994
+ // createBetterAuthInstance (~:397), a different function ~676 lines up.
995
+ // Reaching it means recomputing the env precedence or exporting a
996
+ // helper, i.e. more moving parts for a strictly worse result.
997
+ // (b) It would reintroduce a guessed origin and make this path depend on
998
+ // BETTER_AUTH_URL being set correctly per leg. Relative needs no env.
999
+ // (c) Anything Host-derived would open host-header injection on a redirect
1000
+ // that is currently host-INVARIANT. Relative keeps that property.
1001
+ //
1002
+ // Works whether loginPath is relative (package default '/account-auth/login',
1003
+ // and the graft passes '/sign-in') or absolute — an absolute Location is also
1004
+ // legal, so a consumer passing a full URL still behaves.
1005
+ return new Response(null, { status: 302, headers: { location: loginPath } });
628
1006
  }
629
1007
  }
630
1008
  // After successful OAuth callback: exchange Google identity for IDP tokens