@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/dist/index.d.cts CHANGED
@@ -301,13 +301,26 @@ declare class AuthioBearerError extends AuthioError {
301
301
  get wwwAuthenticate(): string;
302
302
  }
303
303
  /**
304
- * The caller is known but not allowed: answer `403`, with `wwwAuthenticate` when the refusal has a bearer error.
304
+ * A rule refused a caller whose token is valid. A refusal asking for a stronger or more recent sign-in
305
+ * (`insufficient_user_authentication`) answers `401`, as RFC 9470 §3 has it, naming the protected resource metadata
306
+ * (`resource_metadata`, RFC 9728 §5.1) when the API publishes it; every other refusal answers `403`, with
307
+ * `wwwAuthenticate` when it is about scopes (`insufficient_scope`, RFC 6750 §3.1).
305
308
  */
306
309
  declare class AuthioForbiddenError extends AuthioError {
307
- readonly status = 403;
310
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
311
+ readonly status: 401 | 403;
308
312
  readonly failure: AuthioRuleFailure;
309
- constructor(failure: AuthioRuleFailure);
310
- /** The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. */
313
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
314
+ readonly resourceMetadata: string | undefined;
315
+ constructor(failure: AuthioRuleFailure, options?: {
316
+ resourceMetadata?: string;
317
+ });
318
+ /** The same refusal, pointing at the API's protected resource metadata. */
319
+ withResourceMetadata(resourceMetadata: string | undefined): AuthioForbiddenError;
320
+ /**
321
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
322
+ * carries `resource_metadata` when `resourceMetadata` is set.
323
+ */
311
324
  get wwwAuthenticate(): string | undefined;
312
325
  }
313
326
  /**
@@ -316,7 +329,8 @@ declare class AuthioForbiddenError extends AuthioError {
316
329
  */
317
330
  declare function toForbiddenChallenge(failure: AuthioRuleFailure): string | undefined;
318
331
  /**
319
- * Problem details (RFC 9457) for a `401` or `403`.
332
+ * Problem details (RFC 9457) for a `401` or `403`, with the refusal's `status`: a step-up refusal is `401`
333
+ * Unauthorized and carries `error`, `acr_values` and `max_age`.
320
334
  */
321
335
  declare function toProblemDetails(error: AuthioBearerError | AuthioForbiddenError): Record<string, unknown>;
322
336
 
@@ -359,8 +373,14 @@ interface AuthioVerifierOptions extends AuthioConnectionOptions, AuthioUserClaim
359
373
  introspectionCacheDuration?: number;
360
374
  /** Seconds of clock skew tolerated on `exp` and `nbf`. Defaults to 30. */
361
375
  clockSkew?: number;
362
- /** Accepted signing algorithms. Defaults to the asymmetric ones (RS*, PS*, ES*). */
363
- validAlgorithms?: readonly string[];
376
+ /**
377
+ * The algorithm (or algorithms) access tokens are signed with, as the client registered in Authio signs them:
378
+ * RS/PS/ES 256/384/512, or HS256/384/512 verified with the `clientSecret` of `client` (its UTF-8 octets, at least
379
+ * 32, 48 or 64 bytes as RFC 7518 §3.2 asks). Only the client's own API holds that secret, so only it validates an HS*
380
+ * token locally; any other API validates such tokens with introspection. Left out, the asymmetric algorithms of the
381
+ * realm's keys are accepted, and never HS*.
382
+ */
383
+ accessTokenSignedResponseAlg?: string | readonly string[];
364
384
  /** Refuses a token whose `typ` claim is present and not `Bearer` (an ID token, for one). Defaults to `true`. */
365
385
  rejectNonAccessTokens?: boolean;
366
386
  /**
@@ -370,6 +390,13 @@ interface AuthioVerifierOptions extends AuthioConnectionOptions, AuthioUserClaim
370
390
  clientEncryptionKey?: AuthioEncryptionKeySource;
371
391
  /** A PEM or JWK file holding `clientEncryptionKey` (Node only). */
372
392
  clientEncryptionKeyPath?: string;
393
+ /**
394
+ * The key management algorithms (`alg`) decrypted. Defaults to RSA-OAEP and RSA-OAEP-256; RSA1_5 cannot be listed,
395
+ * because Node cannot decrypt it.
396
+ */
397
+ encryptionAlgorithms?: readonly string[];
398
+ /** The content encryption algorithms (`enc`) decrypted. Defaults to A128CBC-HS256, A192CBC-HS384, A256CBC-HS512. */
399
+ encryptionMethods?: readonly string[];
373
400
  /** Accepted for parity with the other Authio SDKs; RSA1_5 stays refused, because Node cannot decrypt it. */
374
401
  allowLegacyEncryptionAlgorithms?: boolean;
375
402
  }
@@ -416,6 +443,7 @@ declare const AuthioBearerErrorDescriptions: {
416
443
  readonly Empty: "The token is empty.";
417
444
  readonly Encrypted: "The token is encrypted; set clientEncryptionKey to decrypt it, or validate it with introspection ('introspectionOnly').";
418
445
  readonly SigningKeysUnavailable: "Authio could not be reached to read its signing keys.";
446
+ readonly 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').";
419
447
  };
420
448
  /**
421
449
  * Creates a token verifier.
package/dist/index.d.ts CHANGED
@@ -301,13 +301,26 @@ declare class AuthioBearerError extends AuthioError {
301
301
  get wwwAuthenticate(): string;
302
302
  }
303
303
  /**
304
- * The caller is known but not allowed: answer `403`, with `wwwAuthenticate` when the refusal has a bearer error.
304
+ * A rule refused a caller whose token is valid. A refusal asking for a stronger or more recent sign-in
305
+ * (`insufficient_user_authentication`) answers `401`, as RFC 9470 §3 has it, naming the protected resource metadata
306
+ * (`resource_metadata`, RFC 9728 §5.1) when the API publishes it; every other refusal answers `403`, with
307
+ * `wwwAuthenticate` when it is about scopes (`insufficient_scope`, RFC 6750 §3.1).
305
308
  */
306
309
  declare class AuthioForbiddenError extends AuthioError {
307
- readonly status = 403;
310
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
311
+ readonly status: 401 | 403;
308
312
  readonly failure: AuthioRuleFailure;
309
- constructor(failure: AuthioRuleFailure);
310
- /** The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. */
313
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
314
+ readonly resourceMetadata: string | undefined;
315
+ constructor(failure: AuthioRuleFailure, options?: {
316
+ resourceMetadata?: string;
317
+ });
318
+ /** The same refusal, pointing at the API's protected resource metadata. */
319
+ withResourceMetadata(resourceMetadata: string | undefined): AuthioForbiddenError;
320
+ /**
321
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
322
+ * carries `resource_metadata` when `resourceMetadata` is set.
323
+ */
311
324
  get wwwAuthenticate(): string | undefined;
312
325
  }
313
326
  /**
@@ -316,7 +329,8 @@ declare class AuthioForbiddenError extends AuthioError {
316
329
  */
317
330
  declare function toForbiddenChallenge(failure: AuthioRuleFailure): string | undefined;
318
331
  /**
319
- * Problem details (RFC 9457) for a `401` or `403`.
332
+ * Problem details (RFC 9457) for a `401` or `403`, with the refusal's `status`: a step-up refusal is `401`
333
+ * Unauthorized and carries `error`, `acr_values` and `max_age`.
320
334
  */
321
335
  declare function toProblemDetails(error: AuthioBearerError | AuthioForbiddenError): Record<string, unknown>;
322
336
 
@@ -359,8 +373,14 @@ interface AuthioVerifierOptions extends AuthioConnectionOptions, AuthioUserClaim
359
373
  introspectionCacheDuration?: number;
360
374
  /** Seconds of clock skew tolerated on `exp` and `nbf`. Defaults to 30. */
361
375
  clockSkew?: number;
362
- /** Accepted signing algorithms. Defaults to the asymmetric ones (RS*, PS*, ES*). */
363
- validAlgorithms?: readonly string[];
376
+ /**
377
+ * The algorithm (or algorithms) access tokens are signed with, as the client registered in Authio signs them:
378
+ * RS/PS/ES 256/384/512, or HS256/384/512 verified with the `clientSecret` of `client` (its UTF-8 octets, at least
379
+ * 32, 48 or 64 bytes as RFC 7518 §3.2 asks). Only the client's own API holds that secret, so only it validates an HS*
380
+ * token locally; any other API validates such tokens with introspection. Left out, the asymmetric algorithms of the
381
+ * realm's keys are accepted, and never HS*.
382
+ */
383
+ accessTokenSignedResponseAlg?: string | readonly string[];
364
384
  /** Refuses a token whose `typ` claim is present and not `Bearer` (an ID token, for one). Defaults to `true`. */
365
385
  rejectNonAccessTokens?: boolean;
366
386
  /**
@@ -370,6 +390,13 @@ interface AuthioVerifierOptions extends AuthioConnectionOptions, AuthioUserClaim
370
390
  clientEncryptionKey?: AuthioEncryptionKeySource;
371
391
  /** A PEM or JWK file holding `clientEncryptionKey` (Node only). */
372
392
  clientEncryptionKeyPath?: string;
393
+ /**
394
+ * The key management algorithms (`alg`) decrypted. Defaults to RSA-OAEP and RSA-OAEP-256; RSA1_5 cannot be listed,
395
+ * because Node cannot decrypt it.
396
+ */
397
+ encryptionAlgorithms?: readonly string[];
398
+ /** The content encryption algorithms (`enc`) decrypted. Defaults to A128CBC-HS256, A192CBC-HS384, A256CBC-HS512. */
399
+ encryptionMethods?: readonly string[];
373
400
  /** Accepted for parity with the other Authio SDKs; RSA1_5 stays refused, because Node cannot decrypt it. */
374
401
  allowLegacyEncryptionAlgorithms?: boolean;
375
402
  }
@@ -416,6 +443,7 @@ declare const AuthioBearerErrorDescriptions: {
416
443
  readonly Empty: "The token is empty.";
417
444
  readonly Encrypted: "The token is encrypted; set clientEncryptionKey to decrypt it, or validate it with introspection ('introspectionOnly').";
418
445
  readonly SigningKeysUnavailable: "Authio could not be reached to read its signing keys.";
446
+ readonly 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').";
419
447
  };
420
448
  /**
421
449
  * Creates a token verifier.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { systemClock, AuthioClaimTypes, parseResourceAccess, AuthioError, AuthioDefaults, resolveIssuer, AuthioConfigurationError, resolveConnection, createAuthioDiscoveryClient, seconds, createAuthioSigningKeys, encryptionKeyFromFile, createAuthioTokenDecrypter, createAuthioClient, toScopeString, AuthioWebhookHeaders, verifyWebhookSignature, tryParseWebhookEvent, toClientCredentialsToken, isAuthioTokenEncrypted, AuthioTokenValidationError, verifyAuthioJwt, AuthioTokenTypes } from '@hyper.authio/client';
1
+ import { systemClock, AuthioClaimTypes, parseResourceAccess, AuthioError, AuthioDefaults, resolveIssuer, AuthioConfigurationError, resolveConnection, createAuthioDiscoveryClient, seconds, AuthioHmacAlgorithms, createAuthioSigningKeys, encryptionKeyFromFile, createAuthioTokenDecrypter, AuthioAsymmetricAlgorithms, requireHmacSecretLength, createAuthioClient, toScopeString, parseAuthioBearerChallenge, AuthioOAuthErrors, AuthioWebhookHeaders, verifyWebhookSignature, tryParseWebhookEvent, toClientCredentialsToken, isAuthioTokenEncrypted, AuthioTokenValidationError, withClientSecret, verifyAuthioJwt, AuthioTokenTypes } from '@hyper.authio/client';
2
+ import { decodeProtectedHeader } from 'jose';
2
3
 
3
4
  // src/user.ts
4
5
  var accessTokenTypes = /* @__PURE__ */ new Set(["bearer", "api_key"]);
@@ -468,16 +469,30 @@ var AuthioBearerError = class _AuthioBearerError extends AuthioError {
468
469
  return parameters.length === 0 ? "Bearer" : `Bearer ${parameters.join(", ")}`;
469
470
  }
470
471
  };
471
- var AuthioForbiddenError = class extends AuthioError {
472
- status = 403;
472
+ var AuthioForbiddenError = class _AuthioForbiddenError extends AuthioError {
473
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
474
+ status;
473
475
  failure;
474
- constructor(failure) {
476
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
477
+ resourceMetadata;
478
+ constructor(failure, options = {}) {
475
479
  super(failure.message);
476
480
  this.failure = failure;
481
+ this.status = failure.error === "insufficient_user_authentication" ? 401 : 403;
482
+ this.resourceMetadata = options.resourceMetadata;
477
483
  }
478
- /** The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. */
484
+ /** The same refusal, pointing at the API's protected resource metadata. */
485
+ withResourceMetadata(resourceMetadata) {
486
+ if (!resourceMetadata || resourceMetadata === this.resourceMetadata) return this;
487
+ return new _AuthioForbiddenError(this.failure, { resourceMetadata });
488
+ }
489
+ /**
490
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
491
+ * carries `resource_metadata` when `resourceMetadata` is set.
492
+ */
479
493
  get wwwAuthenticate() {
480
- return toForbiddenChallenge(this.failure);
494
+ const challenge = toForbiddenChallenge(this.failure);
495
+ return challenge && this.status === 401 && this.resourceMetadata ? `${challenge}, resource_metadata="${escapeQuoted(this.resourceMetadata)}"` : challenge;
481
496
  }
482
497
  };
483
498
  function toForbiddenChallenge(failure) {
@@ -491,15 +506,15 @@ function toForbiddenChallenge(failure) {
491
506
  function toProblemDetails(error) {
492
507
  if (error instanceof AuthioBearerError)
493
508
  return {
494
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.2",
509
+ type: unauthorizedType,
495
510
  title: "Unauthorized",
496
511
  status: 401,
497
512
  detail: "A valid Authio access token is required."
498
513
  };
499
514
  return {
500
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.4",
501
- title: "Forbidden",
502
- status: 403,
515
+ type: error.status === 401 ? unauthorizedType : "https://tools.ietf.org/html/rfc9110#section-15.5.4",
516
+ title: error.status === 401 ? "Unauthorized" : "Forbidden",
517
+ status: error.status,
503
518
  detail: error.failure.message || "The caller is not allowed to access this resource.",
504
519
  ...error.failure.error ? { error: error.failure.error } : {},
505
520
  ...error.failure.scope ? { scope: error.failure.scope } : {},
@@ -507,6 +522,7 @@ function toProblemDetails(error) {
507
522
  ...error.failure.maxAge !== void 0 ? { max_age: error.failure.maxAge } : {}
508
523
  };
509
524
  }
525
+ var unauthorizedType = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
510
526
  function toHeaderText(text) {
511
527
  return [...text].map((character) => character >= " " && character <= "~" && character !== '"' && character !== "\\" ? character : "?").join("");
512
528
  }
@@ -551,7 +567,8 @@ var AuthioBearerErrorDescriptions = {
551
567
  Unconfirmed: "Authio could not be reached to confirm that the token is active.",
552
568
  Empty: "The token is empty.",
553
569
  Encrypted: "The token is encrypted; set clientEncryptionKey to decrypt it, or validate it with introspection ('introspectionOnly').",
554
- SigningKeysUnavailable: "Authio could not be reached to read its signing keys."
570
+ SigningKeysUnavailable: "Authio could not be reached to read its signing keys.",
571
+ 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')."
555
572
  };
556
573
  var maxCachedAnswers = 1e4;
557
574
  function createAuthioVerifier(options) {
@@ -571,7 +588,9 @@ function createAuthioVerifier(options) {
571
588
  const resourceMetadataUrl = resolveResourceMetadata(options.resourceMetadata, resource);
572
589
  const cacheDurationMs = seconds(options.introspectionCacheDuration, 30, "introspectionCacheDuration");
573
590
  const clockSkew = seconds(options.clockSkew, 30, "clockSkew") / 1e3;
574
- const algorithms = options.validAlgorithms && options.validAlgorithms.length > 0 ? [...options.validAlgorithms] : void 0;
591
+ const algorithms = resolveAccessTokenAlgorithms(options.accessTokenSignedResponseAlg);
592
+ const symmetric = algorithms?.filter((algorithm) => AuthioHmacAlgorithms.includes(algorithm)) ?? [];
593
+ const clientSecret = symmetric.length > 0 ? resolveClientSecret(options, symmetric) : void 0;
575
594
  const rejectNonAccessTokens = options.rejectNonAccessTokens ?? true;
576
595
  const claimSettings = { roleClaims: options.roleClaims, groupClaims: options.groupClaims };
577
596
  const cache = /* @__PURE__ */ new Map();
@@ -579,7 +598,9 @@ function createAuthioVerifier(options) {
579
598
  const encryptionKey = options.clientEncryptionKey ?? (options.clientEncryptionKeyPath ? encryptionKeyFromFile(options.clientEncryptionKeyPath) : void 0);
580
599
  const decrypt = encryptionKey === void 0 ? void 0 : createAuthioTokenDecrypter(encryptionKey, {
581
600
  setting: options.clientEncryptionKey === void 0 ? "clientEncryptionKeyPath" : "clientEncryptionKey",
582
- allowLegacyEncryptionAlgorithms: options.allowLegacyEncryptionAlgorithms
601
+ allowLegacyEncryptionAlgorithms: options.allowLegacyEncryptionAlgorithms,
602
+ encryptionAlgorithms: options.encryptionAlgorithms,
603
+ encryptionMethods: options.encryptionMethods
583
604
  });
584
605
  const requiredScopes = Object.freeze([...new Set((options.requiredScopes ?? []).filter((scope) => scope?.trim()))]);
585
606
  const introspect = async (token, key, signal) => {
@@ -609,7 +630,7 @@ function createAuthioVerifier(options) {
609
630
  }
610
631
  let keySet;
611
632
  try {
612
- keySet = await signingKeys(signal);
633
+ keySet = withClientSecret(await signingKeys(signal), clientSecret);
613
634
  } catch (error) {
614
635
  throw new AuthioBearerError(AuthioBearerErrorDescriptions.SigningKeysUnavailable, { cause: error, reason: "signing_keys_unavailable" });
615
636
  }
@@ -623,6 +644,8 @@ function createAuthioVerifier(options) {
623
644
  now: connection.clock.now()
624
645
  }));
625
646
  } catch (error) {
647
+ if (error instanceof AuthioTokenValidationError && error.reason === "invalid_algorithm" && symmetric.length === 0 && isSymmetricToken(signed))
648
+ throw new AuthioBearerError(AuthioBearerErrorDescriptions.Symmetric, { cause: error, reason: error.reason });
626
649
  if (error instanceof AuthioTokenValidationError) throw new AuthioBearerError(error.message, { cause: error, reason: error.reason });
627
650
  throw error;
628
651
  }
@@ -704,6 +727,35 @@ function resolveResourceMetadata(setting, resource) {
704
727
  throw new AuthioConfigurationError("resourceMetadata: true needs the API's resource identifier: set options.resource, or pass the metadata URL.");
705
728
  return protectedResourceMetadataUrl(resource);
706
729
  }
730
+ function resolveAccessTokenAlgorithms(setting) {
731
+ const algorithms = [...new Set((typeof setting === "string" ? [setting] : [...setting ?? []]).map((algorithm) => algorithm.trim()).filter(Boolean))];
732
+ const known = [...AuthioAsymmetricAlgorithms, ...AuthioHmacAlgorithms];
733
+ for (const algorithm of algorithms)
734
+ if (!known.includes(algorithm))
735
+ throw new AuthioConfigurationError(`The Authio accessTokenSignedResponseAlg '${algorithm}' is not one of ${known.join(", ")}.`);
736
+ return algorithms.length > 0 ? algorithms : void 0;
737
+ }
738
+ function resolveClientSecret(options, symmetric) {
739
+ const given = options.client;
740
+ const source = given && !isClient(given) ? given.clientSecret : void 0;
741
+ if (!source)
742
+ throw new AuthioConfigurationError(
743
+ `accessTokenSignedResponseAlg ${symmetric.join(", ")} is verified with the client secret: pass options.client as { clientId, clientSecret } (an AuthioClient does not expose its secret).`
744
+ );
745
+ if (typeof source === "string") for (const algorithm of symmetric) requireHmacSecretLength(algorithm, new TextEncoder().encode(source).length);
746
+ return async () => {
747
+ const value = typeof source === "function" ? await source() : source;
748
+ if (typeof value !== "string" || !value) throw new AuthioConfigurationError("The Authio clientSecret function returned no secret.");
749
+ return value;
750
+ };
751
+ }
752
+ function isSymmetricToken(token) {
753
+ try {
754
+ return AuthioHmacAlgorithms.includes(String(decodeProtectedHeader(token).alg));
755
+ } catch {
756
+ return false;
757
+ }
758
+ }
707
759
  function resolveClient(options) {
708
760
  const given = options.client;
709
761
  if (!given) return void 0;
@@ -866,13 +918,16 @@ function createOnBehalfOf(options) {
866
918
  };
867
919
  const token = await getToken(subject, request.signal);
868
920
  const response = await attempt(token);
869
- if (response.status !== 401) return response;
921
+ if (response.status !== 401 || isStepUpChallenge(response)) return response;
870
922
  await invalidate(subject, token.accessToken);
871
923
  return attempt(await getToken(subject, request.signal));
872
924
  };
873
925
  }
874
926
  };
875
927
  }
928
+ function isStepUpChallenge(response) {
929
+ return parseAuthioBearerChallenge(response.headers.get("www-authenticate"))?.error === AuthioOAuthErrors.InsufficientUserAuthentication;
930
+ }
876
931
  function tokenOf(subject) {
877
932
  if (!subject) return void 0;
878
933
  return typeof subject === "string" ? subject : subject.token;