@payez/next-mvp 4.1.14 → 4.1.21
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/dist/api-handlers/account/profile.d.ts +38 -0
- package/dist/api-handlers/account/profile.js +73 -45
- package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
- package/dist/api-handlers/account/recovery/send-code.js +52 -18
- package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
- package/dist/api-handlers/account/recovery/verify-code.js +52 -18
- package/dist/api-handlers/account/verify-email.js +15 -1
- package/dist/api-handlers/account/verify-sms.js +15 -1
- package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
- package/dist/api-handlers/auth/passkey-signin.js +97 -0
- package/dist/api-handlers/auth/refresh.js +38 -15
- package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
- package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
- package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
- package/dist/api-handlers/mfa/backup-codes.js +40 -0
- package/dist/api-handlers/mfa/methods.d.ts +3 -0
- package/dist/api-handlers/mfa/methods.js +41 -0
- package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
- package/dist/api-handlers/mfa/totp-confirm.js +41 -0
- package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
- package/dist/api-handlers/mfa/totp-enroll.js +41 -0
- package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
- package/dist/api-handlers/mfa/totp-verify.js +134 -0
- package/dist/api-handlers/passkeys/list.d.ts +7 -0
- package/dist/api-handlers/passkeys/list.js +29 -0
- package/dist/api-handlers/passkeys/manage.d.ts +3 -0
- package/dist/api-handlers/passkeys/manage.js +54 -0
- package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
- package/dist/api-handlers/passkeys/register-options.js +41 -0
- package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
- package/dist/api-handlers/passkeys/register-verify.js +40 -0
- package/dist/api-handlers/session/viability.js +19 -0
- package/dist/auth/better-auth.d.ts +68 -2
- package/dist/auth/better-auth.js +137 -18
- package/dist/auth/utils/idp-client.d.ts +13 -22
- package/dist/auth/utils/idp-client.js +12 -82
- package/dist/client/better-auth-client.d.ts +62 -62
- package/dist/lib/ensure-fresh-access-token.js +49 -8
- package/dist/lib/session-store.d.ts +1 -0
- package/dist/lib/session-store.js +19 -8
- package/dist/lib/token-lifecycle.d.ts +2 -0
- package/dist/lib/token-lifecycle.js +61 -7
- package/dist/models/SessionModel.d.ts +17 -0
- package/dist/pages/login/page.d.ts +10 -1
- package/dist/pages/login/page.js +69 -80
- package/dist/pages/verify-code/page.d.ts +10 -1
- package/dist/pages/verify-code/page.js +72 -4
- package/dist/routes/auth/passkey-signin.d.ts +13 -0
- package/dist/routes/auth/passkey-signin.js +18 -0
- package/dist/routes/auth/viability.d.ts +2 -0
- package/dist/routes/auth/viability.js +88 -0
- package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
- package/dist/routes/mfa/backup-code-verify.js +5 -0
- package/dist/routes/mfa/backup-codes.d.ts +10 -0
- package/dist/routes/mfa/backup-codes.js +14 -0
- package/dist/routes/mfa/methods.d.ts +21 -0
- package/dist/routes/mfa/methods.js +10 -0
- package/dist/routes/mfa/totp-confirm.d.ts +10 -0
- package/dist/routes/mfa/totp-confirm.js +14 -0
- package/dist/routes/mfa/totp-enroll.d.ts +10 -0
- package/dist/routes/mfa/totp-enroll.js +14 -0
- package/dist/routes/mfa/totp-verify.d.ts +14 -0
- package/dist/routes/mfa/totp-verify.js +18 -0
- package/dist/routes/passkeys/list.d.ts +10 -0
- package/dist/routes/passkeys/list.js +14 -0
- package/dist/routes/passkeys/manage.d.ts +18 -0
- package/dist/routes/passkeys/manage.js +13 -0
- package/dist/routes/passkeys/register-options.d.ts +10 -0
- package/dist/routes/passkeys/register-options.js +14 -0
- package/dist/routes/passkeys/register-verify.d.ts +10 -0
- package/dist/routes/passkeys/register-verify.js +14 -0
- package/dist/server/auth.d.ts +29 -20
- package/dist/server/auth.js +8 -0
- package/dist/server/decode-session.js +37 -3
- package/package.json +958 -894
- package/scripts/check-internal-url-usage.sh +73 -73
- package/src/api-handlers/account/profile.ts +76 -57
- package/src/api-handlers/account/recovery/send-code.ts +65 -17
- package/src/api-handlers/account/recovery/verify-code.ts +65 -17
- package/src/api-handlers/account/verify-email.ts +15 -1
- package/src/api-handlers/account/verify-sms.ts +15 -1
- package/src/api-handlers/auth/passkey-signin.ts +98 -0
- package/src/api-handlers/auth/refresh.ts +38 -15
- package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
- package/src/api-handlers/mfa/backup-codes.ts +41 -0
- package/src/api-handlers/mfa/methods.ts +46 -0
- package/src/api-handlers/mfa/totp-confirm.ts +42 -0
- package/src/api-handlers/mfa/totp-enroll.ts +42 -0
- package/src/api-handlers/mfa/totp-verify.ts +156 -0
- package/src/api-handlers/passkeys/list.ts +31 -0
- package/src/api-handlers/passkeys/manage.ts +54 -0
- package/src/api-handlers/passkeys/register-options.ts +42 -0
- package/src/api-handlers/passkeys/register-verify.ts +41 -0
- package/src/api-handlers/session/viability.ts +19 -0
- package/src/auth/better-auth.ts +158 -18
- package/src/auth/utils/idp-client.ts +13 -95
- package/src/lib/ensure-fresh-access-token.ts +49 -8
- package/src/lib/session-store.ts +11 -1
- package/src/lib/token-lifecycle.ts +70 -7
- package/src/models/SessionModel.ts +18 -0
- package/src/pages/login/page.tsx +123 -228
- package/src/pages/verify-code/page.tsx +71 -5
- package/src/routes/auth/passkey-signin.ts +13 -0
- package/src/routes/auth/viability.ts +89 -0
- package/src/routes/mfa/backup-code-verify.ts +1 -0
- package/src/routes/mfa/backup-codes.ts +10 -0
- package/src/routes/mfa/methods.ts +22 -0
- package/src/routes/mfa/totp-confirm.ts +10 -0
- package/src/routes/mfa/totp-enroll.ts +10 -0
- package/src/routes/mfa/totp-verify.ts +14 -0
- package/src/routes/passkeys/list.ts +10 -0
- package/src/routes/passkeys/manage.ts +23 -0
- package/src/routes/passkeys/register-options.ts +10 -0
- package/src/routes/passkeys/register-verify.ts +10 -0
- package/src/server/auth.ts +17 -1
- package/src/server/decode-session.ts +36 -3
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
|
+
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* List the signed-in user's passkeys (GET /api/Passkeys, user JWT).
|
|
7
|
+
* Probe-verified live 2026-08-01 (anon -> 401 = routed).
|
|
8
|
+
* No session -> upstream 401, passed through, never invented locally.
|
|
9
|
+
*/
|
|
10
|
+
export async function GET(req: NextRequest) {
|
|
11
|
+
const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Passkeys`, { method: 'GET' });
|
|
12
|
+
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{
|
|
16
|
+
success: false,
|
|
17
|
+
message: 'Upstream error',
|
|
18
|
+
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
|
|
19
|
+
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
20
|
+
},
|
|
21
|
+
{ status: result.status },
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const bodyJson: any = result.json;
|
|
26
|
+
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
27
|
+
if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
|
|
28
|
+
return NextResponse.json(bodyJson, { status: 200 });
|
|
29
|
+
}
|
|
30
|
+
return NextResponse.json(bodyJson ?? {}, { status: 200 });
|
|
31
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
|
+
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manage ONE passkey (PATCH rename / DELETE revoke, /api/Passkeys/{id},
|
|
7
|
+
* user JWT). The id arrives from the Next dynamic segment via the routes
|
|
8
|
+
* wrapper — it is never read from the body.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function upstreamError(result: { status: number; json: unknown; attemptedRefresh: boolean }) {
|
|
12
|
+
return NextResponse.json(
|
|
13
|
+
{
|
|
14
|
+
success: false,
|
|
15
|
+
message: 'Upstream error',
|
|
16
|
+
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
|
|
17
|
+
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
18
|
+
},
|
|
19
|
+
{ status: result.status },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function passthrough(json: unknown) {
|
|
24
|
+
const bodyJson: any = json;
|
|
25
|
+
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
26
|
+
if (bodyJson.success === true) return NextResponse.json(bodyJson.data ?? { success: true }, { status: 200 });
|
|
27
|
+
return NextResponse.json(bodyJson, { status: 200 });
|
|
28
|
+
}
|
|
29
|
+
return NextResponse.json(bodyJson ?? { success: true }, { status: 200 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function PATCH(req: NextRequest, id: string) {
|
|
33
|
+
let body = '{}';
|
|
34
|
+
try {
|
|
35
|
+
const raw = await req.text();
|
|
36
|
+
if (raw && raw.trim().length > 0) body = raw;
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Passkeys/${encodeURIComponent(id)}`, {
|
|
40
|
+
method: 'PATCH',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body,
|
|
43
|
+
});
|
|
44
|
+
if (!result.ok) return upstreamError(result);
|
|
45
|
+
return passthrough(result.json);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function DELETE(req: NextRequest, id: string) {
|
|
49
|
+
const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Passkeys/${encodeURIComponent(id)}`, {
|
|
50
|
+
method: 'DELETE',
|
|
51
|
+
});
|
|
52
|
+
if (!result.ok) return upstreamError(result);
|
|
53
|
+
return passthrough(result.json);
|
|
54
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
|
+
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Begin the passkey REGISTRATION ceremony (POST /api/Passkeys/register/options,
|
|
7
|
+
* user JWT — a credential change; the IDP may demand step-up, surfaced as-is).
|
|
8
|
+
* Returns the WebAuthn PublicKeyCredentialCreationOptions (ride the envelope
|
|
9
|
+
* `data`, per the archived hand-roll contract).
|
|
10
|
+
*/
|
|
11
|
+
export async function POST(req: NextRequest) {
|
|
12
|
+
let body = '{}';
|
|
13
|
+
try {
|
|
14
|
+
const raw = await req.text();
|
|
15
|
+
if (raw && raw.trim().length > 0) body = raw;
|
|
16
|
+
} catch {}
|
|
17
|
+
|
|
18
|
+
const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Passkeys/register/options`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
return NextResponse.json(
|
|
26
|
+
{
|
|
27
|
+
success: false,
|
|
28
|
+
message: 'Upstream error',
|
|
29
|
+
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
|
|
30
|
+
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
31
|
+
},
|
|
32
|
+
{ status: result.status },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const bodyJson: any = result.json;
|
|
37
|
+
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
38
|
+
if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
|
|
39
|
+
return NextResponse.json(bodyJson, { status: 200 });
|
|
40
|
+
}
|
|
41
|
+
return NextResponse.json(bodyJson ?? {}, { status: 200 });
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
|
+
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Finish the passkey REGISTRATION ceremony (POST /api/Passkeys/register/verify,
|
|
7
|
+
* user JWT). Body forwarded VERBATIM — it carries the enrolment handle plus the
|
|
8
|
+
* authenticator attestation produced by navigator.credentials.create().
|
|
9
|
+
*/
|
|
10
|
+
export async function POST(req: NextRequest) {
|
|
11
|
+
let body = '{}';
|
|
12
|
+
try {
|
|
13
|
+
const raw = await req.text();
|
|
14
|
+
if (raw && raw.trim().length > 0) body = raw;
|
|
15
|
+
} catch {}
|
|
16
|
+
|
|
17
|
+
const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Passkeys/register/verify`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!result.ok) {
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{
|
|
26
|
+
success: false,
|
|
27
|
+
message: 'Upstream error',
|
|
28
|
+
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
|
|
29
|
+
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
30
|
+
},
|
|
31
|
+
{ status: result.status },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const bodyJson: any = result.json;
|
|
36
|
+
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
37
|
+
if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
|
|
38
|
+
return NextResponse.json(bodyJson, { status: 200 });
|
|
39
|
+
}
|
|
40
|
+
return NextResponse.json(bodyJson ?? {}, { status: 200 });
|
|
41
|
+
}
|
|
@@ -117,6 +117,25 @@ export async function GET(req: NextRequest) {
|
|
|
117
117
|
// 2FA fields - critical for middleware redirect logic
|
|
118
118
|
requires2FA, // From cached client config (client-wide setting)
|
|
119
119
|
twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
|
|
120
|
+
// Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
|
|
121
|
+
//
|
|
122
|
+
// THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
|
|
123
|
+
// routes/auth/viability.ts, which is what the admin graft mounts.
|
|
124
|
+
// Both are publicly exported entry points, so both can be a consumer's
|
|
125
|
+
// only source of this answer. Adding the relay to just one would leave
|
|
126
|
+
// an app that mounts THIS one still blind to enroll_mfa — the exact
|
|
127
|
+
// seam these cards are about, reopened one export over.
|
|
128
|
+
//
|
|
129
|
+
// 'enroll_mfa' = no USABLE method, the user cannot complete a
|
|
130
|
+
// challenge. 'mfa_verify' = usable method exists. null = nothing due.
|
|
131
|
+
// NOT a completion signal: twoFactorComplete still owns that.
|
|
132
|
+
//
|
|
133
|
+
// No `?? null`: undefined (never captured — every session minted
|
|
134
|
+
// before this change) must stay distinguishable from null (the IDP
|
|
135
|
+
// said nothing is outstanding). Defaulting to null would tell the
|
|
136
|
+
// gate that every pre-existing session is fully authenticated,
|
|
137
|
+
// including zero-method users who must be routed to enrol.
|
|
138
|
+
nextStep: sessionData.nextStep,
|
|
120
139
|
// Token status for refresh decisions
|
|
121
140
|
accessTokenExpired,
|
|
122
141
|
hasRefreshToken: !!sessionData.idpRefreshToken
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { IDPClientConfig } from '../lib/idp-client-config';
|
|
|
20
20
|
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
21
21
|
import { getAppSlug } from '../lib/app-slug';
|
|
22
22
|
import { getRedis } from '../lib/redis';
|
|
23
|
+
import { SESSION_TTL } from '../lib/session-store';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Better Auth social provider config shape.
|
|
@@ -170,6 +171,27 @@ export function buildBetterAuthProviders(
|
|
|
170
171
|
*/
|
|
171
172
|
export interface CreateBetterAuthInstanceOptions {
|
|
172
173
|
magicLink?: MagicLinkOptions;
|
|
174
|
+
/**
|
|
175
|
+
* Extra Better Auth plugins supplied by the consuming app.
|
|
176
|
+
*
|
|
177
|
+
* WHY THIS EXISTS — session minting is not delegable any other way. BA's
|
|
178
|
+
* `setSessionCookie` requires a `GenericEndpointContext`, which only BA's own
|
|
179
|
+
* endpoint machinery constructs, so an app that must mint a session for an
|
|
180
|
+
* identity established OUTSIDE BA's built-in providers (passkey sign-in being
|
|
181
|
+
* the first) cannot do it from a route handler. It needs a real BA endpoint,
|
|
182
|
+
* and endpoints only arrive via plugins.
|
|
183
|
+
*
|
|
184
|
+
* Deliberately GENERIC. The package gains "an app may supply plugins" rather
|
|
185
|
+
* than any knowledge of passkeys — the alternative was teaching this file
|
|
186
|
+
* about a specific auth method, which is how a package accumulates a consumer's
|
|
187
|
+
* feature list.
|
|
188
|
+
*
|
|
189
|
+
* The alternative to this option is an app hand-rolling the session cookie
|
|
190
|
+
* (`{token}.{base64(hmac)}`, standard padding, runtime `authSecret` from
|
|
191
|
+
* Redis). That fails as a session which DECODES BUT DOES NOT AUTHENTICATE —
|
|
192
|
+
* so this option exists specifically to make that shortcut unnecessary.
|
|
193
|
+
*/
|
|
194
|
+
plugins?: Parameters<typeof betterAuth>[0]['plugins'];
|
|
173
195
|
}
|
|
174
196
|
|
|
175
197
|
/**
|
|
@@ -210,15 +232,19 @@ export function createBetterAuthInstance(
|
|
|
210
232
|
// Redis-backed session storage via secondaryStorage
|
|
211
233
|
secondaryStorage: {
|
|
212
234
|
get: async (key: string) => {
|
|
213
|
-
|
|
235
|
+
try {
|
|
236
|
+
return await getRedis().get(`ba:${appSlug}:${key}`);
|
|
237
|
+
} catch { return null; }
|
|
214
238
|
},
|
|
215
239
|
set: async (key: string, value: string, ttl?: number) => {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
240
|
+
try {
|
|
241
|
+
const redis = getRedis();
|
|
242
|
+
if (ttl) {
|
|
243
|
+
await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
|
|
244
|
+
} else {
|
|
245
|
+
await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
|
|
246
|
+
}
|
|
247
|
+
} catch { /* Redis unavailable — cookie cache still works */ }
|
|
222
248
|
},
|
|
223
249
|
delete: async (key: string) => {
|
|
224
250
|
try {
|
|
@@ -265,6 +291,9 @@ export function createBetterAuthInstance(
|
|
|
265
291
|
plugins: [
|
|
266
292
|
nextCookies(),
|
|
267
293
|
...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
|
|
294
|
+
// App-supplied plugins last: they may depend on the built-ins above, and
|
|
295
|
+
// ordering is BA's own concern once they are in the array.
|
|
296
|
+
...(opts.plugins ?? []),
|
|
268
297
|
],
|
|
269
298
|
});
|
|
270
299
|
}
|
|
@@ -449,8 +478,20 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
449
478
|
}
|
|
450
479
|
|
|
451
480
|
// Build IDP token data
|
|
452
|
-
//
|
|
453
|
-
//
|
|
481
|
+
// ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
|
|
482
|
+
// !hasRefreshToken was a reliable "2FA required" signal.
|
|
483
|
+
//
|
|
484
|
+
// BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
|
|
485
|
+
// oauth-callback even for require_2fa tenants that haven't done 2FA yet.
|
|
486
|
+
// This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
|
|
487
|
+
// bypassing the 2FA gate entirely.
|
|
488
|
+
//
|
|
489
|
+
// FIX: Always set mfaVerified=false on the initial OAuth exchange.
|
|
490
|
+
// The tenant's require_2fa setting is enforced by the viability endpoint
|
|
491
|
+
// (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
|
|
492
|
+
// mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
|
|
493
|
+
// Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
|
|
494
|
+
// IDP correctly withholds refresh_token until post-2FA.
|
|
454
495
|
const hasRefreshToken = !!result.refresh_token;
|
|
455
496
|
const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
|
|
456
497
|
const idpTokenData = {
|
|
@@ -464,32 +505,131 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
464
505
|
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
465
506
|
image: image,
|
|
466
507
|
roles: result.user?.roles || result.roles || [],
|
|
467
|
-
mfaVerified:
|
|
508
|
+
mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
|
|
509
|
+
// Cards 141688/141741 — RELAYED, NOT DERIVED.
|
|
510
|
+
//
|
|
511
|
+
// The IDP already decides this (ExternalAuthController:943 and :2024,
|
|
512
|
+
// WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
|
|
513
|
+
// confirmed method exists, null = fully authenticated. It was emitted
|
|
514
|
+
// here and dropped on this line — `idpTokenData` is built field-by-field,
|
|
515
|
+
// so anything not named is silently discarded. Both downstream consumers
|
|
516
|
+
// (the middleware gate and the verify-code page) then reasoned in its
|
|
517
|
+
// absence and sent zero-method users to a page they cannot act on.
|
|
518
|
+
//
|
|
519
|
+
// WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
|
|
520
|
+
// BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
|
|
521
|
+
// `result.nextStep` here would be `undefined` forever and fail silently —
|
|
522
|
+
// the same camel/snake break `requires_two_factor` already cost us once.
|
|
523
|
+
//
|
|
524
|
+
// Do NOT recompute this from a method query. The IDP owns the fact; it is
|
|
525
|
+
// already usable-aware (confirmed and not revoked, per DotNetPert), which
|
|
526
|
+
// is the distinction that decides correctness: a revoked-TOTP user has
|
|
527
|
+
// count>0 but usable=0, and deriving it locally would let through exactly
|
|
528
|
+
// the users we must challenge.
|
|
529
|
+
// No `?? null`: if the response has no `next_step` at all (older IDP
|
|
530
|
+
// build, or a path that does not set it), that is UNKNOWN, not "fully
|
|
531
|
+
// authenticated". Defaulting it to null here would manufacture a
|
|
532
|
+
// clean-bill-of-health the IDP never gave and fail open at the gate.
|
|
533
|
+
nextStep: result.next_step,
|
|
468
534
|
idpClientId: result.client_id ? String(result.client_id) : undefined,
|
|
469
535
|
merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
|
|
470
536
|
};
|
|
471
537
|
|
|
472
|
-
//
|
|
538
|
+
// Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
|
|
539
|
+
const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
|
|
540
|
+
if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
541
|
+
return stored;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Write IDP tokens into an EXISTING Better Auth session. Storage only — this
|
|
550
|
+
* function never acquires tokens and never decides who the caller is.
|
|
551
|
+
*
|
|
552
|
+
* ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
|
|
553
|
+
*
|
|
554
|
+
* `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
|
|
555
|
+
* POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
|
|
556
|
+
* the second half is reusable, and the first half is actively dangerous to
|
|
557
|
+
* reuse:
|
|
558
|
+
*
|
|
559
|
+
* oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
|
|
560
|
+
* cryptographic verification of any kind (card 138635).
|
|
561
|
+
*
|
|
562
|
+
* Passkey sign-in already holds something strictly better — a grant issued by
|
|
563
|
+
* `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
|
|
564
|
+
* signature against a registered credential. Routing that through
|
|
565
|
+
* oauth-callback to obtain a session would DOWNGRADE a cryptographically
|
|
566
|
+
* verified assertion into an unverified one, make passkey sign-in weaker than
|
|
567
|
+
* its own ceremony, and inherit 138635 wholesale.
|
|
568
|
+
*
|
|
569
|
+
* That is not hypothetical: "do what the OAuth path does" is the obvious
|
|
570
|
+
* implementation and it is the wrong one. Splitting acquisition from storage is
|
|
571
|
+
* what makes the wrong one hard to write by accident.
|
|
572
|
+
*
|
|
573
|
+
* ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
|
|
574
|
+
*
|
|
575
|
+
* A Better Auth session must ALREADY EXIST for `sessionToken`. This function
|
|
576
|
+
* enriches; it does not create. Better Auth owns session creation and cookie
|
|
577
|
+
* signing, and nothing in this package should replicate either — the cookie is
|
|
578
|
+
* `{token}.{base64(hmac)}` with standard padding, signed with the runtime
|
|
579
|
+
* `authSecret` from Redis, and hand-rolling that produces a session which
|
|
580
|
+
* decodes but does not authenticate.
|
|
581
|
+
*
|
|
582
|
+
* @param sessionToken the Better Auth session token (from its Set-Cookie)
|
|
583
|
+
* @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
|
|
584
|
+
* verified route. Built field-by-field by the caller so an
|
|
585
|
+
* unnamed field cannot ride along silently.
|
|
586
|
+
* @param authProvider provenance label recorded on the canonical row. Written
|
|
587
|
+
* to the historical `oauthProvider` key, which predates
|
|
588
|
+
* non-OAuth callers; the NAME is legacy, the VALUE should
|
|
589
|
+
* state what actually authenticated ('passkey', 'google').
|
|
590
|
+
*/
|
|
591
|
+
export async function storeIdpTokensInSession(
|
|
592
|
+
sessionToken: string,
|
|
593
|
+
idpTokenData: Record<string, unknown>,
|
|
594
|
+
authProvider: string,
|
|
595
|
+
): Promise<boolean> {
|
|
596
|
+
try {
|
|
597
|
+
const config = await getIDPClientConfig();
|
|
598
|
+
const appSlug = config.clientSlug || getAppSlug();
|
|
599
|
+
const baKey = `ba:${appSlug}:${sessionToken}`;
|
|
600
|
+
|
|
601
|
+
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
602
|
+
if (!baRaw) {
|
|
603
|
+
// No session to enrich. Do NOT create one as a fallback: a session
|
|
604
|
+
// conjured here would carry no Better Auth cookie, so it would be
|
|
605
|
+
// unreachable by the browser and invisible to every other consumer —
|
|
606
|
+
// failing as "silently signed out" rather than as an error.
|
|
607
|
+
console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', sessionToken.substring(0, 10));
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const baData = JSON.parse(baRaw);
|
|
473
612
|
baData.idpTokens = idpTokenData;
|
|
474
|
-
await getRedis().setex(baKey,
|
|
613
|
+
await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
|
|
475
614
|
|
|
476
|
-
//
|
|
477
|
-
//
|
|
615
|
+
// Canonical store — the key getSession() reads, used by the refresh handler
|
|
616
|
+
// and token lifecycle. A failure here leaves the BA session correct and the
|
|
617
|
+
// canonical row missing, so it is warned and not fatal: the user is signed
|
|
618
|
+
// in, and refresh degrades rather than the sign-in failing outright.
|
|
478
619
|
try {
|
|
479
620
|
const { getSessionPrefix } = await import('../lib/app-slug');
|
|
480
621
|
const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
|
|
481
|
-
await getRedis().setex(canonicalKey,
|
|
622
|
+
await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
|
|
482
623
|
...idpTokenData,
|
|
483
|
-
oauthProvider:
|
|
624
|
+
oauthProvider: authProvider,
|
|
484
625
|
}));
|
|
485
626
|
} catch (canonicalErr) {
|
|
486
627
|
console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
|
|
487
628
|
}
|
|
488
629
|
|
|
489
|
-
console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
490
630
|
return true;
|
|
491
631
|
} catch (err) {
|
|
492
|
-
console.error('[BETTER_AUTH]
|
|
632
|
+
console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
|
|
493
633
|
return false;
|
|
494
634
|
}
|
|
495
635
|
}
|
|
@@ -150,105 +150,23 @@ function getLoginErrorMessage(status: number, responseData: any): string {
|
|
|
150
150
|
// ============================================================================
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
*
|
|
153
|
+
* idpOAuthCallback REMOVED 2026-08-05 (NextPert, BAPert 20168 / QAPert 20166).
|
|
154
154
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
155
|
+
* It POSTed to /api/ExternalAuth/oauth-callback with provider, email, name,
|
|
156
|
+
* tokens and client_id — ALL ASSERTIONS, no id_token, nothing verifiable. After
|
|
157
|
+
* the 170003 fix (13436d56e) that call can only ever return 401.
|
|
157
158
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
provider: string;
|
|
163
|
-
providerAccountId: string;
|
|
164
|
-
email: string;
|
|
165
|
-
name?: string;
|
|
166
|
-
image?: string;
|
|
167
|
-
accessToken?: string;
|
|
168
|
-
refreshToken?: string;
|
|
169
|
-
expiresAt?: number;
|
|
170
|
-
}): Promise<IdpOAuthCallbackResponse> {
|
|
171
|
-
const idpUrl = getIdpUrl();
|
|
172
|
-
const clientId = getClientId();
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
const response = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
|
|
176
|
-
method: 'POST',
|
|
177
|
-
headers: {
|
|
178
|
-
'Content-Type': 'application/json',
|
|
179
|
-
'X-Client-Id': clientId,
|
|
180
|
-
},
|
|
181
|
-
body: JSON.stringify({
|
|
182
|
-
provider: oauthData.provider,
|
|
183
|
-
provider_account_id: oauthData.providerAccountId,
|
|
184
|
-
email: oauthData.email,
|
|
185
|
-
name: oauthData.name || '',
|
|
186
|
-
image: oauthData.image || '',
|
|
187
|
-
access_token: oauthData.accessToken || '',
|
|
188
|
-
refresh_token: oauthData.refreshToken || '',
|
|
189
|
-
expires_at: oauthData.expiresAt || 0,
|
|
190
|
-
client_id: clientId,
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
if (!response.ok) {
|
|
195
|
-
const errorText = await response.text().catch(() => 'Unknown error');
|
|
196
|
-
console.error('[IDP_CLIENT] OAuth callback failed:', response.status, errorText);
|
|
197
|
-
return {
|
|
198
|
-
success: false,
|
|
199
|
-
error: {
|
|
200
|
-
code: `HTTP_${response.status}`,
|
|
201
|
-
message: 'OAuth registration failed',
|
|
202
|
-
},
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const data = await response.json();
|
|
207
|
-
const responseData = data.data || data;
|
|
208
|
-
|
|
209
|
-
// Normalize snake_case to camelCase
|
|
210
|
-
return {
|
|
211
|
-
success: responseData.success !== false,
|
|
212
|
-
data: responseData.success !== false
|
|
213
|
-
? {
|
|
214
|
-
accessToken: responseData.accessToken || responseData.access_token,
|
|
215
|
-
refreshToken: responseData.refreshToken || responseData.refresh_token,
|
|
216
|
-
isNewUser: responseData.isNewUser ?? responseData.is_new_user ?? false,
|
|
217
|
-
user: responseData.user
|
|
218
|
-
? {
|
|
219
|
-
userId: responseData.user.userId || responseData.user.user_id,
|
|
220
|
-
email: responseData.user.email || responseData.user.Email,
|
|
221
|
-
fullName: responseData.user.fullName || responseData.user.full_name || responseData.user.name,
|
|
222
|
-
roles: responseData.user.roles || [],
|
|
223
|
-
}
|
|
224
|
-
: undefined,
|
|
225
|
-
}
|
|
226
|
-
: undefined,
|
|
227
|
-
error: responseData.error,
|
|
228
|
-
};
|
|
229
|
-
} catch (error) {
|
|
230
|
-
console.error('[IDP_CLIENT] OAuth callback request failed:', error);
|
|
231
|
-
return {
|
|
232
|
-
success: false,
|
|
233
|
-
error: {
|
|
234
|
-
code: 'NETWORK_ERROR',
|
|
235
|
-
message: 'Failed to connect to authentication service',
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ============================================================================
|
|
242
|
-
// TOKEN REFRESH
|
|
243
|
-
// ============================================================================
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Refresh an expired access token using the refresh token.
|
|
159
|
+
* It was a SECOND caller of the same endpoint, and only the first
|
|
160
|
+
* (better-auth.ts, card 138635) was hardened — the same duplicated-integration
|
|
161
|
+
* trap the viability code documents ("adding the relay to just one would leave
|
|
162
|
+
* an app that mounts THIS one still blind").
|
|
247
163
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
164
|
+
* It had no call sites and was not reachable through the package exports map
|
|
165
|
+
* (no ./auth/utils key, no wildcard), so removing it breaks nothing. Deleted
|
|
166
|
+
* rather than fixed, because an unreachable function whose only outcome is a
|
|
167
|
+
* 401 that LOOKS like a security success is a trap for whoever finds it next.
|
|
251
168
|
*/
|
|
169
|
+
|
|
252
170
|
export async function idpRefreshToken(
|
|
253
171
|
refreshToken: string,
|
|
254
172
|
mfaContext?: {
|
|
@@ -95,6 +95,13 @@ export async function ensureFreshAccessToken(
|
|
|
95
95
|
|
|
96
96
|
const storedToken = session.idpAccessToken;
|
|
97
97
|
if (!storedToken) {
|
|
98
|
+
// Card 187871: same conflation as getIdpToken (server/auth.ts) - a live
|
|
99
|
+
// session with no access token and mfaVerified !== true is a user
|
|
100
|
+
// mid-2FA, not dead. Only terminal once the session itself claims 2FA
|
|
101
|
+
// is already done.
|
|
102
|
+
if (session.mfaVerified !== true) {
|
|
103
|
+
return { ok: false, code: 'NO_TOKEN_PENDING_2FA', message: 'Two-factor authentication required', status: 401, terminal: false };
|
|
104
|
+
}
|
|
98
105
|
return { ok: false, code: 'NO_TOKEN', message: 'No IDP access token in session', status: 401, terminal: true };
|
|
99
106
|
}
|
|
100
107
|
|
|
@@ -206,8 +213,16 @@ export async function ensureFreshAccessToken(
|
|
|
206
213
|
acrValue = '2';
|
|
207
214
|
}
|
|
208
215
|
|
|
216
|
+
// WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
|
|
217
|
+
// `session` was read before we acquired the lock; a concurrent refresh that
|
|
218
|
+
// completed while we waited rotates the token, so the pre-lock value is stale
|
|
219
|
+
// and would draw an avoidable 401 (the CAS now catches the resulting wipe, but
|
|
220
|
+
// the round-trip is wasted). `latest` is the authoritative post-lock session.
|
|
221
|
+
// Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
|
|
222
|
+
// the identity context of THIS request, unchanged by a token rotation.
|
|
223
|
+
const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
|
|
209
224
|
const body: Record<string, unknown> = {
|
|
210
|
-
refresh_token:
|
|
225
|
+
refresh_token: sentRefreshToken,
|
|
211
226
|
amr: authMethods,
|
|
212
227
|
acr: acrValue,
|
|
213
228
|
};
|
|
@@ -246,15 +261,41 @@ export async function ensureFreshAccessToken(
|
|
|
246
261
|
if (!idpResponse.ok) {
|
|
247
262
|
const idpError = responseData?.error || {};
|
|
248
263
|
const code = idpError.code || 'UNKNOWN_ERROR';
|
|
249
|
-
|
|
264
|
+
let discardToken = idpResponse.status === 401 || idpError.discard_token === true;
|
|
250
265
|
const retryable = idpResponse.status !== 401 && idpError.retryable === true;
|
|
251
266
|
if (discardToken) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
// Race-safe discard (CAS): only clear if the refresh token we used is STILL
|
|
268
|
+
// current. A concurrent refresh may have already rotated it — our 401 is then
|
|
269
|
+
// stale/expected, and wiping here would silently log out a healthy session
|
|
270
|
+
// (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
|
|
271
|
+
// a concurrent rotation is NOT a terminal condition.
|
|
272
|
+
const postRefresh = await getSession(sessionToken);
|
|
273
|
+
// CAS compares against the token we ACTUALLY SENT (WO-2: sentRefreshToken,
|
|
274
|
+
// the post-lock value), not the pre-lock snapshot — otherwise a token rotated
|
|
275
|
+
// between pre-lock read and our send would be misjudged.
|
|
276
|
+
const tokenUnchanged =
|
|
277
|
+
!!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
|
|
278
|
+
if (tokenUnchanged) {
|
|
279
|
+
await updateSession(sessionToken, {
|
|
280
|
+
idpRefreshToken: '',
|
|
281
|
+
idpRefreshTokenExpires: undefined,
|
|
282
|
+
refreshTokenClearedAt: Date.now(),
|
|
283
|
+
refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
// Concurrent refresh already rotated the token — our 401 is stale. Return the
|
|
287
|
+
// freshly-rotated token directly so the loser succeeds without a retry round-trip
|
|
288
|
+
// (race-fix follow-up #1).
|
|
289
|
+
discardToken = false;
|
|
290
|
+
if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
accessToken: postRefresh.idpAccessToken,
|
|
294
|
+
accessTokenExpires: postRefresh.idpAccessTokenExpires,
|
|
295
|
+
refreshed: true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
258
299
|
}
|
|
259
300
|
return {
|
|
260
301
|
ok: false,
|
package/src/lib/session-store.ts
CHANGED
|
@@ -26,7 +26,10 @@ const getSessionVersionKey = (token: string) => `${getSessionPrefix()}ver:${toke
|
|
|
26
26
|
const getBetterAuthSessionKey = (token: string, appSlug?: string) => `ba:${appSlug || 'app'}:${token}`;
|
|
27
27
|
|
|
28
28
|
const REFRESH_LOCK_TTL = 60; // 60 seconds
|
|
29
|
-
|
|
29
|
+
// 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
|
|
30
|
+
// of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
|
|
31
|
+
// two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
|
|
32
|
+
export const SESSION_TTL = 3 * 24 * 60 * 60;
|
|
30
33
|
|
|
31
34
|
export interface RefreshLockInfo {
|
|
32
35
|
sessionToken: string;
|
|
@@ -122,6 +125,13 @@ export async function getBetterAuthSession(sessionToken: string, appSlug?: strin
|
|
|
122
125
|
idpRefreshToken: data.idpTokens?.idpRefreshToken,
|
|
123
126
|
idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
|
|
124
127
|
mfaVerified: data.idpTokens?.mfaVerified ?? false,
|
|
128
|
+
// Cards 141688/141741 — the IDP's routing decision, carried not derived.
|
|
129
|
+
// `null` is a real value here (IDP said nothing is outstanding), so it
|
|
130
|
+
// must stay distinguishable from `undefined` (we never captured it —
|
|
131
|
+
// true of every session minted before this change). No `?? null`:
|
|
132
|
+
// defaulting would turn "unknown" into "fully authenticated" and
|
|
133
|
+
// fail open on the existing session population.
|
|
134
|
+
nextStep: data.idpTokens?.nextStep,
|
|
125
135
|
roles: data.idpTokens?.roles || [],
|
|
126
136
|
idpClientId: data.idpTokens?.idpClientId ?? data.idpTokens?.clientId ?? data.idpClientId,
|
|
127
137
|
merchantId: data.idpTokens?.merchantId ?? data.merchantId,
|