@hyper.authio/server 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
@@ -36,12 +36,12 @@ try {
36
36
  | `requiredScopes` | | Scopes every request must carry (all); `authioBearer` answers `403 insufficient_scope`. |
37
37
  | `clientEncryptionKey`, `clientEncryptionKeyPath` | | Decrypts access tokens Authio encrypts to this key and validates them locally. Without it nothing is decrypted. RSA1_5 is refused (`legacy_algorithm`), even with `allowLegacyEncryptionAlgorithms`. |
38
38
  | `encryptionAlgorithms`, `encryptionMethods` | `RSA-OAEP`, `RSA-OAEP-256`; the CBC-HS methods | The JWE `alg` and `enc` accepted, as registered in Authio (AES-GCM may be listed; RSA1_5 cannot: Node cannot decrypt it). |
39
- | `introspection` | `none` | `none`: local JWT validation. `activeCheck`: then confirmed active by introspection (cached per `jti`), so a revoked token, an ended session or a suspension is refused before the token expires. `introspectionOnly`: introspection alone, for opaque tokens (or encrypted ones without the key). |
39
+ | `introspection` | `none` | `none`: local JWT validation; the caller is read from the token alone. `activeCheck`: then confirmed active by introspection (cached per `jti`), so a revoked token, an ended session or a suspension is refused before the token expires; the caller is read from the token, plus the claims of the answer the token does not carry. `introspectionOnly`: introspection alone, for opaque (`Reference`) tokens (or encrypted ones without the key); the caller is read from the answer. A `Lightweight` access token needs `activeCheck` or `introspectionOnly` (see below). |
40
40
  | `client` | | The API's confidential client, needed by introspection: an `AuthioClient` or `{ clientId, clientSecret }` (or a private key), with `tokenEndpointAuthMethod` as the client is registered. It must be the resource server's introspection client. |
41
41
  | `introspectionCacheDuration` | 30 s | Never past the token's expiry; only active answers are kept. |
42
42
  | `clockSkew` | 30 s | |
43
43
  | `accessTokenSignedResponseAlg` | RS*, PS*, ES* (never HS*) | The algorithm (or several) access tokens are signed with, as the client is registered in Authio. HS256/384/512 are verified with the `clientSecret` of `client: { clientId, clientSecret }` (at least 32, 48 or 64 bytes). |
44
- | `rejectNonAccessTokens` | `true` | Refuses a token whose `typ` is not `Bearer` (an ID token, for one). |
44
+ | `rejectNonAccessTokens` | `true` | Refuses a token whose `typ` claim is not `Bearer` (an ID token, for one). The header `typ` must be `at+jwt` (RFC 9068 §4) regardless. |
45
45
  | `roleClaims` | `["role"]` | Claims holding flat role names (a Role claim mapper). |
46
46
  | `groupClaims` | `["groups"]` | Claims holding group names (a Group claim mapper). |
47
47
 
@@ -51,6 +51,31 @@ A refusal is an `AuthioBearerError` whose `wwwAuthenticate` is `Bearer` when the
51
51
  `not_access_token`, `reference_token`, `decryption_failed`, …, and `inactive` / `unconfirmed` for introspection). A
52
52
  token addressed elsewhere is described with the audience the API accepts; introspection failures fail closed.
53
53
 
54
+ ### Lightweight and reference access tokens
55
+
56
+ Authio hands out a client's access tokens in the form its access token format setting names:
57
+
58
+ | Format | The token | Validate it with |
59
+ | --- | --- | --- |
60
+ | `Jwt` | A self-contained JWT with every claim. | Any mode. |
61
+ | `Lightweight` | A JWT with only the identity and session claims (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `typ`, `client_id`, `azp`, `scope`, `sid`, `auth_time`, `acr`, `amr`, `idp`, `act`, `delegation_id`, `session_client`, `org_id`, `org_alias`). The `*_resource_access` roles and permissions, `subscription`, the profile and the email are only in the introspection answer. | `activeCheck` or `introspectionOnly`, which read the caller with the answer's claims, so `roles`, `permissions`, `kind` and `email` are complete. With `none` the caller has only what the token carries: no role or permission, and a consumer reads as a `client`. |
62
+ | `Reference` | An opaque handle, no JWT (refused locally as `reference_token`). | `introspectionOnly`. |
63
+
64
+ ```ts
65
+ const verifier = createAuthioVerifier({
66
+ issuer,
67
+ resource: "https://orders.example.com",
68
+ introspection: "activeCheck", // the client's access token format is Lightweight
69
+ client: { clientId: "orders-api", clientSecret: process.env.AUTHIO_CLIENT_SECRET! },
70
+ });
71
+ ```
72
+
73
+ Nothing tells a lightweight token apart from a full one that carries no role (a person without roles has no
74
+ `*_resource_access` either), so the verifier never guesses: the mode decides. The introspection answer is cached per
75
+ `jti` for `introspectionCacheDuration`, so `activeCheck` asks Authio once per token and cache period. The answer only
76
+ adds claims: one the token carries keeps the token's value, which its signature vouches for (Authio answers a single
77
+ `aud`, and `client_id` for `azp`).
78
+
54
79
  ### Protected resource metadata (RFC 9728)
55
80
 
56
81
  ```ts
@@ -83,7 +108,7 @@ option.
83
108
 
84
109
  ## The caller
85
110
 
86
- `AuthioUser` reads every form of permission, the same way from a JWT or an introspection answer:
111
+ `AuthioUser` reads every form of permission, the same way from a JWT, an introspection answer or both (`activeCheck`):
87
112
 
88
113
  ```ts
89
114
  user.subject; user.clientId; user.scopes;
@@ -134,6 +159,9 @@ if (refusal) {
134
159
  | Sign-in too weak or too old (step-up) | `401` | `Bearer error="insufficient_user_authentication", error_description="…", acr_values="…", max_age=…` |
135
160
  | Missing role, permission, group, claim, organization, client or caller kind | `403` | none |
136
161
 
162
+ When several rules fail at once, a step-up is asked for only if every failing rule is a step-up: a missing scope, role
163
+ or permission, which no stronger sign-in fixes, answers `403` (with `insufficient_scope` for a scope) and no step-up.
164
+
137
165
  `resource_metadata` (RFC 9728 §5.1) is added to every `401` when the verifier publishes the protected resource
138
166
  metadata (`resourceMetadata`): the verifier's own refusals carry it, and `new AuthioForbiddenError(refusal, {
139
167
  resourceMetadata: verifier.resourceMetadataUrl })` (or `withResourceMetadata`) adds it to a step-up; the guards of
package/dist/index.cjs CHANGED
@@ -43,7 +43,7 @@ var AuthioUser = class _AuthioUser {
43
43
  actorChain;
44
44
  /** `auth_time`, in seconds since the epoch. */
45
45
  authTime;
46
- /** The client the token was issued to (`azp`, else `client_id`). */
46
+ /** The client the token was issued to (`client_id`, RFC 9068 §2.2, else `azp`). */
47
47
  clientId;
48
48
  /** `sid`. */
49
49
  sessionId;
@@ -89,7 +89,10 @@ var AuthioUser = class _AuthioUser {
89
89
  credentialType;
90
90
  /** `credential_id`. */
91
91
  credentialId;
92
- /** Every claim, as the token or the introspection answer carried it. */
92
+ /**
93
+ * Every claim, as the token or the introspection answer carried it (with `activeCheck`, the token's, plus the
94
+ * answer's the token does not carry).
95
+ */
93
96
  claims;
94
97
  flatRoles;
95
98
  /**
@@ -104,7 +107,7 @@ var AuthioUser = class _AuthioUser {
104
107
  const text = (name) => typeof claims[name] === "string" && claims[name] ? claims[name] : void 0;
105
108
  this.claims = Object.freeze({ ...claims });
106
109
  this.subject = text(client.AuthioClaimTypes.Subject) ?? "";
107
- this.clientId = text(client.AuthioClaimTypes.AuthorizedParty) ?? text(client.AuthioClaimTypes.ClientId);
110
+ this.clientId = text(client.AuthioClaimTypes.ClientId) ?? text(client.AuthioClaimTypes.AuthorizedParty);
108
111
  this.sessionId = text(client.AuthioClaimTypes.SessionId);
109
112
  this.tokenId = text(client.AuthioClaimTypes.JwtId);
110
113
  this.audiences = Object.freeze(distinct(valuesOf(claims[client.AuthioClaimTypes.Audience])));
@@ -277,8 +280,9 @@ function distinctIgnoringCase(values) {
277
280
  function authorize(user, ...rules) {
278
281
  const failures = rules.map((rule) => rule.check(user)).filter((failure) => failure !== void 0);
279
282
  if (failures.length === 0) return void 0;
280
- const stepUps = failures.filter((failure) => failure.error === "insufficient_user_authentication");
281
- const bearer = stepUps[0] ?? failures.find((failure) => failure.error);
283
+ const others = failures.filter((failure) => failure.error !== "insufficient_user_authentication");
284
+ const stepUps = others.length === 0 ? failures : [];
285
+ const bearer = stepUps[0] ?? others.find((failure) => failure.error);
282
286
  const acrValues = strongestAcr(stepUps.map((failure) => failure.acrValues));
283
287
  const maxAges = stepUps.map((failure) => failure.maxAge).filter((value) => value !== void 0);
284
288
  return {
@@ -638,13 +642,15 @@ function createAuthioVerifier(options) {
638
642
  }
639
643
  let payload;
640
644
  try {
641
- ({ payload } = await client.verifyAuthioJwt(signed, keySet, {
645
+ let header;
646
+ ({ payload, header } = await client.verifyAuthioJwt(signed, keySet, {
642
647
  issuer: connection.issuer,
643
648
  audiences,
644
649
  algorithms,
645
650
  clockSkew,
646
651
  now: connection.clock.now()
647
652
  }));
653
+ client.requireAccessTokenType(header);
648
654
  } catch (error) {
649
655
  if (error instanceof client.AuthioTokenValidationError && error.reason === "invalid_algorithm" && symmetric.length === 0 && isSymmetricToken(signed))
650
656
  throw new AuthioBearerError(AuthioBearerErrorDescriptions.Symmetric, { cause: error, reason: error.reason });
@@ -654,8 +660,9 @@ function createAuthioVerifier(options) {
654
660
  const type = typeof payload.typ === "string" ? payload.typ : void 0;
655
661
  if (rejectNonAccessTokens && type !== void 0 && type.toLowerCase() !== "bearer")
656
662
  throw new AuthioBearerError(`Only access tokens are accepted; this token's typ is '${type}'.`, { reason: "not_access_token" });
657
- if (introspection === "activeCheck") await confirmActive(token, payload, signal);
658
- return new AuthioUser(payload, claimSettings, token);
663
+ if (introspection !== "activeCheck") return new AuthioUser(payload, claimSettings, token);
664
+ const answer = await confirmActive(token, payload, signal);
665
+ return new AuthioUser({ ...claimsOfIntrospection(answer), ...payload }, claimSettings, token);
659
666
  };
660
667
  const confirmActive = async (token, payload, signal) => {
661
668
  const key = payload.jti ? `jti:${payload.jti}` : await keyForToken(token);
@@ -667,6 +674,7 @@ function createAuthioVerifier(options) {
667
674
  throw new AuthioBearerError(AuthioBearerErrorDescriptions.Unconfirmed, { cause: error, reason: "unconfirmed" });
668
675
  }
669
676
  if (!result) throw new AuthioBearerError(AuthioBearerErrorDescriptions.Inactive, { reason: "inactive" });
677
+ return result;
670
678
  };
671
679
  const verifyByIntrospection = async (token, signal) => {
672
680
  let result;