@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/README.md +18 -2
- package/dist/index.cjs +51 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +51 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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`)
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
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");
|