@hyper.authio/bff 0.7.0 → 0.8.1

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
@@ -25,12 +25,16 @@ and refresh tokens outlive the page. With a BFF:
25
25
 
26
26
  ## Register the client at Authio
27
27
 
28
- - **Type**: confidential, with a secret (`client_secret_basic` by default, or `clientAuthentication:
28
+ - **Type**: confidential, with a secret (`client_secret_basic` by default, or `tokenEndpointAuthMethod:
29
29
  "client_secret_post"`), or with `private_key_jwt` (`clientPrivateKey` or `clientPrivateKeyPath`, and
30
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.
31
33
  - **Redirect URI**: `https://<your-app>/signin-oidc` (`callbackPath`).
32
34
  - **Post-logout redirect URI**: `https://<your-app>/signout-callback-oidc` (`signedOutCallbackPath`).
33
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`.
34
38
  - **Scopes**: `openid profile email offline_access` (`offline_access` gives the refresh token), plus `roles` if you set
35
39
  `requestRoles`, plus your API scopes.
36
40
  - **Resources** (optional): the API resource identifiers you ask for with `resource` or use with remote APIs; allow
@@ -49,12 +53,14 @@ const bff = authioBff({
49
53
  realm: "acme",
50
54
  clientId: "acme-web",
51
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",
52
58
  cookieSecret: process.env.BFF_COOKIE_SECRET!, // 32+ characters; an array rotates them
53
59
  requestRoles: true,
54
60
  resource: "https://orders.example.com", // the session's token is addressed to the orders API
55
61
  });
56
62
 
57
- app.use(bff); // /bff/login, /bff/logout, /bff/user, /bff/silent-login, /bff/backchannel-logout, /signin-oidc, /signout-callback-oidc
63
+ app.use(bff); // /bff/login, /bff/logout, /bff/user, /bff/silent-login, /bff/backchannel-logout, /bff/frontchannel-logout, /signin-oidc, /signout-callback-oidc
58
64
 
59
65
  // Remote APIs: mount them before any body parser, so the body is streamed through.
60
66
  app.use("/api/orders", bff.remoteApi("https://orders.example.com"));
@@ -99,11 +105,12 @@ if (response.status === 401) {
99
105
  | Endpoint | Behaviour |
100
106
  | --- | --- |
101
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`…). |
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. |
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. |
103
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. |
104
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`). |
105
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). |
106
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. |
107
114
 
108
115
  ## Calling APIs
109
116
 
@@ -142,7 +149,11 @@ refresh token, which stays addressed to the BFF.
142
149
  - **Refresh**: the access token is refreshed `tokenRefreshSkew` (60 s) before it expires, under a lock per session, so
143
150
  concurrent requests share one refresh (Authio rotates refresh tokens and treats a late reuse as a replay). The rotated
144
151
  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.
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`.
146
157
  - **Session check** (`sessionValidation: "introspection"`, the default): every `sessionValidationInterval` (300 s) the
147
158
  refresh token (or the access token when there is none) is introspected; an inactive answer ends the session. Needs a
148
159
  confidential client.
@@ -194,16 +205,31 @@ sessions) or rely on Authio's short refresh-token reuse leeway.
194
205
  ## Options
195
206
 
196
207
  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:
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:
200
226
 
201
227
  | Option | Default | |
202
228
  | --- | --- | --- |
203
229
  | `cookieSecret` | — (required) | 32+ characters; an array rotates: the first protects, every one is accepted. |
204
230
  | `store` | in memory | `AuthioBffSessionStore`. |
205
231
  | `endpointPrefix` | `/bff` | Where the BFF endpoints are served. |
206
- | `scopes` | `openid profile email offline_access` | `openid` is always sent. |
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. |
207
233
  | `requestRoles` | `false` | Adds `roles`, so the session carries `*_resource_access`. |
208
234
  | `getClaimsFromUserInfoEndpoint` | `false` | Adds UserInfo's claims to the session. |
209
235
  | `organization`, `resource`, `prompt`, `uiLocales`, `maxAge` | — | Sent on every sign-in unless the login request names one. |
@@ -220,7 +246,8 @@ are those of `Authio.AspNetCore.Bff` (`Authio:Bff`), in camelCase, with duration
220
246
  | `antiForgeryHeaderName`, `antiForgeryHeaderValue` | `X-CSRF`, `1` | |
221
247
  | `sessionKeyPrefix` | `authio-bff:` | The prefix of the store keys. |
222
248
  | `backChannelLogout` | `true` | Serves `/bff/backchannel-logout`. |
223
- | `onError` | — | Errors the BFF recovers from (failed revocation, introspection or refresh, refused logout token, failed sign-in). |
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). |
224
251
 
225
252
  Every invalid option is named at once, in one `AuthioConfigurationError`, when `authioBff()` runs.
226
253
 
package/dist/index.cjs CHANGED
@@ -32,16 +32,11 @@ function toSessionClaims(idToken, fallbackSubject) {
32
32
  function mergeUserInfo(claims, userInfo) {
33
33
  for (const [type, value] of Object.entries(userInfo)) if (claims[type] === void 0 && value !== void 0 && value !== null) claims[type] = value;
34
34
  }
35
- async function replaceAccessTokenClaims(claims, accessToken, decrypt) {
36
- const payload = await readPayload(accessToken, decrypt);
35
+ function replaceAccessTokenClaims(claims, payload) {
37
36
  if (!payload) return;
38
37
  for (const type of Object.keys(claims)) if (accessTokenClaimTypes.has(type)) delete claims[type];
39
38
  for (const [type, value] of Object.entries(payload)) if (accessTokenClaimTypes.has(type) && value !== void 0 && value !== null) claims[type] = value;
40
39
  }
41
- async function readAccessTokenSubject(accessToken, decrypt) {
42
- const subject = (await readPayload(accessToken, decrypt))?.sub;
43
- return typeof subject === "string" && subject ? subject : void 0;
44
- }
45
40
  function toAuthioBffClaims(claims, sessionExpiresIn, logoutUrl) {
46
41
  const entries = [];
47
42
  const seen = /* @__PURE__ */ new Set();
@@ -62,18 +57,6 @@ function toAuthioBffClaims(claims, sessionExpiresIn, logoutUrl) {
62
57
  entries.push({ type: AuthioBffClaimTypes.LogoutUrl, value: logoutUrl });
63
58
  return entries;
64
59
  }
65
- async function readPayload(token, decrypt) {
66
- if (!token) return void 0;
67
- try {
68
- const signed = client.isAuthioTokenEncrypted(token) ? await decrypt(token) : token;
69
- const parts = signed.split(".");
70
- if (parts.length !== 3 || !parts[1]) return void 0;
71
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
72
- return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : void 0;
73
- } catch {
74
- return void 0;
75
- }
76
- }
77
60
  function toText(value) {
78
61
  return typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
79
62
  }
@@ -202,7 +185,7 @@ function resolveBffSettings(options) {
202
185
  const settings = {
203
186
  cookieSecrets,
204
187
  endpointPrefix: `/${(options.endpointPrefix ?? AuthioBffDefaults.EndpointPrefix).replace(/^\/+|\/+$/g, "")}`,
205
- scopes: withOpenId([...options.scopes ?? AuthioBffDefaults.Scopes, ...options.requestRoles ? ["roles"] : []]),
188
+ scopes: distinct([...options.scopes ?? AuthioBffDefaults.Scopes, ...options.requestRoles ? ["roles"] : []]),
206
189
  getClaimsFromUserInfoEndpoint: options.getClaimsFromUserInfoEndpoint ?? false,
207
190
  organization: nonEmpty(options.organization),
208
191
  resource: nonEmpty(options.resource),
@@ -226,6 +209,7 @@ function resolveBffSettings(options) {
226
209
  antiForgeryHeaderValue: options.antiForgeryHeaderValue ?? AuthioBffDefaults.AntiForgeryHeaderValue,
227
210
  sessionKeyPrefix: options.sessionKeyPrefix ?? AuthioBffDefaults.SessionKeyPrefix,
228
211
  backChannelLogout: options.backChannelLogout ?? true,
212
+ frontChannelLogout: options.frontChannelLogout ?? false,
229
213
  tokenRefreshSkewMs: (options.tokenRefreshSkew ?? 60) * 1e3,
230
214
  redirectUri: nonEmpty(options.redirectUri),
231
215
  postLogoutRedirectUri: nonEmpty(options.postLogoutRedirectUri),
@@ -254,6 +238,8 @@ function resolveBffSettings(options) {
254
238
  if (settings.maxAge !== void 0 && (!Number.isInteger(settings.maxAge) || settings.maxAge < 0))
255
239
  problems.push("maxAge must be a non-negative whole number of seconds.");
256
240
  if (!settings.sessionKeyPrefix.trim()) problems.push("sessionKeyPrefix is required.");
241
+ if (options.responseMode !== void 0 && options.responseMode !== "query")
242
+ problems.push("responseMode must be query: the BFF reads Authio's answer at callbackPath (web_message needs a page of the SPA's own).");
257
243
  if (!Number.isFinite(settings.tokenRefreshSkewMs) || settings.tokenRefreshSkewMs < 0) problems.push("tokenRefreshSkew must be a non-negative number of seconds.");
258
244
  if (problems.length > 0) throw new client.AuthioConfigurationError(`The Authio BFF options are not valid:
259
245
  - ${problems.join("\n- ")}`);
@@ -263,9 +249,8 @@ function isLocalUrl(url) {
263
249
  if (typeof url !== "string" || !url || /[\u0000-\u001f\u007f-\u009f]/.test(url)) return false;
264
250
  return url[0] === "/" && (url.length === 1 || url[1] !== "/" && url[1] !== "\\");
265
251
  }
266
- function withOpenId(scopes) {
267
- const distinct = [...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))];
268
- return Object.freeze(distinct.includes("openid") ? distinct : ["openid", ...distinct]);
252
+ function distinct(scopes) {
253
+ return Object.freeze([...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))]);
269
254
  }
270
255
  function nonEmpty(value) {
271
256
  return value?.trim() ? value : void 0;
@@ -534,12 +519,9 @@ function authioBff(options) {
534
519
  const protector = createAuthioBffProtector(settings.cookieSecrets);
535
520
  const locks = new AuthioBffSessionLocks();
536
521
  const exchanges = /* @__PURE__ */ new Map();
537
- const decrypt = client.createAuthioTokenDecrypter(
538
- options.clientEncryptionKey ?? (options.clientEncryptionKeyPath ? client.encryptionKeyFromFile(options.clientEncryptionKeyPath) : void 0)
539
- );
540
522
  const prefix = settings.endpointPrefix;
541
523
  const loginLifetimeMs = AuthioBffDefaults.LoginLifetime * 1e3;
542
- const loginCookieName = (state) => `${settings.cookieName}.login.${crypto.createHash("sha256").update(state).digest("base64url").slice(0, 16)}`;
524
+ const loginCookieName = (state) => state ? `${settings.cookieName}.login.${crypto.createHash("sha256").update(state).digest("base64url").slice(0, 16)}` : `${settings.cookieName}.login`;
543
525
  const toInfo = (session) => Object.freeze({
544
526
  subject: textOf(session.data.claims.sub),
545
527
  sessionId: textOf(session.data.claims.sid),
@@ -583,6 +565,26 @@ function authioBff(options) {
583
565
  return;
584
566
  }
585
567
  };
568
+ const readAccessToken = async (accessToken) => {
569
+ if (!accessToken) return void 0;
570
+ try {
571
+ return await client$1.readAccessTokenClaims(accessToken);
572
+ } catch (error) {
573
+ report(error);
574
+ return void 0;
575
+ }
576
+ };
577
+ const refusesIdToken = async (idToken, accessToken, subject) => {
578
+ try {
579
+ const claims = await client$1.validateIdToken(idToken, { accessToken });
580
+ if (typeof subject === "string" && claims.sub !== subject)
581
+ throw new client.AuthioError(`The ID token of the refresh names '${claims.sub}', not the session's person.`);
582
+ return false;
583
+ } catch (error) {
584
+ report(error);
585
+ return true;
586
+ }
587
+ };
586
588
  const isActive = async (data) => {
587
589
  const token = data.refreshToken ?? data.accessToken;
588
590
  if (!token) return void 0;
@@ -643,12 +645,16 @@ function authioBff(options) {
643
645
  try {
644
646
  const issuedAt = clock.now();
645
647
  const answer = await client$1.tokens.refresh({ refreshToken: data.refreshToken });
648
+ if (answer.id_token && await refusesIdToken(answer.id_token, answer.access_token, data.claims.sub)) {
649
+ await endSessionByKey(request, response, session.key);
650
+ return void 0;
651
+ }
646
652
  data.accessToken = answer.access_token;
647
653
  data.accessTokenExpiresAt = issuedAt + lifetimeOf(answer.expires_in);
648
654
  data.tokenType = answer.token_type ?? data.tokenType;
649
655
  if (answer.refresh_token) data.refreshToken = answer.refresh_token;
650
656
  if (answer.id_token) data.idToken = answer.id_token;
651
- await replaceAccessTokenClaims(data.claims, answer.access_token, decrypt);
657
+ replaceAccessTokenClaims(data.claims, await readAccessToken(answer.access_token));
652
658
  if (!await store.get(session.key)) {
653
659
  await endSessionByKey(request, response, session.key);
654
660
  return void 0;
@@ -716,6 +722,9 @@ function authioBff(options) {
716
722
  const startLogin = async (request, response, parameters) => {
717
723
  const { url, transaction } = await client$1.createAuthorizationUrl({
718
724
  redirectUri: settings.redirectUri ?? requestBase(request) + settings.callbackPath,
725
+ checks: options.checks,
726
+ codeChallengeMethod: options.codeChallengeMethod,
727
+ responseMode: options.responseMode,
719
728
  scopes: settings.scopes,
720
729
  resource: parameters.resource ?? settings.resource,
721
730
  organization: parameters.organization ?? settings.organization,
@@ -754,22 +763,25 @@ function authioBff(options) {
754
763
  const silentLogin = (request, response) => startLogin(request, response, { returnUrl: `${request.baseUrl ?? ""}${prefix}/silent-login-callback`, prompt: "none" });
755
764
  const callback = async (request, response) => {
756
765
  const query = readQuery(request);
757
- const state = query.get("state") ?? "";
758
- const cookieName = state ? loginCookieName(state) : void 0;
759
- const sealed = cookieName ? readCookies(request).get(cookieName) : void 0;
766
+ const state = query.get("state") || void 0;
767
+ const cookies = readCookies(request);
768
+ const cookieName = [loginCookieName(state), loginCookieName(void 0)].find((name) => cookies.has(name));
769
+ const sealed = cookieName ? cookies.get(cookieName) : void 0;
760
770
  const pending = sealed ? protector.open("login", sealed) : void 0;
761
771
  if (cookieName && sealed) setCookie(response, cookieName, "", "lax", 0);
762
772
  response.set("Cache-Control", "no-store");
763
773
  const fail = (target2, error) => response.redirect(302, withQueryParameter(target2, "error", error));
764
- if (!pending || pending.transaction?.state !== state || pending.expiresAt <= clock.now()) return fail(settings.defaultReturnUrl, "login_failed");
774
+ if (!pending || pending.transaction?.state !== void 0 && pending.transaction.state !== state || pending.expiresAt <= clock.now())
775
+ return fail(settings.defaultReturnUrl, "login_failed");
765
776
  const target = isLocalUrl(pending.returnUrl) ? pending.returnUrl : settings.defaultReturnUrl;
766
777
  if (settings.requireIssuerParameter && !query.has("iss") && !query.has("error")) return fail(target, "login_failed");
767
778
  let data;
768
779
  try {
769
780
  const { tokens, idToken } = await client$1.handleAuthorizationCallback(query, pending.transaction);
770
781
  const now = clock.now();
771
- const claims = toSessionClaims(idToken, await readAccessTokenSubject(tokens.access_token, decrypt));
772
- await replaceAccessTokenClaims(claims, tokens.access_token, decrypt);
782
+ const accessClaims = await readAccessToken(tokens.access_token);
783
+ const claims = toSessionClaims(idToken, textOf(accessClaims?.sub));
784
+ replaceAccessTokenClaims(claims, accessClaims);
773
785
  if (settings.getClaimsFromUserInfoEndpoint) mergeUserInfo(claims, await client$1.userInfo(tokens.access_token));
774
786
  data = {
775
787
  publicId: crypto.randomBytes(16).toString("hex"),
@@ -871,6 +883,28 @@ function authioBff(options) {
871
883
  else if (logout2.subject) await store.deleteBySubject(logout2.subject);
872
884
  response.status(200).end();
873
885
  };
886
+ const frontChannelLogout = async (request, response) => {
887
+ response.set("Cache-Control", "no-store");
888
+ if (!settings.frontChannelLogout) return void response.status(404).end();
889
+ let sessionId;
890
+ try {
891
+ sessionId = client$1.validateFrontChannelLogout(readQuery(request)).sessionId;
892
+ } catch (error) {
893
+ report(error);
894
+ if (!(error instanceof client.AuthioError)) throw error;
895
+ return sendError(response, 400, "invalid_request", "The front-channel logout request is not valid.");
896
+ }
897
+ const cookie = readCookies(request).get(settings.cookieName);
898
+ const id = cookie ? protector.unsign(cookie) : void 0;
899
+ const key = id ? settings.sessionKeyPrefix + id : void 0;
900
+ if (sessionId) await store.deleteBySessionId(sessionId);
901
+ else if (key) {
902
+ await store.delete(key);
903
+ dropExchanges(key);
904
+ }
905
+ if (key && !await store.get(key)) clearCookie(response);
906
+ response.status(200).type("text/html; charset=utf-8").send('<!doctype html><html><head><meta charset="utf-8"><title>Authio</title></head><body></body></html>');
907
+ };
874
908
  const routes = /* @__PURE__ */ new Map([
875
909
  [settings.callbackPath, { methods: ["GET"], run: callback }],
876
910
  [settings.signedOutCallbackPath, { methods: ["GET"], run: signedOutCallback }],
@@ -879,7 +913,8 @@ function authioBff(options) {
879
913
  [`${prefix}/user`, { methods: ["GET"], run: user }],
880
914
  [`${prefix}/silent-login`, { methods: ["GET"], run: silentLogin }],
881
915
  [`${prefix}/silent-login-callback`, { methods: ["GET"], run: silentLoginCallback }],
882
- [`${prefix}/backchannel-logout`, { methods: ["POST"], run: backChannelLogout }]
916
+ [`${prefix}/backchannel-logout`, { methods: ["POST"], run: backChannelLogout }],
917
+ [`${prefix}/frontchannel-logout`, { methods: ["GET"], run: frontChannelLogout }]
883
918
  ]);
884
919
  const middleware = (request, response, next) => {
885
920
  const route = routes.get(request.path.replace(/(.)\/+$/, "$1"));