feat(one-time-token): support setting session cookie on ott verify (#3659)

This commit is contained in:
Bereket Engida
2025-12-13 22:45:21 -08:00
committed by GitHub
parent 42f2c5c489
commit 08e057c19d
3 changed files with 235 additions and 13 deletions

View File

@@ -2,9 +2,13 @@ import type {
BetterAuthPlugin,
GenericEndpointContext,
} from "@better-auth/core";
import { createAuthEndpoint } from "@better-auth/core/api";
import {
createAuthEndpoint,
createAuthMiddleware,
} from "@better-auth/core/api";
import * as z from "zod";
import { sessionMiddleware } from "../../api";
import { setSessionCookie } from "../../cookies";
import { generateRandomString } from "../../crypto";
import type { Session, User } from "../../types";
import { defaultKeyHasher } from "./utils";
@@ -32,6 +36,10 @@ export interface OneTimeTokenOptions {
ctx: GenericEndpointContext,
) => Promise<string>)
| undefined;
/**
* Disable setting the session cookie when the token is verified
*/
disableSetSessionCookie?: boolean;
/**
* This option allows you to configure how the token is stored in your database.
* Note: This will not affect the token that's sent, it will only affect the token stored in your database.
@@ -45,6 +53,10 @@ export interface OneTimeTokenOptions {
| { type: "custom-hasher"; hash: (token: string) => Promise<string> }
)
| undefined;
/**
* Set the OTT header on new sessions
*/
setOttHeaderOnNewSession?: boolean;
}
const verifyOneTimeTokenBodySchema = z.object({
@@ -74,6 +86,26 @@ export const oneTimeToken = (options?: OneTimeTokenOptions | undefined) => {
return token;
}
async function generateToken(
c: GenericEndpointContext,
session: {
session: Session;
user: User;
},
) {
const token = opts?.generateToken
? await opts.generateToken(session, c)
: generateRandomString(32);
const expiresAt = new Date(Date.now() + (opts?.expiresIn ?? 3) * 60 * 1000);
const storedToken = await storeToken(c, token);
await c.context.internalAdapter.createVerificationValue({
value: session.session.token,
identifier: `one-time-token:${storedToken}`,
expiresAt,
});
return token;
}
return {
id: "one-time-token",
endpoints: {
@@ -106,18 +138,7 @@ export const oneTimeToken = (options?: OneTimeTokenOptions | undefined) => {
});
}
const session = c.context.session;
const token = opts?.generateToken
? await opts.generateToken(session, c)
: generateRandomString(32);
const expiresAt = new Date(
Date.now() + (opts?.expiresIn ?? 3) * 60 * 1000,
);
const storedToken = await storeToken(c, token);
await c.context.internalAdapter.createVerificationValue({
value: session.session.token,
identifier: `one-time-token:${storedToken}`,
expiresAt,
});
const token = await generateToken(c, session);
return c.json({ token });
},
),
@@ -170,6 +191,9 @@ export const oneTimeToken = (options?: OneTimeTokenOptions | undefined) => {
message: "Session not found",
});
}
if (!opts?.disableSetSessionCookie) {
await setSessionCookie(c, session);
}
if (session.session.expiresAt < new Date()) {
throw c.error("BAD_REQUEST", {
@@ -181,5 +205,36 @@ export const oneTimeToken = (options?: OneTimeTokenOptions | undefined) => {
},
),
},
hooks: {
after: [
{
matcher: () => true,
handler: createAuthMiddleware(async (ctx) => {
if (ctx.context.newSession) {
if (!opts?.setOttHeaderOnNewSession) {
return;
}
const exposedHeaders =
ctx.context.responseHeaders?.get(
"access-control-expose-headers",
) || "";
const headersSet = new Set(
exposedHeaders
.split(",")
.map((header) => header.trim())
.filter(Boolean),
);
headersSet.add("set-ott");
const token = await generateToken(ctx, ctx.context.newSession);
ctx.setHeader("set-ott", token);
ctx.setHeader(
"Access-Control-Expose-Headers",
Array.from(headersSet).join(", "),
);
}
}),
},
],
},
} satisfies BetterAuthPlugin;
};

View File

@@ -196,4 +196,169 @@ describe("One-time token", async () => {
});
});
});
describe("disableClientRequest option", async () => {
const { auth, signInWithTestUser, client } = await getTestInstance(
{
plugins: [
oneTimeToken({
disableClientRequest: true,
}),
],
},
{
clientOptions: {
plugins: [oneTimeTokenClient()],
},
},
);
it("should allow server-side requests", async () => {
const { headers } = await signInWithTestUser();
const response = await auth.api.generateOneTimeToken({
headers,
});
expect(response.token).toBeDefined();
});
it("should reject client requests when disableClientRequest is true", async () => {
const { headers } = await signInWithTestUser();
const shouldFail = await client.oneTimeToken.generate({
fetchOptions: {
headers,
},
});
expect(shouldFail.error?.message).toBe("Client requests are disabled");
});
});
describe("disableSetSessionCookie option", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
oneTimeToken({
disableSetSessionCookie: true,
}),
],
});
it("should not set session cookie when disableSetSessionCookie is true", async () => {
const { headers } = await signInWithTestUser();
const response = await auth.api.generateOneTimeToken({
headers,
});
expect(response.token).toBeDefined();
const verifyResponse = await auth.api.verifyOneTimeToken({
body: {
token: response.token,
},
asResponse: true,
});
const setCookieHeader = verifyResponse.headers.get("set-cookie");
expect(setCookieHeader).toBeNull();
});
it("should set session cookie by default", async () => {
const defaultInstance = await getTestInstance({
plugins: [oneTimeToken()],
});
const { headers } = await defaultInstance.signInWithTestUser();
const response = await defaultInstance.auth.api.generateOneTimeToken({
headers,
});
const verifyResponse = await defaultInstance.auth.api.verifyOneTimeToken({
body: {
token: response.token,
},
asResponse: true,
});
const setCookieHeader = verifyResponse.headers.get("set-cookie");
expect(setCookieHeader).toBeDefined();
expect(setCookieHeader).toContain("better-auth.session_token");
});
});
describe("setOttHeaderOnNewSession option", async () => {
it("should set OTT header on new session when enabled", async () => {
const testInstance = await getTestInstance({
plugins: [
oneTimeToken({
setOttHeaderOnNewSession: true,
}),
],
});
const response = await testInstance.auth.api.signUpEmail({
body: {
email: "ott-header-test@test.com",
password: "password123",
name: "OTT Header Test",
},
asResponse: true,
});
const ottHeader = response.headers.get("set-ott");
expect(ottHeader).toBeDefined();
expect(ottHeader).toHaveLength(32);
const exposeHeaders = response.headers.get(
"access-control-expose-headers",
);
expect(exposeHeaders).toContain("set-ott");
});
it("should not set OTT header on new session by default", async () => {
const testInstance = await getTestInstance({
plugins: [oneTimeToken()],
});
const response = await testInstance.auth.api.signUpEmail({
body: {
email: "ott-header-test-default@test.com",
password: "password123",
name: "OTT Header Test Default",
},
asResponse: true,
});
const ottHeader = response.headers.get("set-ott");
expect(ottHeader).toBeNull();
});
it("should set OTT header on sign in when enabled", async () => {
const testInstance = await getTestInstance({
plugins: [
oneTimeToken({
setOttHeaderOnNewSession: true,
}),
],
});
// First create a user
await testInstance.auth.api.signUpEmail({
body: {
email: "ott-signin-test@test.com",
password: "password123",
name: "OTT SignIn Test",
},
});
// Then sign in
const response = await testInstance.auth.api.signInEmail({
body: {
email: "ott-signin-test@test.com",
password: "password123",
},
asResponse: true,
});
const ottHeader = response.headers.get("set-ott");
expect(ottHeader).toBeDefined();
expect(ottHeader).toHaveLength(32);
});
});
});

View File

@@ -660,6 +660,8 @@ export const verifyPasskeyAuthentication = (options: RequiredPassKeyOptions) =>
session: s,
user,
});
await ctx.context.internalAdapter.deleteVerificationValue(challengeId);
return ctx.json(
{
session: s,