better-auth 1.6.1 → 1.6.2
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/routes/callback.mjs +1 -1
- package/dist/integrations/next-js.mjs +21 -12
- package/dist/oauth2/state.d.mts +1 -0
- package/dist/package.mjs +1 -1
- package/dist/plugins/oauth-proxy/index.mjs +4 -0
- package/dist/plugins/two-factor/client.d.mts +11 -1
- package/dist/plugins/two-factor/client.mjs +1 -1
- package/dist/plugins/two-factor/index.d.mts +7 -0
- package/dist/plugins/two-factor/index.mjs +33 -2
- package/dist/plugins/two-factor/otp/index.mjs +1 -1
- package/dist/plugins/two-factor/schema.d.mts +6 -0
- package/dist/plugins/two-factor/schema.mjs +6 -0
- package/dist/plugins/two-factor/totp/index.mjs +19 -10
- package/dist/plugins/two-factor/types.d.mts +1 -1
- package/dist/state.d.mts +6 -0
- package/dist/state.mjs +18 -2
- package/package.json +8 -8
|
@@ -104,7 +104,7 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
|
|
|
104
104
|
return redirectOnError("unable_to_link_account");
|
|
105
105
|
}
|
|
106
106
|
if (userInfo.email?.toLowerCase() !== link.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) return redirectOnError("email_doesn't_match");
|
|
107
|
-
const existingAccount = await c.context.internalAdapter.
|
|
107
|
+
const existingAccount = await c.context.internalAdapter.findAccountByProviderId(String(userInfo.id), provider.id);
|
|
108
108
|
if (existingAccount) {
|
|
109
109
|
if (existingAccount.userId.toString() !== link.userId.toString()) return redirectOnError("account_already_linked_to_different_user");
|
|
110
110
|
const updateData = Object.fromEntries(Object.entries({
|
|
@@ -24,20 +24,29 @@ const nextCookies = () => {
|
|
|
24
24
|
matcher(ctx) {
|
|
25
25
|
return ctx.path === "/get-session";
|
|
26
26
|
},
|
|
27
|
-
handler: createAuthMiddleware(async () => {
|
|
28
|
-
|
|
27
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
28
|
+
if ("_flag" in ctx && ctx._flag === "router") return;
|
|
29
|
+
let headersStore;
|
|
29
30
|
try {
|
|
30
|
-
const {
|
|
31
|
-
|
|
31
|
+
const { headers } = await import("next/headers.js");
|
|
32
|
+
headersStore = await headers();
|
|
32
33
|
} catch {
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Detect RSC via headers, NOT by probing cookies().set().
|
|
38
|
+
* In Next.js, cookies().set() unconditionally triggers router
|
|
39
|
+
* cache invalidation -- even if the value is unchanged.
|
|
40
|
+
*
|
|
41
|
+
* RSC sends `RSC: 1` without `next-action`. Only in that
|
|
42
|
+
* context cookies cannot be written -- skip session refresh
|
|
43
|
+
* to avoid DB/cookie mismatch.
|
|
44
|
+
*
|
|
45
|
+
* @see https://github.com/vercel/next.js/blob/8c5af211d580/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts#L112-L157
|
|
46
|
+
*/
|
|
47
|
+
const isRSC = headersStore.get("RSC") === "1";
|
|
48
|
+
const isServerAction = !!headersStore.get("next-action");
|
|
49
|
+
if (isRSC && !isServerAction) await setShouldSkipSessionRefresh(true);
|
|
41
50
|
})
|
|
42
51
|
}],
|
|
43
52
|
after: [{
|
|
@@ -51,12 +60,12 @@ const nextCookies = () => {
|
|
|
51
60
|
const setCookies = returned?.get("set-cookie");
|
|
52
61
|
if (!setCookies) return;
|
|
53
62
|
const parsed = parseSetCookieHeader(setCookies);
|
|
54
|
-
const { cookies } = await import("next/headers.js");
|
|
55
63
|
let cookieHelper;
|
|
56
64
|
try {
|
|
65
|
+
const { cookies } = await import("next/headers.js");
|
|
57
66
|
cookieHelper = await cookies();
|
|
58
67
|
} catch (error) {
|
|
59
|
-
if (error instanceof Error && error.message.startsWith("`cookies` was called outside a request scope.")) return;
|
|
68
|
+
if (error instanceof Error && (error.message.startsWith("`cookies` was called outside a request scope.") || error.message.includes("Cannot find module"))) return;
|
|
60
69
|
throw error;
|
|
61
70
|
}
|
|
62
71
|
parsed.forEach((value, key) => {
|
package/dist/oauth2/state.d.mts
CHANGED
package/dist/package.mjs
CHANGED
|
@@ -164,6 +164,10 @@ const oAuthProxy = (opts) => {
|
|
|
164
164
|
return;
|
|
165
165
|
}
|
|
166
166
|
const errorURL = stateData.errorURL || ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
167
|
+
if (stateData.oauthState !== void 0 && stateData.oauthState !== statePackage.state) {
|
|
168
|
+
ctx.context.logger.error("OAuth proxy state binding mismatch");
|
|
169
|
+
throw redirectOnError(ctx, errorURL, "state_mismatch");
|
|
170
|
+
}
|
|
167
171
|
if (error) throw redirectOnError(ctx, errorURL, error);
|
|
168
172
|
if (!code) {
|
|
169
173
|
ctx.context.logger.error("OAuth callback missing authorization code");
|
|
@@ -19,8 +19,18 @@ declare const twoFactorClient: (options?: {
|
|
|
19
19
|
/**
|
|
20
20
|
* a redirect function to call if a user needs to verify
|
|
21
21
|
* their two factor
|
|
22
|
+
*
|
|
23
|
+
* @param context.twoFactorMethods - The list of
|
|
24
|
+
* enabled two factor providers (e.g. ["totp", "otp"]).
|
|
25
|
+
* Use this to determine which 2FA UI to show.
|
|
22
26
|
*/
|
|
23
|
-
onTwoFactorRedirect?: (
|
|
27
|
+
onTwoFactorRedirect?: (context: {
|
|
28
|
+
/**
|
|
29
|
+
* The list of enabled two factor providers
|
|
30
|
+
* for the user (e.g. ["totp", "otp"]).
|
|
31
|
+
*/
|
|
32
|
+
twoFactorMethods?: string[];
|
|
33
|
+
}) => void | Promise<void>;
|
|
24
34
|
} | undefined) => {
|
|
25
35
|
id: "two-factor";
|
|
26
36
|
version: string;
|
|
@@ -26,7 +26,7 @@ const twoFactorClient = (options) => {
|
|
|
26
26
|
hooks: { async onSuccess(context) {
|
|
27
27
|
if (context.data?.twoFactorRedirect) {
|
|
28
28
|
if (options?.onTwoFactorRedirect) {
|
|
29
|
-
await options.onTwoFactorRedirect();
|
|
29
|
+
await options.onTwoFactorRedirect({ twoFactorMethods: context.data.twoFactorMethods });
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
32
|
if (options?.twoFactorPage && typeof window !== "undefined") window.location.href = options.twoFactorPage;
|
|
@@ -618,6 +618,7 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
|
|
|
618
618
|
matcher(context: _better_auth_core0.HookEndpointContext): boolean;
|
|
619
619
|
handler: (inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
620
620
|
twoFactorRedirect: boolean;
|
|
621
|
+
twoFactorMethods: string[];
|
|
621
622
|
} | undefined>;
|
|
622
623
|
}[];
|
|
623
624
|
};
|
|
@@ -655,6 +656,12 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
|
|
|
655
656
|
};
|
|
656
657
|
index: true;
|
|
657
658
|
};
|
|
659
|
+
verified: {
|
|
660
|
+
type: "boolean";
|
|
661
|
+
required: false;
|
|
662
|
+
defaultValue: true;
|
|
663
|
+
input: false;
|
|
664
|
+
};
|
|
658
665
|
};
|
|
659
666
|
};
|
|
660
667
|
};
|
|
@@ -103,6 +103,13 @@ const twoFactor = (options) => {
|
|
|
103
103
|
});
|
|
104
104
|
await ctx.context.internalAdapter.deleteSession(ctx.context.session.session.token);
|
|
105
105
|
}
|
|
106
|
+
const existingTwoFactor = await ctx.context.adapter.findOne({
|
|
107
|
+
model: opts.twoFactorTable,
|
|
108
|
+
where: [{
|
|
109
|
+
field: "userId",
|
|
110
|
+
value: user.id
|
|
111
|
+
}]
|
|
112
|
+
});
|
|
106
113
|
await ctx.context.adapter.deleteMany({
|
|
107
114
|
model: opts.twoFactorTable,
|
|
108
115
|
where: [{
|
|
@@ -115,7 +122,8 @@ const twoFactor = (options) => {
|
|
|
115
122
|
data: {
|
|
116
123
|
secret: encryptedSecret,
|
|
117
124
|
backupCodes: backupCodes.encryptedBackupCodes,
|
|
118
|
-
userId: user.id
|
|
125
|
+
userId: user.id,
|
|
126
|
+
verified: existingTwoFactor != null && existingTwoFactor.verified !== false || !!options?.skipVerificationOnEnable
|
|
119
127
|
}
|
|
120
128
|
});
|
|
121
129
|
const totpURI = createOTP(secret, {
|
|
@@ -225,7 +233,30 @@ const twoFactor = (options) => {
|
|
|
225
233
|
expiresAt: new Date(Date.now() + maxAge * 1e3)
|
|
226
234
|
});
|
|
227
235
|
await ctx.setSignedCookie(twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes);
|
|
228
|
-
|
|
236
|
+
const twoFactorMethods = [];
|
|
237
|
+
/**
|
|
238
|
+
* totp requires per-user setup, so we check
|
|
239
|
+
* that the user actually has a secret stored.
|
|
240
|
+
*/
|
|
241
|
+
if (!options?.totpOptions?.disable) {
|
|
242
|
+
const userTotpSecret = await ctx.context.adapter.findOne({
|
|
243
|
+
model: opts.twoFactorTable,
|
|
244
|
+
where: [{
|
|
245
|
+
field: "userId",
|
|
246
|
+
value: data.user.id
|
|
247
|
+
}]
|
|
248
|
+
});
|
|
249
|
+
if (userTotpSecret && userTotpSecret.verified !== false) twoFactorMethods.push("totp");
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* otp is server-level — if sendOTP is configured,
|
|
253
|
+
* any user with 2fa enabled can receive a code.
|
|
254
|
+
*/
|
|
255
|
+
if (options?.otpOptions?.sendOTP) twoFactorMethods.push("otp");
|
|
256
|
+
return ctx.json({
|
|
257
|
+
twoFactorRedirect: true,
|
|
258
|
+
twoFactorMethods
|
|
259
|
+
});
|
|
229
260
|
})
|
|
230
261
|
}] },
|
|
231
262
|
schema: mergeSchema(schema, {
|
|
@@ -175,11 +175,11 @@ const otp2fa = (options) => {
|
|
|
175
175
|
if (!session.session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
|
|
176
176
|
const updatedUser = await ctx.context.internalAdapter.updateUser(session.user.id, { twoFactorEnabled: true });
|
|
177
177
|
const newSession = await ctx.context.internalAdapter.createSession(session.user.id, false, session.session);
|
|
178
|
-
await ctx.context.internalAdapter.deleteSession(session.session.token);
|
|
179
178
|
await setSessionCookie(ctx, {
|
|
180
179
|
session: newSession,
|
|
181
180
|
user: updatedUser
|
|
182
181
|
});
|
|
182
|
+
await ctx.context.internalAdapter.deleteSession(session.session.token);
|
|
183
183
|
return ctx.json({
|
|
184
184
|
token: newSession.token,
|
|
185
185
|
user: parseUserOutput(ctx.context.options, updatedUser)
|
|
@@ -124,6 +124,7 @@ const totp2fa = (options) => {
|
|
|
124
124
|
}
|
|
125
125
|
const { session, valid, invalid } = await verifyTwoFactor(ctx);
|
|
126
126
|
const user = session.user;
|
|
127
|
+
const isSignIn = !session.session;
|
|
127
128
|
const twoFactor = await ctx.context.adapter.findOne({
|
|
128
129
|
model: twoFactorTable,
|
|
129
130
|
where: [{
|
|
@@ -132,6 +133,7 @@ const totp2fa = (options) => {
|
|
|
132
133
|
}]
|
|
133
134
|
});
|
|
134
135
|
if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
|
|
136
|
+
if (isSignIn && twoFactor.verified === false) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
|
|
135
137
|
if (!await createOTP(await symmetricDecrypt({
|
|
136
138
|
key: ctx.context.secretConfig,
|
|
137
139
|
data: twoFactor.secret
|
|
@@ -139,16 +141,23 @@ const totp2fa = (options) => {
|
|
|
139
141
|
period: opts.period,
|
|
140
142
|
digits: opts.digits
|
|
141
143
|
}).verify(ctx.body.code)) return invalid("INVALID_CODE");
|
|
142
|
-
if (
|
|
143
|
-
if (!
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
if (twoFactor.verified !== true) {
|
|
145
|
+
if (!user.twoFactorEnabled) {
|
|
146
|
+
const activeSession = session.session;
|
|
147
|
+
const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: true });
|
|
148
|
+
await setSessionCookie(ctx, {
|
|
149
|
+
session: await ctx.context.internalAdapter.createSession(user.id, false, activeSession),
|
|
150
|
+
user: updatedUser
|
|
151
|
+
});
|
|
152
|
+
await ctx.context.internalAdapter.deleteSession(activeSession.token);
|
|
153
|
+
}
|
|
154
|
+
await ctx.context.adapter.update({
|
|
155
|
+
model: twoFactorTable,
|
|
156
|
+
update: { verified: true },
|
|
157
|
+
where: [{
|
|
158
|
+
field: "id",
|
|
159
|
+
value: twoFactor.id
|
|
160
|
+
}]
|
|
152
161
|
});
|
|
153
162
|
}
|
|
154
163
|
return valid(ctx);
|
package/dist/state.d.mts
CHANGED
|
@@ -9,6 +9,11 @@ declare const stateDataSchema: z.ZodObject<{
|
|
|
9
9
|
errorURL: z.ZodOptional<z.ZodString>;
|
|
10
10
|
newUserURL: z.ZodOptional<z.ZodString>;
|
|
11
11
|
expiresAt: z.ZodNumber;
|
|
12
|
+
/**
|
|
13
|
+
* CSRF nonce returned to the OAuth provider. When using cookie state storage,
|
|
14
|
+
* this must match the callback `state` query parameter.
|
|
15
|
+
*/
|
|
16
|
+
oauthState: z.ZodOptional<z.ZodString>;
|
|
12
17
|
link: z.ZodOptional<z.ZodObject<{
|
|
13
18
|
email: z.ZodString;
|
|
14
19
|
userId: z.ZodCoercedString<unknown>;
|
|
@@ -32,6 +37,7 @@ declare function parseGenericState(c: GenericEndpointContext, state: string, set
|
|
|
32
37
|
expiresAt: number;
|
|
33
38
|
errorURL?: string | undefined;
|
|
34
39
|
newUserURL?: string | undefined;
|
|
40
|
+
oauthState?: string | undefined;
|
|
35
41
|
link?: {
|
|
36
42
|
email: string;
|
|
37
43
|
userId: string;
|
package/dist/state.mjs
CHANGED
|
@@ -10,6 +10,7 @@ const stateDataSchema = z.looseObject({
|
|
|
10
10
|
errorURL: z.string().optional(),
|
|
11
11
|
newUserURL: z.string().optional(),
|
|
12
12
|
expiresAt: z.number(),
|
|
13
|
+
oauthState: z.string().optional(),
|
|
13
14
|
link: z.object({
|
|
14
15
|
email: z.string(),
|
|
15
16
|
userId: z.coerce.string()
|
|
@@ -28,9 +29,13 @@ var StateError = class extends BetterAuthError {
|
|
|
28
29
|
async function generateGenericState(c, stateData, settings) {
|
|
29
30
|
const state = generateRandomString(32);
|
|
30
31
|
if (c.context.oauthConfig.storeStateStrategy === "cookie") {
|
|
32
|
+
const payload = {
|
|
33
|
+
...stateData,
|
|
34
|
+
oauthState: state
|
|
35
|
+
};
|
|
31
36
|
const encryptedData = await symmetricEncrypt({
|
|
32
37
|
key: c.context.secretConfig,
|
|
33
|
-
data: JSON.stringify(
|
|
38
|
+
data: JSON.stringify(payload)
|
|
34
39
|
});
|
|
35
40
|
const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "oauth_state", { maxAge: 600 });
|
|
36
41
|
c.setCookie(stateCookie.name, encryptedData, stateCookie.attributes);
|
|
@@ -44,7 +49,10 @@ async function generateGenericState(c, stateData, settings) {
|
|
|
44
49
|
const expiresAt = /* @__PURE__ */ new Date();
|
|
45
50
|
expiresAt.setMinutes(expiresAt.getMinutes() + 10);
|
|
46
51
|
if (!await c.context.internalAdapter.createVerificationValue({
|
|
47
|
-
value: JSON.stringify(
|
|
52
|
+
value: JSON.stringify({
|
|
53
|
+
...stateData,
|
|
54
|
+
oauthState: state
|
|
55
|
+
}),
|
|
48
56
|
identifier: state,
|
|
49
57
|
expiresAt
|
|
50
58
|
})) throw new StateError("Unable to create verification. Make sure the database adapter is properly working and there is a verification table in the database", { code: "state_generation_error" });
|
|
@@ -76,6 +84,10 @@ async function parseGenericState(c, state, settings) {
|
|
|
76
84
|
cause: error
|
|
77
85
|
});
|
|
78
86
|
}
|
|
87
|
+
if (!parsedData.oauthState || parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
|
|
88
|
+
code: "state_security_mismatch",
|
|
89
|
+
details: { state }
|
|
90
|
+
});
|
|
79
91
|
expireCookie(c, stateCookie);
|
|
80
92
|
} else {
|
|
81
93
|
const data = await c.context.internalAdapter.findVerificationValue(state);
|
|
@@ -84,6 +96,10 @@ async function parseGenericState(c, state, settings) {
|
|
|
84
96
|
details: { state }
|
|
85
97
|
});
|
|
86
98
|
parsedData = stateDataSchema.parse(JSON.parse(data.value));
|
|
99
|
+
if (parsedData.oauthState !== void 0 && parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
|
|
100
|
+
code: "state_security_mismatch",
|
|
101
|
+
details: { state }
|
|
102
|
+
});
|
|
87
103
|
const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "state");
|
|
88
104
|
const stateCookieValue = await c.getSignedCookie(stateCookie.name, c.context.secret);
|
|
89
105
|
if (!(settings?.skipStateCookieCheck ?? c.context.oauthConfig.skipStateCookieCheck) && (!stateCookieValue || stateCookieValue !== state)) throw new StateError("State mismatch: State not persisted correctly", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "better-auth",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "The most comprehensive authentication framework for TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -489,13 +489,13 @@
|
|
|
489
489
|
"kysely": "^0.28.14",
|
|
490
490
|
"nanostores": "^1.1.1",
|
|
491
491
|
"zod": "^4.3.6",
|
|
492
|
-
"@better-auth/core": "1.6.
|
|
493
|
-
"@better-auth/drizzle-adapter": "1.6.
|
|
494
|
-
"@better-auth/kysely-adapter": "1.6.
|
|
495
|
-
"@better-auth/memory-adapter": "1.6.
|
|
496
|
-
"@better-auth/mongo-adapter": "1.6.
|
|
497
|
-
"@better-auth/prisma-adapter": "1.6.
|
|
498
|
-
"@better-auth/telemetry": "1.6.
|
|
492
|
+
"@better-auth/core": "1.6.2",
|
|
493
|
+
"@better-auth/drizzle-adapter": "1.6.2",
|
|
494
|
+
"@better-auth/kysely-adapter": "1.6.2",
|
|
495
|
+
"@better-auth/memory-adapter": "1.6.2",
|
|
496
|
+
"@better-auth/mongo-adapter": "1.6.2",
|
|
497
|
+
"@better-auth/prisma-adapter": "1.6.2",
|
|
498
|
+
"@better-auth/telemetry": "1.6.2"
|
|
499
499
|
},
|
|
500
500
|
"devDependencies": {
|
|
501
501
|
"@lynx-js/react": "^0.116.3",
|