@payez/next-mvp 4.1.21 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/POST-INSTALL.md +408 -0
- package/README.md +7 -0
- package/dist/api-handlers/account/recovery/initiate.js +35 -2
- package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/send-code.js +35 -2
- package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
- package/dist/api-handlers/account/recovery/verify-code.js +35 -2
- package/dist/api-handlers/account/reset-password.js +35 -2
- package/dist/api-handlers/account/validate-password.js +21 -3
- package/dist/api-handlers/admin/analytics.js +2 -1
- package/dist/api-handlers/admin/audit.js +2 -1
- package/dist/api-handlers/admin/sessions.js +2 -1
- package/dist/api-handlers/admin/stats.js +2 -1
- package/dist/api-handlers/admin/users.js +2 -1
- package/dist/api-handlers/admin/vibe-data.js +2 -1
- package/dist/api-handlers/auth/jwks.js +10 -0
- package/dist/api-handlers/auth/login.js +5 -2
- package/dist/api-handlers/auth/passkey-signin.js +16 -1
- package/dist/api-handlers/auth/refresh.d.ts +23 -3
- package/dist/api-handlers/auth/refresh.js +107 -16
- package/dist/api-handlers/auth/signout.js +3 -2
- package/dist/api-handlers/auth/verify-code.js +5 -2
- package/dist/api-handlers/session/viability.js +27 -1
- package/dist/api-handlers/test/force-expire.d.ts +29 -15
- package/dist/api-handlers/test/force-expire.js +109 -14
- package/dist/auth/better-auth.d.ts +263 -6
- package/dist/auth/better-auth.js +437 -59
- package/dist/auth/utils/idp-client.js +1 -1
- package/dist/client/AuthContext.js +49 -17
- package/dist/client/better-auth-client.d.ts +62 -62
- package/dist/components/account/UserAvatarMenu.js +12 -0
- package/dist/components/auth/ModeAwareLoginPage.js +10 -8
- package/dist/components/auth/ModeAwareSignupPage.js +5 -2
- package/dist/components/auth/TraditionalAuthSection.js +5 -1
- package/dist/hooks/useAuthSettings.js +14 -2
- package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
- package/dist/hooks/usePublicAuthSettings.js +28 -14
- package/dist/lib/client-key.d.ts +61 -0
- package/dist/lib/client-key.js +72 -0
- package/dist/lib/demo-mode.js +2 -1
- package/dist/lib/ensure-fresh-access-token.js +5 -1
- package/dist/lib/idp-client-config.d.ts +25 -2
- package/dist/lib/idp-client-config.js +381 -36
- package/dist/lib/idp-fetch.d.ts +2 -0
- package/dist/lib/idp-fetch.js +31 -1
- package/dist/lib/idp-url-guard.d.ts +29 -0
- package/dist/lib/idp-url-guard.js +38 -0
- package/dist/lib/redis.js +77 -5
- package/dist/lib/session-cookie-shape.d.ts +69 -0
- package/dist/lib/session-cookie-shape.js +126 -0
- package/dist/lib/session-store.d.ts +35 -2
- package/dist/lib/session-store.js +135 -4
- package/dist/lib/single-flight.d.ts +35 -0
- package/dist/lib/single-flight.js +30 -0
- package/dist/lib/token-lifecycle.js +12 -0
- package/dist/middleware/create-middleware.js +12 -0
- package/dist/models/SessionModel.d.ts +41 -0
- package/dist/models/SessionModel.js +9 -0
- package/dist/pages/admin-login/page.d.ts +1 -1
- package/dist/pages/admin-login/page.js +5 -41
- package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
- package/dist/pages/security/EnhancedSecurityPage.js +42 -2
- package/dist/pages/security/index.d.ts +1 -0
- package/dist/pages/security/index.js +3 -0
- package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
- package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
- package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
- package/dist/pages/test-env/JwtInspectPage.js +166 -7
- package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
- package/dist/pages/test-env/RefreshTokenPage.js +22 -6
- package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
- package/dist/pages/test-env/TestEnvPage.js +16 -4
- package/dist/pages/test-env/guarded.d.ts +4 -0
- package/dist/pages/test-env/guarded.js +86 -0
- package/dist/pages/test-env/index.d.ts +50 -7
- package/dist/pages/test-env/index.js +58 -12
- package/dist/routes/auth/logout.js +3 -2
- package/dist/routes/auth/refresh.js +27 -1
- package/dist/routes/auth/session.d.ts +12 -3
- package/dist/routes/auth/session.js +92 -9
- package/dist/routes/auth/settings.d.ts +34 -4
- package/dist/routes/auth/settings.js +87 -33
- package/dist/routes/auth/viability.d.ts +1 -1
- package/dist/routes/auth/viability.js +122 -11
- package/dist/server/auth-guard.js +22 -15
- package/dist/server/auth.d.ts +177 -20
- package/dist/server/auth.js +34 -75
- package/dist/server/decode-session.js +84 -10
- package/dist/types/auth.d.ts +18 -0
- package/package.json +969 -958
- package/scripts/check-internal-url-usage.sh +73 -73
- package/scripts/check-publish-health.mjs +120 -0
- package/scripts/dev-broker.ps1 +71 -6
- package/scripts/print-client-config.mjs +162 -0
- package/scripts/server-only-stub.cjs +4 -0
- package/src/api-handlers/account/recovery/initiate.ts +35 -2
- package/src/api-handlers/account/recovery/send-code.ts +36 -2
- package/src/api-handlers/account/recovery/verify-code.ts +36 -2
- package/src/api-handlers/account/reset-password.ts +35 -2
- package/src/api-handlers/account/validate-password.ts +22 -3
- package/src/api-handlers/admin/analytics.ts +2 -1
- package/src/api-handlers/admin/audit.ts +2 -1
- package/src/api-handlers/admin/sessions.ts +2 -1
- package/src/api-handlers/admin/stats.ts +2 -1
- package/src/api-handlers/admin/users.ts +2 -1
- package/src/api-handlers/admin/vibe-data.ts +2 -1
- package/src/api-handlers/auth/jwks.ts +10 -0
- package/src/api-handlers/auth/login.ts +5 -2
- package/src/api-handlers/auth/passkey-signin.ts +112 -98
- package/src/api-handlers/auth/refresh.ts +112 -15
- package/src/api-handlers/auth/signout.ts +4 -3
- package/src/api-handlers/auth/verify-code.ts +5 -2
- package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
- package/src/api-handlers/mfa/backup-codes.ts +41 -41
- package/src/api-handlers/mfa/methods.ts +46 -46
- package/src/api-handlers/mfa/totp-confirm.ts +42 -42
- package/src/api-handlers/mfa/totp-enroll.ts +42 -42
- package/src/api-handlers/passkeys/list.ts +31 -31
- package/src/api-handlers/passkeys/manage.ts +54 -54
- package/src/api-handlers/passkeys/register-options.ts +42 -42
- package/src/api-handlers/passkeys/register-verify.ts +41 -41
- package/src/api-handlers/session/viability.ts +29 -1
- package/src/api-handlers/test/force-expire.ts +118 -13
- package/src/auth/better-auth.ts +486 -51
- package/src/auth/utils/idp-client.ts +1 -1
- package/src/client/AuthContext.tsx +55 -19
- package/src/components/account/UserAvatarMenu.tsx +11 -0
- package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
- package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
- package/src/components/auth/TraditionalAuthSection.tsx +5 -1
- package/src/hooks/useAuthSettings.ts +14 -2
- package/src/hooks/usePublicAuthSettings.ts +38 -16
- package/src/lib/client-key.ts +88 -0
- package/src/lib/demo-mode.ts +2 -1
- package/src/lib/ensure-fresh-access-token.ts +5 -1
- package/src/lib/idp-client-config.ts +399 -36
- package/src/lib/idp-fetch.ts +36 -1
- package/src/lib/idp-url-guard.ts +37 -0
- package/src/lib/redis.ts +84 -6
- package/src/lib/session-cookie-shape.ts +184 -0
- package/src/lib/session-store.ts +152 -6
- package/src/lib/single-flight.ts +64 -0
- package/src/lib/token-lifecycle.ts +12 -0
- package/src/middleware/create-middleware.ts +11 -0
- package/src/models/SessionModel.ts +49 -0
- package/src/pages/admin-login/page.tsx +17 -127
- package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
- package/src/pages/security/index.ts +3 -0
- package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
- package/src/pages/test-env/JwtInspectPage.tsx +235 -20
- package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
- package/src/pages/test-env/TestEnvPage.tsx +16 -4
- package/src/pages/test-env/guarded.tsx +81 -0
- package/src/pages/test-env/index.ts +59 -7
- package/src/routes/auth/logout.ts +4 -3
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +30 -1
- package/src/routes/auth/session.ts +98 -9
- package/src/routes/auth/settings.ts +144 -42
- package/src/routes/auth/viability.ts +129 -11
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/server/auth-guard.ts +24 -17
- package/src/server/auth.ts +37 -73
- package/src/server/decode-session.ts +95 -11
- package/src/templates/instrumentation.ts +21 -2
- package/src/types/auth.ts +20 -0
- package/dist/lib/auth-secret.d.ts +0 -17
- package/dist/lib/auth-secret.js +0 -116
- package/src/lib/auth-secret.ts +0 -137
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ready-to-Use TOTP Enrol Route
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* ```typescript
|
|
6
|
-
* // app/api/mfa/totp/enroll/route.ts
|
|
7
|
-
* export { POST } from '@payez/next-mvp/routes/mfa/totp-enroll';
|
|
8
|
-
* ```
|
|
9
|
-
*/
|
|
10
|
-
export { POST } from '../../api-handlers/mfa/totp-enroll';
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-Use TOTP Enrol Route
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/mfa/totp/enroll/route.ts
|
|
7
|
+
* export { POST } from '@payez/next-mvp/routes/mfa/totp-enroll';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { POST } from '../../api-handlers/mfa/totp-enroll';
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ready-to-Use TOTP Verify Route (2FA CHALLENGE, not enrolment)
|
|
3
|
-
*
|
|
4
|
-
* Verifies an authenticator code at LOGIN and transitions the session to
|
|
5
|
-
* MFA-verified. Pair with totp-enroll/totp-confirm, which only ENROL a factor
|
|
6
|
-
* and deliberately do not touch session 2FA state.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```typescript
|
|
10
|
-
* // app/api/mfa/totp/verify/route.ts
|
|
11
|
-
* export { POST } from '@payez/next-mvp/routes/mfa/totp-verify';
|
|
12
|
-
* ```
|
|
13
|
-
*/
|
|
14
|
-
export { POST } from '../../api-handlers/mfa/totp-verify';
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-Use TOTP Verify Route (2FA CHALLENGE, not enrolment)
|
|
3
|
+
*
|
|
4
|
+
* Verifies an authenticator code at LOGIN and transitions the session to
|
|
5
|
+
* MFA-verified. Pair with totp-enroll/totp-confirm, which only ENROL a factor
|
|
6
|
+
* and deliberately do not touch session 2FA state.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* // app/api/mfa/totp/verify/route.ts
|
|
11
|
+
* export { POST } from '@payez/next-mvp/routes/mfa/totp-verify';
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export { POST } from '../../api-handlers/mfa/totp-verify';
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ready-to-Use Passkey List Route
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* ```typescript
|
|
6
|
-
* // app/api/passkeys/route.ts
|
|
7
|
-
* export { GET } from '@payez/next-mvp/routes/passkeys/list';
|
|
8
|
-
* ```
|
|
9
|
-
*/
|
|
10
|
-
export { GET } from '../../api-handlers/passkeys/list';
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-Use Passkey List Route
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/passkeys/route.ts
|
|
7
|
+
* export { GET } from '@payez/next-mvp/routes/passkeys/list';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { GET } from '../../api-handlers/passkeys/list';
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ready-to-Use Passkey Manage Route (rename / revoke one key)
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* ```typescript
|
|
6
|
-
* // app/api/passkeys/[id]/route.ts
|
|
7
|
-
* export { PATCH, DELETE } from '@payez/next-mvp/routes/passkeys/manage';
|
|
8
|
-
* ```
|
|
9
|
-
*/
|
|
10
|
-
import { NextRequest } from 'next/server';
|
|
11
|
-
import { PATCH as patchOne, DELETE as deleteOne } from '../../api-handlers/passkeys/manage';
|
|
12
|
-
|
|
13
|
-
type Ctx = { params: Promise<{ id: string }> };
|
|
14
|
-
|
|
15
|
-
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
|
16
|
-
const { id } = await ctx.params;
|
|
17
|
-
return patchOne(req, id);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
|
21
|
-
const { id } = await ctx.params;
|
|
22
|
-
return deleteOne(req, id);
|
|
23
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-Use Passkey Manage Route (rename / revoke one key)
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/passkeys/[id]/route.ts
|
|
7
|
+
* export { PATCH, DELETE } from '@payez/next-mvp/routes/passkeys/manage';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
import { NextRequest } from 'next/server';
|
|
11
|
+
import { PATCH as patchOne, DELETE as deleteOne } from '../../api-handlers/passkeys/manage';
|
|
12
|
+
|
|
13
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
14
|
+
|
|
15
|
+
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
|
16
|
+
const { id } = await ctx.params;
|
|
17
|
+
return patchOne(req, id);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
|
21
|
+
const { id } = await ctx.params;
|
|
22
|
+
return deleteOne(req, id);
|
|
23
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ready-to-Use Passkey Registration Options Route
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* ```typescript
|
|
6
|
-
* // app/api/passkeys/register/options/route.ts
|
|
7
|
-
* export { POST } from '@payez/next-mvp/routes/passkeys/register-options';
|
|
8
|
-
* ```
|
|
9
|
-
*/
|
|
10
|
-
export { POST } from '../../api-handlers/passkeys/register-options';
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-Use Passkey Registration Options Route
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/passkeys/register/options/route.ts
|
|
7
|
+
* export { POST } from '@payez/next-mvp/routes/passkeys/register-options';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { POST } from '../../api-handlers/passkeys/register-options';
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ready-to-Use Passkey Registration Verify Route
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* ```typescript
|
|
6
|
-
* // app/api/passkeys/register/verify/route.ts
|
|
7
|
-
* export { POST } from '@payez/next-mvp/routes/passkeys/register-verify';
|
|
8
|
-
* ```
|
|
9
|
-
*/
|
|
10
|
-
export { POST } from '../../api-handlers/passkeys/register-verify';
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-Use Passkey Registration Verify Route
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/passkeys/register/verify/route.ts
|
|
7
|
+
* export { POST } from '@payez/next-mvp/routes/passkeys/register-verify';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { POST } from '../../api-handlers/passkeys/register-verify';
|
package/src/server/auth-guard.ts
CHANGED
|
@@ -110,31 +110,38 @@ export async function authGuard(options?: AuthGuardOptions): Promise<AuthGuardRe
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// --- 2FA check ---
|
|
113
|
+
// This try wraps ONLY the config fetch, because that is the only thing here
|
|
114
|
+
// that can genuinely fail. Do NOT widen it to cover the redirect below:
|
|
115
|
+
// next/navigation's redirect() signals by THROWING a NEXT_REDIRECT digest, so
|
|
116
|
+
// a redirect inside this try is caught by the catch and rewritten into a
|
|
117
|
+
// service-unavailable page - denying a 2FA user the one page that would let
|
|
118
|
+
// them satisfy the challenge, and reporting it as an outage. See card 312242.
|
|
119
|
+
let requires2FA: boolean;
|
|
113
120
|
try {
|
|
114
121
|
const config = await getIDPClientConfig();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (requires2FA) {
|
|
118
|
-
const mfaVerified = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
|
|
119
|
-
const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
120
|
-
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
121
|
-
|
|
122
|
-
if (!mfaVerified || mfaExpired) {
|
|
123
|
-
console.log('[AUTH-GUARD] 2FA required', {
|
|
124
|
-
mfaVerified,
|
|
125
|
-
mfaExpired,
|
|
126
|
-
userId: sessionData.userId,
|
|
127
|
-
pathname,
|
|
128
|
-
});
|
|
129
|
-
redirect(`${verifyCodeUrl}?callbackUrl=${callbackUrl}`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
122
|
+
requires2FA = config.authSettings?.require2FA ?? true;
|
|
132
123
|
} catch (error) {
|
|
133
124
|
// If we can't check 2FA config, fail closed
|
|
134
125
|
console.error('[AUTH-GUARD] 2FA config check failed:', error instanceof Error ? error.message : String(error));
|
|
135
126
|
redirect(serviceUnavailableUrl);
|
|
136
127
|
}
|
|
137
128
|
|
|
129
|
+
if (requires2FA) {
|
|
130
|
+
const mfaVerified = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
|
|
131
|
+
const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
132
|
+
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
133
|
+
|
|
134
|
+
if (!mfaVerified || mfaExpired) {
|
|
135
|
+
console.log('[AUTH-GUARD] 2FA required', {
|
|
136
|
+
mfaVerified,
|
|
137
|
+
mfaExpired,
|
|
138
|
+
userId: sessionData.userId,
|
|
139
|
+
pathname,
|
|
140
|
+
});
|
|
141
|
+
redirect(`${verifyCodeUrl}?callbackUrl=${callbackUrl}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
138
145
|
// --- Custom checks (beta, admin, etc.) ---
|
|
139
146
|
if (options?.checks) {
|
|
140
147
|
for (const check of options.checks) {
|
package/src/server/auth.ts
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import 'server-only';
|
|
9
|
-
import { createBetterAuthInstance } from '../auth/better-auth';
|
|
9
|
+
import { createBetterAuthInstance, secretFingerprint } from '../auth/better-auth';
|
|
10
10
|
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
11
11
|
import {
|
|
12
12
|
getSession as getRedisSession,
|
|
13
13
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
14
14
|
type SessionData,
|
|
15
15
|
} from '../lib/session-store';
|
|
16
|
-
import {
|
|
16
|
+
import { extractSessionTokenFromCookie } from '../lib/session-cookie-shape';
|
|
17
|
+
import { createSingleFlight } from '../lib/single-flight';
|
|
17
18
|
|
|
18
19
|
let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
|
|
19
|
-
let authInitPromise: Promise<ReturnType<typeof createBetterAuthInstance>> | null = null;
|
|
20
20
|
|
|
21
21
|
export type IdpTokenResult =
|
|
22
22
|
| { success: true; accessToken: string; sessionData: SessionData }
|
|
@@ -91,81 +91,45 @@ function attachSessionData(session: any, sessionData: SessionData | null, sessio
|
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Get the initialized Better Auth instance (singleton).
|
|
94
|
+
*
|
|
95
|
+
* 244549: the single-flight memoisation lives in lib/single-flight.ts rather
|
|
96
|
+
* than inline here, and that placement is deliberate rather than tidiness.
|
|
97
|
+
* The bug being fixed -- a REJECTED init promise cached for the life of the
|
|
98
|
+
* process, so recovery was restart-only -- went unnoticed precisely because
|
|
99
|
+
* this module cannot be loaded in a unit test: importing it pulls in
|
|
100
|
+
* better-auth and the whole IDP config stack. Extracted into a zero-import
|
|
101
|
+
* module, the retry semantics are covered by
|
|
102
|
+
* test/single-flight-retry.test.cjs, which imports dist/ and therefore
|
|
103
|
+
* exercises the artefact that actually ships.
|
|
104
|
+
*
|
|
105
|
+
* Semantics, all asserted by that suite: concurrent callers share ONE
|
|
106
|
+
* underlying init; a rejection reaches every caller awaiting that attempt
|
|
107
|
+
* (failures stay loud -- getIDPClientConfig is fail-loud by design and callers
|
|
108
|
+
* depend on it); the NEXT call after a failure starts a fresh attempt; and a
|
|
109
|
+
* slow failure never evicts a newer in-flight attempt.
|
|
94
110
|
*/
|
|
111
|
+
const authInit = createSingleFlight(async () => {
|
|
112
|
+
const config = await getIDPClientConfig(true);
|
|
113
|
+
authInstance = createBetterAuthInstance(config);
|
|
114
|
+
// 185121 line 1, path 2: this module-scope instance had NO creation log
|
|
115
|
+
// line — first-called during a config transition window it pinned a
|
|
116
|
+
// divergent secret INVISIBLY. Name the path and the secret fingerprint
|
|
117
|
+
// (the createBetterAuthInstance line already logged the same hash;
|
|
118
|
+
// this line is what attributes it to THIS init path).
|
|
119
|
+
// Guard matches createBetterAuthInstance's: getIDPClientConfig currently
|
|
120
|
+
// throws FATAL on a missing authSecret (QAPert 27328 verified), but that
|
|
121
|
+
// guarantee is one refactor away — log ABSENT rather than ever throw here.
|
|
122
|
+
console.log(
|
|
123
|
+
`[AUTH] getAuthInstance: created BA instance via server/auth.ts — authSecret ${config.authSecret ? `sha256:${secretFingerprint(config.authSecret as string)}` : 'ABSENT'}`
|
|
124
|
+
);
|
|
125
|
+
return authInstance;
|
|
126
|
+
});
|
|
127
|
+
|
|
95
128
|
export async function getAuthInstance() {
|
|
96
129
|
if (authInstance) return authInstance;
|
|
97
|
-
|
|
98
|
-
authInitPromise = getIDPClientConfig(true).then(config => {
|
|
99
|
-
authInstance = createBetterAuthInstance(config);
|
|
100
|
-
return authInstance;
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return authInitPromise;
|
|
130
|
+
return authInit.get();
|
|
104
131
|
}
|
|
105
132
|
|
|
106
|
-
/**
|
|
107
|
-
* JWT body-decode helper — base64-decode only, NO signature verification.
|
|
108
|
-
* Safe because the value is then used as a Redis lookup key: Redis is the
|
|
109
|
-
* liveness gate, so an attacker forging a JWT body with a guessed
|
|
110
|
-
* sessionToken claim still has to land on a real Redis session (infeasible
|
|
111
|
-
* against high-entropy UUIDs). Used by the canonical-fallback path in
|
|
112
|
-
* `getSession` to extract the session token when Better Auth's primary
|
|
113
|
-
* path returns null (cookie cache miss / secondary storage eviction).
|
|
114
|
-
*/
|
|
115
|
-
function decodeJwtBody(jwt: string): any {
|
|
116
|
-
try {
|
|
117
|
-
const parts = jwt.split('.');
|
|
118
|
-
if (parts.length !== 3) return null;
|
|
119
|
-
const decoded = Buffer.from(parts[1], 'base64').toString('utf-8');
|
|
120
|
-
return JSON.parse(decoded);
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Extract the session-token claim from the Better Auth session cookie
|
|
127
|
-
* WITHOUT going through Better Auth's `auth.api.getSession()`. Handles
|
|
128
|
-
* both single-cookie and chunked-cookie cases (Better Auth chunks long
|
|
129
|
-
* JWTs across `{name}.0`, `{name}.1`, …). Returns the raw `sessionToken`
|
|
130
|
-
* claim suitable for use as a Redis key. Returns null if no cookie is
|
|
131
|
-
* present or the JWT can't be parsed. */
|
|
132
|
-
function extractSessionTokenFromCookie(request: Request): string | null {
|
|
133
|
-
const cookieHeader = request.headers.get('cookie');
|
|
134
|
-
if (!cookieHeader) return null;
|
|
135
|
-
|
|
136
|
-
const cookieName = getSessionCookieName();
|
|
137
|
-
let rawJwt: string | null = null;
|
|
138
|
-
|
|
139
|
-
// Direct cookie first.
|
|
140
|
-
const chunks: Array<{ idx: number; value: string }> = [];
|
|
141
|
-
for (const part of cookieHeader.split(';')) {
|
|
142
|
-
const trimmed = part.trim();
|
|
143
|
-
const eq = trimmed.indexOf('=');
|
|
144
|
-
if (eq === -1) continue;
|
|
145
|
-
const k = trimmed.slice(0, eq);
|
|
146
|
-
const v = trimmed.slice(eq + 1);
|
|
147
|
-
if (k === cookieName) {
|
|
148
|
-
rawJwt = decodeURIComponent(v);
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
if (k.startsWith(`${cookieName}.`)) {
|
|
152
|
-
const idx = parseInt(k.split('.').pop() || '0', 10);
|
|
153
|
-
if (Number.isFinite(idx)) chunks.push({ idx, value: decodeURIComponent(v) });
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
// Chunked cookies fallback (Better Auth splits long JWTs across .0/.1/.2 …).
|
|
157
|
-
if (!rawJwt && chunks.length > 0) {
|
|
158
|
-
chunks.sort((a, b) => a.idx - b.idx);
|
|
159
|
-
rawJwt = chunks.map(c => c.value).join('');
|
|
160
|
-
}
|
|
161
|
-
if (!rawJwt) return null;
|
|
162
|
-
|
|
163
|
-
const decoded = decodeJwtBody(rawJwt);
|
|
164
|
-
if (decoded && typeof decoded === 'object' && typeof decoded.sessionToken === 'string') {
|
|
165
|
-
return decoded.sessionToken;
|
|
166
|
-
}
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
133
|
|
|
170
134
|
/**
|
|
171
135
|
* Get the current session from a request.
|
|
@@ -12,6 +12,7 @@ import { getSession, type SessionData } from '../lib/session-store';
|
|
|
12
12
|
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
13
13
|
import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
|
|
14
14
|
import { ensureInitialized } from '../lib/startup-init';
|
|
15
|
+
import { extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
|
|
15
16
|
|
|
16
17
|
export interface DecodedSession {
|
|
17
18
|
sessionData: SessionData;
|
|
@@ -100,6 +101,48 @@ async function tryBetterAuthSession(
|
|
|
100
101
|
}
|
|
101
102
|
} catch { /* Redis unavailable */ }
|
|
102
103
|
|
|
104
|
+
// LEG-1 FAIL-CLOSED ON UNUSABLE ROLES (card 340015, BAPert R412, 2026-09-03).
|
|
105
|
+
//
|
|
106
|
+
// `idpTokens.roles` is the ONLY source of roles for every role-gated route
|
|
107
|
+
// (see :109 below). When it is empty this leg used to return a perfectly
|
|
108
|
+
// valid-LOOKING session carrying `roles: []` — so the caller was
|
|
109
|
+
// authenticated but authorised for nothing. Every admin screen 403s and
|
|
110
|
+
// renders its chrome with no data and NO ERROR TEXT: it does not look
|
|
111
|
+
// broken, it looks empty.
|
|
112
|
+
//
|
|
113
|
+
// Three independent causes produce that state, so the test is on the
|
|
114
|
+
// CONDITION THAT ACTUALLY HARMS — "no usable roles" — rather than on any
|
|
115
|
+
// one route to it. A fourth cause is then covered for free:
|
|
116
|
+
// A MINT the password / admin-login lane never writes the block
|
|
117
|
+
// (it never calls storeIdpTokensInSession), so it is
|
|
118
|
+
// absent from birth.
|
|
119
|
+
// B CLOBBER better-auth's own 24h session refresh rebuilds the
|
|
120
|
+
// `ba:` record as `{session, user}` and drops the
|
|
121
|
+
// `idpTokens` SIBLING structurally, not conditionally
|
|
122
|
+
// (better-auth internal-adapter.mjs:287). This hits the
|
|
123
|
+
// OAuth and passkey lanes too — they are safe for 24
|
|
124
|
+
// hours and then stop being safe.
|
|
125
|
+
// C DERIVATION the block is PRESENT and fully populated with only
|
|
126
|
+
// `roles` empty (card 339748). `!idpTokens` is FALSE
|
|
127
|
+
// here and cannot see it — which is exactly why the
|
|
128
|
+
// predicate is `!idpTokens?.roles?.length` and not an
|
|
129
|
+
// absence check.
|
|
130
|
+
//
|
|
131
|
+
// Declining here hands the request to the legacy `{slug}:sess:` leg below,
|
|
132
|
+
// which carries the same roles. *** IF THAT RECORD IS ALSO ABSENT THE USER
|
|
133
|
+
// IS SIGNED OUT. THAT IS DELIBERATE AND ACCEPTED, NOT A REGRESSION *** —
|
|
134
|
+
// an honest, visible sign-out in place of a silently half-authorised
|
|
135
|
+
// session. Read the commit message before treating a sign-out here as a
|
|
136
|
+
// bug introduced by this fix.
|
|
137
|
+
if (!idpTokens?.roles?.length) {
|
|
138
|
+
console.warn(
|
|
139
|
+
'[DECODE-SESSION] Leg 1 declining: idpTokens unusable (no roles) for session',
|
|
140
|
+
result.session.token.substring(0, 10),
|
|
141
|
+
'— falling through to the sess: leg'
|
|
142
|
+
);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
103
146
|
// Map Better Auth session + IDP tokens to SessionData
|
|
104
147
|
const sessionData: SessionData = {
|
|
105
148
|
userId: idpTokens?.userId || result.user.id || '',
|
|
@@ -111,6 +154,9 @@ async function tryBetterAuthSession(
|
|
|
111
154
|
idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
|
|
112
155
|
|| (result.session.expiresAt ? new Date(result.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
|
|
113
156
|
mfaVerified: idpTokens?.mfaVerified ?? false,
|
|
157
|
+
// R807 (362550): fail-closed collapse above stays; this preserves the bit it
|
|
158
|
+
// destroys. Explicit `false` counts as a signal - hence `!== undefined`.
|
|
159
|
+
mfaSignalPresent: idpTokens?.mfaVerified !== undefined,
|
|
114
160
|
/**
|
|
115
161
|
* ⚠ WAS HARDCODED `'google'` — third site of QAPert finding #3 (card
|
|
116
162
|
* 167892), found by sweeping rather than by report.
|
|
@@ -193,24 +239,62 @@ export async function decodeSession(
|
|
|
193
239
|
return null;
|
|
194
240
|
}
|
|
195
241
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
242
|
+
// BA-SHAPE-AWARE FALLBACK LEG (183047, 2026-08-08).
|
|
243
|
+
//
|
|
244
|
+
// Better Auth mints session cookies as `{token}.{base64(hmac)}` — TWO
|
|
245
|
+
// segments, one dot. jose's `jwtVerify()` below requires a genuine
|
|
246
|
+
// compact JWS — THREE segments — and throws "Invalid Compact JWS" on a
|
|
247
|
+
// BA-shaped cookie unconditionally, before it ever looks at the secret.
|
|
248
|
+
// Since Better Auth is the only minter in production, that meant this
|
|
249
|
+
// fallback could never succeed for the cookies it actually receives: any
|
|
250
|
+
// single Better-Auth-primary miss (secondary-storage eviction, disabled
|
|
251
|
+
// cookie cache, in-flight secret divergence) read as total session
|
|
252
|
+
// death, because the ONLY fallback was structurally blind to the only
|
|
253
|
+
// shape it would ever be handed.
|
|
254
|
+
//
|
|
255
|
+
// Check the shape FIRST. A BA-hmac cookie skips jose entirely and goes
|
|
256
|
+
// straight to the Redis-canonical liveness check below — same posture
|
|
257
|
+
// as the JWS path always had (no signature re-check here either; Redis
|
|
258
|
+
// presence is the authority). A JWS-shaped cookie (legacy NextAuth-era,
|
|
259
|
+
// if any still linger) keeps the original jwtVerify path unchanged.
|
|
260
|
+
const shaped = extractSessionTokenFromCookieValue(cookieValue);
|
|
261
|
+
|
|
262
|
+
let sessionToken: string | undefined;
|
|
263
|
+
let payload: JWTPayload | undefined;
|
|
264
|
+
|
|
265
|
+
if (shaped?.shape === 'ba-hmac') {
|
|
266
|
+
sessionToken = shaped.token;
|
|
267
|
+
} else {
|
|
268
|
+
const secretKey = new TextEncoder().encode(secret);
|
|
269
|
+
try {
|
|
270
|
+
const result = await jwtVerify(cookieValue, secretKey);
|
|
271
|
+
payload = result.payload;
|
|
272
|
+
} catch (jwtError) {
|
|
273
|
+
console.warn('[DECODE-SESSION] JWT verification failed:', jwtError instanceof Error ? jwtError.message : String(jwtError));
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
sessionToken = (payload as any).sessionToken || (payload as any).redisSessionId;
|
|
204
277
|
}
|
|
205
278
|
|
|
206
|
-
const sessionToken = (payload as any).sessionToken || (payload as any).redisSessionId;
|
|
207
279
|
if (!sessionToken) {
|
|
208
|
-
console.warn('[DECODE-SESSION]
|
|
280
|
+
console.warn('[DECODE-SESSION] No sessionToken resolved from cookie (shape:', shaped?.shape ?? 'unrecognized', ')');
|
|
209
281
|
return null;
|
|
210
282
|
}
|
|
211
283
|
|
|
212
284
|
const sessionData = await getSession(sessionToken);
|
|
213
285
|
if (!sessionData) {
|
|
286
|
+
// The OTHER half of R412(ii). There are two ways decodeSession returns
|
|
287
|
+
// null and they mean different things, so they must not both be silent:
|
|
288
|
+
// Leg 1 declining is routine and recoverable (this leg normally holds
|
|
289
|
+
// the roles), whereas reaching HERE means there is no fallback record
|
|
290
|
+
// either and the caller is about to be signed out. Different meanings,
|
|
291
|
+
// different remediations — do not ship a second silent path while
|
|
292
|
+
// fixing a silent path.
|
|
293
|
+
console.warn(
|
|
294
|
+
'[DECODE-SESSION] No sess: record for token',
|
|
295
|
+
sessionToken.substring(0, 10),
|
|
296
|
+
'— no fallback available, session will be signed out'
|
|
297
|
+
);
|
|
214
298
|
return null;
|
|
215
299
|
}
|
|
216
300
|
|
|
@@ -226,7 +310,7 @@ export async function decodeSession(
|
|
|
226
310
|
|
|
227
311
|
return {
|
|
228
312
|
sessionData,
|
|
229
|
-
jwtPayload: payload as DecodedSession['jwtPayload'],
|
|
313
|
+
jwtPayload: (payload ?? { sessionToken }) as DecodedSession['jwtPayload'],
|
|
230
314
|
};
|
|
231
315
|
} catch (error) {
|
|
232
316
|
console.error('[DECODE-SESSION] Error:', error instanceof Error ? error.message : String(error));
|
|
@@ -34,8 +34,27 @@ export async function register() {
|
|
|
34
34
|
console.log('Server startup initialization complete!');
|
|
35
35
|
console.log('');
|
|
36
36
|
} catch (error) {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// DO NOT SWALLOW THIS. The previous version logged "Will retry initialization on
|
|
38
|
+
// first request" and let the server come up - so a site printed "Ready" while
|
|
39
|
+
// holding NO auth config, and betterAuth() got an empty secret, which it silently
|
|
40
|
+
// upgrades to the public DEFAULT_SECRET. Unavailable beats forgeable.
|
|
41
|
+
//
|
|
42
|
+
// payez_idp_admin_web/src/instrumentation.ts is the reference: it does not catch
|
|
43
|
+
// at all, and has a deliberate, named EMERGENCY_BYPASS_IDP escape hatch instead.
|
|
44
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
45
|
+
console.error('');
|
|
46
|
+
console.error('╔══════════════════════════════════════════════════════════════╗');
|
|
47
|
+
console.error('║ STARTUP FAILED — REFUSING TO SERVE ║');
|
|
48
|
+
console.error('╚══════════════════════════════════════════════════════════════╝');
|
|
49
|
+
console.error('');
|
|
50
|
+
console.error(' ' + msg);
|
|
51
|
+
console.error('');
|
|
52
|
+
console.error(' This server cannot obtain its auth configuration from the IDP,');
|
|
53
|
+
console.error(' so it has no signing secret and no sign-in policy.');
|
|
54
|
+
console.error('');
|
|
55
|
+
// Next CATCHES errors thrown from register() and continues; exiting is the only
|
|
56
|
+
// thing that actually stops the server coming up.
|
|
57
|
+
process.exit(1);
|
|
39
58
|
}
|
|
40
59
|
}
|
|
41
60
|
}
|
package/src/types/auth.ts
CHANGED
|
@@ -4,7 +4,27 @@ export type AuthMode = 'traditional' | 'federated';
|
|
|
4
4
|
export type FederatedProvider = 'google' | 'microsoft' | 'facebook' | 'github' | 'apple';
|
|
5
5
|
|
|
6
6
|
export interface AuthConfig {
|
|
7
|
+
/**
|
|
8
|
+
* @deprecated Superseded by allowFederatedLogin / allowTraditionalLogin.
|
|
9
|
+
* A single mode made the two mutually exclusive, so a site could only ever show ONE of
|
|
10
|
+
* OAuth or username/password - and because sites set it themselves, a client's sign-in
|
|
11
|
+
* screen could contradict what its own IDP offered. Kept so existing callers compile.
|
|
12
|
+
*/
|
|
7
13
|
mode: AuthMode;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether federated (OAuth) sign-in is offered. Defaults to what the IDP reports at
|
|
17
|
+
* /api/auth/settings; a site may override. Both this and allowTraditionalLogin can be
|
|
18
|
+
* true at once - that is the normal case.
|
|
19
|
+
*/
|
|
20
|
+
allowFederatedLogin?: boolean;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether username/password sign-in is offered. DEFAULTS ON. The IDP does not carry an
|
|
24
|
+
* explicit flag for it yet; until it does, it is available unless a site opts out.
|
|
25
|
+
* An OAuth-only client is a deliberate choice, never the default.
|
|
26
|
+
*/
|
|
27
|
+
allowTraditionalLogin?: boolean;
|
|
8
28
|
providers: FederatedProvider[];
|
|
9
29
|
enableRecovery: boolean;
|
|
10
30
|
enableEmailSignup: boolean;
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import 'server-only';
|
|
2
|
-
/**
|
|
3
|
-
* Resolve the Better Auth signing secret (server-only).
|
|
4
|
-
*
|
|
5
|
-
* Priority:
|
|
6
|
-
* 1) Use process.env.BETTER_AUTH_SECRET (preferred) or NEXTAUTH_SECRET (legacy)
|
|
7
|
-
* if present — allows overrides/production via env.
|
|
8
|
-
* 2) Fetch from IDP broker endpoint — IDP handles all Key Vault/signing.
|
|
9
|
-
* 3) Cache result in-memory and set process.env.BETTER_AUTH_SECRET for
|
|
10
|
-
* subsequent calls.
|
|
11
|
-
*
|
|
12
|
-
* NOTE on naming: this secret is the cryptographic key Better Auth uses to
|
|
13
|
-
* sign session JWTs. The IDP backend still names the broker endpoint and
|
|
14
|
-
* response field with the legacy "next-auth" / "nextAuthSecret" names; we
|
|
15
|
-
* read both new and legacy on the wire during the migration window.
|
|
16
|
-
*/
|
|
17
|
-
export declare function resolveAuthSecret(): Promise<string>;
|