@hyper.authio/bff 0.8.1 → 0.10.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
@@ -37,6 +37,10 @@ and refresh tokens outlive the page. With a BFF:
37
37
  with the session required so Authio sends `iss` and `sid`.
38
38
  - **Scopes**: `openid profile email offline_access` (`offline_access` gives the refresh token), plus `roles` if you set
39
39
  `requestRoles`, plus your API scopes.
40
+ - **Access token format**: `Jwt` needs nothing; `Lightweight`, whose token leaves the roles and permissions to
41
+ introspection, is mirrored by `accessTokenClaims: "introspection"`; a `Reference` (an opaque handle) is introspected
42
+ under either value. The BFF introspects as its own confidential client. The refresh token, of either format, is
43
+ opaque to the BFF.
40
44
  - **Resources** (optional): the API resource identifiers you ask for with `resource` or use with remote APIs; allow
41
45
  the **token exchange** grant if a remote API needs a token for a specific resource or a narrower scope set.
42
46
 
@@ -152,8 +156,18 @@ refresh token, which stays addressed to the BFF.
152
156
  error keeps the current token while it lasts. An ID token the refresh returns is validated (signature, `iss`, `aud`,
153
157
  `exp`, `at_hash`, the same `sub`) before it is stored; one that does not validate ends the session.
154
158
  - **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`.
159
+ (`*_resource_access`, `org_id`, `org_alias`, `account_id`), plus UserInfo's with `getClaimsFromUserInfoEndpoint`.
160
+ The access token's are read at sign-in and replaced on every refresh, from where `accessTokenClaims` says: `token`
161
+ (the default) reads them once the access token's signature, `iss` and `exp` verify (`client.readAccessTokenClaims`);
162
+ `introspection` asks Authio's introspection about the access token, as the BFF's client, for a `Lightweight` token
163
+ that does not carry them: there a failed or inactive introspection fails the sign-in (`?error=login_failed`, or the
164
+ OAuth error), and on a refresh `active: false` ends the session while any other failure keeps the session's
165
+ previous claims until the next refresh, stores the new tokens and goes to `onError`. Under `token`, an opaque
166
+ (`Reference`) access token is introspected too: a token requested for a `resource` is active only to that resource
167
+ server's introspection client, so an inactive answer leaves its claims out, without failing the sign-in or ending
168
+ the session; a failed introspection (`5xx`, timeout, network) fails the sign-in and, on a refresh, keeps the
169
+ previous claims. The table of [`conformance/options.md`](../../conformance/options.md#access-token-formats) has
170
+ every case.
157
171
  - **Session check** (`sessionValidation: "introspection"`, the default): every `sessionValidationInterval` (300 s) the
158
172
  refresh token (or the access token when there is none) is introspected; an inactive answer ends the session. Needs a
159
173
  confidential client.
@@ -219,6 +233,7 @@ option left out is off:
219
233
  | ID token signing algorithm HS256 | `clientSecret`, `idTokenSignedResponseAlg: "HS256"` |
220
234
  | Access token signing algorithm HS256 | `clientSecret`, `accessTokenSignedResponseAlg: "HS256"` |
221
235
  | Front-channel logout URI with the session required | `frontChannelLogout: true` |
236
+ | Access token format `Lightweight` | `accessTokenClaims: "introspection"` |
222
237
 
223
238
  `checks`, `codeChallengeMethod` and `responseMode` (only `query`: the BFF reads the callback itself) are read even with
224
239
  an existing `client`. The BFF's own options are those of `Authio.AspNetCore.Bff` (`Authio:Bff`), in camelCase, with
@@ -231,6 +246,7 @@ durations in seconds:
231
246
  | `endpointPrefix` | `/bff` | Where the BFF endpoints are served. |
232
247
  | `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
248
  | `requestRoles` | `false` | Adds `roles`, so the session carries `*_resource_access`. |
249
+ | `accessTokenClaims` | `token` | Where the session's roles, permissions and organization come from: `token` (the verified access token; an opaque one is introspected, and contributes nothing when it is not active to the BFF's client) or `introspection` (Authio's introspection of it, for the `Lightweight` access token format; needs a confidential client, and a failed or inactive answer fails the sign-in). |
234
250
  | `getClaimsFromUserInfoEndpoint` | `false` | Adds UserInfo's claims to the session. |
235
251
  | `organization`, `resource`, `prompt`, `uiLocales`, `maxAge` | — | Sent on every sign-in unless the login request names one. |
236
252
  | `allowedResources` | `[]` | What `/bff/login?resource=` may ask for. |
package/dist/index.cjs CHANGED
@@ -186,6 +186,7 @@ function resolveBffSettings(options) {
186
186
  cookieSecrets,
187
187
  endpointPrefix: `/${(options.endpointPrefix ?? AuthioBffDefaults.EndpointPrefix).replace(/^\/+|\/+$/g, "")}`,
188
188
  scopes: distinct([...options.scopes ?? AuthioBffDefaults.Scopes, ...options.requestRoles ? ["roles"] : []]),
189
+ accessTokenClaims: options.accessTokenClaims ?? "token",
189
190
  getClaimsFromUserInfoEndpoint: options.getClaimsFromUserInfoEndpoint ?? false,
190
191
  organization: nonEmpty(options.organization),
191
192
  resource: nonEmpty(options.resource),
@@ -223,6 +224,12 @@ function resolveBffSettings(options) {
223
224
  else if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(settings.cookieName)) problems.push("cookieName holds characters a cookie name cannot.");
224
225
  if (settings.cookieSameSite !== "lax" && settings.cookieSameSite !== "strict") problems.push("cookieSameSite must be lax or strict.");
225
226
  if (!isPositive(settings.sessionLifetimeMs)) problems.push("sessionLifetime must be a positive number of seconds.");
227
+ if (settings.accessTokenClaims !== "token" && settings.accessTokenClaims !== "introspection")
228
+ problems.push("accessTokenClaims must be token or introspection.");
229
+ else if (settings.accessTokenClaims === "introspection" && !options.client && isPublicClient(options))
230
+ problems.push(
231
+ "accessTokenClaims introspection needs a confidential client: Authio answers introspection only to a client that authenticates, so set clientSecret or clientPrivateKey."
232
+ );
226
233
  if (settings.sessionValidation !== "none" && settings.sessionValidation !== "introspection")
227
234
  problems.push("sessionValidation must be none or introspection.");
228
235
  else if (settings.sessionValidation === "introspection" && !isPositive(settings.sessionValidationIntervalMs))
@@ -249,6 +256,10 @@ function isLocalUrl(url) {
249
256
  if (typeof url !== "string" || !url || /[\u0000-\u001f\u007f-\u009f]/.test(url)) return false;
250
257
  return url[0] === "/" && (url.length === 1 || url[1] !== "/" && url[1] !== "\\");
251
258
  }
259
+ function isPublicClient(options) {
260
+ if (options.tokenEndpointAuthMethod !== void 0) return options.tokenEndpointAuthMethod === "none";
261
+ return !options.clientSecret && options.clientPrivateKey === void 0 && !options.clientPrivateKeyPath;
262
+ }
252
263
  function distinct(scopes) {
253
264
  return Object.freeze([...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))]);
254
265
  }
@@ -566,7 +577,6 @@ function authioBff(options) {
566
577
  }
567
578
  };
568
579
  const readAccessToken = async (accessToken) => {
569
- if (!accessToken) return void 0;
570
580
  try {
571
581
  return await client$1.readAccessTokenClaims(accessToken);
572
582
  } catch (error) {
@@ -574,6 +584,25 @@ function authioBff(options) {
574
584
  return void 0;
575
585
  }
576
586
  };
587
+ const introspectAccessToken = async (accessToken) => {
588
+ const { active, ...claims } = await client$1.introspect(accessToken, { tokenTypeHint: client.AuthioTokenTypes.AccessToken });
589
+ if (active !== true) throw new InactiveAccessTokenError();
590
+ return claims;
591
+ };
592
+ const introspectOpaqueAccessToken = async (accessToken) => {
593
+ try {
594
+ return await introspectAccessToken(accessToken);
595
+ } catch (error) {
596
+ if (error instanceof InactiveAccessTokenError) return void 0;
597
+ throw error;
598
+ }
599
+ };
600
+ const accessTokenClaimsOf = async (accessToken) => {
601
+ if (!accessToken) return void 0;
602
+ if (settings.accessTokenClaims === "introspection") return introspectAccessToken(accessToken);
603
+ if (isReferenceToken(accessToken)) return introspectOpaqueAccessToken(accessToken);
604
+ return readAccessToken(accessToken);
605
+ };
577
606
  const refusesIdToken = async (idToken, accessToken, subject) => {
578
607
  try {
579
608
  const claims = await client$1.validateIdToken(idToken, { accessToken });
@@ -638,6 +667,15 @@ function authioBff(options) {
638
667
  return loading;
639
668
  };
640
669
  const isFresh = (data, skewMs) => Boolean(data.accessToken) && (data.accessTokenExpiresAt === void 0 || clock.now() < data.accessTokenExpiresAt - skewMs);
670
+ const refreshedAccessTokenClaims = async (accessToken) => {
671
+ try {
672
+ return await accessTokenClaimsOf(accessToken);
673
+ } catch (error) {
674
+ if (error instanceof InactiveAccessTokenError) return "inactive";
675
+ report(error);
676
+ return void 0;
677
+ }
678
+ };
641
679
  const refresh = async (request, response, session) => {
642
680
  const data = { ...session.data, claims: { ...session.data.claims } };
643
681
  const unexpired = isFresh(data, 0) ? data.accessToken : void 0;
@@ -654,11 +692,12 @@ function authioBff(options) {
654
692
  data.tokenType = answer.token_type ?? data.tokenType;
655
693
  if (answer.refresh_token) data.refreshToken = answer.refresh_token;
656
694
  if (answer.id_token) data.idToken = answer.id_token;
657
- replaceAccessTokenClaims(data.claims, await readAccessToken(answer.access_token));
658
- if (!await store.get(session.key)) {
695
+ const accessClaims = await refreshedAccessTokenClaims(answer.access_token);
696
+ if (accessClaims === "inactive" || !await store.get(session.key)) {
659
697
  await endSessionByKey(request, response, session.key);
660
698
  return void 0;
661
699
  }
700
+ replaceAccessTokenClaims(data.claims, accessClaims);
662
701
  expose(request, await write(session.key, data, session.expiresAt));
663
702
  return answer.access_token;
664
703
  } catch (error) {
@@ -779,7 +818,7 @@ function authioBff(options) {
779
818
  try {
780
819
  const { tokens, idToken } = await client$1.handleAuthorizationCallback(query, pending.transaction);
781
820
  const now = clock.now();
782
- const accessClaims = await readAccessToken(tokens.access_token);
821
+ const accessClaims = await accessTokenClaimsOf(tokens.access_token);
783
822
  const claims = toSessionClaims(idToken, textOf(accessClaims?.sub));
784
823
  replaceAccessTokenClaims(claims, accessClaims);
785
824
  if (settings.getClaimsFromUserInfoEndpoint) mergeUserInfo(claims, await client$1.userInfo(tokens.access_token));
@@ -976,6 +1015,14 @@ function lifetimeOf(expiresIn) {
976
1015
  function textOf(value) {
977
1016
  return typeof value === "string" && value ? value : void 0;
978
1017
  }
1018
+ var InactiveAccessTokenError = class extends client.AuthioError {
1019
+ constructor() {
1020
+ super("Authio reports the session's access token as not active, so its claims cannot be read.");
1021
+ }
1022
+ };
1023
+ function isReferenceToken(token) {
1024
+ return !token.includes(".");
1025
+ }
979
1026
  function fixedTimeEquals(left, right) {
980
1027
  const a = Buffer.from(left, "utf8");
981
1028
  const b = Buffer.from(right, "utf8");