better-auth
Version:
The most comprehensive authentication framework for TypeScript.
510 lines (509 loc) • 20.1 kB
JavaScript
import { isAPIError } from "../../utils/is-api-error.mjs";
import { hasServerSessionStore } from "../../context/store-capabilities.mjs";
import { symmetricDecodeJWT, verifyJWT } from "../../crypto/jwt.mjs";
import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
import { getDate } from "../../utils/date.mjs";
import { getChunkedCookie, getSessionQuerySchema } from "../../cookies/session-store.mjs";
import { deleteSessionCookie, expireCookie, setCookieCache, setSessionCookie } from "../../cookies/index.mjs";
import { getShouldSkipSessionRefresh } from "../state/should-session-refresh.mjs";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
import * as z from "zod";
import { base64Url } from "@better-auth/utils/base64";
import { binary } from "@better-auth/utils/binary";
import { createHMAC } from "@better-auth/utils/hmac";
//#region src/api/routes/session.ts
const getSession = () => createAuthEndpoint("/get-session", {
method: ["GET", "POST"],
operationId: "getSession",
query: getSessionQuerySchema,
requireHeaders: true,
metadata: { openapi: {
operationId: "getSession",
description: "Get the current session",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: ["object", "null"],
properties: {
session: { $ref: "#/components/schemas/Session" },
user: { $ref: "#/components/schemas/User" }
},
required: ["session", "user"]
} } }
} }
} }
}, async (ctx) => {
ctx.setHeader("cache-control", "no-store");
ctx.setHeader("pragma", "no-cache");
const deferSessionRefresh = ctx.context.options.session?.deferSessionRefresh;
const isPostRequest = ctx.method === "POST";
if (isPostRequest && !deferSessionRefresh) throw APIError.from("METHOD_NOT_ALLOWED", BASE_ERROR_CODES.METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED);
try {
const sessionCookieToken = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
if (!sessionCookieToken) return null;
const sessionDataCookie = getChunkedCookie(ctx, ctx.context.authCookies.sessionData.name);
let sessionDataPayload = null;
if (sessionDataCookie) {
const strategy = ctx.context.options.session?.cookieCache?.strategy || "compact";
if (strategy === "jwe") {
const payload = await symmetricDecodeJWT(sessionDataCookie, ctx.context.secretConfig, "better-auth-session");
if (payload && payload.session && payload.user) sessionDataPayload = {
session: {
session: payload.session,
user: payload.user,
updatedAt: payload.updatedAt,
version: payload.version
},
expiresAt: payload.exp ? payload.exp * 1e3 : Date.now()
};
else expireCookie(ctx, ctx.context.authCookies.sessionData);
} else if (strategy === "jwt") {
const payload = await verifyJWT(sessionDataCookie, ctx.context.secret);
if (payload && payload.session && payload.user) sessionDataPayload = {
session: {
session: payload.session,
user: payload.user,
updatedAt: payload.updatedAt,
version: payload.version
},
expiresAt: payload.exp ? payload.exp * 1e3 : Date.now()
};
else expireCookie(ctx, ctx.context.authCookies.sessionData);
} else {
const parsed = safeJSONParse(binary.decode(base64Url.decode(sessionDataCookie)));
if (parsed) if (await createHMAC("SHA-256", "base64urlnopad").verify(ctx.context.secret, JSON.stringify({
...parsed.session,
expiresAt: parsed.expiresAt
}), parsed.signature)) sessionDataPayload = parsed;
else expireCookie(ctx, ctx.context.authCookies.sessionData);
}
}
const dontRememberMe = await ctx.getSignedCookie(ctx.context.authCookies.dontRememberToken.name, ctx.context.secret);
/**
* If session data is present in the cookie, check if it should be used or refreshed
*/
if (sessionDataPayload?.session && ctx.context.options.session?.cookieCache?.enabled && !ctx.query?.disableCookieCache) {
const session = sessionDataPayload.session;
const versionConfig = ctx.context.options.session?.cookieCache?.version;
let expectedVersion = "1";
if (versionConfig) {
if (typeof versionConfig === "string") expectedVersion = versionConfig;
else if (typeof versionConfig === "function") {
const result = versionConfig(session.session, session.user);
expectedVersion = result instanceof Promise ? await result : result;
}
}
if ((session.version || "1") !== expectedVersion) expireCookie(ctx, ctx.context.authCookies.sessionData);
else {
const cachedSessionExpiresAt = new Date(session.session.expiresAt);
if (sessionDataPayload.expiresAt < Date.now() || cachedSessionExpiresAt < /* @__PURE__ */ new Date()) expireCookie(ctx, ctx.context.authCookies.sessionData);
else {
const cookieRefreshCache = ctx.context.sessionConfig.cookieRefreshCache;
if (cookieRefreshCache === false) {
ctx.context.session = session;
const parsedSession = parseSessionOutput(ctx.context.options, {
...session.session,
expiresAt: new Date(session.session.expiresAt),
createdAt: new Date(session.session.createdAt),
updatedAt: new Date(session.session.updatedAt)
});
const parsedUser = parseUserOutput(ctx.context.options, {
...session.user,
createdAt: new Date(session.user.createdAt),
updatedAt: new Date(session.user.updatedAt)
});
return ctx.json({
session: parsedSession,
user: parsedUser
});
}
const timeUntilExpiry = sessionDataPayload.expiresAt - Date.now();
const updateAge = cookieRefreshCache.updateAge * 1e3;
const shouldSkipSessionRefresh = await getShouldSkipSessionRefresh();
if (timeUntilExpiry < updateAge && !shouldSkipSessionRefresh) {
const refreshedSession = {
session: { ...session.session },
user: session.user,
updatedAt: Date.now()
};
await setCookieCache(ctx, refreshedSession, false);
const sessionTokenOptions = ctx.context.authCookies.sessionToken.attributes;
const sessionTokenMaxAge = dontRememberMe ? void 0 : ctx.context.sessionConfig.expiresIn;
await ctx.setSignedCookie(ctx.context.authCookies.sessionToken.name, session.session.token, ctx.context.secret, {
...sessionTokenOptions,
maxAge: sessionTokenMaxAge
});
const parsedRefreshedSession = parseSessionOutput(ctx.context.options, {
...refreshedSession.session,
expiresAt: new Date(refreshedSession.session.expiresAt),
createdAt: new Date(refreshedSession.session.createdAt),
updatedAt: new Date(refreshedSession.session.updatedAt)
});
const parsedRefreshedUser = parseUserOutput(ctx.context.options, {
...refreshedSession.user,
createdAt: new Date(refreshedSession.user.createdAt),
updatedAt: new Date(refreshedSession.user.updatedAt)
});
ctx.context.session = {
session: parsedRefreshedSession,
user: parsedRefreshedUser
};
return ctx.json({
session: parsedRefreshedSession,
user: parsedRefreshedUser
});
}
const parsedSession = parseSessionOutput(ctx.context.options, {
...session.session,
expiresAt: new Date(session.session.expiresAt),
createdAt: new Date(session.session.createdAt),
updatedAt: new Date(session.session.updatedAt)
});
const parsedUser = parseUserOutput(ctx.context.options, {
...session.user,
createdAt: new Date(session.user.createdAt),
updatedAt: new Date(session.user.updatedAt)
});
ctx.context.session = {
session: parsedSession,
user: parsedUser
};
return ctx.json({
session: parsedSession,
user: parsedUser
});
}
}
}
const session = await ctx.context.internalAdapter.findSession(sessionCookieToken);
ctx.context.session = session;
if (!session || session.session.expiresAt < /* @__PURE__ */ new Date()) {
deleteSessionCookie(ctx);
if (session) {
/**
* if session expired clean up the session
* Only delete on POST when deferSessionRefresh is enabled
*/
if (!deferSessionRefresh || isPostRequest) await ctx.context.internalAdapter.deleteSession(session.session.token);
}
return ctx.json(null);
}
/**
* We don't need to update the session if the user doesn't want to be remembered
* or if the session refresh is disabled
*/
if (dontRememberMe || ctx.query?.disableRefresh) {
const parsedSession = parseSessionOutput(ctx.context.options, session.session);
const parsedUser = parseUserOutput(ctx.context.options, session.user);
return ctx.json({
session: parsedSession,
user: parsedUser
});
}
const expiresIn = ctx.context.sessionConfig.expiresIn;
const updateAge = ctx.context.sessionConfig.updateAge;
const shouldBeUpdated = session.session.expiresAt.valueOf() - expiresIn * 1e3 + updateAge * 1e3 <= Date.now();
const disableRefresh = ctx.query?.disableRefresh || ctx.context.options.session?.disableSessionRefresh;
const shouldSkipSessionRefresh = await getShouldSkipSessionRefresh();
const needsRefresh = shouldBeUpdated && !disableRefresh && !shouldSkipSessionRefresh;
/**
* When deferSessionRefresh is enabled and this is a GET request,
* return the session without performing writes, but include needsRefresh flag
*/
if (deferSessionRefresh && !isPostRequest) {
await setCookieCache(ctx, session, !!dontRememberMe);
const parsedSession = parseSessionOutput(ctx.context.options, session.session);
const parsedUser = parseUserOutput(ctx.context.options, session.user);
return ctx.json({
session: parsedSession,
user: parsedUser,
needsRefresh
});
}
if (needsRefresh) {
const updatedSession = await ctx.context.internalAdapter.updateSession(session.session.token, {
expiresAt: getDate(ctx.context.sessionConfig.expiresIn, "sec"),
updatedAt: /* @__PURE__ */ new Date()
});
if (!updatedSession) {
/**
* Handle case where session update fails (e.g., concurrent deletion)
*/
deleteSessionCookie(ctx);
throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_GET_SESSION);
}
const maxAge = ctx.context.sessionConfig.expiresIn;
await setSessionCookie(ctx, {
session: updatedSession,
user: session.user
}, false, { maxAge });
const parsedUpdatedSession = parseSessionOutput(ctx.context.options, updatedSession);
const parsedUser = parseUserOutput(ctx.context.options, session.user);
return ctx.json({
session: parsedUpdatedSession,
user: parsedUser
});
}
await setCookieCache(ctx, session, !!dontRememberMe);
const parsedSession = parseSessionOutput(ctx.context.options, session.session);
const parsedUser = parseUserOutput(ctx.context.options, session.user);
return ctx.json({
session: parsedSession,
user: parsedUser
});
} catch (error) {
if (isAPIError(error)) throw error;
ctx.context.logger.error("INTERNAL_SERVER_ERROR", error);
throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_GET_SESSION);
}
});
/**
* Whether the deployment keeps sessions in a durable server-side store
* (a database or secondary storage) rather than only in the signed cookie.
*
* Sensitive operations use this to decide whether the cookie cache is merely an
* optimization that must be bypassed for an authoritative read (`true`), or the
* only place the session lives and therefore the authority itself (`false`, for
* stateless / DB-less deployments). Pass the result as `disableCookieCache` so a
* revoked-but-cached session cannot authorize a sensitive action.
*/
const isStateful = (ctx) => hasServerSessionStore(ctx.context.options);
const getSessionFromCtx = async (ctx, config) => {
if (ctx.context.session) return ctx.context.session;
const session = await getSession()({
...ctx,
method: "GET",
asResponse: false,
headers: ctx.headers,
returnHeaders: true,
returnStatus: false,
query: {
...config,
...ctx.query,
disableCookieCache: config?.disableCookieCache || ctx.query?.disableCookieCache,
disableRefresh: config?.disableRefresh || ctx.query?.disableRefresh
}
}).catch(() => {
return null;
});
if (!session) {
ctx.context.session = null;
return null;
}
if (session.headers) session.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
if (lowerKey === "cache-control" || lowerKey === "pragma") return;
if (!ctx.context.responseHeaders) ctx.context.responseHeaders = new Headers({ [key]: value });
else if (lowerKey === "set-cookie") ctx.context.responseHeaders.append(key, value);
else ctx.context.responseHeaders.set(key, value);
});
ctx.context.session = session.response;
return session.response;
};
/**
* Reads the session from the source that can authorize sensitive work.
*
* Stateful deployments must re-read the server-side session store because an
* earlier hook may have populated `ctx.context.session` from cookie cache.
* Stateless deployments keep the signed cookie as the session record.
*/
const getAuthoritativeSessionFromCtx = async (ctx) => {
if (!isStateful(ctx)) return getSessionFromCtx(ctx);
ctx.context.session = null;
return getSessionFromCtx(ctx, { disableCookieCache: true });
};
/**
* The middleware forces the endpoint to require a valid session.
*/
const sessionMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session?.session) throw APIError.from("UNAUTHORIZED", {
message: "Unauthorized",
code: "UNAUTHORIZED"
});
return { session };
});
/**
* This middleware forces the endpoint to require a valid authoritative session.
* This should be used for sensitive operations like password changes, account deletion, etc.
*/
const sensitiveSessionMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getAuthoritativeSessionFromCtx(ctx);
if (!session?.session) throw APIError.from("UNAUTHORIZED", {
message: "Unauthorized",
code: "UNAUTHORIZED"
});
return { session };
});
/**
* This middleware allows you to call the endpoint on the client if session is valid.
* However, if called on the server, no session is required.
*/
const requestOnlySessionMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session?.session && (ctx.request || ctx.headers)) throw APIError.from("UNAUTHORIZED", {
message: "Unauthorized",
code: "UNAUTHORIZED"
});
return { session };
});
/**
* This middleware forces the endpoint to require a valid session,
* as well as making sure the session is fresh before proceeding.
*
* Session freshness check will be skipped if the session config's freshAge
* is set to 0
*/
const freshSessionMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session?.session) throw APIError.from("UNAUTHORIZED", {
message: "Unauthorized",
code: "UNAUTHORIZED"
});
if (ctx.context.sessionConfig.freshAge !== 0) {
const createdAt = new Date(session.session.createdAt).getTime();
const freshAge = ctx.context.sessionConfig.freshAge * 1e3;
if (Date.now() - createdAt >= freshAge) throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.SESSION_NOT_FRESH);
}
return { session };
});
/**
* user active sessions list
*/
const listSessions = () => createAuthEndpoint("/list-sessions", {
method: "GET",
operationId: "listUserSessions",
use: [freshSessionMiddleware],
requireHeaders: true,
metadata: { openapi: {
operationId: "listUserSessions",
description: "List all active sessions for the user",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "array",
items: { $ref: "#/components/schemas/Session" }
} } }
} }
} }
}, async (ctx) => {
try {
const activeSessions = (await ctx.context.internalAdapter.listSessions(ctx.context.session.user.id, { onlyActiveSessions: true })).filter((session) => {
return session.expiresAt > /* @__PURE__ */ new Date();
});
return ctx.json(activeSessions.map((session) => parseSessionOutput(ctx.context.options, session)));
} catch (e) {
ctx.context.logger.error(e);
throw ctx.error("INTERNAL_SERVER_ERROR");
}
});
/**
* revoke a single session
*/
const revokeSession = createAuthEndpoint("/revoke-session", {
method: "POST",
body: z.object({ token: z.string().meta({ description: "The token to revoke" }) }),
use: [sensitiveSessionMiddleware],
requireHeaders: true,
metadata: { openapi: {
description: "Revoke a single session",
requestBody: { content: { "application/json": { schema: {
type: "object",
properties: { token: {
type: "string",
description: "The token to revoke"
} },
required: ["token"]
} } } },
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "object",
properties: { status: {
type: "boolean",
description: "Indicates if the session was revoked successfully"
} },
required: ["status"]
} } }
} }
} }
}, async (ctx) => {
const token = ctx.body.token;
if ((await ctx.context.internalAdapter.findSession(token))?.session.userId === ctx.context.session.user.id) try {
await ctx.context.internalAdapter.deleteSession(token);
} catch (error) {
ctx.context.logger.error(error && typeof error === "object" && "name" in error ? error.name : "", error);
throw APIError.from("INTERNAL_SERVER_ERROR", {
message: "Internal Server Error",
code: "INTERNAL_SERVER_ERROR"
});
}
return ctx.json({ status: true });
});
/**
* revoke all user sessions
*/
const revokeSessions = createAuthEndpoint("/revoke-sessions", {
method: "POST",
use: [sensitiveSessionMiddleware],
requireHeaders: true,
metadata: { openapi: {
description: "Revoke all sessions for the user",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "object",
properties: { status: {
type: "boolean",
description: "Indicates if all sessions were revoked successfully"
} },
required: ["status"]
} } }
} }
} }
}, async (ctx) => {
try {
await ctx.context.internalAdapter.deleteUserSessions(ctx.context.session.user.id);
} catch (error) {
ctx.context.logger.error(error && typeof error === "object" && "name" in error ? error.name : "", error);
throw APIError.from("INTERNAL_SERVER_ERROR", {
message: "Internal Server Error",
code: "INTERNAL_SERVER_ERROR"
});
}
return ctx.json({ status: true });
});
const revokeOtherSessions = createAuthEndpoint("/revoke-other-sessions", {
method: "POST",
requireHeaders: true,
use: [sensitiveSessionMiddleware],
metadata: { openapi: {
description: "Revoke all other sessions for the user except the current one",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "object",
properties: { status: {
type: "boolean",
description: "Indicates if all other sessions were revoked successfully"
} },
required: ["status"]
} } }
} }
} }
}, async (ctx) => {
const session = ctx.context.session;
if (!session.user) throw APIError.from("UNAUTHORIZED", {
message: "Unauthorized",
code: "UNAUTHORIZED"
});
const otherSessions = (await ctx.context.internalAdapter.listSessions(session.user.id)).filter((session) => {
return session.expiresAt > /* @__PURE__ */ new Date();
}).filter((session) => session.token !== ctx.context.session.session.token);
await Promise.all(otherSessions.map((session) => ctx.context.internalAdapter.deleteSession(session.token)));
return ctx.json({ status: true });
});
//#endregion
export { freshSessionMiddleware, getAuthoritativeSessionFromCtx, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware };