@payez/next-mvp 4.1.20 → 4.4.0
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.
- package/POST-INSTALL.md +408 -0
- package/README.md +7 -0
- package/dist/api-handlers/account/recovery/initiate.js +35 -2
- package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/send-code.js +35 -2
- package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/verify-code.js +35 -2
- package/dist/api-handlers/account/reset-password.js +35 -2
- package/dist/api-handlers/account/validate-password.js +21 -3
- package/dist/api-handlers/admin/analytics.js +2 -1
- package/dist/api-handlers/admin/audit.js +2 -1
- package/dist/api-handlers/admin/sessions.js +2 -1
- package/dist/api-handlers/admin/stats.js +2 -1
- package/dist/api-handlers/admin/users.js +2 -1
- package/dist/api-handlers/admin/vibe-data.js +2 -1
- package/dist/api-handlers/auth/jwks.js +10 -0
- package/dist/api-handlers/auth/login.js +5 -2
- package/dist/api-handlers/auth/passkey-signin.js +16 -1
- package/dist/api-handlers/auth/refresh.d.ts +23 -3
- package/dist/api-handlers/auth/refresh.js +107 -16
- package/dist/api-handlers/auth/signout.js +3 -2
- package/dist/api-handlers/auth/verify-code.js +5 -2
- package/dist/api-handlers/session/viability.js +27 -1
- package/dist/api-handlers/test/force-expire.d.ts +29 -15
- package/dist/api-handlers/test/force-expire.js +109 -14
- package/dist/auth/better-auth.d.ts +263 -6
- package/dist/auth/better-auth.js +437 -59
- package/dist/auth/utils/idp-client.js +1 -1
- package/dist/client/AuthContext.js +49 -17
- package/dist/components/account/UserAvatarMenu.js +12 -0
- package/dist/components/auth/ModeAwareLoginPage.js +10 -8
- package/dist/components/auth/ModeAwareSignupPage.js +5 -2
- package/dist/components/auth/TraditionalAuthSection.js +5 -1
- package/dist/hooks/useAuthSettings.js +14 -2
- package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
- package/dist/hooks/usePublicAuthSettings.js +28 -14
- package/dist/lib/client-key.d.ts +61 -0
- package/dist/lib/client-key.js +72 -0
- package/dist/lib/demo-mode.js +2 -1
- package/dist/lib/ensure-fresh-access-token.js +12 -1
- package/dist/lib/idp-client-config.d.ts +25 -2
- package/dist/lib/idp-client-config.js +381 -36
- package/dist/lib/idp-fetch.d.ts +2 -0
- package/dist/lib/idp-fetch.js +31 -1
- package/dist/lib/idp-url-guard.d.ts +29 -0
- package/dist/lib/idp-url-guard.js +38 -0
- package/dist/lib/redis.js +77 -5
- package/dist/lib/session-cookie-shape.d.ts +69 -0
- package/dist/lib/session-cookie-shape.js +126 -0
- package/dist/lib/session-store.d.ts +35 -2
- package/dist/lib/session-store.js +135 -4
- package/dist/lib/single-flight.d.ts +35 -0
- package/dist/lib/single-flight.js +30 -0
- package/dist/lib/token-lifecycle.js +12 -0
- package/dist/middleware/create-middleware.js +12 -0
- package/dist/models/SessionModel.d.ts +41 -0
- package/dist/models/SessionModel.js +9 -0
- package/dist/pages/admin-login/page.d.ts +1 -1
- package/dist/pages/admin-login/page.js +5 -41
- package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
- package/dist/pages/security/EnhancedSecurityPage.js +42 -2
- package/dist/pages/security/index.d.ts +1 -0
- package/dist/pages/security/index.js +3 -0
- package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
- package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
- package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
- package/dist/pages/test-env/JwtInspectPage.js +166 -7
- package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
- package/dist/pages/test-env/RefreshTokenPage.js +22 -6
- package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
- package/dist/pages/test-env/TestEnvPage.js +16 -4
- package/dist/pages/test-env/guarded.d.ts +4 -0
- package/dist/pages/test-env/guarded.js +86 -0
- package/dist/pages/test-env/index.d.ts +50 -7
- package/dist/pages/test-env/index.js +58 -12
- package/dist/routes/auth/logout.js +3 -2
- package/dist/routes/auth/refresh.js +27 -1
- package/dist/routes/auth/session.d.ts +12 -3
- package/dist/routes/auth/session.js +92 -9
- package/dist/routes/auth/settings.d.ts +34 -4
- package/dist/routes/auth/settings.js +87 -33
- package/dist/routes/auth/viability.d.ts +1 -1
- package/dist/routes/auth/viability.js +122 -11
- package/dist/server/auth-guard.js +22 -15
- package/dist/server/auth.d.ts +170 -4
- package/dist/server/auth.js +42 -75
- package/dist/server/decode-session.js +84 -10
- package/dist/types/auth.d.ts +18 -0
- package/package.json +969 -958
- package/scripts/check-internal-url-usage.sh +73 -73
- package/scripts/check-publish-health.mjs +120 -0
- package/scripts/dev-broker.ps1 +71 -6
- package/scripts/print-client-config.mjs +162 -0
- package/scripts/server-only-stub.cjs +4 -0
- package/src/api-handlers/account/recovery/initiate.ts +35 -2
- package/src/api-handlers/account/recovery/send-code.ts +36 -2
- package/src/api-handlers/account/recovery/verify-code.ts +36 -2
- package/src/api-handlers/account/reset-password.ts +35 -2
- package/src/api-handlers/account/validate-password.ts +22 -3
- package/src/api-handlers/admin/analytics.ts +2 -1
- package/src/api-handlers/admin/audit.ts +2 -1
- package/src/api-handlers/admin/sessions.ts +2 -1
- package/src/api-handlers/admin/stats.ts +2 -1
- package/src/api-handlers/admin/users.ts +2 -1
- package/src/api-handlers/admin/vibe-data.ts +2 -1
- package/src/api-handlers/auth/jwks.ts +10 -0
- package/src/api-handlers/auth/login.ts +5 -2
- package/src/api-handlers/auth/passkey-signin.ts +112 -98
- package/src/api-handlers/auth/refresh.ts +112 -15
- package/src/api-handlers/auth/signout.ts +4 -3
- package/src/api-handlers/auth/verify-code.ts +5 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
- package/src/api-handlers/mfa/backup-codes.ts +41 -41
- package/src/api-handlers/mfa/methods.ts +46 -46
- package/src/api-handlers/mfa/totp-confirm.ts +42 -42
- package/src/api-handlers/mfa/totp-enroll.ts +42 -42
- package/src/api-handlers/passkeys/list.ts +31 -31
- package/src/api-handlers/passkeys/manage.ts +54 -54
- package/src/api-handlers/passkeys/register-options.ts +42 -42
- package/src/api-handlers/passkeys/register-verify.ts +41 -41
- package/src/api-handlers/session/viability.ts +29 -1
- package/src/api-handlers/test/force-expire.ts +118 -13
- package/src/auth/better-auth.ts +486 -51
- package/src/auth/utils/idp-client.ts +1 -1
- package/src/client/AuthContext.tsx +55 -19
- package/src/components/account/UserAvatarMenu.tsx +11 -0
- package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
- package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
- package/src/components/auth/TraditionalAuthSection.tsx +5 -1
- package/src/hooks/useAuthSettings.ts +14 -2
- package/src/hooks/usePublicAuthSettings.ts +38 -16
- package/src/lib/client-key.ts +88 -0
- package/src/lib/demo-mode.ts +2 -1
- package/src/lib/ensure-fresh-access-token.ts +12 -1
- package/src/lib/idp-client-config.ts +399 -36
- package/src/lib/idp-fetch.ts +36 -1
- package/src/lib/idp-url-guard.ts +37 -0
- package/src/lib/redis.ts +84 -6
- package/src/lib/session-cookie-shape.ts +184 -0
- package/src/lib/session-store.ts +152 -6
- package/src/lib/single-flight.ts +64 -0
- package/src/lib/token-lifecycle.ts +12 -0
- package/src/middleware/create-middleware.ts +11 -0
- package/src/models/SessionModel.ts +49 -0
- package/src/pages/admin-login/page.tsx +17 -127
- package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
- package/src/pages/security/index.ts +3 -0
- package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
- package/src/pages/test-env/JwtInspectPage.tsx +235 -20
- package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
- package/src/pages/test-env/TestEnvPage.tsx +16 -4
- package/src/pages/test-env/guarded.tsx +81 -0
- package/src/pages/test-env/index.ts +59 -7
- package/src/routes/auth/logout.ts +4 -3
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +30 -1
- package/src/routes/auth/session.ts +98 -9
- package/src/routes/auth/settings.ts +144 -42
- package/src/routes/auth/viability.ts +129 -11
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/server/auth-guard.ts +24 -17
- package/src/server/auth.ts +54 -74
- package/src/server/decode-session.ts +95 -11
- package/src/templates/instrumentation.ts +21 -2
- package/src/types/auth.ts +20 -0
- package/dist/lib/auth-secret.d.ts +0 -17
- package/dist/lib/auth-secret.js +0 -116
- package/src/lib/auth-secret.ts +0 -137
package/src/auth/better-auth.ts
CHANGED
|
@@ -11,17 +11,29 @@
|
|
|
11
11
|
|
|
12
12
|
import 'server-only';
|
|
13
13
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
+
import { createHash } from 'crypto';
|
|
14
15
|
import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
|
|
15
16
|
import { betterAuth } from 'better-auth';
|
|
16
17
|
import { nextCookies } from 'better-auth/next-js';
|
|
17
18
|
import { toNextJsHandler } from 'better-auth/next-js';
|
|
18
19
|
import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
|
|
20
|
+
import { resolveClientKey } from '../lib/client-key';
|
|
21
|
+
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
|
19
22
|
import type { IDPClientConfig } from '../lib/idp-client-config';
|
|
20
23
|
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
21
24
|
import { getAppSlug } from '../lib/app-slug';
|
|
22
25
|
import { getRedis } from '../lib/redis';
|
|
23
26
|
import { SESSION_TTL } from '../lib/session-store';
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
30
|
+
* to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
|
|
31
|
+
* = 48 bits; collision-tellable across a deployment, useless as a credential.
|
|
32
|
+
*/
|
|
33
|
+
export function secretFingerprint(value: string): string {
|
|
34
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* Better Auth social provider config shape.
|
|
27
39
|
*/
|
|
@@ -32,6 +44,13 @@ export interface BetterAuthSocialProvider {
|
|
|
32
44
|
prompt?: string;
|
|
33
45
|
accessType?: 'offline' | 'online';
|
|
34
46
|
hd?: string;
|
|
47
|
+
/**
|
|
48
|
+
* 260419: better-auth's ONLY guard against OAuth auto-provisioning
|
|
49
|
+
* (oauth2/link-account.mjs handleOAuthUserInfo: `if (disableSignUp) return
|
|
50
|
+
* {error:"signup disabled"}`). Unset means ANY unknown Google identity gets a
|
|
51
|
+
* local account created at callback. We wire it from the tenant's own policy.
|
|
52
|
+
*/
|
|
53
|
+
disableSignUp?: boolean;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
/**
|
|
@@ -60,7 +79,118 @@ export interface BetterAuthSocialProvider {
|
|
|
60
79
|
* function. Outside that scope the store is undefined and the body simply omits
|
|
61
80
|
* id_token, which is the correct additive behaviour.
|
|
62
81
|
*/
|
|
63
|
-
|
|
82
|
+
/**
|
|
83
|
+
* ON globalThis FOR THE SAME REASON THE BETTER AUTH INSTANCE IS (181814, see STATE_KEY
|
|
84
|
+
* below) — and it was NOT, which is the whole defect.
|
|
85
|
+
*
|
|
86
|
+
* This module is loaded more than once per process: instrumentation and the auth route
|
|
87
|
+
* are different chunks with their own module registries. `state` was moved to globalThis
|
|
88
|
+
* so both copies share the Better Auth INSTANCE. This AsyncLocalStorage was left in
|
|
89
|
+
* module scope, so each copy got its own.
|
|
90
|
+
*
|
|
91
|
+
* The result, measured on UMI 2026-08-26 against idp.payez.net:
|
|
92
|
+
*
|
|
93
|
+
* copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
|
|
94
|
+
* copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
|
|
95
|
+
* getUserInfo fires, reads A -> getStore() is undefined, `if (scope)` skips
|
|
96
|
+
* the exchange reads B -> empty, so id_token is omitted from the body
|
|
97
|
+
*
|
|
98
|
+
* Sign-in SUCCEEDS at every visible step. The user gets a session, an avatar, an email.
|
|
99
|
+
* The IDP then answers 401 UNVERIFIED_ASSERTION with reason="id_token missing"
|
|
100
|
+
* (confirmed in its own logs, correlationId 0HNO2755RESR1) — so the client half of 138635
|
|
101
|
+
* has been shipping as a no-op wherever the module double-loads, and the security fix it
|
|
102
|
+
* exists to deliver never arrived. A shared instance plus a per-copy scope is the worst
|
|
103
|
+
* combination: everything looks wired, and the one field that proves identity is dropped.
|
|
104
|
+
*
|
|
105
|
+
* globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
|
|
106
|
+
* carrier needs to be reachable from every copy that might run inside the request.
|
|
107
|
+
*/
|
|
108
|
+
const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
|
|
109
|
+
const gScope = globalThis as unknown as Record<
|
|
110
|
+
string,
|
|
111
|
+
AsyncLocalStorage<{ idToken?: string }> | undefined
|
|
112
|
+
>;
|
|
113
|
+
gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<{ idToken?: string }>();
|
|
114
|
+
const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
|
|
118
|
+
* provider credentials in the app.
|
|
119
|
+
*
|
|
120
|
+
* ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
|
|
121
|
+
* Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
|
|
122
|
+
* is uniform with the ratified desktop leg. The IDP holds the provider secret
|
|
123
|
+
* and performs the provider exchange server-side.
|
|
124
|
+
*
|
|
125
|
+
* ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
|
|
126
|
+
* QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
|
|
127
|
+
* clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
|
|
128
|
+
* on whether we need a secret. This builds the generic/custom shape
|
|
129
|
+
* deliberately; reaching for a preset because it looks tidier would silently
|
|
130
|
+
* kill keyless-on-web and nobody would connect the two changes.
|
|
131
|
+
*
|
|
132
|
+
* ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
|
|
133
|
+
* Returns null unless the IDP's OAuth endpoints are explicitly configured. That
|
|
134
|
+
* makes this ADDITIVE: the existing socialProviders path keeps working until an
|
|
135
|
+
* operator turns this on. It is NOT a runtime failover - nothing silently
|
|
136
|
+
* chooses between them, because a silent chooser is how a migration ends up
|
|
137
|
+
* half-applied and undetectable.
|
|
138
|
+
*
|
|
139
|
+
* ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
|
|
140
|
+
* the app stops talking to Google, so it can no longer capture the raw
|
|
141
|
+
* id_token that oauth-callback verifies today, and the per-provider settings
|
|
142
|
+
* below stop being ours to set - profile scope, prompt=select_account,
|
|
143
|
+
* accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
|
|
144
|
+
* exchange must verify signature/iss/aud/exp itself and preserve those four, or
|
|
145
|
+
* they are lost silently.
|
|
146
|
+
*/
|
|
147
|
+
export function buildIdpOAuthProviderConfig(): {
|
|
148
|
+
providerId: string;
|
|
149
|
+
discoveryUrl: string;
|
|
150
|
+
clientId: string;
|
|
151
|
+
pkce: true;
|
|
152
|
+
scopes: string[];
|
|
153
|
+
} | null {
|
|
154
|
+
const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
|
|
155
|
+
const clientId = process.env.CLIENT_ID;
|
|
156
|
+
if (!discoveryUrl || !clientId) return null;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
providerId: 'payez-idp',
|
|
160
|
+
discoveryUrl,
|
|
161
|
+
clientId,
|
|
162
|
+
// NO clientSecret. The whole point: this app is not a credential holder.
|
|
163
|
+
pkce: true,
|
|
164
|
+
scopes: ['openid', 'email', 'profile'],
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* ⚠ DO NOT ADD `authentication: 'basic'` HERE. IT SILENTLY BREAKS KEYLESS.
|
|
168
|
+
*
|
|
169
|
+
* Found by QAPert-NightHawk (21348) — the second config trap in this plugin
|
|
170
|
+
* after the preset one, and the same shape: a field that looks like a
|
|
171
|
+
* harmless transport detail and quietly changes whether we are a public
|
|
172
|
+
* client at all.
|
|
173
|
+
*
|
|
174
|
+
* @better-auth/core validate-authorization-code.mjs:
|
|
175
|
+
* if (authentication === "basic") {
|
|
176
|
+
* headers.authorization = `Basic ${base64(`${clientId}:${clientSecret ?? ""}`)}`;
|
|
177
|
+
* } else {
|
|
178
|
+
* body.set("client_id", clientId);
|
|
179
|
+
* if (options.clientSecret) body.set("client_secret", options.clientSecret);
|
|
180
|
+
* }
|
|
181
|
+
*
|
|
182
|
+
* Note what the 'basic' branch does with NO secret: it sends an EMPTY
|
|
183
|
+
* password, `Basic base64("<clientId>:")`. That is not "no authentication",
|
|
184
|
+
* it is WRONG authentication — we would be presenting ourselves as a
|
|
185
|
+
* confidential client with a blank credential. The default branch is the
|
|
186
|
+
* one that produces a genuine public-client PKCE request.
|
|
187
|
+
*
|
|
188
|
+
* Leaving this field UNSET is load-bearing, which is exactly why it needs a
|
|
189
|
+
* comment: nothing about an absent field advertises that its absence is a
|
|
190
|
+
* decision.
|
|
191
|
+
*/
|
|
192
|
+
};
|
|
193
|
+
}
|
|
64
194
|
|
|
65
195
|
export function buildBetterAuthProviders(
|
|
66
196
|
config: IDPClientConfig
|
|
@@ -81,10 +211,40 @@ export function buildBetterAuthProviders(
|
|
|
81
211
|
scopes.push('profile');
|
|
82
212
|
}
|
|
83
213
|
|
|
214
|
+
/**
|
|
215
|
+
* 224721: better-auth's google provider UNCONDITIONALLY seeds its default
|
|
216
|
+
* scopes ['email','profile','openid'] and then pushes our `scope` verbatim
|
|
217
|
+
* (@better-auth/core social-providers/google.mjs createAuthorizationURL) —
|
|
218
|
+
* the row's "openid email profile" reached Google as
|
|
219
|
+
* "email profile openid openid email profile". Semantically harmless
|
|
220
|
+
* (Google dedupes) but sloppy on a URL a demo audience can read. Hand over
|
|
221
|
+
* only what the defaults do not already cover; undefined when nothing
|
|
222
|
+
* remains, so the provider emits exactly its default set.
|
|
223
|
+
*/
|
|
224
|
+
const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
|
|
225
|
+
const wireScopes =
|
|
226
|
+
name === 'google'
|
|
227
|
+
? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
|
|
228
|
+
: scopes;
|
|
229
|
+
|
|
84
230
|
providers[name] = {
|
|
85
231
|
clientId: oauth.clientId,
|
|
86
232
|
clientSecret: oauth.clientSecret,
|
|
87
|
-
scope:
|
|
233
|
+
scope: wireScopes.length > 0 ? wireScopes : undefined,
|
|
234
|
+
/**
|
|
235
|
+
* 260419 — CLOSE THE SECOND SELF-SERVE PROVISIONING DOOR. 259805 gated
|
|
236
|
+
* the signup BFF on allow_public_registration, but the social lane never
|
|
237
|
+
* touches that code: better-auth creates the user through its own
|
|
238
|
+
* internalAdapter at the OAuth callback, and its ONLY guard is this flag.
|
|
239
|
+
*
|
|
240
|
+
* Wired from the tenant's own policy, explicit-true-opens: the IDP config
|
|
241
|
+
* keeps allowPublicRegistration UNDEFINED when the tenant never answered
|
|
242
|
+
* (idp-client-config.ts, the no-fabricated-default rule), and an
|
|
243
|
+
* unanswered policy question must not provision accounts. That matches
|
|
244
|
+
* the FE's silent-default-is-decline posture for the "Create account"
|
|
245
|
+
* affordance. A tenant that wants Google self-serve says so.
|
|
246
|
+
*/
|
|
247
|
+
disableSignUp: config.authSettings?.allowPublicRegistration !== true,
|
|
88
248
|
// Google is overly eager to reuse the last account unless we
|
|
89
249
|
// explicitly ask for account selection on each social login.
|
|
90
250
|
prompt: typeof rawPrompt === 'string'
|
|
@@ -137,7 +297,22 @@ export function buildBetterAuthProviders(
|
|
|
137
297
|
|
|
138
298
|
// The only new behaviour: hand the raw token to this request's scope.
|
|
139
299
|
const scope = idTokenScope.getStore();
|
|
140
|
-
if (scope)
|
|
300
|
+
if (scope) {
|
|
301
|
+
scope.idToken = token.idToken;
|
|
302
|
+
} else {
|
|
303
|
+
// NOT a silent skip. A missing scope here means 138635 degrades to a no-op:
|
|
304
|
+
// the login still succeeds, and the IDP rejects the exchange with
|
|
305
|
+
// reason="id_token missing" a second later, with nothing connecting the two.
|
|
306
|
+
// That cost a day on UMI. If this fires, the OAuth callback is running outside
|
|
307
|
+
// createAuthGetHandler — either the route mounts the bare
|
|
308
|
+
// `routes/auth/nextauth` re-export instead of createAuthGetHandler, or this
|
|
309
|
+
// module has double-loaded and the scope is not shared (see 181814 above).
|
|
310
|
+
console.warn(
|
|
311
|
+
'[BETTER_AUTH] id_token captured but NO REQUEST SCOPE to put it in — it will ' +
|
|
312
|
+
'NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
313
|
+
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
|
|
314
|
+
);
|
|
315
|
+
}
|
|
141
316
|
|
|
142
317
|
// Mirrors the upstream default (google.mjs) verbatim, minus the hd
|
|
143
318
|
// branch that cannot fire here — so overriding the hook changes WHAT WE
|
|
@@ -206,6 +381,17 @@ export function createBetterAuthInstance(
|
|
|
206
381
|
) {
|
|
207
382
|
const appSlug = idpConfig.clientSlug || getAppSlug();
|
|
208
383
|
|
|
384
|
+
// 185121 line 1: name the secret this instance PINNED, as a hash, never the
|
|
385
|
+
// secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
|
|
386
|
+
// caused by a divergent secret is only diagnosable if every instance
|
|
387
|
+
// creation logged WHICH secret it bound. Both init paths (this module's
|
|
388
|
+
// getBetterAuthInstance and server/auth.ts getAuthInstance) funnel through
|
|
389
|
+
// here, so this one line covers both; getAuthInstance adds its own line
|
|
390
|
+
// naming the path, because it previously created an instance INVISIBLY.
|
|
391
|
+
console.log(
|
|
392
|
+
`[BETTER_AUTH] createBetterAuthInstance: slug=${appSlug} authSecret ${idpConfig.authSecret ? `sha256:${secretFingerprint(idpConfig.authSecret as string)}` : 'ABSENT'}`
|
|
393
|
+
);
|
|
394
|
+
|
|
209
395
|
// Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
|
|
210
396
|
// Must include /api/auth since that's where the catch-all route is mounted
|
|
211
397
|
const rawBaseURL = process.env.BETTER_AUTH_URL
|
|
@@ -213,6 +399,8 @@ export function createBetterAuthInstance(
|
|
|
213
399
|
|| `http://localhost:${process.env.PORT || '3000'}`;
|
|
214
400
|
const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
|
|
215
401
|
|
|
402
|
+
const idpOAuth = buildIdpOAuthProviderConfig();
|
|
403
|
+
|
|
216
404
|
return betterAuth({
|
|
217
405
|
baseURL,
|
|
218
406
|
secret: idpConfig.authSecret as string,
|
|
@@ -224,32 +412,137 @@ export function createBetterAuthInstance(
|
|
|
224
412
|
rawBaseURL,
|
|
225
413
|
baseURL,
|
|
226
414
|
...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
415
|
+
// MEASURED 2026-08-26: this list ended with the literals localhost:3000, :3400
|
|
416
|
+
// and :3600. payez_idp_admin_web runs on :3200 - correctly configured, with
|
|
417
|
+
// BETTER_AUTH_URL=http://localhost:3200 - and every GET /api/auth/get-session
|
|
418
|
+
// returned 400, because the port it actually runs on was not one of the three
|
|
419
|
+
// someone had typed in. A config-shaped constant is not configuration.
|
|
420
|
+
//
|
|
421
|
+
// The app's own origin is already covered by rawBaseURL/baseURL above; the dev
|
|
422
|
+
// origin is DERIVED from PORT, and anything genuinely external comes from
|
|
423
|
+
// BETTER_AUTH_TRUSTED_ORIGINS (comma-separated).
|
|
424
|
+
`http://localhost:${process.env.PORT || '3000'}`,
|
|
425
|
+
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
|
426
|
+
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
|
|
427
|
+
: []),
|
|
230
428
|
],
|
|
231
429
|
|
|
232
430
|
// Redis-backed session storage via secondaryStorage
|
|
233
431
|
secondaryStorage: {
|
|
432
|
+
/**
|
|
433
|
+
* 170033 (QAPert 22654, load-bearing precondition for 183483): DO NOT
|
|
434
|
+
* swallow a Redis failure into `null` here.
|
|
435
|
+
*
|
|
436
|
+
* `null` from `get` is Better Auth's OWN signal for "no session at this
|
|
437
|
+
* key" (see node_modules/better-auth db/internal-adapter.mjs
|
|
438
|
+
* `findSession`: `if (!sessionStringified ...) return null`). Catching
|
|
439
|
+
* a Redis error and returning `null` therefore told better-auth "this
|
|
440
|
+
* session does not exist" when the true state was "the store could not
|
|
441
|
+
* be asked" — indistinguishable from a genuine sign-out. With
|
|
442
|
+
* `cookieCache` disabled (session.cookieCache.enabled above), EVERY
|
|
443
|
+
* `getSession()` call hits this path for real, so a Redis blip
|
|
444
|
+
* manufactured a `200 { session: null }` for every live session at
|
|
445
|
+
* once — the exact channel 183483's client-side guard (verify-code
|
|
446
|
+
* page's session-liveness check) was built to survive, and the one it
|
|
447
|
+
* could not survive while this swallow stood: a fabricated "no
|
|
448
|
+
* session" 200 is not distinguishable from a real one by any client.
|
|
449
|
+
*
|
|
450
|
+
* Letting the error propagate is safe: better-auth's own router
|
|
451
|
+
* (better-call/dist/router.mjs) wraps every endpoint handler in a
|
|
452
|
+
* top-level try/catch and converts an uncaught throw to a genuine
|
|
453
|
+
* `500`, not a crash. A `500` IS the "inconclusive" signal 183483's
|
|
454
|
+
* corrected client guard already knows how to hold a session through —
|
|
455
|
+
* so a Redis outage now surfaces as "couldn't check" instead of
|
|
456
|
+
* silently lying "confirmed logged out".
|
|
457
|
+
*/
|
|
234
458
|
get: async (key: string) => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
459
|
+
const value = await getRedis().get(`ba:${appSlug}:${key}`);
|
|
460
|
+
// 185121 line 2: the 183047 discriminator. A null here means
|
|
461
|
+
// RECORD-MISS — the store was reachable and the key is absent. If the
|
|
462
|
+
// record IS present but BA still rejects the session, this line does
|
|
463
|
+
// NOT fire and the failure is upstream of storage (HMAC/secret gate).
|
|
464
|
+
// BA-primary logs nothing on failure, so without this line the two
|
|
465
|
+
// surviving forks are indistinguishable in the artifact. Key is
|
|
466
|
+
// fingerprinted: it embeds the session token.
|
|
467
|
+
if (value === null) {
|
|
468
|
+
console.warn(
|
|
469
|
+
`[BETTER_AUTH] secondaryStorage.get MISS ba:${appSlug}:sha256:${secretFingerprint(key)} — record absent, store reachable`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
return value;
|
|
238
473
|
},
|
|
474
|
+
/**
|
|
475
|
+
* 170033 fix 1 — DO NOT SWALLOW A FAILED WRITE. A SESSION THAT WAS NOT
|
|
476
|
+
* PERSISTED IS NOT A SESSION.
|
|
477
|
+
*
|
|
478
|
+
* This previously caught every Redis error and returned normally, so
|
|
479
|
+
* better-auth was told the session had been stored when nothing had been
|
|
480
|
+
* written. The user got a successful login, a session cookie, and a
|
|
481
|
+
* rendered signed-in shell on top of a store that holds no session — and
|
|
482
|
+
* the very next `getSession()` reads `null` and signs them straight back
|
|
483
|
+
* out. Nothing logged, nothing thrown, no banner: a login that "worked"
|
|
484
|
+
* and a sign-out nobody can explain.
|
|
485
|
+
*
|
|
486
|
+
* Letting it propagate is the same contract `get` above already relies on:
|
|
487
|
+
* better-auth's router (better-call/dist/router.mjs) wraps every endpoint
|
|
488
|
+
* handler and converts an uncaught throw into a genuine 500. A 500 on the
|
|
489
|
+
* login write is honest — "we could not store your session" — and it is
|
|
490
|
+
* distinguishable by any client from a successful login, which a swallowed
|
|
491
|
+
* failure is not.
|
|
492
|
+
*
|
|
493
|
+
* THE COMMENT THIS REPLACES SAID "Redis unavailable — cookie cache still
|
|
494
|
+
* works". It does not: `session.cookieCache.enabled` is FALSE (see below).
|
|
495
|
+
* The comment described a safety net that had been removed, which is why
|
|
496
|
+
* this swallow looked survivable to every reader who trusted it (170033
|
|
497
|
+
* fix 3).
|
|
498
|
+
*/
|
|
239
499
|
set: async (key: string, value: string, ttl?: number) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (ttl) {
|
|
243
|
-
await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
|
|
244
|
-
} else {
|
|
245
|
-
await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
|
|
246
|
-
}
|
|
247
|
-
} catch { /* Redis unavailable — cookie cache still works */ }
|
|
500
|
+
const redis = getRedis();
|
|
501
|
+
await redis.setex(`ba:${appSlug}:${key}`, ttl ?? SESSION_TTL, value);
|
|
248
502
|
},
|
|
249
503
|
delete: async (key: string) => {
|
|
250
504
|
try {
|
|
251
505
|
await getRedis().del(`ba:${appSlug}:${key}`);
|
|
252
|
-
} catch {
|
|
506
|
+
} catch (err) {
|
|
507
|
+
/**
|
|
508
|
+
* 170033 fix 2 — a failed delete must be VISIBLE, not `/* ignore *\/`.
|
|
509
|
+
*
|
|
510
|
+
* Deliberately logged rather than thrown, and the distinction is not
|
|
511
|
+
* timidity: the CANONICAL store is what decides session liveness (see
|
|
512
|
+
* 181922 below), and its delete runs immediately after this one and is
|
|
513
|
+
* NOT swallowed. So a BA record that outlives its canonical twin does
|
|
514
|
+
* not authenticate anyone — throwing here would fail a sign-out that
|
|
515
|
+
* actually succeeded where it counts.
|
|
516
|
+
*
|
|
517
|
+
* What was unacceptable is that it happened SILENTLY. A revocation
|
|
518
|
+
* that half-failed left no trace anywhere, so the leftover key could
|
|
519
|
+
* only ever be found by someone already reading the keyspace for
|
|
520
|
+
* another reason. `error`, not `warn`: this is a revocation that did
|
|
521
|
+
* not complete, not a retryable blip.
|
|
522
|
+
*/
|
|
523
|
+
console.error(
|
|
524
|
+
`[BETTER_AUTH] secondary delete FAILED for ba:${appSlug}:${key} — ` +
|
|
525
|
+
`the better-auth session record may still exist. Canonical delete ` +
|
|
526
|
+
`below is what governs liveness; if it also failed, the session is ` +
|
|
527
|
+
`NOT revoked:`,
|
|
528
|
+
err
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 181922 — teardown is BOTH stores. BA's sign-out deletes only its
|
|
533
|
+
* secondary record; the canonical {slug}:sess:{token} would survive
|
|
534
|
+
* and keep the session alive (a replayed cookie still authenticates).
|
|
535
|
+
* `key` here IS the session token (the ba: prefix is applied above),
|
|
536
|
+
* so the canonical delete is one call. This also fires on session
|
|
537
|
+
* rotation/expiry deletions, which is correct: a session BA drops
|
|
538
|
+
* must not live on in the canonical store.
|
|
539
|
+
*/
|
|
540
|
+
try {
|
|
541
|
+
const { deleteSession } = await import('../lib/session-store');
|
|
542
|
+
await deleteSession(key);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
|
|
545
|
+
}
|
|
253
546
|
},
|
|
254
547
|
},
|
|
255
548
|
|
|
@@ -290,6 +583,9 @@ export function createBetterAuthInstance(
|
|
|
290
583
|
|
|
291
584
|
plugins: [
|
|
292
585
|
nextCookies(),
|
|
586
|
+
// Exchange-move: present ONLY when the IDP's OAuth endpoints are
|
|
587
|
+
// configured. Absent otherwise, so this cannot alter existing behaviour.
|
|
588
|
+
...(idpOAuth ? [genericOAuth({ config: [idpOAuth] })] : []),
|
|
293
589
|
...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
|
|
294
590
|
// App-supplied plugins last: they may depend on the built-ins above, and
|
|
295
591
|
// ordering is BA's own concern once they are in the array.
|
|
@@ -308,16 +604,36 @@ export function isBetterAuthEnabled(): boolean {
|
|
|
308
604
|
/**
|
|
309
605
|
* Get Better Auth Next.js route handlers (GET, POST).
|
|
310
606
|
* Initializes Better Auth from IDP config on first call, caches the instance.
|
|
607
|
+
*
|
|
608
|
+
* STATE LIVES ON globalThis, NOT IN MODULE SCOPE (181814, measured 2026-08-07):
|
|
609
|
+
* under Turbopack, instrumentation.ts and the /api/auth/[...all] route are
|
|
610
|
+
* different chunks and each loads its OWN copy of this module. Module-level
|
|
611
|
+
* `configuredOpts` set by configureBetterAuth() in instrumentation never
|
|
612
|
+
* reached the copy getBetterAuthInstance() read in the route — measured
|
|
613
|
+
* identity probe: sameInstance=false, route-side plugins=["next-cookies"]
|
|
614
|
+
* while the instrumentation-side instance carried every configured plugin.
|
|
615
|
+
* No throw anywhere (the double-init guard never sees the other copy), core
|
|
616
|
+
* auth green, plugin routes 404. globalThis is per-process, per-runtime —
|
|
617
|
+
* exactly the scope this state must have.
|
|
311
618
|
*/
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
619
|
+
interface BetterAuthModuleState {
|
|
620
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
621
|
+
cachedInstance: any;
|
|
622
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
623
|
+
initPromise: Promise<any> | null;
|
|
624
|
+
configuredOpts: CreateBetterAuthInstanceOptions;
|
|
625
|
+
}
|
|
317
626
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
627
|
+
const STATE_KEY = '__payezBetterAuthState';
|
|
628
|
+
const g = globalThis as unknown as Record<string, BetterAuthModuleState | undefined>;
|
|
629
|
+
g[STATE_KEY] ??= { cachedInstance: null, initPromise: null, configuredOpts: {} };
|
|
630
|
+
const state = g[STATE_KEY];
|
|
631
|
+
|
|
632
|
+
// Expose for server-side session access (decode-session.ts). A getter so the
|
|
633
|
+
// export tracks the SHARED state, not a chunk-local snapshot.
|
|
634
|
+
export function __getBetterAuthInstance(): unknown {
|
|
635
|
+
return state.cachedInstance;
|
|
636
|
+
}
|
|
321
637
|
|
|
322
638
|
/**
|
|
323
639
|
* Configure Better Auth instance options for this process.
|
|
@@ -331,28 +647,28 @@ export { cachedInstance as __betterAuthInstance };
|
|
|
331
647
|
* cannot be applied retroactively.
|
|
332
648
|
*/
|
|
333
649
|
export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
|
|
334
|
-
if (cachedInstance) {
|
|
650
|
+
if (state.cachedInstance) {
|
|
335
651
|
throw new Error(
|
|
336
652
|
'[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
|
|
337
653
|
'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
|
|
338
654
|
);
|
|
339
655
|
}
|
|
340
|
-
configuredOpts = opts;
|
|
656
|
+
state.configuredOpts = opts;
|
|
341
657
|
}
|
|
342
658
|
|
|
343
659
|
export async function getBetterAuthInstance() {
|
|
344
|
-
if (cachedInstance) return cachedInstance;
|
|
660
|
+
if (state.cachedInstance) return state.cachedInstance;
|
|
345
661
|
|
|
346
|
-
if (!initPromise) {
|
|
347
|
-
initPromise = getIDPClientConfig(true).then(config => {
|
|
348
|
-
const instance = createBetterAuthInstance(config, configuredOpts);
|
|
349
|
-
cachedInstance = instance;
|
|
662
|
+
if (!state.initPromise) {
|
|
663
|
+
state.initPromise = getIDPClientConfig(true).then(config => {
|
|
664
|
+
const instance = createBetterAuthInstance(config, state.configuredOpts);
|
|
665
|
+
state.cachedInstance = instance;
|
|
350
666
|
console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
|
|
351
667
|
return instance;
|
|
352
668
|
});
|
|
353
669
|
}
|
|
354
670
|
|
|
355
|
-
return initPromise;
|
|
671
|
+
return state.initPromise;
|
|
356
672
|
}
|
|
357
673
|
|
|
358
674
|
/**
|
|
@@ -432,7 +748,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
432
748
|
email,
|
|
433
749
|
name,
|
|
434
750
|
image,
|
|
435
|
-
client_id: config.
|
|
751
|
+
client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? '',
|
|
436
752
|
/**
|
|
437
753
|
* Card 138635 — the RAW provider id_token, forwarded verbatim.
|
|
438
754
|
*
|
|
@@ -465,6 +781,42 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
465
781
|
|
|
466
782
|
if (!oauthRes.ok) {
|
|
467
783
|
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
|
|
787
|
+
*
|
|
788
|
+
* UNVERIFIED_ASSERTION has exactly two causes and the response body cannot tell
|
|
789
|
+
* them apart: we sent no id_token for the IDP to verify, or we sent one whose
|
|
790
|
+
* signature/aud/iss the IDP rejected. Without this line the operator sees a 401 on
|
|
791
|
+
* a login that visibly succeeded and has nowhere to go — which is where UMI sat on
|
|
792
|
+
* 2026-08-26.
|
|
793
|
+
*
|
|
794
|
+
* NEVER THE TOKEN ITSELF. An id_token is credential material and this whole code
|
|
795
|
+
* path exists to keep it out of anything persistent; a log file is persistent. We
|
|
796
|
+
* print presence, length, and the three claims that decide verification. `aud` is
|
|
797
|
+
* the Google client id — not a secret, and the single most likely mismatch, since
|
|
798
|
+
* the IDP checks it against what IT has registered for this client.
|
|
799
|
+
*/
|
|
800
|
+
const forwarded = idTokenScope.getStore()?.idToken;
|
|
801
|
+
let claims: Record<string, unknown> = {};
|
|
802
|
+
if (forwarded) {
|
|
803
|
+
try {
|
|
804
|
+
claims = decodeJwt(forwarded) as Record<string, unknown>;
|
|
805
|
+
} catch {
|
|
806
|
+
claims = { decode: 'FAILED — not a well-formed JWT' };
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
console.error('[BETTER_AUTH] exchange diagnostics:', {
|
|
810
|
+
id_token_forwarded: !!forwarded,
|
|
811
|
+
id_token_length: forwarded ? forwarded.length : 0,
|
|
812
|
+
// Absent id_token => the IDP had nothing to verify. Present => compare these
|
|
813
|
+
// against the client's registered Google credentials on the IDP.
|
|
814
|
+
aud: claims.aud ?? null,
|
|
815
|
+
iss: claims.iss ?? null,
|
|
816
|
+
email_verified: claims.email_verified ?? null,
|
|
817
|
+
sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
|
|
818
|
+
idp_url: idpUrl,
|
|
819
|
+
});
|
|
468
820
|
return false;
|
|
469
821
|
}
|
|
470
822
|
|
|
@@ -478,22 +830,69 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
478
830
|
}
|
|
479
831
|
|
|
480
832
|
// Build IDP token data
|
|
481
|
-
// ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
|
|
482
|
-
// !hasRefreshToken was a reliable "2FA required" signal.
|
|
483
833
|
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
834
|
+
// 170007 CONTRACT (BAPert ruling on 121350, agreed with DotNetPert
|
|
835
|
+
// 2026-08-08 — see mail thread, not re-derived here):
|
|
836
|
+
// 1. The IDP emits `requires_two_factor` (snake_case, both serializers,
|
|
837
|
+
// per the estate law) as a NON-NULLABLE bool on OAuthCallbackResponse
|
|
838
|
+
// (PayEz.DTOs OAuthCallbackResponse.cs) — ALWAYS present. No IDP
|
|
839
|
+
// change needed or wanted: emitting a second, camelCase name for the
|
|
840
|
+
// same fact would be the dialect problem the estate law exists to
|
|
841
|
+
// prevent, not a fix.
|
|
842
|
+
// 2. Read requires_two_factor (snake) ONLY. CORRECTED mid-review:
|
|
843
|
+
// BAPert's first draft of this contract said "read both casings,
|
|
844
|
+
// defensively" — DotNetPert rejected that: a dual-casing client-side
|
|
845
|
+
// read gives one fact two wire names just as surely as a dual-casing
|
|
846
|
+
// IDP emit would, and it is the exact audience-dialect mistake
|
|
847
|
+
// (186443) made deliberately instead of by accident. This package
|
|
848
|
+
// previously read camel ONLY (card 170083, now corrected — it must
|
|
849
|
+
// NOT record "emit both casings" as the IDP-side fix), which is why
|
|
850
|
+
// this line silently fell through to the heuristic below on every
|
|
851
|
+
// real call. The fix is reading the one name the IDP actually sends,
|
|
852
|
+
// not tolerating both.
|
|
853
|
+
// 3. The old !hasRefreshToken fallback (IDP-2FA-BUG below) is RETIRED,
|
|
854
|
+
// not patched. DotNetPert traced ExternalAuthController's
|
|
855
|
+
// oauth-callback: IssueRefreshToken and RequiresTwoFactor both derive
|
|
856
|
+
// from the SAME isFullyAuthenticated variable, so they cannot
|
|
857
|
+
// disagree on this path — the divergence this fallback existed for
|
|
858
|
+
// is not present here. Worse, the heuristic would currently produce
|
|
859
|
+
// the RIGHT answer by coincidence, which hides the day it stops
|
|
860
|
+
// being derived from one variable.
|
|
861
|
+
// 4. requires_two_factor is non-nullable, so reaching the `true`
|
|
862
|
+
// default below means the response ISN'T an OAuthCallbackResponse at
|
|
863
|
+
// all (wrong endpoint / error body / failed parse) — logged as that,
|
|
864
|
+
// not as "2FA status unknown". Fail-closed to requiresTwoFactor=true
|
|
865
|
+
// either way (rule 3).
|
|
866
|
+
// 5. mfaVerified = !requiresTwoFactor, computed fresh from THIS
|
|
867
|
+
// response — no hardcoding, no carrying a prior session value
|
|
868
|
+
// forward (that half of the monotonic-OR defect lived in
|
|
869
|
+
// refresh.ts / ensure-fresh-access-token.ts, fixed separately).
|
|
488
870
|
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
const
|
|
496
|
-
|
|
871
|
+
// PRIOR STATE, for the record: this used to hardcode mfaVerified=false on
|
|
872
|
+
// every OAuth exchange as a blunt workaround for a since-disproven IDP
|
|
873
|
+
// bug, which itself caused every Google user to be sent to /mfa even on
|
|
874
|
+
// require_2fa=FALSE tenants (NextPert 13450's blocker, failing T4 on the
|
|
875
|
+
// demo tenant). That workaround is removed along with the bug it worked
|
|
876
|
+
// around.
|
|
877
|
+
const requiresTwoFactor: boolean = (() => {
|
|
878
|
+
// SNAKE ONLY (BAPert's corrected ruling, 2026-08-08): reading camel as
|
|
879
|
+
// a "defensive" secondary would give one fact two wire names — exactly
|
|
880
|
+
// the audience-dialect mistake (186443) being made deliberately. The
|
|
881
|
+
// estate law is explicit-snake-per-field-both-serializers; the IDP
|
|
882
|
+
// already complies (OAuthCallbackResponse.cs, both System.Text.Json
|
|
883
|
+
// and Newtonsoft). No IDP change, no camel read, no dual-casing.
|
|
884
|
+
const v = result.requires_two_factor ?? result.user?.requires_two_factor;
|
|
885
|
+
if (v === undefined) {
|
|
886
|
+
console.warn(
|
|
887
|
+
'[BETTER_AUTH] oauth-callback response has no requires_two_factor field at all — ' +
|
|
888
|
+
'unexpected response shape (requires_two_factor is non-nullable on OAuthCallbackResponse; ' +
|
|
889
|
+
'this is not "2FA status unknown", it means we are not looking at an oauth-callback response). ' +
|
|
890
|
+
'Defaulting requiresTwoFactor=true (fail closed).',
|
|
891
|
+
);
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
return !!v;
|
|
895
|
+
})();
|
|
497
896
|
const idpTokenData = {
|
|
498
897
|
idpAccessToken: result.access_token,
|
|
499
898
|
idpRefreshToken: result.refresh_token,
|
|
@@ -505,7 +904,7 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
505
904
|
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
506
905
|
image: image,
|
|
507
906
|
roles: result.user?.roles || result.roles || [],
|
|
508
|
-
mfaVerified:
|
|
907
|
+
mfaVerified: !requiresTwoFactor, // re-derived fresh — see 170007 contract above
|
|
509
908
|
// Cards 141688/141741 — RELAYED, NOT DERIVED.
|
|
510
909
|
//
|
|
511
910
|
// The IDP already decides this (ExternalAuthController:943 and :2024,
|
|
@@ -671,7 +1070,43 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
671
1070
|
const location = response.headers.get('location') || '';
|
|
672
1071
|
if (location.includes('/api/auth/error') || location.includes('please_restart')) {
|
|
673
1072
|
console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
|
|
674
|
-
|
|
1073
|
+
// *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
|
|
1074
|
+
// Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
|
|
1075
|
+
// runtime and again on node 24:
|
|
1076
|
+
// Response.redirect('/account-auth/login', 302)
|
|
1077
|
+
// -> TypeError: Failed to parse URL from /account-auth/login
|
|
1078
|
+
// That requirement is the whole cause of the bug this replaces. The old line
|
|
1079
|
+
// was:
|
|
1080
|
+
// return Response.redirect(new URL(loginPath, request.url), 302);
|
|
1081
|
+
// `request.url` inside a containerised Next standalone server carries the
|
|
1082
|
+
// LISTEN address, not the address the browser used, so a container bound to
|
|
1083
|
+
// 0.0.0.0:8080 handed the browser `http://0.0.0.0:8080/sign-in` — an address
|
|
1084
|
+
// that can NEVER load (net::ERR_ADDRESS_INVALID, confirmed in a real browser
|
|
1085
|
+
// by QAPert). Reload cannot recover it, because the address is invalid rather
|
|
1086
|
+
// than down. It fired on the ORDINARY path: the oauth_state cookie is
|
|
1087
|
+
// Max-Age=600, so anyone spending >10 minutes at Google's account chooser
|
|
1088
|
+
// landed on a dead tab.
|
|
1089
|
+
//
|
|
1090
|
+
// A RELATIVE Location header is legal (RFC 7231 §7.1.2) and the browser
|
|
1091
|
+
// resolves it against the origin it is ACTUALLY on, so no origin has to be
|
|
1092
|
+
// guessed, configured, or derived from a header. This is the same shape the
|
|
1093
|
+
// app's own middleware already emits successfully (307 + relative), which is
|
|
1094
|
+
// why page redirects survived while this one did not.
|
|
1095
|
+
//
|
|
1096
|
+
// WHY NOT REBUILD FROM BETTER_AUTH_URL / rawBaseURL, the other proposal:
|
|
1097
|
+
// (a) rawBaseURL is NOT in scope here — it is a local inside
|
|
1098
|
+
// createBetterAuthInstance (~:397), a different function ~676 lines up.
|
|
1099
|
+
// Reaching it means recomputing the env precedence or exporting a
|
|
1100
|
+
// helper, i.e. more moving parts for a strictly worse result.
|
|
1101
|
+
// (b) It would reintroduce a guessed origin and make this path depend on
|
|
1102
|
+
// BETTER_AUTH_URL being set correctly per leg. Relative needs no env.
|
|
1103
|
+
// (c) Anything Host-derived would open host-header injection on a redirect
|
|
1104
|
+
// that is currently host-INVARIANT. Relative keeps that property.
|
|
1105
|
+
//
|
|
1106
|
+
// Works whether loginPath is relative (package default '/account-auth/login',
|
|
1107
|
+
// and the graft passes '/sign-in') or absolute — an absolute Location is also
|
|
1108
|
+
// legal, so a consumer passing a full URL still behaves.
|
|
1109
|
+
return new Response(null, { status: 302, headers: { location: loginPath } });
|
|
675
1110
|
}
|
|
676
1111
|
}
|
|
677
1112
|
|