@hyper.authio/server 0.8.0 → 0.9.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
@@ -41,7 +41,7 @@ try {
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
 
@@ -120,11 +120,28 @@ const refusal = authorize(user, requirePermission("orders", "write"), anyOf(requ
120
120
 
121
121
  if (refusal) {
122
122
  const error = new AuthioForbiddenError(refusal);
123
- // 403, with error.wwwAuthenticate when the refusal is about scopes (insufficient_scope) or the sign-in
124
- // (insufficient_user_authentication); toProblemDetails(error) for an RFC 9457 body.
123
+ // error.status: 401 when the refusal asks for a stronger or more recent sign-in (insufficient_user_authentication,
124
+ // RFC 9470 §3), 403 otherwise; error.wwwAuthenticate for scope (insufficient_scope) and sign-in refusals;
125
+ // toProblemDetails(error) for an RFC 9457 body with the same status.
125
126
  }
126
127
  ```
127
128
 
129
+ | Refusal | Status | `WWW-Authenticate` |
130
+ | --- | --- | --- |
131
+ | No token | `401` | `Bearer` (no `error`, RFC 6750 §3.1) |
132
+ | Token not valid | `401` | `Bearer error="invalid_token", error_description="…"` |
133
+ | Missing scope | `403` | `Bearer error="insufficient_scope", error_description="…", scope="…"` |
134
+ | Sign-in too weak or too old (step-up) | `401` | `Bearer error="insufficient_user_authentication", error_description="…", acr_values="…", max_age=…` |
135
+ | Missing role, permission, group, claim, organization, client or caller kind | `403` | none |
136
+
137
+ 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
138
+ or permission, which no stronger sign-in fixes, answers `403` (with `insufficient_scope` for a scope) and no step-up.
139
+
140
+ `resource_metadata` (RFC 9728 §5.1) is added to every `401` when the verifier publishes the protected resource
141
+ metadata (`resourceMetadata`): the verifier's own refusals carry it, and `new AuthioForbiddenError(refusal, {
142
+ resourceMetadata: verifier.resourceMetadataUrl })` (or `withResourceMetadata`) adds it to a step-up; the guards of
143
+ `@hyper.authio/express` do it for you. The table is `conformance/challenges.json`, which every Authio library runs.
144
+
128
145
  Rules: `requireScope`, `requireRole`, `requirePermission`, `requireGroup`, `requireClaim`, `requireOrganization`,
129
146
  `requireAuthenticationLevel`, `requireAuthenticationMethod`, `requireRecentAuthentication`, `requireCallerKind`,
130
147
  `requireClient`, combined with `allOf` and `anyOf`. `authorize` checks every rule and joins their messages.
@@ -132,8 +149,9 @@ Rules: `requireScope`, `requireRole`, `requirePermission`, `requireGroup`, `requ
132
149
  **Step-up (RFC 9470)**: `requireAuthenticationLevel(2, { maxAge: 300 })`, `requireAuthenticationMethod("otp")` and
133
150
  `requireRecentAuthentication(300)` refuse with `insufficient_user_authentication`, `acrValues` (the level asked for;
134
151
  `2` for `mfa`/`otp`/`mca`, `3` for `swk`/`hwk`) and `maxAge`, so the challenge reads
135
- `Bearer error="insufficient_user_authentication", error_description="…", acr_values="2", max_age=300`. A client
136
- answers it with `createStepUpAuthorizationUrl` from `@hyper.authio/client`.
152
+ `Bearer error="insufficient_user_authentication", error_description="…", acr_values="2", max_age=300`, answered with
153
+ `401` (RFC 9470 §3; `403` before 0.8.1). A client answers it with `createStepUpAuthorizationUrl` from
154
+ `@hyper.authio/client`.
137
155
 
138
156
  ## Webhooks
139
157
 
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;
@@ -104,7 +104,7 @@ var AuthioUser = class _AuthioUser {
104
104
  const text = (name) => typeof claims[name] === "string" && claims[name] ? claims[name] : void 0;
105
105
  this.claims = Object.freeze({ ...claims });
106
106
  this.subject = text(client.AuthioClaimTypes.Subject) ?? "";
107
- this.clientId = text(client.AuthioClaimTypes.AuthorizedParty) ?? text(client.AuthioClaimTypes.ClientId);
107
+ this.clientId = text(client.AuthioClaimTypes.ClientId) ?? text(client.AuthioClaimTypes.AuthorizedParty);
108
108
  this.sessionId = text(client.AuthioClaimTypes.SessionId);
109
109
  this.tokenId = text(client.AuthioClaimTypes.JwtId);
110
110
  this.audiences = Object.freeze(distinct(valuesOf(claims[client.AuthioClaimTypes.Audience])));
@@ -277,8 +277,9 @@ function distinctIgnoringCase(values) {
277
277
  function authorize(user, ...rules) {
278
278
  const failures = rules.map((rule) => rule.check(user)).filter((failure) => failure !== void 0);
279
279
  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);
280
+ const others = failures.filter((failure) => failure.error !== "insufficient_user_authentication");
281
+ const stepUps = others.length === 0 ? failures : [];
282
+ const bearer = stepUps[0] ?? others.find((failure) => failure.error);
282
283
  const acrValues = strongestAcr(stepUps.map((failure) => failure.acrValues));
283
284
  const maxAges = stepUps.map((failure) => failure.maxAge).filter((value) => value !== void 0);
284
285
  return {
@@ -471,16 +472,30 @@ var AuthioBearerError = class _AuthioBearerError extends client.AuthioError {
471
472
  return parameters.length === 0 ? "Bearer" : `Bearer ${parameters.join(", ")}`;
472
473
  }
473
474
  };
474
- var AuthioForbiddenError = class extends client.AuthioError {
475
- status = 403;
475
+ var AuthioForbiddenError = class _AuthioForbiddenError extends client.AuthioError {
476
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
477
+ status;
476
478
  failure;
477
- constructor(failure) {
479
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
480
+ resourceMetadata;
481
+ constructor(failure, options = {}) {
478
482
  super(failure.message);
479
483
  this.failure = failure;
484
+ this.status = failure.error === "insufficient_user_authentication" ? 401 : 403;
485
+ this.resourceMetadata = options.resourceMetadata;
480
486
  }
481
- /** The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. */
487
+ /** The same refusal, pointing at the API's protected resource metadata. */
488
+ withResourceMetadata(resourceMetadata) {
489
+ if (!resourceMetadata || resourceMetadata === this.resourceMetadata) return this;
490
+ return new _AuthioForbiddenError(this.failure, { resourceMetadata });
491
+ }
492
+ /**
493
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
494
+ * carries `resource_metadata` when `resourceMetadata` is set.
495
+ */
482
496
  get wwwAuthenticate() {
483
- return toForbiddenChallenge(this.failure);
497
+ const challenge = toForbiddenChallenge(this.failure);
498
+ return challenge && this.status === 401 && this.resourceMetadata ? `${challenge}, resource_metadata="${escapeQuoted(this.resourceMetadata)}"` : challenge;
484
499
  }
485
500
  };
486
501
  function toForbiddenChallenge(failure) {
@@ -494,15 +509,15 @@ function toForbiddenChallenge(failure) {
494
509
  function toProblemDetails(error) {
495
510
  if (error instanceof AuthioBearerError)
496
511
  return {
497
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.2",
512
+ type: unauthorizedType,
498
513
  title: "Unauthorized",
499
514
  status: 401,
500
515
  detail: "A valid Authio access token is required."
501
516
  };
502
517
  return {
503
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.4",
504
- title: "Forbidden",
505
- status: 403,
518
+ type: error.status === 401 ? unauthorizedType : "https://tools.ietf.org/html/rfc9110#section-15.5.4",
519
+ title: error.status === 401 ? "Unauthorized" : "Forbidden",
520
+ status: error.status,
506
521
  detail: error.failure.message || "The caller is not allowed to access this resource.",
507
522
  ...error.failure.error ? { error: error.failure.error } : {},
508
523
  ...error.failure.scope ? { scope: error.failure.scope } : {},
@@ -510,6 +525,7 @@ function toProblemDetails(error) {
510
525
  ...error.failure.maxAge !== void 0 ? { max_age: error.failure.maxAge } : {}
511
526
  };
512
527
  }
528
+ var unauthorizedType = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
513
529
  function toHeaderText(text) {
514
530
  return [...text].map((character) => character >= " " && character <= "~" && character !== '"' && character !== "\\" ? character : "?").join("");
515
531
  }
@@ -623,13 +639,15 @@ function createAuthioVerifier(options) {
623
639
  }
624
640
  let payload;
625
641
  try {
626
- ({ payload } = await client.verifyAuthioJwt(signed, keySet, {
642
+ let header;
643
+ ({ payload, header } = await client.verifyAuthioJwt(signed, keySet, {
627
644
  issuer: connection.issuer,
628
645
  audiences,
629
646
  algorithms,
630
647
  clockSkew,
631
648
  now: connection.clock.now()
632
649
  }));
650
+ client.requireAccessTokenType(header);
633
651
  } catch (error) {
634
652
  if (error instanceof client.AuthioTokenValidationError && error.reason === "invalid_algorithm" && symmetric.length === 0 && isSymmetricToken(signed))
635
653
  throw new AuthioBearerError(AuthioBearerErrorDescriptions.Symmetric, { cause: error, reason: error.reason });
@@ -905,13 +923,16 @@ function createOnBehalfOf(options) {
905
923
  };
906
924
  const token = await getToken(subject, request.signal);
907
925
  const response = await attempt(token);
908
- if (response.status !== 401) return response;
926
+ if (response.status !== 401 || isStepUpChallenge(response)) return response;
909
927
  await invalidate(subject, token.accessToken);
910
928
  return attempt(await getToken(subject, request.signal));
911
929
  };
912
930
  }
913
931
  };
914
932
  }
933
+ function isStepUpChallenge(response) {
934
+ return client.parseAuthioBearerChallenge(response.headers.get("www-authenticate"))?.error === client.AuthioOAuthErrors.InsufficientUserAuthentication;
935
+ }
915
936
  function tokenOf(subject) {
916
937
  if (!subject) return void 0;
917
938
  return typeof subject === "string" ? subject : subject.token;