@payez/next-mvp 4.8.1 → 4.8.3
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 -408
- package/README.md +859 -859
- package/dist/api-handlers/admin/audit.js +13 -21
- package/dist/api-handlers/admin/sessions.js +59 -45
- package/dist/api-handlers/admin/stats.js +1 -1
- package/dist/api-handlers/admin/users.js +15 -23
- package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
- package/dist/api-handlers/admin/vibe-data.js +5 -0
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/session-store.js +21 -21
- package/dist/lib/startup-init.js +19 -19
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/dist/vibe/client.d.ts +1 -1
- package/dist/vibe/client.js +2 -1
- package/dist/vibe/filters.d.ts +36 -0
- package/dist/vibe/filters.js +47 -0
- package/dist/vibe/generic.js +2 -1
- package/dist/vibe/sessions.js +28 -3
- package/package.json +2 -2
- package/scripts/check-publish-eol.mjs +33 -15
- package/src/api/auth-handler.ts +550 -550
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/change-password.ts +170 -170
- package/src/api-handlers/account/masked-info.ts +46 -46
- package/src/api-handlers/account/profile.ts +99 -99
- package/src/api-handlers/account/recovery/forward.ts +117 -117
- package/src/api-handlers/account/recovery/initiate.ts +59 -59
- package/src/api-handlers/account/recovery/send-code.ts +21 -21
- package/src/api-handlers/account/recovery/verify-code.ts +20 -20
- package/src/api-handlers/account/reset-password.ts +87 -87
- package/src/api-handlers/account/send-code.ts +70 -70
- package/src/api-handlers/account/update-phone.ts +84 -84
- package/src/api-handlers/account/validate-password.ts +139 -139
- package/src/api-handlers/account/verify-email.ts +134 -134
- package/src/api-handlers/account/verify-sms.ts +134 -134
- package/src/api-handlers/admin/analytics.ts +452 -452
- package/src/api-handlers/admin/audit.ts +223 -232
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +259 -259
- package/src/api-handlers/admin/sessions.ts +341 -327
- package/src/api-handlers/admin/site-logs.ts +388 -388
- package/src/api-handlers/admin/stats.ts +260 -260
- package/src/api-handlers/admin/users.ts +240 -251
- package/src/api-handlers/admin/vibe-data.ts +338 -333
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
- package/src/api-handlers/auth/credential-login.ts +948 -948
- package/src/api-handlers/auth/jwks.ts +31 -31
- package/src/api-handlers/auth/login.ts +252 -252
- package/src/api-handlers/auth/passkey-signin.ts +116 -116
- package/src/api-handlers/auth/refresh.ts +829 -829
- package/src/api-handlers/auth/signout.ts +201 -201
- package/src/api-handlers/auth/status.ts +23 -23
- package/src/api-handlers/auth/update-session.ts +144 -144
- package/src/api-handlers/auth/validate.ts +45 -45
- package/src/api-handlers/auth/verify-code.ts +116 -116
- package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
- package/src/api-handlers/mfa/backup-codes.ts +42 -42
- package/src/api-handlers/mfa/methods.ts +47 -47
- package/src/api-handlers/mfa/totp-confirm.ts +43 -43
- package/src/api-handlers/mfa/totp-enroll.ts +43 -43
- package/src/api-handlers/mfa/totp-verify.ts +172 -172
- package/src/api-handlers/oauth/resume.ts +182 -182
- package/src/api-handlers/onboarding/send-email-code.ts +18 -18
- package/src/api-handlers/onboarding/set-password.ts +21 -21
- package/src/api-handlers/onboarding/verify-code.ts +20 -20
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
- package/src/api-handlers/passkeys/list.ts +32 -32
- package/src/api-handlers/passkeys/manage.ts +55 -55
- package/src/api-handlers/passkeys/register-options.ts +43 -43
- package/src/api-handlers/passkeys/register-verify.ts +42 -42
- package/src/api-handlers/session/refresh-viability.ts +56 -56
- package/src/api-handlers/session/viability.ts +238 -238
- package/src/api-handlers/test/force-expire.ts +167 -167
- package/src/auth/auth-decision.ts +292 -292
- package/src/auth/better-auth.ts +1688 -1688
- package/src/auth/idp-oauth.ts +310 -310
- package/src/auth/password-reset-bridge.ts +98 -98
- package/src/auth/revocation-fields.ts +16 -16
- package/src/auth/route-config.ts +236 -236
- package/src/auth/safe-callback.ts +199 -199
- package/src/auth/session-cookie-clear.ts +30 -30
- package/src/auth/session-reset-link.ts +159 -159
- package/src/auth/session-response-sanitizer.ts +151 -151
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/auth/utils/idp-client.ts +364 -364
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +192 -192
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +277 -277
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +508 -508
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +421 -421
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/CredentialSignInForm.tsx +240 -240
- package/src/components/auth/FederatedAuthSection.tsx +129 -129
- package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
- package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
- package/src/components/auth/PasswordResetBridge.tsx +30 -30
- package/src/components/auth/TraditionalAuthSection.tsx +103 -103
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/RequestedStep.tsx +30 -30
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +118 -118
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +55 -55
- package/src/config/logger.ts +62 -62
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +109 -109
- package/src/hooks/useAvailableProviders.ts +143 -143
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/useProfile.ts +82 -82
- package/src/hooks/usePublicAuthSettings.ts +171 -171
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +337 -337
- package/src/index.ts +64 -64
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +228 -228
- package/src/lib/authorize-resume.ts +172 -172
- package/src/lib/bearer-fetch.ts +31 -31
- package/src/lib/client-key.ts +88 -88
- package/src/lib/code-step-copy.ts +83 -83
- package/src/lib/credential-sign-in-client.ts +101 -101
- package/src/lib/demo-mode.ts +18 -18
- package/src/lib/device-id.ts +67 -67
- package/src/lib/ensure-fresh-access-token.ts +509 -509
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-client-config.ts +1005 -1005
- package/src/lib/idp-fetch.ts +169 -169
- package/src/lib/idp-url-guard.ts +37 -37
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/lock-window.ts +49 -49
- package/src/lib/login-error-message.ts +41 -41
- package/src/lib/never-in-response.ts +64 -64
- package/src/lib/nextauth-secret.ts +168 -168
- package/src/lib/onboarding-proxy.ts +57 -57
- package/src/lib/password-rules.ts +50 -50
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +105 -105
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/roles.ts +177 -177
- package/src/lib/safe-error-response.ts +149 -149
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-cookie-shape.ts +185 -185
- package/src/lib/session-store.ts +1092 -1092
- package/src/lib/session.ts +34 -34
- package/src/lib/single-flight.ts +64 -64
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/startup-init.ts +276 -276
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +546 -546
- package/src/lib/token-log-id.ts +69 -69
- package/src/lib/twofa-rejection.ts +91 -91
- package/src/lib/types/api-responses.ts +336 -336
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +1141 -1141
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +331 -331
- package/src/pages/admin-login/page.tsx +111 -111
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +338 -338
- package/src/pages/onboarding/index.ts +11 -11
- package/src/pages/onboarding/page.tsx +212 -212
- package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +267 -267
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
- package/src/pages/security/index.ts +12 -12
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/service-unavailable/page.tsx +63 -63
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
- package/src/pages/test-env/JwtInspectPage.tsx +449 -449
- package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
- package/src/pages/test-env/TestEnvPage.tsx +130 -130
- package/src/pages/test-env/guarded.tsx +81 -81
- package/src/pages/test-env/index.ts +77 -77
- package/src/pages/test-env/redact-credentials.ts +96 -96
- package/src/pages/verify-code/page.tsx +746 -746
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/credential-login-email-code.ts +6 -6
- package/src/routes/auth/credential-login.ts +6 -6
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/logout.ts +111 -111
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +77 -77
- package/src/routes/auth/session-reset.ts +37 -37
- package/src/routes/auth/session.ts +261 -261
- package/src/routes/auth/settings.ts +181 -181
- package/src/routes/auth/viability.ts +519 -519
- package/src/routes/index.ts +17 -17
- 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/onboarding/index.ts +15 -15
- package/src/routes/onboarding/send-email-code.ts +2 -2
- package/src/routes/onboarding/set-password.ts +2 -2
- package/src/routes/onboarding/verify-code.ts +2 -2
- package/src/routes/onboarding/verify-welcome-email.ts +2 -2
- 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/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth-guard.ts +203 -203
- package/src/server/auth.ts +486 -486
- package/src/server/decode-session.ts +309 -309
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/verified-session-token.ts +103 -103
- package/src/server/with-auth.ts +93 -93
- package/src/services/signalrActivityService.ts +258 -258
- package/src/stores/authStore.ts +1900 -1900
- package/src/templates/instrumentation.ts +60 -60
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +48 -48
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +401 -401
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +878 -877
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +209 -209
- package/src/vibe/filters.ts +52 -0
- package/src/vibe/generic.ts +340 -339
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +133 -133
- package/src/vibe/sessions.ts +925 -899
- package/src/vibe/types.ts +577 -577
package/src/auth/better-auth.ts
CHANGED
|
@@ -1,1688 +1,1688 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Better Auth Configuration
|
|
3
|
-
*
|
|
4
|
-
* Primary auth configuration. Replaces the former NextAuth auth-options.ts.
|
|
5
|
-
*
|
|
6
|
-
* Architecture: No database adapter — Better Auth runs in stateless mode
|
|
7
|
-
* with JWE cookie cache. User management stays on IDP, sessions on Redis.
|
|
8
|
-
*
|
|
9
|
-
* @see BETTER-AUTH-MIGRATION-SPEC.md
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import 'server-only';
|
|
13
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
-
import { createHash } from 'crypto';
|
|
15
|
-
import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
|
|
16
|
-
import { betterAuth } from 'better-auth';
|
|
17
|
-
import { nextCookies } from 'better-auth/next-js';
|
|
18
|
-
import { toNextJsHandler } from 'better-auth/next-js';
|
|
19
|
-
import { wrapBetterAuthHandler } from './session-response-sanitizer';
|
|
20
|
-
import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
|
|
21
|
-
import { resolveClientKey } from '../lib/client-key';
|
|
22
|
-
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
|
23
|
-
import {
|
|
24
|
-
createIdpOAuthHooks,
|
|
25
|
-
resolveIdpRedirectUri,
|
|
26
|
-
type IdpTokenGrant,
|
|
27
|
-
type IdpVerifiedClaims,
|
|
28
|
-
} from './idp-oauth';
|
|
29
|
-
import type { IDPClientConfig } from '../lib/idp-client-config';
|
|
30
|
-
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
31
|
-
import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
|
|
32
|
-
import { getRedis } from '../lib/redis';
|
|
33
|
-
import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
|
|
34
|
-
import { SESSION_TTL } from '../lib/session-store';
|
|
35
|
-
import { tokenLogId } from '../lib/token-log-id';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
39
|
-
* to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
|
|
40
|
-
* = 48 bits; collision-tellable across a deployment, useless as a credential.
|
|
41
|
-
*/
|
|
42
|
-
export function secretFingerprint(value: string): string {
|
|
43
|
-
return createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
|
|
48
|
-
* than Better Auth, which must survive a Better Auth session write.
|
|
49
|
-
*
|
|
50
|
-
* Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
|
|
51
|
-
* list is a named constant rather than a hardcoded check because the shape of
|
|
52
|
-
* the bug is "a second author appeared and the adapter did not know" — the next
|
|
53
|
-
* enrichment key should be added here, not rediscovered as a strip.
|
|
54
|
-
*/
|
|
55
|
-
const PRESERVED_SESSION_KEYS = ['idpTokens'] as const;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* True for a JSON object we can safely merge into — not null, not an array.
|
|
59
|
-
* `typeof null === 'object'` and arrays are objects, and merging into either
|
|
60
|
-
* would corrupt the record rather than preserve it.
|
|
61
|
-
*/
|
|
62
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
63
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Better Auth social provider config shape.
|
|
68
|
-
*/
|
|
69
|
-
export interface BetterAuthSocialProvider {
|
|
70
|
-
clientId: string;
|
|
71
|
-
clientSecret: string;
|
|
72
|
-
scope?: string[];
|
|
73
|
-
prompt?: string;
|
|
74
|
-
accessType?: 'offline' | 'online';
|
|
75
|
-
hd?: string;
|
|
76
|
-
/**
|
|
77
|
-
* 260419: better-auth's ONLY guard against OAuth auto-provisioning
|
|
78
|
-
* (oauth2/link-account.mjs handleOAuthUserInfo: `if (disableSignUp) return
|
|
79
|
-
* {error:"signup disabled"}`). Unset means ANY unknown Google identity gets a
|
|
80
|
-
* local account created at callback. We wire it from the tenant's own policy.
|
|
81
|
-
*/
|
|
82
|
-
disableSignUp?: boolean;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Build Better Auth social providers from IDP config.
|
|
87
|
-
*/
|
|
88
|
-
/**
|
|
89
|
-
* REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
|
|
90
|
-
*
|
|
91
|
-
* WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
|
|
92
|
-
* ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
|
|
93
|
-
* for the IDP to require a provider-signed id_token and verify it itself. This
|
|
94
|
-
* is the client half: capture the token Better Auth already receives, and
|
|
95
|
-
* forward it. Additive — the IDP ignores it until its gate flips.
|
|
96
|
-
*
|
|
97
|
-
* WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
|
|
98
|
-
* an id_token is credential material. Writing it to the session record would put
|
|
99
|
-
* it one allow-list edit away from leaking out of /api/internal/session-data,
|
|
100
|
-
* and it would outlive the request that needed it. ALS scopes it to the single
|
|
101
|
-
* request, and it is gone when the request ends — nothing to expire, nothing to
|
|
102
|
-
* scrub, nothing to leak.
|
|
103
|
-
*
|
|
104
|
-
* SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
|
|
105
|
-
* exchange (which reads it) both run inside createAuthGetHandler's own request.
|
|
106
|
-
* VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
|
|
107
|
-
* that invokes getUserInfo — and the exchange follows at :465 in the SAME
|
|
108
|
-
* function. Outside that scope the store is undefined and the body simply omits
|
|
109
|
-
* id_token, which is the correct additive behaviour.
|
|
110
|
-
*/
|
|
111
|
-
/**
|
|
112
|
-
* ON globalThis FOR THE SAME REASON THE BETTER AUTH INSTANCE IS (181814, see STATE_KEY
|
|
113
|
-
* below) — and it was NOT, which is the whole defect.
|
|
114
|
-
*
|
|
115
|
-
* This module is loaded more than once per process: instrumentation and the auth route
|
|
116
|
-
* are different chunks with their own module registries. `state` was moved to globalThis
|
|
117
|
-
* so both copies share the Better Auth INSTANCE. This AsyncLocalStorage was left in
|
|
118
|
-
* module scope, so each copy got its own.
|
|
119
|
-
*
|
|
120
|
-
* The result, measured on UMI 2026-08-26 against idp.payez.net:
|
|
121
|
-
*
|
|
122
|
-
* copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
|
|
123
|
-
* copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
|
|
124
|
-
* getUserInfo fires, reads A -> getStore() is undefined, `if (scope)` skips
|
|
125
|
-
* the exchange reads B -> empty, so id_token is omitted from the body
|
|
126
|
-
*
|
|
127
|
-
* Sign-in SUCCEEDS at every visible step. The user gets a session, an avatar, an email.
|
|
128
|
-
* The IDP then answers 401 UNVERIFIED_ASSERTION with reason="id_token missing"
|
|
129
|
-
* (confirmed in its own logs, correlationId 0HNO2755RESR1) — so the client half of 138635
|
|
130
|
-
* has been shipping as a no-op wherever the module double-loads, and the security fix it
|
|
131
|
-
* exists to deliver never arrived. A shared instance plus a per-copy scope is the worst
|
|
132
|
-
* combination: everything looks wired, and the one field that proves identity is dropped.
|
|
133
|
-
*
|
|
134
|
-
* globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
|
|
135
|
-
* carrier needs to be reachable from every copy that might run inside the request.
|
|
136
|
-
*/
|
|
137
|
-
/**
|
|
138
|
-
* THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
|
|
139
|
-
* THINGS. Renaming it would give a copy on the old name and a copy on the new
|
|
140
|
-
* name two DIFFERENT stores in the same process — which is precisely the
|
|
141
|
-
* double-load failure documented above, reintroduced by a cosmetic rename. The
|
|
142
|
-
* name is a wire contract between copies, not a description.
|
|
143
|
-
*
|
|
144
|
-
* accessToken is here for providers that do NOT issue an id_token. GitHub is
|
|
145
|
-
* not an OIDC provider: it has no id_token, no JWKS and no discovery document,
|
|
146
|
-
* so `id_token` can never be the field that proves a GitHub identity. The IDP
|
|
147
|
-
* re-derives that identity by calling GitHub itself with this access token
|
|
148
|
-
* (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
|
|
149
|
-
* verifies for itself and never trusts an asserted email.
|
|
150
|
-
*/
|
|
151
|
-
const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
|
|
152
|
-
type ProviderTokenScope = {
|
|
153
|
-
idToken?: string;
|
|
154
|
-
accessToken?: string;
|
|
155
|
-
/** PAY-2305: what the payez-idp getToken / getUserInfo hooks captured for THIS request (never persisted here). */
|
|
156
|
-
idp?: { tokens?: IdpTokenGrant; claims?: IdpVerifiedClaims };
|
|
157
|
-
};
|
|
158
|
-
const gScope = globalThis as unknown as Record<
|
|
159
|
-
string,
|
|
160
|
-
AsyncLocalStorage<ProviderTokenScope> | undefined
|
|
161
|
-
>;
|
|
162
|
-
gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
|
|
163
|
-
const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
|
|
167
|
-
* provider credentials in the app.
|
|
168
|
-
*
|
|
169
|
-
* ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
|
|
170
|
-
* Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
|
|
171
|
-
* is uniform with the ratified desktop leg. The IDP holds the provider secret
|
|
172
|
-
* and performs the provider exchange server-side.
|
|
173
|
-
*
|
|
174
|
-
* ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
|
|
175
|
-
* QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
|
|
176
|
-
* clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
|
|
177
|
-
* on whether we need a secret. This builds the generic/custom shape
|
|
178
|
-
* deliberately; reaching for a preset because it looks tidier would silently
|
|
179
|
-
* kill keyless-on-web and nobody would connect the two changes.
|
|
180
|
-
*
|
|
181
|
-
* ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
|
|
182
|
-
* Returns null unless the IDP's OAuth endpoints are explicitly configured. That
|
|
183
|
-
* makes this ADDITIVE: the existing socialProviders path keeps working until an
|
|
184
|
-
* operator turns this on. It is NOT a runtime failover - nothing silently
|
|
185
|
-
* chooses between them, because a silent chooser is how a migration ends up
|
|
186
|
-
* half-applied and undetectable.
|
|
187
|
-
*
|
|
188
|
-
* ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
|
|
189
|
-
* the app stops talking to Google, so it can no longer capture the raw
|
|
190
|
-
* id_token that oauth-callback verifies today, and the per-provider settings
|
|
191
|
-
* below stop being ours to set - profile scope, prompt=select_account,
|
|
192
|
-
* accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
|
|
193
|
-
* exchange must verify signature/iss/aud/exp itself and preserve those four, or
|
|
194
|
-
* they are lost silently.
|
|
195
|
-
*/
|
|
196
|
-
export interface IdpOAuthProviderConfig {
|
|
197
|
-
providerId: string;
|
|
198
|
-
discoveryUrl: string;
|
|
199
|
-
clientId: string;
|
|
200
|
-
pkce: true;
|
|
201
|
-
scopes: string[];
|
|
202
|
-
/** PAY-2305: the REGISTERED {site_url}/api/auth/callback, sent identically at authorize and token time. */
|
|
203
|
-
redirectURI: string;
|
|
204
|
-
getToken: ReturnType<typeof createIdpOAuthHooks>['getToken'];
|
|
205
|
-
getUserInfo: ReturnType<typeof createIdpOAuthHooks>['getUserInfo'];
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProviderConfig | null {
|
|
209
|
-
const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
|
|
210
|
-
const clientId = process.env.CLIENT_ID;
|
|
211
|
-
if (!discoveryUrl || !clientId) return null;
|
|
212
|
-
|
|
213
|
-
const redirectURI = resolveIdpRedirectUri(
|
|
214
|
-
appBaseUrl || process.env.BETTER_AUTH_URL || process.env.APP_URL || `http://localhost:${process.env.PORT || '3000'}`,
|
|
215
|
-
);
|
|
216
|
-
const hooks = createIdpOAuthHooks({
|
|
217
|
-
discoveryUrl,
|
|
218
|
-
clientId,
|
|
219
|
-
redirectURI,
|
|
220
|
-
issuer: process.env.IDP_OAUTH_ISSUER || undefined,
|
|
221
|
-
trustedOrigins: process.env.IDP_OAUTH_TRUSTED_ORIGINS
|
|
222
|
-
? process.env.IDP_OAUTH_TRUSTED_ORIGINS.split(',').map((x) => x.trim()).filter(Boolean)
|
|
223
|
-
: undefined,
|
|
224
|
-
onTokens: (tokens) => {
|
|
225
|
-
const store = idTokenScope.getStore();
|
|
226
|
-
if (store) (store.idp ??= {}).tokens = tokens;
|
|
227
|
-
},
|
|
228
|
-
onClaims: (claims) => {
|
|
229
|
-
const store = idTokenScope.getStore();
|
|
230
|
-
if (store) (store.idp ??= {}).claims = claims;
|
|
231
|
-
},
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
return {
|
|
235
|
-
providerId: 'payez-idp',
|
|
236
|
-
discoveryUrl,
|
|
237
|
-
clientId,
|
|
238
|
-
redirectURI,
|
|
239
|
-
getToken: hooks.getToken,
|
|
240
|
-
getUserInfo: hooks.getUserInfo,
|
|
241
|
-
// NO clientSecret. The whole point: this app is not a credential holder.
|
|
242
|
-
pkce: true,
|
|
243
|
-
scopes: ['openid', 'email', 'profile'],
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* ⚠ DO NOT ADD `authentication: 'basic'` HERE. IT SILENTLY BREAKS KEYLESS.
|
|
247
|
-
*
|
|
248
|
-
* Found by QAPert-NightHawk (21348) — the second config trap in this plugin
|
|
249
|
-
* after the preset one, and the same shape: a field that looks like a
|
|
250
|
-
* harmless transport detail and quietly changes whether we are a public
|
|
251
|
-
* client at all.
|
|
252
|
-
*
|
|
253
|
-
* @better-auth/core validate-authorization-code.mjs:
|
|
254
|
-
* if (authentication === "basic") {
|
|
255
|
-
* headers.authorization = `Basic ${base64(`${clientId}:${clientSecret ?? ""}`)}`;
|
|
256
|
-
* } else {
|
|
257
|
-
* body.set("client_id", clientId);
|
|
258
|
-
* if (options.clientSecret) body.set("client_secret", options.clientSecret);
|
|
259
|
-
* }
|
|
260
|
-
*
|
|
261
|
-
* Note what the 'basic' branch does with NO secret: it sends an EMPTY
|
|
262
|
-
* password, `Basic base64("<clientId>:")`. That is not "no authentication",
|
|
263
|
-
* it is WRONG authentication — we would be presenting ourselves as a
|
|
264
|
-
* confidential client with a blank credential. The default branch is the
|
|
265
|
-
* one that produces a genuine public-client PKCE request.
|
|
266
|
-
*
|
|
267
|
-
* Leaving this field UNSET is load-bearing, which is exactly why it needs a
|
|
268
|
-
* comment: nothing about an absent field advertises that its absence is a
|
|
269
|
-
* decision.
|
|
270
|
-
*/
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export function buildBetterAuthProviders(
|
|
275
|
-
config: IDPClientConfig
|
|
276
|
-
): Record<string, BetterAuthSocialProvider> {
|
|
277
|
-
const providers: Record<string, BetterAuthSocialProvider> = {};
|
|
278
|
-
|
|
279
|
-
for (const oauth of config.oauthProviders || []) {
|
|
280
|
-
if (!oauth.enabled) continue;
|
|
281
|
-
const name = oauth.provider.toLowerCase();
|
|
282
|
-
const additionalParams = oauth.additionalParams ?? {};
|
|
283
|
-
const rawPrompt = additionalParams.prompt;
|
|
284
|
-
const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
|
|
285
|
-
const rawHostedDomain = additionalParams.hd;
|
|
286
|
-
|
|
287
|
-
// Ensure profile scope is present for Google so avatar image is returned
|
|
288
|
-
const scopes = oauth.scopes?.split(' ') || [];
|
|
289
|
-
if (name === 'google' && !scopes.includes('profile')) {
|
|
290
|
-
scopes.push('profile');
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* 224721: better-auth's google provider UNCONDITIONALLY seeds its default
|
|
295
|
-
* scopes ['email','profile','openid'] and then pushes our `scope` verbatim
|
|
296
|
-
* (@better-auth/core social-providers/google.mjs createAuthorizationURL) —
|
|
297
|
-
* the row's "openid email profile" reached Google as
|
|
298
|
-
* "email profile openid openid email profile". Semantically harmless
|
|
299
|
-
* (Google dedupes) but sloppy on a URL a demo audience can read. Hand over
|
|
300
|
-
* only what the defaults do not already cover; undefined when nothing
|
|
301
|
-
* remains, so the provider emits exactly its default set.
|
|
302
|
-
*/
|
|
303
|
-
const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
|
|
304
|
-
/**
|
|
305
|
-
* Same defect, same fix, different provider: github's createAuthorizationURL
|
|
306
|
-
* also seeds ['read:user','user:email'] unconditionally and then pushes ours
|
|
307
|
-
* on top (@better-auth/core social-providers/github.ts), so the stored row
|
|
308
|
-
* "read:user user:email" reached GitHub as
|
|
309
|
-
* "read:user user:email read:user user:email". Measured on :4799, not
|
|
310
|
-
* assumed. GitHub dedupes, so this is legibility rather than function — but
|
|
311
|
-
* a scope list is the first thing a security reviewer reads off that URL.
|
|
312
|
-
*/
|
|
313
|
-
const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
314
|
-
const wireScopes =
|
|
315
|
-
name === 'google'
|
|
316
|
-
? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
|
|
317
|
-
: name === 'github'
|
|
318
|
-
? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
|
|
319
|
-
: scopes;
|
|
320
|
-
|
|
321
|
-
providers[name] = {
|
|
322
|
-
clientId: oauth.clientId,
|
|
323
|
-
clientSecret: oauth.clientSecret,
|
|
324
|
-
scope: wireScopes.length > 0 ? wireScopes : undefined,
|
|
325
|
-
/**
|
|
326
|
-
* PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
|
|
327
|
-
*
|
|
328
|
-
* 260419 wired this from `config.authSettings?.allowPublicRegistration` to
|
|
329
|
-
* close a client-side self-serve provisioning door. It cannot do that job
|
|
330
|
-
* correctly, and it BREAKS SIGN-IN:
|
|
331
|
-
*
|
|
332
|
-
* - This app runs better-auth in STATELESS mode (no DB adapter — see the
|
|
333
|
-
* file header). better-auth therefore has NO user store, so at the OAuth
|
|
334
|
-
* callback it cannot tell an EXISTING user from a NEW one. Its
|
|
335
|
-
* handleOAuthUserInfo consults `disableSignUp` for BOTH, so
|
|
336
|
-
* `disableSignUp: true` refuses a returning user exactly like a
|
|
337
|
-
* stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
|
|
338
|
-
* Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
|
|
339
|
-
* once client 12's allow_public_registration reported false.
|
|
340
|
-
*
|
|
341
|
-
* - The REAL registration gate already exists and is correct, at the IDP:
|
|
342
|
-
* PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
|
|
343
|
-
* user up by verified email (:799); an existing user logs in UNTOUCHED
|
|
344
|
-
* (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
|
|
345
|
-
* which refuses fail-closed when the client forbids public registration
|
|
346
|
-
* (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
|
|
347
|
-
* Its own comment states the contract: "existing federated users ...
|
|
348
|
-
* log in untouched, so gating here never blocks login."
|
|
349
|
-
*
|
|
350
|
-
* So this flag was a DUPLICATE gate that could only ever misfire — it cannot
|
|
351
|
-
* distinguish new from existing, and the IDP already refuses new identities.
|
|
352
|
-
* Removing it restores sign-in for existing users WITHOUT reopening
|
|
353
|
-
* self-serve: a NEW identity that completes the better-auth leg still gets
|
|
354
|
-
* REFUSED at the IDP exchange and never receives IDP tokens, which is the
|
|
355
|
-
* portal's actual authorization gate.
|
|
356
|
-
*
|
|
357
|
-
* WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
|
|
358
|
-
* (above) as the backstop, and each app's own front-door sign-up gate
|
|
359
|
-
* (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
|
|
360
|
-
* Both are correct and neither is this flag.
|
|
361
|
-
*/
|
|
362
|
-
// Google is overly eager to reuse the last account unless we
|
|
363
|
-
// explicitly ask for account selection on each social login.
|
|
364
|
-
prompt: typeof rawPrompt === 'string'
|
|
365
|
-
? rawPrompt
|
|
366
|
-
: name === 'google'
|
|
367
|
-
? 'select_account'
|
|
368
|
-
: undefined,
|
|
369
|
-
accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
|
|
370
|
-
hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* CAPTURE THE RAW id_token — card 138635, client half.
|
|
375
|
-
*
|
|
376
|
-
* getUserInfo is the ONLY provider hook that receives the token set.
|
|
377
|
-
* mapProfileToUser cannot do this: it is handed the DECODED claims, and a
|
|
378
|
-
* signed JWT cannot be reconstructed from them. We need the raw token
|
|
379
|
-
* because the IDP must verify the signature ITSELF — a client-decoded
|
|
380
|
-
* claim is just another assertable field, which is the defect restated.
|
|
381
|
-
*
|
|
382
|
-
* ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
|
|
383
|
-
* `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
|
|
384
|
-
* returns our result verbatim, so everything the default did is now ours to
|
|
385
|
-
* do — including the `hd` hosted-domain restriction.
|
|
386
|
-
*
|
|
387
|
-
* `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
|
|
388
|
-
* above, so a client can restrict sign-in to a Google Workspace domain.
|
|
389
|
-
* Silently dropping it would let ANY Google account into a client that had
|
|
390
|
-
* restricted itself — a security regression introduced by a security fix.
|
|
391
|
-
* (Measured on 93: zero clients set it today. That is a reason to be
|
|
392
|
-
* careful, not a reason to skip it — it is configurable and unused, which
|
|
393
|
-
* is exactly the state in which a regression goes unnoticed.)
|
|
394
|
-
*
|
|
395
|
-
* SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
|
|
396
|
-
* ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
|
|
397
|
-
* `if (!configuredHostedDomain) return true`. With no hd configured the
|
|
398
|
-
* default would return true unconditionally, so omitting it is equivalent
|
|
399
|
-
* by the library's own definition rather than by our judgement.
|
|
400
|
-
*
|
|
401
|
-
* A client that DOES set hd keeps the stock provider, keeps its domain
|
|
402
|
-
* restriction, and simply does not contribute an id_token yet. Losing a
|
|
403
|
-
* capability is an acceptable cost; silently losing a security control is
|
|
404
|
-
* not. (better-auth does not re-export the predicate through its public
|
|
405
|
-
* types, and reimplementing a security check is how two copies drift — so
|
|
406
|
-
* the honest move is to not need it.)
|
|
407
|
-
*/
|
|
408
|
-
if (name === 'google' && !providers[name].hd) {
|
|
409
|
-
(providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
|
|
410
|
-
if (!token?.idToken) return null;
|
|
411
|
-
|
|
412
|
-
// The only new behaviour: hand the raw token to this request's scope.
|
|
413
|
-
const scope = idTokenScope.getStore();
|
|
414
|
-
if (scope) {
|
|
415
|
-
scope.idToken = token.idToken;
|
|
416
|
-
} else {
|
|
417
|
-
// NOT a silent skip. A missing scope here means 138635 degrades to a no-op:
|
|
418
|
-
// the login still succeeds, and the IDP rejects the exchange with
|
|
419
|
-
// reason="id_token missing" a second later, with nothing connecting the two.
|
|
420
|
-
// That cost a day on UMI. If this fires, the OAuth callback is running outside
|
|
421
|
-
// createAuthGetHandler — either the route mounts the bare
|
|
422
|
-
// `routes/auth/nextauth` re-export instead of createAuthGetHandler, or this
|
|
423
|
-
// module has double-loaded and the scope is not shared (see 181814 above).
|
|
424
|
-
console.warn(
|
|
425
|
-
'[BETTER_AUTH] id_token captured but NO REQUEST SCOPE to put it in — it will ' +
|
|
426
|
-
'NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
427
|
-
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// Mirrors the upstream default (google.mjs) verbatim, minus the hd
|
|
432
|
-
// branch that cannot fire here — so overriding the hook changes WHAT WE
|
|
433
|
-
// KEEP, never what we ACCEPT.
|
|
434
|
-
const claims = decodeJwt(token.idToken) as Record<string, any>;
|
|
435
|
-
return {
|
|
436
|
-
user: {
|
|
437
|
-
id: claims.sub,
|
|
438
|
-
name: claims.name,
|
|
439
|
-
email: claims.email,
|
|
440
|
-
image: claims.picture,
|
|
441
|
-
emailVerified: claims.email_verified,
|
|
442
|
-
},
|
|
443
|
-
data: claims,
|
|
444
|
-
};
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
|
|
450
|
-
*
|
|
451
|
-
* GitHub is not an OIDC provider — it issues no id_token — so the google
|
|
452
|
-
* branch above can never fire for it and the exchange would post an
|
|
453
|
-
* assertion with nothing that proves identity. That is exactly the
|
|
454
|
-
* 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
|
|
455
|
-
* `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
|
|
456
|
-
* the IDP rejected it instantly, without ever calling GitHub.
|
|
457
|
-
*
|
|
458
|
-
* getUserInfo is the ONLY provider hook that receives the token set (see the
|
|
459
|
-
* google note above), and @better-auth/core's github provider calls
|
|
460
|
-
* `options.getUserInfo(token)` BEFORE its own body — so installing this
|
|
461
|
-
* REPLACES the default rather than wrapping it, and the default's work has
|
|
462
|
-
* to be reproduced here or the sign-in loses its profile entirely.
|
|
463
|
-
*
|
|
464
|
-
* Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
|
|
465
|
-
* the version the container actually runs (better-auth 1.5.6 — the graft's
|
|
466
|
-
* node_modules says 1.6.29 and is NOT what executes). Kept deliberately
|
|
467
|
-
* dumb: it must not "improve" on upstream, because every divergence here is
|
|
468
|
-
* a behaviour change disguised as a token capture.
|
|
469
|
-
*
|
|
470
|
-
* This is NOT the identity check. The IDP re-derives the GitHub identity
|
|
471
|
-
* itself from this access token and enforces primary && verified server-side
|
|
472
|
-
* (GitHubIdentityVerifier); what we return here only shapes the local
|
|
473
|
-
* better-auth user record.
|
|
474
|
-
*/
|
|
475
|
-
if (name === 'github') {
|
|
476
|
-
(providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
|
|
477
|
-
accessToken?: string;
|
|
478
|
-
}) => {
|
|
479
|
-
const store = idTokenScope.getStore();
|
|
480
|
-
if (store) {
|
|
481
|
-
store.accessToken = token?.accessToken;
|
|
482
|
-
} else {
|
|
483
|
-
console.warn(
|
|
484
|
-
'[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
|
|
485
|
-
'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
486
|
-
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const ghHeaders = {
|
|
491
|
-
'User-Agent': 'better-auth',
|
|
492
|
-
Authorization: `Bearer ${token?.accessToken}`,
|
|
493
|
-
};
|
|
494
|
-
|
|
495
|
-
const profileRes = await fetch('https://api.github.com/user', {
|
|
496
|
-
headers: ghHeaders,
|
|
497
|
-
});
|
|
498
|
-
// Upstream returns null on profile error, which fails the sign-in
|
|
499
|
-
// closed. Preserved rather than softened.
|
|
500
|
-
if (!profileRes.ok) {
|
|
501
|
-
console.warn(
|
|
502
|
-
`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
|
|
503
|
-
);
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
const profile = (await profileRes.json()) as Record<string, any>;
|
|
507
|
-
|
|
508
|
-
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
|
509
|
-
headers: ghHeaders,
|
|
510
|
-
});
|
|
511
|
-
const emails = emailsRes.ok
|
|
512
|
-
? ((await emailsRes.json()) as Array<{
|
|
513
|
-
email: string;
|
|
514
|
-
primary: boolean;
|
|
515
|
-
verified: boolean;
|
|
516
|
-
}>)
|
|
517
|
-
: undefined;
|
|
518
|
-
|
|
519
|
-
// A GitHub account with a private email returns profile.email = null;
|
|
520
|
-
// upstream falls back to the primary address here.
|
|
521
|
-
if (!profile.email && emails) {
|
|
522
|
-
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
|
|
523
|
-
}
|
|
524
|
-
const emailVerified =
|
|
525
|
-
emails?.find((e) => e.email === profile.email)?.verified ?? false;
|
|
526
|
-
|
|
527
|
-
return {
|
|
528
|
-
user: {
|
|
529
|
-
id: profile.id,
|
|
530
|
-
name: profile.name || profile.login || '',
|
|
531
|
-
email: profile.email,
|
|
532
|
-
image: profile.avatar_url,
|
|
533
|
-
emailVerified,
|
|
534
|
-
},
|
|
535
|
-
data: profile,
|
|
536
|
-
};
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
return providers;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* Optional configuration for `createBetterAuthInstance`.
|
|
546
|
-
*
|
|
547
|
-
* - `magicLink`: if provided, registers Better Auth's magic-link plugin.
|
|
548
|
-
* The host app supplies its own `sendMagicLink` callback — typically a
|
|
549
|
-
* fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
|
|
550
|
-
* Omit the `magicLink` key entirely to skip the plugin; the consuming
|
|
551
|
-
* app will not have a magic-link flow.
|
|
552
|
-
*/
|
|
553
|
-
export interface CreateBetterAuthInstanceOptions {
|
|
554
|
-
magicLink?: MagicLinkOptions;
|
|
555
|
-
/**
|
|
556
|
-
* Extra Better Auth plugins supplied by the consuming app.
|
|
557
|
-
*
|
|
558
|
-
* WHY THIS EXISTS — session minting is not delegable any other way. BA's
|
|
559
|
-
* `setSessionCookie` requires a `GenericEndpointContext`, which only BA's own
|
|
560
|
-
* endpoint machinery constructs, so an app that must mint a session for an
|
|
561
|
-
* identity established OUTSIDE BA's built-in providers (passkey sign-in being
|
|
562
|
-
* the first) cannot do it from a route handler. It needs a real BA endpoint,
|
|
563
|
-
* and endpoints only arrive via plugins.
|
|
564
|
-
*
|
|
565
|
-
* Deliberately GENERIC. The package gains "an app may supply plugins" rather
|
|
566
|
-
* than any knowledge of passkeys — the alternative was teaching this file
|
|
567
|
-
* about a specific auth method, which is how a package accumulates a consumer's
|
|
568
|
-
* feature list.
|
|
569
|
-
*
|
|
570
|
-
* The alternative to this option is an app hand-rolling the session cookie
|
|
571
|
-
* (`{token}.{base64(hmac)}`, standard padding, runtime `authSecret` from
|
|
572
|
-
* Redis). That fails as a session which DECODES BUT DOES NOT AUTHENTICATE —
|
|
573
|
-
* so this option exists specifically to make that shortcut unnecessary.
|
|
574
|
-
*/
|
|
575
|
-
plugins?: Parameters<typeof betterAuth>[0]['plugins'];
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Create Better Auth instance from IDP config.
|
|
580
|
-
*
|
|
581
|
-
* No database — runs in stateless mode with JWE cookie cache.
|
|
582
|
-
* Call after getIDPClientConfig() resolves.
|
|
583
|
-
*/
|
|
584
|
-
export function createBetterAuthInstance(
|
|
585
|
-
idpConfig: IDPClientConfig,
|
|
586
|
-
opts: CreateBetterAuthInstanceOptions = {}
|
|
587
|
-
) {
|
|
588
|
-
assertAppSlugMatchesIdp(idpConfig.clientSlug);
|
|
589
|
-
const appSlug = idpConfig.clientSlug || getAppSlug();
|
|
590
|
-
|
|
591
|
-
// 185121 line 1: name the secret this instance PINNED, as a hash, never the
|
|
592
|
-
// secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
|
|
593
|
-
// caused by a divergent secret is only diagnosable if every instance
|
|
594
|
-
// creation logged WHICH secret it bound. Both init paths (this module's
|
|
595
|
-
// getBetterAuthInstance and server/auth.ts getAuthInstance) funnel through
|
|
596
|
-
// here, so this one line covers both; getAuthInstance adds its own line
|
|
597
|
-
// naming the path, because it previously created an instance INVISIBLY.
|
|
598
|
-
console.log(
|
|
599
|
-
`[BETTER_AUTH] createBetterAuthInstance: slug=${appSlug} authSecret ${idpConfig.authSecret ? `sha256:${secretFingerprint(idpConfig.authSecret as string)}` : 'ABSENT'}`
|
|
600
|
-
);
|
|
601
|
-
|
|
602
|
-
// Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
|
|
603
|
-
// Must include /api/auth since that's where the catch-all route is mounted
|
|
604
|
-
const rawBaseURL = process.env.BETTER_AUTH_URL
|
|
605
|
-
|| idpConfig.baseClientUrl
|
|
606
|
-
|| `http://localhost:${process.env.PORT || '3000'}`;
|
|
607
|
-
const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
|
|
608
|
-
|
|
609
|
-
const idpOAuth = buildIdpOAuthProviderConfig(rawBaseURL);
|
|
610
|
-
|
|
611
|
-
return betterAuth({
|
|
612
|
-
baseURL,
|
|
613
|
-
secret: idpConfig.authSecret as string,
|
|
614
|
-
|
|
615
|
-
socialProviders: buildBetterAuthProviders(idpConfig),
|
|
616
|
-
|
|
617
|
-
// Trust the app's own origin + any configured base URL
|
|
618
|
-
trustedOrigins: [
|
|
619
|
-
rawBaseURL,
|
|
620
|
-
baseURL,
|
|
621
|
-
...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
|
|
622
|
-
// MEASURED 2026-08-26: this list ended with the literals localhost:3000, :3400
|
|
623
|
-
// and :3600. payez_idp_admin_web runs on :3200 - correctly configured, with
|
|
624
|
-
// BETTER_AUTH_URL=http://localhost:3200 - and every GET /api/auth/get-session
|
|
625
|
-
// returned 400, because the port it actually runs on was not one of the three
|
|
626
|
-
// someone had typed in. A config-shaped constant is not configuration.
|
|
627
|
-
//
|
|
628
|
-
// The app's own origin is already covered by rawBaseURL/baseURL above; the dev
|
|
629
|
-
// origin is DERIVED from PORT, and anything genuinely external comes from
|
|
630
|
-
// BETTER_AUTH_TRUSTED_ORIGINS (comma-separated).
|
|
631
|
-
`http://localhost:${process.env.PORT || '3000'}`,
|
|
632
|
-
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
|
633
|
-
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
|
|
634
|
-
: []),
|
|
635
|
-
],
|
|
636
|
-
|
|
637
|
-
// Redis-backed session storage via secondaryStorage
|
|
638
|
-
secondaryStorage: {
|
|
639
|
-
/**
|
|
640
|
-
* 170033 (QAPert 22654, load-bearing precondition for 183483): DO NOT
|
|
641
|
-
* swallow a Redis failure into `null` here.
|
|
642
|
-
*
|
|
643
|
-
* `null` from `get` is Better Auth's OWN signal for "no session at this
|
|
644
|
-
* key" (see node_modules/better-auth db/internal-adapter.mjs
|
|
645
|
-
* `findSession`: `if (!sessionStringified ...) return null`). Catching
|
|
646
|
-
* a Redis error and returning `null` therefore told better-auth "this
|
|
647
|
-
* session does not exist" when the true state was "the store could not
|
|
648
|
-
* be asked" — indistinguishable from a genuine sign-out. With
|
|
649
|
-
* `cookieCache` disabled (session.cookieCache.enabled above), EVERY
|
|
650
|
-
* `getSession()` call hits this path for real, so a Redis blip
|
|
651
|
-
* manufactured a `200 { session: null }` for every live session at
|
|
652
|
-
* once — the exact channel 183483's client-side guard (verify-code
|
|
653
|
-
* page's session-liveness check) was built to survive, and the one it
|
|
654
|
-
* could not survive while this swallow stood: a fabricated "no
|
|
655
|
-
* session" 200 is not distinguishable from a real one by any client.
|
|
656
|
-
*
|
|
657
|
-
* Letting the error propagate is safe: better-auth's own router
|
|
658
|
-
* (better-call/dist/router.mjs) wraps every endpoint handler in a
|
|
659
|
-
* top-level try/catch and converts an uncaught throw to a genuine
|
|
660
|
-
* `500`, not a crash. A `500` IS the "inconclusive" signal 183483's
|
|
661
|
-
* corrected client guard already knows how to hold a session through —
|
|
662
|
-
* so a Redis outage now surfaces as "couldn't check" instead of
|
|
663
|
-
* silently lying "confirmed logged out".
|
|
664
|
-
*/
|
|
665
|
-
get: async (key: string) => {
|
|
666
|
-
const value = await getRedis().get(`ba:${appSlug}:${key}`);
|
|
667
|
-
// 185121 line 2: the 183047 discriminator. A null here means
|
|
668
|
-
// RECORD-MISS — the store was reachable and the key is absent. If the
|
|
669
|
-
// record IS present but BA still rejects the session, this line does
|
|
670
|
-
// NOT fire and the failure is upstream of storage (HMAC/secret gate).
|
|
671
|
-
// BA-primary logs nothing on failure, so without this line the two
|
|
672
|
-
// surviving forks are indistinguishable in the artifact. Key is
|
|
673
|
-
// fingerprinted: it embeds the session token.
|
|
674
|
-
if (value === null) {
|
|
675
|
-
console.warn(
|
|
676
|
-
`[BETTER_AUTH] secondaryStorage.get MISS ba:${appSlug}:sha256:${secretFingerprint(key)} — record absent, store reachable`
|
|
677
|
-
);
|
|
678
|
-
}
|
|
679
|
-
return value;
|
|
680
|
-
},
|
|
681
|
-
/**
|
|
682
|
-
* 170033 fix 1 — DO NOT SWALLOW A FAILED WRITE. A SESSION THAT WAS NOT
|
|
683
|
-
* PERSISTED IS NOT A SESSION.
|
|
684
|
-
*
|
|
685
|
-
* This previously caught every Redis error and returned normally, so
|
|
686
|
-
* better-auth was told the session had been stored when nothing had been
|
|
687
|
-
* written. The user got a successful login, a session cookie, and a
|
|
688
|
-
* rendered signed-in shell on top of a store that holds no session — and
|
|
689
|
-
* the very next `getSession()` reads `null` and signs them straight back
|
|
690
|
-
* out. Nothing logged, nothing thrown, no banner: a login that "worked"
|
|
691
|
-
* and a sign-out nobody can explain.
|
|
692
|
-
*
|
|
693
|
-
* Letting it propagate is the same contract `get` above already relies on:
|
|
694
|
-
* better-auth's router (better-call/dist/router.mjs) wraps every endpoint
|
|
695
|
-
* handler and converts an uncaught throw into a genuine 500. A 500 on the
|
|
696
|
-
* login write is honest — "we could not store your session" — and it is
|
|
697
|
-
* distinguishable by any client from a successful login, which a swallowed
|
|
698
|
-
* failure is not.
|
|
699
|
-
*
|
|
700
|
-
* THE COMMENT THIS REPLACES SAID "Redis unavailable — cookie cache still
|
|
701
|
-
* works". It does not: `session.cookieCache.enabled` is FALSE (see below).
|
|
702
|
-
* The comment described a safety net that had been removed, which is why
|
|
703
|
-
* this swallow looked survivable to every reader who trusted it (170033
|
|
704
|
-
* fix 3).
|
|
705
|
-
*/
|
|
706
|
-
set: async (key: string, value: string, ttl?: number) => {
|
|
707
|
-
const redis = getRedis();
|
|
708
|
-
const redisKey = `ba:${appSlug}:${key}`;
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
|
|
712
|
-
* (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
|
|
713
|
-
* evidence under BAPert's R920(a). Reproduced here before fixing.)
|
|
714
|
-
*
|
|
715
|
-
* Better Auth owns `{session, user}` and serialises exactly that. But
|
|
716
|
-
* `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
|
|
717
|
-
* `idpTokens` via its own read-modify-write. This setter did a blind
|
|
718
|
-
* `setex` of Better Auth's payload, so every write it makes — including
|
|
719
|
-
* each routine `updateAge` session rewrite, not merely an expiry —
|
|
720
|
-
* STRIPPED `idpTokens` back off the record.
|
|
721
|
-
*
|
|
722
|
-
* The consumer then falls back to an identity husk: `mfaVerified:false`,
|
|
723
|
-
* `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
|
|
724
|
-
* per BAPert it is a public-test blocker on its own terms, because a
|
|
725
|
-
* tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
|
|
726
|
-
* AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
|
|
727
|
-
* failure regardless of which direction it fails in.
|
|
728
|
-
*
|
|
729
|
-
* WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
|
|
730
|
-
* does a read-modify-write; it was correct. The bug is that ONE of the
|
|
731
|
-
* two authors of this key wrote as though it were the only one. Fixing
|
|
732
|
-
* the adapter fixes it for every future enrichment key, whereas moving
|
|
733
|
-
* `idpTokens` elsewhere would leave the next one to rediscover this.
|
|
734
|
-
*
|
|
735
|
-
* ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
|
|
736
|
-
* payload does not set them — so Better Auth still wins on everything it
|
|
737
|
-
* owns, and an explicit incoming value is never overridden by a stale one.
|
|
738
|
-
*
|
|
739
|
-
* NOT ATOMIC, and deliberately not: an enrichment landing between this
|
|
740
|
-
* read and this write is still lost. Closing that needs a WATCH/MULTI or
|
|
741
|
-
* a Lua script, which is a larger change than tonight allows. This
|
|
742
|
-
* narrows the window from "every single write" to "a genuine race", and
|
|
743
|
-
* the residual is recorded here rather than left to be re-found.
|
|
744
|
-
*/
|
|
745
|
-
let payload = value;
|
|
746
|
-
try {
|
|
747
|
-
const existingRaw = await redis.get(redisKey);
|
|
748
|
-
if (existingRaw) {
|
|
749
|
-
const existing = JSON.parse(existingRaw);
|
|
750
|
-
const incoming = JSON.parse(value);
|
|
751
|
-
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
|
752
|
-
let carried = false;
|
|
753
|
-
for (const preserved of PRESERVED_SESSION_KEYS) {
|
|
754
|
-
if (preserved in existing && !(preserved in incoming)) {
|
|
755
|
-
incoming[preserved] = existing[preserved];
|
|
756
|
-
carried = true;
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
if (carried) payload = JSON.stringify(incoming);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
} catch (mergeErr) {
|
|
763
|
-
/**
|
|
764
|
-
* This adapter also stores `verification:*` and `active-sessions-*`
|
|
765
|
-
* records, which are not necessarily session-shaped JSON. A record we
|
|
766
|
-
* cannot parse is not a session to enrich, so writing through unchanged
|
|
767
|
-
* is correct rather than merely tolerable.
|
|
768
|
-
*
|
|
769
|
-
* WARNED, NOT THROWN, and the asymmetry is the point: this block is an
|
|
770
|
-
* ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
|
|
771
|
-
* degraded read into a lost session — the precise inversion 170033 fix 1
|
|
772
|
-
* above exists to prevent. The `setex` below is outside this try and is
|
|
773
|
-
* still unswallowed, so a genuine write failure still propagates.
|
|
774
|
-
*/
|
|
775
|
-
console.warn(
|
|
776
|
-
`[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
|
|
777
|
-
`ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
|
|
778
|
-
`as-is. If this record held idpTokens they are now gone (273183):`,
|
|
779
|
-
mergeErr instanceof Error ? mergeErr.message : String(mergeErr)
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* QA 6952 / R913 - CLAMP, DO NOT DEFER.
|
|
785
|
-
*
|
|
786
|
-
* This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
|
|
787
|
-
* source of session TTL" - what session-store.ts claims, and the mechanism
|
|
788
|
-
* by which the 7d-vs-3d zombie window was supposedly closed for good.
|
|
789
|
-
*
|
|
790
|
-
* IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
|
|
791
|
-
* eight secondaryStorage.set call sites in
|
|
792
|
-
* better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
|
|
793
|
-
* :180, :189, :309, :320, :344, :553, :622). `??` fires only on
|
|
794
|
-
* null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
|
|
795
|
-
* Auth's 7d default - `session.expiresIn` is not configured in this file -
|
|
796
|
-
* while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
|
|
797
|
-
* the zombie window the constant exists to prevent: the guard was bypassed
|
|
798
|
-
* by the very parameter written to defer to it.
|
|
799
|
-
*
|
|
800
|
-
* `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
|
|
801
|
-
* `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
|
|
802
|
-
* records whose TTLs are legitimately SHORTER than 3d. Overwriting those
|
|
803
|
-
* would EXTEND them - turning a fix for over-long sessions into a bug that
|
|
804
|
-
* keeps verification codes alive for three days. Clamp DOWN, never up.
|
|
805
|
-
*
|
|
806
|
-
* Clamped down to 3d rather than raising the canonical store to 7d because
|
|
807
|
-
* SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
|
|
808
|
-
* the refresh token sustaining it cannot be refreshed, so the extra days are
|
|
809
|
-
* a husk that reads as authenticated. Fail-closed beats ambiguous.
|
|
810
|
-
*
|
|
811
|
-
* DO NOT "fix" this by making storeIdpTokensInSession preserve Better
|
|
812
|
-
* Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
|
|
813
|
-
* universal. See test/ba-session-ttl-clamp.test.cjs.
|
|
814
|
-
*/
|
|
815
|
-
await redis.setex(redisKey, Math.min(ttl ?? SESSION_TTL, SESSION_TTL), payload);
|
|
816
|
-
},
|
|
817
|
-
delete: async (key: string) => {
|
|
818
|
-
try {
|
|
819
|
-
await getRedis().del(`ba:${appSlug}:${key}`);
|
|
820
|
-
} catch (err) {
|
|
821
|
-
/**
|
|
822
|
-
* 170033 fix 2 — a failed delete must be VISIBLE, not `/* ignore *\/`.
|
|
823
|
-
*
|
|
824
|
-
* Deliberately logged rather than thrown, and the distinction is not
|
|
825
|
-
* timidity: the CANONICAL store is what decides session liveness (see
|
|
826
|
-
* 181922 below), and its delete runs immediately after this one and is
|
|
827
|
-
* NOT swallowed. So a BA record that outlives its canonical twin does
|
|
828
|
-
* not authenticate anyone — throwing here would fail a sign-out that
|
|
829
|
-
* actually succeeded where it counts.
|
|
830
|
-
*
|
|
831
|
-
* What was unacceptable is that it happened SILENTLY. A revocation
|
|
832
|
-
* that half-failed left no trace anywhere, so the leftover key could
|
|
833
|
-
* only ever be found by someone already reading the keyspace for
|
|
834
|
-
* another reason. `error`, not `warn`: this is a revocation that did
|
|
835
|
-
* not complete, not a retryable blip.
|
|
836
|
-
*/
|
|
837
|
-
console.error(
|
|
838
|
-
`[BETTER_AUTH] secondary delete FAILED for ba:${appSlug}:${key} — ` +
|
|
839
|
-
`the better-auth session record may still exist. Canonical delete ` +
|
|
840
|
-
`below is what governs liveness; if it also failed, the session is ` +
|
|
841
|
-
`NOT revoked:`,
|
|
842
|
-
err
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
|
-
/**
|
|
846
|
-
* 181922 — teardown is BOTH stores. BA's sign-out deletes only its
|
|
847
|
-
* secondary record; the canonical {slug}:sess:{token} would survive
|
|
848
|
-
* and keep the session alive (a replayed cookie still authenticates).
|
|
849
|
-
* `key` here IS the session token (the ba: prefix is applied above),
|
|
850
|
-
* so the canonical delete is one call. This also fires on session
|
|
851
|
-
* rotation/expiry deletions, which is correct: a session BA drops
|
|
852
|
-
* must not live on in the canonical store.
|
|
853
|
-
*/
|
|
854
|
-
try {
|
|
855
|
-
const { deleteSession } = await import('../lib/session-store');
|
|
856
|
-
await deleteSession(key);
|
|
857
|
-
} catch (err) {
|
|
858
|
-
console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
|
|
859
|
-
}
|
|
860
|
-
},
|
|
861
|
-
},
|
|
862
|
-
|
|
863
|
-
session: {
|
|
864
|
-
// Cookie cache DISABLED — Redis is canonical for session liveness.
|
|
865
|
-
//
|
|
866
|
-
// Previously: enabled with maxAge 300 + refreshCache false. That cached
|
|
867
|
-
// a decoded session in process memory for 5 minutes, bypassing Redis
|
|
868
|
-
// reads during the window. Consequence: if the canonical app session
|
|
869
|
-
// at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
|
|
870
|
-
// Better Auth's in-memory copy stayed alive — and callers got
|
|
871
|
-
// contradictory answers depending on whether they consulted Better
|
|
872
|
-
// Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
|
|
873
|
-
// visible in production traces: viability 200 + idp-token 200 +
|
|
874
|
-
// getFreshIdpToken NO_SESSION terminal within milliseconds.
|
|
875
|
-
//
|
|
876
|
-
// Trade-off: every Better Auth getSession() call now incurs one Redis
|
|
877
|
-
// read on the secondary-storage path. Latency cost is acceptable
|
|
878
|
-
// (sub-ms in-region) and the alternative is duplicate Layer-1
|
|
879
|
-
// workarounds in every consumer app — already shipped in idealvibe.online
|
|
880
|
-
// at b6a91f6.
|
|
881
|
-
cookieCache: {
|
|
882
|
-
enabled: false,
|
|
883
|
-
maxAge: 300,
|
|
884
|
-
refreshCache: false,
|
|
885
|
-
},
|
|
886
|
-
},
|
|
887
|
-
|
|
888
|
-
// PAY-1781 B: KEEP OAUTH STATE IN THE ENCRYPTED COOKIE, as it was on better-auth 1.5.6.
|
|
889
|
-
// 1.5.6 resolved the default as `options.database ? 'database' : 'cookie'` - we have no
|
|
890
|
-
// database, so: cookie. 1.6.13+ resolves it as `(database || secondaryStorage) ? 'database'
|
|
891
|
-
// : 'cookie'`, and we DO have secondaryStorage, so the upgrade alone would silently move every
|
|
892
|
-
// OAuth state record into Redis through the adapter above (its enrichment read-modify-write
|
|
893
|
-
// and key handling were built for session records, not state). Pinned so the upgrade changes
|
|
894
|
-
// the version, not the design. The cookie branch is where GHSA-wxw3-q3m9-c3jr lived; 1.6.2+
|
|
895
|
-
// compares the returned `state` with the nonce sealed in the cookie, and our provider is also
|
|
896
|
-
// pkce:true, which the advisory required to be false. Test: oauth-state-strategy-cookie.
|
|
897
|
-
account: {
|
|
898
|
-
storeStateStrategy: 'cookie',
|
|
899
|
-
},
|
|
900
|
-
|
|
901
|
-
// Cookie prefix must match slim-middleware expectations ({slug}.session-token)
|
|
902
|
-
advanced: {
|
|
903
|
-
cookiePrefix: appSlug,
|
|
904
|
-
cookies: {
|
|
905
|
-
session_token: {
|
|
906
|
-
name: `${appSlug}.session-token`,
|
|
907
|
-
},
|
|
908
|
-
},
|
|
909
|
-
},
|
|
910
|
-
|
|
911
|
-
plugins: [
|
|
912
|
-
nextCookies(),
|
|
913
|
-
// Exchange-move: present ONLY when the IDP's OAuth endpoints are
|
|
914
|
-
// configured. Absent otherwise, so this cannot alter existing behaviour.
|
|
915
|
-
...(idpOAuth ? [genericOAuth({ config: [idpOAuth] })] : []),
|
|
916
|
-
...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
|
|
917
|
-
// App-supplied plugins last: they may depend on the built-ins above, and
|
|
918
|
-
// ordering is BA's own concern once they are in the array.
|
|
919
|
-
...(opts.plugins ?? []),
|
|
920
|
-
],
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Better Auth is always enabled (NextAuth removed in 4.0).
|
|
926
|
-
*/
|
|
927
|
-
export function isBetterAuthEnabled(): boolean {
|
|
928
|
-
return true;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* Get Better Auth Next.js route handlers (GET, POST).
|
|
933
|
-
* Initializes Better Auth from IDP config on first call, caches the instance.
|
|
934
|
-
*
|
|
935
|
-
* STATE LIVES ON globalThis, NOT IN MODULE SCOPE (181814, measured 2026-08-07):
|
|
936
|
-
* under Turbopack, instrumentation.ts and the /api/auth/[...all] route are
|
|
937
|
-
* different chunks and each loads its OWN copy of this module. Module-level
|
|
938
|
-
* `configuredOpts` set by configureBetterAuth() in instrumentation never
|
|
939
|
-
* reached the copy getBetterAuthInstance() read in the route — measured
|
|
940
|
-
* identity probe: sameInstance=false, route-side plugins=["next-cookies"]
|
|
941
|
-
* while the instrumentation-side instance carried every configured plugin.
|
|
942
|
-
* No throw anywhere (the double-init guard never sees the other copy), core
|
|
943
|
-
* auth green, plugin routes 404. globalThis is per-process, per-runtime —
|
|
944
|
-
* exactly the scope this state must have.
|
|
945
|
-
*/
|
|
946
|
-
interface BetterAuthModuleState {
|
|
947
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
948
|
-
cachedInstance: any;
|
|
949
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
950
|
-
initPromise: Promise<any> | null;
|
|
951
|
-
// PAY-642: the init attempt, single-flighted so a REJECTED attempt clears itself (see getBetterAuthInstance).
|
|
952
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
953
|
-
initFlight?: SingleFlight<any>;
|
|
954
|
-
configuredOpts: CreateBetterAuthInstanceOptions;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
const STATE_KEY = '__payezBetterAuthState';
|
|
958
|
-
const g = globalThis as unknown as Record<string, BetterAuthModuleState | undefined>;
|
|
959
|
-
g[STATE_KEY] ??= { cachedInstance: null, initPromise: null, configuredOpts: {} };
|
|
960
|
-
const state = g[STATE_KEY];
|
|
961
|
-
|
|
962
|
-
// Expose for server-side session access (decode-session.ts). A getter so the
|
|
963
|
-
// export tracks the SHARED state, not a chunk-local snapshot.
|
|
964
|
-
export function __getBetterAuthInstance(): unknown {
|
|
965
|
-
return state.cachedInstance;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/**
|
|
969
|
-
* Configure Better Auth instance options for this process.
|
|
970
|
-
*
|
|
971
|
-
* Must be called before the first auth request — before
|
|
972
|
-
* `getBetterAuthInstance()` caches an instance. Typically called once at
|
|
973
|
-
* app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
|
|
974
|
-
* server bootstrap hook.
|
|
975
|
-
*
|
|
976
|
-
* Throws if called after the instance has already been resolved: options
|
|
977
|
-
* cannot be applied retroactively.
|
|
978
|
-
*/
|
|
979
|
-
export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
|
|
980
|
-
if (state.cachedInstance) {
|
|
981
|
-
throw new Error(
|
|
982
|
-
'[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
|
|
983
|
-
'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
|
|
984
|
-
);
|
|
985
|
-
}
|
|
986
|
-
state.configuredOpts = opts;
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/**
|
|
990
|
-
* PAY-642 (card 338215): the init attempt no longer LATCHES A FAILURE.
|
|
991
|
-
*
|
|
992
|
-
* This used to be `state.initPromise ??= getIDPClientConfig(true).then(...)` with no .catch and no reset. One
|
|
993
|
-
* auth request against a cold process while the IdP was unreachable stored a rejected promise, and every later
|
|
994
|
-
* caller replayed it until the process restarted, even after the IdP recovered. This funnel backs every
|
|
995
|
-
* /api/auth/* route, the login GET handler and withAuth. server/auth.ts getAuthInstance() already used
|
|
996
|
-
* createSingleFlight for this exact bug; this sibling funnel was missed.
|
|
997
|
-
*
|
|
998
|
-
* Now the attempt runs through the same helper: concurrent callers share one attempt, a failure still rejects
|
|
999
|
-
* for every caller of THAT attempt (loud), the next call starts a fresh attempt, and a success is cached in
|
|
1000
|
-
* state.cachedInstance as before. The flight lives on the globalThis state for the same reason the instance
|
|
1001
|
-
* does (duplicated route chunks must share one init).
|
|
1002
|
-
*/
|
|
1003
|
-
export async function getBetterAuthInstance() {
|
|
1004
|
-
if (state.cachedInstance) return state.cachedInstance;
|
|
1005
|
-
|
|
1006
|
-
state.initFlight ??= createSingleFlight(() =>
|
|
1007
|
-
getIDPClientConfig(true).then(config => {
|
|
1008
|
-
const instance = createBetterAuthInstance(config, state.configuredOpts);
|
|
1009
|
-
state.cachedInstance = instance;
|
|
1010
|
-
console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
|
|
1011
|
-
return instance;
|
|
1012
|
-
})
|
|
1013
|
-
);
|
|
1014
|
-
|
|
1015
|
-
const attempt = state.initFlight.get();
|
|
1016
|
-
// Kept for callers that inspect it; it always points at the CURRENT attempt, never a settled failure.
|
|
1017
|
-
state.initPromise = attempt;
|
|
1018
|
-
return attempt;
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
/**
|
|
1022
|
-
* Get flag-gated auth handler for Next.js route.
|
|
1023
|
-
*
|
|
1024
|
-
* When USE_BETTER_AUTH=true, returns Better Auth handlers.
|
|
1025
|
-
* Otherwise returns null (auth disabled).
|
|
1026
|
-
*
|
|
1027
|
-
* Usage in host app route:
|
|
1028
|
-
* ```ts
|
|
1029
|
-
* import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
|
|
1030
|
-
*
|
|
1031
|
-
* export async function GET(req: Request) {
|
|
1032
|
-
* const ba = await getBetterAuthHandler();
|
|
1033
|
-
* if (ba) return ba.GET(req);
|
|
1034
|
-
* }
|
|
1035
|
-
* ```
|
|
1036
|
-
*/
|
|
1037
|
-
export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
|
|
1038
|
-
if (!isBetterAuthEnabled()) return null;
|
|
1039
|
-
|
|
1040
|
-
const auth = await getBetterAuthInstance();
|
|
1041
|
-
// PAY-2217: Better Auth's native endpoints (get-session, list-sessions, sign-in/*, sign-up/*) put the session
|
|
1042
|
-
// token in the body. Strip it at the HTTP boundary here, so every consumer that mounts this handler gets it;
|
|
1043
|
-
// auth.api (server-side) is untouched and still sees the token. See session-response-sanitizer.ts.
|
|
1044
|
-
return wrapBetterAuthHandler(auth as any, toNextJsHandler(auth));
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/**
|
|
1048
|
-
* Exchange OAuth identity for IDP tokens and store in the BA Redis session.
|
|
1049
|
-
*
|
|
1050
|
-
* Call this from the OAuth callback route AFTER better-auth has processed the
|
|
1051
|
-
* callback and created the session. Reads the session token from the Set-Cookie
|
|
1052
|
-
* header of the response to find the BA Redis key.
|
|
1053
|
-
*
|
|
1054
|
-
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
1055
|
-
*
|
|
1056
|
-
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
1057
|
-
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
1058
|
-
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
1059
|
-
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
1060
|
-
* refused registration bounced silently, which is the failure Jon named.
|
|
1061
|
-
*/
|
|
1062
|
-
export async function exchangeOAuthForIdpTokens(
|
|
1063
|
-
sessionToken: string,
|
|
1064
|
-
provider: string = 'google'
|
|
1065
|
-
): Promise<boolean | string> {
|
|
1066
|
-
try {
|
|
1067
|
-
const config = await getIDPClientConfig();
|
|
1068
|
-
const appSlug = config.clientSlug || getAppSlug();
|
|
1069
|
-
const baKey = `ba:${appSlug}:${sessionToken}`;
|
|
1070
|
-
|
|
1071
|
-
// Read the BA session from Redis
|
|
1072
|
-
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
1073
|
-
if (!baRaw) {
|
|
1074
|
-
console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', tokenLogId(sessionToken));
|
|
1075
|
-
return false;
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
const baData = JSON.parse(baRaw);
|
|
1079
|
-
const email = baData?.user?.email;
|
|
1080
|
-
const name = baData?.user?.name;
|
|
1081
|
-
const image = baData?.user?.image;
|
|
1082
|
-
const baUserId = baData?.session?.userId || baData?.user?.id;
|
|
1083
|
-
|
|
1084
|
-
if (!email) {
|
|
1085
|
-
console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
|
|
1086
|
-
return false;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// Call IDP oauth-callback
|
|
1090
|
-
const idpUrl = process.env.IDP_URL || '';
|
|
1091
|
-
if (!idpUrl) {
|
|
1092
|
-
console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
|
|
1093
|
-
return false;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
|
|
1097
|
-
|
|
1098
|
-
const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
|
|
1099
|
-
method: 'POST',
|
|
1100
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1101
|
-
body: JSON.stringify({
|
|
1102
|
-
provider,
|
|
1103
|
-
provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
|
|
1104
|
-
email,
|
|
1105
|
-
name,
|
|
1106
|
-
image,
|
|
1107
|
-
client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? '',
|
|
1108
|
-
/**
|
|
1109
|
-
* Card 138635 — the RAW provider id_token, forwarded verbatim.
|
|
1110
|
-
*
|
|
1111
|
-
* Every other field in this body is an ASSERTION. The endpoint mints an
|
|
1112
|
-
* IDP bearer from them today with no cryptographic check of any kind,
|
|
1113
|
-
* which is why it is a complete authentication bypass and why it has
|
|
1114
|
-
* been publicly reachable for 488 days.
|
|
1115
|
-
*
|
|
1116
|
-
* This field is the only one the IDP can VERIFY: signature against
|
|
1117
|
-
* Google's JWKS, plus iss / exp / aud, and then read email_verified
|
|
1118
|
-
* from the verified claims. Additive — the IDP ignores it until its
|
|
1119
|
-
* gate flips, so this cannot break a login on its own.
|
|
1120
|
-
*
|
|
1121
|
-
* RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
|
|
1122
|
-
* decoded email_verified. A client-computed verification flag is a body
|
|
1123
|
-
* field with a better story: an attacker posts email_verified:true and
|
|
1124
|
-
* the defect is rebuilt one layer up. The IDP verifies for itself or
|
|
1125
|
-
* the fix is theatre.
|
|
1126
|
-
*
|
|
1127
|
-
* Absent when this runs outside the OAuth callback scope — the key is
|
|
1128
|
-
* simply omitted rather than sent null, so "we never captured one" stays
|
|
1129
|
-
* distinguishable from "the provider issued none".
|
|
1130
|
-
*/
|
|
1131
|
-
...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
|
|
1132
|
-
/**
|
|
1133
|
-
* The same field for providers that have no id_token to give.
|
|
1134
|
-
*
|
|
1135
|
-
* Identical doctrine to the id_token above: RAW, forwarded verbatim,
|
|
1136
|
-
* NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
|
|
1137
|
-
* "never captured" stays distinguishable from "provider issued none".
|
|
1138
|
-
* The IDP verifies for itself by calling GitHub with this token and
|
|
1139
|
-
* enforcing primary && verified; we assert nothing about the identity.
|
|
1140
|
-
*
|
|
1141
|
-
* Scoped to the provider access token captured during THIS callback, so
|
|
1142
|
-
* a google login continues to send id_token only and is unaffected.
|
|
1143
|
-
*/
|
|
1144
|
-
...(idTokenScope.getStore()?.accessToken
|
|
1145
|
-
? { access_token: idTokenScope.getStore()!.accessToken }
|
|
1146
|
-
: {}),
|
|
1147
|
-
}),
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
|
-
const oauthResText = await oauthRes.text();
|
|
1151
|
-
// PAY-1427 (QAPert, PR 154 thread 326): this printed the first 500 chars of the oauth-callback TOKEN
|
|
1152
|
-
// envelope on EVERY successful OAuth login (it runs before the !ok check). Status + length only.
|
|
1153
|
-
console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
|
|
1154
|
-
|
|
1155
|
-
if (!oauthRes.ok) {
|
|
1156
|
-
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
1157
|
-
|
|
1158
|
-
/**
|
|
1159
|
-
* PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
|
|
1160
|
-
*
|
|
1161
|
-
* Every refusal used to return a bare `false`, so the abort collapsed ALL
|
|
1162
|
-
* causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
|
|
1163
|
-
* registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
|
|
1164
|
-
* bounced silently, indistinguishable from a network blip. Jon named the
|
|
1165
|
-
* clear message explicitly (67856): "(b) a brand-new Google user with
|
|
1166
|
-
* registration OFF is refused by the IDP with a clear message, not a
|
|
1167
|
-
* silent bounce."
|
|
1168
|
-
*
|
|
1169
|
-
* The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
|
|
1170
|
-
* We read ONLY `error.code` and return it VERBATIM — the abort puts it in
|
|
1171
|
-
* the `?error=` param, and the HOST's allowlist decides whether to render
|
|
1172
|
-
* it. The package does NOT interpret the code and does NOT add a message
|
|
1173
|
-
* (that would be a second allowlist). No code in the body -> null (the
|
|
1174
|
-
* generic host code), exactly as before.
|
|
1175
|
-
*/
|
|
1176
|
-
let idpErrorCode: string | null = null;
|
|
1177
|
-
try {
|
|
1178
|
-
const parsed = JSON.parse(oauthResText);
|
|
1179
|
-
// FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
|
|
1180
|
-
// unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
|
|
1181
|
-
if (typeof parsed?.error?.code === 'string' && IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
|
|
1182
|
-
idpErrorCode = parsed.error.code;
|
|
1183
|
-
}
|
|
1184
|
-
} catch {
|
|
1185
|
-
/* non-JSON refusal body -> no code (generic abort) */
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/**
|
|
1189
|
-
* WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
|
|
1190
|
-
*
|
|
1191
|
-
* UNVERIFIED_ASSERTION has exactly two causes and the response body cannot tell
|
|
1192
|
-
* them apart: we sent no id_token for the IDP to verify, or we sent one whose
|
|
1193
|
-
* signature/aud/iss the IDP rejected. Without this line the operator sees a 401 on
|
|
1194
|
-
* a login that visibly succeeded and has nowhere to go — which is where UMI sat on
|
|
1195
|
-
* 2026-08-26.
|
|
1196
|
-
*
|
|
1197
|
-
* NEVER THE TOKEN ITSELF. An id_token is credential material and this whole code
|
|
1198
|
-
* path exists to keep it out of anything persistent; a log file is persistent. We
|
|
1199
|
-
* print presence, length, and the three claims that decide verification. `aud` is
|
|
1200
|
-
* the Google client id — not a secret, and the single most likely mismatch, since
|
|
1201
|
-
* the IDP checks it against what IT has registered for this client.
|
|
1202
|
-
*/
|
|
1203
|
-
const forwarded = idTokenScope.getStore()?.idToken;
|
|
1204
|
-
let claims: Record<string, unknown> = {};
|
|
1205
|
-
if (forwarded) {
|
|
1206
|
-
try {
|
|
1207
|
-
claims = decodeJwt(forwarded) as Record<string, unknown>;
|
|
1208
|
-
} catch {
|
|
1209
|
-
claims = { decode: 'FAILED — not a well-formed JWT' };
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
const forwardedAccess = idTokenScope.getStore()?.accessToken;
|
|
1213
|
-
console.error('[BETTER_AUTH] exchange diagnostics:', {
|
|
1214
|
-
id_token_forwarded: !!forwarded,
|
|
1215
|
-
id_token_length: forwarded ? forwarded.length : 0,
|
|
1216
|
-
/**
|
|
1217
|
-
* Read these two for github, and IGNORE the id_token pair above: github
|
|
1218
|
-
* issues no id_token, so `id_token_forwarded: false` is its NORMAL state
|
|
1219
|
-
* and not the fault. Presence and length only — an access token is
|
|
1220
|
-
* credential material and this log is persistent. A github failure with
|
|
1221
|
-
* access_token_forwarded:true means the IDP called GitHub and refused
|
|
1222
|
-
* the answer (unverified or non-primary email, or app not installed);
|
|
1223
|
-
* false means the capture hook never ran and the IDP never called out.
|
|
1224
|
-
*/
|
|
1225
|
-
access_token_forwarded: !!forwardedAccess,
|
|
1226
|
-
access_token_length: forwardedAccess ? forwardedAccess.length : 0,
|
|
1227
|
-
// Absent id_token => the IDP had nothing to verify. Present => compare these
|
|
1228
|
-
// against the client's registered Google credentials on the IDP.
|
|
1229
|
-
aud: claims.aud ?? null,
|
|
1230
|
-
iss: claims.iss ?? null,
|
|
1231
|
-
email_verified: claims.email_verified ?? null,
|
|
1232
|
-
sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
|
|
1233
|
-
idp_url: idpUrl,
|
|
1234
|
-
});
|
|
1235
|
-
return idpErrorCode ?? false;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
let idpData: any;
|
|
1239
|
-
try { idpData = JSON.parse(oauthResText); } catch { return false; }
|
|
1240
|
-
const result = idpData?.data?.result || idpData?.data || idpData;
|
|
1241
|
-
|
|
1242
|
-
if (!result?.access_token) {
|
|
1243
|
-
console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
|
|
1244
|
-
return false;
|
|
1245
|
-
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// Build IDP token data
|
|
1249
|
-
//
|
|
1250
|
-
// 170007 CONTRACT (BAPert ruling on 121350, agreed with DotNetPert
|
|
1251
|
-
// 2026-08-08 — see mail thread, not re-derived here):
|
|
1252
|
-
// 1. The IDP emits `requires_two_factor` (snake_case, both serializers,
|
|
1253
|
-
// per the estate law) as a NON-NULLABLE bool on OAuthCallbackResponse
|
|
1254
|
-
// (PayEz.DTOs OAuthCallbackResponse.cs) — ALWAYS present. No IDP
|
|
1255
|
-
// change needed or wanted: emitting a second, camelCase name for the
|
|
1256
|
-
// same fact would be the dialect problem the estate law exists to
|
|
1257
|
-
// prevent, not a fix.
|
|
1258
|
-
// 2. Read requires_two_factor (snake) ONLY. CORRECTED mid-review:
|
|
1259
|
-
// BAPert's first draft of this contract said "read both casings,
|
|
1260
|
-
// defensively" — DotNetPert rejected that: a dual-casing client-side
|
|
1261
|
-
// read gives one fact two wire names just as surely as a dual-casing
|
|
1262
|
-
// IDP emit would, and it is the exact audience-dialect mistake
|
|
1263
|
-
// (186443) made deliberately instead of by accident. This package
|
|
1264
|
-
// previously read camel ONLY (card 170083, now corrected — it must
|
|
1265
|
-
// NOT record "emit both casings" as the IDP-side fix), which is why
|
|
1266
|
-
// this line silently fell through to the heuristic below on every
|
|
1267
|
-
// real call. The fix is reading the one name the IDP actually sends,
|
|
1268
|
-
// not tolerating both.
|
|
1269
|
-
// 3. The old !hasRefreshToken fallback (IDP-2FA-BUG below) is RETIRED,
|
|
1270
|
-
// not patched. DotNetPert traced ExternalAuthController's
|
|
1271
|
-
// oauth-callback: IssueRefreshToken and RequiresTwoFactor both derive
|
|
1272
|
-
// from the SAME isFullyAuthenticated variable, so they cannot
|
|
1273
|
-
// disagree on this path — the divergence this fallback existed for
|
|
1274
|
-
// is not present here. Worse, the heuristic would currently produce
|
|
1275
|
-
// the RIGHT answer by coincidence, which hides the day it stops
|
|
1276
|
-
// being derived from one variable.
|
|
1277
|
-
// 4. requires_two_factor is non-nullable, so reaching the `true`
|
|
1278
|
-
// default below means the response ISN'T an OAuthCallbackResponse at
|
|
1279
|
-
// all (wrong endpoint / error body / failed parse) — logged as that,
|
|
1280
|
-
// not as "2FA status unknown". Fail-closed to requiresTwoFactor=true
|
|
1281
|
-
// either way (rule 3).
|
|
1282
|
-
// 5. mfaVerified = !requiresTwoFactor, computed fresh from THIS
|
|
1283
|
-
// response — no hardcoding, no carrying a prior session value
|
|
1284
|
-
// forward (that half of the monotonic-OR defect lived in
|
|
1285
|
-
// refresh.ts / ensure-fresh-access-token.ts, fixed separately).
|
|
1286
|
-
//
|
|
1287
|
-
// PRIOR STATE, for the record: this used to hardcode mfaVerified=false on
|
|
1288
|
-
// every OAuth exchange as a blunt workaround for a since-disproven IDP
|
|
1289
|
-
// bug, which itself caused every Google user to be sent to /mfa even on
|
|
1290
|
-
// require_2fa=FALSE tenants (NextPert 13450's blocker, failing T4 on the
|
|
1291
|
-
// demo tenant). That workaround is removed along with the bug it worked
|
|
1292
|
-
// around.
|
|
1293
|
-
const requiresTwoFactor: boolean = (() => {
|
|
1294
|
-
// SNAKE ONLY (BAPert's corrected ruling, 2026-08-08): reading camel as
|
|
1295
|
-
// a "defensive" secondary would give one fact two wire names — exactly
|
|
1296
|
-
// the audience-dialect mistake (186443) being made deliberately. The
|
|
1297
|
-
// estate law is explicit-snake-per-field-both-serializers; the IDP
|
|
1298
|
-
// already complies (OAuthCallbackResponse.cs, both System.Text.Json
|
|
1299
|
-
// and Newtonsoft). No IDP change, no camel read, no dual-casing.
|
|
1300
|
-
const v = result.requires_two_factor ?? result.user?.requires_two_factor;
|
|
1301
|
-
if (v === undefined) {
|
|
1302
|
-
console.warn(
|
|
1303
|
-
'[BETTER_AUTH] oauth-callback response has no requires_two_factor field at all — ' +
|
|
1304
|
-
'unexpected response shape (requires_two_factor is non-nullable on OAuthCallbackResponse; ' +
|
|
1305
|
-
'this is not "2FA status unknown", it means we are not looking at an oauth-callback response). ' +
|
|
1306
|
-
'Defaulting requiresTwoFactor=true (fail closed).',
|
|
1307
|
-
);
|
|
1308
|
-
return true;
|
|
1309
|
-
}
|
|
1310
|
-
return !!v;
|
|
1311
|
-
})();
|
|
1312
|
-
const idpTokenData = {
|
|
1313
|
-
idpAccessToken: result.access_token,
|
|
1314
|
-
idpRefreshToken: result.refresh_token,
|
|
1315
|
-
idpAccessTokenExpires: result.expires_in
|
|
1316
|
-
? Date.now() + result.expires_in * 1000
|
|
1317
|
-
: Date.now() + 15 * 60 * 1000,
|
|
1318
|
-
userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
|
|
1319
|
-
email: result.user?.email || result.email || email,
|
|
1320
|
-
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
1321
|
-
image: image,
|
|
1322
|
-
roles: result.user?.roles || result.roles || [],
|
|
1323
|
-
mfaVerified: !requiresTwoFactor, // re-derived fresh — see 170007 contract above
|
|
1324
|
-
// Cards 141688/141741 — RELAYED, NOT DERIVED.
|
|
1325
|
-
//
|
|
1326
|
-
// The IDP already decides this (ExternalAuthController:943 and :2024,
|
|
1327
|
-
// WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
|
|
1328
|
-
// confirmed method exists, null = fully authenticated. It was emitted
|
|
1329
|
-
// here and dropped on this line — `idpTokenData` is built field-by-field,
|
|
1330
|
-
// so anything not named is silently discarded. Both downstream consumers
|
|
1331
|
-
// (the middleware gate and the verify-code page) then reasoned in its
|
|
1332
|
-
// absence and sent zero-method users to a page they cannot act on.
|
|
1333
|
-
//
|
|
1334
|
-
// WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
|
|
1335
|
-
// BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
|
|
1336
|
-
// `result.nextStep` here would be `undefined` forever and fail silently —
|
|
1337
|
-
// the same camel/snake break `requires_two_factor` already cost us once.
|
|
1338
|
-
//
|
|
1339
|
-
// Do NOT recompute this from a method query. The IDP owns the fact; it is
|
|
1340
|
-
// already usable-aware (confirmed and not revoked, per DotNetPert), which
|
|
1341
|
-
// is the distinction that decides correctness: a revoked-TOTP user has
|
|
1342
|
-
// count>0 but usable=0, and deriving it locally would let through exactly
|
|
1343
|
-
// the users we must challenge.
|
|
1344
|
-
// No `?? null`: if the response has no `next_step` at all (older IDP
|
|
1345
|
-
// build, or a path that does not set it), that is UNKNOWN, not "fully
|
|
1346
|
-
// authenticated". Defaulting it to null here would manufacture a
|
|
1347
|
-
// clean-bill-of-health the IDP never gave and fail open at the gate.
|
|
1348
|
-
nextStep: result.next_step,
|
|
1349
|
-
idpClientId: result.client_id ? String(result.client_id) : undefined,
|
|
1350
|
-
merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
|
|
1351
|
-
};
|
|
1352
|
-
|
|
1353
|
-
// Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
|
|
1354
|
-
const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
|
|
1355
|
-
if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
1356
|
-
// true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
|
|
1357
|
-
return stored;
|
|
1358
|
-
} catch (err) {
|
|
1359
|
-
console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
|
|
1360
|
-
return false;
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
/**
|
|
1366
|
-
* Write IDP tokens into an EXISTING Better Auth session. Storage only — this
|
|
1367
|
-
* function never acquires tokens and never decides who the caller is.
|
|
1368
|
-
*
|
|
1369
|
-
* ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
|
|
1370
|
-
*
|
|
1371
|
-
* `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
|
|
1372
|
-
* POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
|
|
1373
|
-
* the second half is reusable, and the first half is actively dangerous to
|
|
1374
|
-
* reuse:
|
|
1375
|
-
*
|
|
1376
|
-
* oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
|
|
1377
|
-
* cryptographic verification of any kind (card 138635).
|
|
1378
|
-
*
|
|
1379
|
-
* Passkey sign-in already holds something strictly better — a grant issued by
|
|
1380
|
-
* `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
|
|
1381
|
-
* signature against a registered credential. Routing that through
|
|
1382
|
-
* oauth-callback to obtain a session would DOWNGRADE a cryptographically
|
|
1383
|
-
* verified assertion into an unverified one, make passkey sign-in weaker than
|
|
1384
|
-
* its own ceremony, and inherit 138635 wholesale.
|
|
1385
|
-
*
|
|
1386
|
-
* That is not hypothetical: "do what the OAuth path does" is the obvious
|
|
1387
|
-
* implementation and it is the wrong one. Splitting acquisition from storage is
|
|
1388
|
-
* what makes the wrong one hard to write by accident.
|
|
1389
|
-
*
|
|
1390
|
-
* ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
|
|
1391
|
-
*
|
|
1392
|
-
* A Better Auth session must ALREADY EXIST for `sessionToken`. This function
|
|
1393
|
-
* enriches; it does not create. Better Auth owns session creation and cookie
|
|
1394
|
-
* signing, and nothing in this package should replicate either — the cookie is
|
|
1395
|
-
* `{token}.{base64(hmac)}` with standard padding, signed with the runtime
|
|
1396
|
-
* `authSecret` from Redis, and hand-rolling that produces a session which
|
|
1397
|
-
* decodes but does not authenticate.
|
|
1398
|
-
*
|
|
1399
|
-
* @param sessionToken the Better Auth session token (from its Set-Cookie)
|
|
1400
|
-
* @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
|
|
1401
|
-
* verified route. Built field-by-field by the caller so an
|
|
1402
|
-
* unnamed field cannot ride along silently.
|
|
1403
|
-
* @param authProvider provenance label recorded on the canonical row. Written
|
|
1404
|
-
* to the historical `oauthProvider` key, which predates
|
|
1405
|
-
* non-OAuth callers; the NAME is legacy, the VALUE should
|
|
1406
|
-
* state what actually authenticated ('passkey', 'google').
|
|
1407
|
-
*/
|
|
1408
|
-
export async function storeIdpTokensInSession(
|
|
1409
|
-
sessionToken: string,
|
|
1410
|
-
idpTokenData: Record<string, unknown>,
|
|
1411
|
-
authProvider: string,
|
|
1412
|
-
): Promise<boolean> {
|
|
1413
|
-
try {
|
|
1414
|
-
const config = await getIDPClientConfig();
|
|
1415
|
-
const appSlug = config.clientSlug || getAppSlug();
|
|
1416
|
-
const baKey = `ba:${appSlug}:${sessionToken}`;
|
|
1417
|
-
|
|
1418
|
-
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
1419
|
-
if (!baRaw) {
|
|
1420
|
-
// No session to enrich. Do NOT create one as a fallback: a session
|
|
1421
|
-
// conjured here would carry no Better Auth cookie, so it would be
|
|
1422
|
-
// unreachable by the browser and invisible to every other consumer —
|
|
1423
|
-
// failing as "silently signed out" rather than as an error.
|
|
1424
|
-
console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', tokenLogId(sessionToken));
|
|
1425
|
-
return false;
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
const baData = JSON.parse(baRaw);
|
|
1429
|
-
baData.idpTokens = idpTokenData;
|
|
1430
|
-
await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
|
|
1431
|
-
|
|
1432
|
-
// Canonical store — the key getSession() reads, used by the refresh handler
|
|
1433
|
-
// and token lifecycle. A failure here leaves the BA session correct and the
|
|
1434
|
-
// canonical row missing, so it is warned and not fatal: the user is signed
|
|
1435
|
-
// in, and refresh degrades rather than the sign-in failing outright.
|
|
1436
|
-
try {
|
|
1437
|
-
const { getSessionPrefix } = await import('../lib/app-slug');
|
|
1438
|
-
const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
|
|
1439
|
-
await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
|
|
1440
|
-
...idpTokenData,
|
|
1441
|
-
oauthProvider: authProvider,
|
|
1442
|
-
}));
|
|
1443
|
-
} catch (canonicalErr) {
|
|
1444
|
-
console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
return true;
|
|
1448
|
-
} catch (err) {
|
|
1449
|
-
console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
|
|
1450
|
-
return false;
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
/**
|
|
1455
|
-
* The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
|
|
1456
|
-
* store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
|
|
1457
|
-
* Try again or use another method." The cause stays in the server log, never on the page.
|
|
1458
|
-
*/
|
|
1459
|
-
export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
|
|
1460
|
-
|
|
1461
|
-
/**
|
|
1462
|
-
* PAY-2305: store the tokens the payez-idp provider hooks captured during THIS request. Fail closed: no captured tokens or no
|
|
1463
|
-
* verified claims (getUserInfo refused the token) => false, and the caller aborts the sign-in. nextStep is left undefined on
|
|
1464
|
-
* purpose (unknown is not "fully authenticated", see 141688); mfaVerified comes only from the verified token's amr.
|
|
1465
|
-
*/
|
|
1466
|
-
export async function storePayezIdpSession(sessionToken: string): Promise<boolean> {
|
|
1467
|
-
const captured = idTokenScope.getStore()?.idp;
|
|
1468
|
-
if (!captured?.tokens?.accessToken || !captured.claims) {
|
|
1469
|
-
console.warn('[BETTER_AUTH] payez-idp callback: no verified IdP tokens captured for this request');
|
|
1470
|
-
return false;
|
|
1471
|
-
}
|
|
1472
|
-
const { tokens, claims } = captured;
|
|
1473
|
-
const stored = await storeIdpTokensInSession(
|
|
1474
|
-
sessionToken,
|
|
1475
|
-
{
|
|
1476
|
-
idpAccessToken: tokens.accessToken,
|
|
1477
|
-
idpRefreshToken: tokens.refreshToken,
|
|
1478
|
-
idpAccessTokenExpires: tokens.accessTokenExpires ?? Date.now() + 15 * 60 * 1000,
|
|
1479
|
-
userId: String(claims.userId ?? claims.sub),
|
|
1480
|
-
email: claims.email,
|
|
1481
|
-
name: claims.name,
|
|
1482
|
-
roles: claims.roles,
|
|
1483
|
-
mfaVerified: claims.mfaVerified,
|
|
1484
|
-
nextStep: undefined,
|
|
1485
|
-
},
|
|
1486
|
-
'payez-idp',
|
|
1487
|
-
);
|
|
1488
|
-
if (stored) console.log('[BETTER_AUTH] IdP tokens stored in session for', claims.email);
|
|
1489
|
-
return stored;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/**
|
|
1493
|
-
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
1494
|
-
*
|
|
1495
|
-
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
1496
|
-
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
1497
|
-
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
1498
|
-
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
1499
|
-
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
1500
|
-
* wearing a different number. Add them together, never one alone.
|
|
1501
|
-
*/
|
|
1502
|
-
export const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string> = new Set(['REGISTRATION_NOT_ALLOWED']);
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
/**
|
|
1506
|
-
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
1507
|
-
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
|
1508
|
-
* accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
|
|
1509
|
-
*/
|
|
1510
|
-
export function expireSetCookies(setCookies: readonly string[]): string[] {
|
|
1511
|
-
const out: string[] = [];
|
|
1512
|
-
for (const sc of setCookies) {
|
|
1513
|
-
const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
|
|
1514
|
-
const eq = pair.indexOf('=');
|
|
1515
|
-
const name = eq > 0 ? pair.slice(0, eq) : '';
|
|
1516
|
-
if (!name) continue;
|
|
1517
|
-
const kept = attrs.filter((a) => /^(path|domain|secure|samesite|httponly)\b/i.test(a));
|
|
1518
|
-
out.push([`${name}=`, ...kept, 'Max-Age=0'].join('; '));
|
|
1519
|
-
}
|
|
1520
|
-
return out;
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
/**
|
|
1524
|
-
* PAY-1733 (Jon, live on :4797): ABORT an OAuth sign-in whose IdP exchange did not store tokens.
|
|
1525
|
-
*
|
|
1526
|
-
* Before this, a failed exchange was a console.error and the user kept the Better Auth session the callback had
|
|
1527
|
-
* just created: signed in, holding NO IdP tokens. The host then routed that session to 2FA, which painted the
|
|
1528
|
-
* user's email and failed every call - a red "sign in again" screen about an account the app could no longer
|
|
1529
|
-
* authenticate. The IdP's 401 was CORRECT (it fail-closes unverified assertions); keeping the session was the bug.
|
|
1530
|
-
*
|
|
1531
|
-
* So: no session without IdP tokens. The Better Auth session is destroyed (its own signOut, best effort, then the
|
|
1532
|
-
* `ba:<slug>:<token>` record directly - that record is what makes it resolvable), every cookie the callback set is
|
|
1533
|
-
* expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
|
|
1534
|
-
* branch above (328439: an absolute URL built from request.url is a dead tab in a container).
|
|
1535
|
-
*/
|
|
1536
|
-
async function abortOAuthSignIn(
|
|
1537
|
-
callbackResponse: Response,
|
|
1538
|
-
sessionToken: string | null,
|
|
1539
|
-
cookieHeader: string,
|
|
1540
|
-
loginPath: string,
|
|
1541
|
-
errorCode: string = OAUTH_SIGN_IN_FAILED_ERROR,
|
|
1542
|
-
): Promise<Response> {
|
|
1543
|
-
const headers = new Headers();
|
|
1544
|
-
if (sessionToken) {
|
|
1545
|
-
try {
|
|
1546
|
-
const auth = await getBetterAuthInstance();
|
|
1547
|
-
const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
|
|
1548
|
-
for (const sc of (out as Response | undefined)?.headers?.getSetCookie?.() ?? []) headers.append('set-cookie', sc);
|
|
1549
|
-
} catch (err) {
|
|
1550
|
-
console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
|
|
1551
|
-
}
|
|
1552
|
-
try {
|
|
1553
|
-
const config = await getIDPClientConfig();
|
|
1554
|
-
await getRedis().del(`ba:${config.clientSlug || getAppSlug()}:${sessionToken}`);
|
|
1555
|
-
} catch (err) {
|
|
1556
|
-
console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), err instanceof Error ? err.message : String(err));
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
|
|
1560
|
-
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
|
|
1561
|
-
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)', 'code=' + errorCode);
|
|
1562
|
-
return new Response(null, { status: 302, headers });
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
/**
|
|
1566
|
-
* Create a production-ready GET handler for the auth catch-all route.
|
|
1567
|
-
*
|
|
1568
|
-
* Wraps better-auth's GET handler with:
|
|
1569
|
-
* - OAuth state error recovery (redirects to login instead of error page)
|
|
1570
|
-
* - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
|
|
1571
|
-
* (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
|
|
1572
|
-
*
|
|
1573
|
-
* Usage in host app:
|
|
1574
|
-
* ```ts
|
|
1575
|
-
* import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
|
|
1576
|
-
* export const GET = createAuthGetHandler('/account-auth/login');
|
|
1577
|
-
* export async function POST(req: Request) {
|
|
1578
|
-
* const ba = await getBetterAuthHandler();
|
|
1579
|
-
* return ba!.POST(req);
|
|
1580
|
-
* }
|
|
1581
|
-
* ```
|
|
1582
|
-
*/
|
|
1583
|
-
export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
|
|
1584
|
-
return async function GET(request: Request): Promise<Response> {
|
|
1585
|
-
// Card 138635: establish the request-scoped carrier for the provider's raw
|
|
1586
|
-
// id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
|
|
1587
|
-
// hook that sees the token) and the exchange below (which forwards it).
|
|
1588
|
-
// Wrapping the whole handler is what makes "never persisted" achievable —
|
|
1589
|
-
// the token exists only for the life of this request.
|
|
1590
|
-
return idTokenScope.run({}, async () => {
|
|
1591
|
-
const ba = await getBetterAuthHandler();
|
|
1592
|
-
if (!ba) {
|
|
1593
|
-
return new Response('Auth handler not configured', { status: 500 });
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
const response = await ba.GET(request);
|
|
1597
|
-
|
|
1598
|
-
// Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
|
|
1599
|
-
if (response.status === 302) {
|
|
1600
|
-
const location = response.headers.get('location') || '';
|
|
1601
|
-
if (location.includes('/api/auth/error') || location.includes('please_restart')) {
|
|
1602
|
-
console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
|
|
1603
|
-
// *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
|
|
1604
|
-
// Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
|
|
1605
|
-
// runtime and again on node 24:
|
|
1606
|
-
// Response.redirect('/account-auth/login', 302)
|
|
1607
|
-
// -> TypeError: Failed to parse URL from /account-auth/login
|
|
1608
|
-
// That requirement is the whole cause of the bug this replaces. The old line
|
|
1609
|
-
// was:
|
|
1610
|
-
// return Response.redirect(new URL(loginPath, request.url), 302);
|
|
1611
|
-
// `request.url` inside a containerised Next standalone server carries the
|
|
1612
|
-
// LISTEN address, not the address the browser used, so a container bound to
|
|
1613
|
-
// 0.0.0.0:8080 handed the browser `http://0.0.0.0:8080/sign-in` — an address
|
|
1614
|
-
// that can NEVER load (net::ERR_ADDRESS_INVALID, confirmed in a real browser
|
|
1615
|
-
// by QAPert). Reload cannot recover it, because the address is invalid rather
|
|
1616
|
-
// than down. It fired on the ORDINARY path: the oauth_state cookie is
|
|
1617
|
-
// Max-Age=600, so anyone spending >10 minutes at Google's account chooser
|
|
1618
|
-
// landed on a dead tab.
|
|
1619
|
-
//
|
|
1620
|
-
// A RELATIVE Location header is legal (RFC 7231 §7.1.2) and the browser
|
|
1621
|
-
// resolves it against the origin it is ACTUALLY on, so no origin has to be
|
|
1622
|
-
// guessed, configured, or derived from a header. This is the same shape the
|
|
1623
|
-
// app's own middleware already emits successfully (307 + relative), which is
|
|
1624
|
-
// why page redirects survived while this one did not.
|
|
1625
|
-
//
|
|
1626
|
-
// WHY NOT REBUILD FROM BETTER_AUTH_URL / rawBaseURL, the other proposal:
|
|
1627
|
-
// (a) rawBaseURL is NOT in scope here — it is a local inside
|
|
1628
|
-
// createBetterAuthInstance (~:397), a different function ~676 lines up.
|
|
1629
|
-
// Reaching it means recomputing the env precedence or exporting a
|
|
1630
|
-
// helper, i.e. more moving parts for a strictly worse result.
|
|
1631
|
-
// (b) It would reintroduce a guessed origin and make this path depend on
|
|
1632
|
-
// BETTER_AUTH_URL being set correctly per leg. Relative needs no env.
|
|
1633
|
-
// (c) Anything Host-derived would open host-header injection on a redirect
|
|
1634
|
-
// that is currently host-INVARIANT. Relative keeps that property.
|
|
1635
|
-
//
|
|
1636
|
-
// Works whether loginPath is relative (package default '/account-auth/login',
|
|
1637
|
-
// and the graft passes '/sign-in') or absolute — an absolute Location is also
|
|
1638
|
-
// legal, so a consumer passing a full URL still behaves.
|
|
1639
|
-
return new Response(null, { status: 302, headers: { location: loginPath } });
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
// After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
|
|
1644
|
-
// IdP refused (401), no session was found, or anything threw - the sign-in is ABORTED (PAY-1733). A session is
|
|
1645
|
-
// kept only when it holds IdP tokens.
|
|
1646
|
-
const url = new URL(request.url);
|
|
1647
|
-
if (url.pathname.includes('/callback/') && response.status === 302) {
|
|
1648
|
-
const setCookies = response.headers.getSetCookie?.() || [];
|
|
1649
|
-
const cookieHeader = setCookies
|
|
1650
|
-
.map((c: string) => c.split(';')[0])
|
|
1651
|
-
.join('; ');
|
|
1652
|
-
let sessionToken: string | null = null;
|
|
1653
|
-
// true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
|
|
1654
|
-
// (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
|
|
1655
|
-
let exchange: boolean | string = false;
|
|
1656
|
-
try {
|
|
1657
|
-
const auth = await getBetterAuthInstance();
|
|
1658
|
-
if (auth?.api?.getSession) {
|
|
1659
|
-
const headers = new Headers();
|
|
1660
|
-
headers.set('cookie', cookieHeader);
|
|
1661
|
-
|
|
1662
|
-
const session = await auth.api.getSession({ headers });
|
|
1663
|
-
if (session?.session?.token) {
|
|
1664
|
-
sessionToken = session.session.token;
|
|
1665
|
-
console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
|
|
1666
|
-
// PAY-2305: the payez-idp callback already holds an IdP-ISSUED bearer (verified in getUserInfo). It must NOT go
|
|
1667
|
-
// through oauth-callback, the asserted-email path (card 138635), and storing it is what the passkey path does.
|
|
1668
|
-
exchange = url.pathname.endsWith('/callback/payez-idp')
|
|
1669
|
-
? await storePayezIdpSession(session.session.token)
|
|
1670
|
-
: await exchangeOAuthForIdpTokens(session.session.token);
|
|
1671
|
-
} else {
|
|
1672
|
-
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
} catch (err: any) {
|
|
1676
|
-
console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
|
|
1677
|
-
}
|
|
1678
|
-
if (exchange !== true) {
|
|
1679
|
-
// A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
|
|
1680
|
-
const errorCode = typeof exchange === 'string' ? exchange : OAUTH_SIGN_IN_FAILED_ERROR;
|
|
1681
|
-
return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
return response;
|
|
1686
|
-
});
|
|
1687
|
-
};
|
|
1688
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Better Auth Configuration
|
|
3
|
+
*
|
|
4
|
+
* Primary auth configuration. Replaces the former NextAuth auth-options.ts.
|
|
5
|
+
*
|
|
6
|
+
* Architecture: No database adapter — Better Auth runs in stateless mode
|
|
7
|
+
* with JWE cookie cache. User management stays on IDP, sessions on Redis.
|
|
8
|
+
*
|
|
9
|
+
* @see BETTER-AUTH-MIGRATION-SPEC.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import 'server-only';
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
+
import { createHash } from 'crypto';
|
|
15
|
+
import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
|
|
16
|
+
import { betterAuth } from 'better-auth';
|
|
17
|
+
import { nextCookies } from 'better-auth/next-js';
|
|
18
|
+
import { toNextJsHandler } from 'better-auth/next-js';
|
|
19
|
+
import { wrapBetterAuthHandler } from './session-response-sanitizer';
|
|
20
|
+
import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
|
|
21
|
+
import { resolveClientKey } from '../lib/client-key';
|
|
22
|
+
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
|
23
|
+
import {
|
|
24
|
+
createIdpOAuthHooks,
|
|
25
|
+
resolveIdpRedirectUri,
|
|
26
|
+
type IdpTokenGrant,
|
|
27
|
+
type IdpVerifiedClaims,
|
|
28
|
+
} from './idp-oauth';
|
|
29
|
+
import type { IDPClientConfig } from '../lib/idp-client-config';
|
|
30
|
+
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
31
|
+
import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
|
|
32
|
+
import { getRedis } from '../lib/redis';
|
|
33
|
+
import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
|
|
34
|
+
import { SESSION_TTL } from '../lib/session-store';
|
|
35
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
39
|
+
* to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
|
|
40
|
+
* = 48 bits; collision-tellable across a deployment, useless as a credential.
|
|
41
|
+
*/
|
|
42
|
+
export function secretFingerprint(value: string): string {
|
|
43
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
|
|
48
|
+
* than Better Auth, which must survive a Better Auth session write.
|
|
49
|
+
*
|
|
50
|
+
* Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
|
|
51
|
+
* list is a named constant rather than a hardcoded check because the shape of
|
|
52
|
+
* the bug is "a second author appeared and the adapter did not know" — the next
|
|
53
|
+
* enrichment key should be added here, not rediscovered as a strip.
|
|
54
|
+
*/
|
|
55
|
+
const PRESERVED_SESSION_KEYS = ['idpTokens'] as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True for a JSON object we can safely merge into — not null, not an array.
|
|
59
|
+
* `typeof null === 'object'` and arrays are objects, and merging into either
|
|
60
|
+
* would corrupt the record rather than preserve it.
|
|
61
|
+
*/
|
|
62
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Better Auth social provider config shape.
|
|
68
|
+
*/
|
|
69
|
+
export interface BetterAuthSocialProvider {
|
|
70
|
+
clientId: string;
|
|
71
|
+
clientSecret: string;
|
|
72
|
+
scope?: string[];
|
|
73
|
+
prompt?: string;
|
|
74
|
+
accessType?: 'offline' | 'online';
|
|
75
|
+
hd?: string;
|
|
76
|
+
/**
|
|
77
|
+
* 260419: better-auth's ONLY guard against OAuth auto-provisioning
|
|
78
|
+
* (oauth2/link-account.mjs handleOAuthUserInfo: `if (disableSignUp) return
|
|
79
|
+
* {error:"signup disabled"}`). Unset means ANY unknown Google identity gets a
|
|
80
|
+
* local account created at callback. We wire it from the tenant's own policy.
|
|
81
|
+
*/
|
|
82
|
+
disableSignUp?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build Better Auth social providers from IDP config.
|
|
87
|
+
*/
|
|
88
|
+
/**
|
|
89
|
+
* REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
|
|
90
|
+
*
|
|
91
|
+
* WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
|
|
92
|
+
* ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
|
|
93
|
+
* for the IDP to require a provider-signed id_token and verify it itself. This
|
|
94
|
+
* is the client half: capture the token Better Auth already receives, and
|
|
95
|
+
* forward it. Additive — the IDP ignores it until its gate flips.
|
|
96
|
+
*
|
|
97
|
+
* WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
|
|
98
|
+
* an id_token is credential material. Writing it to the session record would put
|
|
99
|
+
* it one allow-list edit away from leaking out of /api/internal/session-data,
|
|
100
|
+
* and it would outlive the request that needed it. ALS scopes it to the single
|
|
101
|
+
* request, and it is gone when the request ends — nothing to expire, nothing to
|
|
102
|
+
* scrub, nothing to leak.
|
|
103
|
+
*
|
|
104
|
+
* SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
|
|
105
|
+
* exchange (which reads it) both run inside createAuthGetHandler's own request.
|
|
106
|
+
* VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
|
|
107
|
+
* that invokes getUserInfo — and the exchange follows at :465 in the SAME
|
|
108
|
+
* function. Outside that scope the store is undefined and the body simply omits
|
|
109
|
+
* id_token, which is the correct additive behaviour.
|
|
110
|
+
*/
|
|
111
|
+
/**
|
|
112
|
+
* ON globalThis FOR THE SAME REASON THE BETTER AUTH INSTANCE IS (181814, see STATE_KEY
|
|
113
|
+
* below) — and it was NOT, which is the whole defect.
|
|
114
|
+
*
|
|
115
|
+
* This module is loaded more than once per process: instrumentation and the auth route
|
|
116
|
+
* are different chunks with their own module registries. `state` was moved to globalThis
|
|
117
|
+
* so both copies share the Better Auth INSTANCE. This AsyncLocalStorage was left in
|
|
118
|
+
* module scope, so each copy got its own.
|
|
119
|
+
*
|
|
120
|
+
* The result, measured on UMI 2026-08-26 against idp.payez.net:
|
|
121
|
+
*
|
|
122
|
+
* copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
|
|
123
|
+
* copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
|
|
124
|
+
* getUserInfo fires, reads A -> getStore() is undefined, `if (scope)` skips
|
|
125
|
+
* the exchange reads B -> empty, so id_token is omitted from the body
|
|
126
|
+
*
|
|
127
|
+
* Sign-in SUCCEEDS at every visible step. The user gets a session, an avatar, an email.
|
|
128
|
+
* The IDP then answers 401 UNVERIFIED_ASSERTION with reason="id_token missing"
|
|
129
|
+
* (confirmed in its own logs, correlationId 0HNO2755RESR1) — so the client half of 138635
|
|
130
|
+
* has been shipping as a no-op wherever the module double-loads, and the security fix it
|
|
131
|
+
* exists to deliver never arrived. A shared instance plus a per-copy scope is the worst
|
|
132
|
+
* combination: everything looks wired, and the one field that proves identity is dropped.
|
|
133
|
+
*
|
|
134
|
+
* globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
|
|
135
|
+
* carrier needs to be reachable from every copy that might run inside the request.
|
|
136
|
+
*/
|
|
137
|
+
/**
|
|
138
|
+
* THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
|
|
139
|
+
* THINGS. Renaming it would give a copy on the old name and a copy on the new
|
|
140
|
+
* name two DIFFERENT stores in the same process — which is precisely the
|
|
141
|
+
* double-load failure documented above, reintroduced by a cosmetic rename. The
|
|
142
|
+
* name is a wire contract between copies, not a description.
|
|
143
|
+
*
|
|
144
|
+
* accessToken is here for providers that do NOT issue an id_token. GitHub is
|
|
145
|
+
* not an OIDC provider: it has no id_token, no JWKS and no discovery document,
|
|
146
|
+
* so `id_token` can never be the field that proves a GitHub identity. The IDP
|
|
147
|
+
* re-derives that identity by calling GitHub itself with this access token
|
|
148
|
+
* (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
|
|
149
|
+
* verifies for itself and never trusts an asserted email.
|
|
150
|
+
*/
|
|
151
|
+
const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
|
|
152
|
+
type ProviderTokenScope = {
|
|
153
|
+
idToken?: string;
|
|
154
|
+
accessToken?: string;
|
|
155
|
+
/** PAY-2305: what the payez-idp getToken / getUserInfo hooks captured for THIS request (never persisted here). */
|
|
156
|
+
idp?: { tokens?: IdpTokenGrant; claims?: IdpVerifiedClaims };
|
|
157
|
+
};
|
|
158
|
+
const gScope = globalThis as unknown as Record<
|
|
159
|
+
string,
|
|
160
|
+
AsyncLocalStorage<ProviderTokenScope> | undefined
|
|
161
|
+
>;
|
|
162
|
+
gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
|
|
163
|
+
const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
|
|
167
|
+
* provider credentials in the app.
|
|
168
|
+
*
|
|
169
|
+
* ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
|
|
170
|
+
* Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
|
|
171
|
+
* is uniform with the ratified desktop leg. The IDP holds the provider secret
|
|
172
|
+
* and performs the provider exchange server-side.
|
|
173
|
+
*
|
|
174
|
+
* ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
|
|
175
|
+
* QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
|
|
176
|
+
* clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
|
|
177
|
+
* on whether we need a secret. This builds the generic/custom shape
|
|
178
|
+
* deliberately; reaching for a preset because it looks tidier would silently
|
|
179
|
+
* kill keyless-on-web and nobody would connect the two changes.
|
|
180
|
+
*
|
|
181
|
+
* ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
|
|
182
|
+
* Returns null unless the IDP's OAuth endpoints are explicitly configured. That
|
|
183
|
+
* makes this ADDITIVE: the existing socialProviders path keeps working until an
|
|
184
|
+
* operator turns this on. It is NOT a runtime failover - nothing silently
|
|
185
|
+
* chooses between them, because a silent chooser is how a migration ends up
|
|
186
|
+
* half-applied and undetectable.
|
|
187
|
+
*
|
|
188
|
+
* ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
|
|
189
|
+
* the app stops talking to Google, so it can no longer capture the raw
|
|
190
|
+
* id_token that oauth-callback verifies today, and the per-provider settings
|
|
191
|
+
* below stop being ours to set - profile scope, prompt=select_account,
|
|
192
|
+
* accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
|
|
193
|
+
* exchange must verify signature/iss/aud/exp itself and preserve those four, or
|
|
194
|
+
* they are lost silently.
|
|
195
|
+
*/
|
|
196
|
+
export interface IdpOAuthProviderConfig {
|
|
197
|
+
providerId: string;
|
|
198
|
+
discoveryUrl: string;
|
|
199
|
+
clientId: string;
|
|
200
|
+
pkce: true;
|
|
201
|
+
scopes: string[];
|
|
202
|
+
/** PAY-2305: the REGISTERED {site_url}/api/auth/callback, sent identically at authorize and token time. */
|
|
203
|
+
redirectURI: string;
|
|
204
|
+
getToken: ReturnType<typeof createIdpOAuthHooks>['getToken'];
|
|
205
|
+
getUserInfo: ReturnType<typeof createIdpOAuthHooks>['getUserInfo'];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProviderConfig | null {
|
|
209
|
+
const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
|
|
210
|
+
const clientId = process.env.CLIENT_ID;
|
|
211
|
+
if (!discoveryUrl || !clientId) return null;
|
|
212
|
+
|
|
213
|
+
const redirectURI = resolveIdpRedirectUri(
|
|
214
|
+
appBaseUrl || process.env.BETTER_AUTH_URL || process.env.APP_URL || `http://localhost:${process.env.PORT || '3000'}`,
|
|
215
|
+
);
|
|
216
|
+
const hooks = createIdpOAuthHooks({
|
|
217
|
+
discoveryUrl,
|
|
218
|
+
clientId,
|
|
219
|
+
redirectURI,
|
|
220
|
+
issuer: process.env.IDP_OAUTH_ISSUER || undefined,
|
|
221
|
+
trustedOrigins: process.env.IDP_OAUTH_TRUSTED_ORIGINS
|
|
222
|
+
? process.env.IDP_OAUTH_TRUSTED_ORIGINS.split(',').map((x) => x.trim()).filter(Boolean)
|
|
223
|
+
: undefined,
|
|
224
|
+
onTokens: (tokens) => {
|
|
225
|
+
const store = idTokenScope.getStore();
|
|
226
|
+
if (store) (store.idp ??= {}).tokens = tokens;
|
|
227
|
+
},
|
|
228
|
+
onClaims: (claims) => {
|
|
229
|
+
const store = idTokenScope.getStore();
|
|
230
|
+
if (store) (store.idp ??= {}).claims = claims;
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
providerId: 'payez-idp',
|
|
236
|
+
discoveryUrl,
|
|
237
|
+
clientId,
|
|
238
|
+
redirectURI,
|
|
239
|
+
getToken: hooks.getToken,
|
|
240
|
+
getUserInfo: hooks.getUserInfo,
|
|
241
|
+
// NO clientSecret. The whole point: this app is not a credential holder.
|
|
242
|
+
pkce: true,
|
|
243
|
+
scopes: ['openid', 'email', 'profile'],
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* ⚠ DO NOT ADD `authentication: 'basic'` HERE. IT SILENTLY BREAKS KEYLESS.
|
|
247
|
+
*
|
|
248
|
+
* Found by QAPert-NightHawk (21348) — the second config trap in this plugin
|
|
249
|
+
* after the preset one, and the same shape: a field that looks like a
|
|
250
|
+
* harmless transport detail and quietly changes whether we are a public
|
|
251
|
+
* client at all.
|
|
252
|
+
*
|
|
253
|
+
* @better-auth/core validate-authorization-code.mjs:
|
|
254
|
+
* if (authentication === "basic") {
|
|
255
|
+
* headers.authorization = `Basic ${base64(`${clientId}:${clientSecret ?? ""}`)}`;
|
|
256
|
+
* } else {
|
|
257
|
+
* body.set("client_id", clientId);
|
|
258
|
+
* if (options.clientSecret) body.set("client_secret", options.clientSecret);
|
|
259
|
+
* }
|
|
260
|
+
*
|
|
261
|
+
* Note what the 'basic' branch does with NO secret: it sends an EMPTY
|
|
262
|
+
* password, `Basic base64("<clientId>:")`. That is not "no authentication",
|
|
263
|
+
* it is WRONG authentication — we would be presenting ourselves as a
|
|
264
|
+
* confidential client with a blank credential. The default branch is the
|
|
265
|
+
* one that produces a genuine public-client PKCE request.
|
|
266
|
+
*
|
|
267
|
+
* Leaving this field UNSET is load-bearing, which is exactly why it needs a
|
|
268
|
+
* comment: nothing about an absent field advertises that its absence is a
|
|
269
|
+
* decision.
|
|
270
|
+
*/
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function buildBetterAuthProviders(
|
|
275
|
+
config: IDPClientConfig
|
|
276
|
+
): Record<string, BetterAuthSocialProvider> {
|
|
277
|
+
const providers: Record<string, BetterAuthSocialProvider> = {};
|
|
278
|
+
|
|
279
|
+
for (const oauth of config.oauthProviders || []) {
|
|
280
|
+
if (!oauth.enabled) continue;
|
|
281
|
+
const name = oauth.provider.toLowerCase();
|
|
282
|
+
const additionalParams = oauth.additionalParams ?? {};
|
|
283
|
+
const rawPrompt = additionalParams.prompt;
|
|
284
|
+
const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
|
|
285
|
+
const rawHostedDomain = additionalParams.hd;
|
|
286
|
+
|
|
287
|
+
// Ensure profile scope is present for Google so avatar image is returned
|
|
288
|
+
const scopes = oauth.scopes?.split(' ') || [];
|
|
289
|
+
if (name === 'google' && !scopes.includes('profile')) {
|
|
290
|
+
scopes.push('profile');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 224721: better-auth's google provider UNCONDITIONALLY seeds its default
|
|
295
|
+
* scopes ['email','profile','openid'] and then pushes our `scope` verbatim
|
|
296
|
+
* (@better-auth/core social-providers/google.mjs createAuthorizationURL) —
|
|
297
|
+
* the row's "openid email profile" reached Google as
|
|
298
|
+
* "email profile openid openid email profile". Semantically harmless
|
|
299
|
+
* (Google dedupes) but sloppy on a URL a demo audience can read. Hand over
|
|
300
|
+
* only what the defaults do not already cover; undefined when nothing
|
|
301
|
+
* remains, so the provider emits exactly its default set.
|
|
302
|
+
*/
|
|
303
|
+
const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
|
|
304
|
+
/**
|
|
305
|
+
* Same defect, same fix, different provider: github's createAuthorizationURL
|
|
306
|
+
* also seeds ['read:user','user:email'] unconditionally and then pushes ours
|
|
307
|
+
* on top (@better-auth/core social-providers/github.ts), so the stored row
|
|
308
|
+
* "read:user user:email" reached GitHub as
|
|
309
|
+
* "read:user user:email read:user user:email". Measured on :4799, not
|
|
310
|
+
* assumed. GitHub dedupes, so this is legibility rather than function — but
|
|
311
|
+
* a scope list is the first thing a security reviewer reads off that URL.
|
|
312
|
+
*/
|
|
313
|
+
const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
314
|
+
const wireScopes =
|
|
315
|
+
name === 'google'
|
|
316
|
+
? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
|
|
317
|
+
: name === 'github'
|
|
318
|
+
? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
|
|
319
|
+
: scopes;
|
|
320
|
+
|
|
321
|
+
providers[name] = {
|
|
322
|
+
clientId: oauth.clientId,
|
|
323
|
+
clientSecret: oauth.clientSecret,
|
|
324
|
+
scope: wireScopes.length > 0 ? wireScopes : undefined,
|
|
325
|
+
/**
|
|
326
|
+
* PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
|
|
327
|
+
*
|
|
328
|
+
* 260419 wired this from `config.authSettings?.allowPublicRegistration` to
|
|
329
|
+
* close a client-side self-serve provisioning door. It cannot do that job
|
|
330
|
+
* correctly, and it BREAKS SIGN-IN:
|
|
331
|
+
*
|
|
332
|
+
* - This app runs better-auth in STATELESS mode (no DB adapter — see the
|
|
333
|
+
* file header). better-auth therefore has NO user store, so at the OAuth
|
|
334
|
+
* callback it cannot tell an EXISTING user from a NEW one. Its
|
|
335
|
+
* handleOAuthUserInfo consults `disableSignUp` for BOTH, so
|
|
336
|
+
* `disableSignUp: true` refuses a returning user exactly like a
|
|
337
|
+
* stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
|
|
338
|
+
* Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
|
|
339
|
+
* once client 12's allow_public_registration reported false.
|
|
340
|
+
*
|
|
341
|
+
* - The REAL registration gate already exists and is correct, at the IDP:
|
|
342
|
+
* PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
|
|
343
|
+
* user up by verified email (:799); an existing user logs in UNTOUCHED
|
|
344
|
+
* (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
|
|
345
|
+
* which refuses fail-closed when the client forbids public registration
|
|
346
|
+
* (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
|
|
347
|
+
* Its own comment states the contract: "existing federated users ...
|
|
348
|
+
* log in untouched, so gating here never blocks login."
|
|
349
|
+
*
|
|
350
|
+
* So this flag was a DUPLICATE gate that could only ever misfire — it cannot
|
|
351
|
+
* distinguish new from existing, and the IDP already refuses new identities.
|
|
352
|
+
* Removing it restores sign-in for existing users WITHOUT reopening
|
|
353
|
+
* self-serve: a NEW identity that completes the better-auth leg still gets
|
|
354
|
+
* REFUSED at the IDP exchange and never receives IDP tokens, which is the
|
|
355
|
+
* portal's actual authorization gate.
|
|
356
|
+
*
|
|
357
|
+
* WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
|
|
358
|
+
* (above) as the backstop, and each app's own front-door sign-up gate
|
|
359
|
+
* (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
|
|
360
|
+
* Both are correct and neither is this flag.
|
|
361
|
+
*/
|
|
362
|
+
// Google is overly eager to reuse the last account unless we
|
|
363
|
+
// explicitly ask for account selection on each social login.
|
|
364
|
+
prompt: typeof rawPrompt === 'string'
|
|
365
|
+
? rawPrompt
|
|
366
|
+
: name === 'google'
|
|
367
|
+
? 'select_account'
|
|
368
|
+
: undefined,
|
|
369
|
+
accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
|
|
370
|
+
hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* CAPTURE THE RAW id_token — card 138635, client half.
|
|
375
|
+
*
|
|
376
|
+
* getUserInfo is the ONLY provider hook that receives the token set.
|
|
377
|
+
* mapProfileToUser cannot do this: it is handed the DECODED claims, and a
|
|
378
|
+
* signed JWT cannot be reconstructed from them. We need the raw token
|
|
379
|
+
* because the IDP must verify the signature ITSELF — a client-decoded
|
|
380
|
+
* claim is just another assertable field, which is the defect restated.
|
|
381
|
+
*
|
|
382
|
+
* ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
|
|
383
|
+
* `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
|
|
384
|
+
* returns our result verbatim, so everything the default did is now ours to
|
|
385
|
+
* do — including the `hd` hosted-domain restriction.
|
|
386
|
+
*
|
|
387
|
+
* `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
|
|
388
|
+
* above, so a client can restrict sign-in to a Google Workspace domain.
|
|
389
|
+
* Silently dropping it would let ANY Google account into a client that had
|
|
390
|
+
* restricted itself — a security regression introduced by a security fix.
|
|
391
|
+
* (Measured on 93: zero clients set it today. That is a reason to be
|
|
392
|
+
* careful, not a reason to skip it — it is configurable and unused, which
|
|
393
|
+
* is exactly the state in which a regression goes unnoticed.)
|
|
394
|
+
*
|
|
395
|
+
* SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
|
|
396
|
+
* ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
|
|
397
|
+
* `if (!configuredHostedDomain) return true`. With no hd configured the
|
|
398
|
+
* default would return true unconditionally, so omitting it is equivalent
|
|
399
|
+
* by the library's own definition rather than by our judgement.
|
|
400
|
+
*
|
|
401
|
+
* A client that DOES set hd keeps the stock provider, keeps its domain
|
|
402
|
+
* restriction, and simply does not contribute an id_token yet. Losing a
|
|
403
|
+
* capability is an acceptable cost; silently losing a security control is
|
|
404
|
+
* not. (better-auth does not re-export the predicate through its public
|
|
405
|
+
* types, and reimplementing a security check is how two copies drift — so
|
|
406
|
+
* the honest move is to not need it.)
|
|
407
|
+
*/
|
|
408
|
+
if (name === 'google' && !providers[name].hd) {
|
|
409
|
+
(providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
|
|
410
|
+
if (!token?.idToken) return null;
|
|
411
|
+
|
|
412
|
+
// The only new behaviour: hand the raw token to this request's scope.
|
|
413
|
+
const scope = idTokenScope.getStore();
|
|
414
|
+
if (scope) {
|
|
415
|
+
scope.idToken = token.idToken;
|
|
416
|
+
} else {
|
|
417
|
+
// NOT a silent skip. A missing scope here means 138635 degrades to a no-op:
|
|
418
|
+
// the login still succeeds, and the IDP rejects the exchange with
|
|
419
|
+
// reason="id_token missing" a second later, with nothing connecting the two.
|
|
420
|
+
// That cost a day on UMI. If this fires, the OAuth callback is running outside
|
|
421
|
+
// createAuthGetHandler — either the route mounts the bare
|
|
422
|
+
// `routes/auth/nextauth` re-export instead of createAuthGetHandler, or this
|
|
423
|
+
// module has double-loaded and the scope is not shared (see 181814 above).
|
|
424
|
+
console.warn(
|
|
425
|
+
'[BETTER_AUTH] id_token captured but NO REQUEST SCOPE to put it in — it will ' +
|
|
426
|
+
'NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
427
|
+
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Mirrors the upstream default (google.mjs) verbatim, minus the hd
|
|
432
|
+
// branch that cannot fire here — so overriding the hook changes WHAT WE
|
|
433
|
+
// KEEP, never what we ACCEPT.
|
|
434
|
+
const claims = decodeJwt(token.idToken) as Record<string, any>;
|
|
435
|
+
return {
|
|
436
|
+
user: {
|
|
437
|
+
id: claims.sub,
|
|
438
|
+
name: claims.name,
|
|
439
|
+
email: claims.email,
|
|
440
|
+
image: claims.picture,
|
|
441
|
+
emailVerified: claims.email_verified,
|
|
442
|
+
},
|
|
443
|
+
data: claims,
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
|
|
450
|
+
*
|
|
451
|
+
* GitHub is not an OIDC provider — it issues no id_token — so the google
|
|
452
|
+
* branch above can never fire for it and the exchange would post an
|
|
453
|
+
* assertion with nothing that proves identity. That is exactly the
|
|
454
|
+
* 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
|
|
455
|
+
* `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
|
|
456
|
+
* the IDP rejected it instantly, without ever calling GitHub.
|
|
457
|
+
*
|
|
458
|
+
* getUserInfo is the ONLY provider hook that receives the token set (see the
|
|
459
|
+
* google note above), and @better-auth/core's github provider calls
|
|
460
|
+
* `options.getUserInfo(token)` BEFORE its own body — so installing this
|
|
461
|
+
* REPLACES the default rather than wrapping it, and the default's work has
|
|
462
|
+
* to be reproduced here or the sign-in loses its profile entirely.
|
|
463
|
+
*
|
|
464
|
+
* Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
|
|
465
|
+
* the version the container actually runs (better-auth 1.5.6 — the graft's
|
|
466
|
+
* node_modules says 1.6.29 and is NOT what executes). Kept deliberately
|
|
467
|
+
* dumb: it must not "improve" on upstream, because every divergence here is
|
|
468
|
+
* a behaviour change disguised as a token capture.
|
|
469
|
+
*
|
|
470
|
+
* This is NOT the identity check. The IDP re-derives the GitHub identity
|
|
471
|
+
* itself from this access token and enforces primary && verified server-side
|
|
472
|
+
* (GitHubIdentityVerifier); what we return here only shapes the local
|
|
473
|
+
* better-auth user record.
|
|
474
|
+
*/
|
|
475
|
+
if (name === 'github') {
|
|
476
|
+
(providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
|
|
477
|
+
accessToken?: string;
|
|
478
|
+
}) => {
|
|
479
|
+
const store = idTokenScope.getStore();
|
|
480
|
+
if (store) {
|
|
481
|
+
store.accessToken = token?.accessToken;
|
|
482
|
+
} else {
|
|
483
|
+
console.warn(
|
|
484
|
+
'[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
|
|
485
|
+
'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
486
|
+
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const ghHeaders = {
|
|
491
|
+
'User-Agent': 'better-auth',
|
|
492
|
+
Authorization: `Bearer ${token?.accessToken}`,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const profileRes = await fetch('https://api.github.com/user', {
|
|
496
|
+
headers: ghHeaders,
|
|
497
|
+
});
|
|
498
|
+
// Upstream returns null on profile error, which fails the sign-in
|
|
499
|
+
// closed. Preserved rather than softened.
|
|
500
|
+
if (!profileRes.ok) {
|
|
501
|
+
console.warn(
|
|
502
|
+
`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
|
|
503
|
+
);
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const profile = (await profileRes.json()) as Record<string, any>;
|
|
507
|
+
|
|
508
|
+
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
|
509
|
+
headers: ghHeaders,
|
|
510
|
+
});
|
|
511
|
+
const emails = emailsRes.ok
|
|
512
|
+
? ((await emailsRes.json()) as Array<{
|
|
513
|
+
email: string;
|
|
514
|
+
primary: boolean;
|
|
515
|
+
verified: boolean;
|
|
516
|
+
}>)
|
|
517
|
+
: undefined;
|
|
518
|
+
|
|
519
|
+
// A GitHub account with a private email returns profile.email = null;
|
|
520
|
+
// upstream falls back to the primary address here.
|
|
521
|
+
if (!profile.email && emails) {
|
|
522
|
+
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
|
|
523
|
+
}
|
|
524
|
+
const emailVerified =
|
|
525
|
+
emails?.find((e) => e.email === profile.email)?.verified ?? false;
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
user: {
|
|
529
|
+
id: profile.id,
|
|
530
|
+
name: profile.name || profile.login || '',
|
|
531
|
+
email: profile.email,
|
|
532
|
+
image: profile.avatar_url,
|
|
533
|
+
emailVerified,
|
|
534
|
+
},
|
|
535
|
+
data: profile,
|
|
536
|
+
};
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return providers;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Optional configuration for `createBetterAuthInstance`.
|
|
546
|
+
*
|
|
547
|
+
* - `magicLink`: if provided, registers Better Auth's magic-link plugin.
|
|
548
|
+
* The host app supplies its own `sendMagicLink` callback — typically a
|
|
549
|
+
* fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
|
|
550
|
+
* Omit the `magicLink` key entirely to skip the plugin; the consuming
|
|
551
|
+
* app will not have a magic-link flow.
|
|
552
|
+
*/
|
|
553
|
+
export interface CreateBetterAuthInstanceOptions {
|
|
554
|
+
magicLink?: MagicLinkOptions;
|
|
555
|
+
/**
|
|
556
|
+
* Extra Better Auth plugins supplied by the consuming app.
|
|
557
|
+
*
|
|
558
|
+
* WHY THIS EXISTS — session minting is not delegable any other way. BA's
|
|
559
|
+
* `setSessionCookie` requires a `GenericEndpointContext`, which only BA's own
|
|
560
|
+
* endpoint machinery constructs, so an app that must mint a session for an
|
|
561
|
+
* identity established OUTSIDE BA's built-in providers (passkey sign-in being
|
|
562
|
+
* the first) cannot do it from a route handler. It needs a real BA endpoint,
|
|
563
|
+
* and endpoints only arrive via plugins.
|
|
564
|
+
*
|
|
565
|
+
* Deliberately GENERIC. The package gains "an app may supply plugins" rather
|
|
566
|
+
* than any knowledge of passkeys — the alternative was teaching this file
|
|
567
|
+
* about a specific auth method, which is how a package accumulates a consumer's
|
|
568
|
+
* feature list.
|
|
569
|
+
*
|
|
570
|
+
* The alternative to this option is an app hand-rolling the session cookie
|
|
571
|
+
* (`{token}.{base64(hmac)}`, standard padding, runtime `authSecret` from
|
|
572
|
+
* Redis). That fails as a session which DECODES BUT DOES NOT AUTHENTICATE —
|
|
573
|
+
* so this option exists specifically to make that shortcut unnecessary.
|
|
574
|
+
*/
|
|
575
|
+
plugins?: Parameters<typeof betterAuth>[0]['plugins'];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Create Better Auth instance from IDP config.
|
|
580
|
+
*
|
|
581
|
+
* No database — runs in stateless mode with JWE cookie cache.
|
|
582
|
+
* Call after getIDPClientConfig() resolves.
|
|
583
|
+
*/
|
|
584
|
+
export function createBetterAuthInstance(
|
|
585
|
+
idpConfig: IDPClientConfig,
|
|
586
|
+
opts: CreateBetterAuthInstanceOptions = {}
|
|
587
|
+
) {
|
|
588
|
+
assertAppSlugMatchesIdp(idpConfig.clientSlug);
|
|
589
|
+
const appSlug = idpConfig.clientSlug || getAppSlug();
|
|
590
|
+
|
|
591
|
+
// 185121 line 1: name the secret this instance PINNED, as a hash, never the
|
|
592
|
+
// secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
|
|
593
|
+
// caused by a divergent secret is only diagnosable if every instance
|
|
594
|
+
// creation logged WHICH secret it bound. Both init paths (this module's
|
|
595
|
+
// getBetterAuthInstance and server/auth.ts getAuthInstance) funnel through
|
|
596
|
+
// here, so this one line covers both; getAuthInstance adds its own line
|
|
597
|
+
// naming the path, because it previously created an instance INVISIBLY.
|
|
598
|
+
console.log(
|
|
599
|
+
`[BETTER_AUTH] createBetterAuthInstance: slug=${appSlug} authSecret ${idpConfig.authSecret ? `sha256:${secretFingerprint(idpConfig.authSecret as string)}` : 'ABSENT'}`
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
// Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
|
|
603
|
+
// Must include /api/auth since that's where the catch-all route is mounted
|
|
604
|
+
const rawBaseURL = process.env.BETTER_AUTH_URL
|
|
605
|
+
|| idpConfig.baseClientUrl
|
|
606
|
+
|| `http://localhost:${process.env.PORT || '3000'}`;
|
|
607
|
+
const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
|
|
608
|
+
|
|
609
|
+
const idpOAuth = buildIdpOAuthProviderConfig(rawBaseURL);
|
|
610
|
+
|
|
611
|
+
return betterAuth({
|
|
612
|
+
baseURL,
|
|
613
|
+
secret: idpConfig.authSecret as string,
|
|
614
|
+
|
|
615
|
+
socialProviders: buildBetterAuthProviders(idpConfig),
|
|
616
|
+
|
|
617
|
+
// Trust the app's own origin + any configured base URL
|
|
618
|
+
trustedOrigins: [
|
|
619
|
+
rawBaseURL,
|
|
620
|
+
baseURL,
|
|
621
|
+
...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
|
|
622
|
+
// MEASURED 2026-08-26: this list ended with the literals localhost:3000, :3400
|
|
623
|
+
// and :3600. payez_idp_admin_web runs on :3200 - correctly configured, with
|
|
624
|
+
// BETTER_AUTH_URL=http://localhost:3200 - and every GET /api/auth/get-session
|
|
625
|
+
// returned 400, because the port it actually runs on was not one of the three
|
|
626
|
+
// someone had typed in. A config-shaped constant is not configuration.
|
|
627
|
+
//
|
|
628
|
+
// The app's own origin is already covered by rawBaseURL/baseURL above; the dev
|
|
629
|
+
// origin is DERIVED from PORT, and anything genuinely external comes from
|
|
630
|
+
// BETTER_AUTH_TRUSTED_ORIGINS (comma-separated).
|
|
631
|
+
`http://localhost:${process.env.PORT || '3000'}`,
|
|
632
|
+
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
|
633
|
+
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
|
|
634
|
+
: []),
|
|
635
|
+
],
|
|
636
|
+
|
|
637
|
+
// Redis-backed session storage via secondaryStorage
|
|
638
|
+
secondaryStorage: {
|
|
639
|
+
/**
|
|
640
|
+
* 170033 (QAPert 22654, load-bearing precondition for 183483): DO NOT
|
|
641
|
+
* swallow a Redis failure into `null` here.
|
|
642
|
+
*
|
|
643
|
+
* `null` from `get` is Better Auth's OWN signal for "no session at this
|
|
644
|
+
* key" (see node_modules/better-auth db/internal-adapter.mjs
|
|
645
|
+
* `findSession`: `if (!sessionStringified ...) return null`). Catching
|
|
646
|
+
* a Redis error and returning `null` therefore told better-auth "this
|
|
647
|
+
* session does not exist" when the true state was "the store could not
|
|
648
|
+
* be asked" — indistinguishable from a genuine sign-out. With
|
|
649
|
+
* `cookieCache` disabled (session.cookieCache.enabled above), EVERY
|
|
650
|
+
* `getSession()` call hits this path for real, so a Redis blip
|
|
651
|
+
* manufactured a `200 { session: null }` for every live session at
|
|
652
|
+
* once — the exact channel 183483's client-side guard (verify-code
|
|
653
|
+
* page's session-liveness check) was built to survive, and the one it
|
|
654
|
+
* could not survive while this swallow stood: a fabricated "no
|
|
655
|
+
* session" 200 is not distinguishable from a real one by any client.
|
|
656
|
+
*
|
|
657
|
+
* Letting the error propagate is safe: better-auth's own router
|
|
658
|
+
* (better-call/dist/router.mjs) wraps every endpoint handler in a
|
|
659
|
+
* top-level try/catch and converts an uncaught throw to a genuine
|
|
660
|
+
* `500`, not a crash. A `500` IS the "inconclusive" signal 183483's
|
|
661
|
+
* corrected client guard already knows how to hold a session through —
|
|
662
|
+
* so a Redis outage now surfaces as "couldn't check" instead of
|
|
663
|
+
* silently lying "confirmed logged out".
|
|
664
|
+
*/
|
|
665
|
+
get: async (key: string) => {
|
|
666
|
+
const value = await getRedis().get(`ba:${appSlug}:${key}`);
|
|
667
|
+
// 185121 line 2: the 183047 discriminator. A null here means
|
|
668
|
+
// RECORD-MISS — the store was reachable and the key is absent. If the
|
|
669
|
+
// record IS present but BA still rejects the session, this line does
|
|
670
|
+
// NOT fire and the failure is upstream of storage (HMAC/secret gate).
|
|
671
|
+
// BA-primary logs nothing on failure, so without this line the two
|
|
672
|
+
// surviving forks are indistinguishable in the artifact. Key is
|
|
673
|
+
// fingerprinted: it embeds the session token.
|
|
674
|
+
if (value === null) {
|
|
675
|
+
console.warn(
|
|
676
|
+
`[BETTER_AUTH] secondaryStorage.get MISS ba:${appSlug}:sha256:${secretFingerprint(key)} — record absent, store reachable`
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
return value;
|
|
680
|
+
},
|
|
681
|
+
/**
|
|
682
|
+
* 170033 fix 1 — DO NOT SWALLOW A FAILED WRITE. A SESSION THAT WAS NOT
|
|
683
|
+
* PERSISTED IS NOT A SESSION.
|
|
684
|
+
*
|
|
685
|
+
* This previously caught every Redis error and returned normally, so
|
|
686
|
+
* better-auth was told the session had been stored when nothing had been
|
|
687
|
+
* written. The user got a successful login, a session cookie, and a
|
|
688
|
+
* rendered signed-in shell on top of a store that holds no session — and
|
|
689
|
+
* the very next `getSession()` reads `null` and signs them straight back
|
|
690
|
+
* out. Nothing logged, nothing thrown, no banner: a login that "worked"
|
|
691
|
+
* and a sign-out nobody can explain.
|
|
692
|
+
*
|
|
693
|
+
* Letting it propagate is the same contract `get` above already relies on:
|
|
694
|
+
* better-auth's router (better-call/dist/router.mjs) wraps every endpoint
|
|
695
|
+
* handler and converts an uncaught throw into a genuine 500. A 500 on the
|
|
696
|
+
* login write is honest — "we could not store your session" — and it is
|
|
697
|
+
* distinguishable by any client from a successful login, which a swallowed
|
|
698
|
+
* failure is not.
|
|
699
|
+
*
|
|
700
|
+
* THE COMMENT THIS REPLACES SAID "Redis unavailable — cookie cache still
|
|
701
|
+
* works". It does not: `session.cookieCache.enabled` is FALSE (see below).
|
|
702
|
+
* The comment described a safety net that had been removed, which is why
|
|
703
|
+
* this swallow looked survivable to every reader who trusted it (170033
|
|
704
|
+
* fix 3).
|
|
705
|
+
*/
|
|
706
|
+
set: async (key: string, value: string, ttl?: number) => {
|
|
707
|
+
const redis = getRedis();
|
|
708
|
+
const redisKey = `ba:${appSlug}:${key}`;
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
|
|
712
|
+
* (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
|
|
713
|
+
* evidence under BAPert's R920(a). Reproduced here before fixing.)
|
|
714
|
+
*
|
|
715
|
+
* Better Auth owns `{session, user}` and serialises exactly that. But
|
|
716
|
+
* `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
|
|
717
|
+
* `idpTokens` via its own read-modify-write. This setter did a blind
|
|
718
|
+
* `setex` of Better Auth's payload, so every write it makes — including
|
|
719
|
+
* each routine `updateAge` session rewrite, not merely an expiry —
|
|
720
|
+
* STRIPPED `idpTokens` back off the record.
|
|
721
|
+
*
|
|
722
|
+
* The consumer then falls back to an identity husk: `mfaVerified:false`,
|
|
723
|
+
* `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
|
|
724
|
+
* per BAPert it is a public-test blocker on its own terms, because a
|
|
725
|
+
* tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
|
|
726
|
+
* AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
|
|
727
|
+
* failure regardless of which direction it fails in.
|
|
728
|
+
*
|
|
729
|
+
* WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
|
|
730
|
+
* does a read-modify-write; it was correct. The bug is that ONE of the
|
|
731
|
+
* two authors of this key wrote as though it were the only one. Fixing
|
|
732
|
+
* the adapter fixes it for every future enrichment key, whereas moving
|
|
733
|
+
* `idpTokens` elsewhere would leave the next one to rediscover this.
|
|
734
|
+
*
|
|
735
|
+
* ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
|
|
736
|
+
* payload does not set them — so Better Auth still wins on everything it
|
|
737
|
+
* owns, and an explicit incoming value is never overridden by a stale one.
|
|
738
|
+
*
|
|
739
|
+
* NOT ATOMIC, and deliberately not: an enrichment landing between this
|
|
740
|
+
* read and this write is still lost. Closing that needs a WATCH/MULTI or
|
|
741
|
+
* a Lua script, which is a larger change than tonight allows. This
|
|
742
|
+
* narrows the window from "every single write" to "a genuine race", and
|
|
743
|
+
* the residual is recorded here rather than left to be re-found.
|
|
744
|
+
*/
|
|
745
|
+
let payload = value;
|
|
746
|
+
try {
|
|
747
|
+
const existingRaw = await redis.get(redisKey);
|
|
748
|
+
if (existingRaw) {
|
|
749
|
+
const existing = JSON.parse(existingRaw);
|
|
750
|
+
const incoming = JSON.parse(value);
|
|
751
|
+
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
|
752
|
+
let carried = false;
|
|
753
|
+
for (const preserved of PRESERVED_SESSION_KEYS) {
|
|
754
|
+
if (preserved in existing && !(preserved in incoming)) {
|
|
755
|
+
incoming[preserved] = existing[preserved];
|
|
756
|
+
carried = true;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (carried) payload = JSON.stringify(incoming);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
} catch (mergeErr) {
|
|
763
|
+
/**
|
|
764
|
+
* This adapter also stores `verification:*` and `active-sessions-*`
|
|
765
|
+
* records, which are not necessarily session-shaped JSON. A record we
|
|
766
|
+
* cannot parse is not a session to enrich, so writing through unchanged
|
|
767
|
+
* is correct rather than merely tolerable.
|
|
768
|
+
*
|
|
769
|
+
* WARNED, NOT THROWN, and the asymmetry is the point: this block is an
|
|
770
|
+
* ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
|
|
771
|
+
* degraded read into a lost session — the precise inversion 170033 fix 1
|
|
772
|
+
* above exists to prevent. The `setex` below is outside this try and is
|
|
773
|
+
* still unswallowed, so a genuine write failure still propagates.
|
|
774
|
+
*/
|
|
775
|
+
console.warn(
|
|
776
|
+
`[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
|
|
777
|
+
`ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
|
|
778
|
+
`as-is. If this record held idpTokens they are now gone (273183):`,
|
|
779
|
+
mergeErr instanceof Error ? mergeErr.message : String(mergeErr)
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* QA 6952 / R913 - CLAMP, DO NOT DEFER.
|
|
785
|
+
*
|
|
786
|
+
* This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
|
|
787
|
+
* source of session TTL" - what session-store.ts claims, and the mechanism
|
|
788
|
+
* by which the 7d-vs-3d zombie window was supposedly closed for good.
|
|
789
|
+
*
|
|
790
|
+
* IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
|
|
791
|
+
* eight secondaryStorage.set call sites in
|
|
792
|
+
* better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
|
|
793
|
+
* :180, :189, :309, :320, :344, :553, :622). `??` fires only on
|
|
794
|
+
* null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
|
|
795
|
+
* Auth's 7d default - `session.expiresIn` is not configured in this file -
|
|
796
|
+
* while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
|
|
797
|
+
* the zombie window the constant exists to prevent: the guard was bypassed
|
|
798
|
+
* by the very parameter written to defer to it.
|
|
799
|
+
*
|
|
800
|
+
* `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
|
|
801
|
+
* `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
|
|
802
|
+
* records whose TTLs are legitimately SHORTER than 3d. Overwriting those
|
|
803
|
+
* would EXTEND them - turning a fix for over-long sessions into a bug that
|
|
804
|
+
* keeps verification codes alive for three days. Clamp DOWN, never up.
|
|
805
|
+
*
|
|
806
|
+
* Clamped down to 3d rather than raising the canonical store to 7d because
|
|
807
|
+
* SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
|
|
808
|
+
* the refresh token sustaining it cannot be refreshed, so the extra days are
|
|
809
|
+
* a husk that reads as authenticated. Fail-closed beats ambiguous.
|
|
810
|
+
*
|
|
811
|
+
* DO NOT "fix" this by making storeIdpTokensInSession preserve Better
|
|
812
|
+
* Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
|
|
813
|
+
* universal. See test/ba-session-ttl-clamp.test.cjs.
|
|
814
|
+
*/
|
|
815
|
+
await redis.setex(redisKey, Math.min(ttl ?? SESSION_TTL, SESSION_TTL), payload);
|
|
816
|
+
},
|
|
817
|
+
delete: async (key: string) => {
|
|
818
|
+
try {
|
|
819
|
+
await getRedis().del(`ba:${appSlug}:${key}`);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
/**
|
|
822
|
+
* 170033 fix 2 — a failed delete must be VISIBLE, not `/* ignore *\/`.
|
|
823
|
+
*
|
|
824
|
+
* Deliberately logged rather than thrown, and the distinction is not
|
|
825
|
+
* timidity: the CANONICAL store is what decides session liveness (see
|
|
826
|
+
* 181922 below), and its delete runs immediately after this one and is
|
|
827
|
+
* NOT swallowed. So a BA record that outlives its canonical twin does
|
|
828
|
+
* not authenticate anyone — throwing here would fail a sign-out that
|
|
829
|
+
* actually succeeded where it counts.
|
|
830
|
+
*
|
|
831
|
+
* What was unacceptable is that it happened SILENTLY. A revocation
|
|
832
|
+
* that half-failed left no trace anywhere, so the leftover key could
|
|
833
|
+
* only ever be found by someone already reading the keyspace for
|
|
834
|
+
* another reason. `error`, not `warn`: this is a revocation that did
|
|
835
|
+
* not complete, not a retryable blip.
|
|
836
|
+
*/
|
|
837
|
+
console.error(
|
|
838
|
+
`[BETTER_AUTH] secondary delete FAILED for ba:${appSlug}:${key} — ` +
|
|
839
|
+
`the better-auth session record may still exist. Canonical delete ` +
|
|
840
|
+
`below is what governs liveness; if it also failed, the session is ` +
|
|
841
|
+
`NOT revoked:`,
|
|
842
|
+
err
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* 181922 — teardown is BOTH stores. BA's sign-out deletes only its
|
|
847
|
+
* secondary record; the canonical {slug}:sess:{token} would survive
|
|
848
|
+
* and keep the session alive (a replayed cookie still authenticates).
|
|
849
|
+
* `key` here IS the session token (the ba: prefix is applied above),
|
|
850
|
+
* so the canonical delete is one call. This also fires on session
|
|
851
|
+
* rotation/expiry deletions, which is correct: a session BA drops
|
|
852
|
+
* must not live on in the canonical store.
|
|
853
|
+
*/
|
|
854
|
+
try {
|
|
855
|
+
const { deleteSession } = await import('../lib/session-store');
|
|
856
|
+
await deleteSession(key);
|
|
857
|
+
} catch (err) {
|
|
858
|
+
console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
|
|
863
|
+
session: {
|
|
864
|
+
// Cookie cache DISABLED — Redis is canonical for session liveness.
|
|
865
|
+
//
|
|
866
|
+
// Previously: enabled with maxAge 300 + refreshCache false. That cached
|
|
867
|
+
// a decoded session in process memory for 5 minutes, bypassing Redis
|
|
868
|
+
// reads during the window. Consequence: if the canonical app session
|
|
869
|
+
// at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
|
|
870
|
+
// Better Auth's in-memory copy stayed alive — and callers got
|
|
871
|
+
// contradictory answers depending on whether they consulted Better
|
|
872
|
+
// Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
|
|
873
|
+
// visible in production traces: viability 200 + idp-token 200 +
|
|
874
|
+
// getFreshIdpToken NO_SESSION terminal within milliseconds.
|
|
875
|
+
//
|
|
876
|
+
// Trade-off: every Better Auth getSession() call now incurs one Redis
|
|
877
|
+
// read on the secondary-storage path. Latency cost is acceptable
|
|
878
|
+
// (sub-ms in-region) and the alternative is duplicate Layer-1
|
|
879
|
+
// workarounds in every consumer app — already shipped in idealvibe.online
|
|
880
|
+
// at b6a91f6.
|
|
881
|
+
cookieCache: {
|
|
882
|
+
enabled: false,
|
|
883
|
+
maxAge: 300,
|
|
884
|
+
refreshCache: false,
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
|
|
888
|
+
// PAY-1781 B: KEEP OAUTH STATE IN THE ENCRYPTED COOKIE, as it was on better-auth 1.5.6.
|
|
889
|
+
// 1.5.6 resolved the default as `options.database ? 'database' : 'cookie'` - we have no
|
|
890
|
+
// database, so: cookie. 1.6.13+ resolves it as `(database || secondaryStorage) ? 'database'
|
|
891
|
+
// : 'cookie'`, and we DO have secondaryStorage, so the upgrade alone would silently move every
|
|
892
|
+
// OAuth state record into Redis through the adapter above (its enrichment read-modify-write
|
|
893
|
+
// and key handling were built for session records, not state). Pinned so the upgrade changes
|
|
894
|
+
// the version, not the design. The cookie branch is where GHSA-wxw3-q3m9-c3jr lived; 1.6.2+
|
|
895
|
+
// compares the returned `state` with the nonce sealed in the cookie, and our provider is also
|
|
896
|
+
// pkce:true, which the advisory required to be false. Test: oauth-state-strategy-cookie.
|
|
897
|
+
account: {
|
|
898
|
+
storeStateStrategy: 'cookie',
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
// Cookie prefix must match slim-middleware expectations ({slug}.session-token)
|
|
902
|
+
advanced: {
|
|
903
|
+
cookiePrefix: appSlug,
|
|
904
|
+
cookies: {
|
|
905
|
+
session_token: {
|
|
906
|
+
name: `${appSlug}.session-token`,
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
plugins: [
|
|
912
|
+
nextCookies(),
|
|
913
|
+
// Exchange-move: present ONLY when the IDP's OAuth endpoints are
|
|
914
|
+
// configured. Absent otherwise, so this cannot alter existing behaviour.
|
|
915
|
+
...(idpOAuth ? [genericOAuth({ config: [idpOAuth] })] : []),
|
|
916
|
+
...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
|
|
917
|
+
// App-supplied plugins last: they may depend on the built-ins above, and
|
|
918
|
+
// ordering is BA's own concern once they are in the array.
|
|
919
|
+
...(opts.plugins ?? []),
|
|
920
|
+
],
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Better Auth is always enabled (NextAuth removed in 4.0).
|
|
926
|
+
*/
|
|
927
|
+
export function isBetterAuthEnabled(): boolean {
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Get Better Auth Next.js route handlers (GET, POST).
|
|
933
|
+
* Initializes Better Auth from IDP config on first call, caches the instance.
|
|
934
|
+
*
|
|
935
|
+
* STATE LIVES ON globalThis, NOT IN MODULE SCOPE (181814, measured 2026-08-07):
|
|
936
|
+
* under Turbopack, instrumentation.ts and the /api/auth/[...all] route are
|
|
937
|
+
* different chunks and each loads its OWN copy of this module. Module-level
|
|
938
|
+
* `configuredOpts` set by configureBetterAuth() in instrumentation never
|
|
939
|
+
* reached the copy getBetterAuthInstance() read in the route — measured
|
|
940
|
+
* identity probe: sameInstance=false, route-side plugins=["next-cookies"]
|
|
941
|
+
* while the instrumentation-side instance carried every configured plugin.
|
|
942
|
+
* No throw anywhere (the double-init guard never sees the other copy), core
|
|
943
|
+
* auth green, plugin routes 404. globalThis is per-process, per-runtime —
|
|
944
|
+
* exactly the scope this state must have.
|
|
945
|
+
*/
|
|
946
|
+
interface BetterAuthModuleState {
|
|
947
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
948
|
+
cachedInstance: any;
|
|
949
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
950
|
+
initPromise: Promise<any> | null;
|
|
951
|
+
// PAY-642: the init attempt, single-flighted so a REJECTED attempt clears itself (see getBetterAuthInstance).
|
|
952
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
953
|
+
initFlight?: SingleFlight<any>;
|
|
954
|
+
configuredOpts: CreateBetterAuthInstanceOptions;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const STATE_KEY = '__payezBetterAuthState';
|
|
958
|
+
const g = globalThis as unknown as Record<string, BetterAuthModuleState | undefined>;
|
|
959
|
+
g[STATE_KEY] ??= { cachedInstance: null, initPromise: null, configuredOpts: {} };
|
|
960
|
+
const state = g[STATE_KEY];
|
|
961
|
+
|
|
962
|
+
// Expose for server-side session access (decode-session.ts). A getter so the
|
|
963
|
+
// export tracks the SHARED state, not a chunk-local snapshot.
|
|
964
|
+
export function __getBetterAuthInstance(): unknown {
|
|
965
|
+
return state.cachedInstance;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Configure Better Auth instance options for this process.
|
|
970
|
+
*
|
|
971
|
+
* Must be called before the first auth request — before
|
|
972
|
+
* `getBetterAuthInstance()` caches an instance. Typically called once at
|
|
973
|
+
* app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
|
|
974
|
+
* server bootstrap hook.
|
|
975
|
+
*
|
|
976
|
+
* Throws if called after the instance has already been resolved: options
|
|
977
|
+
* cannot be applied retroactively.
|
|
978
|
+
*/
|
|
979
|
+
export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
|
|
980
|
+
if (state.cachedInstance) {
|
|
981
|
+
throw new Error(
|
|
982
|
+
'[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
|
|
983
|
+
'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
state.configuredOpts = opts;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* PAY-642 (card 338215): the init attempt no longer LATCHES A FAILURE.
|
|
991
|
+
*
|
|
992
|
+
* This used to be `state.initPromise ??= getIDPClientConfig(true).then(...)` with no .catch and no reset. One
|
|
993
|
+
* auth request against a cold process while the IdP was unreachable stored a rejected promise, and every later
|
|
994
|
+
* caller replayed it until the process restarted, even after the IdP recovered. This funnel backs every
|
|
995
|
+
* /api/auth/* route, the login GET handler and withAuth. server/auth.ts getAuthInstance() already used
|
|
996
|
+
* createSingleFlight for this exact bug; this sibling funnel was missed.
|
|
997
|
+
*
|
|
998
|
+
* Now the attempt runs through the same helper: concurrent callers share one attempt, a failure still rejects
|
|
999
|
+
* for every caller of THAT attempt (loud), the next call starts a fresh attempt, and a success is cached in
|
|
1000
|
+
* state.cachedInstance as before. The flight lives on the globalThis state for the same reason the instance
|
|
1001
|
+
* does (duplicated route chunks must share one init).
|
|
1002
|
+
*/
|
|
1003
|
+
export async function getBetterAuthInstance() {
|
|
1004
|
+
if (state.cachedInstance) return state.cachedInstance;
|
|
1005
|
+
|
|
1006
|
+
state.initFlight ??= createSingleFlight(() =>
|
|
1007
|
+
getIDPClientConfig(true).then(config => {
|
|
1008
|
+
const instance = createBetterAuthInstance(config, state.configuredOpts);
|
|
1009
|
+
state.cachedInstance = instance;
|
|
1010
|
+
console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
|
|
1011
|
+
return instance;
|
|
1012
|
+
})
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
const attempt = state.initFlight.get();
|
|
1016
|
+
// Kept for callers that inspect it; it always points at the CURRENT attempt, never a settled failure.
|
|
1017
|
+
state.initPromise = attempt;
|
|
1018
|
+
return attempt;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Get flag-gated auth handler for Next.js route.
|
|
1023
|
+
*
|
|
1024
|
+
* When USE_BETTER_AUTH=true, returns Better Auth handlers.
|
|
1025
|
+
* Otherwise returns null (auth disabled).
|
|
1026
|
+
*
|
|
1027
|
+
* Usage in host app route:
|
|
1028
|
+
* ```ts
|
|
1029
|
+
* import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
|
|
1030
|
+
*
|
|
1031
|
+
* export async function GET(req: Request) {
|
|
1032
|
+
* const ba = await getBetterAuthHandler();
|
|
1033
|
+
* if (ba) return ba.GET(req);
|
|
1034
|
+
* }
|
|
1035
|
+
* ```
|
|
1036
|
+
*/
|
|
1037
|
+
export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
|
|
1038
|
+
if (!isBetterAuthEnabled()) return null;
|
|
1039
|
+
|
|
1040
|
+
const auth = await getBetterAuthInstance();
|
|
1041
|
+
// PAY-2217: Better Auth's native endpoints (get-session, list-sessions, sign-in/*, sign-up/*) put the session
|
|
1042
|
+
// token in the body. Strip it at the HTTP boundary here, so every consumer that mounts this handler gets it;
|
|
1043
|
+
// auth.api (server-side) is untouched and still sees the token. See session-response-sanitizer.ts.
|
|
1044
|
+
return wrapBetterAuthHandler(auth as any, toNextJsHandler(auth));
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Exchange OAuth identity for IDP tokens and store in the BA Redis session.
|
|
1049
|
+
*
|
|
1050
|
+
* Call this from the OAuth callback route AFTER better-auth has processed the
|
|
1051
|
+
* callback and created the session. Reads the session token from the Set-Cookie
|
|
1052
|
+
* header of the response to find the BA Redis key.
|
|
1053
|
+
*
|
|
1054
|
+
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
1055
|
+
*
|
|
1056
|
+
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
1057
|
+
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
1058
|
+
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
1059
|
+
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
1060
|
+
* refused registration bounced silently, which is the failure Jon named.
|
|
1061
|
+
*/
|
|
1062
|
+
export async function exchangeOAuthForIdpTokens(
|
|
1063
|
+
sessionToken: string,
|
|
1064
|
+
provider: string = 'google'
|
|
1065
|
+
): Promise<boolean | string> {
|
|
1066
|
+
try {
|
|
1067
|
+
const config = await getIDPClientConfig();
|
|
1068
|
+
const appSlug = config.clientSlug || getAppSlug();
|
|
1069
|
+
const baKey = `ba:${appSlug}:${sessionToken}`;
|
|
1070
|
+
|
|
1071
|
+
// Read the BA session from Redis
|
|
1072
|
+
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
1073
|
+
if (!baRaw) {
|
|
1074
|
+
console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', tokenLogId(sessionToken));
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const baData = JSON.parse(baRaw);
|
|
1079
|
+
const email = baData?.user?.email;
|
|
1080
|
+
const name = baData?.user?.name;
|
|
1081
|
+
const image = baData?.user?.image;
|
|
1082
|
+
const baUserId = baData?.session?.userId || baData?.user?.id;
|
|
1083
|
+
|
|
1084
|
+
if (!email) {
|
|
1085
|
+
console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
|
|
1086
|
+
return false;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Call IDP oauth-callback
|
|
1090
|
+
const idpUrl = process.env.IDP_URL || '';
|
|
1091
|
+
if (!idpUrl) {
|
|
1092
|
+
console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
|
|
1097
|
+
|
|
1098
|
+
const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
|
|
1099
|
+
method: 'POST',
|
|
1100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1101
|
+
body: JSON.stringify({
|
|
1102
|
+
provider,
|
|
1103
|
+
provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
|
|
1104
|
+
email,
|
|
1105
|
+
name,
|
|
1106
|
+
image,
|
|
1107
|
+
client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? '',
|
|
1108
|
+
/**
|
|
1109
|
+
* Card 138635 — the RAW provider id_token, forwarded verbatim.
|
|
1110
|
+
*
|
|
1111
|
+
* Every other field in this body is an ASSERTION. The endpoint mints an
|
|
1112
|
+
* IDP bearer from them today with no cryptographic check of any kind,
|
|
1113
|
+
* which is why it is a complete authentication bypass and why it has
|
|
1114
|
+
* been publicly reachable for 488 days.
|
|
1115
|
+
*
|
|
1116
|
+
* This field is the only one the IDP can VERIFY: signature against
|
|
1117
|
+
* Google's JWKS, plus iss / exp / aud, and then read email_verified
|
|
1118
|
+
* from the verified claims. Additive — the IDP ignores it until its
|
|
1119
|
+
* gate flips, so this cannot break a login on its own.
|
|
1120
|
+
*
|
|
1121
|
+
* RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
|
|
1122
|
+
* decoded email_verified. A client-computed verification flag is a body
|
|
1123
|
+
* field with a better story: an attacker posts email_verified:true and
|
|
1124
|
+
* the defect is rebuilt one layer up. The IDP verifies for itself or
|
|
1125
|
+
* the fix is theatre.
|
|
1126
|
+
*
|
|
1127
|
+
* Absent when this runs outside the OAuth callback scope — the key is
|
|
1128
|
+
* simply omitted rather than sent null, so "we never captured one" stays
|
|
1129
|
+
* distinguishable from "the provider issued none".
|
|
1130
|
+
*/
|
|
1131
|
+
...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
|
|
1132
|
+
/**
|
|
1133
|
+
* The same field for providers that have no id_token to give.
|
|
1134
|
+
*
|
|
1135
|
+
* Identical doctrine to the id_token above: RAW, forwarded verbatim,
|
|
1136
|
+
* NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
|
|
1137
|
+
* "never captured" stays distinguishable from "provider issued none".
|
|
1138
|
+
* The IDP verifies for itself by calling GitHub with this token and
|
|
1139
|
+
* enforcing primary && verified; we assert nothing about the identity.
|
|
1140
|
+
*
|
|
1141
|
+
* Scoped to the provider access token captured during THIS callback, so
|
|
1142
|
+
* a google login continues to send id_token only and is unaffected.
|
|
1143
|
+
*/
|
|
1144
|
+
...(idTokenScope.getStore()?.accessToken
|
|
1145
|
+
? { access_token: idTokenScope.getStore()!.accessToken }
|
|
1146
|
+
: {}),
|
|
1147
|
+
}),
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const oauthResText = await oauthRes.text();
|
|
1151
|
+
// PAY-1427 (QAPert, PR 154 thread 326): this printed the first 500 chars of the oauth-callback TOKEN
|
|
1152
|
+
// envelope on EVERY successful OAuth login (it runs before the !ok check). Status + length only.
|
|
1153
|
+
console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
|
|
1154
|
+
|
|
1155
|
+
if (!oauthRes.ok) {
|
|
1156
|
+
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
|
|
1160
|
+
*
|
|
1161
|
+
* Every refusal used to return a bare `false`, so the abort collapsed ALL
|
|
1162
|
+
* causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
|
|
1163
|
+
* registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
|
|
1164
|
+
* bounced silently, indistinguishable from a network blip. Jon named the
|
|
1165
|
+
* clear message explicitly (67856): "(b) a brand-new Google user with
|
|
1166
|
+
* registration OFF is refused by the IDP with a clear message, not a
|
|
1167
|
+
* silent bounce."
|
|
1168
|
+
*
|
|
1169
|
+
* The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
|
|
1170
|
+
* We read ONLY `error.code` and return it VERBATIM — the abort puts it in
|
|
1171
|
+
* the `?error=` param, and the HOST's allowlist decides whether to render
|
|
1172
|
+
* it. The package does NOT interpret the code and does NOT add a message
|
|
1173
|
+
* (that would be a second allowlist). No code in the body -> null (the
|
|
1174
|
+
* generic host code), exactly as before.
|
|
1175
|
+
*/
|
|
1176
|
+
let idpErrorCode: string | null = null;
|
|
1177
|
+
try {
|
|
1178
|
+
const parsed = JSON.parse(oauthResText);
|
|
1179
|
+
// FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
|
|
1180
|
+
// unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
|
|
1181
|
+
if (typeof parsed?.error?.code === 'string' && IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
|
|
1182
|
+
idpErrorCode = parsed.error.code;
|
|
1183
|
+
}
|
|
1184
|
+
} catch {
|
|
1185
|
+
/* non-JSON refusal body -> no code (generic abort) */
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
|
|
1190
|
+
*
|
|
1191
|
+
* UNVERIFIED_ASSERTION has exactly two causes and the response body cannot tell
|
|
1192
|
+
* them apart: we sent no id_token for the IDP to verify, or we sent one whose
|
|
1193
|
+
* signature/aud/iss the IDP rejected. Without this line the operator sees a 401 on
|
|
1194
|
+
* a login that visibly succeeded and has nowhere to go — which is where UMI sat on
|
|
1195
|
+
* 2026-08-26.
|
|
1196
|
+
*
|
|
1197
|
+
* NEVER THE TOKEN ITSELF. An id_token is credential material and this whole code
|
|
1198
|
+
* path exists to keep it out of anything persistent; a log file is persistent. We
|
|
1199
|
+
* print presence, length, and the three claims that decide verification. `aud` is
|
|
1200
|
+
* the Google client id — not a secret, and the single most likely mismatch, since
|
|
1201
|
+
* the IDP checks it against what IT has registered for this client.
|
|
1202
|
+
*/
|
|
1203
|
+
const forwarded = idTokenScope.getStore()?.idToken;
|
|
1204
|
+
let claims: Record<string, unknown> = {};
|
|
1205
|
+
if (forwarded) {
|
|
1206
|
+
try {
|
|
1207
|
+
claims = decodeJwt(forwarded) as Record<string, unknown>;
|
|
1208
|
+
} catch {
|
|
1209
|
+
claims = { decode: 'FAILED — not a well-formed JWT' };
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const forwardedAccess = idTokenScope.getStore()?.accessToken;
|
|
1213
|
+
console.error('[BETTER_AUTH] exchange diagnostics:', {
|
|
1214
|
+
id_token_forwarded: !!forwarded,
|
|
1215
|
+
id_token_length: forwarded ? forwarded.length : 0,
|
|
1216
|
+
/**
|
|
1217
|
+
* Read these two for github, and IGNORE the id_token pair above: github
|
|
1218
|
+
* issues no id_token, so `id_token_forwarded: false` is its NORMAL state
|
|
1219
|
+
* and not the fault. Presence and length only — an access token is
|
|
1220
|
+
* credential material and this log is persistent. A github failure with
|
|
1221
|
+
* access_token_forwarded:true means the IDP called GitHub and refused
|
|
1222
|
+
* the answer (unverified or non-primary email, or app not installed);
|
|
1223
|
+
* false means the capture hook never ran and the IDP never called out.
|
|
1224
|
+
*/
|
|
1225
|
+
access_token_forwarded: !!forwardedAccess,
|
|
1226
|
+
access_token_length: forwardedAccess ? forwardedAccess.length : 0,
|
|
1227
|
+
// Absent id_token => the IDP had nothing to verify. Present => compare these
|
|
1228
|
+
// against the client's registered Google credentials on the IDP.
|
|
1229
|
+
aud: claims.aud ?? null,
|
|
1230
|
+
iss: claims.iss ?? null,
|
|
1231
|
+
email_verified: claims.email_verified ?? null,
|
|
1232
|
+
sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
|
|
1233
|
+
idp_url: idpUrl,
|
|
1234
|
+
});
|
|
1235
|
+
return idpErrorCode ?? false;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
let idpData: any;
|
|
1239
|
+
try { idpData = JSON.parse(oauthResText); } catch { return false; }
|
|
1240
|
+
const result = idpData?.data?.result || idpData?.data || idpData;
|
|
1241
|
+
|
|
1242
|
+
if (!result?.access_token) {
|
|
1243
|
+
console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
|
|
1244
|
+
return false;
|
|
1245
|
+
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Build IDP token data
|
|
1249
|
+
//
|
|
1250
|
+
// 170007 CONTRACT (BAPert ruling on 121350, agreed with DotNetPert
|
|
1251
|
+
// 2026-08-08 — see mail thread, not re-derived here):
|
|
1252
|
+
// 1. The IDP emits `requires_two_factor` (snake_case, both serializers,
|
|
1253
|
+
// per the estate law) as a NON-NULLABLE bool on OAuthCallbackResponse
|
|
1254
|
+
// (PayEz.DTOs OAuthCallbackResponse.cs) — ALWAYS present. No IDP
|
|
1255
|
+
// change needed or wanted: emitting a second, camelCase name for the
|
|
1256
|
+
// same fact would be the dialect problem the estate law exists to
|
|
1257
|
+
// prevent, not a fix.
|
|
1258
|
+
// 2. Read requires_two_factor (snake) ONLY. CORRECTED mid-review:
|
|
1259
|
+
// BAPert's first draft of this contract said "read both casings,
|
|
1260
|
+
// defensively" — DotNetPert rejected that: a dual-casing client-side
|
|
1261
|
+
// read gives one fact two wire names just as surely as a dual-casing
|
|
1262
|
+
// IDP emit would, and it is the exact audience-dialect mistake
|
|
1263
|
+
// (186443) made deliberately instead of by accident. This package
|
|
1264
|
+
// previously read camel ONLY (card 170083, now corrected — it must
|
|
1265
|
+
// NOT record "emit both casings" as the IDP-side fix), which is why
|
|
1266
|
+
// this line silently fell through to the heuristic below on every
|
|
1267
|
+
// real call. The fix is reading the one name the IDP actually sends,
|
|
1268
|
+
// not tolerating both.
|
|
1269
|
+
// 3. The old !hasRefreshToken fallback (IDP-2FA-BUG below) is RETIRED,
|
|
1270
|
+
// not patched. DotNetPert traced ExternalAuthController's
|
|
1271
|
+
// oauth-callback: IssueRefreshToken and RequiresTwoFactor both derive
|
|
1272
|
+
// from the SAME isFullyAuthenticated variable, so they cannot
|
|
1273
|
+
// disagree on this path — the divergence this fallback existed for
|
|
1274
|
+
// is not present here. Worse, the heuristic would currently produce
|
|
1275
|
+
// the RIGHT answer by coincidence, which hides the day it stops
|
|
1276
|
+
// being derived from one variable.
|
|
1277
|
+
// 4. requires_two_factor is non-nullable, so reaching the `true`
|
|
1278
|
+
// default below means the response ISN'T an OAuthCallbackResponse at
|
|
1279
|
+
// all (wrong endpoint / error body / failed parse) — logged as that,
|
|
1280
|
+
// not as "2FA status unknown". Fail-closed to requiresTwoFactor=true
|
|
1281
|
+
// either way (rule 3).
|
|
1282
|
+
// 5. mfaVerified = !requiresTwoFactor, computed fresh from THIS
|
|
1283
|
+
// response — no hardcoding, no carrying a prior session value
|
|
1284
|
+
// forward (that half of the monotonic-OR defect lived in
|
|
1285
|
+
// refresh.ts / ensure-fresh-access-token.ts, fixed separately).
|
|
1286
|
+
//
|
|
1287
|
+
// PRIOR STATE, for the record: this used to hardcode mfaVerified=false on
|
|
1288
|
+
// every OAuth exchange as a blunt workaround for a since-disproven IDP
|
|
1289
|
+
// bug, which itself caused every Google user to be sent to /mfa even on
|
|
1290
|
+
// require_2fa=FALSE tenants (NextPert 13450's blocker, failing T4 on the
|
|
1291
|
+
// demo tenant). That workaround is removed along with the bug it worked
|
|
1292
|
+
// around.
|
|
1293
|
+
const requiresTwoFactor: boolean = (() => {
|
|
1294
|
+
// SNAKE ONLY (BAPert's corrected ruling, 2026-08-08): reading camel as
|
|
1295
|
+
// a "defensive" secondary would give one fact two wire names — exactly
|
|
1296
|
+
// the audience-dialect mistake (186443) being made deliberately. The
|
|
1297
|
+
// estate law is explicit-snake-per-field-both-serializers; the IDP
|
|
1298
|
+
// already complies (OAuthCallbackResponse.cs, both System.Text.Json
|
|
1299
|
+
// and Newtonsoft). No IDP change, no camel read, no dual-casing.
|
|
1300
|
+
const v = result.requires_two_factor ?? result.user?.requires_two_factor;
|
|
1301
|
+
if (v === undefined) {
|
|
1302
|
+
console.warn(
|
|
1303
|
+
'[BETTER_AUTH] oauth-callback response has no requires_two_factor field at all — ' +
|
|
1304
|
+
'unexpected response shape (requires_two_factor is non-nullable on OAuthCallbackResponse; ' +
|
|
1305
|
+
'this is not "2FA status unknown", it means we are not looking at an oauth-callback response). ' +
|
|
1306
|
+
'Defaulting requiresTwoFactor=true (fail closed).',
|
|
1307
|
+
);
|
|
1308
|
+
return true;
|
|
1309
|
+
}
|
|
1310
|
+
return !!v;
|
|
1311
|
+
})();
|
|
1312
|
+
const idpTokenData = {
|
|
1313
|
+
idpAccessToken: result.access_token,
|
|
1314
|
+
idpRefreshToken: result.refresh_token,
|
|
1315
|
+
idpAccessTokenExpires: result.expires_in
|
|
1316
|
+
? Date.now() + result.expires_in * 1000
|
|
1317
|
+
: Date.now() + 15 * 60 * 1000,
|
|
1318
|
+
userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
|
|
1319
|
+
email: result.user?.email || result.email || email,
|
|
1320
|
+
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
1321
|
+
image: image,
|
|
1322
|
+
roles: result.user?.roles || result.roles || [],
|
|
1323
|
+
mfaVerified: !requiresTwoFactor, // re-derived fresh — see 170007 contract above
|
|
1324
|
+
// Cards 141688/141741 — RELAYED, NOT DERIVED.
|
|
1325
|
+
//
|
|
1326
|
+
// The IDP already decides this (ExternalAuthController:943 and :2024,
|
|
1327
|
+
// WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
|
|
1328
|
+
// confirmed method exists, null = fully authenticated. It was emitted
|
|
1329
|
+
// here and dropped on this line — `idpTokenData` is built field-by-field,
|
|
1330
|
+
// so anything not named is silently discarded. Both downstream consumers
|
|
1331
|
+
// (the middleware gate and the verify-code page) then reasoned in its
|
|
1332
|
+
// absence and sent zero-method users to a page they cannot act on.
|
|
1333
|
+
//
|
|
1334
|
+
// WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
|
|
1335
|
+
// BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
|
|
1336
|
+
// `result.nextStep` here would be `undefined` forever and fail silently —
|
|
1337
|
+
// the same camel/snake break `requires_two_factor` already cost us once.
|
|
1338
|
+
//
|
|
1339
|
+
// Do NOT recompute this from a method query. The IDP owns the fact; it is
|
|
1340
|
+
// already usable-aware (confirmed and not revoked, per DotNetPert), which
|
|
1341
|
+
// is the distinction that decides correctness: a revoked-TOTP user has
|
|
1342
|
+
// count>0 but usable=0, and deriving it locally would let through exactly
|
|
1343
|
+
// the users we must challenge.
|
|
1344
|
+
// No `?? null`: if the response has no `next_step` at all (older IDP
|
|
1345
|
+
// build, or a path that does not set it), that is UNKNOWN, not "fully
|
|
1346
|
+
// authenticated". Defaulting it to null here would manufacture a
|
|
1347
|
+
// clean-bill-of-health the IDP never gave and fail open at the gate.
|
|
1348
|
+
nextStep: result.next_step,
|
|
1349
|
+
idpClientId: result.client_id ? String(result.client_id) : undefined,
|
|
1350
|
+
merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
// Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
|
|
1354
|
+
const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
|
|
1355
|
+
if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
1356
|
+
// true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
|
|
1357
|
+
return stored;
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Write IDP tokens into an EXISTING Better Auth session. Storage only — this
|
|
1367
|
+
* function never acquires tokens and never decides who the caller is.
|
|
1368
|
+
*
|
|
1369
|
+
* ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
|
|
1370
|
+
*
|
|
1371
|
+
* `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
|
|
1372
|
+
* POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
|
|
1373
|
+
* the second half is reusable, and the first half is actively dangerous to
|
|
1374
|
+
* reuse:
|
|
1375
|
+
*
|
|
1376
|
+
* oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
|
|
1377
|
+
* cryptographic verification of any kind (card 138635).
|
|
1378
|
+
*
|
|
1379
|
+
* Passkey sign-in already holds something strictly better — a grant issued by
|
|
1380
|
+
* `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
|
|
1381
|
+
* signature against a registered credential. Routing that through
|
|
1382
|
+
* oauth-callback to obtain a session would DOWNGRADE a cryptographically
|
|
1383
|
+
* verified assertion into an unverified one, make passkey sign-in weaker than
|
|
1384
|
+
* its own ceremony, and inherit 138635 wholesale.
|
|
1385
|
+
*
|
|
1386
|
+
* That is not hypothetical: "do what the OAuth path does" is the obvious
|
|
1387
|
+
* implementation and it is the wrong one. Splitting acquisition from storage is
|
|
1388
|
+
* what makes the wrong one hard to write by accident.
|
|
1389
|
+
*
|
|
1390
|
+
* ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
|
|
1391
|
+
*
|
|
1392
|
+
* A Better Auth session must ALREADY EXIST for `sessionToken`. This function
|
|
1393
|
+
* enriches; it does not create. Better Auth owns session creation and cookie
|
|
1394
|
+
* signing, and nothing in this package should replicate either — the cookie is
|
|
1395
|
+
* `{token}.{base64(hmac)}` with standard padding, signed with the runtime
|
|
1396
|
+
* `authSecret` from Redis, and hand-rolling that produces a session which
|
|
1397
|
+
* decodes but does not authenticate.
|
|
1398
|
+
*
|
|
1399
|
+
* @param sessionToken the Better Auth session token (from its Set-Cookie)
|
|
1400
|
+
* @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
|
|
1401
|
+
* verified route. Built field-by-field by the caller so an
|
|
1402
|
+
* unnamed field cannot ride along silently.
|
|
1403
|
+
* @param authProvider provenance label recorded on the canonical row. Written
|
|
1404
|
+
* to the historical `oauthProvider` key, which predates
|
|
1405
|
+
* non-OAuth callers; the NAME is legacy, the VALUE should
|
|
1406
|
+
* state what actually authenticated ('passkey', 'google').
|
|
1407
|
+
*/
|
|
1408
|
+
export async function storeIdpTokensInSession(
|
|
1409
|
+
sessionToken: string,
|
|
1410
|
+
idpTokenData: Record<string, unknown>,
|
|
1411
|
+
authProvider: string,
|
|
1412
|
+
): Promise<boolean> {
|
|
1413
|
+
try {
|
|
1414
|
+
const config = await getIDPClientConfig();
|
|
1415
|
+
const appSlug = config.clientSlug || getAppSlug();
|
|
1416
|
+
const baKey = `ba:${appSlug}:${sessionToken}`;
|
|
1417
|
+
|
|
1418
|
+
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
1419
|
+
if (!baRaw) {
|
|
1420
|
+
// No session to enrich. Do NOT create one as a fallback: a session
|
|
1421
|
+
// conjured here would carry no Better Auth cookie, so it would be
|
|
1422
|
+
// unreachable by the browser and invisible to every other consumer —
|
|
1423
|
+
// failing as "silently signed out" rather than as an error.
|
|
1424
|
+
console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', tokenLogId(sessionToken));
|
|
1425
|
+
return false;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const baData = JSON.parse(baRaw);
|
|
1429
|
+
baData.idpTokens = idpTokenData;
|
|
1430
|
+
await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
|
|
1431
|
+
|
|
1432
|
+
// Canonical store — the key getSession() reads, used by the refresh handler
|
|
1433
|
+
// and token lifecycle. A failure here leaves the BA session correct and the
|
|
1434
|
+
// canonical row missing, so it is warned and not fatal: the user is signed
|
|
1435
|
+
// in, and refresh degrades rather than the sign-in failing outright.
|
|
1436
|
+
try {
|
|
1437
|
+
const { getSessionPrefix } = await import('../lib/app-slug');
|
|
1438
|
+
const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
|
|
1439
|
+
await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
|
|
1440
|
+
...idpTokenData,
|
|
1441
|
+
oauthProvider: authProvider,
|
|
1442
|
+
}));
|
|
1443
|
+
} catch (canonicalErr) {
|
|
1444
|
+
console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
return true;
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
|
|
1450
|
+
return false;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
|
|
1456
|
+
* store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
|
|
1457
|
+
* Try again or use another method." The cause stays in the server log, never on the page.
|
|
1458
|
+
*/
|
|
1459
|
+
export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* PAY-2305: store the tokens the payez-idp provider hooks captured during THIS request. Fail closed: no captured tokens or no
|
|
1463
|
+
* verified claims (getUserInfo refused the token) => false, and the caller aborts the sign-in. nextStep is left undefined on
|
|
1464
|
+
* purpose (unknown is not "fully authenticated", see 141688); mfaVerified comes only from the verified token's amr.
|
|
1465
|
+
*/
|
|
1466
|
+
export async function storePayezIdpSession(sessionToken: string): Promise<boolean> {
|
|
1467
|
+
const captured = idTokenScope.getStore()?.idp;
|
|
1468
|
+
if (!captured?.tokens?.accessToken || !captured.claims) {
|
|
1469
|
+
console.warn('[BETTER_AUTH] payez-idp callback: no verified IdP tokens captured for this request');
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
const { tokens, claims } = captured;
|
|
1473
|
+
const stored = await storeIdpTokensInSession(
|
|
1474
|
+
sessionToken,
|
|
1475
|
+
{
|
|
1476
|
+
idpAccessToken: tokens.accessToken,
|
|
1477
|
+
idpRefreshToken: tokens.refreshToken,
|
|
1478
|
+
idpAccessTokenExpires: tokens.accessTokenExpires ?? Date.now() + 15 * 60 * 1000,
|
|
1479
|
+
userId: String(claims.userId ?? claims.sub),
|
|
1480
|
+
email: claims.email,
|
|
1481
|
+
name: claims.name,
|
|
1482
|
+
roles: claims.roles,
|
|
1483
|
+
mfaVerified: claims.mfaVerified,
|
|
1484
|
+
nextStep: undefined,
|
|
1485
|
+
},
|
|
1486
|
+
'payez-idp',
|
|
1487
|
+
);
|
|
1488
|
+
if (stored) console.log('[BETTER_AUTH] IdP tokens stored in session for', claims.email);
|
|
1489
|
+
return stored;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
1494
|
+
*
|
|
1495
|
+
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
1496
|
+
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
1497
|
+
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
1498
|
+
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
1499
|
+
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
1500
|
+
* wearing a different number. Add them together, never one alone.
|
|
1501
|
+
*/
|
|
1502
|
+
export const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string> = new Set(['REGISTRATION_NOT_ALLOWED']);
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
1507
|
+
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
|
1508
|
+
* accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
|
|
1509
|
+
*/
|
|
1510
|
+
export function expireSetCookies(setCookies: readonly string[]): string[] {
|
|
1511
|
+
const out: string[] = [];
|
|
1512
|
+
for (const sc of setCookies) {
|
|
1513
|
+
const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
|
|
1514
|
+
const eq = pair.indexOf('=');
|
|
1515
|
+
const name = eq > 0 ? pair.slice(0, eq) : '';
|
|
1516
|
+
if (!name) continue;
|
|
1517
|
+
const kept = attrs.filter((a) => /^(path|domain|secure|samesite|httponly)\b/i.test(a));
|
|
1518
|
+
out.push([`${name}=`, ...kept, 'Max-Age=0'].join('; '));
|
|
1519
|
+
}
|
|
1520
|
+
return out;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* PAY-1733 (Jon, live on :4797): ABORT an OAuth sign-in whose IdP exchange did not store tokens.
|
|
1525
|
+
*
|
|
1526
|
+
* Before this, a failed exchange was a console.error and the user kept the Better Auth session the callback had
|
|
1527
|
+
* just created: signed in, holding NO IdP tokens. The host then routed that session to 2FA, which painted the
|
|
1528
|
+
* user's email and failed every call - a red "sign in again" screen about an account the app could no longer
|
|
1529
|
+
* authenticate. The IdP's 401 was CORRECT (it fail-closes unverified assertions); keeping the session was the bug.
|
|
1530
|
+
*
|
|
1531
|
+
* So: no session without IdP tokens. The Better Auth session is destroyed (its own signOut, best effort, then the
|
|
1532
|
+
* `ba:<slug>:<token>` record directly - that record is what makes it resolvable), every cookie the callback set is
|
|
1533
|
+
* expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
|
|
1534
|
+
* branch above (328439: an absolute URL built from request.url is a dead tab in a container).
|
|
1535
|
+
*/
|
|
1536
|
+
async function abortOAuthSignIn(
|
|
1537
|
+
callbackResponse: Response,
|
|
1538
|
+
sessionToken: string | null,
|
|
1539
|
+
cookieHeader: string,
|
|
1540
|
+
loginPath: string,
|
|
1541
|
+
errorCode: string = OAUTH_SIGN_IN_FAILED_ERROR,
|
|
1542
|
+
): Promise<Response> {
|
|
1543
|
+
const headers = new Headers();
|
|
1544
|
+
if (sessionToken) {
|
|
1545
|
+
try {
|
|
1546
|
+
const auth = await getBetterAuthInstance();
|
|
1547
|
+
const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
|
|
1548
|
+
for (const sc of (out as Response | undefined)?.headers?.getSetCookie?.() ?? []) headers.append('set-cookie', sc);
|
|
1549
|
+
} catch (err) {
|
|
1550
|
+
console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
|
|
1551
|
+
}
|
|
1552
|
+
try {
|
|
1553
|
+
const config = await getIDPClientConfig();
|
|
1554
|
+
await getRedis().del(`ba:${config.clientSlug || getAppSlug()}:${sessionToken}`);
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), err instanceof Error ? err.message : String(err));
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
|
|
1560
|
+
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
|
|
1561
|
+
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)', 'code=' + errorCode);
|
|
1562
|
+
return new Response(null, { status: 302, headers });
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Create a production-ready GET handler for the auth catch-all route.
|
|
1567
|
+
*
|
|
1568
|
+
* Wraps better-auth's GET handler with:
|
|
1569
|
+
* - OAuth state error recovery (redirects to login instead of error page)
|
|
1570
|
+
* - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
|
|
1571
|
+
* (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
|
|
1572
|
+
*
|
|
1573
|
+
* Usage in host app:
|
|
1574
|
+
* ```ts
|
|
1575
|
+
* import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
|
|
1576
|
+
* export const GET = createAuthGetHandler('/account-auth/login');
|
|
1577
|
+
* export async function POST(req: Request) {
|
|
1578
|
+
* const ba = await getBetterAuthHandler();
|
|
1579
|
+
* return ba!.POST(req);
|
|
1580
|
+
* }
|
|
1581
|
+
* ```
|
|
1582
|
+
*/
|
|
1583
|
+
export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
|
|
1584
|
+
return async function GET(request: Request): Promise<Response> {
|
|
1585
|
+
// Card 138635: establish the request-scoped carrier for the provider's raw
|
|
1586
|
+
// id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
|
|
1587
|
+
// hook that sees the token) and the exchange below (which forwards it).
|
|
1588
|
+
// Wrapping the whole handler is what makes "never persisted" achievable —
|
|
1589
|
+
// the token exists only for the life of this request.
|
|
1590
|
+
return idTokenScope.run({}, async () => {
|
|
1591
|
+
const ba = await getBetterAuthHandler();
|
|
1592
|
+
if (!ba) {
|
|
1593
|
+
return new Response('Auth handler not configured', { status: 500 });
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const response = await ba.GET(request);
|
|
1597
|
+
|
|
1598
|
+
// Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
|
|
1599
|
+
if (response.status === 302) {
|
|
1600
|
+
const location = response.headers.get('location') || '';
|
|
1601
|
+
if (location.includes('/api/auth/error') || location.includes('please_restart')) {
|
|
1602
|
+
console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
|
|
1603
|
+
// *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
|
|
1604
|
+
// Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
|
|
1605
|
+
// runtime and again on node 24:
|
|
1606
|
+
// Response.redirect('/account-auth/login', 302)
|
|
1607
|
+
// -> TypeError: Failed to parse URL from /account-auth/login
|
|
1608
|
+
// That requirement is the whole cause of the bug this replaces. The old line
|
|
1609
|
+
// was:
|
|
1610
|
+
// return Response.redirect(new URL(loginPath, request.url), 302);
|
|
1611
|
+
// `request.url` inside a containerised Next standalone server carries the
|
|
1612
|
+
// LISTEN address, not the address the browser used, so a container bound to
|
|
1613
|
+
// 0.0.0.0:8080 handed the browser `http://0.0.0.0:8080/sign-in` — an address
|
|
1614
|
+
// that can NEVER load (net::ERR_ADDRESS_INVALID, confirmed in a real browser
|
|
1615
|
+
// by QAPert). Reload cannot recover it, because the address is invalid rather
|
|
1616
|
+
// than down. It fired on the ORDINARY path: the oauth_state cookie is
|
|
1617
|
+
// Max-Age=600, so anyone spending >10 minutes at Google's account chooser
|
|
1618
|
+
// landed on a dead tab.
|
|
1619
|
+
//
|
|
1620
|
+
// A RELATIVE Location header is legal (RFC 7231 §7.1.2) and the browser
|
|
1621
|
+
// resolves it against the origin it is ACTUALLY on, so no origin has to be
|
|
1622
|
+
// guessed, configured, or derived from a header. This is the same shape the
|
|
1623
|
+
// app's own middleware already emits successfully (307 + relative), which is
|
|
1624
|
+
// why page redirects survived while this one did not.
|
|
1625
|
+
//
|
|
1626
|
+
// WHY NOT REBUILD FROM BETTER_AUTH_URL / rawBaseURL, the other proposal:
|
|
1627
|
+
// (a) rawBaseURL is NOT in scope here — it is a local inside
|
|
1628
|
+
// createBetterAuthInstance (~:397), a different function ~676 lines up.
|
|
1629
|
+
// Reaching it means recomputing the env precedence or exporting a
|
|
1630
|
+
// helper, i.e. more moving parts for a strictly worse result.
|
|
1631
|
+
// (b) It would reintroduce a guessed origin and make this path depend on
|
|
1632
|
+
// BETTER_AUTH_URL being set correctly per leg. Relative needs no env.
|
|
1633
|
+
// (c) Anything Host-derived would open host-header injection on a redirect
|
|
1634
|
+
// that is currently host-INVARIANT. Relative keeps that property.
|
|
1635
|
+
//
|
|
1636
|
+
// Works whether loginPath is relative (package default '/account-auth/login',
|
|
1637
|
+
// and the graft passes '/sign-in') or absolute — an absolute Location is also
|
|
1638
|
+
// legal, so a consumer passing a full URL still behaves.
|
|
1639
|
+
return new Response(null, { status: 302, headers: { location: loginPath } });
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
|
|
1644
|
+
// IdP refused (401), no session was found, or anything threw - the sign-in is ABORTED (PAY-1733). A session is
|
|
1645
|
+
// kept only when it holds IdP tokens.
|
|
1646
|
+
const url = new URL(request.url);
|
|
1647
|
+
if (url.pathname.includes('/callback/') && response.status === 302) {
|
|
1648
|
+
const setCookies = response.headers.getSetCookie?.() || [];
|
|
1649
|
+
const cookieHeader = setCookies
|
|
1650
|
+
.map((c: string) => c.split(';')[0])
|
|
1651
|
+
.join('; ');
|
|
1652
|
+
let sessionToken: string | null = null;
|
|
1653
|
+
// true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
|
|
1654
|
+
// (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
|
|
1655
|
+
let exchange: boolean | string = false;
|
|
1656
|
+
try {
|
|
1657
|
+
const auth = await getBetterAuthInstance();
|
|
1658
|
+
if (auth?.api?.getSession) {
|
|
1659
|
+
const headers = new Headers();
|
|
1660
|
+
headers.set('cookie', cookieHeader);
|
|
1661
|
+
|
|
1662
|
+
const session = await auth.api.getSession({ headers });
|
|
1663
|
+
if (session?.session?.token) {
|
|
1664
|
+
sessionToken = session.session.token;
|
|
1665
|
+
console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
|
|
1666
|
+
// PAY-2305: the payez-idp callback already holds an IdP-ISSUED bearer (verified in getUserInfo). It must NOT go
|
|
1667
|
+
// through oauth-callback, the asserted-email path (card 138635), and storing it is what the passkey path does.
|
|
1668
|
+
exchange = url.pathname.endsWith('/callback/payez-idp')
|
|
1669
|
+
? await storePayezIdpSession(session.session.token)
|
|
1670
|
+
: await exchangeOAuthForIdpTokens(session.session.token);
|
|
1671
|
+
} else {
|
|
1672
|
+
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
} catch (err: any) {
|
|
1676
|
+
console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
|
|
1677
|
+
}
|
|
1678
|
+
if (exchange !== true) {
|
|
1679
|
+
// A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
|
|
1680
|
+
const errorCode = typeof exchange === 'string' ? exchange : OAUTH_SIGN_IN_FAILED_ERROR;
|
|
1681
|
+
return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
return response;
|
|
1686
|
+
});
|
|
1687
|
+
};
|
|
1688
|
+
}
|