@xpfarm/auth
v0.1.0
Published
Verify XP Farm Auth tokens and plug XP Farm Studio products into auth.xpfarmstudio.com
Readme
@xpfarm/auth
Verify XP Farm Auth access tokens, protect APIs and MCP servers, and sign users in from product web apps. Runs on Cloudflare Workers, Node 22+, Bun, Deno and AWS Lambda (Web Crypto and fetch only).
| Entry | Use it for |
|---|---|
| @xpfarm/auth | Verify access tokens, claim helpers, MCP metadata helpers |
| @xpfarm/auth/hono | requireAuth() middleware and mcpProtectedResource() for Hono |
| @xpfarm/auth/express | The same for Express 4 and 5 |
| @xpfarm/auth/web | Server-side OIDC client (login, session cookie, refresh, logout) |
| @xpfarm/auth/webhooks | verifyWebhook(): check and parse webhooks sent by XP Farm Auth |
hono and express are optional peer dependencies: install only the one you use.
Verify a token
import { bearerChallenge, bearerToken, createVerifier, hasRole } from "@xpfarm/auth";
const RESOURCE = "https://api.myproduct.com";
const verify = createVerifier({ audience: RESOURCE }); // create once, reuse
const token = bearerToken(request.headers.get("authorization"));
if (!token) return new Response(null, { status: 401, headers: { "WWW-Authenticate": bearerChallenge(RESOURCE) } });
const auth = await verify(token); // throws InvalidTokenError
auth.userId; auth.tenantId; auth.roles; hasRole(auth, "editor");Hono
import { Hono } from "hono";
import { type AuthVariables, mcpProtectedResource, requireAuth } from "@xpfarm/auth/hono";
const RESOURCE = "https://mcp.myproduct.com/mcp";
const app = new Hono<{ Variables: AuthVariables }>();
app.route("/", mcpProtectedResource({ resource: RESOURCE, scopes: ["myproduct:read"] })); // RFC 9728, CORS *
app.use("/mcp/*", requireAuth({ audience: RESOURCE, scopes: ["myproduct:read"], roles: ["editor"] }));
app.post("/mcp", (c) => c.json({ user: c.var.auth.userId }));Missing or invalid token: 401 with WWW-Authenticate. Missing scope: 403 insufficient_scope. roles is "any of"; scopes is "all of".
Express (4 and 5)
import express from "express";
import { mcpProtectedResource, requireAuth } from "@xpfarm/auth/express";
const RESOURCE = "https://mcp.myproduct.com/mcp";
const app = express();
app.use(mcpProtectedResource({ resource: RESOURCE })); // mount at the root
app.use("/mcp", requireAuth({ audience: RESOURCE }));
app.post("/mcp", (req, res) => res.json({ user: req.auth?.userId })); // `req.auth` is typedWeb apps (backend-for-frontend)
Tokens stay on the server, in an encrypted HttpOnly cookie (__Host- prefixed on https). Works on any Request/Response runtime: Hono, Next.js route handlers, Remix, Workers.
import { createAuthClient } from "@xpfarm/auth/web";
const auth = createAuthClient({
clientId: "cli_…",
clientSecret: process.env.XPF_CLIENT_SECRET, // omit for public clients
redirectUri: "https://app.myproduct.com/auth/callback",
postLogoutRedirectUri: "https://app.myproduct.com/",
resource: "https://api.myproduct.com", // audience of the access token, if you call your API with it
cookieSecret: process.env.COOKIE_SECRET!, // 32+ random characters
});
// GET /login
export const login = (req: Request) => auth.login(req, { returnTo: "/dashboard" });
// GET /auth/callback — throws AuthClientError if the response can't be trusted
export const callback = (req: Request) => auth.callback(req);
// Anywhere you need the user: refreshes the access token when it is close to expiry
const { session, headers } = await auth.getSession(request);
if (!session) return auth.login(request, { returnTo: new URL(request.url).pathname });
return new Response(JSON.stringify(session.user), { headers }); // `headers` carries any refreshed Set-Cookie
// POST /logout
export const logout = (req: Request) => auth.logout(req);
// POST /auth/backchannel-logout — register this URL as the client's backchannel_logout_uri
export const backchannel = (req: Request) =>
auth.handleBackchannelLogout(req, { onLogout: (sid, sub) => revoke(sid, sub) });Because the session lives in a cookie, back-channel logout can't delete it directly. Record the revoked sid in onLogout and pass isSessionRevoked to createAuthClient so getSession rejects it.
Webhooks
XP Farm Auth can call your product when members, roles, tenants or access change (see docs/api/webhooks.md in the repository for the events). Deliveries follow Standard Webhooks: verify each one with the secret returned when you created the endpoint.
import { verifyWebhook, WebhookVerificationError } from "@xpfarm/auth/webhooks";
// POST /webhooks/xpfarm — pass the request untouched, the signature covers the exact body
export async function handler(request: Request) {
try {
const event = await verifyWebhook(request, process.env.XPF_WEBHOOK_SECRET!, { toleranceSeconds: 300 });
if (event.type === "roles.changed") await syncRoles(event.data.userId, event.data.tenantId, event.data.roles);
return new Response(null, { status: 204 }); // any 2xx; anything else is retried for about a day
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response(null, { status: 400 });
throw err;
}
}eventis typed byevent.type.event.idis the same on every retry: deduplicate on it.- Behind Express, pass the raw body:
verifyWebhook({ headers: req.headers, body: rawBody }, secret)(useexpress.raw({ type: "application/json" })). - While rotating a secret, pass both:
verifyWebhook(request, [newSecret, oldSecret]). - The check uses Web Crypto and a constant-time comparison, and rejects timestamps further than
toleranceSeconds(default 300) from now.
This package is built for XP Farm Studio products and is provided as-is, without support.
