@hyper.authio/express
v0.9.0
Published
Express middleware for Authio: bearer validation, scope/role/permission/group/claim/organization/acr/amr/client guards, and signed webhook routes.
Maintainers
Readme
@hyper.authio/express
Protect an Express 4 or 5 API with Authio: bearer validation, guards for every form of permission and for the caller
kind, step-up challenges, protected resource metadata (RFC 9728), a back-channel logout endpoint, and a signed webhook
route. Built on @hyper.authio/server.
npm install @hyper.authio/expressimport express from "express";
import {
anyOf,
authioBearer,
authioWebhooks,
requireAuthenticationLevel,
requireAuthio,
requireGroup,
requireOrganization,
requirePermission,
requireRole,
requireScope,
} from "@hyper.authio/express";
const app = express();
// Before any JSON parser: the signature is checked over the raw body.
app.post(
"/webhooks/authio",
authioWebhooks({ secret: process.env.AUTHIO_WEBHOOK_SECRET }).on("user_deleted", async (event) => {
await forget(event.data.id);
}),
);
app.use(express.json());
app.use("/api", authioBearer({ issuer: "https://auth.example.com/realms/acme", audiences: "https://orders.example.com" }));
app.get("/api/me", (req, res) => res.json(req.authio!.user));
app.get("/api/orders", requireScope("orders:read"), requirePermission("orders", "read"), listOrders);
app.delete("/api/orders/:id", requireRole("orders-admin", { sources: ["user"] }), deleteOrder);
app.get("/api/finance", requireGroup("finance"), financeReport);
app.post("/api/payouts", requireAuthio(requirePermission("payouts", "create"), anyOf(requireAuthenticationLevel(2), requireOrganization("acme"))), payout);authioBearer(verifierOrOptions, { optional?, problemDetails?, passErrors? })validates the token and puts{ user, token }onreq.authio; it answers401withWWW-Authenticate. Withoptional, a request without a token goes through unauthenticated.requireScope,requireRole,requirePermission,requireGroup,requireClaim,requireOrganization,requireAuthenticationLevel,requireAuthenticationMethod,requireRecentAuthentication,requireCallerKindandrequireClientanswer403, withinsufficient_scopeinWWW-Authenticatewhen a scope is missing. A refusal that asks for a stronger or more recent sign-in answers401withinsufficient_user_authentication(withacr_valuesandmax_age), as RFC 9470 §3 has it.- With
requiredScopeson the verifier,authioBeareritself answers403 insufficient_scopewhen a token lacks one. - Every guard is also a rule:
requireAuthio(...),allOf(...)andanyOf(...)combine guards of this package and rules of@hyper.authio/serveralike, and are guards themselves. problemDetails: trueadds an RFC 9457 body;passErrors: truehandsAuthioBearerError/AuthioForbiddenErrortonext(error)for your own error handler.sendAuthioError(res, error)answers one by hand.authioWebhooks(options)reads the raw body itself (or takesexpress.raw()'s buffer), and answers200handled or duplicate,400,401,413or500.
Protected resource metadata (RFC 9728)
const verifier = createAuthioVerifier({ issuer, resource: "https://orders.example.com/api", resourceMetadata: true });
app.use(authioProtectedResourceMetadata({ verifier, resourceName: "Orders" })); // GET /.well-known/oauth-protected-resource/api
app.use("/api", authioBearer(verifier)); // 401: Bearer resource_metadata="https://orders.example.com/.well-known/oauth-protected-resource/api"Back-channel logout
const client = createAuthioClient({ issuer, clientId: "storefront", clientSecret });
app.post(
"/auth/backchannel-logout",
authioBackchannelLogout({
client,
onLogout: async ({ subject, sessionId }) => sessions.destroyWhere({ subject, sessionId }), // your session store
}),
);Register the URL as the client's back-channel logout URI in Authio. The handler reads logout_token (with or without
express.urlencoded()), validates it with client.validateLogoutToken, answers 200, 400 (invalid_request) for
a token that does not validate, and always sends Cache-Control: no-store.
Front-channel logout
app.get(
"/auth/frontchannel-logout",
authioFrontchannelLogout({
client,
onLogout: async ({ sessionId }, request) => (sessionId ? sessions.destroyWhere({ sessionId }) : sessions.destroy(request)),
}),
);Register the URL as the client's front-channel logout URI in Authio, with the session required so Authio sends iss
and sid. Authio loads it in an iframe when a session ends; the handler checks iss with
client.validateFrontChannelLogout, answers 200 with an empty page, 400 (invalid_request) for another issuer or a
sid without iss, and always sends Cache-Control: no-store.
License
MIT
