mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
fix(2fa): server-side trust device expiration and configurable maxAge (#7644)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
committed by
Alex Yang
co-authored by
Alex Yang
parent
c6f8f3b270
commit
5d15bf4a0a
@@ -9,7 +9,11 @@ import { createOTP } from "@better-auth/utils/otp";
|
||||
import { APIError } from "better-call";
|
||||
import * as z from "zod";
|
||||
import { sessionMiddleware } from "../../api";
|
||||
import { deleteSessionCookie, setSessionCookie } from "../../cookies";
|
||||
import {
|
||||
deleteSessionCookie,
|
||||
expireCookie,
|
||||
setSessionCookie,
|
||||
} from "../../cookies";
|
||||
import { symmetricEncrypt } from "../../crypto";
|
||||
import { generateRandomString } from "../../crypto/random";
|
||||
import { mergeSchema } from "../../db/schema";
|
||||
@@ -51,6 +55,8 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
|
||||
const opts = {
|
||||
twoFactorTable: "twoFactor",
|
||||
};
|
||||
const trustDeviceMaxAge =
|
||||
options?.trustDeviceMaxAge ?? TRUST_DEVICE_COOKIE_MAX_AGE;
|
||||
const backupCodeOptions = {
|
||||
storeBackupCodes: "encrypted",
|
||||
...options?.backupCodeOptions,
|
||||
@@ -280,6 +286,25 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
|
||||
await ctx.context.internalAdapter.deleteSession(
|
||||
ctx.context.session.session.token,
|
||||
);
|
||||
const disableTrustCookie = ctx.context.createAuthCookie(
|
||||
TRUST_DEVICE_COOKIE_NAME,
|
||||
{
|
||||
maxAge: trustDeviceMaxAge,
|
||||
},
|
||||
);
|
||||
const disableTrustValue = await ctx.getSignedCookie(
|
||||
disableTrustCookie.name,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (disableTrustValue) {
|
||||
const [, trustId] = disableTrustValue.split("!");
|
||||
if (trustId) {
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
trustId,
|
||||
);
|
||||
}
|
||||
expireCookie(ctx, disableTrustCookie);
|
||||
}
|
||||
return ctx.json({ status: true });
|
||||
},
|
||||
),
|
||||
@@ -308,7 +333,7 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
|
||||
const trustDeviceCookieAttrs = ctx.context.createAuthCookie(
|
||||
TRUST_DEVICE_COOKIE_NAME,
|
||||
{
|
||||
maxAge: TRUST_DEVICE_COOKIE_MAX_AGE,
|
||||
maxAge: trustDeviceMaxAge,
|
||||
},
|
||||
);
|
||||
// Check for trust device cookie
|
||||
@@ -318,36 +343,62 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
|
||||
);
|
||||
|
||||
if (trustDeviceCookie) {
|
||||
const [token, sessionToken] = trustDeviceCookie.split("!");
|
||||
const expectedToken = await createHMAC(
|
||||
"SHA-256",
|
||||
"base64urlnopad",
|
||||
).sign(ctx.context.secret, `${data.user.id}!${sessionToken}`);
|
||||
|
||||
// Checks if the token is signed correctly, not that its the current session token
|
||||
if (token === expectedToken) {
|
||||
// Trust device cookie is valid, refresh it and skip 2FA
|
||||
const newTrustDeviceCookie = ctx.context.createAuthCookie(
|
||||
TRUST_DEVICE_COOKIE_NAME,
|
||||
{
|
||||
maxAge: TRUST_DEVICE_COOKIE_MAX_AGE,
|
||||
},
|
||||
);
|
||||
const newToken = await createHMAC(
|
||||
const [token, trustIdentifier] = trustDeviceCookie.split("!");
|
||||
if (token && trustIdentifier) {
|
||||
const expectedToken = await createHMAC(
|
||||
"SHA-256",
|
||||
"base64urlnopad",
|
||||
).sign(
|
||||
ctx.context.secret,
|
||||
`${data.user.id}!${data.session.token}`,
|
||||
`${data.user.id}!${trustIdentifier}`,
|
||||
);
|
||||
await ctx.setSignedCookie(
|
||||
newTrustDeviceCookie.name,
|
||||
`${newToken}!${data.session.token}`,
|
||||
ctx.context.secret,
|
||||
trustDeviceCookieAttrs.attributes,
|
||||
);
|
||||
return;
|
||||
|
||||
if (token === expectedToken) {
|
||||
// HMAC is valid; verify the server-side record
|
||||
const verificationRecord =
|
||||
await ctx.context.internalAdapter.findVerificationValue(
|
||||
trustIdentifier,
|
||||
);
|
||||
if (
|
||||
verificationRecord &&
|
||||
verificationRecord.value === data.user.id &&
|
||||
verificationRecord.expiresAt > new Date()
|
||||
) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationRecord.id,
|
||||
);
|
||||
const newTrustIdentifier = `trust-device-${generateRandomString(32)}`;
|
||||
const newToken = await createHMAC(
|
||||
"SHA-256",
|
||||
"base64urlnopad",
|
||||
).sign(
|
||||
ctx.context.secret,
|
||||
`${data.user.id}!${newTrustIdentifier}`,
|
||||
);
|
||||
await ctx.context.internalAdapter.createVerificationValue({
|
||||
value: data.user.id,
|
||||
identifier: newTrustIdentifier,
|
||||
expiresAt: new Date(
|
||||
Date.now() + trustDeviceMaxAge * 1000,
|
||||
),
|
||||
});
|
||||
const newTrustDeviceCookie = ctx.context.createAuthCookie(
|
||||
TRUST_DEVICE_COOKIE_NAME,
|
||||
{
|
||||
maxAge: trustDeviceMaxAge,
|
||||
},
|
||||
);
|
||||
await ctx.setSignedCookie(
|
||||
newTrustDeviceCookie.name,
|
||||
`${newToken}!${newTrustIdentifier}`,
|
||||
ctx.context.secret,
|
||||
trustDeviceCookieAttrs.attributes,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
expireCookie(ctx, trustDeviceCookieAttrs);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -437,7 +437,8 @@ describe("two factor", async () => {
|
||||
});
|
||||
expect(signInRes.data?.user).toBeDefined();
|
||||
|
||||
// Should still work with original headers
|
||||
// Old trust device cookie should no longer work because the
|
||||
// server-side record was rotated on sign-in
|
||||
const signIn2Res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
@@ -445,9 +446,9 @@ describe("two factor", async () => {
|
||||
headers: newHeaders,
|
||||
},
|
||||
});
|
||||
expect(signIn2Res.data?.user).toBeDefined();
|
||||
expect((signIn2Res.data as any)?.twoFactorRedirect).toBe(true);
|
||||
|
||||
// Should work with updated headers
|
||||
// Should work with the refreshed (rotated) headers
|
||||
const signIn3Res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
@@ -848,6 +849,468 @@ describe("view backup codes", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("trust device server-side validation", async () => {
|
||||
let OTP = "";
|
||||
const { auth, signInWithTestUser, testUser, db } = await getTestInstance({
|
||||
secret: DEFAULT_SECRET,
|
||||
plugins: [
|
||||
twoFactor({
|
||||
otpOptions: {
|
||||
sendOTP({ otp }) {
|
||||
OTP = otp;
|
||||
},
|
||||
},
|
||||
skipVerificationOnEnable: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
let { headers } = await signInWithTestUser();
|
||||
|
||||
it("should force 2FA when server-side trust record is expired", async () => {
|
||||
// Enable 2FA
|
||||
const enableRes = await auth.api.enableTwoFactor({
|
||||
body: { password: testUser.password },
|
||||
headers,
|
||||
asResponse: true,
|
||||
});
|
||||
headers = convertSetCookieToCookie(enableRes.headers);
|
||||
|
||||
// Sign in to trigger 2FA
|
||||
const signInRes = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
|
||||
|
||||
// Send and verify OTP with trustDevice
|
||||
await auth.api.sendTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {},
|
||||
});
|
||||
|
||||
const verifyRes = await auth.api.verifyTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {
|
||||
code: OTP,
|
||||
trustDevice: true,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
expect(verifyRes.status).toBe(200);
|
||||
|
||||
// Extract the trust device cookie
|
||||
const parsed = parseSetCookieHeader(
|
||||
verifyRes.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const trustDeviceCookieValue = parsed.get(
|
||||
"better-auth.trust_device",
|
||||
)?.value;
|
||||
expect(trustDeviceCookieValue).toBeDefined();
|
||||
|
||||
// The cookie value is signed: "value.signature" where value is "token!trustIdentifier"
|
||||
// Extract the unsigned value (everything before the last dot)
|
||||
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
|
||||
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
|
||||
const [, trustIdentifier] = unsignedValue.split("!");
|
||||
expect(trustIdentifier).toBeDefined();
|
||||
|
||||
const verificationRecord = await db.findOne<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
value: string;
|
||||
expiresAt: Date;
|
||||
}>({
|
||||
model: "verification",
|
||||
where: [{ field: "identifier", value: trustIdentifier! }],
|
||||
});
|
||||
expect(verificationRecord).toBeDefined();
|
||||
|
||||
// Manually expire the record by setting expiresAt to the past
|
||||
await db.update({
|
||||
model: "verification",
|
||||
where: [{ field: "id", value: verificationRecord!.id }],
|
||||
update: {
|
||||
expiresAt: new Date(Date.now() - 1000), // 1 second ago
|
||||
},
|
||||
});
|
||||
|
||||
// Now sign in with the trust device cookie - should require 2FA again
|
||||
const trustHeaders = new Headers();
|
||||
trustHeaders.set(
|
||||
"cookie",
|
||||
`better-auth.trust_device=${trustDeviceCookieValue}`,
|
||||
);
|
||||
|
||||
const signIn2Res = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
headers: trustHeaders,
|
||||
asResponse: true,
|
||||
});
|
||||
|
||||
const signIn2Json = (await signIn2Res.json()) as {
|
||||
twoFactorRedirect?: boolean;
|
||||
};
|
||||
expect(signIn2Json.twoFactorRedirect).toBe(true);
|
||||
|
||||
// The expired trust cookie should have been cleared
|
||||
const signIn2Parsed = parseSetCookieHeader(
|
||||
signIn2Res.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const clearedTrustCookie = signIn2Parsed.get("better-auth.trust_device");
|
||||
expect(clearedTrustCookie?.value).toBe("");
|
||||
});
|
||||
|
||||
it("should preserve trust device after sign-out", async () => {
|
||||
// Sign in to trigger 2FA
|
||||
const signInRes = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
|
||||
|
||||
// Send and verify OTP with trustDevice
|
||||
await auth.api.sendTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {},
|
||||
});
|
||||
|
||||
const verifyRes = await auth.api.verifyTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {
|
||||
code: OTP,
|
||||
trustDevice: true,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
expect(verifyRes.status).toBe(200);
|
||||
const sessionHeaders = convertSetCookieToCookie(verifyRes.headers);
|
||||
|
||||
// Extract the trust device cookie
|
||||
const parsed = parseSetCookieHeader(
|
||||
verifyRes.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const trustDeviceCookieValue = parsed.get(
|
||||
"better-auth.trust_device",
|
||||
)?.value;
|
||||
expect(trustDeviceCookieValue).toBeDefined();
|
||||
|
||||
// Extract trust identifier (cookie is signed: "value.signature")
|
||||
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
|
||||
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
|
||||
const [, trustIdentifier] = unsignedValue.split("!");
|
||||
expect(trustIdentifier).toBeDefined();
|
||||
|
||||
const verificationBefore = await db.findOne<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
value: string;
|
||||
expiresAt: Date;
|
||||
}>({
|
||||
model: "verification",
|
||||
where: [{ field: "identifier", value: trustIdentifier! }],
|
||||
});
|
||||
expect(verificationBefore).toBeDefined();
|
||||
|
||||
// Sign out
|
||||
const signOutRes = await auth.api.signOut({
|
||||
headers: sessionHeaders,
|
||||
asResponse: true,
|
||||
});
|
||||
expect(signOutRes.status).toBe(200);
|
||||
|
||||
// Verify the trust device cookie was NOT cleared (trust survives sign-out)
|
||||
const signOutParsed = parseSetCookieHeader(
|
||||
signOutRes.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const trustCookieAfterSignOut = signOutParsed.get(
|
||||
"better-auth.trust_device",
|
||||
);
|
||||
// Cookie should either not be set (unchanged) or still have its value
|
||||
expect(trustCookieAfterSignOut?.value || "preserved").not.toBe("");
|
||||
|
||||
// Verify the DB record still exists
|
||||
const verificationAfter = await db.findOne<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
value: string;
|
||||
expiresAt: Date;
|
||||
}>({
|
||||
model: "verification",
|
||||
where: [{ field: "identifier", value: trustIdentifier! }],
|
||||
});
|
||||
expect(verificationAfter).toBeDefined();
|
||||
|
||||
// Sign in again with the trust device cookie - should skip 2FA
|
||||
const trustHeaders = new Headers();
|
||||
trustHeaders.set(
|
||||
"cookie",
|
||||
`better-auth.trust_device=${trustDeviceCookieValue}`,
|
||||
);
|
||||
|
||||
const signIn2Res = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
headers: trustHeaders,
|
||||
asResponse: true,
|
||||
});
|
||||
|
||||
const signIn2Json = (await signIn2Res.json()) as {
|
||||
user?: { email: string };
|
||||
};
|
||||
// Should NOT require 2FA - trust device survived sign-out
|
||||
expect(signIn2Json.user?.email).toBe(testUser.email);
|
||||
});
|
||||
|
||||
it("should revoke trust device when disabling 2FA", async () => {
|
||||
// Sign in to trigger 2FA
|
||||
const signInRes = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
|
||||
|
||||
// Send and verify OTP with trustDevice
|
||||
await auth.api.sendTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {},
|
||||
});
|
||||
|
||||
const verifyRes = await auth.api.verifyTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {
|
||||
code: OTP,
|
||||
trustDevice: true,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
expect(verifyRes.status).toBe(200);
|
||||
const sessionHeaders = convertSetCookieToCookie(verifyRes.headers);
|
||||
|
||||
// Extract the trust device cookie
|
||||
const parsed = parseSetCookieHeader(
|
||||
verifyRes.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const trustDeviceCookieValue = parsed.get(
|
||||
"better-auth.trust_device",
|
||||
)?.value;
|
||||
expect(trustDeviceCookieValue).toBeDefined();
|
||||
|
||||
// Extract trust identifier (cookie is signed: "value.signature")
|
||||
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
|
||||
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
|
||||
const [, trustIdentifier] = unsignedValue.split("!");
|
||||
expect(trustIdentifier).toBeDefined();
|
||||
|
||||
const verificationBefore = await db.findOne<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
value: string;
|
||||
expiresAt: Date;
|
||||
}>({
|
||||
model: "verification",
|
||||
where: [{ field: "identifier", value: trustIdentifier! }],
|
||||
});
|
||||
expect(verificationBefore).toBeDefined();
|
||||
|
||||
// Disable 2FA
|
||||
const disableRes = await auth.api.disableTwoFactor({
|
||||
headers: sessionHeaders,
|
||||
body: {
|
||||
password: testUser.password,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
expect(disableRes.status).toBe(200);
|
||||
|
||||
// Verify the trust device cookie was cleared
|
||||
const disableParsed = parseSetCookieHeader(
|
||||
disableRes.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const clearedCookie = disableParsed.get("better-auth.trust_device");
|
||||
expect(clearedCookie?.value).toBe("");
|
||||
|
||||
// Verify the DB record was deleted
|
||||
const verificationAfter = await db.findOne<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
value: string;
|
||||
expiresAt: Date;
|
||||
}>({
|
||||
model: "verification",
|
||||
where: [{ field: "identifier", value: trustIdentifier! }],
|
||||
});
|
||||
expect(verificationAfter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trustDeviceMaxAge", async () => {
|
||||
const customMaxAge = 7 * 24 * 60 * 60; // 7 days instead of default 30
|
||||
|
||||
let OTP = "";
|
||||
const { auth, signInWithTestUser, testUser, db } = await getTestInstance({
|
||||
secret: DEFAULT_SECRET,
|
||||
plugins: [
|
||||
twoFactor({
|
||||
otpOptions: {
|
||||
sendOTP({ otp }) {
|
||||
OTP = otp;
|
||||
},
|
||||
},
|
||||
skipVerificationOnEnable: true,
|
||||
trustDeviceMaxAge: customMaxAge,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
let { headers } = await signInWithTestUser();
|
||||
|
||||
it("should use custom trustDeviceMaxAge for the trust device cookie", async () => {
|
||||
// Enable 2FA
|
||||
const enableRes = await auth.api.enableTwoFactor({
|
||||
body: { password: testUser.password },
|
||||
headers,
|
||||
asResponse: true,
|
||||
});
|
||||
headers = convertSetCookieToCookie(enableRes.headers);
|
||||
|
||||
// Sign in to trigger 2FA
|
||||
const signInRes = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
|
||||
|
||||
// Send and verify OTP with trustDevice
|
||||
await auth.api.sendTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {},
|
||||
});
|
||||
|
||||
const verifyRes = await auth.api.verifyTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {
|
||||
code: OTP,
|
||||
trustDevice: true,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
expect(verifyRes.status).toBe(200);
|
||||
|
||||
// Verify the trust device cookie has the custom max-age
|
||||
const parsed = parseSetCookieHeader(
|
||||
verifyRes.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const trustDeviceCookie = parsed.get("better-auth.trust_device");
|
||||
expect(trustDeviceCookie).toBeDefined();
|
||||
expect(Number(trustDeviceCookie?.["max-age"])).toBe(customMaxAge);
|
||||
|
||||
// Also verify the DB record has the correct expiration
|
||||
const trustDeviceCookieValue = trustDeviceCookie?.value;
|
||||
// Extract trust identifier (cookie is signed: "value.signature")
|
||||
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
|
||||
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
|
||||
const [, trustIdentifier] = unsignedValue.split("!");
|
||||
expect(trustIdentifier).toBeDefined();
|
||||
|
||||
const verificationRecord = await db.findOne<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
value: string;
|
||||
expiresAt: Date;
|
||||
}>({
|
||||
model: "verification",
|
||||
where: [{ field: "identifier", value: trustIdentifier! }],
|
||||
});
|
||||
expect(verificationRecord).toBeDefined();
|
||||
|
||||
// Expiration should be approximately customMaxAge seconds from now
|
||||
const expiresAt = new Date(verificationRecord!.expiresAt).getTime();
|
||||
const expectedExpiry = Date.now() + customMaxAge * 1000;
|
||||
// Allow 5 second tolerance for test execution time
|
||||
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it("should use default 30 days when trustDeviceMaxAge is not specified", async () => {
|
||||
let defaultOTP = "";
|
||||
const { auth: authDefault, signInWithTestUser: signInDefault } =
|
||||
await getTestInstance({
|
||||
secret: DEFAULT_SECRET,
|
||||
plugins: [
|
||||
twoFactor({
|
||||
otpOptions: {
|
||||
sendOTP({ otp }) {
|
||||
defaultOTP = otp;
|
||||
},
|
||||
},
|
||||
skipVerificationOnEnable: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { headers: defaultHeaders } = await signInDefault();
|
||||
|
||||
// Enable 2FA
|
||||
await authDefault.api.enableTwoFactor({
|
||||
body: { password: testUser.password },
|
||||
headers: defaultHeaders,
|
||||
asResponse: true,
|
||||
});
|
||||
|
||||
// Sign in to trigger 2FA
|
||||
const signInRes = await authDefault.api.signInEmail({
|
||||
body: {
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
|
||||
|
||||
// Send and verify OTP with trustDevice
|
||||
await authDefault.api.sendTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {},
|
||||
});
|
||||
|
||||
const verifyRes = await authDefault.api.verifyTwoFactorOTP({
|
||||
headers: twoFactorHeaders,
|
||||
body: {
|
||||
code: defaultOTP,
|
||||
trustDevice: true,
|
||||
},
|
||||
asResponse: true,
|
||||
});
|
||||
expect(verifyRes.status).toBe(200);
|
||||
|
||||
// Verify the trust device cookie has the default max-age (30 days)
|
||||
const parsed = parseSetCookieHeader(
|
||||
verifyRes.headers.get("Set-Cookie") || "",
|
||||
);
|
||||
const trustDeviceCookie = parsed.get("better-auth.trust_device");
|
||||
expect(trustDeviceCookie).toBeDefined();
|
||||
// Default is 30 days = 30 * 24 * 60 * 60 = 2592000 seconds
|
||||
expect(Number(trustDeviceCookie?.["max-age"])).toBe(30 * 24 * 60 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("twoFactorCookieMaxAge", async () => {
|
||||
const customMaxAge = 15 * 60; // 15 minutes
|
||||
const { auth, signInWithTestUser, testUser } = await getTestInstance({
|
||||
|
||||
@@ -39,6 +39,14 @@ export interface TwoFactorOptions {
|
||||
* @default 600 (10 minutes)
|
||||
*/
|
||||
twoFactorCookieMaxAge?: number | undefined;
|
||||
/**
|
||||
* Maximum age (in seconds) for the trusted device cookie.
|
||||
* When a user opts to trust a device, this controls how long
|
||||
* the device stays trusted before requiring 2FA again.
|
||||
*
|
||||
* @default 2592000 (30 days)
|
||||
*/
|
||||
trustDeviceMaxAge?: number | undefined;
|
||||
}
|
||||
|
||||
export interface UserWithTwoFactor extends User {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHMAC } from "@better-auth/utils/hmac";
|
||||
import { APIError } from "better-call";
|
||||
import { getSessionFromCtx } from "../../api";
|
||||
import { expireCookie, setSessionCookie } from "../../cookies";
|
||||
import { generateRandomString } from "../../crypto/random";
|
||||
import { parseUserOutput } from "../../db/schema";
|
||||
import {
|
||||
TRUST_DEVICE_COOKIE_MAX_AGE,
|
||||
@@ -76,23 +77,33 @@ export async function verifyTwoFactor(ctx: GenericEndpointContext) {
|
||||
// Always clear the two factor cookie after successful verification
|
||||
expireCookie(ctx, twoFactorCookie);
|
||||
if (ctx.body.trustDevice) {
|
||||
const plugin = ctx.context.getPlugin("two-factor");
|
||||
const trustDeviceMaxAge = plugin!.options!.trustDeviceMaxAge;
|
||||
const maxAge = trustDeviceMaxAge ?? TRUST_DEVICE_COOKIE_MAX_AGE;
|
||||
const trustDeviceCookie = ctx.context.createAuthCookie(
|
||||
TRUST_DEVICE_COOKIE_NAME,
|
||||
{
|
||||
maxAge: TRUST_DEVICE_COOKIE_MAX_AGE,
|
||||
maxAge,
|
||||
},
|
||||
);
|
||||
/**
|
||||
* create a token that will be used to
|
||||
* verify the device
|
||||
* Create a random identifier for the trust device record.
|
||||
* Store it in the verification table with an expiration
|
||||
* so the server can validate and revoke it.
|
||||
*/
|
||||
const trustIdentifier = `trust-device-${generateRandomString(32)}`;
|
||||
const token = await createHMAC("SHA-256", "base64urlnopad").sign(
|
||||
ctx.context.secret,
|
||||
`${user.id}!${session.token}`,
|
||||
`${user.id}!${trustIdentifier}`,
|
||||
);
|
||||
await ctx.context.internalAdapter.createVerificationValue({
|
||||
value: user.id,
|
||||
identifier: trustIdentifier,
|
||||
expiresAt: new Date(Date.now() + maxAge * 1000),
|
||||
});
|
||||
await ctx.setSignedCookie(
|
||||
trustDeviceCookie.name,
|
||||
`${token}!${session.token}`,
|
||||
`${token}!${trustIdentifier}`,
|
||||
ctx.context.secret,
|
||||
trustDeviceCookie.attributes,
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function generateGenericState(
|
||||
const stateCookie = c.context.createAuthCookie(
|
||||
settings?.cookieName ?? "oauth_state",
|
||||
{
|
||||
maxAge: 10 * 60 * 1000, // 10 minutes
|
||||
maxAge: 10 * 60, // 10 minutes
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function generateGenericState(
|
||||
const stateCookie = c.context.createAuthCookie(
|
||||
settings?.cookieName ?? "state",
|
||||
{
|
||||
maxAge: 5 * 60 * 1000, // 5 minutes
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const expoAuthorizationProxy = createAuthEndpoint(
|
||||
const { oauthState } = ctx.query;
|
||||
if (oauthState) {
|
||||
const oauthStateCookie = ctx.context.createAuthCookie("oauth_state", {
|
||||
maxAge: 10 * 60 * 1000, // 10 minutes
|
||||
maxAge: 10 * 60, // 10 minutes
|
||||
});
|
||||
ctx.setCookie(
|
||||
oauthStateCookie.name,
|
||||
@@ -35,7 +35,7 @@ export const expoAuthorizationProxy = createAuthEndpoint(
|
||||
});
|
||||
}
|
||||
const stateCookie = ctx.context.createAuthCookie("state", {
|
||||
maxAge: 5 * 60 * 1000, // 5 minutes
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
});
|
||||
await ctx.setSignedCookie(
|
||||
stateCookie.name,
|
||||
|
||||
Reference in New Issue
Block a user