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
12 changes: 8 additions & 4 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ Use the summary as adopter-facing release notes. The release workflow turns
merged changesets into package changelogs, npm publishes, Git tags, and GitHub
Releases.

The JavaScript packages are currently linked so `@seamless-auth/core` and
official adapters move together while the API is pre-1.0. After the v1 contract
is stable, remove packages from the linked group when they can safely version
independently.
`@seamless-auth/core` and `@seamless-auth/express` are linked, so they move
together. After the v1 contract is stable, remove them from the linked group when
they can safely version independently.

`@seamless-auth/fastify` is deliberately outside that group. It starts at `0.1.0`
and versions on its own, so a newer adapter does not inherit a version number
that claims the maturity of the ones that have been shipping. Add it to the
linked group once it has the same track record.
14 changes: 14 additions & 0 deletions .changeset/adapter-guards-and-cookie-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": patch
---

Move the remaining guard decisions into core, and fully specify a cookie's lifetime in the response contract.

`checkOrigin`, `authenticateCookie`, and `authorizeRoles` join `checkProxyIdentity`: each takes the request facts a guard needs and returns a `GuardRejection` or nothing, so an adapter reads headers and cookies and writes a response but decides nothing. The Express origin guard, `requireAuth`, and `requireRole` now call them, which is a straight substitution with no behavior change.

`SeamlessAuthUser` comes from `@seamless-auth/types`, which already defines it, rather than being declared again here. It is re-exported from `@seamless-auth/core` and `@seamless-auth/express` under the same name, so nothing changes for adopters. The re-export is type-only, so it is erased at compile time and neither `zod` nor the schema barrel enters the runtime module graph.

`SetCookieCommand` and `ClearCookieCommand` now carry an `expires` alongside `maxAgeSeconds`. Previously the command specified only a max age, and two adapters could satisfy it while emitting different headers: Express sent both `Expires` and `Max-Age`, and a second adapter sent only `Max-Age`, which older clients treat as a session cookie. Specifying both means every adapter emits the same header for the same instruction. Clearing carries the epoch for the same reason.

No change to what `@seamless-auth/express` sends. The `expires` it now receives explicitly is the value it was already deriving.
11 changes: 11 additions & 0 deletions .changeset/fastify-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@seamless-auth/fastify": minor
---

Add `@seamless-auth/fastify`, a Fastify adapter serving the same routes as the Express adapter.

Register it under a prefix and it serves the auth flows, the passthrough proxy routes, and the admin, session, metrics, and system-config routes, managing the session cookies they depend on. `requireAuth` and `requireRole` are exported as `preHandler` hooks for an adopter's own routes, alongside `getSeamlessUser`.

Both adapters emit identical responses. A parity suite runs the same requests through each against the same mocked auth API and asserts the status, body, and every `Set-Cookie` header match, so the two cannot drift.

`createSeamlessConsoleProxy` has no Fastify equivalent yet. It proxies the admin console's static assets and is separate from the auth routes.
6 changes: 4 additions & 2 deletions .changeset/unify-result-failure-contract.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
"@seamless-auth/core": major
"@seamless-auth/express": major
"@seamless-auth/core": minor
"@seamless-auth/express": minor
---

Split the handler result `error` field into `errorCode` and `errorBody`.

BREAKING for direct consumers of the handler result types. Released as a minor because these packages are pre-1.0, where a minor is the breaking bump. The details are below.

`error` meant two different things depending on which handler produced it. On 12 sites it held the auth API's whole failure body, forwarded to the caller unchanged. On 8 sites it held a short code that the adapter wrapped as `{ error }`. The declared types could not describe either honestly, and `FinishLoginResult` declared `error?: string` while assigning the whole body. Nothing in the type told an adapter which rendering applied, which is the first thing a second adapter has to get right.

Failures are now reported through `ResultFailure`, exported from `@seamless-auth/core`:
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/applyResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export interface SetCookieCommand {
value: string;
domain?: string;
maxAgeSeconds: number;
/**
* The same lifetime as `maxAgeSeconds`, as an absolute time.
*
* Both are specified so every adapter emits the same header. `Max-Age` wins
* wherever it is understood; `Expires` is the fallback for clients that do
* not, which would otherwise treat the cookie as a session cookie. Leaving
* this to the adapter is how two adapters end up issuing different cookies for
* the same session.
*/
expires: Date;
httpOnly: boolean;
secure: boolean;
sameSite: CookieSameSite;
Expand All @@ -39,11 +49,20 @@ export interface SetCookieCommand {
export interface ClearCookieCommand {
name: string;
domain?: string;
/**
* The epoch, which is what tells the browser to drop the cookie. Specified
* here for the same reason as on the set path: so every adapter emits the
* same header rather than each picking its own expression of "delete this".
*/
expires: Date;
secure: boolean;
sameSite: CookieSameSite;
path: string;
}

/** Any time in the past drops the cookie; the epoch is the conventional one. */
const COOKIE_EPOCH = new Date(0);

/**
* The three things an adapter has to be able to do with its framework's
* response. Everything else about turning a handler result into a response,
Expand Down Expand Up @@ -117,6 +136,7 @@ export function applyCookies(
adapter.clearCookie({
name,
domain: opts.cookieDomain,
expires: COOKIE_EPOCH,
secure,
sameSite,
path: "/",
Expand All @@ -127,12 +147,15 @@ export function applyCookies(
if (result.setCookies?.length) {
const secret = requireSecret(opts);

const now = Date.now();

for (const cookie of result.setCookies) {
adapter.setCookie({
name: cookie.name,
value: signSessionCookie(cookie.value, secret, cookie.ttl),
domain: cookie.domain,
maxAgeSeconds: cookie.ttl,
expires: new Date(now + cookie.ttl * 1000),
httpOnly: true,
secure,
sameSite,
Expand Down
186 changes: 186 additions & 0 deletions packages/core/src/guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import type { SeamlessAuthUser } from "@seamless-auth/types";

import { resolveCookieSameSite, type CookieSameSite } from "./applyResult.js";
import { hasScopedRole } from "@seamless-auth/types/role/matching";
import { assertSecretStrength } from "./validateSecrets.js";
import { verifyCookieJwt } from "./verifyCookieJwt.js";

/**
* The session a verified access cookie describes: the access token's claims as
* a resource server reads them off a request. Distinct from `MeUser`, which is
* the hydrated profile fetched from the auth API.
*
* A type-only re-export, so it costs nothing at runtime: the import is erased at
* compile time and neither zod nor the schema barrel enters the module graph.
*/
export type { SeamlessAuthUser } from "@seamless-auth/types";

/**
* A guard's decision to refuse a request. Adapters render it as
* `{ error: errorCode, ...detail }` and log `warn` when it is set.
*/
export interface GuardRejection {
status: number;
errorCode: string;
detail?: Record<string, unknown>;
warn?: string;
}

// GET/HEAD are read-only and OPTIONS is the CORS preflight, so none can carry a
// state change worth gating.
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);

export interface OriginCheckInput {
method: string;
/** `Sec-Fetch-Site`, already reduced to a single value. */
secFetchSite?: string;
/** `Origin`, already reduced to a single value. */
origin?: string;
cookieSecure?: boolean;
cookieSameSite?: CookieSameSite;
allowedOrigins?: string[];
}

function normalizeOrigin(value: string): string {
return value.trim().toLowerCase();
}

/**
* 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.
*
* `Sec-Fetch-Site` is the primary signal: current browsers send it and page
* JavaScript cannot forge it. When it is absent the `Origin` is matched against
* `allowedOrigins`, but only when the adopter opted in, so nothing regresses for
* callers that predate the guard.
*/
export function checkOrigin(
input: OriginCheckInput,
): GuardRejection | undefined {
const active = resolveCookieSameSite(input) === "none";

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

const rejection: GuardRejection = {
status: 403,
errorCode: "cross_site_request_blocked",
};

if (input.secFetchSite !== undefined) {
return input.secFetchSite.toLowerCase() === "cross-site"
? rejection
: undefined;
}

// No `Origin` and no `Sec-Fetch-Site` is a same-origin or non-browser
// server-to-server caller.
if (input.origin === undefined) {
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;
}

const allowed = new Set(
input.allowedOrigins.map(normalizeOrigin).filter(Boolean),
);

return allowed.has(normalizeOrigin(input.origin)) ? undefined : rejection;
}

export interface CookieAuthInput {
/** The raw access cookie, or `undefined` when the request carried none. */
token?: string;
cookieSecret: string;
}

export type CookieAuthResult =
| { user: SeamlessAuthUser; rejection?: undefined }
| { user?: undefined; rejection: GuardRejection };

/**
* Verifies an access cookie into a session.
*
* Does not refresh: silent refresh belongs to `ensureCookies`, mounted on the
* auth router. A guard on an adopter's own route only reads what is already
* there.
*/
export function authenticateCookie(input: CookieAuthInput): CookieAuthResult {
assertSecretStrength("requireAuth: cookieSecret", input.cookieSecret);

if (!input.token) {
return {
rejection: {
status: 401,
errorCode: "Failed to find authentication token required",
warn: "Missing expected auth cookie.",
},
};
}

const payload = verifyCookieJwt(input.token, input.cookieSecret);

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

return {
user: {
id: payload.sub,
roles: Array.isArray(payload.roles) ? payload.roles : [],
email: payload.email,
phone: payload.phone,
iat: payload.iat,
exp: payload.exp,
token: payload.token,
},
};
}

/**
* Authorization only, against a session a guard has already authenticated.
*
* Any one of the required roles is enough. Scoped names are understood: a broad
* `admin` grants everything under it, and a `:write` role grants the matching
* `:read`.
*/
export function authorizeRoles(
user: SeamlessAuthUser | undefined,
requiredRoles: string | string[],
): GuardRejection | undefined {
const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];

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

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

if (!hasScopedRole(user.roles, roles)) {
return {
status: 403,
errorCode: "Insufficient role",
detail: { required: roles, actual: user.roles },
};
}

return undefined;
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./verifyRefreshCookie.js";
export * from "./verifySignedAuthResponse.js";
export * from "./refreshAccessToken.js";
export * from "./getSeamlessUser.js";
export * from "./guards.js";
export * from "./createServiceToken.js";
export * from "./validateSecrets.js";
// Role matching decides whether a request is authorized, and the auth API runs
Expand Down
25 changes: 25 additions & 0 deletions packages/core/tests/applyResult.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,38 @@ describe("cookies", () => {
{
name: "seamless-ephemeral",
domain: "acme.test",
expires: new Date(0),
secure: true,
sameSite: "none",
path: "/",
},
]);
});

// Both lifetimes are specified so every adapter emits the same header. Left to
// the adapter, one framework sends Max-Age only and another sends both, and
// the two issue different cookies for the same session.
it("gives a set cookie both a max age and a matching absolute expiry", () => {
const adapter = recorder();
const before = Date.now();

applyResult(cookieResult, adapter, { cookieSecret: SECRET });

const { maxAgeSeconds, expires } = adapter.calls.set[0];
expect(maxAgeSeconds).toBe(300);
expect(expires).toBeInstanceOf(Date);
expect(expires.getTime()).toBeGreaterThanOrEqual(before + 300 * 1000);
expect(expires.getTime()).toBeLessThanOrEqual(Date.now() + 300 * 1000);
});

it("clears with the epoch", () => {
const adapter = recorder();

applyResult(cookieResult, adapter, { cookieSecret: SECRET });

expect(adapter.calls.cleared[0].expires.getTime()).toBe(0);
});

it("clears before setting, since doing both replaces a session", () => {
const order = [];
const adapter = {
Expand Down
2 changes: 2 additions & 0 deletions packages/express/src/internal/respond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function expressResponseAdapter(res: Response): ResponseAdapter {
path: command.path,
domain: command.domain,
maxAge: command.maxAgeSeconds * 1000,
expires: command.expires,
});
},

Expand All @@ -33,6 +34,7 @@ export function expressResponseAdapter(res: Response): ResponseAdapter {
sameSite: command.sameSite,
domain: command.domain,
path: command.path,
expires: command.expires,
});
},

Expand Down
Loading
Loading