Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions sdk/ts/src/__tests__/cookie.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ describe("sessionCookieAttrs", () => {
const attrs = sessionCookieAttrs("tok");
expect(attrs.maxAge).toBeUndefined();
});

it("uses a plain, non-Secure cookie when secure: false (local HTTP dev)", () => {
const attrs = sessionCookieAttrs("tok_dev", { secure: false });
expect(attrs.name).toBe("session");
expect(attrs.secure).toBe(false);
expect(attrs.httpOnly).toBe(true);
expect(attrs.sameSite).toBe("lax");
});
});

describe("clearCookieAttrs", () => {
Expand All @@ -48,6 +56,13 @@ describe("clearCookieAttrs", () => {
expect(attrs.domain).toBe("example.com");
expect(attrs.maxAge).toBe(-1);
});

it("clears the plain cookie when secure: false", () => {
const attrs = clearCookieAttrs({ secure: false });
expect(attrs.name).toBe("session");
expect(attrs.secure).toBe(false);
expect(attrs.maxAge).toBe(-1);
});
});

describe("getSessionToken", () => {
Expand All @@ -65,6 +80,16 @@ describe("getSessionToken", () => {
expect(getSessionToken(r)).toBe("tok_456");
});

it("reads the plain session cookie (local HTTP dev)", () => {
const r = req({ cookie: "session=tok_dev" });
expect(getSessionToken(r)).toBe("tok_dev");
});

it("prefers hardened cookies over the plain session cookie", () => {
const r = req({ cookie: "session=plain_tok; __Host-session=host_tok" });
expect(getSessionToken(r)).toBe("host_tok");
});

it("prefers cookie over Authorization header", () => {
const r = req({
cookie: "__Host-session=from_cookie",
Expand Down
56 changes: 37 additions & 19 deletions sdk/ts/src/server/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/**
* Server-only cookie helpers for Beyond Auth session tokens.
*
* Encodes cookie security best-practices so callers can't opt out:
* - `HttpOnly`, `Secure`, `SameSite=lax`, `Path=/` on every cookie
* - `__Host-` prefix when no `domain` is set (HTTPS + exact domain, no Domain attribute)
* - `__Secure-` prefix when `domain` is set (HTTPS, allows cross-subdomain)
* Secure by default, with an escape hatch for local HTTP development:
* - `HttpOnly`, `SameSite=lax`, `Path=/` on every cookie
* - `Secure` + `__Host-` prefix when no `domain` is set (HTTPS, exact origin)
* - `Secure` + `__Secure-` prefix when `domain` is set (HTTPS, cross-subdomain)
* - `secure: false` → a plain `session` cookie (no prefix, not `Secure`) so the
* session works over `http://localhost` in every browser (the `__Host-`/
* `__Secure-` prefixes and `Secure` require HTTPS). Use only in development.
* - `MaxAge: -1` to clear (correct — not just omitting the attribute)
*/

Expand All @@ -22,18 +25,25 @@ export interface CookieOptions {
* make the cookie persistent.
*/
maxAge?: number;
/**
* Whether to set a `Secure`, prefix-hardened cookie. Defaults to `true`.
* Set to `false` only for local HTTP development — `Secure` and the
* `__Host-`/`__Secure-` prefixes require HTTPS, so on `http://localhost`
* they are dropped by some browsers. A plain `session` cookie is used instead.
*/
secure?: boolean;
}

/** Fully-resolved cookie attributes ready to pass to any framework. */
export interface CookieAttrs {
/** Cookie name, including the security prefix. */
/** Cookie name, including the security prefix (or plain `session` when insecure). */
name: string;
/** Opaque session token value. */
value: string;
/** Always `true` — prevents JS access to the session token. */
httpOnly: true;
/** Always `true` — cookie is only sent over HTTPS. */
secure: true;
/** `true` unless `secure: false` was passed (local HTTP dev). */
secure: boolean;
/** Always `'lax'` — CSRF-safe while allowing top-level navigations. */
sameSite: "lax";
/** Always `'/'` — visible to all routes. */
Expand All @@ -44,8 +54,10 @@ export interface CookieAttrs {

const HOST_COOKIE = "__Host-session";
const SECURE_COOKIE = "__Secure-session";
const PLAIN_COOKIE = "session";

function cookieName(domain?: string): string {
function cookieName(domain?: string, secure = true): string {
if (!secure) return PLAIN_COOKIE; // prefixes require Secure/HTTPS
return domain !== undefined ? SECURE_COOKIE : HOST_COOKIE;
}

Expand All @@ -71,11 +83,12 @@ export function sessionCookieAttrs(
token: string,
opts?: CookieOptions,
): CookieAttrs {
const secure = opts?.secure ?? true;
const attrs: CookieAttrs = {
name: cookieName(opts?.domain),
name: cookieName(opts?.domain, secure),
value: token,
httpOnly: true,
secure: true,
secure,
sameSite: "lax",
path: "/",
};
Expand All @@ -101,13 +114,14 @@ export function sessionCookieAttrs(
* ```
*/
export function clearCookieAttrs(
opts?: Pick<CookieOptions, "domain">,
opts?: Pick<CookieOptions, "domain" | "secure">,
): CookieAttrs {
const secure = opts?.secure ?? true;
const attrs: CookieAttrs = {
name: cookieName(opts?.domain),
name: cookieName(opts?.domain, secure),
value: "",
httpOnly: true,
secure: true,
secure,
sameSite: "lax",
path: "/",
maxAge: -1,
Expand All @@ -119,9 +133,9 @@ export function clearCookieAttrs(
/**
* Extracts the Beyond Auth session token from Node.js-style headers.
*
* Checks the `__Host-session` cookie first (preferred), then
* `__Secure-session`, then falls back to the `Authorization: Bearer <token>`
* header. Returns `null` when no token is present.
* Checks the `__Host-session` cookie first (preferred), then `__Secure-session`,
* then the plain `session` cookie (local HTTP dev), then falls back to the
* `Authorization: Bearer <token>` header. Returns `null` when no token is present.
*
* Use this with Fastify and Express where headers are `IncomingHttpHeaders`.
* For Web API `Request` objects (Hono, Next.js), use {@link getSessionToken}.
Expand All @@ -139,16 +153,20 @@ export function getSessionTokenFromNodeHeaders(
if (cookieHeader) {
let hostValue: string | undefined;
let secureValue: string | undefined;
let plainValue: string | undefined;
for (const part of cookieHeader.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const name = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (name === HOST_COOKIE && value) hostValue = value;
else if (name === SECURE_COOKIE && value) secureValue = value;
else if (name === PLAIN_COOKIE && value) plainValue = value;
}
// Prefer the hardened cookies; fall back to the plain dev cookie.
if (hostValue) return hostValue;
if (secureValue) return secureValue;
if (plainValue) return plainValue;
}

const authHeader = Array.isArray(headers.authorization)
Expand All @@ -166,9 +184,9 @@ export function getSessionTokenFromNodeHeaders(
/**
* Extracts the Beyond Auth session token from an incoming `Request`.
*
* Checks the `__Host-session` cookie first (preferred), then
* `__Secure-session`, then falls back to the `Authorization: Bearer <token>`
* header. Returns `null` when no token is present.
* Checks the `__Host-session` cookie first (preferred), then `__Secure-session`,
* then the plain `session` cookie (local HTTP dev), then falls back to the
* `Authorization: Bearer <token>` header. Returns `null` when no token is present.
*
* @param request - The incoming HTTP request (Web API `Request` or compatible).
* @returns The raw session token string, or `null` if absent.
Expand Down