@hyper.authio/bff 0.7.0 → 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/dist/index.d.cts CHANGED
@@ -99,13 +99,16 @@ type AuthioBffSessionValidation = "none" | "introspection";
99
99
  type AuthioBffCookieSameSite = "lax" | "strict";
100
100
  /**
101
101
  * Settings of the Authio BFF. The client itself (`issuer` or `authority` + `realm`, `clientId`, `clientSecret`,
102
- * `clientAuthentication`, `clientPrivateKey`, `clientEncryptionKey`, `tokenRefreshSkew`…) takes the names of
103
- * `conformance/options.md`; the BFF's own settings are those of `Authio.AspNetCore.Bff` (`Authio:Bff`).
102
+ * `tokenEndpointAuthMethod`, `clientPrivateKey`, `clientEncryptionKey`, `checks`, `codeChallengeMethod`,
103
+ * `idTokenSignedResponseAlg`, `tokenRefreshSkew`…) takes the names of `conformance/options.md` and mirrors the client
104
+ * as registered in Authio: an option left out is off. The BFF's own settings are those of `Authio.AspNetCore.Bff`
105
+ * (`Authio:Bff`).
104
106
  */
105
107
  interface AuthioBffOptions extends Partial<AuthioClientOptions> {
106
108
  /**
107
109
  * An Authio client to use instead of creating one from the connection and credential options above. The BFF still
108
- * reads `tokenRefreshSkew`, `clientEncryptionKey`, `redirectUri` and `postLogoutRedirectUri` from these options.
110
+ * reads `tokenRefreshSkew`, `checks`, `codeChallengeMethod`, `responseMode`, `redirectUri` and
111
+ * `postLogoutRedirectUri` from these options; tokens are validated and decrypted by the client.
109
112
  */
110
113
  client?: AuthioClient;
111
114
  /**
@@ -118,7 +121,10 @@ interface AuthioBffOptions extends Partial<AuthioClientOptions> {
118
121
  store?: AuthioBffSessionStore;
119
122
  /** The route prefix of the BFF endpoints. Defaults to `/bff`. */
120
123
  endpointPrefix?: string;
121
- /** The scopes asked for at sign-in. Defaults to `openid profile email offline_access`; `openid` is always sent. */
124
+ /**
125
+ * The scopes asked for at sign-in, sent as configured: without `openid` there is no ID token, and the session is
126
+ * built from the access token (and UserInfo). Defaults to `openid profile email offline_access`.
127
+ */
122
128
  scopes?: readonly string[];
123
129
  /** Adds the `roles` scope, so the session carries the `*_resource_access` claims flattened on `/bff/user`. */
124
130
  requestRoles?: boolean;
@@ -174,6 +180,12 @@ interface AuthioBffOptions extends Partial<AuthioClientOptions> {
174
180
  * (`sub`) a logout token names. Defaults to `true`; register that URL as the client's back-channel logout URI.
175
181
  */
176
182
  backChannelLogout?: boolean;
183
+ /**
184
+ * Serves `GET {endpointPrefix}/frontchannel-logout`, which Authio loads in an iframe when a session ends: it ends the
185
+ * sessions of the `sid` it names (with `iss` checked), or the browser's own session when it names none. Defaults to
186
+ * `false`; register that URL as the client's front-channel logout URI, with the session required.
187
+ */
188
+ frontChannelLogout?: boolean;
177
189
  /** Called with errors the BFF recovers from: a failed revocation, introspection or refresh, a refused logout token. */
178
190
  onError?: (error: unknown) => void;
179
191
  }
@@ -289,8 +301,9 @@ interface AuthioBff extends RequestHandler {
289
301
  }
290
302
  /**
291
303
  * Creates the Authio BFF. Mount it with `app.use(bff)`: it serves `{endpointPrefix}/login`, `/logout`, `/user`,
292
- * `/silent-login`, `/silent-login-callback`, `/backchannel-logout`, the sign-in callback (`callbackPath`) and the
293
- * sign-out callback (`signedOutCallbackPath`), and reads the session of every other request that carries the cookie.
304
+ * `/silent-login`, `/silent-login-callback`, `/backchannel-logout`, `/frontchannel-logout` (when turned on), the sign-in
305
+ * callback (`callbackPath`) and the sign-out callback (`signedOutCallbackPath`), and reads the session of every other
306
+ * request that carries the cookie.
294
307
  *
295
308
  * @throws AuthioConfigurationError naming every invalid option.
296
309
  */
package/dist/index.d.ts CHANGED
@@ -99,13 +99,16 @@ type AuthioBffSessionValidation = "none" | "introspection";
99
99
  type AuthioBffCookieSameSite = "lax" | "strict";
100
100
  /**
101
101
  * Settings of the Authio BFF. The client itself (`issuer` or `authority` + `realm`, `clientId`, `clientSecret`,
102
- * `clientAuthentication`, `clientPrivateKey`, `clientEncryptionKey`, `tokenRefreshSkew`…) takes the names of
103
- * `conformance/options.md`; the BFF's own settings are those of `Authio.AspNetCore.Bff` (`Authio:Bff`).
102
+ * `tokenEndpointAuthMethod`, `clientPrivateKey`, `clientEncryptionKey`, `checks`, `codeChallengeMethod`,
103
+ * `idTokenSignedResponseAlg`, `tokenRefreshSkew`…) takes the names of `conformance/options.md` and mirrors the client
104
+ * as registered in Authio: an option left out is off. The BFF's own settings are those of `Authio.AspNetCore.Bff`
105
+ * (`Authio:Bff`).
104
106
  */
105
107
  interface AuthioBffOptions extends Partial<AuthioClientOptions> {
106
108
  /**
107
109
  * An Authio client to use instead of creating one from the connection and credential options above. The BFF still
108
- * reads `tokenRefreshSkew`, `clientEncryptionKey`, `redirectUri` and `postLogoutRedirectUri` from these options.
110
+ * reads `tokenRefreshSkew`, `checks`, `codeChallengeMethod`, `responseMode`, `redirectUri` and
111
+ * `postLogoutRedirectUri` from these options; tokens are validated and decrypted by the client.
109
112
  */
110
113
  client?: AuthioClient;
111
114
  /**
@@ -118,7 +121,10 @@ interface AuthioBffOptions extends Partial<AuthioClientOptions> {
118
121
  store?: AuthioBffSessionStore;
119
122
  /** The route prefix of the BFF endpoints. Defaults to `/bff`. */
120
123
  endpointPrefix?: string;
121
- /** The scopes asked for at sign-in. Defaults to `openid profile email offline_access`; `openid` is always sent. */
124
+ /**
125
+ * The scopes asked for at sign-in, sent as configured: without `openid` there is no ID token, and the session is
126
+ * built from the access token (and UserInfo). Defaults to `openid profile email offline_access`.
127
+ */
122
128
  scopes?: readonly string[];
123
129
  /** Adds the `roles` scope, so the session carries the `*_resource_access` claims flattened on `/bff/user`. */
124
130
  requestRoles?: boolean;
@@ -174,6 +180,12 @@ interface AuthioBffOptions extends Partial<AuthioClientOptions> {
174
180
  * (`sub`) a logout token names. Defaults to `true`; register that URL as the client's back-channel logout URI.
175
181
  */
176
182
  backChannelLogout?: boolean;
183
+ /**
184
+ * Serves `GET {endpointPrefix}/frontchannel-logout`, which Authio loads in an iframe when a session ends: it ends the
185
+ * sessions of the `sid` it names (with `iss` checked), or the browser's own session when it names none. Defaults to
186
+ * `false`; register that URL as the client's front-channel logout URI, with the session required.
187
+ */
188
+ frontChannelLogout?: boolean;
177
189
  /** Called with errors the BFF recovers from: a failed revocation, introspection or refresh, a refused logout token. */
178
190
  onError?: (error: unknown) => void;
179
191
  }
@@ -289,8 +301,9 @@ interface AuthioBff extends RequestHandler {
289
301
  }
290
302
  /**
291
303
  * Creates the Authio BFF. Mount it with `app.use(bff)`: it serves `{endpointPrefix}/login`, `/logout`, `/user`,
292
- * `/silent-login`, `/silent-login-callback`, `/backchannel-logout`, the sign-in callback (`callbackPath`) and the
293
- * sign-out callback (`signedOutCallbackPath`), and reads the session of every other request that carries the cookie.
304
+ * `/silent-login`, `/silent-login-callback`, `/backchannel-logout`, `/frontchannel-logout` (when turned on), the sign-in
305
+ * callback (`callbackPath`) and the sign-out callback (`signedOutCallbackPath`), and reads the session of every other
306
+ * request that carries the cookie.
294
307
  *
295
308
  * @throws AuthioConfigurationError naming every invalid option.
296
309
  */
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes, createDecipheriv, createCipheriv, timingSafeEqual, createHash, hkdfSync, createHmac } from 'crypto';
2
- import { AuthioClaimTypes, AuthioResourceAccessClaims, systemClock, createAuthioClient, createAuthioTokenDecrypter, encryptionKeyFromFile, AuthioPrompts, AuthioProtocolError, AuthioTokenTypes, AuthioError, AuthioConfigurationError, AuthioDefaults, resourceAccessSourceOf, parseResourceAccess, AuthioOAuthErrors, isAuthioTokenEncrypted } from '@hyper.authio/client';
2
+ import { AuthioClaimTypes, AuthioResourceAccessClaims, systemClock, createAuthioClient, AuthioPrompts, AuthioProtocolError, AuthioTokenTypes, AuthioError, AuthioConfigurationError, AuthioDefaults, resourceAccessSourceOf, parseResourceAccess, AuthioOAuthErrors } from '@hyper.authio/client';
3
3
  import { Readable } from 'stream';
4
4
  import { pipeline } from 'stream/promises';
5
5
 
@@ -30,16 +30,11 @@ function toSessionClaims(idToken, fallbackSubject) {
30
30
  function mergeUserInfo(claims, userInfo) {
31
31
  for (const [type, value] of Object.entries(userInfo)) if (claims[type] === void 0 && value !== void 0 && value !== null) claims[type] = value;
32
32
  }
33
- async function replaceAccessTokenClaims(claims, accessToken, decrypt) {
34
- const payload = await readPayload(accessToken, decrypt);
33
+ function replaceAccessTokenClaims(claims, payload) {
35
34
  if (!payload) return;
36
35
  for (const type of Object.keys(claims)) if (accessTokenClaimTypes.has(type)) delete claims[type];
37
36
  for (const [type, value] of Object.entries(payload)) if (accessTokenClaimTypes.has(type) && value !== void 0 && value !== null) claims[type] = value;
38
37
  }
39
- async function readAccessTokenSubject(accessToken, decrypt) {
40
- const subject = (await readPayload(accessToken, decrypt))?.sub;
41
- return typeof subject === "string" && subject ? subject : void 0;
42
- }
43
38
  function toAuthioBffClaims(claims, sessionExpiresIn, logoutUrl) {
44
39
  const entries = [];
45
40
  const seen = /* @__PURE__ */ new Set();
@@ -60,18 +55,6 @@ function toAuthioBffClaims(claims, sessionExpiresIn, logoutUrl) {
60
55
  entries.push({ type: AuthioBffClaimTypes.LogoutUrl, value: logoutUrl });
61
56
  return entries;
62
57
  }
63
- async function readPayload(token, decrypt) {
64
- if (!token) return void 0;
65
- try {
66
- const signed = isAuthioTokenEncrypted(token) ? await decrypt(token) : token;
67
- const parts = signed.split(".");
68
- if (parts.length !== 3 || !parts[1]) return void 0;
69
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
70
- return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : void 0;
71
- } catch {
72
- return void 0;
73
- }
74
- }
75
58
  function toText(value) {
76
59
  return typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
77
60
  }
@@ -200,7 +183,7 @@ function resolveBffSettings(options) {
200
183
  const settings = {
201
184
  cookieSecrets,
202
185
  endpointPrefix: `/${(options.endpointPrefix ?? AuthioBffDefaults.EndpointPrefix).replace(/^\/+|\/+$/g, "")}`,
203
- scopes: withOpenId([...options.scopes ?? AuthioBffDefaults.Scopes, ...options.requestRoles ? ["roles"] : []]),
186
+ scopes: distinct([...options.scopes ?? AuthioBffDefaults.Scopes, ...options.requestRoles ? ["roles"] : []]),
204
187
  getClaimsFromUserInfoEndpoint: options.getClaimsFromUserInfoEndpoint ?? false,
205
188
  organization: nonEmpty(options.organization),
206
189
  resource: nonEmpty(options.resource),
@@ -224,6 +207,7 @@ function resolveBffSettings(options) {
224
207
  antiForgeryHeaderValue: options.antiForgeryHeaderValue ?? AuthioBffDefaults.AntiForgeryHeaderValue,
225
208
  sessionKeyPrefix: options.sessionKeyPrefix ?? AuthioBffDefaults.SessionKeyPrefix,
226
209
  backChannelLogout: options.backChannelLogout ?? true,
210
+ frontChannelLogout: options.frontChannelLogout ?? false,
227
211
  tokenRefreshSkewMs: (options.tokenRefreshSkew ?? 60) * 1e3,
228
212
  redirectUri: nonEmpty(options.redirectUri),
229
213
  postLogoutRedirectUri: nonEmpty(options.postLogoutRedirectUri),
@@ -252,6 +236,8 @@ function resolveBffSettings(options) {
252
236
  if (settings.maxAge !== void 0 && (!Number.isInteger(settings.maxAge) || settings.maxAge < 0))
253
237
  problems.push("maxAge must be a non-negative whole number of seconds.");
254
238
  if (!settings.sessionKeyPrefix.trim()) problems.push("sessionKeyPrefix is required.");
239
+ if (options.responseMode !== void 0 && options.responseMode !== "query")
240
+ problems.push("responseMode must be query: the BFF reads Authio's answer at callbackPath (web_message needs a page of the SPA's own).");
255
241
  if (!Number.isFinite(settings.tokenRefreshSkewMs) || settings.tokenRefreshSkewMs < 0) problems.push("tokenRefreshSkew must be a non-negative number of seconds.");
256
242
  if (problems.length > 0) throw new AuthioConfigurationError(`The Authio BFF options are not valid:
257
243
  - ${problems.join("\n- ")}`);
@@ -261,9 +247,8 @@ function isLocalUrl(url) {
261
247
  if (typeof url !== "string" || !url || /[\u0000-\u001f\u007f-\u009f]/.test(url)) return false;
262
248
  return url[0] === "/" && (url.length === 1 || url[1] !== "/" && url[1] !== "\\");
263
249
  }
264
- function withOpenId(scopes) {
265
- const distinct = [...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))];
266
- return Object.freeze(distinct.includes("openid") ? distinct : ["openid", ...distinct]);
250
+ function distinct(scopes) {
251
+ return Object.freeze([...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))]);
267
252
  }
268
253
  function nonEmpty(value) {
269
254
  return value?.trim() ? value : void 0;
@@ -532,12 +517,9 @@ function authioBff(options) {
532
517
  const protector = createAuthioBffProtector(settings.cookieSecrets);
533
518
  const locks = new AuthioBffSessionLocks();
534
519
  const exchanges = /* @__PURE__ */ new Map();
535
- const decrypt = createAuthioTokenDecrypter(
536
- options.clientEncryptionKey ?? (options.clientEncryptionKeyPath ? encryptionKeyFromFile(options.clientEncryptionKeyPath) : void 0)
537
- );
538
520
  const prefix = settings.endpointPrefix;
539
521
  const loginLifetimeMs = AuthioBffDefaults.LoginLifetime * 1e3;
540
- const loginCookieName = (state) => `${settings.cookieName}.login.${createHash("sha256").update(state).digest("base64url").slice(0, 16)}`;
522
+ const loginCookieName = (state) => state ? `${settings.cookieName}.login.${createHash("sha256").update(state).digest("base64url").slice(0, 16)}` : `${settings.cookieName}.login`;
541
523
  const toInfo = (session) => Object.freeze({
542
524
  subject: textOf(session.data.claims.sub),
543
525
  sessionId: textOf(session.data.claims.sid),
@@ -581,6 +563,26 @@ function authioBff(options) {
581
563
  return;
582
564
  }
583
565
  };
566
+ const readAccessToken = async (accessToken) => {
567
+ if (!accessToken) return void 0;
568
+ try {
569
+ return await client.readAccessTokenClaims(accessToken);
570
+ } catch (error) {
571
+ report(error);
572
+ return void 0;
573
+ }
574
+ };
575
+ const refusesIdToken = async (idToken, accessToken, subject) => {
576
+ try {
577
+ const claims = await client.validateIdToken(idToken, { accessToken });
578
+ if (typeof subject === "string" && claims.sub !== subject)
579
+ throw new AuthioError(`The ID token of the refresh names '${claims.sub}', not the session's person.`);
580
+ return false;
581
+ } catch (error) {
582
+ report(error);
583
+ return true;
584
+ }
585
+ };
584
586
  const isActive = async (data) => {
585
587
  const token = data.refreshToken ?? data.accessToken;
586
588
  if (!token) return void 0;
@@ -641,12 +643,16 @@ function authioBff(options) {
641
643
  try {
642
644
  const issuedAt = clock.now();
643
645
  const answer = await client.tokens.refresh({ refreshToken: data.refreshToken });
646
+ if (answer.id_token && await refusesIdToken(answer.id_token, answer.access_token, data.claims.sub)) {
647
+ await endSessionByKey(request, response, session.key);
648
+ return void 0;
649
+ }
644
650
  data.accessToken = answer.access_token;
645
651
  data.accessTokenExpiresAt = issuedAt + lifetimeOf(answer.expires_in);
646
652
  data.tokenType = answer.token_type ?? data.tokenType;
647
653
  if (answer.refresh_token) data.refreshToken = answer.refresh_token;
648
654
  if (answer.id_token) data.idToken = answer.id_token;
649
- await replaceAccessTokenClaims(data.claims, answer.access_token, decrypt);
655
+ replaceAccessTokenClaims(data.claims, await readAccessToken(answer.access_token));
650
656
  if (!await store.get(session.key)) {
651
657
  await endSessionByKey(request, response, session.key);
652
658
  return void 0;
@@ -714,6 +720,9 @@ function authioBff(options) {
714
720
  const startLogin = async (request, response, parameters) => {
715
721
  const { url, transaction } = await client.createAuthorizationUrl({
716
722
  redirectUri: settings.redirectUri ?? requestBase(request) + settings.callbackPath,
723
+ checks: options.checks,
724
+ codeChallengeMethod: options.codeChallengeMethod,
725
+ responseMode: options.responseMode,
717
726
  scopes: settings.scopes,
718
727
  resource: parameters.resource ?? settings.resource,
719
728
  organization: parameters.organization ?? settings.organization,
@@ -752,22 +761,25 @@ function authioBff(options) {
752
761
  const silentLogin = (request, response) => startLogin(request, response, { returnUrl: `${request.baseUrl ?? ""}${prefix}/silent-login-callback`, prompt: "none" });
753
762
  const callback = async (request, response) => {
754
763
  const query = readQuery(request);
755
- const state = query.get("state") ?? "";
756
- const cookieName = state ? loginCookieName(state) : void 0;
757
- const sealed = cookieName ? readCookies(request).get(cookieName) : void 0;
764
+ const state = query.get("state") || void 0;
765
+ const cookies = readCookies(request);
766
+ const cookieName = [loginCookieName(state), loginCookieName(void 0)].find((name) => cookies.has(name));
767
+ const sealed = cookieName ? cookies.get(cookieName) : void 0;
758
768
  const pending = sealed ? protector.open("login", sealed) : void 0;
759
769
  if (cookieName && sealed) setCookie(response, cookieName, "", "lax", 0);
760
770
  response.set("Cache-Control", "no-store");
761
771
  const fail = (target2, error) => response.redirect(302, withQueryParameter(target2, "error", error));
762
- if (!pending || pending.transaction?.state !== state || pending.expiresAt <= clock.now()) return fail(settings.defaultReturnUrl, "login_failed");
772
+ if (!pending || pending.transaction?.state !== void 0 && pending.transaction.state !== state || pending.expiresAt <= clock.now())
773
+ return fail(settings.defaultReturnUrl, "login_failed");
763
774
  const target = isLocalUrl(pending.returnUrl) ? pending.returnUrl : settings.defaultReturnUrl;
764
775
  if (settings.requireIssuerParameter && !query.has("iss") && !query.has("error")) return fail(target, "login_failed");
765
776
  let data;
766
777
  try {
767
778
  const { tokens, idToken } = await client.handleAuthorizationCallback(query, pending.transaction);
768
779
  const now = clock.now();
769
- const claims = toSessionClaims(idToken, await readAccessTokenSubject(tokens.access_token, decrypt));
770
- await replaceAccessTokenClaims(claims, tokens.access_token, decrypt);
780
+ const accessClaims = await readAccessToken(tokens.access_token);
781
+ const claims = toSessionClaims(idToken, textOf(accessClaims?.sub));
782
+ replaceAccessTokenClaims(claims, accessClaims);
771
783
  if (settings.getClaimsFromUserInfoEndpoint) mergeUserInfo(claims, await client.userInfo(tokens.access_token));
772
784
  data = {
773
785
  publicId: randomBytes(16).toString("hex"),
@@ -869,6 +881,28 @@ function authioBff(options) {
869
881
  else if (logout2.subject) await store.deleteBySubject(logout2.subject);
870
882
  response.status(200).end();
871
883
  };
884
+ const frontChannelLogout = async (request, response) => {
885
+ response.set("Cache-Control", "no-store");
886
+ if (!settings.frontChannelLogout) return void response.status(404).end();
887
+ let sessionId;
888
+ try {
889
+ sessionId = client.validateFrontChannelLogout(readQuery(request)).sessionId;
890
+ } catch (error) {
891
+ report(error);
892
+ if (!(error instanceof AuthioError)) throw error;
893
+ return sendError(response, 400, "invalid_request", "The front-channel logout request is not valid.");
894
+ }
895
+ const cookie = readCookies(request).get(settings.cookieName);
896
+ const id = cookie ? protector.unsign(cookie) : void 0;
897
+ const key = id ? settings.sessionKeyPrefix + id : void 0;
898
+ if (sessionId) await store.deleteBySessionId(sessionId);
899
+ else if (key) {
900
+ await store.delete(key);
901
+ dropExchanges(key);
902
+ }
903
+ if (key && !await store.get(key)) clearCookie(response);
904
+ 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>');
905
+ };
872
906
  const routes = /* @__PURE__ */ new Map([
873
907
  [settings.callbackPath, { methods: ["GET"], run: callback }],
874
908
  [settings.signedOutCallbackPath, { methods: ["GET"], run: signedOutCallback }],
@@ -877,7 +911,8 @@ function authioBff(options) {
877
911
  [`${prefix}/user`, { methods: ["GET"], run: user }],
878
912
  [`${prefix}/silent-login`, { methods: ["GET"], run: silentLogin }],
879
913
  [`${prefix}/silent-login-callback`, { methods: ["GET"], run: silentLoginCallback }],
880
- [`${prefix}/backchannel-logout`, { methods: ["POST"], run: backChannelLogout }]
914
+ [`${prefix}/backchannel-logout`, { methods: ["POST"], run: backChannelLogout }],
915
+ [`${prefix}/frontchannel-logout`, { methods: ["GET"], run: frontChannelLogout }]
881
916
  ]);
882
917
  const middleware = (request, response, next) => {
883
918
  const route = routes.get(request.path.replace(/(.)\/+$/, "$1"));