@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/dist/index.d.cts CHANGED
@@ -75,7 +75,7 @@ declare class AuthioUser {
75
75
  readonly actorChain: readonly string[];
76
76
  /** `auth_time`, in seconds since the epoch. */
77
77
  readonly authTime: number | undefined;
78
- /** The client the token was issued to (`azp`, else `client_id`). */
78
+ /** The client the token was issued to (`client_id`, RFC 9068 §2.2, else `azp`). */
79
79
  readonly clientId: string | undefined;
80
80
  /** `sid`. */
81
81
  readonly sessionId: string | undefined;
@@ -203,9 +203,11 @@ interface AuthioRule {
203
203
  check(user: AuthioUser): AuthioRuleFailure | undefined;
204
204
  }
205
205
  /**
206
- * Checks every rule. On refusal the message joins every rule's, and the error is the one asking for a stronger
207
- * sign-in (with the strongest `acr_values` and the shortest `max_age` the failing rules ask for), else the first one
208
- * that has an error.
206
+ * Checks every rule. On refusal the message joins every rule's. When a rule other than a step-up fails (a scope, role,
207
+ * permission, group, claim, organization, client or caller kind), no stronger sign-in can satisfy the request: the
208
+ * refusal is a `403`, with the first such rule's error (`insufficient_scope`) or none, and never asks for a step-up.
209
+ * Only when every failing rule asks for a stronger or more recent sign-in is the error `insufficient_user_authentication`,
210
+ * with the strongest `acr_values` and the shortest `max_age` they ask for.
209
211
  */
210
212
  declare function authorize(user: AuthioUser, ...rules: AuthioRule[]): AuthioRuleFailure | undefined;
211
213
  /**
@@ -264,7 +266,7 @@ declare function requireRecentAuthentication(maxAge: number, options?: {
264
266
  */
265
267
  declare function requireCallerKind(kinds: AuthioCallerKind | readonly AuthioCallerKind[]): AuthioRule;
266
268
  /**
267
- * Requires the token to have been issued to one of the clients (`azp`, else `client_id`).
269
+ * Requires the token to have been issued to one of the clients (`client_id`, else `azp`).
268
270
  */
269
271
  declare function requireClient(clientIds: string | readonly string[]): AuthioRule;
270
272
  /**
@@ -301,13 +303,26 @@ declare class AuthioBearerError extends AuthioError {
301
303
  get wwwAuthenticate(): string;
302
304
  }
303
305
  /**
304
- * The caller is known but not allowed: answer `403`, with `wwwAuthenticate` when the refusal has a bearer error.
306
+ * A rule refused a caller whose token is valid. A refusal asking for a stronger or more recent sign-in
307
+ * (`insufficient_user_authentication`) answers `401`, as RFC 9470 §3 has it, naming the protected resource metadata
308
+ * (`resource_metadata`, RFC 9728 §5.1) when the API publishes it; every other refusal answers `403`, with
309
+ * `wwwAuthenticate` when it is about scopes (`insufficient_scope`, RFC 6750 §3.1).
305
310
  */
306
311
  declare class AuthioForbiddenError extends AuthioError {
307
- readonly status = 403;
312
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
313
+ readonly status: 401 | 403;
308
314
  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. */
315
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
316
+ readonly resourceMetadata: string | undefined;
317
+ constructor(failure: AuthioRuleFailure, options?: {
318
+ resourceMetadata?: string;
319
+ });
320
+ /** The same refusal, pointing at the API's protected resource metadata. */
321
+ withResourceMetadata(resourceMetadata: string | undefined): AuthioForbiddenError;
322
+ /**
323
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
324
+ * carries `resource_metadata` when `resourceMetadata` is set.
325
+ */
311
326
  get wwwAuthenticate(): string | undefined;
312
327
  }
313
328
  /**
@@ -316,7 +331,8 @@ declare class AuthioForbiddenError extends AuthioError {
316
331
  */
317
332
  declare function toForbiddenChallenge(failure: AuthioRuleFailure): string | undefined;
318
333
  /**
319
- * Problem details (RFC 9457) for a `401` or `403`.
334
+ * Problem details (RFC 9457) for a `401` or `403`, with the refusal's `status`: a step-up refusal is `401`
335
+ * Unauthorized and carries `error`, `acr_values` and `max_age`.
320
336
  */
321
337
  declare function toProblemDetails(error: AuthioBearerError | AuthioForbiddenError): Record<string, unknown>;
322
338
 
@@ -367,7 +383,10 @@ interface AuthioVerifierOptions extends AuthioConnectionOptions, AuthioUserClaim
367
383
  * realm's keys are accepted, and never HS*.
368
384
  */
369
385
  accessTokenSignedResponseAlg?: string | readonly string[];
370
- /** Refuses a token whose `typ` claim is present and not `Bearer` (an ID token, for one). Defaults to `true`. */
386
+ /**
387
+ * Refuses a token whose `typ` claim is present and not `Bearer` (an ID token, for one). Defaults to `true`. A JWT
388
+ * whose header `typ` is not `at+jwt` (RFC 9068 §4) is refused whatever this says.
389
+ */
371
390
  rejectNonAccessTokens?: boolean;
372
391
  /**
373
392
  * The private half of the encryption key Authio encrypts this API's access tokens to (the resource server's or its
package/dist/index.d.ts CHANGED
@@ -75,7 +75,7 @@ declare class AuthioUser {
75
75
  readonly actorChain: readonly string[];
76
76
  /** `auth_time`, in seconds since the epoch. */
77
77
  readonly authTime: number | undefined;
78
- /** The client the token was issued to (`azp`, else `client_id`). */
78
+ /** The client the token was issued to (`client_id`, RFC 9068 §2.2, else `azp`). */
79
79
  readonly clientId: string | undefined;
80
80
  /** `sid`. */
81
81
  readonly sessionId: string | undefined;
@@ -203,9 +203,11 @@ interface AuthioRule {
203
203
  check(user: AuthioUser): AuthioRuleFailure | undefined;
204
204
  }
205
205
  /**
206
- * Checks every rule. On refusal the message joins every rule's, and the error is the one asking for a stronger
207
- * sign-in (with the strongest `acr_values` and the shortest `max_age` the failing rules ask for), else the first one
208
- * that has an error.
206
+ * Checks every rule. On refusal the message joins every rule's. When a rule other than a step-up fails (a scope, role,
207
+ * permission, group, claim, organization, client or caller kind), no stronger sign-in can satisfy the request: the
208
+ * refusal is a `403`, with the first such rule's error (`insufficient_scope`) or none, and never asks for a step-up.
209
+ * Only when every failing rule asks for a stronger or more recent sign-in is the error `insufficient_user_authentication`,
210
+ * with the strongest `acr_values` and the shortest `max_age` they ask for.
209
211
  */
210
212
  declare function authorize(user: AuthioUser, ...rules: AuthioRule[]): AuthioRuleFailure | undefined;
211
213
  /**
@@ -264,7 +266,7 @@ declare function requireRecentAuthentication(maxAge: number, options?: {
264
266
  */
265
267
  declare function requireCallerKind(kinds: AuthioCallerKind | readonly AuthioCallerKind[]): AuthioRule;
266
268
  /**
267
- * Requires the token to have been issued to one of the clients (`azp`, else `client_id`).
269
+ * Requires the token to have been issued to one of the clients (`client_id`, else `azp`).
268
270
  */
269
271
  declare function requireClient(clientIds: string | readonly string[]): AuthioRule;
270
272
  /**
@@ -301,13 +303,26 @@ declare class AuthioBearerError extends AuthioError {
301
303
  get wwwAuthenticate(): string;
302
304
  }
303
305
  /**
304
- * The caller is known but not allowed: answer `403`, with `wwwAuthenticate` when the refusal has a bearer error.
306
+ * A rule refused a caller whose token is valid. A refusal asking for a stronger or more recent sign-in
307
+ * (`insufficient_user_authentication`) answers `401`, as RFC 9470 §3 has it, naming the protected resource metadata
308
+ * (`resource_metadata`, RFC 9728 §5.1) when the API publishes it; every other refusal answers `403`, with
309
+ * `wwwAuthenticate` when it is about scopes (`insufficient_scope`, RFC 6750 §3.1).
305
310
  */
306
311
  declare class AuthioForbiddenError extends AuthioError {
307
- readonly status = 403;
312
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
313
+ readonly status: 401 | 403;
308
314
  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. */
315
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
316
+ readonly resourceMetadata: string | undefined;
317
+ constructor(failure: AuthioRuleFailure, options?: {
318
+ resourceMetadata?: string;
319
+ });
320
+ /** The same refusal, pointing at the API's protected resource metadata. */
321
+ withResourceMetadata(resourceMetadata: string | undefined): AuthioForbiddenError;
322
+ /**
323
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
324
+ * carries `resource_metadata` when `resourceMetadata` is set.
325
+ */
311
326
  get wwwAuthenticate(): string | undefined;
312
327
  }
313
328
  /**
@@ -316,7 +331,8 @@ declare class AuthioForbiddenError extends AuthioError {
316
331
  */
317
332
  declare function toForbiddenChallenge(failure: AuthioRuleFailure): string | undefined;
318
333
  /**
319
- * Problem details (RFC 9457) for a `401` or `403`.
334
+ * Problem details (RFC 9457) for a `401` or `403`, with the refusal's `status`: a step-up refusal is `401`
335
+ * Unauthorized and carries `error`, `acr_values` and `max_age`.
320
336
  */
321
337
  declare function toProblemDetails(error: AuthioBearerError | AuthioForbiddenError): Record<string, unknown>;
322
338
 
@@ -367,7 +383,10 @@ interface AuthioVerifierOptions extends AuthioConnectionOptions, AuthioUserClaim
367
383
  * realm's keys are accepted, and never HS*.
368
384
  */
369
385
  accessTokenSignedResponseAlg?: string | readonly string[];
370
- /** Refuses a token whose `typ` claim is present and not `Bearer` (an ID token, for one). Defaults to `true`. */
386
+ /**
387
+ * Refuses a token whose `typ` claim is present and not `Bearer` (an ID token, for one). Defaults to `true`. A JWT
388
+ * whose header `typ` is not `at+jwt` (RFC 9068 §4) is refused whatever this says.
389
+ */
371
390
  rejectNonAccessTokens?: boolean;
372
391
  /**
373
392
  * The private half of the encryption key Authio encrypts this API's access tokens to (the resource server's or its
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { systemClock, AuthioClaimTypes, parseResourceAccess, AuthioError, AuthioDefaults, resolveIssuer, AuthioConfigurationError, resolveConnection, createAuthioDiscoveryClient, seconds, AuthioHmacAlgorithms, createAuthioSigningKeys, encryptionKeyFromFile, createAuthioTokenDecrypter, AuthioAsymmetricAlgorithms, requireHmacSecretLength, createAuthioClient, toScopeString, AuthioWebhookHeaders, verifyWebhookSignature, tryParseWebhookEvent, toClientCredentialsToken, isAuthioTokenEncrypted, AuthioTokenValidationError, withClientSecret, 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, requireAccessTokenType, AuthioTokenTypes } from '@hyper.authio/client';
2
2
  import { decodeProtectedHeader } from 'jose';
3
3
 
4
4
  // src/user.ts
@@ -41,7 +41,7 @@ var AuthioUser = class _AuthioUser {
41
41
  actorChain;
42
42
  /** `auth_time`, in seconds since the epoch. */
43
43
  authTime;
44
- /** The client the token was issued to (`azp`, else `client_id`). */
44
+ /** The client the token was issued to (`client_id`, RFC 9068 §2.2, else `azp`). */
45
45
  clientId;
46
46
  /** `sid`. */
47
47
  sessionId;
@@ -102,7 +102,7 @@ var AuthioUser = class _AuthioUser {
102
102
  const text = (name) => typeof claims[name] === "string" && claims[name] ? claims[name] : void 0;
103
103
  this.claims = Object.freeze({ ...claims });
104
104
  this.subject = text(AuthioClaimTypes.Subject) ?? "";
105
- this.clientId = text(AuthioClaimTypes.AuthorizedParty) ?? text(AuthioClaimTypes.ClientId);
105
+ this.clientId = text(AuthioClaimTypes.ClientId) ?? text(AuthioClaimTypes.AuthorizedParty);
106
106
  this.sessionId = text(AuthioClaimTypes.SessionId);
107
107
  this.tokenId = text(AuthioClaimTypes.JwtId);
108
108
  this.audiences = Object.freeze(distinct(valuesOf(claims[AuthioClaimTypes.Audience])));
@@ -275,8 +275,9 @@ function distinctIgnoringCase(values) {
275
275
  function authorize(user, ...rules) {
276
276
  const failures = rules.map((rule) => rule.check(user)).filter((failure) => failure !== void 0);
277
277
  if (failures.length === 0) return void 0;
278
- const stepUps = failures.filter((failure) => failure.error === "insufficient_user_authentication");
279
- const bearer = stepUps[0] ?? failures.find((failure) => failure.error);
278
+ const others = failures.filter((failure) => failure.error !== "insufficient_user_authentication");
279
+ const stepUps = others.length === 0 ? failures : [];
280
+ const bearer = stepUps[0] ?? others.find((failure) => failure.error);
280
281
  const acrValues = strongestAcr(stepUps.map((failure) => failure.acrValues));
281
282
  const maxAges = stepUps.map((failure) => failure.maxAge).filter((value) => value !== void 0);
282
283
  return {
@@ -469,16 +470,30 @@ var AuthioBearerError = class _AuthioBearerError extends AuthioError {
469
470
  return parameters.length === 0 ? "Bearer" : `Bearer ${parameters.join(", ")}`;
470
471
  }
471
472
  };
472
- var AuthioForbiddenError = class extends AuthioError {
473
- status = 403;
473
+ var AuthioForbiddenError = class _AuthioForbiddenError extends AuthioError {
474
+ /** `401` for a step-up (`insufficient_user_authentication`), `403` otherwise. */
475
+ status;
474
476
  failure;
475
- constructor(failure) {
477
+ /** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` with a step-up `401`. */
478
+ resourceMetadata;
479
+ constructor(failure, options = {}) {
476
480
  super(failure.message);
477
481
  this.failure = failure;
482
+ this.status = failure.error === "insufficient_user_authentication" ? 401 : 403;
483
+ this.resourceMetadata = options.resourceMetadata;
478
484
  }
479
- /** The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. */
485
+ /** The same refusal, pointing at the API's protected resource metadata. */
486
+ withResourceMetadata(resourceMetadata) {
487
+ if (!resourceMetadata || resourceMetadata === this.resourceMetadata) return this;
488
+ return new _AuthioForbiddenError(this.failure, { resourceMetadata });
489
+ }
490
+ /**
491
+ * The `WWW-Authenticate` value, or `undefined` when the refusal is not about scopes or the sign-in. A step-up `401`
492
+ * carries `resource_metadata` when `resourceMetadata` is set.
493
+ */
480
494
  get wwwAuthenticate() {
481
- return toForbiddenChallenge(this.failure);
495
+ const challenge = toForbiddenChallenge(this.failure);
496
+ return challenge && this.status === 401 && this.resourceMetadata ? `${challenge}, resource_metadata="${escapeQuoted(this.resourceMetadata)}"` : challenge;
482
497
  }
483
498
  };
484
499
  function toForbiddenChallenge(failure) {
@@ -492,15 +507,15 @@ function toForbiddenChallenge(failure) {
492
507
  function toProblemDetails(error) {
493
508
  if (error instanceof AuthioBearerError)
494
509
  return {
495
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.2",
510
+ type: unauthorizedType,
496
511
  title: "Unauthorized",
497
512
  status: 401,
498
513
  detail: "A valid Authio access token is required."
499
514
  };
500
515
  return {
501
- type: "https://tools.ietf.org/html/rfc9110#section-15.5.4",
502
- title: "Forbidden",
503
- status: 403,
516
+ type: error.status === 401 ? unauthorizedType : "https://tools.ietf.org/html/rfc9110#section-15.5.4",
517
+ title: error.status === 401 ? "Unauthorized" : "Forbidden",
518
+ status: error.status,
504
519
  detail: error.failure.message || "The caller is not allowed to access this resource.",
505
520
  ...error.failure.error ? { error: error.failure.error } : {},
506
521
  ...error.failure.scope ? { scope: error.failure.scope } : {},
@@ -508,6 +523,7 @@ function toProblemDetails(error) {
508
523
  ...error.failure.maxAge !== void 0 ? { max_age: error.failure.maxAge } : {}
509
524
  };
510
525
  }
526
+ var unauthorizedType = "https://tools.ietf.org/html/rfc9110#section-15.5.2";
511
527
  function toHeaderText(text) {
512
528
  return [...text].map((character) => character >= " " && character <= "~" && character !== '"' && character !== "\\" ? character : "?").join("");
513
529
  }
@@ -621,13 +637,15 @@ function createAuthioVerifier(options) {
621
637
  }
622
638
  let payload;
623
639
  try {
624
- ({ payload } = await verifyAuthioJwt(signed, keySet, {
640
+ let header;
641
+ ({ payload, header } = await verifyAuthioJwt(signed, keySet, {
625
642
  issuer: connection.issuer,
626
643
  audiences,
627
644
  algorithms,
628
645
  clockSkew,
629
646
  now: connection.clock.now()
630
647
  }));
648
+ requireAccessTokenType(header);
631
649
  } catch (error) {
632
650
  if (error instanceof AuthioTokenValidationError && error.reason === "invalid_algorithm" && symmetric.length === 0 && isSymmetricToken(signed))
633
651
  throw new AuthioBearerError(AuthioBearerErrorDescriptions.Symmetric, { cause: error, reason: error.reason });
@@ -903,13 +921,16 @@ function createOnBehalfOf(options) {
903
921
  };
904
922
  const token = await getToken(subject, request.signal);
905
923
  const response = await attempt(token);
906
- if (response.status !== 401) return response;
924
+ if (response.status !== 401 || isStepUpChallenge(response)) return response;
907
925
  await invalidate(subject, token.accessToken);
908
926
  return attempt(await getToken(subject, request.signal));
909
927
  };
910
928
  }
911
929
  };
912
930
  }
931
+ function isStepUpChallenge(response) {
932
+ return parseAuthioBearerChallenge(response.headers.get("www-authenticate"))?.error === AuthioOAuthErrors.InsufficientUserAuthentication;
933
+ }
913
934
  function tokenOf(subject) {
914
935
  if (!subject) return void 0;
915
936
  return typeof subject === "string" ? subject : subject.token;