@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.
- 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/client/better-auth-client.d.ts +62 -62
- 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 +5 -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 +177 -20
- package/dist/server/auth.js +34 -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 +162 -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 +5 -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 +37 -73
- 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/dist/auth/better-auth.js
CHANGED
|
@@ -43,10 +43,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
43
43
|
};
|
|
44
44
|
})();
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
-
exports.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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 {
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
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
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
const
|
|
458
|
-
|
|
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:
|
|
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
|
-
|
|
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
|