@payez/next-mvp 4.4.6 → 4.4.8
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/change-password.js +28 -6
- package/dist/auth/better-auth.d.ts +62 -15
- package/dist/auth/better-auth.js +180 -19
- package/dist/client/better-auth-client.d.ts +101 -956
- package/dist/config/env.d.ts +0 -2
- package/dist/config/env.js +0 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -1
- package/dist/lib/idp-client-config.js +8 -1
- package/dist/lib/never-in-response.d.ts +30 -0
- package/dist/lib/never-in-response.js +71 -0
- package/dist/lib/nextauth-secret.d.ts +51 -0
- package/dist/lib/nextauth-secret.js +141 -0
- package/dist/lib/safe-error-response.d.ts +6 -6
- package/dist/lib/safe-error-response.js +6 -6
- package/dist/lib/two-factor-verify-failure.d.ts +36 -3
- package/dist/lib/two-factor-verify-failure.js +79 -2
- package/dist/middleware/create-middleware.d.ts +2 -0
- package/dist/middleware/create-middleware.js +36 -0
- package/dist/pages/login/page.js +5 -0
- package/dist/pages/verify-code/page.js +4 -2
- package/dist/routes/auth/settings.js +4 -1
- package/dist/server/auth.d.ts +36 -294
- package/dist/server/auth.js +22 -25
- package/dist/vibe/client.d.ts +132 -16
- package/dist/vibe/client.js +288 -82
- package/dist/vibe/document-unwrap.d.ts +50 -0
- package/dist/vibe/document-unwrap.js +78 -0
- package/dist/vibe/errors.d.ts +17 -0
- package/dist/vibe/errors.js +21 -1
- package/dist/vibe/generic.d.ts +2 -49
- package/dist/vibe/generic.js +17 -78
- package/dist/vibe/index.d.ts +1 -1
- package/dist/vibe/index.js +2 -1
- package/package.json +17 -2
- package/src/api-handlers/account/change-password.ts +31 -6
- package/src/auth/better-auth.ts +188 -21
- package/src/config/env.ts +0 -2
- package/src/index.ts +4 -0
- package/src/lib/idp-client-config.ts +8 -1
- package/src/lib/never-in-response.ts +64 -0
- package/src/lib/nextauth-secret.ts +168 -0
- package/src/lib/safe-error-response.ts +6 -6
- package/src/lib/two-factor-verify-failure.ts +86 -5
- package/src/middleware/create-middleware.ts +42 -0
- package/src/pages/login/page.tsx +4 -0
- package/src/pages/verify-code/page.tsx +4 -2
- package/src/routes/auth/settings.ts +5 -1
- package/src/server/auth.ts +23 -29
- package/src/vibe/client.ts +378 -91
- package/src/vibe/document-unwrap.ts +103 -0
- package/src/vibe/errors.ts +24 -0
- package/src/vibe/generic.ts +8 -100
- package/src/vibe/index.ts +1 -0
|
@@ -5,6 +5,25 @@ const server_1 = require("next/server");
|
|
|
5
5
|
const auth_1 = require("../../server/auth");
|
|
6
6
|
const session_store_1 = require("../../lib/session-store");
|
|
7
7
|
const nanoid_1 = require("nanoid");
|
|
8
|
+
/**
|
|
9
|
+
* PAY-1596: the failure code comes from the IdP's REAL envelope { success, error: { code, message, details } } - the handler
|
|
10
|
+
* used to read a top-level `error_code` the IdP never sends, so every refusal was CHANGE_PASSWORD_FAILED and "this account has
|
|
11
|
+
* no password" / "wrong current password" could not be told apart. Allowlisted: nothing but these codes ever leaves here.
|
|
12
|
+
* - error.code NO_PASSWORD_SET / INVALID_CURRENT_PASSWORD (IdP PR 412) / VALIDATION_ERROR
|
|
13
|
+
* - the pre-412 wrong-current shape: VALIDATION_ERROR + details.value[] { code: InvalidCurrentPassword, field_name: currentPassword }
|
|
14
|
+
* - a legacy top-level error_code, for older IdPs
|
|
15
|
+
*/
|
|
16
|
+
const CHANGE_PASSWORD_CODES = new Set(['NO_PASSWORD_SET', 'INVALID_CURRENT_PASSWORD', 'VALIDATION_ERROR']);
|
|
17
|
+
function changePasswordFailureCode(body) {
|
|
18
|
+
const err = body && typeof body.error === 'object' && body.error ? body.error : null;
|
|
19
|
+
const code = typeof err?.code === 'string' ? err.code : typeof body?.error_code === 'string' ? body.error_code : '';
|
|
20
|
+
if (code === 'VALIDATION_ERROR' && Array.isArray(err?.details?.value)) {
|
|
21
|
+
const wrongCurrent = err.details.value.some((e) => e && typeof e === 'object' && (String(e.field_name ?? e.fieldName ?? '').toLowerCase() === 'currentpassword' || String(e.code ?? '').toLowerCase() === 'invalidcurrentpassword'));
|
|
22
|
+
if (wrongCurrent)
|
|
23
|
+
return 'INVALID_CURRENT_PASSWORD';
|
|
24
|
+
}
|
|
25
|
+
return CHANGE_PASSWORD_CODES.has(code) ? code : 'CHANGE_PASSWORD_FAILED';
|
|
26
|
+
}
|
|
8
27
|
const safe_error_response_1 = require("../../lib/safe-error-response");
|
|
9
28
|
// ...
|
|
10
29
|
async function POST(req) {
|
|
@@ -75,22 +94,25 @@ async function POST(req) {
|
|
|
75
94
|
if (!idpResponse.ok) {
|
|
76
95
|
// Extract error message from IDP response
|
|
77
96
|
let errorMessage = 'Failed to change password';
|
|
97
|
+
const failureCode = changePasswordFailureCode(responseData);
|
|
98
|
+
// The real envelope nests details under `error`; older IdPs put them at the top level.
|
|
99
|
+
const idpDetails = (responseData.error && typeof responseData.error === 'object' ? responseData.error.details : undefined) ?? responseData.details;
|
|
78
100
|
if (responseData.message) {
|
|
79
101
|
errorMessage = responseData.message;
|
|
80
102
|
}
|
|
81
|
-
else if (
|
|
82
|
-
errorMessage =
|
|
103
|
+
else if (idpDetails?.value && Array.isArray(idpDetails.value) && idpDetails.value.length > 0) {
|
|
104
|
+
errorMessage = idpDetails.value[0].message || errorMessage;
|
|
83
105
|
}
|
|
84
|
-
else if (
|
|
85
|
-
errorMessage =
|
|
106
|
+
else if (idpDetails?.message) {
|
|
107
|
+
errorMessage = idpDetails.message;
|
|
86
108
|
}
|
|
87
109
|
return server_1.NextResponse.json({
|
|
88
110
|
success: false,
|
|
89
111
|
message: errorMessage,
|
|
90
|
-
error_code:
|
|
112
|
+
error_code: failureCode,
|
|
91
113
|
request_id: requestId,
|
|
92
114
|
// PAY-1438: only the password-policy messages the security pages display, never the raw envelope.
|
|
93
|
-
details: (0, safe_error_response_1.projectPasswordPolicyDetails)(
|
|
115
|
+
details: (0, safe_error_response_1.projectPasswordPolicyDetails)(idpDetails),
|
|
94
116
|
}, { status: idpResponse.status });
|
|
95
117
|
}
|
|
96
118
|
return server_1.NextResponse.json({
|
|
@@ -182,6 +182,9 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
182
182
|
refreshCache: false;
|
|
183
183
|
};
|
|
184
184
|
};
|
|
185
|
+
account: {
|
|
186
|
+
storeStateStrategy: "cookie";
|
|
187
|
+
};
|
|
185
188
|
advanced: {
|
|
186
189
|
cookiePrefix: string;
|
|
187
190
|
cookies: {
|
|
@@ -192,22 +195,25 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
192
195
|
};
|
|
193
196
|
plugins: [{
|
|
194
197
|
id: "next-cookies";
|
|
198
|
+
version: string;
|
|
195
199
|
hooks: {
|
|
196
200
|
before: {
|
|
197
201
|
matcher(ctx: import("better-auth").HookEndpointContext): boolean;
|
|
198
|
-
handler: (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void
|
|
202
|
+
handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
|
|
199
203
|
}[];
|
|
200
204
|
after: {
|
|
201
205
|
matcher(ctx: import("better-auth").HookEndpointContext): true;
|
|
202
|
-
handler: (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void
|
|
206
|
+
handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
|
|
203
207
|
}[];
|
|
204
208
|
};
|
|
205
|
-
}, ...(
|
|
209
|
+
}, ...({
|
|
206
210
|
id: "magic-link";
|
|
211
|
+
version: string;
|
|
207
212
|
endpoints: {
|
|
208
213
|
signInMagicLink: import("better-auth").StrictEndpoint<"/sign-in/magic-link", {
|
|
209
214
|
method: "POST";
|
|
210
215
|
requireHeaders: true;
|
|
216
|
+
use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>[];
|
|
211
217
|
body: import("better-auth").ZodObject<{
|
|
212
218
|
email: import("better-auth").ZodEmail;
|
|
213
219
|
name: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
@@ -215,7 +221,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
215
221
|
newUserCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
216
222
|
errorCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
217
223
|
metadata: import("better-auth").ZodOptional<import("better-auth").ZodRecord<import("better-auth").ZodString, import("better-auth").ZodAny>>;
|
|
218
|
-
}, import("
|
|
224
|
+
}, import("zod/v4/core").$strip>;
|
|
219
225
|
metadata: {
|
|
220
226
|
openapi: {
|
|
221
227
|
operationId: string;
|
|
@@ -249,8 +255,8 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
249
255
|
callbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
250
256
|
errorCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
251
257
|
newUserCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
252
|
-
}, import("
|
|
253
|
-
use: ((inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void
|
|
258
|
+
}, import("zod/v4/core").$strip>;
|
|
259
|
+
use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>[];
|
|
254
260
|
requireHeaders: true;
|
|
255
261
|
metadata: {
|
|
256
262
|
openapi: {
|
|
@@ -289,6 +295,16 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
289
295
|
name: string;
|
|
290
296
|
image?: string | null | undefined;
|
|
291
297
|
};
|
|
298
|
+
session: {
|
|
299
|
+
id: string;
|
|
300
|
+
createdAt: Date;
|
|
301
|
+
updatedAt: Date;
|
|
302
|
+
userId: string;
|
|
303
|
+
expiresAt: Date;
|
|
304
|
+
token: string;
|
|
305
|
+
ipAddress?: string | null | undefined;
|
|
306
|
+
userAgent?: string | null | undefined;
|
|
307
|
+
};
|
|
292
308
|
}>;
|
|
293
309
|
};
|
|
294
310
|
rateLimit: {
|
|
@@ -297,8 +313,9 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
297
313
|
max: number;
|
|
298
314
|
}[];
|
|
299
315
|
options: MagicLinkOptions;
|
|
300
|
-
} | {
|
|
316
|
+
} | import("better-auth").BetterAuthPlugin | {
|
|
301
317
|
id: "generic-oauth";
|
|
318
|
+
version: string;
|
|
302
319
|
init: (ctx: import("better-auth").AuthContext) => {
|
|
303
320
|
context: {
|
|
304
321
|
socialProviders: import("better-auth").OAuthProvider<Record<string, any>, Partial<import("better-auth").ProviderOptions<any>>>[];
|
|
@@ -316,7 +333,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
316
333
|
scopes: import("better-auth").ZodOptional<import("better-auth").ZodArray<import("better-auth").ZodString>>;
|
|
317
334
|
requestSignUp: import("better-auth").ZodOptional<import("better-auth").ZodBoolean>;
|
|
318
335
|
additionalData: import("better-auth").ZodOptional<import("better-auth").ZodRecord<import("better-auth").ZodString, import("better-auth").ZodAny>>;
|
|
319
|
-
}, import("
|
|
336
|
+
}, import("zod/v4/core").$strip>;
|
|
320
337
|
metadata: {
|
|
321
338
|
openapi: {
|
|
322
339
|
description: string;
|
|
@@ -354,7 +371,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
354
371
|
error_description: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
355
372
|
state: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
356
373
|
iss: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
357
|
-
}, import("
|
|
374
|
+
}, import("zod/v4/core").$strip>;
|
|
358
375
|
metadata: {
|
|
359
376
|
allowedMediaTypes: string[];
|
|
360
377
|
openapi: {
|
|
@@ -379,7 +396,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
379
396
|
};
|
|
380
397
|
scope: "server";
|
|
381
398
|
};
|
|
382
|
-
},
|
|
399
|
+
}, never>;
|
|
383
400
|
oAuth2LinkAccount: import("better-auth").StrictEndpoint<"/oauth2/link", {
|
|
384
401
|
method: "POST";
|
|
385
402
|
body: import("better-auth").ZodObject<{
|
|
@@ -387,8 +404,8 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
387
404
|
callbackURL: import("better-auth").ZodString;
|
|
388
405
|
scopes: import("better-auth").ZodOptional<import("better-auth").ZodArray<import("better-auth").ZodString>>;
|
|
389
406
|
errorCallbackURL: import("better-auth").ZodOptional<import("better-auth").ZodString>;
|
|
390
|
-
}, import("
|
|
391
|
-
use: ((inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
|
|
407
|
+
}, import("zod/v4/core").$strip>;
|
|
408
|
+
use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
|
|
392
409
|
session: {
|
|
393
410
|
session: Record<string, any> & {
|
|
394
411
|
id: string;
|
|
@@ -410,7 +427,7 @@ export declare function createBetterAuthInstance(idpConfig: IDPClientConfig, opt
|
|
|
410
427
|
image?: string | null | undefined;
|
|
411
428
|
};
|
|
412
429
|
};
|
|
413
|
-
}
|
|
430
|
+
}>>[];
|
|
414
431
|
metadata: {
|
|
415
432
|
openapi: {
|
|
416
433
|
description: string;
|
|
@@ -519,8 +536,14 @@ export declare function getBetterAuthHandler(): Promise<{
|
|
|
519
536
|
* header of the response to find the BA Redis key.
|
|
520
537
|
*
|
|
521
538
|
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
539
|
+
*
|
|
540
|
+
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
541
|
+
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
542
|
+
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
543
|
+
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
544
|
+
* refused registration bounced silently, which is the failure Jon named.
|
|
522
545
|
*/
|
|
523
|
-
export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean>;
|
|
546
|
+
export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider?: string): Promise<boolean | string>;
|
|
524
547
|
/**
|
|
525
548
|
* Write IDP tokens into an EXISTING Better Auth session. Storage only — this
|
|
526
549
|
* function never acquires tokens and never decides who the caller is.
|
|
@@ -565,12 +588,36 @@ export declare function exchangeOAuthForIdpTokens(sessionToken: string, provider
|
|
|
565
588
|
* state what actually authenticated ('passkey', 'google').
|
|
566
589
|
*/
|
|
567
590
|
export declare function storeIdpTokensInSession(sessionToken: string, idpTokenData: Record<string, unknown>, authProvider: string): Promise<boolean>;
|
|
591
|
+
/**
|
|
592
|
+
* The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
|
|
593
|
+
* store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
|
|
594
|
+
* Try again or use another method." The cause stays in the server log, never on the page.
|
|
595
|
+
*/
|
|
596
|
+
export declare const OAUTH_SIGN_IN_FAILED_ERROR = "OAUTH_SIGN_IN_FAILED";
|
|
597
|
+
/**
|
|
598
|
+
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
599
|
+
*
|
|
600
|
+
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
601
|
+
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
602
|
+
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
603
|
+
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
604
|
+
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
605
|
+
* wearing a different number. Add them together, never one alone.
|
|
606
|
+
*/
|
|
607
|
+
export declare const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string>;
|
|
608
|
+
/**
|
|
609
|
+
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
610
|
+
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
|
611
|
+
* accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
|
|
612
|
+
*/
|
|
613
|
+
export declare function expireSetCookies(setCookies: readonly string[]): string[];
|
|
568
614
|
/**
|
|
569
615
|
* Create a production-ready GET handler for the auth catch-all route.
|
|
570
616
|
*
|
|
571
617
|
* Wraps better-auth's GET handler with:
|
|
572
618
|
* - OAuth state error recovery (redirects to login instead of error page)
|
|
573
|
-
* - IDP token exchange after successful OAuth callback
|
|
619
|
+
* - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
|
|
620
|
+
* (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
|
|
574
621
|
*
|
|
575
622
|
* Usage in host app:
|
|
576
623
|
* ```ts
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -43,6 +43,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
43
43
|
};
|
|
44
44
|
})();
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.IDP_ERROR_CODES_TO_FORWARD = exports.OAUTH_SIGN_IN_FAILED_ERROR = void 0;
|
|
46
47
|
exports.secretFingerprint = secretFingerprint;
|
|
47
48
|
exports.buildIdpOAuthProviderConfig = buildIdpOAuthProviderConfig;
|
|
48
49
|
exports.buildBetterAuthProviders = buildBetterAuthProviders;
|
|
@@ -54,6 +55,7 @@ exports.getBetterAuthInstance = getBetterAuthInstance;
|
|
|
54
55
|
exports.getBetterAuthHandler = getBetterAuthHandler;
|
|
55
56
|
exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
|
|
56
57
|
exports.storeIdpTokensInSession = storeIdpTokensInSession;
|
|
58
|
+
exports.expireSetCookies = expireSetCookies;
|
|
57
59
|
exports.createAuthGetHandler = createAuthGetHandler;
|
|
58
60
|
require("server-only");
|
|
59
61
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
@@ -284,19 +286,42 @@ function buildBetterAuthProviders(config) {
|
|
|
284
286
|
clientSecret: oauth.clientSecret,
|
|
285
287
|
scope: wireScopes.length > 0 ? wireScopes : undefined,
|
|
286
288
|
/**
|
|
287
|
-
*
|
|
288
|
-
* the signup BFF on allow_public_registration, but the social lane never
|
|
289
|
-
* touches that code: better-auth creates the user through its own
|
|
290
|
-
* internalAdapter at the OAuth callback, and its ONLY guard is this flag.
|
|
289
|
+
* PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
|
|
291
290
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
291
|
+
* 260419 wired this from `config.authSettings?.allowPublicRegistration` to
|
|
292
|
+
* close a client-side self-serve provisioning door. It cannot do that job
|
|
293
|
+
* correctly, and it BREAKS SIGN-IN:
|
|
294
|
+
*
|
|
295
|
+
* - This app runs better-auth in STATELESS mode (no DB adapter — see the
|
|
296
|
+
* file header). better-auth therefore has NO user store, so at the OAuth
|
|
297
|
+
* callback it cannot tell an EXISTING user from a NEW one. Its
|
|
298
|
+
* handleOAuthUserInfo consults `disableSignUp` for BOTH, so
|
|
299
|
+
* `disableSignUp: true` refuses a returning user exactly like a
|
|
300
|
+
* stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
|
|
301
|
+
* Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
|
|
302
|
+
* once client 12's allow_public_registration reported false.
|
|
303
|
+
*
|
|
304
|
+
* - The REAL registration gate already exists and is correct, at the IDP:
|
|
305
|
+
* PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
|
|
306
|
+
* user up by verified email (:799); an existing user logs in UNTOUCHED
|
|
307
|
+
* (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
|
|
308
|
+
* which refuses fail-closed when the client forbids public registration
|
|
309
|
+
* (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
|
|
310
|
+
* Its own comment states the contract: "existing federated users ...
|
|
311
|
+
* log in untouched, so gating here never blocks login."
|
|
312
|
+
*
|
|
313
|
+
* So this flag was a DUPLICATE gate that could only ever misfire — it cannot
|
|
314
|
+
* distinguish new from existing, and the IDP already refuses new identities.
|
|
315
|
+
* Removing it restores sign-in for existing users WITHOUT reopening
|
|
316
|
+
* self-serve: a NEW identity that completes the better-auth leg still gets
|
|
317
|
+
* REFUSED at the IDP exchange and never receives IDP tokens, which is the
|
|
318
|
+
* portal's actual authorization gate.
|
|
319
|
+
*
|
|
320
|
+
* WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
|
|
321
|
+
* (above) as the backstop, and each app's own front-door sign-up gate
|
|
322
|
+
* (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
|
|
323
|
+
* Both are correct and neither is this flag.
|
|
298
324
|
*/
|
|
299
|
-
disableSignUp: config.authSettings?.allowPublicRegistration !== true,
|
|
300
325
|
// Google is overly eager to reuse the last account unless we
|
|
301
326
|
// explicitly ask for account selection on each social login.
|
|
302
327
|
prompt: typeof rawPrompt === 'string'
|
|
@@ -748,6 +773,18 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
|
|
|
748
773
|
refreshCache: false,
|
|
749
774
|
},
|
|
750
775
|
},
|
|
776
|
+
// PAY-1781 B: KEEP OAUTH STATE IN THE ENCRYPTED COOKIE, as it was on better-auth 1.5.6.
|
|
777
|
+
// 1.5.6 resolved the default as `options.database ? 'database' : 'cookie'` - we have no
|
|
778
|
+
// database, so: cookie. 1.6.13+ resolves it as `(database || secondaryStorage) ? 'database'
|
|
779
|
+
// : 'cookie'`, and we DO have secondaryStorage, so the upgrade alone would silently move every
|
|
780
|
+
// OAuth state record into Redis through the adapter above (its enrichment read-modify-write
|
|
781
|
+
// and key handling were built for session records, not state). Pinned so the upgrade changes
|
|
782
|
+
// the version, not the design. The cookie branch is where GHSA-wxw3-q3m9-c3jr lived; 1.6.2+
|
|
783
|
+
// compares the returned `state` with the nonce sealed in the cookie, and our provider is also
|
|
784
|
+
// pkce:true, which the advisory required to be false. Test: oauth-state-strategy-cookie.
|
|
785
|
+
account: {
|
|
786
|
+
storeStateStrategy: 'cookie',
|
|
787
|
+
},
|
|
751
788
|
// Cookie prefix must match slim-middleware expectations ({slug}.session-token)
|
|
752
789
|
advanced: {
|
|
753
790
|
cookiePrefix: appSlug,
|
|
@@ -860,6 +897,12 @@ async function getBetterAuthHandler() {
|
|
|
860
897
|
* header of the response to find the BA Redis key.
|
|
861
898
|
*
|
|
862
899
|
* This replaces the old databaseHooks approach which doesn't fire in stateless mode.
|
|
900
|
+
*
|
|
901
|
+
* RETURNS the IdP's own error code on an IDP refusal, or null otherwise
|
|
902
|
+
* (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
|
|
903
|
+
* boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
|
|
904
|
+
* blip and every cause collapsed to one generic login-page code — a NEW user
|
|
905
|
+
* refused registration bounced silently, which is the failure Jon named.
|
|
863
906
|
*/
|
|
864
907
|
async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
865
908
|
try {
|
|
@@ -945,6 +988,36 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
945
988
|
console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
|
|
946
989
|
if (!oauthRes.ok) {
|
|
947
990
|
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
991
|
+
/**
|
|
992
|
+
* PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
|
|
993
|
+
*
|
|
994
|
+
* Every refusal used to return a bare `false`, so the abort collapsed ALL
|
|
995
|
+
* causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
|
|
996
|
+
* registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
|
|
997
|
+
* bounced silently, indistinguishable from a network blip. Jon named the
|
|
998
|
+
* clear message explicitly (67856): "(b) a brand-new Google user with
|
|
999
|
+
* registration OFF is refused by the IDP with a clear message, not a
|
|
1000
|
+
* silent bounce."
|
|
1001
|
+
*
|
|
1002
|
+
* The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
|
|
1003
|
+
* We read ONLY `error.code` and return it VERBATIM — the abort puts it in
|
|
1004
|
+
* the `?error=` param, and the HOST's allowlist decides whether to render
|
|
1005
|
+
* it. The package does NOT interpret the code and does NOT add a message
|
|
1006
|
+
* (that would be a second allowlist). No code in the body -> null (the
|
|
1007
|
+
* generic host code), exactly as before.
|
|
1008
|
+
*/
|
|
1009
|
+
let idpErrorCode = null;
|
|
1010
|
+
try {
|
|
1011
|
+
const parsed = JSON.parse(oauthResText);
|
|
1012
|
+
// FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
|
|
1013
|
+
// unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
|
|
1014
|
+
if (typeof parsed?.error?.code === 'string' && exports.IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
|
|
1015
|
+
idpErrorCode = parsed.error.code;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
/* non-JSON refusal body -> no code (generic abort) */
|
|
1020
|
+
}
|
|
948
1021
|
/**
|
|
949
1022
|
* WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
|
|
950
1023
|
*
|
|
@@ -993,7 +1066,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
993
1066
|
sent_client_id: (0, client_key_1.resolveClientKey)(config, 'auth/better-auth:oauth-callback').key ?? null,
|
|
994
1067
|
idp_url: idpUrl,
|
|
995
1068
|
});
|
|
996
|
-
return false;
|
|
1069
|
+
return idpErrorCode ?? false;
|
|
997
1070
|
}
|
|
998
1071
|
let idpData;
|
|
999
1072
|
try {
|
|
@@ -1113,6 +1186,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
1113
1186
|
const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
|
|
1114
1187
|
if (stored)
|
|
1115
1188
|
console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
1189
|
+
// true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
|
|
1116
1190
|
return stored;
|
|
1117
1191
|
}
|
|
1118
1192
|
catch (err) {
|
|
@@ -1202,12 +1276,87 @@ async function storeIdpTokensInSession(sessionToken, idpTokenData, authProvider)
|
|
|
1202
1276
|
return false;
|
|
1203
1277
|
}
|
|
1204
1278
|
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
|
|
1281
|
+
* store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
|
|
1282
|
+
* Try again or use another method." The cause stays in the server log, never on the page.
|
|
1283
|
+
*/
|
|
1284
|
+
exports.OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
|
|
1285
|
+
/**
|
|
1286
|
+
* The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
|
|
1287
|
+
*
|
|
1288
|
+
* Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
|
|
1289
|
+
* log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
|
|
1290
|
+
* the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
|
|
1291
|
+
* line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
|
|
1292
|
+
* (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
|
|
1293
|
+
* wearing a different number. Add them together, never one alone.
|
|
1294
|
+
*/
|
|
1295
|
+
exports.IDP_ERROR_CODES_TO_FORWARD = new Set(['REGISTRATION_NOT_ALLOWED']);
|
|
1296
|
+
/**
|
|
1297
|
+
* Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
|
|
1298
|
+
* WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
|
|
1299
|
+
* accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
|
|
1300
|
+
*/
|
|
1301
|
+
function expireSetCookies(setCookies) {
|
|
1302
|
+
const out = [];
|
|
1303
|
+
for (const sc of setCookies) {
|
|
1304
|
+
const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
|
|
1305
|
+
const eq = pair.indexOf('=');
|
|
1306
|
+
const name = eq > 0 ? pair.slice(0, eq) : '';
|
|
1307
|
+
if (!name)
|
|
1308
|
+
continue;
|
|
1309
|
+
const kept = attrs.filter((a) => /^(path|domain|secure|samesite|httponly)\b/i.test(a));
|
|
1310
|
+
out.push([`${name}=`, ...kept, 'Max-Age=0'].join('; '));
|
|
1311
|
+
}
|
|
1312
|
+
return out;
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* PAY-1733 (Jon, live on :4797): ABORT an OAuth sign-in whose IdP exchange did not store tokens.
|
|
1316
|
+
*
|
|
1317
|
+
* Before this, a failed exchange was a console.error and the user kept the Better Auth session the callback had
|
|
1318
|
+
* just created: signed in, holding NO IdP tokens. The host then routed that session to 2FA, which painted the
|
|
1319
|
+
* user's email and failed every call - a red "sign in again" screen about an account the app could no longer
|
|
1320
|
+
* authenticate. The IdP's 401 was CORRECT (it fail-closes unverified assertions); keeping the session was the bug.
|
|
1321
|
+
*
|
|
1322
|
+
* So: no session without IdP tokens. The Better Auth session is destroyed (its own signOut, best effort, then the
|
|
1323
|
+
* `ba:<slug>:<token>` record directly - that record is what makes it resolvable), every cookie the callback set is
|
|
1324
|
+
* expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
|
|
1325
|
+
* branch above (328439: an absolute URL built from request.url is a dead tab in a container).
|
|
1326
|
+
*/
|
|
1327
|
+
async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, loginPath, errorCode = exports.OAUTH_SIGN_IN_FAILED_ERROR) {
|
|
1328
|
+
const headers = new Headers();
|
|
1329
|
+
if (sessionToken) {
|
|
1330
|
+
try {
|
|
1331
|
+
const auth = await getBetterAuthInstance();
|
|
1332
|
+
const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
|
|
1333
|
+
for (const sc of out?.headers?.getSetCookie?.() ?? [])
|
|
1334
|
+
headers.append('set-cookie', sc);
|
|
1335
|
+
}
|
|
1336
|
+
catch (err) {
|
|
1337
|
+
console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
|
|
1338
|
+
}
|
|
1339
|
+
try {
|
|
1340
|
+
const config = await (0, idp_client_config_1.getIDPClientConfig)();
|
|
1341
|
+
await (0, redis_1.getRedis)().del(`ba:${config.clientSlug || (0, app_slug_1.getAppSlug)()}:${sessionToken}`);
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', (0, token_log_id_1.tokenLogId)(sessionToken), err instanceof Error ? err.message : String(err));
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? []))
|
|
1348
|
+
headers.append('set-cookie', sc);
|
|
1349
|
+
headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
|
|
1350
|
+
console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : '(no session)', 'code=' + errorCode);
|
|
1351
|
+
return new Response(null, { status: 302, headers });
|
|
1352
|
+
}
|
|
1205
1353
|
/**
|
|
1206
1354
|
* Create a production-ready GET handler for the auth catch-all route.
|
|
1207
1355
|
*
|
|
1208
1356
|
* Wraps better-auth's GET handler with:
|
|
1209
1357
|
* - OAuth state error recovery (redirects to login instead of error page)
|
|
1210
|
-
* - IDP token exchange after successful OAuth callback
|
|
1358
|
+
* - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
|
|
1359
|
+
* (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
|
|
1211
1360
|
*
|
|
1212
1361
|
* Usage in host app:
|
|
1213
1362
|
* ```ts
|
|
@@ -1276,22 +1425,29 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1276
1425
|
return new Response(null, { status: 302, headers: { location: loginPath } });
|
|
1277
1426
|
}
|
|
1278
1427
|
}
|
|
1279
|
-
// After
|
|
1428
|
+
// After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
|
|
1429
|
+
// IdP refused (401), no session was found, or anything threw - the sign-in is ABORTED (PAY-1733). A session is
|
|
1430
|
+
// kept only when it holds IdP tokens.
|
|
1280
1431
|
const url = new URL(request.url);
|
|
1281
1432
|
if (url.pathname.includes('/callback/') && response.status === 302) {
|
|
1433
|
+
const setCookies = response.headers.getSetCookie?.() || [];
|
|
1434
|
+
const cookieHeader = setCookies
|
|
1435
|
+
.map((c) => c.split(';')[0])
|
|
1436
|
+
.join('; ');
|
|
1437
|
+
let sessionToken = null;
|
|
1438
|
+
// true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
|
|
1439
|
+
// (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
|
|
1440
|
+
let exchange = false;
|
|
1282
1441
|
try {
|
|
1283
1442
|
const auth = await getBetterAuthInstance();
|
|
1284
1443
|
if (auth?.api?.getSession) {
|
|
1285
|
-
const setCookies = response.headers.getSetCookie?.() || [];
|
|
1286
|
-
const cookieHeader = setCookies
|
|
1287
|
-
.map((c) => c.split(';')[0])
|
|
1288
|
-
.join('; ');
|
|
1289
1444
|
const headers = new Headers();
|
|
1290
1445
|
headers.set('cookie', cookieHeader);
|
|
1291
1446
|
const session = await auth.api.getSession({ headers });
|
|
1292
1447
|
if (session?.session?.token) {
|
|
1448
|
+
sessionToken = session.session.token;
|
|
1293
1449
|
console.log('[BETTER_AUTH] Got session token from callback:', (0, token_log_id_1.tokenLogId)(session.session.token), '| email:', session.user?.email);
|
|
1294
|
-
await exchangeOAuthForIdpTokens(session.session.token);
|
|
1450
|
+
exchange = await exchangeOAuthForIdpTokens(session.session.token);
|
|
1295
1451
|
}
|
|
1296
1452
|
else {
|
|
1297
1453
|
console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
|
|
@@ -1301,6 +1457,11 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1301
1457
|
catch (err) {
|
|
1302
1458
|
console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
|
|
1303
1459
|
}
|
|
1460
|
+
if (exchange !== true) {
|
|
1461
|
+
// A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
|
|
1462
|
+
const errorCode = typeof exchange === 'string' ? exchange : exports.OAUTH_SIGN_IN_FAILED_ERROR;
|
|
1463
|
+
return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
|
|
1464
|
+
}
|
|
1304
1465
|
}
|
|
1305
1466
|
return response;
|
|
1306
1467
|
});
|