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
19 changes: 19 additions & 0 deletions .changeset/adapter-manifest-proxy.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
---

Serve auth API routes from the adapter manifest (#201).

The auth API publishes which token each route takes and which tokens its response issues or clears at `/.well-known/seamless-adapter.json`. Every adapter now serves any route listed there that it has no handler of its own for, so a new API route works without a new release of these packages. Routes with their own handlers behave as before.

- Routes that had no passthrough now work: TOTP sign-in (`POST /totp/verify-login`), `POST /registration/phone` and `/registration/phone/verify`, `POST /admin/users/import`, and anything the API adds later.
- `@seamless-auth/nextjs` returns a `PUT` handler, so the OAuth provider retirement routes are reachable. Export it from the catch-all route: `export const { GET, POST, PUT, PATCH, DELETE } = createSeamlessAuthHandler(...)`.
- Fastify serves manifest routes whatever their path casing, as Express and Next.js already did.
- Routes served from the manifest never return `token` or `refreshToken` to the browser in cookie transport.
- `ensureCookies` takes optional `method` and `manifest`, and then loads the cookie the manifest names for the route.
- Core exports `createAdapterManifestSource`, `matchManifestRoute`, `handleManifestRoute`, `parseAdapterManifest`, `buildManifestPath` and `ADAPTER_MANIFEST_PATH`.

Each adapter fetches the manifest from the auth API on its first request, waiting up to five seconds, and keeps it for the life of the process. If the API does not serve one (versions before the manifest), it uses the copy bundled with the package and tries again a minute later. Tests that mock `fetch` will see this extra request; pass `fetchManifest: false` to use only the bundled copy.
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ packages/
error path.
- The adapters bridge to `seamless-auth-api`; when behavior looks off, check the
API's route/token/JWKS contract before changing code here.
- A new API route needs no change here. Each adapter serves any route in the
API's adapter manifest (`/.well-known/seamless-adapter.json`) that it has no
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.

## Tooling

Expand Down
15 changes: 15 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ remain for direct imports.
- `signSessionCookie(...)` / `resolveCookieSameSite(...)` – cookie format and policy
- `authFetch(...)` – calls the auth API with the adapter's headers and a tolerant `json()`

**The adapter manifest**

The auth API publishes, at `ADAPTER_MANIFEST_PATH` (`/.well-known/seamless-adapter.json`), which
token each route takes and which tokens its response issues or clears. Adapters serve every route it
lists that they have no handler of their own for, so a new API route works without a new release
of this package.

- `createAdapterManifestSource({ authServerUrl, fetchManifest })` – fetches the manifest once,
falling back to the copy bundled with this version (`fetchManifest: false` uses only that copy)
- `matchManifestRoute(manifest, method, path)` – finds the route and its path parameters
- `handleManifestRoute(input, opts)` – proxies a matched route: sends the held token it names, stores
the session it issues, clears what it clears, and keeps tokens out of cookie-transport bodies
- `parseAdapterManifest(...)` / `buildManifestPath(...)` – validation and upstream path building
- `ensureCookies` takes optional `method` and `manifest`, and then loads the cookie the manifest names

**Auth flow handlers**

`loginHandler`, `finishLoginHandler`, `registerHandler`, `finishRegisterHandler`,
Expand Down
61 changes: 50 additions & 11 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { verifyCookieJwt } from "./verifyCookieJwt.js";
import type { ResultFailure } from "./result.js";
import { refreshAccessToken } from "./refreshAccessToken.js";
import { assertSecrets } from "./validateSecrets.js";
import {
type AdapterManifest,
matchManifestRoute,
} from "./manifest/adapterManifest.js";
import type { AuthServerIssuerOption } from "./authServerIssuer.js";
import {
issueSessionCookies,
Expand All @@ -11,6 +15,9 @@ import {
export interface EnsureCookiesInput {
path: string;
cookies: Record<string, string | undefined>;
/** With `manifest`, the route's credential comes from the manifest. */
method?: string;
manifest?: AdapterManifest;
}

export interface CookiePayload {
Expand Down Expand Up @@ -225,6 +232,46 @@ const COOKIE_REQUIREMENTS: Record<
},
};

const MANIFEST_CREDENTIAL_COOKIES = {
preAuth: "preAuthCookieName",
registration: "registrationCookieName",
access: "accessCookieName",
} as const;

/**
* Which cookie a request needs, if any.
*
* The manifest wins for any route it lists. The table remains for requests the
* manifest cannot place, such as one made without a method or a manifest.
*/
function cookieRequirement(
input: EnsureCookiesInput,
): { name: keyof EnsureCookiesOptions; required: boolean } | undefined {
if (input.manifest && input.method) {
const match = matchManifestRoute(input.manifest, input.method, input.path);

if (match) {
const { credential } = match.route;

return credential === "none" || credential === "refresh"
? undefined
: { name: MANIFEST_CREDENTIAL_COOKIES[credential], required: true };
}
}

// Match case-insensitively: Express route matching is case-insensitive by
// default, so a client may send a path whose casing differs from the mounted
// route (e.g. "/webauthn/..." vs "/webAuthn/..."). A case-sensitive miss here
// would silently skip cookie loading and break the request downstream, so the
// comparison is normalized to lower case on both sides.
const requestPath = input.path.toLowerCase();
const match = Object.entries(COOKIE_REQUIREMENTS).find(([path]) =>
requestPath.startsWith(path.toLowerCase()),
);

return match?.[1];
}

async function refreshRequiredCookie(
cookieName: string,
refreshCookie: string | undefined,
Expand Down Expand Up @@ -304,21 +351,13 @@ export async function ensureCookies(
): Promise<EnsureCookiesResult> {
assertSecrets(opts);

// Match case-insensitively: Express route matching is case-insensitive by
// default, so a client may send a path whose casing differs from the mounted
// route (e.g. "/webauthn/..." vs "/webAuthn/..."). A case-sensitive miss here
// would silently skip cookie loading and break the request downstream, so the
// comparison is normalized to lower case on both sides.
const requestPath = input.path.toLowerCase();
const match = Object.entries(COOKIE_REQUIREMENTS).find(([path]) =>
requestPath.startsWith(path.toLowerCase()),
);
const requirement = cookieRequirement(input);

if (!match) {
if (!requirement) {
return { type: "ok" };
}

const [, { name, required }] = match;
const { name, required } = requirement;

// A not-required entry marks a route that is explicitly ungated: it must pass
// through regardless of which cookies are (or are not) present, so a stale or
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export type { AuthServerIssuerOption } from "./authServerIssuer.js";
export * from "./authMessaging.js";
export * from "./deliverAuthMessage.js";
export * from "./ensureCookies.js";
export * from "./manifest/adapterManifest.js";
export * from "./manifestProxy.js";
export * from "./verifyCookieJwt.js";
export * from "./verifyRefreshCookie.js";
export * from "./verifySignedAuthResponse.js";
Expand Down
Loading
Loading