mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 23:52:05 -05:00
feat(one-time-token): support setting session cookie on ott verify (#3659)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -660,6 +660,8 @@ export const verifyPasskeyAuthentication = (options: RequiredPassKeyOptions) =>
|
||||
session: s,
|
||||
user,
|
||||
});
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(challengeId);
|
||||
|
||||
return ctx.json(
|
||||
{
|
||||
session: s,
|
||||
|
||||
Reference in New Issue
Block a user