@hyper.authio/bff 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,237 @@
1
- # Temporary Holding Version
1
+ # @hyper.authio/bff
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A **Backend-for-Frontend** for [Authio](https://github.com/LeonardoFerreira1209/authio-node) on Express 4 and 5. It
4
+ lets a browser SPA (React, Angular, Vue, plain JavaScript…) sign in with Authio **without ever holding a token**,
5
+ following the BFF pattern of the IETF draft *OAuth 2.0 for Browser-Based Applications*. The Node counterpart of
6
+ `Authio.AspNetCore.Bff`: same endpoints, same cookie, same anti-forgery header, same option names. Built on
7
+ [`@hyper.authio/client`](../client/README.md).
8
+
9
+ ```bash
10
+ npm install @hyper.authio/bff
11
+ ```
12
+
13
+ ## Why a BFF
14
+
15
+ Tokens kept in the browser (local storage, session storage, memory) can be stolen by any script that runs on the page,
16
+ and refresh tokens outlive the page. With a BFF:
17
+
18
+ - the SPA and the BFF share an origin; the browser only holds an `HttpOnly`, `Secure`, `SameSite=Lax`, host-only
19
+ cookie (`__Host-authio`) that carries a signed random session id and nothing else;
20
+ - the BFF is a **confidential client**: it runs the authorization code flow with PKCE, keeps the access, refresh and
21
+ ID tokens **server-side** (encrypted in the session store), refreshes them, and attaches the access token when it
22
+ calls APIs on the person's behalf;
23
+ - every API call from the SPA carries a custom header (`X-CSRF: 1`), which a cross-site page cannot add without a CORS
24
+ preflight, so the cookie cannot be abused by CSRF.
25
+
26
+ ## Register the client at Authio
27
+
28
+ - **Type**: confidential, with a secret (`client_secret_basic` by default, or `clientAuthentication:
29
+ "client_secret_post"`), or with `private_key_jwt` (`clientPrivateKey` or `clientPrivateKeyPath`, and
30
+ `clientKeyId`).
31
+ - **Redirect URI**: `https://<your-app>/signin-oidc` (`callbackPath`).
32
+ - **Post-logout redirect URI**: `https://<your-app>/signout-callback-oidc` (`signedOutCallbackPath`).
33
+ - **Back-channel logout URI**: `https://<your-app>/bff/backchannel-logout`.
34
+ - **Scopes**: `openid profile email offline_access` (`offline_access` gives the refresh token), plus `roles` if you set
35
+ `requestRoles`, plus your API scopes.
36
+ - **Resources** (optional): the API resource identifiers you ask for with `resource` or use with remote APIs; allow
37
+ the **token exchange** grant if a remote API needs a token for a specific resource or a narrower scope set.
38
+
39
+ ## Setup
40
+
41
+ ```ts
42
+ import express from "express";
43
+ import { authioBff } from "@hyper.authio/bff";
44
+
45
+ const app = express();
46
+
47
+ const bff = authioBff({
48
+ authority: "https://auth.example.com",
49
+ realm: "acme",
50
+ clientId: "acme-web",
51
+ clientSecret: () => process.env.AUTHIO_CLIENT_SECRET!, // read on every call: a rotated secret needs no restart
52
+ cookieSecret: process.env.BFF_COOKIE_SECRET!, // 32+ characters; an array rotates them
53
+ requestRoles: true,
54
+ resource: "https://orders.example.com", // the session's token is addressed to the orders API
55
+ });
56
+
57
+ app.use(bff); // /bff/login, /bff/logout, /bff/user, /bff/silent-login, /bff/backchannel-logout, /signin-oidc, /signout-callback-oidc
58
+
59
+ // Remote APIs: mount them before any body parser, so the body is streamed through.
60
+ app.use("/api/orders", bff.remoteApi("https://orders.example.com"));
61
+ app.use("/api/inventory", bff.remoteApi("https://inventory.example.com", { tokenMode: "clientCredentials", scopes: ["inventory:read"] }));
62
+ app.use("/api/billing", bff.remoteApi("https://billing.example.com", { resource: "https://billing.example.com" }));
63
+
64
+ app.use(express.json());
65
+
66
+ // Local APIs: the anti-forgery header and a session are required; req.authioBff holds the session, never a token.
67
+ app.get("/api/me", bff.api(), (req, res) => res.json({ name: req.authioBff!.claims.name }));
68
+
69
+ app.get("/api/report", bff.api(), async (req, res) => {
70
+ const reports = bff.userFetch(req, res, { resource: "https://reports.example.com" });
71
+ const answer = await reports("https://reports.example.com/monthly");
72
+
73
+ res.status(answer.status).json(await answer.json());
74
+ });
75
+
76
+ app.use(express.static("dist")); // the SPA
77
+
78
+ app.listen(5000);
79
+ ```
80
+
81
+ `app.set("trust proxy", 1)` behind a reverse proxy, so the redirect URIs are built with `https`; or set
82
+ `redirectUri` and `postLogoutRedirectUri` explicitly.
83
+
84
+ ## The SPA side
85
+
86
+ ```js
87
+ const api = (url, init = {}) => fetch(url, { ...init, credentials: "same-origin", headers: { ...init.headers, "X-CSRF": "1" } });
88
+
89
+ const response = await api("/bff/user");
90
+
91
+ if (response.status === 401) {
92
+ window.location.href = "/bff/login?returnUrl=" + encodeURIComponent(location.pathname);
93
+ } else {
94
+ const claims = await response.json();
95
+ const logoutUrl = claims.find((claim) => claim.type === "bff:logout_url").value;
96
+ }
97
+ ```
98
+
99
+ | Endpoint | Behaviour |
100
+ | --- | --- |
101
+ | `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`…). |
102
+ | `GET /signin-oidc` | The callback: checks `state` against the sealed sign-in cookie, the RFC 9207 `iss`, redeems the code with the PKCE verifier, validates the ID token (nonce, `at_hash`), creates the session and replaces any previous one. |
103
+ | `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. |
104
+ | `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`). |
105
+ | `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). |
106
+ | `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. |
107
+
108
+ ## Calling APIs
109
+
110
+ **Local APIs**: `bff.api()` answers `401 antiforgery_required` without the header and `401 unauthorized` without a
111
+ session; `bff.api({ optional: true })` lets anonymous calls through, `{ requireAntiForgery: false }` drops the header
112
+ check. `bff.getUserAccessToken(req, res, { resource?, scopes? })` and `bff.userFetch(req, res, …)` hand the person's
113
+ token to your own code.
114
+
115
+ **Remote APIs**: `bff.remoteApi(destination, options)` forwards everything below its mount path to `destination`,
116
+ strips the browser's cookies, the anti-forgery header and any incoming `Authorization`, attaches the token, drops
117
+ upstream `Set-Cookie`, streams both bodies, and answers `502` when the API cannot be reached.
118
+
119
+ | `tokenMode` | Token |
120
+ | --- | --- |
121
+ | `user` (default) | The person's access token; anonymous calls get `401`. |
122
+ | `userOrNone` | The person's token when signed in, otherwise none. |
123
+ | `clientCredentials` | A client-credentials token of the BFF (cached by `@hyper.authio/client`), whoever is signed in. |
124
+ | `userOrClientCredentials` | The person's token when signed in, otherwise a client-credentials token. |
125
+ | `none` | No token. |
126
+
127
+ With `resource` or `scopes`, a user token is obtained by **token exchange** (RFC 8693, the result carries `act`) and
128
+ cached per session, resource and scopes; a client-credentials token is asked for them directly. When a remote API
129
+ answers `401` to a user call, the BFF introspects the session and ends it if Authio says it is gone
130
+ (`validateSessionOnUnauthorized`). `requireAntiForgery: false` is meant for anonymous APIs only.
131
+
132
+ ### Tokens for an API protected by Authio
133
+
134
+ An API protected with `@hyper.authio/express` or `Authio.AspNetCore` accepts only tokens addressed to it (`aud` = the
135
+ resource identifier in its `audiences`). Either make the session's token the API's with `resource` (or
136
+ `/bff/login?resource=…`, allowed by `allowedResources`), or give the remote API a `resource` so the session's token is
137
+ exchanged for one addressed to it. Keep `offline_access` when you set `resource`: the session check introspects the
138
+ refresh token, which stays addressed to the BFF.
139
+
140
+ ## Tokens and sessions
141
+
142
+ - **Refresh**: the access token is refreshed `tokenRefreshSkew` (60 s) before it expires, under a lock per session, so
143
+ concurrent requests share one refresh (Authio rotates refresh tokens and treats a late reuse as a replay). The rotated
144
+ refresh token is stored before the lock is released. A refused refresh (`invalid_grant`) ends the session; a network
145
+ error keeps the current token while it lasts.
146
+ - **Session check** (`sessionValidation: "introspection"`, the default): every `sessionValidationInterval` (300 s) the
147
+ refresh token (or the access token when there is none) is introspected; an inactive answer ends the session. Needs a
148
+ confidential client.
149
+ - **Lifetime**: `sessionLifetime` (8 h) without activity; renewed once half of it has passed (`slidingExpiration`).
150
+ The cookie is a browser-session cookie; the server-side expiry decides.
151
+ - **Cookie**: `__Host-authio=<64 hex>.<HMAC-SHA256>`, `HttpOnly`, `Secure`, `SameSite=Lax` (or `strict`), `Path=/`, no
152
+ `Domain`. A tampered or unknown cookie is cleared.
153
+ - **Store**: in memory by default. For several instances, implement `AuthioBffSessionStore` over a shared store; the
154
+ BFF encrypts each session (AES-256-GCM, from `cookieSecret`) before it reaches the store and keeps only `sessionId`
155
+ (`sid`), `subject` (`sub`) and `expiresAt` in clear, for indexing.
156
+
157
+ ```ts
158
+ import type { AuthioBffSessionStore, AuthioBffStoredSession } from "@hyper.authio/bff";
159
+
160
+ const redisStore = (redis: Redis): AuthioBffSessionStore => ({
161
+ async get(key) {
162
+ const value = await redis.get(key);
163
+
164
+ return value ? (JSON.parse(value) as AuthioBffStoredSession) : undefined;
165
+ },
166
+ async set(session) {
167
+ const ttl = Math.max(1, Math.ceil((session.expiresAt - Date.now()) / 1000));
168
+
169
+ await redis.multi()
170
+ .set(session.key, JSON.stringify(session), "EX", ttl)
171
+ .sadd(`sid:${session.sessionId}`, session.key).expire(`sid:${session.sessionId}`, ttl)
172
+ .sadd(`sub:${session.subject}`, session.key).expire(`sub:${session.subject}`, ttl)
173
+ .exec();
174
+ },
175
+ async delete(key) {
176
+ await redis.del(key);
177
+ },
178
+ async deleteBySessionId(sessionId) {
179
+ const keys = await redis.smembers(`sid:${sessionId}`);
180
+
181
+ if (keys.length > 0) await redis.del(...keys, `sid:${sessionId}`);
182
+ },
183
+ async deleteBySubject(subject) {
184
+ const keys = await redis.smembers(`sub:${subject}`);
185
+
186
+ if (keys.length > 0) await redis.del(...keys, `sub:${subject}`);
187
+ },
188
+ });
189
+ ```
190
+
191
+ The refresh lock is local to the process, as in .NET: with several instances, keep a person on one instance (sticky
192
+ sessions) or rely on Authio's short refresh-token reuse leeway.
193
+
194
+ ## Options
195
+
196
+ The client takes the names of [`conformance/options.md`](../../conformance/options.md) (`issuer` or `authority` +
197
+ `realm`, `clientId`, `clientSecret`, `clientAuthentication`, `clientPrivateKey`, `clientKeyId`,
198
+ `clientEncryptionKey`, `tokenRefreshSkew`, `requireHttps`, `clock`…), or an existing `client`. The BFF's own options
199
+ are those of `Authio.AspNetCore.Bff` (`Authio:Bff`), in camelCase, with durations in seconds:
200
+
201
+ | Option | Default | |
202
+ | --- | --- | --- |
203
+ | `cookieSecret` | — (required) | 32+ characters; an array rotates: the first protects, every one is accepted. |
204
+ | `store` | in memory | `AuthioBffSessionStore`. |
205
+ | `endpointPrefix` | `/bff` | Where the BFF endpoints are served. |
206
+ | `scopes` | `openid profile email offline_access` | `openid` is always sent. |
207
+ | `requestRoles` | `false` | Adds `roles`, so the session carries `*_resource_access`. |
208
+ | `getClaimsFromUserInfoEndpoint` | `false` | Adds UserInfo's claims to the session. |
209
+ | `organization`, `resource`, `prompt`, `uiLocales`, `maxAge` | — | Sent on every sign-in unless the login request names one. |
210
+ | `allowedResources` | `[]` | What `/bff/login?resource=` may ask for. |
211
+ | `additionalAuthorizationParameters` | `{}` | Extra authorize parameters. |
212
+ | `requireIssuerParameter` | `true` | Refuses a callback without `iss` (RFC 9207). |
213
+ | `callbackPath`, `signedOutCallbackPath` | `/signin-oidc`, `/signout-callback-oidc` | Registered at Authio. |
214
+ | `redirectUri`, `postLogoutRedirectUri` | built from the request | Absolute URIs, when the request's origin is not the public one. |
215
+ | `defaultReturnUrl` | `/` | Local only. |
216
+ | `cookieName`, `cookieSameSite` | `__Host-authio`, `lax` | The name must start with `__Host-` or `__Secure-`. |
217
+ | `sessionLifetime`, `slidingExpiration` | `28800`, `true` | |
218
+ | `sessionValidation`, `sessionValidationInterval` | `introspection`, `300` | `none` turns the check off. |
219
+ | `revokeRefreshTokenOnLogout` | `true` | |
220
+ | `antiForgeryHeaderName`, `antiForgeryHeaderValue` | `X-CSRF`, `1` | |
221
+ | `sessionKeyPrefix` | `authio-bff:` | The prefix of the store keys. |
222
+ | `backChannelLogout` | `true` | Serves `/bff/backchannel-logout`. |
223
+ | `onError` | — | Errors the BFF recovers from (failed revocation, introspection or refresh, refused logout token, failed sign-in). |
224
+
225
+ Every invalid option is named at once, in one `AuthioConfigurationError`, when `authioBff()` runs.
226
+
227
+ ## Differences from Authio.AspNetCore.Bff
228
+
229
+ - `cookieSecret` is required: ASP.NET Core protects the cookie and the stored tickets with Data Protection, which Node
230
+ does not have. It signs the cookie (HMAC-SHA256) and encrypts the stored sessions and the sign-in cookie
231
+ (AES-256-GCM).
232
+ - The session store is this package's own interface (indexed by `sid` and `sub`, so back-channel logout deletes the
233
+ sessions) rather than `IDistributedCache` with logout markers.
234
+ - `AsAuthioBffApiEndpoint()`/`[AuthioBffApi]` + `RequireAuthorization()` are one middleware, `bff.api()`;
235
+ `AddAuthioBffUserToken()` on an `HttpClient` is `bff.userFetch()`; `MapAuthioRemoteApi` is `bff.remoteApi()` mounted
236
+ with `app.use`. There is no `403` access-denied answer: Express has no authorization policies to refuse with.
237
+ - YARP's per-request settings (`RequestConfig`, `HttpClientName`) become a `fetch` option on the remote API.