@hyper.authio/bff 0.0.0-stage → 0.8.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 +263 -2
- package/dist/index.cjs +991 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +340 -0
- package/dist/index.d.ts +340 -0
- package/dist/index.js +985 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -4
package/README.md
CHANGED
|
@@ -1,3 +1,264 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hyper.authio/bff
|
|
2
2
|
|
|
3
|
-
|
|
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 `tokenEndpointAuthMethod:
|
|
29
|
+
"client_secret_post"`), or with `private_key_jwt` (`clientPrivateKey` or `clientPrivateKeyPath`, and
|
|
30
|
+
`clientKeyId`).
|
|
31
|
+
- **PKCE**: required with S256 is the recommended setting, mirrored by `checks: ["pkce", "state", "nonce"]` and
|
|
32
|
+
`codeChallengeMethod: "S256"`. The BFF checks only what `checks` lists: left out, it checks nothing.
|
|
33
|
+
- **Redirect URI**: `https://<your-app>/signin-oidc` (`callbackPath`).
|
|
34
|
+
- **Post-logout redirect URI**: `https://<your-app>/signout-callback-oidc` (`signedOutCallbackPath`).
|
|
35
|
+
- **Back-channel logout URI**: `https://<your-app>/bff/backchannel-logout`.
|
|
36
|
+
- **Front-channel logout URI** (optional, with `frontChannelLogout: true`): `https://<your-app>/bff/frontchannel-logout`,
|
|
37
|
+
with the session required so Authio sends `iss` and `sid`.
|
|
38
|
+
- **Scopes**: `openid profile email offline_access` (`offline_access` gives the refresh token), plus `roles` if you set
|
|
39
|
+
`requestRoles`, plus your API scopes.
|
|
40
|
+
- **Resources** (optional): the API resource identifiers you ask for with `resource` or use with remote APIs; allow
|
|
41
|
+
the **token exchange** grant if a remote API needs a token for a specific resource or a narrower scope set.
|
|
42
|
+
|
|
43
|
+
## Setup
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import express from "express";
|
|
47
|
+
import { authioBff } from "@hyper.authio/bff";
|
|
48
|
+
|
|
49
|
+
const app = express();
|
|
50
|
+
|
|
51
|
+
const bff = authioBff({
|
|
52
|
+
authority: "https://auth.example.com",
|
|
53
|
+
realm: "acme",
|
|
54
|
+
clientId: "acme-web",
|
|
55
|
+
clientSecret: () => process.env.AUTHIO_CLIENT_SECRET!, // read on every call: a rotated secret needs no restart
|
|
56
|
+
checks: ["pkce", "state", "nonce"], // the client requires PKCE with S256: mirror it, nothing is checked by default
|
|
57
|
+
codeChallengeMethod: "S256",
|
|
58
|
+
cookieSecret: process.env.BFF_COOKIE_SECRET!, // 32+ characters; an array rotates them
|
|
59
|
+
requestRoles: true,
|
|
60
|
+
resource: "https://orders.example.com", // the session's token is addressed to the orders API
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
app.use(bff); // /bff/login, /bff/logout, /bff/user, /bff/silent-login, /bff/backchannel-logout, /bff/frontchannel-logout, /signin-oidc, /signout-callback-oidc
|
|
64
|
+
|
|
65
|
+
// Remote APIs: mount them before any body parser, so the body is streamed through.
|
|
66
|
+
app.use("/api/orders", bff.remoteApi("https://orders.example.com"));
|
|
67
|
+
app.use("/api/inventory", bff.remoteApi("https://inventory.example.com", { tokenMode: "clientCredentials", scopes: ["inventory:read"] }));
|
|
68
|
+
app.use("/api/billing", bff.remoteApi("https://billing.example.com", { resource: "https://billing.example.com" }));
|
|
69
|
+
|
|
70
|
+
app.use(express.json());
|
|
71
|
+
|
|
72
|
+
// Local APIs: the anti-forgery header and a session are required; req.authioBff holds the session, never a token.
|
|
73
|
+
app.get("/api/me", bff.api(), (req, res) => res.json({ name: req.authioBff!.claims.name }));
|
|
74
|
+
|
|
75
|
+
app.get("/api/report", bff.api(), async (req, res) => {
|
|
76
|
+
const reports = bff.userFetch(req, res, { resource: "https://reports.example.com" });
|
|
77
|
+
const answer = await reports("https://reports.example.com/monthly");
|
|
78
|
+
|
|
79
|
+
res.status(answer.status).json(await answer.json());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
app.use(express.static("dist")); // the SPA
|
|
83
|
+
|
|
84
|
+
app.listen(5000);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`app.set("trust proxy", 1)` behind a reverse proxy, so the redirect URIs are built with `https`; or set
|
|
88
|
+
`redirectUri` and `postLogoutRedirectUri` explicitly.
|
|
89
|
+
|
|
90
|
+
## The SPA side
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
const api = (url, init = {}) => fetch(url, { ...init, credentials: "same-origin", headers: { ...init.headers, "X-CSRF": "1" } });
|
|
94
|
+
|
|
95
|
+
const response = await api("/bff/user");
|
|
96
|
+
|
|
97
|
+
if (response.status === 401) {
|
|
98
|
+
window.location.href = "/bff/login?returnUrl=" + encodeURIComponent(location.pathname);
|
|
99
|
+
} else {
|
|
100
|
+
const claims = await response.json();
|
|
101
|
+
const logoutUrl = claims.find((claim) => claim.type === "bff:logout_url").value;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| Endpoint | Behaviour |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `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`…). |
|
|
108
|
+
| `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. |
|
|
109
|
+
| `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. |
|
|
110
|
+
| `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`). |
|
|
111
|
+
| `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). |
|
|
112
|
+
| `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. |
|
|
113
|
+
| `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. |
|
|
114
|
+
|
|
115
|
+
## Calling APIs
|
|
116
|
+
|
|
117
|
+
**Local APIs**: `bff.api()` answers `401 antiforgery_required` without the header and `401 unauthorized` without a
|
|
118
|
+
session; `bff.api({ optional: true })` lets anonymous calls through, `{ requireAntiForgery: false }` drops the header
|
|
119
|
+
check. `bff.getUserAccessToken(req, res, { resource?, scopes? })` and `bff.userFetch(req, res, …)` hand the person's
|
|
120
|
+
token to your own code.
|
|
121
|
+
|
|
122
|
+
**Remote APIs**: `bff.remoteApi(destination, options)` forwards everything below its mount path to `destination`,
|
|
123
|
+
strips the browser's cookies, the anti-forgery header and any incoming `Authorization`, attaches the token, drops
|
|
124
|
+
upstream `Set-Cookie`, streams both bodies, and answers `502` when the API cannot be reached.
|
|
125
|
+
|
|
126
|
+
| `tokenMode` | Token |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| `user` (default) | The person's access token; anonymous calls get `401`. |
|
|
129
|
+
| `userOrNone` | The person's token when signed in, otherwise none. |
|
|
130
|
+
| `clientCredentials` | A client-credentials token of the BFF (cached by `@hyper.authio/client`), whoever is signed in. |
|
|
131
|
+
| `userOrClientCredentials` | The person's token when signed in, otherwise a client-credentials token. |
|
|
132
|
+
| `none` | No token. |
|
|
133
|
+
|
|
134
|
+
With `resource` or `scopes`, a user token is obtained by **token exchange** (RFC 8693, the result carries `act`) and
|
|
135
|
+
cached per session, resource and scopes; a client-credentials token is asked for them directly. When a remote API
|
|
136
|
+
answers `401` to a user call, the BFF introspects the session and ends it if Authio says it is gone
|
|
137
|
+
(`validateSessionOnUnauthorized`). `requireAntiForgery: false` is meant for anonymous APIs only.
|
|
138
|
+
|
|
139
|
+
### Tokens for an API protected by Authio
|
|
140
|
+
|
|
141
|
+
An API protected with `@hyper.authio/express` or `Authio.AspNetCore` accepts only tokens addressed to it (`aud` = the
|
|
142
|
+
resource identifier in its `audiences`). Either make the session's token the API's with `resource` (or
|
|
143
|
+
`/bff/login?resource=…`, allowed by `allowedResources`), or give the remote API a `resource` so the session's token is
|
|
144
|
+
exchanged for one addressed to it. Keep `offline_access` when you set `resource`: the session check introspects the
|
|
145
|
+
refresh token, which stays addressed to the BFF.
|
|
146
|
+
|
|
147
|
+
## Tokens and sessions
|
|
148
|
+
|
|
149
|
+
- **Refresh**: the access token is refreshed `tokenRefreshSkew` (60 s) before it expires, under a lock per session, so
|
|
150
|
+
concurrent requests share one refresh (Authio rotates refresh tokens and treats a late reuse as a replay). The rotated
|
|
151
|
+
refresh token is stored before the lock is released. A refused refresh (`invalid_grant`) ends the session; a network
|
|
152
|
+
error keeps the current token while it lasts. An ID token the refresh returns is validated (signature, `iss`, `aud`,
|
|
153
|
+
`exp`, `at_hash`, the same `sub`) before it is stored; one that does not validate ends the session.
|
|
154
|
+
- **Claims**: the session's claims are the validated ID token's, plus the claims only the access token carries
|
|
155
|
+
(`*_resource_access`, `org_id`, `org_alias`, `account_id`) once the access token's signature, `iss` and `exp` verify
|
|
156
|
+
(`client.readAccessTokenClaims`), plus UserInfo's with `getClaimsFromUserInfoEndpoint`.
|
|
157
|
+
- **Session check** (`sessionValidation: "introspection"`, the default): every `sessionValidationInterval` (300 s) the
|
|
158
|
+
refresh token (or the access token when there is none) is introspected; an inactive answer ends the session. Needs a
|
|
159
|
+
confidential client.
|
|
160
|
+
- **Lifetime**: `sessionLifetime` (8 h) without activity; renewed once half of it has passed (`slidingExpiration`).
|
|
161
|
+
The cookie is a browser-session cookie; the server-side expiry decides.
|
|
162
|
+
- **Cookie**: `__Host-authio=<64 hex>.<HMAC-SHA256>`, `HttpOnly`, `Secure`, `SameSite=Lax` (or `strict`), `Path=/`, no
|
|
163
|
+
`Domain`. A tampered or unknown cookie is cleared.
|
|
164
|
+
- **Store**: in memory by default. For several instances, implement `AuthioBffSessionStore` over a shared store; the
|
|
165
|
+
BFF encrypts each session (AES-256-GCM, from `cookieSecret`) before it reaches the store and keeps only `sessionId`
|
|
166
|
+
(`sid`), `subject` (`sub`) and `expiresAt` in clear, for indexing.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import type { AuthioBffSessionStore, AuthioBffStoredSession } from "@hyper.authio/bff";
|
|
170
|
+
|
|
171
|
+
const redisStore = (redis: Redis): AuthioBffSessionStore => ({
|
|
172
|
+
async get(key) {
|
|
173
|
+
const value = await redis.get(key);
|
|
174
|
+
|
|
175
|
+
return value ? (JSON.parse(value) as AuthioBffStoredSession) : undefined;
|
|
176
|
+
},
|
|
177
|
+
async set(session) {
|
|
178
|
+
const ttl = Math.max(1, Math.ceil((session.expiresAt - Date.now()) / 1000));
|
|
179
|
+
|
|
180
|
+
await redis.multi()
|
|
181
|
+
.set(session.key, JSON.stringify(session), "EX", ttl)
|
|
182
|
+
.sadd(`sid:${session.sessionId}`, session.key).expire(`sid:${session.sessionId}`, ttl)
|
|
183
|
+
.sadd(`sub:${session.subject}`, session.key).expire(`sub:${session.subject}`, ttl)
|
|
184
|
+
.exec();
|
|
185
|
+
},
|
|
186
|
+
async delete(key) {
|
|
187
|
+
await redis.del(key);
|
|
188
|
+
},
|
|
189
|
+
async deleteBySessionId(sessionId) {
|
|
190
|
+
const keys = await redis.smembers(`sid:${sessionId}`);
|
|
191
|
+
|
|
192
|
+
if (keys.length > 0) await redis.del(...keys, `sid:${sessionId}`);
|
|
193
|
+
},
|
|
194
|
+
async deleteBySubject(subject) {
|
|
195
|
+
const keys = await redis.smembers(`sub:${subject}`);
|
|
196
|
+
|
|
197
|
+
if (keys.length > 0) await redis.del(...keys, `sub:${subject}`);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The refresh lock is local to the process, as in .NET: with several instances, keep a person on one instance (sticky
|
|
203
|
+
sessions) or rely on Authio's short refresh-token reuse leeway.
|
|
204
|
+
|
|
205
|
+
## Options
|
|
206
|
+
|
|
207
|
+
The client takes the names of [`conformance/options.md`](../../conformance/options.md) (`issuer` or `authority` +
|
|
208
|
+
`realm`, `clientId`, `clientSecret`, `tokenEndpointAuthMethod`, `clientPrivateKey`, `clientKeyId`,
|
|
209
|
+
`clientEncryptionKey`, `encryptionAlgorithms`, `encryptionMethods`, `idTokenSignedResponseAlg`, `tokenRefreshSkew`,
|
|
210
|
+
`requireHttps`, `clock`…), or an existing `client`. They mirror the client as it is registered in Authio, and an
|
|
211
|
+
option left out is off:
|
|
212
|
+
|
|
213
|
+
| In the console | Options |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| PKCE required, S256 | `checks: ["pkce", "state", "nonce"]`, `codeChallengeMethod: "S256"` |
|
|
216
|
+
| PKCE required, plain only | `checks: ["pkce", "state", "nonce"]`, `codeChallengeMethod: "plain"` |
|
|
217
|
+
| PKCE off | `checks: ["state", "nonce"]` |
|
|
218
|
+
| `client_secret_post` | `clientSecret`, `tokenEndpointAuthMethod: "client_secret_post"` |
|
|
219
|
+
| ID token signing algorithm HS256 | `clientSecret`, `idTokenSignedResponseAlg: "HS256"` |
|
|
220
|
+
| Access token signing algorithm HS256 | `clientSecret`, `accessTokenSignedResponseAlg: "HS256"` |
|
|
221
|
+
| Front-channel logout URI with the session required | `frontChannelLogout: true` |
|
|
222
|
+
|
|
223
|
+
`checks`, `codeChallengeMethod` and `responseMode` (only `query`: the BFF reads the callback itself) are read even with
|
|
224
|
+
an existing `client`. The BFF's own options are those of `Authio.AspNetCore.Bff` (`Authio:Bff`), in camelCase, with
|
|
225
|
+
durations in seconds:
|
|
226
|
+
|
|
227
|
+
| Option | Default | |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| `cookieSecret` | — (required) | 32+ characters; an array rotates: the first protects, every one is accepted. |
|
|
230
|
+
| `store` | in memory | `AuthioBffSessionStore`. |
|
|
231
|
+
| `endpointPrefix` | `/bff` | Where the BFF endpoints are served. |
|
|
232
|
+
| `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. |
|
|
233
|
+
| `requestRoles` | `false` | Adds `roles`, so the session carries `*_resource_access`. |
|
|
234
|
+
| `getClaimsFromUserInfoEndpoint` | `false` | Adds UserInfo's claims to the session. |
|
|
235
|
+
| `organization`, `resource`, `prompt`, `uiLocales`, `maxAge` | — | Sent on every sign-in unless the login request names one. |
|
|
236
|
+
| `allowedResources` | `[]` | What `/bff/login?resource=` may ask for. |
|
|
237
|
+
| `additionalAuthorizationParameters` | `{}` | Extra authorize parameters. |
|
|
238
|
+
| `requireIssuerParameter` | `true` | Refuses a callback without `iss` (RFC 9207). |
|
|
239
|
+
| `callbackPath`, `signedOutCallbackPath` | `/signin-oidc`, `/signout-callback-oidc` | Registered at Authio. |
|
|
240
|
+
| `redirectUri`, `postLogoutRedirectUri` | built from the request | Absolute URIs, when the request's origin is not the public one. |
|
|
241
|
+
| `defaultReturnUrl` | `/` | Local only. |
|
|
242
|
+
| `cookieName`, `cookieSameSite` | `__Host-authio`, `lax` | The name must start with `__Host-` or `__Secure-`. |
|
|
243
|
+
| `sessionLifetime`, `slidingExpiration` | `28800`, `true` | |
|
|
244
|
+
| `sessionValidation`, `sessionValidationInterval` | `introspection`, `300` | `none` turns the check off. |
|
|
245
|
+
| `revokeRefreshTokenOnLogout` | `true` | |
|
|
246
|
+
| `antiForgeryHeaderName`, `antiForgeryHeaderValue` | `X-CSRF`, `1` | |
|
|
247
|
+
| `sessionKeyPrefix` | `authio-bff:` | The prefix of the store keys. |
|
|
248
|
+
| `backChannelLogout` | `true` | Serves `/bff/backchannel-logout`. |
|
|
249
|
+
| `frontChannelLogout` | `false` | Serves `/bff/frontchannel-logout`. |
|
|
250
|
+
| `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). |
|
|
251
|
+
|
|
252
|
+
Every invalid option is named at once, in one `AuthioConfigurationError`, when `authioBff()` runs.
|
|
253
|
+
|
|
254
|
+
## Differences from Authio.AspNetCore.Bff
|
|
255
|
+
|
|
256
|
+
- `cookieSecret` is required: ASP.NET Core protects the cookie and the stored tickets with Data Protection, which Node
|
|
257
|
+
does not have. It signs the cookie (HMAC-SHA256) and encrypts the stored sessions and the sign-in cookie
|
|
258
|
+
(AES-256-GCM).
|
|
259
|
+
- The session store is this package's own interface (indexed by `sid` and `sub`, so back-channel logout deletes the
|
|
260
|
+
sessions) rather than `IDistributedCache` with logout markers.
|
|
261
|
+
- `AsAuthioBffApiEndpoint()`/`[AuthioBffApi]` + `RequireAuthorization()` are one middleware, `bff.api()`;
|
|
262
|
+
`AddAuthioBffUserToken()` on an `HttpClient` is `bff.userFetch()`; `MapAuthioRemoteApi` is `bff.remoteApi()` mounted
|
|
263
|
+
with `app.use`. There is no `403` access-denied answer: Express has no authorization policies to refuse with.
|
|
264
|
+
- YARP's per-request settings (`RequestConfig`, `HttpClientName`) become a `fetch` option on the remote API.
|