Skip to content

Repository files navigation

fedcm-server

npm version CI license: MIT

Turn a Node.js app into a FedCM Identity Provider. FedCM (Federated Credential Management, spec) is a browser-mediated federated sign-in flow: the relying party calls navigator.credentials.get({ identity: ... }), the browser fetches a small set of well-known endpoints from your IdP, renders its own account chooser, and hands the RP an assertion token — no third-party cookies, no redirect dance. fedcm-server implements that IdP endpoint surface as a framework-agnostic route map with thin adapters for Express, Fastify, Hono, and raw node:http. You supply an adapter that answers "who is signed in?" and "mint a token for this client"; the library handles the protocol shape and the browser-facing security checks.

Features

  • Framework-agnostic corecreateFedCMRoutes(config) returns plain { method, path, handler } routes over a small request/response contract, with adapters for Express, Fastify, Hono, and node:http.
  • All FedCM IdP endpoints: /.well-known/web-identity, IdP config, accounts, ID assertion, client metadata, disconnect.
  • Sec-Fetch-Dest: webidentity validation on every credentialed endpoint, plus per-request CORS headers on the assertion and disconnect responses.
  • Login Status API helperSet-Login: logged-in|logged-out header generation.
  • Safari/ITP popup fallback (opt-in, non-standard): a served client script plus a popup endpoint for browsers that do not implement FedCM.
  • Dev server with an in-memory mock account store, strict TypeScript types throughout.
  • ESM-only, zero runtime dependencies (framework packages are optional peer dependencies).

Install

npm install fedcm-server

Requirements:

  • Node.js >= 18 (tested on Node 20+ in CI)
  • ESM only. The package ships "type": "module" with no CommonJS build — use import, not require().
  • express / fastify / hono are optional peer dependencies; the core and the node:http adapter need nothing.
  • HTTPS in production — browsers only run FedCM against secure origins (localhost excepted).

Quickstart (Express)

import express from "express";
import { createFedCMMiddleware, setLoginStatus } from "fedcm-server/express";
import type { FedCMAccountAdapter } from "fedcm-server";

// Registered out of band: which RP origins may use which client_id.
// The FedCM spec requires the IdP to enforce this — see "The adapter contract".
const CLIENT_ORIGINS: Record<string, readonly string[]> = {
  "rp-web-app": ["https://app.example.com"],
};

const adapter: FedCMAccountAdapter = {
  async getAccounts(cookies) {
    const user = await lookupSession(cookies["session"]);
    if (!user) {
      return null; // -> 401, the browser treats the IdP as signed out
    }

    return [{
      id: user.id,
      name: user.fullName,
      email: user.email,
      given_name: user.firstName,
      picture: user.avatarUrl,
      approved_clients: user.approvedClientIds, // client_ids that already have consent
    }];
  },

  async createAssertion({ accountId, clientId, origin, nonce }) {
    // 1. Is this client_id allowed to be used from this origin? (Required — see below.)
    if (!CLIENT_ORIGINS[clientId]?.includes(origin)) {
      return { error: { code: "unauthorized_client" } };
    }

    // 2. Mint a token bound to this client (audience) and this nonce (replay).
    const token = await mintIdToken({ sub: accountId, aud: clientId, nonce });
    return { token };
  },

  async getClientMetadata(clientId) {
    const client = await lookupClient(clientId);
    return {
      privacy_policy_url: client?.privacyPolicyUrl,
      terms_of_service_url: client?.termsUrl,
    };
  },

  async disconnect({ accountHint, clientId }) {
    const user = await revokeGrant(accountHint, clientId);
    return { accountId: user.id };
  },
};

const app = express();

// Mount at the app root: the middleware owns absolute paths such as
// /.well-known/web-identity.
app.use(createFedCMMiddleware({ issuer: "https://idp.example.com", adapter, loginUrl: "/login" }));

// Tell the browser about login state on every auth transition (Login Status API):
// once it records "logged-out" it stops fetching your accounts endpoint until you
// send "logged-in" again.
app.post("/login", async (req, res) => {
  await signIn(req, res);
  setLoginStatus(res, "logged-in");
  res.redirect("/");
});

app.post("/logout", async (req, res) => {
  await signOut(req, res);
  setLoginStatus(res, "logged-out");
  res.redirect("/login");
});

app.listen(3000);

Session cookies must be readable in the FedCM (cross-site) context: set them SameSite=None; Secure; HttpOnly.

Endpoints served

Paths are the defaults from src/core/defaults.ts and are individually overridable via config.

Route Method Default path Role in the flow
wellKnown GET /.well-known/web-identity Discovery. Returns { "provider_urls": ["<issuer><configPath>"] } so the browser can confirm the IdP sanctions this config URL. Path is fixed by the spec.
config GET /fedcm/config.json The IdP manifest: accounts_endpoint, client_metadata_endpoint, id_assertion_endpoint, disconnect_endpoint, login_url, plus branding / account_label / itp_support when configured.
accounts GET /fedcm/accounts Credentialed. Calls adapter.getAccounts(cookies) and returns { accounts: [...] }; null becomes 401 { error: "Not signed in" }, which the browser reads as signed out.
assertion POST /fedcm/assertion Credentialed, form-encoded (account_id, client_id, nonce, disclosure_text_shown, is_auto_selected). Calls adapter.createAssertion and returns 200 { token } or 403 { error: { code, url } }.
clientMetadata GET /fedcm/client-metadata?client_id=… Privacy policy / terms URLs the browser shows in its disclosure UI.
disconnect POST /fedcm/disconnect Form-encoded (account_hint, client_id). Calls adapter.disconnect and returns { account_id }.
itpScript GET /fedcm/connect.js Only when itpSupport.enabled — serves the client fallback script.
itpPopup GET /fedcm/popup Only when itpSupport.enabled — the popup authentication page.

accounts, assertion, clientMetadata and disconnect reject any request without Sec-Fetch-Dest: webidentity with 400 { error: "Missing required Sec-Fetch-Dest: webidentity header" }. That header cannot be set by fetch()/XHR, so it is what keeps these endpoints reachable only from the browser's FedCM machinery. assertion and disconnect additionally require an Origin header and echo it back with Access-Control-Allow-Origin / Access-Control-Allow-Credentials: true.

Configuration

type FedCMIssuer = string | ((req: FedCMRequest) => string);

interface FedCMServerConfig {
  issuer: FedCMIssuer;            // absolute base URL; builds the well-known provider URL
  adapter: FedCMAccountAdapter;   // your implementation (see below)
  loginUrl?: string;              // default "/login" — advertised as config.json login_url
  branding?: FedCMBranding;       // background_color, color, icons: [{ url, size }]
  accountLabel?: string;          // config.json account_label
  itpSupport?: ITPSupportConfig;  // opt-in Safari fallback (see below)
  // configPath / accountsPath / assertionPath / clientMetadataPath / disconnectPath
  // override the default paths listed in the table above.
}

issuer may be a function when one server answers on several hostnames (localhost, a LAN IP, a public hostname). It is resolved per request, so /.well-known/web-identity advertises a provider_urls entry matching the origin the browser is actually on — a fixed string would mismatch and the browser would reject the config URL. Validate the host against an allowlist; never echo the Host header back unchecked.

const ALLOWED_HOSTS = new Set(["idp.example.com", "192.168.1.20:8443"]);

createFedCMRoutes({
  issuer: (req) => {
    const host = req.headers["host"];
    return `https://${host && ALLOWED_HOSTS.has(host) ? host : "idp.example.com"}`;
  },
  adapter,
});

The adapter contract

FedCMAccountAdapter is the entire integration surface. Everything that depends on your identity store, your client registry, and your token format lives here.

interface FedCMAccountAdapter {
  getAccounts(cookies: Record<string, string>): Promise<readonly FedCMAccount[] | null>;

  createAssertion(params: {
    accountId: string; clientId: string; origin: string;
    nonce?: string; params: Readonly<Record<string, unknown>>; rawParams?: string;
    disclosureTextShown: boolean; isAutoSelected: boolean;
  }): Promise<{ token: string } | { error: { code?: string; url?: string } }>;

  getClientMetadata(
    clientId: string,
  ): Promise<{ privacy_policy_url?: string; terms_of_service_url?: string }>;

  disconnect(params: { accountHint: string; clientId: string }): Promise<{ accountId: string }>;
}

getAccounts(cookies)

Resolve the session from the request cookies (already parsed into a flat record by the adapter) and return the signed-in accounts. Return null — not [] — when there is no session; the endpoint answers 401 and the browser treats the IdP as logged out. Account fields are FedCM's snake_case wire names:

interface FedCMAccount {
  id: string;                     // stable account id, echoed back as account_id on assertion
  name: string;                   // display name
  email: string;
  tel?: string;
  username?: string;
  given_name?: string;
  picture?: string;               // absolute avatar URL
  approved_clients?: readonly string[];  // client_ids that already have consent (skips disclosure UI)
  domain_hints?: readonly string[];
  label_hints?: readonly string[];
  login_hints?: readonly string[];
}

createAssertion(params)

Called with the account the user picked in the browser's chooser, the RP's client_id, the RP's origin (taken from the request's Origin header), the RP-supplied nonce, whether the browser showed disclosure text, and whether the credential was auto-selected. Return { token } (any string your RPs can verify — typically a signed JWT) or { error: { code, url } }, which the endpoint returns as 403.

Where the nonce comes from. Modern RPs pass a params object to navigator.credentials.get(), which reaches the id_assertion_endpoint as a single form field holding serialized JSON, and the nonce normally lives inside it — the top-level nonce field is being deprecated (w3c-fedid/FedCM#616). nonce is therefore resolved as params.nonce when that is a string, falling back to the legacy top-level field, then undefined. Read params.nonce yourself only if you need to distinguish the two sources.

The blob itself is handed over as well:

  • params — the parsed object. {} when the field is absent, malformed, or not a JSON object; parsing never throws into your adapter. Everything in it is untrusted RP input — validate any field you act on, and expect extra keys (scope, audience, whatever the RP sent).
  • rawParams — the field exactly as received, or undefined. Use it when a signature or audit record must cover the RP's original bytes rather than a re-serialization.

On the Safari/ITP popup path there is no params blob — that endpoint has its own query-parameter contract — so adapters see params: {} and the nonce query parameter there.

Security: origin↔client validation and token binding are your responsibility. The FedCM spec requires the IdP to verify, inside the assertion endpoint, that the requesting origin is a registered origin for the given clientId, and to bind the issued token to that client (audience) and to the request nonce (replay protection). This library deliberately does not do that: it has no client registry and no signing key. It validates Sec-Fetch-Dest: webidentity, requires and echoes Origin, and shapes responses — everything else is createAssertion's job. See the quickstart adapter above: a clientId -> allowed origins table, an early return { error: { code: "unauthorized_client" } } on mismatch, then a token carrying aud: clientId and the request nonce.

Skipping the origin check lets any site that learns a client_id obtain tokens for your users. Omitting aud lets a token minted for one RP be replayed at another; omitting nonce lets a captured token be replayed at the same RP. error.code may be one of the spec's known codes (invalid_request, unauthorized_client, access_denied, temporarily_unavailable, server_error) or your own string; error.url can point at a human-readable explanation page.

The remaining methods

  • getClientMetadata(clientId) — the RP's privacy policy and terms URLs for the browser's disclosure UI. Both fields optional; return {} for unknown clients. This endpoint is not credentialed with your session, so do not leak user data through it.
  • disconnect({ accountHint, clientId }) — revoke the grant for that account hint and client and return the affected { accountId }, echoed to the browser as account_id. Afterwards the browser drops its record of the account↔RP connection.

The login location is not part of the adapter: it comes from FedCMServerConfig.loginUrl, which is advertised as login_url and used by the ITP popup redirect.

Other adapters

Fastify

import Fastify from "fastify";
import { fedcmPlugin, setLoginStatus } from "fedcm-server/fastify";

const app = Fastify();
await app.register(fedcmPlugin, { issuer: "https://idp.example.com", adapter });
await app.listen({ port: 3000 });
// setLoginStatus(reply, "logged-in") inside your auth handlers

The plugin registers an application/x-www-form-urlencoded content-type parser and every route in the map, including the ITP routes when enabled.

Hono

import { Hono } from "hono";
import { createFedCMApp, setLoginStatus } from "fedcm-server/hono";

const app = new Hono();
app.route("/", createFedCMApp({ issuer: "https://idp.example.com", adapter }));
// setLoginStatus(c, "logged-out") inside your auth handlers

fedcmMiddleware(config) is an alias for createFedCMApp(config). It registers every route in the map, including the ITP routes when enabled.

Raw node:http

import { createServer } from "node:http";
import { createFedCMHandler, setLoginStatus } from "fedcm-server/node";

const handleFedCM = createFedCMHandler({ issuer: "https://idp.example.com", adapter });

createServer(async (req, res) => {
  // Returns true when the request matched a FedCM route and was answered.
  if (await handleFedCM(req, res)) {
    return;
  }
  // ... your own routing, calling setLoginStatus(res, "logged-in") on auth events
}).listen(3000);

No try/catch is needed around handleFedCM: like the other three adapters, it answers a throwing adapter with a generic 500 { "error": "Internal server error" } and always ends the response. The underlying error message is never sent to the browser — log it inside your own adapter if you need it.

Core route map

For any other framework, build the routes yourself: createFedCMRoutes(config) returns { wellKnown, config, accounts, assertion, clientMetadata, disconnect, itpScript?, itpPopup? }, each a { method, path, handler }. A handler takes { method, path, headers, cookies, body?, query? } (headers lower-cased, cookies and form bodies pre-parsed into flat records) and resolves to { status, headers, body }. Apply headers verbatim and switch on the body type: the protocol endpoints return an object to serialise as JSON, the ITP script and popup routes return a string to send as-is.

Safari / ITP popup fallback

Safari does not implement FedCM and blocks third-party cookies via ITP, so the native flow cannot run there. Opt into a popup-based fallback:

createFedCMRoutes({
  issuer: "https://idp.example.com",
  adapter,
  loginUrl: "/login",
  itpSupport: {
    enabled: true,
    allowedOrigins: ["https://app.example.com"], // exact RP origins, no wildcards
    scriptPath: "/fedcm/connect.js",             // optional, this is the default
    popupPath: "/fedcm/popup",                   // optional, this is the default
  },
});

This adds two routes. GET <scriptPath> serves a dependency-free client script (with the configured popupPath baked in) that defines window.FedCMConnect. GET <popupPath>?client_id=…&origin=…&nonce=… validates origin against allowedOrigins, calls adapter.getAccounts with the popup's first-party cookies, and then either calls adapter.createAssertion and renders a page that postMessages { type: "fedcm-credential", token } to window.opener before closing itself, or redirects to loginUrl with a redirect parameter pointing back at popupPath so the flow resumes after login. All four adapters (Express, Fastify, Hono, node:http) mount both routes automatically whenever itpSupport.enabled is set, and send them with their own content types — JavaScript for the script, HTML for the popup page.

The popup page is a top-level navigation only: it is served with X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none', and a request that arrives with a Sec-Fetch-Dest other than document is answered 403 before the adapter is called at all.

On the RP page:

<script src="https://idp.example.com/fedcm/connect.js"></script>
<script>
  // Uses native FedCM when available, otherwise the popup.
  const result = await window.FedCMConnect.get({
    configURL: "https://idp.example.com/fedcm/config.json",
    clientId: "rp-web-app",
    nonce: crypto.randomUUID(),
  });
  // result: { token, isAutoSelected?, source: "fedcm" | "popup" }
  await fetch("/session", { method: "POST", body: result.token });
</script>

window.FedCMConnect also exposes prompt(config) (renders a built-in sign-in sheet and invokes config.callback with the result, or config.onError with the failure), cancel(), and isSupported(). The result and config shapes are published as types — import type { FedCMConnectConfig, FedCMConnectResult } from "fedcm-server" — along with isFedCMSupported() for your own feature detection.

Caveats — read these before enabling:

  1. This popup flow is a non-standard extension, not part of the FedCM spec. The itp_support key ({ script_url, popup_url }) added to config.json when itpSupport is enabled is this library's own convention; browsers ignore it. Only clients that know about fedcm-server can use this path.
  2. The BroadcastChannel COOP fallback only works when the RP and IdP are same-origin. Token delivery is primarily window.opener.postMessage. If a Cross-Origin-Opener-Policy header on the popup response severs the opener reference, the popup falls back to a BroadcastChannel message — but BroadcastChannel never crosses origins, and the popup runs on the IdP origin. A cross-origin RP therefore receives nothing and fails with a popup-closed or timeout error (which carries a hint saying exactly this). Do not send COOP headers that sever window.opener on the popup response.
  3. The popup issues an assertion for the first account returned by getAccounts, with disclosureTextShown: false and isAutoSelected: false. There is no account chooser on this path, so it suits single-session IdPs. Your createAssertion origin↔client check still runs — and still must.

CDN usage

The same client script also ships prebuilt inside the npm tarball, so jsDelivr and unpkg serve it to RPs that cannot add an IdP-hosted <script> tag. Pin the version:

<script src="https://cdn.jsdelivr.net/npm/fedcm-server@0.1.2/dist/browser/fedcm-connect.min.js"></script>
<script>
  const result = await window.FedCMConnect.get({
    configURL: "https://idp.example.com/fedcm/config.json",
    clientId: "rp-web-app",
    nonce: crypto.randomUUID(),
    popupUrl: "/auth/fedcm-popup", // only when the IdP's popupPath is not the default
  });
</script>

The CDN bundle is generated with the default popup path /fedcm/popup baked in. If your IdP configured a different popupPath, pass popupUrl in the call config — it is resolved against the configURL origin — otherwise the popup opens a path the IdP does not serve. Serving the script from the IdP via createScriptHandler, which bakes the configured path in for you, stays the recommended default; the CDN copy is for RPs that cannot do that. dist/browser/fedcm-connect.js is the same bundle unminified.

Because this script acquires credentials, add Subresource Integrity: version-pinned jsDelivr URLs are immutable, so copy the sha384-… hash jsDelivr publishes for the pinned file (the SRI option on its jsDelivr page, or the hash field from https://data.jsdelivr.com/v1/packages/npm/fedcm-server@0.1.2) and load the tag as <script src="…" integrity="sha384-…" crossorigin="anonymous"></script>.

Dev server

A throwaway IdP for local testing, built on the node:http adapter and an in-memory mock store:

import { createDevServer } from "fedcm-server/dev";

const server = createDevServer({
  port: 3000,            // default 3000
  hostname: "localhost", // default "localhost"
  users: [{ id: "1", name: "Test User", email: "test@example.com", password: "password" }],
});

await server.start(); // await server.stop() to shut down

It serves all six FedCM endpoints plus / (status page), GET|POST /login, and POST /logout, sets Set-Login on login and logout, and logs every request. The default credentials are test@example.com / password. Tokens are unsigned base64url JSON — useful for inspecting the flow, never for anything else.

/login honours a ?redirect= parameter — the ITP popup sends logged-out users to loginUrl?redirect=<popup path> — and returns there after a successful sign-in. Only same-origin relative paths are followed: the value must start with /, must not start with // and must not contain :// or a backslash; anything else falls back to /.

The same subpath also exports the pieces the dev server is built from, so you can assemble your own test harness (custom HTTPS, extra routes, itpSupport) on top of the shipped mocks:

import { createMockStore, renderLoginPage } from "fedcm-server/dev";
import type { MockStore, MockUser, LoginPageConfig } from "fedcm-server/dev";

const store = createMockStore({ users });          // an in-memory FedCMAccountAdapter
const sessionId = store.login(email, password);    // plus login()/logout()
const html = renderLoginPage({ error: false, redirectTo: "/fedcm/popup?client_id=…" });

Example

examples/betterauth is a complete IdP backed by BetterAuth and SQLite, with a built-in RP test page, HTTPS dev mode via mkcert, browser capability detection, and the ITP popup fallback wired up. See its README for setup and Chrome DevTools/flag tips.

Browser support

Chrome and Edge 117+ implement FedCM natively; Safari does not, and is covered only by the opt-in popup fallback described above. For everything else, check the MDN compatibility tableisFedCMSupported() (exported from fedcm-server) is the runtime check.

Security

  • Serve over HTTPS in production. Browsers restrict FedCM to secure origins, and cross-site session cookies need SameSite=None; Secure.
  • Sec-Fetch-Dest: webidentity is enforced on the accounts, assertion, client-metadata, and disconnect endpoints. The ITP popup endpoint is a top-level navigation and is instead gated on the allowedOrigins list.
  • Validate origin against clientId and bind tokens to aud + nonce inside createAssertion — see the security note above. The library cannot do this for you.
  • Never return more than the browser needs from getAccounts and getClientMetadata.
  • Report vulnerabilities privately per SECURITY.md — please do not open public issues for them.

Contributing

Bug reports and pull requests are welcome. See CONTRIBUTING.md for the dev setup, project layout, and test expectations; notable changes are recorded in CHANGELOG.md.

License

MIT © HackPad Labs. See LICENSE.

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages