@gjermundgaraba/clankerauth-sdk 0.25.0 → 0.26.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 +24 -10
- package/dist/resource.d.ts +5 -0
- package/dist/resource.js +1 -0
- package/dist/transport.d.ts +4 -0
- package/dist/transport.js +9 -2
- package/dist/verify.d.ts +48 -2
- package/dist/verify.js +91 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,14 +70,26 @@ import { Effect } from "effect";
|
|
|
70
70
|
import { FetchHttpClient } from "effect/http";
|
|
71
71
|
import { Verifier } from "@gjermundgaraba/clankerauth-sdk";
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
// Once, at module scope: what every verification has read.
|
|
74
|
+
const store = Verifier.store();
|
|
75
|
+
|
|
76
|
+
// Per request, in the request that verifies.
|
|
77
|
+
const verify = (token: string) =>
|
|
78
|
+
Verifier.make({
|
|
79
|
+
issuer: "https://clankerauth.internal/api/auth",
|
|
80
|
+
resource: "https://notes.internal/",
|
|
81
|
+
store,
|
|
82
|
+
requiredScopes: ["notes:read"],
|
|
83
|
+
}).pipe(
|
|
84
|
+
Effect.flatMap((verifier) => verifier.verifyToken(token)),
|
|
85
|
+
Effect.provide(FetchHttpClient.layer),
|
|
86
|
+
);
|
|
79
87
|
```
|
|
80
88
|
|
|
89
|
+
The store holds what verifiers have read, decoded and checked: each issuer's JWKS keys, and each resource's verified key list with when it was read and when it expires. It holds values only, never an Effect, a fiber, a Promise, a `Response` or a timer, so it may live as long as the process. A verifier holds nothing but its options, the store and the HTTP client. On a hit it verifies locally; on a miss, or once what it holds has expired, it reads the issuer in the verification that missed and writes the result back. Concurrent misses each read, and none waits on another's: a verification that misses before any read has finished reads for itself. Steady traffic costs one or two extra issuer reads per refresh; a burst arriving at an empty or expired store reads once per verification in it.
|
|
90
|
+
|
|
91
|
+
That split is what Cloudflare Workers requires. A Worker's I/O belongs to the request that started it: a request cannot resume work another request started, and a continuation whose request has ended is dropped ([I/O on behalf of a different request](https://developers.cloudflare.com/workers/observability/errors/#cannot-perform-io-on-behalf-of-a-different-request), [cross-request promise resolution](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#handle-cross-request-promise-resolution-correctly)). Data may live in global scope; in-flight work may not. So keep the store at module scope, as data, and acquire the verifier in each request. On Node a verifier acquired once is just as correct, since it owns no running work either.
|
|
92
|
+
|
|
81
93
|
## One resource per application
|
|
82
94
|
|
|
83
95
|
Register **one** resource in the clankerauth dashboard, identified by the application's public origin root, written with its trailing slash: `https://notes.internal/`. That one resource covers `/api`, `/mcp` and any socket, with one RFC 9728 document at `/.well-known/oauth-protected-resource` and one `forward_auth` block in the proxy.
|
|
@@ -106,11 +118,12 @@ export const Login = Authentication.make("notes.Login", CurrentPrincipal, {
|
|
|
106
118
|
export const Http = ActionHttp.make([List, Write, Whoami], { authentication: Login });
|
|
107
119
|
```
|
|
108
120
|
|
|
109
|
-
The server declares the resource once, at module level, from that descriptor, every scope it has and the one every credential must carry, `required`, one of `scopes` as the types check. It provides its `layer` and its `provider` once at application construction
|
|
121
|
+
The server declares the resource once, at module level, from that descriptor, every scope it has and the one every credential must carry, `required`, one of `scopes` as the types check. It provides its `layer` and its `provider` once at application construction. `Resource.make` takes only a descriptor declaring `ProviderUnavailable`: one that does not is a type error there, naming `error: ProviderUnavailable`.
|
|
110
122
|
|
|
111
123
|
```ts
|
|
112
124
|
import { Layer } from "effect";
|
|
113
125
|
import { FetchHttpClient } from "effect/http";
|
|
126
|
+
import { Verifier } from "@gjermundgaraba/clankerauth-sdk";
|
|
114
127
|
import { Resource } from "@gjermundgaraba/clankerauth-sdk/effect-actions";
|
|
115
128
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
116
129
|
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
@@ -133,13 +146,14 @@ const routes = Layer.mergeAll(
|
|
|
133
146
|
NotesResource.layer({
|
|
134
147
|
issuer: "https://clankerauth.internal/api/auth",
|
|
135
148
|
publicUrl: new URL("https://notes.internal"),
|
|
149
|
+
store: Verifier.store(),
|
|
136
150
|
}),
|
|
137
151
|
),
|
|
138
152
|
Layer.provide(FetchHttpClient.layer),
|
|
139
153
|
);
|
|
140
154
|
```
|
|
141
155
|
|
|
142
|
-
Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the release that matches your Effect. The scopes are the same in every deployment, so they are declared with the resource; `NotesResource.layer` takes what varies by deployment and builds the resource once: its verifier and refusals. `NotesResource.provider` is the descriptor's provider, effect-actions' `Authentication.layer`: it verifies every protected route and tool call before decoding, and publishes discovery itself, public and outside authentication; there is no discovery route to merge. Discovery cache policy belongs to the host. Every response to a request it authenticates carries `Cache-Control: no-store`. Middleware of your own runs as effect-actions runs it: router middleware provided after the provider, or an HTTP layer's `middleware`.
|
|
156
|
+
Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the release that matches your Effect. The scopes are the same in every deployment, so they are declared with the resource; `NotesResource.layer` takes what varies by deployment and builds the resource once: its verifier and refusals. Its `store` is the verifier's, as above. The resource holds no running work, so built once it still reads the issuer only in the request that verifies; an application that builds its layers per request, as a Worker may, passes a store made at module scope, so what was read outlives each request. `NotesResource.provider` is the descriptor's provider, effect-actions' `Authentication.layer`: it verifies every protected route and tool call before decoding, and publishes discovery itself, public and outside authentication; there is no discovery route to merge. Discovery cache policy belongs to the host. Every response to a request it authenticates carries `Cache-Control: no-store`. Middleware of your own runs as effect-actions runs it: router middleware provided after the provider, or an HTTP layer's `middleware`.
|
|
143
157
|
|
|
144
158
|
A surface whose callers hold API keys and never run an OAuth flow, such as a second listener beside the one that publishes the resource, sets `discovery: false`: it authenticates the same way, publishes nothing, and its challenge is a plain `Bearer`. It is the same registered resource, so it takes the publishing listener's public URL, not its own address: a public machine listener beside a private admin one still passes the admin URL.
|
|
145
159
|
|
|
@@ -147,7 +161,7 @@ Two listeners with different scopes, such as that admin and machine pair, are st
|
|
|
147
161
|
|
|
148
162
|
A declared resource is a `Resource.Declared<"notes.Login">`, and what its `layer` builds a `Resource.Built`; its descriptor is a `Resource.Login<"notes.Login">`. `Declared` and `Login` take what the descriptor declares as an optional second parameter, `ProviderUnavailable` alone when left out. `Declared` and `Built` take the resource's scopes as an optional parameter, last, which `make` infers from `scopes`: an annotation that leaves it out accepts any scope string. The entry point also exports each type a resource's declarations mention under a flat name (`DeclaredResource`, `BuiltResource`, `ResourceService`, `ResourceDeclaration`, `ResourceOptions`, `Login`, `Admission`, `Refusal`, `Principal`, `Verifier`); `CurrentPrincipal` and its `Caller` are `/session`'s, where declarations name them. So a package that exports its descriptor, its resource or its implementations emits declarations without an annotation.
|
|
149
163
|
|
|
150
|
-
The supplied `HttpClient` must not retry credential exchanges or follow redirects. The SDK overrides only FetchHttpClient's redirect policy to
|
|
164
|
+
The supplied `HttpClient` must not retry credential exchanges or follow redirects. The SDK overrides only FetchHttpClient's redirect policy, to `manual`, and fails a read answered with a redirect (any 3xx) as `ProviderUnavailable`; it preserves other caller-provided fetch defaults and never installs retry middleware. `manual`, not `error`, because Cloudflare Workers rejects `redirect: "error"` before sending ([Request](https://developers.cloudflare.com/workers/runtime-apis/request/#options)).
|
|
151
165
|
|
|
152
166
|
## Scope enforcement
|
|
153
167
|
|
|
@@ -326,7 +340,7 @@ Tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauthorized`
|
|
|
326
340
|
|
|
327
341
|
There is one 403. Only the verifier's own check fails with `InsufficientScope`: a credential that is valid but lacks a scope in `requiredScopes`, a resource's `required`. It names that scope and the credential's `actor`, which decides whether its `Forbidden` steps up. A scope asked for after verification, by `requires(scope)`, `admitted(scope)`, `admit(header, scope)` or `watch(header, scope)`, is refused with effect-actions' `Action.Forbidden` directly, decided by the same rule. A credential that is not this resource's at all — the wrong audience, or an API key with no entry in this resource's key list — is `Unauthorized`, because from the resource's side those are the same thing.
|
|
328
342
|
|
|
329
|
-
JWTs are checked against issuer, audience, EdDSA signature, token type, required claims, expiry and scopes. Sender-constrained tokens are rejected. One JWKS read answers every verification for a minute, so a key the issuer starts or stops publishing takes effect within that minute; a `kid` the current document does not publish is `Unauthorized` and never triggers a read, so a token with an invented `kid` cannot become provider traffic. A failed read answers `ProviderUnavailable` (503) for five seconds before the next attempt; a read
|
|
343
|
+
JWTs are checked against issuer, audience, EdDSA signature, token type, required claims, expiry and scopes. Sender-constrained tokens are rejected. One JWKS read answers every verification for a minute, so a key the issuer starts or stops publishing takes effect within that minute; a `kid` the current document does not publish is `Unauthorized` and never triggers a read, so a token with an invented `kid` cannot become provider traffic. A failed read answers `ProviderUnavailable` (503) for five seconds before the next attempt; a read whose verification is interrupted writes nothing, and the next verification reads again. API keys are verified offline, against the resource's key list: one document the issuer signs with its access-token key, holding a sealed entry per key granted on this resource, which only that key can find or open. The list is verified once, when it is read, and one read answers every key it names for a minute, without issuer traffic; so a key disabled, deleted or re-scoped keeps the access it had until the next read, within about a minute. A key the held list does not name may have been created, enabled or granted on this resource since, so the list is read again before the key is refused, unless it was read within the last five seconds: such a key works within seconds, and unknown keys reach the issuer at most once every five seconds. A key's own expiry is checked on every verification, so it applies at once. A failed read, or one whose list or JWKS fetch takes longer than three seconds, is no error while the last list is inside its own lifetime, which the issuer sets to 24 hours: keys keep verifying through an outage, JWKS or not, and the issuer is tried again after five seconds. A read whose verification is interrupted writes nothing, and the next key reads again. Past that lifetime, or before any list has been read, a key is `ProviderUnavailable`. Reads that start failing are logged once as a warning, and their recovery once at info level, so an outage the held list hides still shows in the application's logs without a line per retry. The last list read is the one held, whatever its `iat`: HTTPS to the issuer is what keeps an old list from being replayed, and one could only ever be replayed inside its own day. The issuer does not count key use, so rate limiting is the application's. A key is a bearer secret, and one granted on several resources can be replayed by any of them to the others; a resource you trust less gets its own key. A resource that accepts only OAuth access tokens sets `apiKeys: false`; key-shaped bearers then fail as `Unauthorized` without reading the key list. Admission through a `Resource` has a five-second deadline; the raw `Verifier` has none, so an in-process caller can await a provider call it cannot cancel. The exception is the key list read, which gives each of its fetches three seconds: the list, and the JWKS it is verified with when none is held, so a slow first fetch does not cut the second short. An issuer that stops answering therefore delays a key by three seconds and never fails it while a list is held; one that answers the list late and then stalls on the JWKS can take the read past `Resource`'s deadline.
|
|
330
344
|
|
|
331
345
|
The bundled issuer does not configure automatic signing-key rotation. Immediate-use rotation is supported, but tokens signed by a new key are rejected until the next JWKS read, up to a minute; a key list signed by it is not taken until then either, and the held list keeps deciding. Publish-before-use avoids that window; it is not mandatory. Key lists are re-signed on every read, so issued API keys are unaffected by rotation.
|
|
332
346
|
|
package/dist/resource.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface Options {
|
|
|
26
26
|
* logged once as a warning, since an OAuth client discovering the resource there refuses it.
|
|
27
27
|
*/
|
|
28
28
|
readonly publicUrl: URL;
|
|
29
|
+
/**
|
|
30
|
+
* What the resource's verifier keeps of what it reads, `Verifier.store()`: made once where
|
|
31
|
+
* the process starts, so it outlives a layer built per request.
|
|
32
|
+
*/
|
|
33
|
+
readonly store: Verifier.Store;
|
|
29
34
|
/** `false` refuses API keys without reading the issuer's key list; only OAuth access tokens are accepted. */
|
|
30
35
|
readonly apiKeys?: boolean;
|
|
31
36
|
/**
|
package/dist/resource.js
CHANGED
|
@@ -56,6 +56,7 @@ const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, r
|
|
|
56
56
|
const unbounded = yield* Verifier.make({
|
|
57
57
|
issuer: options.issuer,
|
|
58
58
|
resource: identifier,
|
|
59
|
+
store: options.store,
|
|
59
60
|
requiredScopes: [required],
|
|
60
61
|
apiKeys: options.apiKeys,
|
|
61
62
|
});
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
3
|
import { ProviderUnavailable } from "./errors.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Send `request` without following a redirect: a redirect fails the read. `manual`, since
|
|
6
|
+
* Cloudflare Workers rejects `redirect: "error"` before sending.
|
|
7
|
+
*/
|
|
4
8
|
export declare const execute: (client: HttpClient.HttpClient, request: HttpClientRequest.HttpClientRequest) => Effect.Effect<import("effect/http/HttpClientResponse").HttpClientResponse, ProviderUnavailable, import("effect/Scope").Scope>;
|
package/dist/transport.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { Context, Effect } from "effect";
|
|
2
2
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
3
3
|
import { ProviderUnavailable } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Send `request` without following a redirect: a redirect fails the read. `manual`, since
|
|
6
|
+
* Cloudflare Workers rejects `redirect: "error"` before sending.
|
|
7
|
+
*/
|
|
4
8
|
export const execute = (client, request) => Effect.contextWith((context) => HttpClient.withScope(client)
|
|
5
9
|
.execute(request)
|
|
6
10
|
.pipe(Effect.provideService(FetchHttpClient.RequestInit, {
|
|
7
11
|
...Context.getOrUndefined(context, FetchHttpClient.RequestInit),
|
|
8
|
-
redirect: "
|
|
9
|
-
}), Effect.mapError((cause) => new ProviderUnavailable({ operation: "http.request", cause }))))
|
|
12
|
+
redirect: "manual",
|
|
13
|
+
}), Effect.mapError((cause) => new ProviderUnavailable({ operation: "http.request", cause })), Effect.filterOrFail((response) => response.status < 300 || response.status >= 400, (response) => new ProviderUnavailable({
|
|
14
|
+
operation: "http.redirect",
|
|
15
|
+
cause: { status: response.status },
|
|
16
|
+
}))));
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import type { JWK } from "jose";
|
|
2
3
|
import { HttpClient } from "effect/http";
|
|
3
4
|
import { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.ts";
|
|
4
5
|
import type { AuthenticationError } from "./errors.ts";
|
|
@@ -28,6 +29,8 @@ export interface Principal {
|
|
|
28
29
|
export interface Options {
|
|
29
30
|
readonly issuer: string;
|
|
30
31
|
readonly resource: string;
|
|
32
|
+
/** Where this verifier keeps what it reads and finds what was read before: see `Store`. */
|
|
33
|
+
readonly store: Store;
|
|
31
34
|
readonly requiredScopes?: readonly string[];
|
|
32
35
|
/** `false` refuses API keys without reading the issuer's key list; only OAuth access tokens are accepted. */
|
|
33
36
|
readonly apiKeys?: boolean;
|
|
@@ -35,11 +38,54 @@ export interface Options {
|
|
|
35
38
|
export interface Verifier {
|
|
36
39
|
readonly verifyToken: (token: string) => Effect.Effect<Principal, AuthenticationError>;
|
|
37
40
|
}
|
|
41
|
+
/** One verified key list: the entries it holds and how long they may decide. */
|
|
42
|
+
export interface Keys {
|
|
43
|
+
/** Epoch milliseconds, by this host's clock: when the list was read. */
|
|
44
|
+
readonly readAt: number;
|
|
45
|
+
/** Epoch milliseconds: the end of the issuer's outage window for this list. */
|
|
46
|
+
readonly expiresAt: number;
|
|
47
|
+
readonly entries: ReadonlyMap<string, string>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The last JWKS document read from an issuer: its keys, which signing keys are resolved
|
|
51
|
+
* from, or the operation that failed. Either answers until its lifetime or cooldown ends.
|
|
52
|
+
*/
|
|
53
|
+
export type SettledJwks = {
|
|
54
|
+
readonly readAt: number;
|
|
55
|
+
readonly keys: readonly JWK[];
|
|
56
|
+
} | {
|
|
57
|
+
readonly readAt: number;
|
|
58
|
+
readonly failure: string;
|
|
59
|
+
};
|
|
60
|
+
/** The last key list read for one resource of an issuer, and the last one that verified. */
|
|
61
|
+
export interface SettledKeyList {
|
|
62
|
+
/** Epoch milliseconds: when the last read ended, which holds off the next for the cooldown. */
|
|
63
|
+
readonly readAt: number;
|
|
64
|
+
/** The operation the last read failed at; `undefined` when it succeeded. */
|
|
65
|
+
readonly failure: string | undefined;
|
|
66
|
+
/** The last list that verified, which decides through an outage until it expires. */
|
|
67
|
+
readonly held: Keys | undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* What verifiers have read and decoded, for every verifier given it: JWKS documents by
|
|
71
|
+
* issuer, key lists by issuer and resource. It holds values only, never an Effect, fiber,
|
|
72
|
+
* Promise, Response or timer, so it may live as long as the process, at module scope on a
|
|
73
|
+
* runtime whose I/O belongs to one request, such as Cloudflare Workers. Every read runs in
|
|
74
|
+
* the verification that misses, and writes its result back; concurrent misses each read.
|
|
75
|
+
*/
|
|
76
|
+
export interface Store {
|
|
77
|
+
readonly jwks: Map<string, SettledJwks>;
|
|
78
|
+
readonly keyLists: Map<string, SettledKeyList>;
|
|
79
|
+
}
|
|
80
|
+
/** An empty store: `const store = Verifier.store()`, once, where the process starts. */
|
|
81
|
+
export declare const store: () => Store;
|
|
38
82
|
/**
|
|
39
|
-
*
|
|
83
|
+
* A verifier over `options.store`; transport is selected by the application, not the SDK.
|
|
84
|
+
* It holds nothing but its options, the store and the HTTP client, so acquiring one per
|
|
85
|
+
* request costs nothing: every read it makes runs in the verification that needs it.
|
|
40
86
|
* Verification runs to completion: a deadline is the request edge's policy, which
|
|
41
87
|
* `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
|
|
42
|
-
* The one exception is a key list read, which gives up after three seconds.
|
|
88
|
+
* The one exception is a key list read, which gives up on each of its fetches after three seconds.
|
|
43
89
|
*/
|
|
44
90
|
export declare const make: (options: Options) => Effect.Effect<{
|
|
45
91
|
verifyToken: (token: string) => Effect.Effect<{
|
package/dist/verify.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Clock, Duration, Effect,
|
|
1
|
+
import { Clock, Duration, Effect, Result, Schema } from "effect";
|
|
2
2
|
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
|
|
3
3
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
4
|
import { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized, } from "./errors.js";
|
|
@@ -26,27 +26,37 @@ const Claims = Schema.Struct({
|
|
|
26
26
|
/** The list document as the issuer's `keyList` action answers it. */
|
|
27
27
|
const KeyListResponse = Schema.Struct({ list: Schema.String });
|
|
28
28
|
const KeyListClaims = KeyList.Claims.pipe(Schema.fieldsAssign({ exp: Schema.Finite }));
|
|
29
|
+
/** An empty store: `const store = Verifier.store()`, once, where the process starts. */
|
|
30
|
+
export const store = () => ({ jwks: new Map(), keyLists: new Map() });
|
|
29
31
|
const unauthorized = (cause) => new Unauthorized({ cause });
|
|
30
32
|
/** How long a JWKS document answers verification before it is read again. */
|
|
31
|
-
const documentLifetime =
|
|
33
|
+
const documentLifetime = Duration.minutes(1);
|
|
32
34
|
/**
|
|
33
35
|
* How long a read holds off the next one: a failed read of either document, and any read
|
|
34
36
|
* of the key list, so a key the held list does not name reaches the issuer at most this often.
|
|
35
37
|
*/
|
|
36
38
|
const cooldown = Duration.seconds(5);
|
|
37
39
|
/**
|
|
38
|
-
* How long a key list read may take
|
|
39
|
-
*
|
|
40
|
+
* How long each fetch of a key list read may take: the list, and the JWKS it is verified
|
|
41
|
+
* with when none is held. Under `Resource`'s five-second deadline, so an issuer that stops
|
|
42
|
+
* answering fails the read, not the request, and a held list still decides.
|
|
40
43
|
*/
|
|
41
|
-
const listReadTimeout =
|
|
44
|
+
const listReadTimeout = Duration.seconds(3);
|
|
45
|
+
const deadline = (operation) => (effect) => Effect.timeoutOrElse(effect, {
|
|
46
|
+
duration: listReadTimeout,
|
|
47
|
+
orElse: () => Effect.fail(new ProviderUnavailable({ operation })),
|
|
48
|
+
});
|
|
42
49
|
/**
|
|
43
|
-
*
|
|
50
|
+
* A verifier over `options.store`; transport is selected by the application, not the SDK.
|
|
51
|
+
* It holds nothing but its options, the store and the HTTP client, so acquiring one per
|
|
52
|
+
* request costs nothing: every read it makes runs in the verification that needs it.
|
|
44
53
|
* Verification runs to completion: a deadline is the request edge's policy, which
|
|
45
54
|
* `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
|
|
46
|
-
* The one exception is a key list read, which gives up after three seconds.
|
|
55
|
+
* The one exception is a key list read, which gives up on each of its fetches after three seconds.
|
|
47
56
|
*/
|
|
48
57
|
export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
49
58
|
const client = yield* HttpClient.HttpClient;
|
|
59
|
+
const listed = JSON.stringify([options.issuer, options.resource]);
|
|
50
60
|
const endpoints = yield* Effect.try({
|
|
51
61
|
try: () => ({
|
|
52
62
|
jwks: new URL(`${options.issuer}/jwks`).href,
|
|
@@ -62,15 +72,35 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
62
72
|
cause: { status: response.status },
|
|
63
73
|
});
|
|
64
74
|
const jwks = yield* HttpClientResponse.schemaBodyJson(Jwks)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "jwks.decode", cause })));
|
|
65
|
-
return
|
|
66
|
-
keys: jwks.keys.map(({ key_ops, ...key }) => key_ops ? { ...key, key_ops: [...key_ops] } : key),
|
|
67
|
-
});
|
|
75
|
+
return jwks.keys.map(({ key_ops, ...key }) => key_ops ? { ...key, key_ops: [...key_ops] } : key);
|
|
68
76
|
}, Effect.scoped);
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
77
|
+
/** The keys read within their lifetime, a failed read's failure within its cooldown, or a read now. */
|
|
78
|
+
const keys = Effect.gen(function* () {
|
|
79
|
+
const settled = options.store.jwks.get(options.issuer);
|
|
80
|
+
const startedAt = yield* Clock.currentTimeMillis;
|
|
81
|
+
if (settled !== undefined) {
|
|
82
|
+
const answers = "keys" in settled ? documentLifetime : cooldown;
|
|
83
|
+
if (startedAt - settled.readAt < Duration.toMillis(answers))
|
|
84
|
+
return "keys" in settled
|
|
85
|
+
? settled.keys
|
|
86
|
+
: yield* new ProviderUnavailable({ operation: settled.failure });
|
|
87
|
+
}
|
|
88
|
+
const read = yield* Effect.result(document());
|
|
89
|
+
const readAt = yield* Clock.currentTimeMillis;
|
|
90
|
+
if (Result.isSuccess(read)) {
|
|
91
|
+
options.store.jwks.set(options.issuer, { readAt, keys: read.success });
|
|
92
|
+
return read.success;
|
|
93
|
+
}
|
|
94
|
+
// why: a failure never replaces an outcome that ended after it began.
|
|
95
|
+
if ((options.store.jwks.get(options.issuer)?.readAt ?? -Infinity) >= startedAt)
|
|
96
|
+
return yield* read.failure;
|
|
97
|
+
options.store.jwks.set(options.issuer, { readAt, failure: read.failure.operation });
|
|
98
|
+
return yield* read.failure;
|
|
99
|
+
});
|
|
100
|
+
const signingKey = Effect.fn("Verifier.signingKey")(function* (kid, held) {
|
|
101
|
+
const jwks = { keys: [...(yield* held)] };
|
|
72
102
|
return yield* Effect.tryPromise({
|
|
73
|
-
try: () =>
|
|
103
|
+
try: () => createLocalJWKSet(jwks)({ alg: "EdDSA", kid }),
|
|
74
104
|
catch: unauthorized,
|
|
75
105
|
});
|
|
76
106
|
});
|
|
@@ -78,9 +108,9 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
78
108
|
* A document this issuer signed for this resource: EdDSA under a key it names, with the
|
|
79
109
|
* expected `typ`, issuer and audience, and not expired. Which claims it must carry is
|
|
80
110
|
* its schema's to say. `Unauthorized` means it is not to be trusted; what that amounts
|
|
81
|
-
* to is the caller's to say.
|
|
111
|
+
* to is the caller's to say. Its signing keys are `held`'s, the store's JWKS by default.
|
|
82
112
|
*/
|
|
83
|
-
const verifySigned = Effect.fn("Verifier.signed")(function* (jws, typ) {
|
|
113
|
+
const verifySigned = Effect.fn("Verifier.signed")(function* (jws, typ, held = keys) {
|
|
84
114
|
const header = yield* Effect.try({
|
|
85
115
|
try: () => decodeProtectedHeader(jws),
|
|
86
116
|
catch: unauthorized,
|
|
@@ -88,7 +118,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
88
118
|
// why: refused before any JWKS read, so an unauthenticated request causes no issuer traffic.
|
|
89
119
|
if (header.alg !== "EdDSA" || header.kid === undefined)
|
|
90
120
|
return yield* unauthorized();
|
|
91
|
-
const key = yield* signingKey(header.kid);
|
|
121
|
+
const key = yield* signingKey(header.kid, held);
|
|
92
122
|
const now = yield* Clock.currentTimeMillis;
|
|
93
123
|
const { payload } = yield* Effect.tryPromise({
|
|
94
124
|
try: () => jwtVerify(jws, key, {
|
|
@@ -116,15 +146,19 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
116
146
|
});
|
|
117
147
|
const listDocument = Effect.fn("Verifier.keyList")(function* () {
|
|
118
148
|
const request = HttpClientRequest.post(endpoints.keyList).pipe(HttpClientRequest.bodyJsonUnsafe({ resource: options.resource }));
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
const fetched = Effect.gen(function* () {
|
|
150
|
+
const response = yield* execute(client, request);
|
|
151
|
+
if (response.status !== 200)
|
|
152
|
+
return yield* new ProviderUnavailable({
|
|
153
|
+
operation: "key-list.fetch",
|
|
154
|
+
cause: { status: response.status },
|
|
155
|
+
});
|
|
156
|
+
return yield* HttpClientResponse.schemaBodyJson(KeyListResponse)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "key-list.decode", cause })));
|
|
157
|
+
});
|
|
158
|
+
const { list } = yield* fetched.pipe(deadline("key-list.timeout"));
|
|
126
159
|
const rejected = (cause) => new ProviderUnavailable({ operation: "key-list.verify", cause });
|
|
127
|
-
|
|
160
|
+
// why: a JWKS read on its own deadline, so a cold one does not spend the list's.
|
|
161
|
+
const payload = yield* verifySigned(list, KeyList.type, keys.pipe(deadline("jwks.timeout"))).pipe(Effect.catchTag("Unauthorized", (cause) => Effect.fail(rejected(cause))));
|
|
128
162
|
const claims = yield* Schema.decodeUnknownEffect(KeyListClaims)(payload).pipe(Effect.mapError(rejected));
|
|
129
163
|
return {
|
|
130
164
|
readAt: yield* Clock.currentTimeMillis,
|
|
@@ -132,20 +166,40 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
132
166
|
entries: new Map(claims.keys.map((entry) => [entry.id, entry.sealed])),
|
|
133
167
|
};
|
|
134
168
|
}, Effect.scoped);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Read the key list, unless a read ended within the cooldown: then that read's outcome
|
|
171
|
+
* stands. A list that verified is always written back; a failure is not, over a read that
|
|
172
|
+
* ended after it began. A failure is logged once until a read succeeds again.
|
|
173
|
+
*/
|
|
174
|
+
const readList = Effect.gen(function* () {
|
|
175
|
+
const settled = options.store.keyLists.get(listed);
|
|
176
|
+
const startedAt = yield* Clock.currentTimeMillis;
|
|
177
|
+
if (settled !== undefined && startedAt - settled.readAt < Duration.toMillis(cooldown))
|
|
178
|
+
return settled.failure === undefined
|
|
179
|
+
? undefined
|
|
180
|
+
: yield* new ProviderUnavailable({ operation: settled.failure });
|
|
181
|
+
const read = yield* Effect.result(listDocument());
|
|
182
|
+
const readAt = yield* Clock.currentTimeMillis;
|
|
183
|
+
const before = options.store.keyLists.get(listed);
|
|
184
|
+
const failing = before?.failure !== undefined;
|
|
185
|
+
if (Result.isSuccess(read)) {
|
|
186
|
+
options.store.keyLists.set(listed, { readAt, failure: undefined, held: read.success });
|
|
187
|
+
if (failing)
|
|
188
|
+
yield* Effect.logInfo("clankerauth key list reads have recovered");
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
if ((before?.readAt ?? -Infinity) >= startedAt)
|
|
192
|
+
return yield* read.failure;
|
|
193
|
+
const { operation } = read.failure;
|
|
194
|
+
options.store.keyLists.set(listed, { readAt, failure: operation, held: before?.held });
|
|
195
|
+
if (!failing)
|
|
196
|
+
yield* Effect.logWarning("clankerauth key list reads are failing; a held list decides until it expires", { operation });
|
|
197
|
+
return yield* read.failure;
|
|
198
|
+
});
|
|
143
199
|
/** The list read now, or within the cooldown, or else the held one, while inside its window. */
|
|
144
200
|
const latest = Effect.gen(function* () {
|
|
145
201
|
const fetched = yield* Effect.result(readList);
|
|
146
|
-
|
|
147
|
-
yield* Ref.set(held, fetched.success);
|
|
148
|
-
const list = yield* Ref.get(held);
|
|
202
|
+
const list = options.store.keyLists.get(listed)?.held;
|
|
149
203
|
if (list !== undefined && list.expiresAt > (yield* Clock.currentTimeMillis))
|
|
150
204
|
return list;
|
|
151
205
|
return yield* Result.isFailure(fetched)
|
|
@@ -158,7 +212,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
158
212
|
catch: unauthorized,
|
|
159
213
|
});
|
|
160
214
|
const now = yield* Clock.currentTimeMillis;
|
|
161
|
-
const current =
|
|
215
|
+
const current = options.store.keyLists.get(listed)?.held;
|
|
162
216
|
const list = current !== undefined &&
|
|
163
217
|
now - current.readAt < Duration.toMillis(keyListRefresh) &&
|
|
164
218
|
current.expiresAt > now
|
package/package.json
CHANGED