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
13 changes: 13 additions & 0 deletions .changeset/check-token-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Check the token type before trusting a token. `ensureCookies` now refuses an access cookie whose
inner token is not an access token (`typ: "access"`), as `requireAuth` already did, so a signed
pre-auth cookie copied into the access slot is answered 401. `verifyUpstreamSession` refuses a
response whose token has the wrong `typ`: an access token for session flows, and an ephemeral
token for `/login` and the manifest's pre-auth and registration routes (new optional
`expectedType` argument). Core also exports `holdsAccessToken`.
11 changes: 11 additions & 0 deletions .changeset/console-security-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Keep the API's framing and content-type headers on the console proxy. The proxy now forwards
`Content-Security-Policy`, `X-Frame-Options` and `X-Content-Type-Options` from the API, and when
the API sends neither of the last two it sets `X-Frame-Options: DENY` and
`X-Content-Type-Options: nosniff` (unless the application already set them), so the admin console
served on the app origin cannot be framed.
12 changes: 12 additions & 0 deletions .changeset/no-upstream-redirects.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
---

Do not follow redirects on upstream requests. `authFetch` now sends every auth API request with
`redirect: "manual"` and answers a 3xx as a 502 with `{ "error": "upstream_redirect" }`, so a
misconfigured proxy in front of the API can no longer get the service token and forwarded client
headers resent to another origin. The adapter manifest fetch no longer follows redirects either,
and falls back to the bundled copy instead.
16 changes: 16 additions & 0 deletions .changeset/origin-check-same-site.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
"@seamless-auth/fastify": minor
"@seamless-auth/nextjs": minor
---

Check `Origin` against `allowedOrigins` even when `Sec-Fetch-Site` is present. A `same-site`
request also covers sibling subdomains, so with `SameSite=None` cookies the header alone let any
subdomain drive a state change. When `allowedOrigins` is configured, a state-changing `same-site`
request must now carry an `Origin` on the list (a missing `Origin` is refused). `same-origin`
requests always pass and `cross-site` requests are always refused.

This tightens behavior for adopters who set `allowedOrigins`: list every origin that calls the
adapter from a sibling subdomain. With `allowedOrigins` unset the default is unchanged, and a
`same-site` request passes as before.
11 changes: 11 additions & 0 deletions .changeset/refuse-dot-segment-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Refuse empty, `.` and `..` route params on the hand-written passthroughs and core handlers with
a 400 `bad_request`, as the manifest proxy already did. A decoded `%2e%2e` param previously stayed
literal after encoding and moved the upstream path. Core exports `encodePathParam` and
`InvalidPathParamError` for the shared rule.
19 changes: 19 additions & 0 deletions .changeset/refuse-placeholder-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
"@seamless-auth/fastify": minor
"@seamless-auth/nextjs": minor
---

Refuse known placeholder secrets at startup. `assertSecretStrength` now refuses
`dev-only-cookie-signing-key-replace-me`, `GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV`, and any value
containing `replace-me`, which were long enough to pass the length check. Every adapter refuses one
for `cookieSecret` or `serviceSecret` when it starts, unless it runs in its insecure local mode
(`cookieSecure: false`). `assertSecretStrength` takes a new `{ allowPlaceholder }` option and
`assertSecrets` reads `cookieSecure`; core also exports `isPlaceholderSecret` and
`KNOWN_PLACEHOLDER_SECRETS`.

`requireAuth` (and `authenticateRequest`) configured with `authServerUrl` and `audience` now also
verifies the access token inside the cookie against the auth API's JWKS, and takes the session's
roles from it, so a cookie forged with the cookie secret alone cannot carry claims of its own.
Without that configuration the guard decodes the inner token as before.
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: 2
updates:
# Workflow actions are pinned to commit SHAs, so updates have to come from here.
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
- dev
- main

permissions:
contents: read

jobs:
test:
name: Run Tests (Node ${{ matrix.node-version }})
Expand All @@ -19,13 +22,13 @@ jobs:
node-version: [22, 24, latest]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10.33.0

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ name: conformance
on:
pull_request:

permissions:
contents: read

jobs:
verify:
uses: fells-code/seamless-cli/.github/workflows/verify-conformance.yml@main
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ on:
- beta
- rc

permissions:
contents: read

jobs:
prerelease:
name: Publish Prerelease Snapshot
Expand All @@ -27,15 +30,15 @@ jobs:
id-token: write

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0

- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10.33.0

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: pnpm
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
- main
workflow_dispatch:

permissions:
contents: read

jobs:
release:
name: Version or Publish Stable Packages
Expand All @@ -17,15 +20,15 @@ jobs:
id-token: write

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0

- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10.33.0

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: pnpm
Expand All @@ -38,7 +41,7 @@ jobs:
run: pnpm test

- name: Version or publish packages
uses: changesets/action@v1
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
version: pnpm version-packages
publish: pnpm release:stable
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/authFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
*/
import { getSeamlessLogger } from "./logger.js";

export interface AuthFetchOptions {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
Expand Down Expand Up @@ -31,15 +33,49 @@ export async function authFetch(
: {}),
};

// Following a redirect would resend the service token and the forwarded
// client headers to wherever a misconfigured proxy in front of the API points.
const response = await fetch(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
redirect: "manual",
});

if (isRedirect(response)) {
getSeamlessLogger().error(
`[SeamlessAuth] The auth API at ${originOf(url)} answered a ${options.method} with a redirect (HTTP ${response.status}), which is not followed.`,
);
return makeJsonTolerant(
new Response(JSON.stringify({ error: UPSTREAM_REDIRECT_ERROR_CODE }), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
);
}

return makeJsonTolerant(response);
}

/** The error code a request gets when the auth API answered it with a redirect. */
export const UPSTREAM_REDIRECT_ERROR_CODE = "upstream_redirect";

function isRedirect(response: Response): boolean {
return (
response.type === "opaqueredirect" ||
(response.status >= 300 && response.status < 400 && response.status !== 304)
);
}

// The path can carry a credential, such as a magic link token.
function originOf(url: string): string {
try {
return new URL(url).origin;
} catch {
return "<invalid url>";
}
}

// Upstream responses aren't always JSON, and native Response.json() throws on the ones
// that aren't, which would crash the 24 call sites that parse a body before checking
// the status. Make json() tolerant instead: parsed JSON, { message: text } for a
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/createServiceToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export interface ServiceTokenOptions {
}

export function createServiceToken(opts: ServiceTokenOptions): string {
assertSecretStrength("serviceSecret", opts.serviceSecret);
assertSecretStrength("serviceSecret", opts.serviceSecret, {
allowPlaceholder: true,
});

return jwt.sign(
{
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { holdsAccessToken } from "./guards.js";
import { verifyCookieJwt } from "./verifyCookieJwt.js";
import type { ResultFailure } from "./result.js";
import { refreshAccessToken } from "./refreshAccessToken.js";
Expand Down Expand Up @@ -349,7 +350,9 @@ export async function ensureCookies(
input: EnsureCookiesInput,
opts: EnsureCookiesOptions,
): Promise<EnsureCookiesResult> {
assertSecrets(opts);
// The adapter refused a placeholder at startup, where it knows whether it
// runs in local mode; these options do not say.
assertSecrets(opts, { allowPlaceholder: true });

const requirement = cookieRequirement(input);

Expand Down Expand Up @@ -414,7 +417,13 @@ export async function ensureCookies(
};
}

const token = typeof payload.token === "string" ? payload.token : undefined;
// Every adapter cookie is signed with the same secret, so the signature
// does not tell a pre-auth cookie from a session. The token type does.
const token =
typeof payload.token === "string" &&
(cookieName !== opts.accessCookieName || holdsAccessToken(payload.token))
? payload.token
: undefined;

if (required && !token && cookieName === opts.accessCookieName) {
const refreshed = await refreshRequiredCookie(
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/getSeamlessUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export async function getSeamlessUser<T = SeamlessUser>(
cookies: Record<string, string | undefined>,
opts: GetSeamlessUserOptions,
): Promise<T | null> {
assertSecretStrength("cookieSecret", opts.cookieSecret);
assertSecretStrength("cookieSecret", opts.cookieSecret, {
allowPlaceholder: true,
});

const authorization = await resolveUpstreamAuthorization(cookies, opts);
if (authorization === null) return null;
Expand Down
Loading
Loading