@hyper.authio/bff 0.9.0 → 0.10.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/dist/index.d.cts CHANGED
@@ -93,6 +93,15 @@ declare const AuthioBffDefaults: {
93
93
  * refresh token (its access token when there is none) every `sessionValidationInterval`.
94
94
  */
95
95
  type AuthioBffSessionValidation = "none" | "introspection";
96
+ /**
97
+ * Where the BFF reads the claims only the access token carries (the `*_resource_access` roles and permissions,
98
+ * `org_id`, `org_alias`, `account_id`): `token`, from the access token once its signature verifies; or
99
+ * `introspection`, from Authio's introspection of the access token, asked as the BFF's own client. Mirror the client's
100
+ * access token format: `introspection` for `Lightweight`, whose token leaves those claims to introspection. Under
101
+ * `token`, an opaque (`Reference`) access token is introspected too, and contributes nothing when it is not active to
102
+ * the BFF's client.
103
+ */
104
+ type AuthioBffAccessTokenClaims = "token" | "introspection";
96
105
  /**
97
106
  * The `SameSite` mode of the session cookie.
98
107
  */
@@ -128,6 +137,17 @@ interface AuthioBffOptions extends Partial<AuthioClientOptions> {
128
137
  scopes?: readonly string[];
129
138
  /** Adds the `roles` scope, so the session carries the `*_resource_access` claims flattened on `/bff/user`. */
130
139
  requestRoles?: boolean;
140
+ /**
141
+ * Where the session's roles, permissions and organization come from, read at sign-in and again on every refresh:
142
+ * `token` (default), the access token once its signature verifies; `introspection`, Authio's introspection of the
143
+ * access token, asked as the BFF's own confidential client. Use `introspection` when the client's access token
144
+ * format is `Lightweight`: then a failed or inactive introspection fails the sign-in, and on a refresh an inactive
145
+ * answer ends the session while any other failure keeps the session's previous claims until the next refresh and
146
+ * goes to `onError`. Under `token`, an opaque (`Reference`) access token is introspected too: an inactive answer
147
+ * (a token requested for another resource server's introspection client) leaves its claims out and keeps the session
148
+ * on a refresh, while a failed introspection fails the sign-in and, on a refresh, keeps the previous claims.
149
+ */
150
+ accessTokenClaims?: AuthioBffAccessTokenClaims;
131
151
  /** Calls Authio's UserInfo endpoint after sign-in and adds its claims to the session. Defaults to `false`. */
132
152
  getClaimsFromUserInfoEndpoint?: boolean;
133
153
  /** The `organization` sent on every sign-in unless the login request names one. */
@@ -234,7 +254,10 @@ interface AuthioBffSessionInfo {
234
254
  readonly subject: string | undefined;
235
255
  /** The Authio session (`sid`). */
236
256
  readonly sessionId: string | undefined;
237
- /** The session's claims: the ID token's, the access token's `*_resource_access` and organization, UserInfo's. */
257
+ /**
258
+ * The session's claims: the ID token's, the access token's `*_resource_access` and organization (read from the
259
+ * token or its introspection, as `accessTokenClaims` says), UserInfo's.
260
+ */
238
261
  readonly claims: Readonly<Record<string, unknown>>;
239
262
  /** When the BFF session expires, in milliseconds since the epoch. */
240
263
  readonly expiresAt: number;
@@ -337,4 +360,4 @@ interface AuthioBffError {
337
360
  readonly error_description?: string;
338
361
  }
339
362
 
340
- export { type AuthioBff, type AuthioBffApiOptions, type AuthioBffClaim, AuthioBffClaimTypes, type AuthioBffCookieSameSite, AuthioBffDefaults, type AuthioBffError, type AuthioBffMemoryStore, type AuthioBffMemoryStoreOptions, type AuthioBffOptions, type AuthioBffRemoteApiOptions, type AuthioBffRemoteApiTokenMode, type AuthioBffSessionInfo, type AuthioBffSessionStore, type AuthioBffSessionValidation, type AuthioBffStoredSession, type AuthioBffUserTokenOptions, authioBff, createAuthioBffMemoryStore, isLocalUrl };
363
+ export { type AuthioBff, type AuthioBffAccessTokenClaims, type AuthioBffApiOptions, type AuthioBffClaim, AuthioBffClaimTypes, type AuthioBffCookieSameSite, AuthioBffDefaults, type AuthioBffError, type AuthioBffMemoryStore, type AuthioBffMemoryStoreOptions, type AuthioBffOptions, type AuthioBffRemoteApiOptions, type AuthioBffRemoteApiTokenMode, type AuthioBffSessionInfo, type AuthioBffSessionStore, type AuthioBffSessionValidation, type AuthioBffStoredSession, type AuthioBffUserTokenOptions, authioBff, createAuthioBffMemoryStore, isLocalUrl };
package/dist/index.d.ts CHANGED
@@ -93,6 +93,15 @@ declare const AuthioBffDefaults: {
93
93
  * refresh token (its access token when there is none) every `sessionValidationInterval`.
94
94
  */
95
95
  type AuthioBffSessionValidation = "none" | "introspection";
96
+ /**
97
+ * Where the BFF reads the claims only the access token carries (the `*_resource_access` roles and permissions,
98
+ * `org_id`, `org_alias`, `account_id`): `token`, from the access token once its signature verifies; or
99
+ * `introspection`, from Authio's introspection of the access token, asked as the BFF's own client. Mirror the client's
100
+ * access token format: `introspection` for `Lightweight`, whose token leaves those claims to introspection. Under
101
+ * `token`, an opaque (`Reference`) access token is introspected too, and contributes nothing when it is not active to
102
+ * the BFF's client.
103
+ */
104
+ type AuthioBffAccessTokenClaims = "token" | "introspection";
96
105
  /**
97
106
  * The `SameSite` mode of the session cookie.
98
107
  */
@@ -128,6 +137,17 @@ interface AuthioBffOptions extends Partial<AuthioClientOptions> {
128
137
  scopes?: readonly string[];
129
138
  /** Adds the `roles` scope, so the session carries the `*_resource_access` claims flattened on `/bff/user`. */
130
139
  requestRoles?: boolean;
140
+ /**
141
+ * Where the session's roles, permissions and organization come from, read at sign-in and again on every refresh:
142
+ * `token` (default), the access token once its signature verifies; `introspection`, Authio's introspection of the
143
+ * access token, asked as the BFF's own confidential client. Use `introspection` when the client's access token
144
+ * format is `Lightweight`: then a failed or inactive introspection fails the sign-in, and on a refresh an inactive
145
+ * answer ends the session while any other failure keeps the session's previous claims until the next refresh and
146
+ * goes to `onError`. Under `token`, an opaque (`Reference`) access token is introspected too: an inactive answer
147
+ * (a token requested for another resource server's introspection client) leaves its claims out and keeps the session
148
+ * on a refresh, while a failed introspection fails the sign-in and, on a refresh, keeps the previous claims.
149
+ */
150
+ accessTokenClaims?: AuthioBffAccessTokenClaims;
131
151
  /** Calls Authio's UserInfo endpoint after sign-in and adds its claims to the session. Defaults to `false`. */
132
152
  getClaimsFromUserInfoEndpoint?: boolean;
133
153
  /** The `organization` sent on every sign-in unless the login request names one. */
@@ -234,7 +254,10 @@ interface AuthioBffSessionInfo {
234
254
  readonly subject: string | undefined;
235
255
  /** The Authio session (`sid`). */
236
256
  readonly sessionId: string | undefined;
237
- /** The session's claims: the ID token's, the access token's `*_resource_access` and organization, UserInfo's. */
257
+ /**
258
+ * The session's claims: the ID token's, the access token's `*_resource_access` and organization (read from the
259
+ * token or its introspection, as `accessTokenClaims` says), UserInfo's.
260
+ */
238
261
  readonly claims: Readonly<Record<string, unknown>>;
239
262
  /** When the BFF session expires, in milliseconds since the epoch. */
240
263
  readonly expiresAt: number;
@@ -337,4 +360,4 @@ interface AuthioBffError {
337
360
  readonly error_description?: string;
338
361
  }
339
362
 
340
- export { type AuthioBff, type AuthioBffApiOptions, type AuthioBffClaim, AuthioBffClaimTypes, type AuthioBffCookieSameSite, AuthioBffDefaults, type AuthioBffError, type AuthioBffMemoryStore, type AuthioBffMemoryStoreOptions, type AuthioBffOptions, type AuthioBffRemoteApiOptions, type AuthioBffRemoteApiTokenMode, type AuthioBffSessionInfo, type AuthioBffSessionStore, type AuthioBffSessionValidation, type AuthioBffStoredSession, type AuthioBffUserTokenOptions, authioBff, createAuthioBffMemoryStore, isLocalUrl };
363
+ export { type AuthioBff, type AuthioBffAccessTokenClaims, type AuthioBffApiOptions, type AuthioBffClaim, AuthioBffClaimTypes, type AuthioBffCookieSameSite, AuthioBffDefaults, type AuthioBffError, type AuthioBffMemoryStore, type AuthioBffMemoryStoreOptions, type AuthioBffOptions, type AuthioBffRemoteApiOptions, type AuthioBffRemoteApiTokenMode, type AuthioBffSessionInfo, type AuthioBffSessionStore, type AuthioBffSessionValidation, type AuthioBffStoredSession, type AuthioBffUserTokenOptions, authioBff, createAuthioBffMemoryStore, isLocalUrl };
package/dist/index.js CHANGED
@@ -184,6 +184,7 @@ function resolveBffSettings(options) {
184
184
  cookieSecrets,
185
185
  endpointPrefix: `/${(options.endpointPrefix ?? AuthioBffDefaults.EndpointPrefix).replace(/^\/+|\/+$/g, "")}`,
186
186
  scopes: distinct([...options.scopes ?? AuthioBffDefaults.Scopes, ...options.requestRoles ? ["roles"] : []]),
187
+ accessTokenClaims: options.accessTokenClaims ?? "token",
187
188
  getClaimsFromUserInfoEndpoint: options.getClaimsFromUserInfoEndpoint ?? false,
188
189
  organization: nonEmpty(options.organization),
189
190
  resource: nonEmpty(options.resource),
@@ -221,6 +222,12 @@ function resolveBffSettings(options) {
221
222
  else if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(settings.cookieName)) problems.push("cookieName holds characters a cookie name cannot.");
222
223
  if (settings.cookieSameSite !== "lax" && settings.cookieSameSite !== "strict") problems.push("cookieSameSite must be lax or strict.");
223
224
  if (!isPositive(settings.sessionLifetimeMs)) problems.push("sessionLifetime must be a positive number of seconds.");
225
+ if (settings.accessTokenClaims !== "token" && settings.accessTokenClaims !== "introspection")
226
+ problems.push("accessTokenClaims must be token or introspection.");
227
+ else if (settings.accessTokenClaims === "introspection" && !options.client && isPublicClient(options))
228
+ problems.push(
229
+ "accessTokenClaims introspection needs a confidential client: Authio answers introspection only to a client that authenticates, so set clientSecret or clientPrivateKey."
230
+ );
224
231
  if (settings.sessionValidation !== "none" && settings.sessionValidation !== "introspection")
225
232
  problems.push("sessionValidation must be none or introspection.");
226
233
  else if (settings.sessionValidation === "introspection" && !isPositive(settings.sessionValidationIntervalMs))
@@ -247,6 +254,10 @@ function isLocalUrl(url) {
247
254
  if (typeof url !== "string" || !url || /[\u0000-\u001f\u007f-\u009f]/.test(url)) return false;
248
255
  return url[0] === "/" && (url.length === 1 || url[1] !== "/" && url[1] !== "\\");
249
256
  }
257
+ function isPublicClient(options) {
258
+ if (options.tokenEndpointAuthMethod !== void 0) return options.tokenEndpointAuthMethod === "none";
259
+ return !options.clientSecret && options.clientPrivateKey === void 0 && !options.clientPrivateKeyPath;
260
+ }
250
261
  function distinct(scopes) {
251
262
  return Object.freeze([...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))]);
252
263
  }
@@ -564,7 +575,6 @@ function authioBff(options) {
564
575
  }
565
576
  };
566
577
  const readAccessToken = async (accessToken) => {
567
- if (!accessToken) return void 0;
568
578
  try {
569
579
  return await client.readAccessTokenClaims(accessToken);
570
580
  } catch (error) {
@@ -572,6 +582,25 @@ function authioBff(options) {
572
582
  return void 0;
573
583
  }
574
584
  };
585
+ const introspectAccessToken = async (accessToken) => {
586
+ const { active, ...claims } = await client.introspect(accessToken, { tokenTypeHint: AuthioTokenTypes.AccessToken });
587
+ if (active !== true) throw new InactiveAccessTokenError();
588
+ return claims;
589
+ };
590
+ const introspectOpaqueAccessToken = async (accessToken) => {
591
+ try {
592
+ return await introspectAccessToken(accessToken);
593
+ } catch (error) {
594
+ if (error instanceof InactiveAccessTokenError) return void 0;
595
+ throw error;
596
+ }
597
+ };
598
+ const accessTokenClaimsOf = async (accessToken) => {
599
+ if (!accessToken) return void 0;
600
+ if (settings.accessTokenClaims === "introspection") return introspectAccessToken(accessToken);
601
+ if (isReferenceToken(accessToken)) return introspectOpaqueAccessToken(accessToken);
602
+ return readAccessToken(accessToken);
603
+ };
575
604
  const refusesIdToken = async (idToken, accessToken, subject) => {
576
605
  try {
577
606
  const claims = await client.validateIdToken(idToken, { accessToken });
@@ -636,6 +665,15 @@ function authioBff(options) {
636
665
  return loading;
637
666
  };
638
667
  const isFresh = (data, skewMs) => Boolean(data.accessToken) && (data.accessTokenExpiresAt === void 0 || clock.now() < data.accessTokenExpiresAt - skewMs);
668
+ const refreshedAccessTokenClaims = async (accessToken) => {
669
+ try {
670
+ return await accessTokenClaimsOf(accessToken);
671
+ } catch (error) {
672
+ if (error instanceof InactiveAccessTokenError) return "inactive";
673
+ report(error);
674
+ return void 0;
675
+ }
676
+ };
639
677
  const refresh = async (request, response, session) => {
640
678
  const data = { ...session.data, claims: { ...session.data.claims } };
641
679
  const unexpired = isFresh(data, 0) ? data.accessToken : void 0;
@@ -652,11 +690,12 @@ function authioBff(options) {
652
690
  data.tokenType = answer.token_type ?? data.tokenType;
653
691
  if (answer.refresh_token) data.refreshToken = answer.refresh_token;
654
692
  if (answer.id_token) data.idToken = answer.id_token;
655
- replaceAccessTokenClaims(data.claims, await readAccessToken(answer.access_token));
656
- if (!await store.get(session.key)) {
693
+ const accessClaims = await refreshedAccessTokenClaims(answer.access_token);
694
+ if (accessClaims === "inactive" || !await store.get(session.key)) {
657
695
  await endSessionByKey(request, response, session.key);
658
696
  return void 0;
659
697
  }
698
+ replaceAccessTokenClaims(data.claims, accessClaims);
660
699
  expose(request, await write(session.key, data, session.expiresAt));
661
700
  return answer.access_token;
662
701
  } catch (error) {
@@ -777,7 +816,7 @@ function authioBff(options) {
777
816
  try {
778
817
  const { tokens, idToken } = await client.handleAuthorizationCallback(query, pending.transaction);
779
818
  const now = clock.now();
780
- const accessClaims = await readAccessToken(tokens.access_token);
819
+ const accessClaims = await accessTokenClaimsOf(tokens.access_token);
781
820
  const claims = toSessionClaims(idToken, textOf(accessClaims?.sub));
782
821
  replaceAccessTokenClaims(claims, accessClaims);
783
822
  if (settings.getClaimsFromUserInfoEndpoint) mergeUserInfo(claims, await client.userInfo(tokens.access_token));
@@ -974,6 +1013,14 @@ function lifetimeOf(expiresIn) {
974
1013
  function textOf(value) {
975
1014
  return typeof value === "string" && value ? value : void 0;
976
1015
  }
1016
+ var InactiveAccessTokenError = class extends AuthioError {
1017
+ constructor() {
1018
+ super("Authio reports the session's access token as not active, so its claims cannot be read.");
1019
+ }
1020
+ };
1021
+ function isReferenceToken(token) {
1022
+ return !token.includes(".");
1023
+ }
977
1024
  function fixedTimeEquals(left, right) {
978
1025
  const a = Buffer.from(left, "utf8");
979
1026
  const b = Buffer.from(right, "utf8");