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
15 changes: 15 additions & 0 deletions .changeset/secure-cookie-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@codelitdev/oauth-server-kit": minor
---

Add hardened Better Auth defaults. `@codelitdev/oauth-server-kit/better-auth` exports `secureCookieOptions` (`__Host-` session cookies over https, `__Secure-` with cross-subdomain cookies, plain over http, optional client-IP header), `sessionCookieName`, `secureSessionOptions` (no session cookie cache), `secureEmailAndPasswordOptions` (password sign-up off; opting in also requires a verified email before sign-in) and `isRecentlyAuthenticated`. Session identities now carry `authenticatedAt`. `@codelitdev/oauth-server-kit/express` exports `parseTrustProxy`, `DEFAULT_TRUST_PROXY` and `createClientIpMiddleware`.

Requires Better Auth 1.7.16 or later (`better-auth` and `@better-auth/oauth-provider` peer floor `^1.7.16`; tested on 1.7.22), which carries fixes for GHSA-fvxx-76fc-893x, GHSA-2f9c-7xj7-gh55 and GHSA-9hm7-xhfp-cx58. A product's resource client must be bound to its real Better Auth instance (`options` and a `$context` resolving `baseURL`), as newer `@better-auth/oauth-provider` requires.

**Product changes for this release**

- Upgrade `better-auth` and every `@better-auth/*` package together (1.7.22 needs `@better-auth/utils` 0.5.1, `better-call` 1.4.5 and `@better-fetch/fetch` 1.3.3; mismatched copies break plugin types). If your database ran Better Auth 1.7.0–1.7.2, make `account.issuer` nullable and drop `account_issuer_accountId_uidx` (see Better Auth's 1.7 upgrade guide).

- Spread `secureCookieOptions({ baseURL, cookiePrefix, clientIpHeader })` into `advanced`, `secureSessionOptions()` into `session`, and set `emailAndPassword: secureEmailAndPasswordOptions()`. Switching to `__Host-` renames the session cookie, so users sign in once more; select credentials with `sessionCookieName(advanced)`.
- Set `app.set("trust proxy", parseTrustProxy(process.env.TRUST_PROXY))` and mount `createClientIpMiddleware` before Better Auth's handler.
- Under `__Host-` the OAuth provider's RP-initiated logout confirmation cookie is rejected by browsers; use cross-subdomain mode if you need that step.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
## General Tips
- We are building a "good enough" framework, not a "perfect" one. Focus on building in a way where we are able to ship faster without compromising reliability, mantainability and security but know when we are going overboard and being pedantic.
- None of our platform has get hit thousands of users (let alone millions), so don't waste time trying to pre-optimize the stack for such cases.
- Every product must set a product-specific `advanced.cookiePrefix` on its Better Auth instance (for example, `courselit` or `sendlit`) so products on the same browser hostname do not overwrite each other's session cookies. Keep any cookie-name parsing or legacy-cookie cleanup aligned with that prefix, and preserve other `advanced` settings such as `crossSubDomainCookies`. This belongs in the product's Better Auth configuration, not `@codelitdev/oauth-server-kit`, which does not create the auth instance.
- Every product must set a product-specific `advanced.cookiePrefix` on its Better Auth instance (for example, `courselit` or `sendlit`) so products on the same browser hostname do not overwrite each other's session cookies. Keep any cookie-name parsing or legacy-cookie cleanup aligned with that prefix, and preserve other `advanced` settings such as `crossSubDomainCookies`. Products keep their own prefix but pass it through `secureCookieOptions` (and `secureSessionOptions` for `session`) from `@codelitdev/oauth-server-kit/better-auth`, which adds the `__Host-` prefix over https and the trusted client-IP header. The kit still does not create the auth instance; the product spreads the helpers into its own Better Auth config.
- Make sure to record critical decisions as ADR in `docs/decisions`
78 changes: 37 additions & 41 deletions bun.lock

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions docs/decisions/0018-secure-session-cookies-and-client-ip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ADR 0018: Secure session cookies, no cookie cache, trusted client IP

Status: accepted
Date: 2026-11-13

## Context

CourseLit hardened its Better Auth setup locally. SendLit and MediaLit have the
same exposure, and each would otherwise re-derive it. The kit does not create
the Better Auth instance, so these must be helpers products spread into their
own config.

## Decision

- Better Auth 1.7.16 or later is required (peer floor): it fixes revoked
sessions outliving the cookie cache on sensitive routes, OTP attempt-limit
races, and unverified OAuth emails counting as verified (GHSA-fvxx-76fc-893x,
GHSA-2f9c-7xj7-gh55, GHSA-9hm7-xhfp-cx58). The kit is tested on 1.7.22.

- Session cookies default to `__Host-<prefix>.session_token` over https
(Secure, `Path=/`, no `Domain`) so a sibling subdomain cannot plant or
shadow them. Better Auth only knows `__Secure-`, so `__Host-` is folded into
`cookiePrefix` with `useSecureCookies: false` and Secure set through
`defaultCookieAttributes`. Products that need cross-subdomain cookies keep
`__Secure-` (`__Host-` forbids `Domain`). Plain names are used over http.
Exposed as `secureCookieOptions` and `sessionCookieName`.
- Password sign-up is off by default (`secureEmailAndPasswordOptions`); a
product that opts in must also require a verified email before sign-in,
so nobody can register an address they don't own and sit on it.
- The session cookie cache is off (`secureSessionOptions`): every request
reads the session row, so a revoked session stops working immediately.
- The client address comes from a trusted proxy chain: the app sets Express
`trust proxy` (`parseTrustProxy`), `createClientIpMiddleware` writes `req.ip`
(which walks `X-Forwarded-For` from the right) into an internal header,
overwriting client input, and Better Auth is told to read only that header.
The default trusted proxies are private networks; products whose clients
can reach them from a private network set `TRUST_PROXY` to the real proxy
subnet. The same internal header also feeds product rate limiting, so one
resolution serves Better Auth and the product (rather than Better Auth's
own `ipAddress.trustedProxies`, which only Better Auth would use).
- Session identities expose `authenticatedAt` (the session's `createdAt`) and
`isRecentlyAuthenticated` supports step-up checks. Products decide which
actions need it; OAuth identities never count.

## Consequences

- `@better-auth/oauth-provider` sets its logout-confirmation cookie
(`<session cookie>.oauth_logout_confirmation`) on
`/api/auth/oauth2/end-session/confirm`; browsers reject a `__Host-` cookie
with that path. Neither plugin options nor `advanced.cookies` can change its
name or path, so under `__Host-` the confirmation step of RP-initiated
logout does not complete. Accepted; products needing it use cross-subdomain
(`__Secure-`) mode.
- Disabling the cookie cache costs one session lookup per request, acceptable
at our scale.
- Changing the cookie name logs existing users out once.
12 changes: 6 additions & 6 deletions examples/reference-product/apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
"pretest": "bun run check:auth-schema"
},
"dependencies": {
"@better-auth/core": "1.7.2",
"@better-auth/oauth-provider": "1.7.2",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.1",
"@better-auth/core": "1.7.22",
"@better-auth/oauth-provider": "1.7.22",
"@better-auth/utils": "0.5.1",
"@better-fetch/fetch": "1.3.3",
"@codelitdev/billing": "workspace:*",
"@codelitdev/mcp-server-kit": "workspace:*",
"@codelitdev/oauth-server-kit": "workspace:*",
Expand All @@ -35,8 +35,8 @@
"@ts-rest/express": "3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@types/pg": "^8.23.1",
"better-call": "1.4.0",
"better-auth": "1.7.2",
"better-call": "1.4.5",
"better-auth": "1.7.22",
"drizzle-orm": "^0.45.2",
"express": "^4.21.2",
"pg": "^8.23.0",
Expand Down
4 changes: 3 additions & 1 deletion examples/reference-product/apps/api/src/auth/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export async function authenticateHttpRequest(
headers: HeaderMap,
deps: AuthRuntime,
): Promise<AuthenticationResult<string>> {
const selected = selectHttpCredential(headers);
const selected = selectHttpCredential(headers, {
sessionCookieName: deps.auth.sessionCookieName,
});
if (selected.kind === "absent") {
return mapTransportAuthentication({ kind: "absent" }, { transport: "http" });
}
Expand Down
8 changes: 7 additions & 1 deletion examples/reference-product/apps/api/src/auth/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import * as authSchema from "../db/schema/auth.generated.js";
import type { AppDb } from "../types.js";
import { AUTH_BASE_PATH, authUrls, referenceAuthOptions } from "./options.js";
import {
AUTH_BASE_PATH,
authCookieOptions,
authUrls,
referenceAuthOptions,
} from "./options.js";

export function createReferenceAuth(input: {
db: AppDb;
Expand Down Expand Up @@ -32,6 +37,7 @@ export function createReferenceAuth(input: {
oauthResourceClient,
...urls,
authBasePath: AUTH_BASE_PATH,
sessionCookieName: authCookieOptions(input.publicApiUrl).sessionCookieName,
};
}

Expand Down
27 changes: 24 additions & 3 deletions examples/reference-product/apps/api/src/auth/options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { oauthProvider } from "@better-auth/oauth-provider";
import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth";
import {
createOAuthProviderOptions,
secureCookieOptions,
secureEmailAndPasswordOptions,
secureSessionOptions,
sessionCookieName,
} from "@codelitdev/oauth-server-kit/better-auth";
import type { BetterAuthOptions } from "better-auth";
import { emailOTP } from "better-auth/plugins/email-otp";
import { jwt } from "better-auth/plugins/jwt";

export const AUTH_BASE_PATH = "/api/auth";
export const AUTH_COOKIE_PREFIX = "reference-product";
export const AUTH_SECRET_MIN_LENGTH = 32;

export const DATA_READ_SCOPE = "data:read";
Expand All @@ -16,6 +24,15 @@ export const MCP_SCOPES_SUPPORTED = [
"offline_access",
] as const;

/** Better Auth's `advanced` cookie options, plus the session cookie name they produce. */
export function authCookieOptions(publicApiUrl: string) {
const advanced = secureCookieOptions({
baseURL: publicApiUrl,
cookiePrefix: AUTH_COOKIE_PREFIX,
});
return { advanced, sessionCookieName: sessionCookieName(advanced) };
}

export type ReferenceAuthUrls = {
publicApiUrl: string;
webOrigin: string;
Expand Down Expand Up @@ -68,16 +85,20 @@ export function referenceAuthOptions(input: {
publicApiUrl: string;
secret: string;
webOrigin?: string;
database?: unknown;
database?: BetterAuthOptions["database"];
}) {
const urls = authUrls(input.publicApiUrl, input.webOrigin);
return {
appName: "Reference Product",
baseURL: urls.publicApiUrl,
basePath: AUTH_BASE_PATH,
secret: input.secret,
// `__Host-` session cookie over https; each product keeps its own prefix.
advanced: { ...authCookieOptions(urls.publicApiUrl).advanced },
// No cookie cache: a revoked session stops working immediately.
session: secureSessionOptions(),
trustedOrigins: [urls.webOrigin, urls.publicApiUrl],
emailAndPassword: { enabled: false },
emailAndPassword: secureEmailAndPasswordOptions(),
...(input.database ? { database: input.database } : {}),
plugins: [
jwt(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ CREATE TABLE IF NOT EXISTS "session" (
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "account" (
id text PRIMARY KEY,
"issuer" text NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export const session = pgTable("session", {

export const account = pgTable("account", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
Expand Down
31 changes: 31 additions & 0 deletions packages/oauth-server-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@ import {

import {
createOAuthProviderOptions,
isRecentlyAuthenticated,
resolveBetterAuthSession,
secureCookieOptions,
secureEmailAndPasswordOptions,
secureSessionOptions,
sessionCookieName,
} from "@codelitdev/oauth-server-kit/better-auth";

import {
createClientIpMiddleware,
createOAuthBearerMiddleware,
createOAuthPagesRouter,
parseTrustProxy,
} from "@codelitdev/oauth-server-kit/express";

import { createMcpOAuthDiscoveryRoutes } from "@codelitdev/oauth-server-kit/mcp";
Expand All @@ -57,9 +64,33 @@ application.
- Ordinary-login redirects are restricted to configured origins.
- Hosted pages reject framing and clear configured stale host-only cookies at
both `/login` and `/oauth/login`.
- Session cookies are `__Host-<prefix>.session_token` (Secure, `Path=/`, no
`Domain`) over https through `secureCookieOptions`; with cross-subdomain
cookies they stay `__Secure-`, and over http (local dev) they are plain.
Products keep their own `cookiePrefix` and pass it to the helper.
- `secureSessionOptions()` turns the session cookie cache off, so a revoked
session stops working on the next request.
- `secureEmailAndPasswordOptions()` keeps password sign-up off; a product
that opts in with `{ enabled: true }` also requires a verified email before
sign-in, so nobody can claim an address they don't own.
- `createClientIpMiddleware` resolves the client address from a trusted proxy
chain (`req.ip`, which ignores the attacker-controlled left of
`X-Forwarded-For`) into an internal header, and `secureCookieOptions`'
`clientIpHeader` makes Better Auth read only that header.
- Session identities carry `authenticatedAt`; `isRecentlyAuthenticated`
supports step-up checks. OAuth identities never count as recent.
- Product claims such as `team_id`, roles, or permissions are not returned as
trusted package identity fields.

Known limitation: `@better-auth/oauth-provider`'s RP-initiated logout
confirmation cookie (`<session cookie>.oauth_logout_confirmation`) is set on
`/api/auth/oauth2/end-session/confirm`. Browsers reject a `__Host-` cookie with a
path other than `/`, so under `__Host-` that confirmation step (shown when an
OAuth client calls end-session without a valid `id_token_hint`) cannot complete.
Neither the plugin options nor `advanced.cookies` can change that cookie's name
or path. Use cross-subdomain mode (`__Secure-`) if a product needs the flow.
See [ADR 0018](../../docs/decisions/0018-secure-session-cookies-and-client-ip.md).

See [docs/integration.md](docs/integration.md) for full web, mobile, REST, and
MCP setup, and [docs/architecture.md](docs/architecture.md) for the normative
product requirements and public contract.
Expand Down
62 changes: 61 additions & 1 deletion packages/oauth-server-kit/docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import { cimd } from "@better-auth/cimd";
import { fetchClientMetadataResource } from "@better-auth/cimd/node";
import { oauthProvider } from "@better-auth/oauth-provider";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth";
import {
createOAuthProviderOptions,
secureCookieOptions,
secureEmailAndPasswordOptions,
secureSessionOptions,
} from "@codelitdev/oauth-server-kit/better-auth";

const publicApiUrl = "https://api.example.com";
const authBasePath = "/api/auth";
Expand All @@ -31,6 +36,19 @@ export const auth = betterAuth({
basePath: authBasePath,
secret: process.env.BETTER_AUTH_SECRET,
database: productBetterAuthAdapter,
// Keep your own prefix; the helper makes it `__Host-<prefix>` over https.
advanced: {
...secureCookieOptions({
baseURL: publicApiUrl,
cookiePrefix: "myproduct",
// crossSubDomainCookies: { enabled: true, domain: "example.com" },
clientIpHeader: "x-client-ip", // see "Client IP" below
}),
},
// No session cookie cache: revoked sessions stop working immediately.
session: secureSessionOptions(),
// Password sign-up off; `{ enabled: true }` also requires a verified email.
emailAndPassword: secureEmailAndPasswordOptions(),
trustedOrigins: ["https://app.example.com", publicApiUrl],
plugins: [
emailOTP({ sendVerificationOTP: productSendOtp }),
Expand Down Expand Up @@ -70,6 +88,48 @@ export const auth = betterAuth({
export const oauthResourceClient = oauthProviderResourceClient(auth);
```

### Cookies, client IP and step-up checks

`secureCookieOptions` returns Better Auth `advanced` settings: a
`__Host-` session cookie over https (or `__Secure-` when
`crossSubDomainCookies` is enabled, since `__Host-` forbids `Domain`), plain
names over http. Products that pick credentials by cookie name can call
`sessionCookieName(advanced)` for the exact name.

Client IP. Set Express's trust proxy, then mount the middleware before Better
Auth's handler so Better Auth never reads `X-Forwarded-For`:

```ts
import {
createClientIpMiddleware,
parseTrustProxy,
} from "@codelitdev/oauth-server-kit/express";

app.set("trust proxy", parseTrustProxy(process.env.TRUST_PROXY));
app.use(createClientIpMiddleware({ header: "x-client-ip" }));
app.all("/api/auth/*", toNodeHandler(auth));
```

`TRUST_PROXY` defaults to private networks (`loopback, linklocal,
uniquelocal`). Set it to your proxies' actual addresses or subnet when clients
can also reach the app from a private network. This is the approach Better
Auth recommends for `ipAddressHeaders` (list only a header your server sets,
replacing any client value); its own `ipAddress.trustedProxies` is not needed
alongside it.

Step-up checks. Session identities from `resolveBetterAuthSession` carry
`authenticatedAt`; the product decides which actions need a recent sign-in:

```ts
if (!isRecentlyAuthenticated(identity, { maxAgeMs: 10 * 60 * 1000 })) {
return response.status(403).json({ error: "recent_sign_in_required" });
}
```

Limitation: under `__Host-` the OAuth provider's logout-confirmation cookie
(path `/api/auth/oauth2/end-session/confirm`) is rejected by browsers; see the
README.

### MCP client registration with CIMD

Install a compatible `@better-auth/cimd` version in the product API. For Node
Expand Down
16 changes: 8 additions & 8 deletions packages/oauth-server-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,20 @@
"tag": "latest"
},
"peerDependencies": {
"@better-auth/oauth-provider": "^1.7.2",
"better-auth": "^1.7.2",
"@better-auth/oauth-provider": "^1.7.16",
"better-auth": "^1.7.16",
"express": "^4.21.2"
},
"devDependencies": {
"@better-auth/core": "1.7.2",
"@better-auth/oauth-provider": "1.7.2",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.1",
"@better-auth/core": "1.7.22",
"@better-auth/oauth-provider": "1.7.22",
"@better-auth/utils": "0.5.1",
"@better-fetch/fetch": "1.3.3",
"@types/express": "^4.17.20",
"@types/node": "^22.14.1",
"@types/bun": "^1.4.1",
"better-auth": "1.7.2",
"better-call": "1.4.0",
"better-auth": "1.7.22",
"better-call": "1.4.5",
"express": "^4.21.2",
"rimraf": "^5.0.0",
"tsup": "^8.3.0",
Expand Down
13 changes: 13 additions & 0 deletions packages/oauth-server-kit/src/better-auth-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,16 @@ export {
type ResolveBetterAuthSessionOptions,
resolveBetterAuthSession,
} from "./better-auth";
export {
type IsRecentlyAuthenticatedOptions,
isRecentlyAuthenticated,
} from "./recent-authentication";
export {
type SecureCookieAdvancedOptions,
type SecureCookieOptionsInput,
type SecureEmailAndPasswordInput,
secureCookieOptions,
secureEmailAndPasswordOptions,
secureSessionOptions,
sessionCookieName,
} from "./secure-cookies";
Loading
Loading