Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1f64072
test: reproduce body-less form POST CSRF under SameSite=Lax
Bccorb Nov 3, 2026
9eb1b63
fix: guard unsafe auth requests whatever the SameSite setting
Bccorb Nov 3, 2026
00468d9
test(core): reproduce replay of a completed refresh rotation
Bccorb Nov 3, 2026
41bc08b
fix(core): share a refresh rotation only while it is in flight
Bccorb Nov 3, 2026
09f599b
test: reproduce guards that skip the origin check
Bccorb Nov 3, 2026
e5608a5
fix: run the origin check in the Express and Fastify guards
Bccorb Nov 3, 2026
674d414
docs: describe the content-type gate and guard origin check
Bccorb Nov 3, 2026
30f1301
test(express): reproduce forwarding a client IP under a trust-everyth…
Bccorb Nov 3, 2026
a63c4f6
fix(express): detect any trust-everything proxy setting before forwar…
Bccorb Nov 3, 2026
0ccda2d
test(core): keep keys served at the host root from verifying prefixed…
Bccorb Nov 3, 2026
cdab867
test(core): reproduce client credentials tokens that look like users
Bccorb Nov 3, 2026
1fc3b9c
fix(core): mark client credentials tokens on the resource server user
Bccorb Nov 3, 2026
29ce8fc
test(core): reproduce resource server guards that cannot require a user
Bccorb Nov 3, 2026
8aa4bbe
feat: let verify-only resource servers require a user
Bccorb Nov 3, 2026
c81aaf6
Merge remote-tracking branch 'origin/main' into fix/wave1-origin-and-…
Bccorb Nov 3, 2026
e054f5b
ci: run conformance from seamless-cli v0.27.2 and hold Next.js to the…
Bccorb Nov 3, 2026
fa9c5b9
test(core): reproduce a second tab losing its session on a late refre…
Bccorb Nov 3, 2026
595dfaa
fix: keep a completed refresh for cookie transport for five seconds
Bccorb Nov 3, 2026
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
5 changes: 5 additions & 0 deletions .changeset/express-trust-everything-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@seamless-auth/express": patch
---

Stop forwarding a client-chosen IP when Express `trust proxy` trusts every address in a form other than `true` (#257). The adapter used to refuse only `trust proxy === true`, so a setting such as `"0.0.0.0/1, 128.0.0.0/1"` or a function that always returns `true` let a caller pick the `x-seamless-client-ip` the auth API uses for per-IP rate limits and audit records. The adapter now asks the compiled trust function about documentation addresses, as the Fastify adapter does, and in that case logs a warning and forwards no client IP. A hop count or a named proxy address or subnet forwards as before.
12 changes: 12 additions & 0 deletions .changeset/guard-origin-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
---

Run the origin check in the Express and Fastify `requireAuth` guards, as the Next.js guards and the Go, Python and Rust adapters already do (#256), and refuse a literal `null` `Origin` in every case.

Requests now refused:

- On a cookie session, Express and Fastify `requireAuth` (and so `requireRole` behind it) answer a state-changing request (anything but `GET`, `HEAD` and `OPTIONS`) with `403 {"error":"cross_site_request_blocked"}` when `Sec-Fetch-Site` is `cross-site` or the `Origin` is a literal `null`. A new `allowedOrigins` option, which takes the same list as the auth router, also requires a listed `Origin` for a `same-site` request or one with no `Sec-Fetch-Site`; an empty list is enforced. A bearer request is not checked, and the guards ask for no JSON content type.
- `checkOrigin` refuses a literal `null` `Origin` whatever `Sec-Fetch-Site` and `allowedOrigins` say. It used to pass one sent with `Sec-Fetch-Site: same-origin`, `same-site` or `none`. This applies to the auth routes of every adapter and to the Next.js guards.
12 changes: 12 additions & 0 deletions .changeset/refresh-in-flight-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Stop replaying a completed bearer refresh rotation to later callers (#258). After a successful rotation, the adapter used to hand the new session to any caller presenting the same old refresh cookie or bearer refresh token for the next 5 seconds, without calling the auth API, so a stolen bearer refresh token replayed in that window got the new tokens and the auth API's reuse detection never saw it.

Bearer transport now shares only a rotation still in flight. A bearer client that presents a refresh token after its rotation completed goes to the auth API, which treats it as a replay and answers `401 {"error":"refresh_token_reused"}`.

Cookie transport keeps the 5 second window, because browser tabs share one refresh cookie and a second tab sending the old cookie just after the first tab's rotation is expected. Within 5 seconds of a successful rotation the old refresh cookie gets the same rotated session; after that it goes to the auth API as a replay. A failed rotation is never kept, and the window is keyed on a hash of the cookie.
10 changes: 10 additions & 0 deletions .changeset/resource-server-allow-clients.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
"@seamless-auth/fastify": minor
"@seamless-auth/nextjs": minor
---

Let a verify-only resource server require a user rather than a client credentials token (#260). The Express and Fastify `requireAuth` guards from `createResourceServer` take `{ allowClients: false }`, the Next.js `protect` and `authenticate` requirements take `allowClients: false`, and core's `authenticate(authorization, { allowClients: false })` does the same. A verified token whose user has `isClient` set is then refused with `403 {"error":"user_required"}` and no `WWW-Authenticate` header, the same shape as `insufficient_role`. A token that fails verification still gets `401` first. `allowClients` defaults to `true`, so existing routes are unchanged.

The shared verify-only vectors gain `is_client` on each accepted token and a `user_required_requests` list. Both are additive, so the Go, Python and Rust copies keep passing until those adapters implement the option.
8 changes: 8 additions & 0 deletions .changeset/resource-server-is-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Mark client credentials tokens on the resource server user (#260). A user verified by `createResourceServer` now carries `isClient`, `true` when `sub` equals `client_id` (a machine acting for itself, whose `id` is a client id rather than a user id) and `false` otherwise. Code that builds a `ResourceServerUser` by hand, such as a test double, needs the new field.
17 changes: 17 additions & 0 deletions .changeset/unsafe-request-content-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Close a form-post CSRF gap on the auth routes (#256). A body-less form `POST` from a sibling subdomain, or from a browser that sends no fetch metadata, could reach cookie-authenticated routes such as `/recovery-codes` when cookies were `SameSite=Lax` or `Strict`.

Requests now refused:

- The origin check runs whatever the `SameSite` setting. A state-changing request with `Sec-Fetch-Site: cross-site` answers `403 {"error":"cross_site_request_blocked"}` under `Lax` and `Strict` too, and `allowedOrigins` is enforced for `same-site` and metadata-free requests under every setting. An empty `allowedOrigins: []` counts as configured. This also applies to the Next.js `requireAuth` and `requireRole` guards.
- A state-changing request to the auth routes must declare `Content-Type: application/json` or carry an `x-seamless-auth-transport` header (any value), even with no body, or it answers `415 {"error":"unsupported_media_type"}`. `POST /refresh`, `DELETE /logout` and `DELETE /logout/all` are exempt for now, because the current browser client sends them bare; they still get the origin check. A client of your own that sends a body-less `POST`, `PUT`, `PATCH` or `DELETE` with no content type must add one of the two.

Fastify accepts an empty body under `Content-Type: application/json` on the plugin's routes, and Express no longer reads one as `{}`, so a body-less JSON request forwards no body on all three adapters.

`checkContentType` is exported from `@seamless-auth/core`. `checkOrigin` ignores its `cookieSecure` and `cookieSameSite` inputs.
8 changes: 6 additions & 2 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ concurrency:

jobs:
verify:
uses: fells-code/seamless-cli/.github/workflows/verify-conformance.yml@1ff9552d68d3d74096992a83d931acc0233edab2 # v0.26.0
uses: fells-code/seamless-cli/.github/workflows/verify-conformance.yml@28a363fcd6ba6c2ea3930c7172093638d8c41fe2 # v0.27.2
with:
server-ref: ${{ github.event.pull_request.head.sha }}

Expand All @@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: fells-code/seamless-cli
ref: 1ff9552d68d3d74096992a83d931acc0233edab2 # v0.26.0
ref: 28a363fcd6ba6c2ea3930c7172093638d8c41fe2 # v0.27.2
path: seamless-cli

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -89,6 +89,10 @@ jobs:
- name: Conformance
env:
SEAMLESS_API_DIR: ${{ github.workspace }}/seamless-auth-api
# This adapter ships the content-type gate, so hold it to the pending
# contract (seamless-cli verify/harness/lib/pendingContracts.ts). The
# reusable workflow above has no input for it yet.
SEAMLESS_VERIFY_CONTRACTS: content-type-gate
run: node seamless-cli/dist/index.js verify --adapter-url=http://localhost:3002

- name: Reference app log
Expand Down
7 changes: 5 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ remain for direct imports.
- `authenticateBearer(...)` – verifies a bearer access token into a session
- `authenticateRequest(...)` – the cookie when present, otherwise the bearer token when enabled
- `authorizeRoles(...)` – role check against an authenticated session
- `checkOrigin(...)` – cross-site request check for `SameSite=None` deployments
- `checkOrigin(...)` – cross-site request check for state-changing requests, whatever the `SameSite` setting
- `checkContentType(...)` – refuses a state-changing request a form or `no-cors` fetch could send without a CORS preflight

**Building an adapter**

Expand Down Expand Up @@ -286,7 +287,9 @@ provider identity linking. Core and adapter code never store provider access tok
`createResourceServer({ issuer, audience })` verifies the OpenID Provider's RFC 9068 access
tokens for an API that only receives them, with `authenticate`, `authorizeScopes`,
`authorizeRoles` and `stepUp` returning a `GuardRejection` (status, `errorCode`, `detail` and
`headers`, including the `WWW-Authenticate` challenge). The Express, Fastify and Next.js
`headers`, including the `WWW-Authenticate` challenge). `authenticate(authorization,
{ allowClients: false })` refuses a client credentials token (`isClient`) with
`403 user_required` and no challenge, the same shape as `insufficient_role`. The Express, Fastify and Next.js
adapters wrap it; their READMEs list the rules and responses. `tests/fixtures/verify-only`
holds the signed vectors every adapter, including Go, Python and Rust, is tested against, with
the script that generates them.
Expand Down
102 changes: 83 additions & 19 deletions packages/core/src/guards.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SeamlessAuthUser } from "@seamless-auth/types";
import { decodeJwt } from "jose";

import { resolveCookieSameSite, type CookieSameSite } from "./applyResult.js";
import type { CookieSameSite } from "./applyResult.js";
import { hasScopedRole } from "@seamless-auth/types/role/matching";
import type { AuthServerIssuerOption } from "./authServerIssuer.js";
import { assertSecretStrength } from "./validateSecrets.js";
Expand Down Expand Up @@ -47,8 +47,15 @@ export interface OriginCheckInput {
secFetchSite?: string;
/** `Origin`, already reduced to a single value. */
origin?: string;
/**
* No longer consulted. The check runs whatever the cookie policy, because a
* `Lax` or `Strict` cookie still rides a same-site request. Kept so existing
* callers compile.
*/
cookieSecure?: boolean;
/** As `cookieSecure`. */
cookieSameSite?: CookieSameSite;
/** Set, even to `[]`, it is enforced for same-site and metadata-free requests. */
allowedOrigins?: string[];
}

Expand All @@ -59,24 +66,24 @@ function normalizeOrigin(value: string): string {
/**
* Decides whether a cross-site state-changing request should be refused.
*
* Only matters when the adapter issues `SameSite=None` cookies, which the
* browser would otherwise attach to a forged cross-site request. A `Lax` or
* `Strict` cookie is not sent on one, so the check is inert there.
* Runs whatever the adapter's `SameSite` setting: `Lax` and `Strict` keep a
* cookie off a cross-site request but not off a same-site one, and a sibling
* subdomain is same-site.
*
* A literal `null` `Origin` is refused first, in every case.
*
* `Sec-Fetch-Site` is the primary signal: current browsers send it and page
* JavaScript cannot forge it. `cross-site` is refused and `same-origin` passes.
* `same-site` also covers a sibling subdomain, so when the adopter configured
* `allowedOrigins` the `Origin` must be on it as well; without the allowlist a
* same-site request passes, as it always has. When `Sec-Fetch-Site` is absent
* the `Origin` is matched against `allowedOrigins`, again only when the adopter
* opted in, so nothing regresses for callers that predate the guard.
* `allowedOrigins` the `Origin` must be on it as well. When `Sec-Fetch-Site` is
* absent the `Origin` is matched against `allowedOrigins`, again only when the
* adopter configured it. Without the list both pass here, and
* `checkContentType` is what keeps a forged form post out.
*/
export function checkOrigin(
input: OriginCheckInput,
): GuardRejection | undefined {
const active = resolveCookieSameSite(input) === "none";

if (!active || SAFE_METHODS.has(input.method)) {
if (SAFE_METHODS.has(input.method)) {
return undefined;
}

Expand All @@ -85,6 +92,12 @@ export function checkOrigin(
errorCode: "cross_site_request_blocked",
};

// A literal `null` origin is opaque or sandboxed, never the adopter's own
// page, so it is refused whatever `Sec-Fetch-Site` and the allowlist say.
if (input.origin?.trim().toLowerCase() === "null") {
return rejection;
}

if (input.secFetchSite !== undefined) {
const site = input.secFetchSite.toLowerCase();

Expand All @@ -108,14 +121,6 @@ export function checkOrigin(
return undefined;
}

// A literal `null` origin is opaque or sandboxed, which is cross-site
// regardless of the allowlist.
if (input.origin === "null") {
return rejection;
}

// Older browser, but the adopter has not opted into an allowlist. Preserve the
// pre-guard behavior rather than start rejecting these.
if (!input.allowedOrigins) {
return undefined;
}
Expand All @@ -125,6 +130,65 @@ export function checkOrigin(
: rejection;
}

export interface ContentTypeCheckInput {
method: string;
/** Relative to the adapter's mount point, as the route tables name it. */
path: string;
/** `Content-Type`, already reduced to a single value. */
contentType?: string;
/** `x-seamless-auth-transport`, already reduced to a single value. */
transport?: string;
}

// The browser client up to now sends these with no body and no header, so
// they stay open until a client that declares JSON on them is the floor. They
// still get the origin check.
const CONTENT_TYPE_EXEMPT = new Set([
"POST /refresh",
"DELETE /logout",
"DELETE /logout/all",
]);

function routeKey(method: string, path: string): string {
const normalized = path.toLowerCase().replace(/\/+$/, "") || "/";
return `${method.toUpperCase()} ${normalized}`;
}

/**
* Refuses a state-changing request to the adapter's auth routes that declares
* neither `Content-Type: application/json` nor an `x-seamless-auth-transport`
* header (any value). An HTML form can only send urlencoded, multipart or plain
* text, and a `no-cors` fetch can set neither, so requiring one forces a CORS
* preflight the adopter's CORS policy then decides.
*
* Applies even to a request with no body, because the routes act on the cookie
* alone. Not for an adopter's own routes behind a guard: those get the origin
* check only.
*/
export function checkContentType(
input: ContentTypeCheckInput,
): GuardRejection | undefined {
const method = input.method.toUpperCase();

if (SAFE_METHODS.has(method)) {
return undefined;
}

if (CONTENT_TYPE_EXEMPT.has(routeKey(method, input.path))) {
return undefined;
}

if (input.transport !== undefined) {
return undefined;
}

const mediaType = input.contentType?.split(";")[0]?.trim().toLowerCase();

return mediaType === "application/json"
? undefined
: { status: 415, errorCode: "unsupported_media_type" };
}

function isAllowedOrigin(origin: string, allowedOrigins: string[]): boolean {
const allowed = new Set(allowedOrigins.map(normalizeOrigin).filter(Boolean));

Expand Down
Loading
Loading