diff --git a/packages/better-auth/src/plugins/one-time-token/index.ts b/packages/better-auth/src/plugins/one-time-token/index.ts index 13a1102285..dc87f35b70 100644 --- a/packages/better-auth/src/plugins/one-time-token/index.ts +++ b/packages/better-auth/src/plugins/one-time-token/index.ts @@ -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) | 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 } ) | 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; }; diff --git a/packages/better-auth/src/plugins/one-time-token/one-time-token.test.ts b/packages/better-auth/src/plugins/one-time-token/one-time-token.test.ts index 0b5c2ca249..700fa70b58 100644 --- a/packages/better-auth/src/plugins/one-time-token/one-time-token.test.ts +++ b/packages/better-auth/src/plugins/one-time-token/one-time-token.test.ts @@ -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); + }); + }); }); diff --git a/packages/passkey/src/routes.ts b/packages/passkey/src/routes.ts index 71c49ee092..e5466dd1ab 100644 --- a/packages/passkey/src/routes.ts +++ b/packages/passkey/src/routes.ts @@ -660,6 +660,8 @@ export const verifyPasskeyAuthentication = (options: RequiredPassKeyOptions) => session: s, user, }); + await ctx.context.internalAdapter.deleteVerificationValue(challengeId); + return ctx.json( { session: s,