@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 +43 -7
- package/dist/index.cjs +69 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -7
- package/dist/index.d.ts +35 -7
- package/dist/index.js +70 -15
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
| `
|
|
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
|
-
//
|
|
103
|
-
//
|
|
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
|
|
115
|
-
answers it with `createStepUpAuthorizationUrl` from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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;
|