@hyper.authio/bff
v0.9.0
Published
Backend-for-Frontend for Authio on Express: server-side sessions, login/logout/user endpoints, anti-forgery, token refresh, remote API proxy and back-channel logout. Tokens never reach the browser.
Maintainers
Readme
@hyper.authio/bff
A Backend-for-Frontend for Authio on Express 4 and 5. It
lets a browser SPA (React, Angular, Vue, plain JavaScript…) sign in with Authio without ever holding a token,
following the BFF pattern of the IETF draft OAuth 2.0 for Browser-Based Applications. The Node counterpart of
Authio.AspNetCore.Bff: same endpoints, same cookie, same anti-forgery header, same option names. Built on
@hyper.authio/client.
npm install @hyper.authio/bffWhy a BFF
Tokens kept in the browser (local storage, session storage, memory) can be stolen by any script that runs on the page, and refresh tokens outlive the page. With a BFF:
- the SPA and the BFF share an origin; the browser only holds an
HttpOnly,Secure,SameSite=Lax, host-only cookie (__Host-authio) that carries a signed random session id and nothing else; - the BFF is a confidential client: it runs the authorization code flow with PKCE, keeps the access, refresh and ID tokens server-side (encrypted in the session store), refreshes them, and attaches the access token when it calls APIs on the person's behalf;
- every API call from the SPA carries a custom header (
X-CSRF: 1), which a cross-site page cannot add without a CORS preflight, so the cookie cannot be abused by CSRF.
Register the client at Authio
- Type: confidential, with a secret (
client_secret_basicby default, ortokenEndpointAuthMethod: "client_secret_post"), or withprivate_key_jwt(clientPrivateKeyorclientPrivateKeyPath, andclientKeyId). - PKCE: required with S256 is the recommended setting, mirrored by
checks: ["pkce", "state", "nonce"]andcodeChallengeMethod: "S256". The BFF checks only whatcheckslists: left out, it checks nothing. - Redirect URI:
https://<your-app>/signin-oidc(callbackPath). - Post-logout redirect URI:
https://<your-app>/signout-callback-oidc(signedOutCallbackPath). - Back-channel logout URI:
https://<your-app>/bff/backchannel-logout. - Front-channel logout URI (optional, with
frontChannelLogout: true):https://<your-app>/bff/frontchannel-logout, with the session required so Authio sendsissandsid. - Scopes:
openid profile email offline_access(offline_accessgives the refresh token), plusrolesif you setrequestRoles, plus your API scopes. - Resources (optional): the API resource identifiers you ask for with
resourceor use with remote APIs; allow the token exchange grant if a remote API needs a token for a specific resource or a narrower scope set.
Setup
import express from "express";
import { authioBff } from "@hyper.authio/bff";
const app = express();
const bff = authioBff({
authority: "https://auth.example.com",
realm: "acme",
clientId: "acme-web",
clientSecret: () => process.env.AUTHIO_CLIENT_SECRET!, // read on every call: a rotated secret needs no restart
checks: ["pkce", "state", "nonce"], // the client requires PKCE with S256: mirror it, nothing is checked by default
codeChallengeMethod: "S256",
cookieSecret: process.env.BFF_COOKIE_SECRET!, // 32+ characters; an array rotates them
requestRoles: true,
resource: "https://orders.example.com", // the session's token is addressed to the orders API
});
app.use(bff); // /bff/login, /bff/logout, /bff/user, /bff/silent-login, /bff/backchannel-logout, /bff/frontchannel-logout, /signin-oidc, /signout-callback-oidc
// Remote APIs: mount them before any body parser, so the body is streamed through.
app.use("/api/orders", bff.remoteApi("https://orders.example.com"));
app.use("/api/inventory", bff.remoteApi("https://inventory.example.com", { tokenMode: "clientCredentials", scopes: ["inventory:read"] }));
app.use("/api/billing", bff.remoteApi("https://billing.example.com", { resource: "https://billing.example.com" }));
app.use(express.json());
// Local APIs: the anti-forgery header and a session are required; req.authioBff holds the session, never a token.
app.get("/api/me", bff.api(), (req, res) => res.json({ name: req.authioBff!.claims.name }));
app.get("/api/report", bff.api(), async (req, res) => {
const reports = bff.userFetch(req, res, { resource: "https://reports.example.com" });
const answer = await reports("https://reports.example.com/monthly");
res.status(answer.status).json(await answer.json());
});
app.use(express.static("dist")); // the SPA
app.listen(5000);app.set("trust proxy", 1) behind a reverse proxy, so the redirect URIs are built with https; or set
redirectUri and postLogoutRedirectUri explicitly.
The SPA side
const api = (url, init = {}) => fetch(url, { ...init, credentials: "same-origin", headers: { ...init.headers, "X-CSRF": "1" } });
const response = await api("/bff/user");
if (response.status === 401) {
window.location.href = "/bff/login?returnUrl=" + encodeURIComponent(location.pathname);
} else {
const claims = await response.json();
const logoutUrl = claims.find((claim) => claim.type === "bff:logout_url").value;
}| Endpoint | Behaviour |
| --- | --- |
| GET /bff/login | Starts the sign-in. returnUrl (local only, else 400), organization, prompt (none, login, select_account, consent), login_hint, ui_locales, max_age, resource (must be resource or one of allowedResources, else 400 invalid_target). A failure comes back as returnUrl?error=<code> (access_denied, login_required, login_failed…). |
| GET /signin-oidc | The callback: finds the sealed sign-in cookie (by state with the state check), checks the RFC 9207 iss and the configured checks, redeems the code (with the PKCE verifier with pkce), validates the ID token (nonce with nonce, at_hash), reads the access token's claims once its signature verifies, creates the session and replaces any previous one. |
| GET /bff/user | With X-CSRF: 1: [{ "type", "value" }] — the session's claims (strings, one entry per array element), one role per Authio role and one permission (resource:method) per grant from the *_resource_access claims, bff:session_expires_in (seconds) and bff:logout_url. 401 without the header (antiforgery_required) or without a session (unauthorized), never a redirect. |
| GET\|POST /bff/logout | With the session's sid (from bff:logout_url) or X-CSRF: 1, optionally returnUrl: revokes the refresh token, deletes the session, clears the cookie and redirects to Authio's end-session endpoint with id_token_hint, post_logout_redirect_uri and a sealed state; /signout-callback-oidc then sends the browser to returnUrl. Without sid or the header, a signed-in person cannot be logged out by another site (400). |
| GET /bff/silent-login | A prompt=none sign-in for a hidden iframe; /bff/silent-login-callback posts { source: "authio-bff-silent-login", isLoggedIn, error } to the parent window (same origin only). |
| POST /bff/backchannel-logout | Authio's back-channel logout: validates the logout_token (validateLogoutToken) and deletes every session of that Authio session (sid), or of the person (sub) when the token names no sid. 400 invalid_request for a missing or invalid token. |
| GET /bff/frontchannel-logout | With frontChannelLogout: true, Authio's front-channel logout iframe: checks iss (validateFrontChannelLogout) and deletes every session of the sid it names, or the browser's own session when it names none; answers an empty page. 400 invalid_request for another issuer or a sid without iss; 404 when turned off. |
Calling APIs
Local APIs: bff.api() answers 401 antiforgery_required without the header and 401 unauthorized without a
session; bff.api({ optional: true }) lets anonymous calls through, { requireAntiForgery: false } drops the header
check. bff.getUserAccessToken(req, res, { resource?, scopes? }) and bff.userFetch(req, res, …) hand the person's
token to your own code.
Remote APIs: bff.remoteApi(destination, options) forwards everything below its mount path to destination,
strips the browser's cookies, the anti-forgery header and any incoming Authorization, attaches the token, drops
upstream Set-Cookie, streams both bodies, and answers 502 when the API cannot be reached.
| tokenMode | Token |
| --- | --- |
| user (default) | The person's access token; anonymous calls get 401. |
| userOrNone | The person's token when signed in, otherwise none. |
| clientCredentials | A client-credentials token of the BFF (cached by @hyper.authio/client), whoever is signed in. |
| userOrClientCredentials | The person's token when signed in, otherwise a client-credentials token. |
| none | No token. |
With resource or scopes, a user token is obtained by token exchange (RFC 8693, the result carries act) and
cached per session, resource and scopes; a client-credentials token is asked for them directly. When a remote API
answers 401 to a user call, the BFF introspects the session and ends it if Authio says it is gone
(validateSessionOnUnauthorized). requireAntiForgery: false is meant for anonymous APIs only.
Tokens for an API protected by Authio
An API protected with @hyper.authio/express or Authio.AspNetCore accepts only tokens addressed to it (aud = the
resource identifier in its audiences). Either make the session's token the API's with resource (or
/bff/login?resource=…, allowed by allowedResources), or give the remote API a resource so the session's token is
exchanged for one addressed to it. Keep offline_access when you set resource: the session check introspects the
refresh token, which stays addressed to the BFF.
Tokens and sessions
- Refresh: the access token is refreshed
tokenRefreshSkew(60 s) before it expires, under a lock per session, so concurrent requests share one refresh (Authio rotates refresh tokens and treats a late reuse as a replay). The rotated refresh token is stored before the lock is released. A refused refresh (invalid_grant) ends the session; a network error keeps the current token while it lasts. An ID token the refresh returns is validated (signature,iss,aud,exp,at_hash, the samesub) before it is stored; one that does not validate ends the session. - Claims: the session's claims are the validated ID token's, plus the claims only the access token carries
(
*_resource_access,org_id,org_alias,account_id) once the access token's signature,issandexpverify (client.readAccessTokenClaims), plus UserInfo's withgetClaimsFromUserInfoEndpoint. - Session check (
sessionValidation: "introspection", the default): everysessionValidationInterval(300 s) the refresh token (or the access token when there is none) is introspected; an inactive answer ends the session. Needs a confidential client. - Lifetime:
sessionLifetime(8 h) without activity; renewed once half of it has passed (slidingExpiration). The cookie is a browser-session cookie; the server-side expiry decides. - Cookie:
__Host-authio=<64 hex>.<HMAC-SHA256>,HttpOnly,Secure,SameSite=Lax(orstrict),Path=/, noDomain. A tampered or unknown cookie is cleared. - Store: in memory by default. For several instances, implement
AuthioBffSessionStoreover a shared store; the BFF encrypts each session (AES-256-GCM, fromcookieSecret) before it reaches the store and keeps onlysessionId(sid),subject(sub) andexpiresAtin clear, for indexing.
import type { AuthioBffSessionStore, AuthioBffStoredSession } from "@hyper.authio/bff";
const redisStore = (redis: Redis): AuthioBffSessionStore => ({
async get(key) {
const value = await redis.get(key);
return value ? (JSON.parse(value) as AuthioBffStoredSession) : undefined;
},
async set(session) {
const ttl = Math.max(1, Math.ceil((session.expiresAt - Date.now()) / 1000));
await redis.multi()
.set(session.key, JSON.stringify(session), "EX", ttl)
.sadd(`sid:${session.sessionId}`, session.key).expire(`sid:${session.sessionId}`, ttl)
.sadd(`sub:${session.subject}`, session.key).expire(`sub:${session.subject}`, ttl)
.exec();
},
async delete(key) {
await redis.del(key);
},
async deleteBySessionId(sessionId) {
const keys = await redis.smembers(`sid:${sessionId}`);
if (keys.length > 0) await redis.del(...keys, `sid:${sessionId}`);
},
async deleteBySubject(subject) {
const keys = await redis.smembers(`sub:${subject}`);
if (keys.length > 0) await redis.del(...keys, `sub:${subject}`);
},
});The refresh lock is local to the process, as in .NET: with several instances, keep a person on one instance (sticky sessions) or rely on Authio's short refresh-token reuse leeway.
Options
The client takes the names of conformance/options.md (issuer or authority +
realm, clientId, clientSecret, tokenEndpointAuthMethod, clientPrivateKey, clientKeyId,
clientEncryptionKey, encryptionAlgorithms, encryptionMethods, idTokenSignedResponseAlg, tokenRefreshSkew,
requireHttps, clock…), or an existing client. They mirror the client as it is registered in Authio, and an
option left out is off:
| In the console | Options |
| --- | --- |
| PKCE required, S256 | checks: ["pkce", "state", "nonce"], codeChallengeMethod: "S256" |
| PKCE required, plain only | checks: ["pkce", "state", "nonce"], codeChallengeMethod: "plain" |
| PKCE off | checks: ["state", "nonce"] |
| client_secret_post | clientSecret, tokenEndpointAuthMethod: "client_secret_post" |
| ID token signing algorithm HS256 | clientSecret, idTokenSignedResponseAlg: "HS256" |
| Access token signing algorithm HS256 | clientSecret, accessTokenSignedResponseAlg: "HS256" |
| Front-channel logout URI with the session required | frontChannelLogout: true |
checks, codeChallengeMethod and responseMode (only query: the BFF reads the callback itself) are read even with
an existing client. The BFF's own options are those of Authio.AspNetCore.Bff (Authio:Bff), in camelCase, with
durations in seconds:
| Option | Default | |
| --- | --- | --- |
| cookieSecret | — (required) | 32+ characters; an array rotates: the first protects, every one is accepted. |
| store | in memory | AuthioBffSessionStore. |
| endpointPrefix | /bff | Where the BFF endpoints are served. |
| scopes | openid profile email offline_access | Sent as configured; openid is not added. Without it there is no ID token, and the session is built from the verified access token. |
| requestRoles | false | Adds roles, so the session carries *_resource_access. |
| getClaimsFromUserInfoEndpoint | false | Adds UserInfo's claims to the session. |
| organization, resource, prompt, uiLocales, maxAge | — | Sent on every sign-in unless the login request names one. |
| allowedResources | [] | What /bff/login?resource= may ask for. |
| additionalAuthorizationParameters | {} | Extra authorize parameters. |
| requireIssuerParameter | true | Refuses a callback without iss (RFC 9207). |
| callbackPath, signedOutCallbackPath | /signin-oidc, /signout-callback-oidc | Registered at Authio. |
| redirectUri, postLogoutRedirectUri | built from the request | Absolute URIs, when the request's origin is not the public one. |
| defaultReturnUrl | / | Local only. |
| cookieName, cookieSameSite | __Host-authio, lax | The name must start with __Host- or __Secure-. |
| sessionLifetime, slidingExpiration | 28800, true | |
| sessionValidation, sessionValidationInterval | introspection, 300 | none turns the check off. |
| revokeRefreshTokenOnLogout | true | |
| antiForgeryHeaderName, antiForgeryHeaderValue | X-CSRF, 1 | |
| sessionKeyPrefix | authio-bff: | The prefix of the store keys. |
| backChannelLogout | true | Serves /bff/backchannel-logout. |
| frontChannelLogout | false | Serves /bff/frontchannel-logout. |
| onError | — | Errors the BFF recovers from (failed revocation, introspection or refresh, refused logout token or ID token, an access token that does not verify, failed sign-in). |
Every invalid option is named at once, in one AuthioConfigurationError, when authioBff() runs.
Differences from Authio.AspNetCore.Bff
cookieSecretis required: ASP.NET Core protects the cookie and the stored tickets with Data Protection, which Node does not have. It signs the cookie (HMAC-SHA256) and encrypts the stored sessions and the sign-in cookie (AES-256-GCM).- The session store is this package's own interface (indexed by
sidandsub, so back-channel logout deletes the sessions) rather thanIDistributedCachewith logout markers. AsAuthioBffApiEndpoint()/[AuthioBffApi]+RequireAuthorization()are one middleware,bff.api();AddAuthioBffUserToken()on anHttpClientisbff.userFetch();MapAuthioRemoteApiisbff.remoteApi()mounted withapp.use. There is no403access-denied answer: Express has no authorization policies to refuse with.- YARP's per-request settings (
RequestConfig,HttpClientName) become afetchoption on the remote API.
