@hyper.authio/server 0.8.0 → 0.8.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 CHANGED
@@ -120,11 +120,25 @@ 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
+ `resource_metadata` (RFC 9728 §5.1) is added to every `401` when the verifier publishes the protected resource
138
+ metadata (`resourceMetadata`): the verifier's own refusals carry it, and `new AuthioForbiddenError(refusal, {
139
+ resourceMetadata: verifier.resourceMetadataUrl })` (or `withResourceMetadata`) adds it to a step-up; the guards of
140
+ `@hyper.authio/express` do it for you. The table is `conformance/challenges.json`, which every Authio library runs.
141
+
128
142
  Rules: `requireScope`, `requireRole`, `requirePermission`, `requireGroup`, `requireClaim`, `requireOrganization`,
129
143
  `requireAuthenticationLevel`, `requireAuthenticationMethod`, `requireRecentAuthentication`, `requireCallerKind`,
130
144
  `requireClient`, combined with `allOf` and `anyOf`. `authorize` checks every rule and joins their messages.
@@ -132,8 +146,9 @@ Rules: `requireScope`, `requireRole`, `requirePermission`, `requireGroup`, `requ
132
146
  **Step-up (RFC 9470)**: `requireAuthenticationLevel(2, { maxAge: 300 })`, `requireAuthenticationMethod("otp")` and
133
147
  `requireRecentAuthentication(300)` refuse with `insufficient_user_authentication`, `acrValues` (the level asked for;
134
148
  `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`.
149
+ `Bearer error="insufficient_user_authentication", error_description="…", acr_values="2", max_age=300`, answered with
150
+ `401` (RFC 9470 §3; `403` before 0.8.1). A client answers it with `createStepUpAuthorizationUrl` from
151
+ `@hyper.authio/client`.
137
152
 
138
153
  ## Webhooks
139
154
 
package/dist/index.cjs CHANGED
@@ -471,16 +471,30 @@ var AuthioBearerError = class _AuthioBearerError extends client.AuthioError {
471
471
  return parameters.length === 0 ? "Bearer" : `Bearer ${parameters.join(", ")}`;
472
472
  }
473
473
  };
474
- var AuthioForbiddenError = class extends client.AuthioError {
475
- status = 403;
474
+ var AuthioForbiddenError = class _AuthioForbiddenError extends client.AuthioError {
475
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
476
+ status;
476
477
  failure;
477
- constructor(failure) {
478
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
479
+ resourceMetadata;
480
+ constructor(failure, options = {}) {
478
481
  super(failure.message);
479
482
  this.failure = failure;
483
+ this.status = failure.error === "insufficient_user_authentication" ? 401 : 403;
484
+ this.resourceMetadata = options.resourceMetadata;
480
485
  }
481
- /** The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. */
486
+ /** The same refusal, pointing at the API's protected resource metadata. */
487
+ withResourceMetadata(resourceMetadata) {
488
+ if (!resourceMetadata || resourceMetadata === this.resourceMetadata) return this;
489
+ return new _AuthioForbiddenError(this.failure, { resourceMetadata });
490
+ }
491
+ /**
492
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
493
+ * carries `resource_metadata` when `resourceMetadata` is set.
494
+ */
482
495
  get wwwAuthenticate() {
483
- return toForbiddenChallenge(this.failure);
496
+ const challenge = toForbiddenChallenge(this.failure);
497
+ return challenge && this.status === 401 && this.resourceMetadata ? `${challenge}, resource_metadata="${escapeQuoted(this.resourceMetadata)}"` : challenge;
484
498
  }
485
499
  };
486
500
  function toForbiddenChallenge(failure) {
@@ -494,15 +508,15 @@ function toForbiddenChallenge(failure) {
494
508
  function toProblemDetails(error) {
495
509
  if (error instanceof AuthioBearerError)
496
510
  return {
497
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.2",
511
+ type: unauthorizedType,
498
512
  title: "Unauthorized",
499
513
  status: 401,
500
514
  detail: "A valid Authio access token is required."
501
515
  };
502
516
  return {
503
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.4",
504
- title: "Forbidden",
505
- status: 403,
517
+ type: error.status === 401 ? unauthorizedType : "https://tools.ietf.org/html/rfc9110#section-15.5.4",
518
+ title: error.status === 401 ? "Unauthorized" : "Forbidden",
519
+ status: error.status,
506
520
  detail: error.failure.message || "The caller is not allowed to access this resource.",
507
521
  ...error.failure.error ? { error: error.failure.error } : {},
508
522
  ...error.failure.scope ? { scope: error.failure.scope } : {},
@@ -510,6 +524,7 @@ function toProblemDetails(error) {
510
524
  ...error.failure.maxAge !== void 0 ? { max_age: error.failure.maxAge } : {}
511
525
  };
512
526
  }
527
+ var unauthorizedType = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
513
528
  function toHeaderText(text) {
514
529
  return [...text].map((character) => character >= " " && character <= "~" && character !== '"' && character !== "\\" ? character : "?").join("");
515
530
  }
@@ -905,13 +920,16 @@ function createOnBehalfOf(options) {
905
920
  };
906
921
  const token = await getToken(subject, request.signal);
907
922
  const response = await attempt(token);
908
- if (response.status !== 401) return response;
923
+ if (response.status !== 401 || isStepUpChallenge(response)) return response;
909
924
  await invalidate(subject, token.accessToken);
910
925
  return attempt(await getToken(subject, request.signal));
911
926
  };
912
927
  }
913
928
  };
914
929
  }
930
+ function isStepUpChallenge(response) {
931
+ return client.parseAuthioBearerChallenge(response.headers.get("www-authenticate"))?.error === client.AuthioOAuthErrors.InsufficientUserAuthentication;
932
+ }
915
933
  function tokenOf(subject) {
916
934
  if (!subject) return void 0;
917
935
  return typeof subject === "string" ? subject : subject.token;