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
21 changes: 21 additions & 0 deletions .changeset/manifest-passthrough-guard-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
"@seamless-auth/fastify": minor
"@seamless-auth/nextjs": minor
---

Passthrough routes come from the auth API's adapter manifest, and guard and not-found responses match the Go, Python and Rust adapters.

Breaking response changes:

- `requireAuth` answers `401 {"error":"unauthenticated"}` for a missing, invalid or expired session and for an ephemeral token. It used to answer `"Failed to find authentication token required"` or `"Invalid or expired session"`.
- `requireRole` answers `403 {"error":"insufficient_role"}` when no listed role is granted, including for a user with no roles. The body no longer carries `required` and `actual`, so a client never sees the user's roles. A request no guard authenticated gets `401 {"error":"unauthenticated"}`.
- In Express, a path under the auth router that no route and no manifest entry matches answers `404 {"error":"not_found"}` instead of calling `next()`. Fastify and Next.js already did.
- `POST /users/update` is no longer served. The auth API has no such route, so it only ever answered the API's 404.

The static passthrough tables are gone. Organizations, step-up, TOTP, credentials, OAuth provider configuration, admin organizations, enrollment, review accounts, the auth event export and integrity check, and the coverage report are served from the manifest, like any other route the adapter has no handler for. In cookie transport their bodies no longer carry `token` or `refreshToken`. A dot segment in one of their path parameters answers `404 not_found` rather than `400 bad_request`, and no upstream call is made either way. A recovery session that enrolls an authenticator app is still rotated right away.

OTP verification, magic link polling and verification, and organization switching also drop tokens from a cookie-transport body when the auth API's answer is not a complete session.

`setSeamlessLogger` and the `SeamlessLogger` type are exported from `@seamless-auth/express`, `@seamless-auth/fastify` and `@seamless-auth/nextjs`, and every warning and error those packages log goes through it.
27 changes: 27 additions & 0 deletions .github/workflows/manifest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: manifest

# The bundled adapter manifest must list the routes seamless-auth-api main
# publishes. The schedule catches API route changes that land between PRs here.
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '17 6 * * *'
workflow_dispatch:

permissions:
contents: read

jobs:
drift:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22

- run: node scripts/check-adapter-manifest.mjs
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ conformance/
handler of its own for, through `handleManifestRoute` in core. Add a dedicated
handler only for behaviour the manifest cannot describe. Refresh the bundled
fallback with `node scripts/sync-adapter-manifest.mjs` after the API's
manifest changes.
manifest changes. `node scripts/check-adapter-manifest.mjs` (the `manifest`
workflow, also run daily) fails while the two list different routes.

## Tooling

Expand All @@ -125,6 +126,7 @@ conformance/
| Install | `pnpm install` |
| Build | `pnpm build` |
| Test | `pnpm test` |
| Manifest | `node scripts/check-adapter-manifest.mjs` |

- Supported Node is `>=22`. CI tests 22, 24, and the latest release;
`.nvmrc` pins Node 24 for development and releases. Run `nvm use` and
Expand Down
28 changes: 16 additions & 12 deletions packages/core/src/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import { verifyCookieJwt } from "./verifyCookieJwt.js";
*/
export type { SeamlessAuthUser } from "@seamless-auth/types";

/** What every first-party guard answers for a missing, invalid or pre-auth credential. */
export const UNAUTHENTICATED = "unauthenticated";

/** What a role guard answers for a session that holds none of the required roles. */
export const INSUFFICIENT_ROLE = "insufficient_role";

/**
* A guard's decision to refuse a request. Adapters render it as
* `{ error: errorCode, ...detail }` and log `warn` when it is set.
Expand Down Expand Up @@ -206,7 +212,7 @@ export function authenticateCookie(input: CookieAuthInput): CookieAuthResult {
return {
rejection: {
status: 401,
errorCode: "Failed to find authentication token required",
errorCode: UNAUTHENTICATED,
warn: "Missing expected auth cookie.",
},
};
Expand All @@ -216,7 +222,7 @@ export function authenticateCookie(input: CookieAuthInput): CookieAuthResult {

if (!payload || !payload.sub || !holdsAccessToken(payload.token)) {
return {
rejection: { status: 401, errorCode: "Invalid or expired session" },
rejection: { status: 401, errorCode: UNAUTHENTICATED },
};
}

Expand Down Expand Up @@ -255,7 +261,7 @@ export type AuthResult = CookieAuthResult;
function missingTokenRejection(warn: string): GuardRejection {
return {
status: 401,
errorCode: "Failed to find authentication token required",
errorCode: UNAUTHENTICATED,
warn,
};
}
Expand Down Expand Up @@ -291,7 +297,7 @@ export async function authenticateBearer(

if (!claims) {
return {
rejection: { status: 401, errorCode: "Invalid or expired session" },
rejection: { status: 401, errorCode: UNAUTHENTICATED },
};
}

Expand Down Expand Up @@ -381,7 +387,7 @@ async function verifyCookieAccessToken(

if (!claims || claims.sub !== user.id) {
return {
rejection: { status: 401, errorCode: "Invalid or expired session" },
rejection: { status: 401, errorCode: UNAUTHENTICATED },
};
}

Expand All @@ -408,19 +414,17 @@ export function authorizeRoles(
const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];

if (!user) {
return { status: 401, errorCode: "Authentication required" };
return { status: 401, errorCode: UNAUTHENTICATED };
}

if (!Array.isArray(user.roles)) {
return { status: 403, errorCode: "User has no roles assigned" };
return { status: 403, errorCode: INSUFFICIENT_ROLE };
}

// The body names neither the user's roles nor the required ones: the client
// gains nothing from them, and they map out the app's authorization model.
if (!hasScopedRole(user.roles, roles)) {
return {
status: 403,
errorCode: "Insufficient role",
detail: { required: roles, actual: user.roles },
};
return { status: 403, errorCode: INSUFFICIENT_ROLE };
}

return undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AuthServerIssuerOption } from "../authServerIssuer.js";
import { withoutTokens } from "../bodyTokens.js";
import { authFetch } from "../authFetch.js";
import { sessionResult } from "../upstreamSession.js";
import type { AuthTransport } from "../transport.js";
Expand Down Expand Up @@ -63,7 +64,7 @@ export async function pollMagicLinkConfirmationHandler(
if (!data?.token || !data?.refreshToken || !data?.sub) {
return {
status: up.status,
body: data,
body: opts.transport === "bearer" ? data : withoutTokens(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/switchOrganizationHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { encodePathParam } from "../pathParam.js";
import type { AuthServerIssuerOption } from "../authServerIssuer.js";
import { withoutTokens } from "../bodyTokens.js";
import { authFetch } from "../authFetch.js";
import { sessionResult } from "../upstreamSession.js";
import type { AuthTransport } from "../transport.js";
Expand Down Expand Up @@ -61,7 +62,7 @@ export async function switchOrganizationHandler(
if (!data?.token || !data?.sub) {
return {
status: up.status,
body: data,
body: opts.transport === "bearer" ? data : withoutTokens(data),
};
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/handlers/verifyLoginOtpHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AuthServerIssuerOption } from "../authServerIssuer.js";
import { withoutTokens } from "../bodyTokens.js";
import { authFetch } from "../authFetch.js";
import { sessionResult } from "../upstreamSession.js";
import type { AuthTransport } from "../transport.js";
Expand Down Expand Up @@ -66,7 +67,7 @@ async function verifyOtp(
if (!data?.token) {
return {
status: up.status,
body: data,
body: opts.transport === "bearer" ? data : withoutTokens(data),
};
}

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/handlers/verifyMagicLinkHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { encodePathParam } from "../pathParam.js";
import { withoutTokens } from "../bodyTokens.js";
import { authFetch } from "../authFetch.js";
import { readPassthroughFailure } from "../upstreamError.js";
import type { ResultFailure } from "../result.js";
Expand All @@ -18,6 +19,8 @@ export interface VerifyMagicLinkInput {

export interface VerifyMagicLinkOptions {
authServerUrl: string;
/** Cookie transport (the default) drops any token from the body. */
transport?: AuthTransport;
serviceAuthorization?: string;
forwardedClientIp?: string;
forwardedUserAgent?: string;
Expand Down Expand Up @@ -90,6 +93,6 @@ export async function verifyMagicLinkHandler(

return {
status: up.status,
body: data,
body: opts.transport === "bearer" ? data : withoutTokens(data),
};
}
16 changes: 8 additions & 8 deletions packages/core/tests/authenticateBearer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe("authenticateBearer", () => {

expect(result.rejection).toMatchObject({
status: 401,
errorCode: "Failed to find authentication token required",
errorCode: "unauthenticated",
});
expect(result.rejection.warn).toMatch(/bearer/i);
});
Expand All @@ -120,7 +120,7 @@ describe("authenticateBearer", () => {

expect(result.rejection.status).toBe(401);
expect(result.rejection.errorCode).toBe(
"Failed to find authentication token required",
"unauthenticated",
);
});

Expand All @@ -136,7 +136,7 @@ describe("authenticateBearer", () => {

expect(result.rejection).toEqual({
status: 401,
errorCode: "Invalid or expired session",
errorCode: "unauthenticated",
});
});

Expand All @@ -150,7 +150,7 @@ describe("authenticateBearer", () => {
audience: "https://someone-else.example.com",
});

expect(result.rejection.errorCode).toBe("Invalid or expired session");
expect(result.rejection.errorCode).toBe("unauthenticated");
});
});

Expand Down Expand Up @@ -183,7 +183,7 @@ describe("authenticateRequest", () => {
});
expect(withoutCookie.rejection).toMatchObject({
status: 401,
errorCode: "Failed to find authentication token required",
errorCode: "unauthenticated",
warn: "Missing expected auth cookie.",
});
});
Expand All @@ -201,7 +201,7 @@ describe("authenticateRequest", () => {

expect(result.rejection).toEqual({
status: 401,
errorCode: "Invalid or expired session",
errorCode: "unauthenticated",
});
expect(global.fetch).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -246,7 +246,7 @@ describe("authenticateRequest", () => {
});
expect(unconfigured.rejection).toEqual({
status: 401,
errorCode: "Invalid or expired session",
errorCode: "unauthenticated",
});
});

Expand All @@ -263,7 +263,7 @@ describe("authenticateRequest", () => {

expect(result.rejection).toEqual({
status: 401,
errorCode: "Invalid or expired session",
errorCode: "unauthenticated",
});
});

Expand Down
80 changes: 80 additions & 0 deletions packages/core/tests/incompleteSessionBody.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { jest } from "@jest/globals";

function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

const AUTH = "https://auth.example.com";
const SESSION_OPTS = {
authServerUrl: AUTH,
audience: AUTH,
accessCookieName: "seamless-access",
refreshCookieName: "seamless-refresh",
};

// Each handler builds cookies only from a complete session body. Anything short of
// one used to go back as-is, tokens included, in cookie transport too.
const HANDLERS = [
[
"verifyLoginOtpHandler",
"verifyLoginOtpHandler.js",
(fn, opts) => fn({ kind: "email", body: { code: "1" } }, opts),
{ message: "partial", refreshToken: "leaked" },
],
[
"pollMagicLinkConfirmationHandler",
"pollMagicLinkConfirmationHandler.js",
(fn, opts) => fn({}, opts),
{ message: "partial", token: "leaked", sub: "user-1" },
],
[
"switchOrganizationHandler",
"switchOrganizationHandler.js",
(fn, opts) => fn({ organizationId: "org-1" }, opts),
{ message: "partial", token: "leaked", refreshToken: "leaked" },
],
[
"verifyMagicLinkHandler",
"verifyMagicLinkHandler.js",
(fn, opts) => fn({ token: "link" }, opts),
{ message: "verified", token: "leaked" },
],
];

describe("handlers given an incomplete session body", () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
});

it.each(HANDLERS)(
"%s drops tokens in cookie transport",
async (name, file, run, body) => {
const mod = await import(`../dist/handlers/${file}`);
global.fetch = jest.fn(async () => jsonResponse(200, body));

const result = await run(mod[name], SESSION_OPTS);

expect(result.setCookies).toBeUndefined();
expect(result.body).not.toHaveProperty("token");
expect(result.body).not.toHaveProperty("refreshToken");
expect(result.body.message).toBe(body.message);
},
);

it.each(HANDLERS)(
"%s returns the body whole in bearer transport",
async (name, file, run, body) => {
const mod = await import(`../dist/handlers/${file}`);
global.fetch = jest.fn(async () => jsonResponse(200, body));

const result = await run(mod[name], { ...SESSION_OPTS, transport: "bearer" });

expect(result.body).toEqual(body);
},
);
});
Loading
Loading