@hyper.authio/server 0.7.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
@@ -34,12 +34,13 @@ try {
34
34
  | `resource` | | The API's own resource identifier (RFC 8707); added to `audiences` and used for RFC 9728. |
35
35
  | `resourceMetadata` | off | `true` (or the URL) adds `resource_metadata="{origin}/.well-known/oauth-protected-resource{path}"` to `401` challenges (RFC 9728). |
36
36
  | `requiredScopes` | | Scopes every request must carry (all); `authioBearer` answers `403 insufficient_scope`. |
37
- | `clientEncryptionKey`, `clientEncryptionKeyPath` | | Decrypts access tokens Authio encrypts to this key (RSA-OAEP, RSA-OAEP-256) and validates them locally. RSA1_5 is refused (`legacy_algorithm`), even with `allowLegacyEncryptionAlgorithms`. |
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
+ | `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). |
38
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
- | `client` | | The API's confidential client, needed by introspection: an `AuthioClient` or `{ clientId, clientSecret }` (or a private key). It must be the resource server's introspection client. |
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. |
40
41
  | `introspectionCacheDuration` | 30 s | Never past the token's expiry; only active answers are kept. |
41
42
  | `clockSkew` | 30 s | |
42
- | `validAlgorithms` | RS*, PS*, ES* | |
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). |
43
44
  | `rejectNonAccessTokens` | `true` | Refuses a token whose `typ` is not `Bearer` (an ID token, for one). |
44
45
  | `roleClaims` | `["role"]` | Claims holding flat role names (a Role claim mapper). |
45
46
  | `groupClaims` | `["groups"]` | Claims holding group names (a Group claim mapper). |
@@ -60,6 +61,26 @@ const path = protectedResourceMetadataPath(verifier.resource!); // /.well-known/
60
61
  const document = createProtectedResourceMetadata({ issuer, resource: verifier.resource!, scopesSupported: ["orders:read"] });
61
62
  ```
62
63
 
64
+ ### Access tokens signed with the client secret (HS*)
65
+
66
+ Authio signs a client's access tokens with the algorithm of its access token setting. When that is HS256, HS384 or
67
+ HS512, the signature is made with the client secret: only who holds the secret validates the token locally, so that is
68
+ the client's own API.
69
+
70
+ ```ts
71
+ const verifier = createAuthioVerifier({
72
+ issuer,
73
+ audiences: "storefront",
74
+ accessTokenSignedResponseAlg: "HS256",
75
+ client: { clientId: "storefront", clientSecret: process.env.AUTHIO_CLIENT_SECRET! }, // 32+ bytes for HS256
76
+ });
77
+ ```
78
+
79
+ Third-party APIs never get the secret: they validate such tokens with introspection (`introspection:
80
+ "introspectionOnly"` and their own introspection client). Without `accessTokenSignedResponseAlg`, an HS* token is
81
+ refused (`invalid_algorithm`) with a description naming both ways. `authioBearer` and the MCP packages take the same
82
+ option.
83
+
63
84
  ## The caller
64
85
 
65
86
  `AuthioUser` reads every form of permission, the same way from a JWT or an introspection answer:
@@ -99,11 +120,25 @@ const refusal = authorize(user, requirePermission("orders", "write"), anyOf(requ
99
120
 
100
121
  if (refusal) {
101
122
  const error = new AuthioForbiddenError(refusal);
102
- // 403, with error.wwwAuthenticate when the refusal is about scopes (insufficient_scope) or the sign-in
103
- // (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.
104
126
  }
105
127
  ```
106
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
+
107
142
  Rules: `requireScope`, `requireRole`, `requirePermission`, `requireGroup`, `requireClaim`, `requireOrganization`,
108
143
  `requireAuthenticationLevel`, `requireAuthenticationMethod`, `requireRecentAuthentication`, `requireCallerKind`,
109
144
  `requireClient`, combined with `allOf` and `anyOf`. `authorize` checks every rule and joins their messages.
@@ -111,8 +146,9 @@ Rules: `requireScope`, `requireRole`, `requirePermission`, `requireGroup`, `requ
111
146
  **Step-up (RFC 9470)**: `requireAuthenticationLevel(2, { maxAge: 300 })`, `requireAuthenticationMethod("otp")` and
112
147
  `requireRecentAuthentication(300)` refuse with `insufficient_user_authentication`, `acrValues` (the level asked for;
113
148
  `2` for `mfa`/`otp`/`mca`, `3` for `swk`/`hwk`) and `maxAge`, so the challenge reads
114
- `Bearer error="insufficient_user_authentication", error_description="…", acr_values="2", max_age=300`. A client
115
- 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`.
116
152
 
117
153
  ## Webhooks
118
154
 
package/dist/index.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var client = require('@hyper.authio/client');
4
+ var jose = require('jose');
4
5
 
5
6
  // src/user.ts
6
7
  var accessTokenTypes = /* @__PURE__ */ new Set(["bearer", "api_key"]);
@@ -470,16 +471,30 @@ var AuthioBearerError = class _AuthioBearerError extends client.AuthioError {
470
471
  return parameters.length === 0 ? "Bearer" : `Bearer ${parameters.join(", ")}`;
471
472
  }
472
473
  };
473
- var AuthioForbiddenError = class extends client.AuthioError {
474
- status = 403;
474
+ var AuthioForbiddenError = class _AuthioForbiddenError extends client.AuthioError {
475
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
476
+ status;
475
477
  failure;
476
- 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 = {}) {
477
481
  super(failure.message);
478
482
  this.failure = failure;
483
+ this.status = failure.error === "insufficient_user_authentication" ? 401 : 403;
484
+ this.resourceMetadata = options.resourceMetadata;
479
485
  }
480
- /** 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
+ */
481
495
  get wwwAuthenticate() {
482
- 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;
483
498
  }
484
499
  };
485
500
  function toForbiddenChallenge(failure) {
@@ -493,15 +508,15 @@ function toForbiddenChallenge(failure) {
493
508
  function toProblemDetails(error) {
494
509
  if (error instanceof AuthioBearerError)
495
510
  return {
496
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.2",
511
+ type: unauthorizedType,
497
512
  title: "Unauthorized",
498
513
  status: 401,
499
514
  detail: "A valid Authio access token is required."
500
515
  };
501
516
  return {
502
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.4",
503
- title: "Forbidden",
504
- 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,
505
520
  detail: error.failure.message || "The caller is not allowed to access this resource.",
506
521
  ...error.failure.error ? { error: error.failure.error } : {},
507
522
  ...error.failure.scope ? { scope: error.failure.scope } : {},
@@ -509,6 +524,7 @@ function toProblemDetails(error) {
509
524
  ...error.failure.maxAge !== void 0 ? { max_age: error.failure.maxAge } : {}
510
525
  };
511
526
  }
527
+ var unauthorizedType = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
512
528
  function toHeaderText(text) {
513
529
  return [...text].map((character) => character >= " " && character <= "~" && character !== '"' && character !== "\\" ? character : "?").join("");
514
530
  }
@@ -553,7 +569,8 @@ var AuthioBearerErrorDescriptions = {
553
569
  Unconfirmed: "Authio could not be reached to confirm that the token is active.",
554
570
  Empty: "The token is empty.",
555
571
  Encrypted: "The token is encrypted; set clientEncryptionKey to decrypt it, or validate it with introspection ('introspectionOnly').",
556
- SigningKeysUnavailable: "Authio could not be reached to read its signing keys."
572
+ SigningKeysUnavailable: "Authio could not be reached to read its signing keys.",
573
+ Symmetric: "The token is signed with the client's secret (HS*): set accessTokenSignedResponseAlg and the client's clientSecret to validate it locally, or validate it with introspection ('introspectionOnly')."
557
574
  };
558
575
  var maxCachedAnswers = 1e4;
559
576
  function createAuthioVerifier(options) {
@@ -573,7 +590,9 @@ function createAuthioVerifier(options) {
573
590
  const resourceMetadataUrl = resolveResourceMetadata(options.resourceMetadata, resource);
574
591
  const cacheDurationMs = client.seconds(options.introspectionCacheDuration, 30, "introspectionCacheDuration");
575
592
  const clockSkew = client.seconds(options.clockSkew, 30, "clockSkew") / 1e3;
576
- const algorithms = options.validAlgorithms && options.validAlgorithms.length > 0 ? [...options.validAlgorithms] : void 0;
593
+ const algorithms = resolveAccessTokenAlgorithms(options.accessTokenSignedResponseAlg);
594
+ const symmetric = algorithms?.filter((algorithm) => client.AuthioHmacAlgorithms.includes(algorithm)) ?? [];
595
+ const clientSecret = symmetric.length > 0 ? resolveClientSecret(options, symmetric) : void 0;
577
596
  const rejectNonAccessTokens = options.rejectNonAccessTokens ?? true;
578
597
  const claimSettings = { roleClaims: options.roleClaims, groupClaims: options.groupClaims };
579
598
  const cache = /* @__PURE__ */ new Map();
@@ -581,7 +600,9 @@ function createAuthioVerifier(options) {
581
600
  const encryptionKey = options.clientEncryptionKey ?? (options.clientEncryptionKeyPath ? client.encryptionKeyFromFile(options.clientEncryptionKeyPath) : void 0);
582
601
  const decrypt = encryptionKey === void 0 ? void 0 : client.createAuthioTokenDecrypter(encryptionKey, {
583
602
  setting: options.clientEncryptionKey === void 0 ? "clientEncryptionKeyPath" : "clientEncryptionKey",
584
- allowLegacyEncryptionAlgorithms: options.allowLegacyEncryptionAlgorithms
603
+ allowLegacyEncryptionAlgorithms: options.allowLegacyEncryptionAlgorithms,
604
+ encryptionAlgorithms: options.encryptionAlgorithms,
605
+ encryptionMethods: options.encryptionMethods
585
606
  });
586
607
  const requiredScopes = Object.freeze([...new Set((options.requiredScopes ?? []).filter((scope) => scope?.trim()))]);
587
608
  const introspect = async (token, key, signal) => {
@@ -611,7 +632,7 @@ function createAuthioVerifier(options) {
611
632
  }
612
633
  let keySet;
613
634
  try {
614
- keySet = await signingKeys(signal);
635
+ keySet = client.withClientSecret(await signingKeys(signal), clientSecret);
615
636
  } catch (error) {
616
637
  throw new AuthioBearerError(AuthioBearerErrorDescriptions.SigningKeysUnavailable, { cause: error, reason: "signing_keys_unavailable" });
617
638
  }
@@ -625,6 +646,8 @@ function createAuthioVerifier(options) {
625
646
  now: connection.clock.now()
626
647
  }));
627
648
  } catch (error) {
649
+ if (error instanceof client.AuthioTokenValidationError && error.reason === "invalid_algorithm" && symmetric.length === 0 && isSymmetricToken(signed))
650
+ throw new AuthioBearerError(AuthioBearerErrorDescriptions.Symmetric, { cause: error, reason: error.reason });
628
651
  if (error instanceof client.AuthioTokenValidationError) throw new AuthioBearerError(error.message, { cause: error, reason: error.reason });
629
652
  throw error;
630
653
  }
@@ -706,6 +729,35 @@ function resolveResourceMetadata(setting, resource) {
706
729
  throw new client.AuthioConfigurationError("resourceMetadata: true needs the API's resource identifier: set options.resource, or pass the metadata URL.");
707
730
  return protectedResourceMetadataUrl(resource);
708
731
  }
732
+ function resolveAccessTokenAlgorithms(setting) {
733
+ const algorithms = [...new Set((typeof setting === "string" ? [setting] : [...setting ?? []]).map((algorithm) => algorithm.trim()).filter(Boolean))];
734
+ const known = [...client.AuthioAsymmetricAlgorithms, ...client.AuthioHmacAlgorithms];
735
+ for (const algorithm of algorithms)
736
+ if (!known.includes(algorithm))
737
+ throw new client.AuthioConfigurationError(`The Authio accessTokenSignedResponseAlg '${algorithm}' is not one of ${known.join(", ")}.`);
738
+ return algorithms.length > 0 ? algorithms : void 0;
739
+ }
740
+ function resolveClientSecret(options, symmetric) {
741
+ const given = options.client;
742
+ const source = given && !isClient(given) ? given.clientSecret : void 0;
743
+ if (!source)
744
+ throw new client.AuthioConfigurationError(
745
+ `accessTokenSignedResponseAlg ${symmetric.join(", ")} is verified with the client secret: pass options.client as { clientId, clientSecret } (an AuthioClient does not expose its secret).`
746
+ );
747
+ if (typeof source === "string") for (const algorithm of symmetric) client.requireHmacSecretLength(algorithm, new TextEncoder().encode(source).length);
748
+ return async () => {
749
+ const value = typeof source === "function" ? await source() : source;
750
+ if (typeof value !== "string" || !value) throw new client.AuthioConfigurationError("The Authio clientSecret function returned no secret.");
751
+ return value;
752
+ };
753
+ }
754
+ function isSymmetricToken(token) {
755
+ try {
756
+ return client.AuthioHmacAlgorithms.includes(String(jose.decodeProtectedHeader(token).alg));
757
+ } catch {
758
+ return false;
759
+ }
760
+ }
709
761
  function resolveClient(options) {
710
762
  const given = options.client;
711
763
  if (!given) return void 0;
@@ -868,13 +920,16 @@ function createOnBehalfOf(options) {
868
920
  };
869
921
  const token = await getToken(subject, request.signal);
870
922
  const response = await attempt(token);
871
- if (response.status !== 401) return response;
923
+ if (response.status !== 401 || isStepUpChallenge(response)) return response;
872
924
  await invalidate(subject, token.accessToken);
873
925
  return attempt(await getToken(subject, request.signal));
874
926
  };
875
927
  }
876
928
  };
877
929
  }
930
+ function isStepUpChallenge(response) {
931
+ return client.parseAuthioBearerChallenge(response.headers.get("www-authenticate"))?.error === client.AuthioOAuthErrors.InsufficientUserAuthentication;
932
+ }
878
933
  function tokenOf(subject) {
879
934
  if (!subject) return void 0;
880
935
  return typeof subject === "string" ? subject : subject.token;