@gjermundgaraba/clankerauth-sdk 0.13.0 → 0.15.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/README.md +6 -4
- package/dist/resource.d.ts +6 -3
- package/dist/resource.js +23 -6
- package/dist/verify.js +6 -11
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ vp add effect
|
|
|
17
17
|
The `/session` and `/effect-actions` entry points also need effect-actions, an optional peer. Install this range: the SDK's declarations are built against it, and under `skipLibCheck` they silently degrade to `any` with another.
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
-
vp add @gjermundgaraba/effect-actions@^0.
|
|
20
|
+
vp add @gjermundgaraba/effect-actions@^0.11.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Entry points
|
|
@@ -56,6 +56,8 @@ Register **one** resource in the clankerauth dashboard, identified by the applic
|
|
|
56
56
|
|
|
57
57
|
A declared resource's `layer` takes the public URL and derives that identifier itself, so the application never writes it twice and never writes a form an MCP client would rewrite. Any path on the URL is discarded. The official client's own `checkResourceAllowed` accepts an origin-root resource for an endpoint beneath it.
|
|
58
58
|
|
|
59
|
+
The public URL is where the listener that publishes discovery is reached, because an OAuth client refuses metadata naming another resource than the one it reached (RFC 9728). API keys never read discovery, so they keep working whatever the URL says; only OAuth logins fail. The resource therefore logs one warning the first time a request reaches its publishing listener at another host. A proxy that rewrites the host makes that warning harmless; anything else means the URL is wrong.
|
|
60
|
+
|
|
59
61
|
Contracts state who may call them, `caller: CurrentPrincipal`, and a binding names how a caller proves it: an effect-actions authentication descriptor, declared once beside the contracts, where a browser client imports it too. The descriptor also declares what its verifier fails with besides a refusal: `ProviderUnavailable`, the issuer being unreachable.
|
|
60
62
|
|
|
61
63
|
```ts
|
|
@@ -111,9 +113,9 @@ const routes = Layer.mergeAll(
|
|
|
111
113
|
|
|
112
114
|
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`.
|
|
113
115
|
|
|
114
|
-
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`.
|
|
116
|
+
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.
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
Two listeners with different scopes, such as that admin and machine pair, are still one resource in the dashboard, with every scope of both. The process declares two descriptors and two resources over it, one per listener, each requiring its own scope and given the same `layer` options but `discovery`: `Resource.make(Authentication.make("notes.admin", CurrentPrincipal, { error: ProviderUnavailable }), { scopes: ["notes:read", "notes:write"], required: "notes:read" })` and one for `"notes.machine"`, each named by its own binding. Each is a service of its own, so each surface reads its own resource, in one layer graph or several. The descriptor's name is the identity: declaring one name twice declares the same resource twice.
|
|
117
119
|
|
|
118
120
|
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.
|
|
119
121
|
|
|
@@ -296,7 +298,7 @@ Tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauthorized`
|
|
|
296
298
|
|
|
297
299
|
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.
|
|
298
300
|
|
|
299
|
-
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
|
|
301
|
+
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 every waiting caller gave up on is dropped, 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 that 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 every waiting caller gave up on is dropped, 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's own three seconds: shorter than that deadline, so an issuer that stops answering delays a key by at most that long, never fails it while a list is held.
|
|
300
302
|
|
|
301
303
|
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.
|
|
302
304
|
|
package/dist/resource.d.ts
CHANGED
|
@@ -20,8 +20,10 @@ export declare const authenticate: (verifier: Verifier.Verifier, token: Redacted
|
|
|
20
20
|
export interface Options {
|
|
21
21
|
readonly issuer: string;
|
|
22
22
|
/**
|
|
23
|
-
* The origin browsers and agents reach this application at
|
|
24
|
-
* root, so any path here is discarded: one identifier
|
|
23
|
+
* The origin browsers and agents reach this application at: the listener that publishes
|
|
24
|
+
* discovery. The resource is its origin root, so any path here is discarded: one identifier
|
|
25
|
+
* covers `/api`, `/mcp` and sockets. A request reaching that listener at another host is
|
|
26
|
+
* logged once as a warning, since an OAuth client discovering the resource there refuses it.
|
|
25
27
|
*/
|
|
26
28
|
readonly publicUrl: URL;
|
|
27
29
|
/** `false` refuses API keys without reading the issuer's key list; only OAuth access tokens are accepted. */
|
|
@@ -29,7 +31,8 @@ export interface Options {
|
|
|
29
31
|
/**
|
|
30
32
|
* `false` publishes no RFC 9728 discovery and names none in a challenge: for a surface
|
|
31
33
|
* whose callers hold API keys and never run an OAuth flow, such as a second listener
|
|
32
|
-
* beside the one that publishes the resource.
|
|
34
|
+
* beside the one that publishes the resource. That listener still names the same resource,
|
|
35
|
+
* so it takes the publishing listener's `publicUrl`, not its own.
|
|
33
36
|
*/
|
|
34
37
|
readonly discovery?: boolean;
|
|
35
38
|
}
|
package/dist/resource.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Clock, Context, Duration, Effect, Layer, Option, Redacted, Result } from "effect";
|
|
1
|
+
import { Clock, Context, Duration, Effect, Layer, Option, Redacted, Ref, Result } from "effect";
|
|
2
2
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
3
|
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
4
|
-
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
4
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
5
5
|
import { InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.js";
|
|
6
6
|
import { keyListRefresh } from "./refresh.js";
|
|
7
7
|
import { CurrentPrincipal, Whoami } from "./session.js";
|
|
@@ -63,9 +63,9 @@ const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, r
|
|
|
63
63
|
requiredScopes: [required],
|
|
64
64
|
apiKeys: options.apiKeys,
|
|
65
65
|
});
|
|
66
|
-
// A request must not wait on an issuer that does not answer: the
|
|
67
|
-
//
|
|
68
|
-
// sooner, so a held list still decides a key.
|
|
66
|
+
// A request must not wait on an issuer that does not answer: the credential is refused
|
|
67
|
+
// as unavailable, and a read no other request waits on is interrupted. A key list read
|
|
68
|
+
// gives up sooner, so a held list still decides a key.
|
|
69
69
|
const deadline = (effect) => Effect.timeoutOrElse(effect, {
|
|
70
70
|
duration: "5 seconds",
|
|
71
71
|
orElse: () => Effect.fail(new ProviderUnavailable({ operation: "verify.timeout" })),
|
|
@@ -160,7 +160,24 @@ export const make = (authentication, declaration) => {
|
|
|
160
160
|
// `E` declares `ProviderUnavailable`, which a generic `E` cannot show the types by itself.
|
|
161
161
|
const declaring = authentication;
|
|
162
162
|
// The descriptor's verifier: a token to its principal, a refusal, or `ProviderUnavailable`.
|
|
163
|
-
const provider = Authentication.layer(declaring, Effect.
|
|
163
|
+
const provider = Authentication.layer(declaring, Effect.gen(function* () {
|
|
164
|
+
const { verifier, resource, protectedResource } = yield* service;
|
|
165
|
+
const host = new URL(resource).host;
|
|
166
|
+
const warned = yield* Ref.make(protectedResource === undefined);
|
|
167
|
+
// RFC 9728 has a client refuse metadata naming another resource than the one it
|
|
168
|
+
// reached, so a listener publishing discovery at another host fails every OAuth login
|
|
169
|
+
// while keys keep working. A proxy may rewrite the host, so this warns and refuses nothing.
|
|
170
|
+
const noteHost = Effect.gen(function* () {
|
|
171
|
+
if (yield* Ref.get(warned))
|
|
172
|
+
return;
|
|
173
|
+
const reached = (yield* HttpServerRequest.HttpServerRequest).headers.host;
|
|
174
|
+
if (reached === undefined || reached === host)
|
|
175
|
+
return;
|
|
176
|
+
yield* Ref.set(warned, true);
|
|
177
|
+
yield* Effect.logWarning(`The resource ${resource} publishes discovery, but a request reached it at ${reached}: an OAuth client discovering it there refuses the mismatch. Set publicUrl to the origin this listener is reached at.`);
|
|
178
|
+
});
|
|
179
|
+
return (token) => Effect.andThen(noteHost, authenticate(verifier, token));
|
|
180
|
+
}), { protectedResource: Effect.map(service, ({ protectedResource }) => protectedResource) });
|
|
164
181
|
/**
|
|
165
182
|
* Refuse a caller that lacks `scope`, as the application's authorizer decides:
|
|
166
183
|
* `Action.implement(actions, handlers, { authorize: (action) => action.readOnly ? Effect.void : Notes.requires("notes:write") })`.
|
package/dist/verify.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Clock, Duration, Effect, Exit, Ref, 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";
|
|
@@ -66,15 +66,10 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
66
66
|
keys: jwks.keys.map(({ key_ops, ...key }) => key_ops ? { ...key, key_ops: [...key_ops] } : key),
|
|
67
67
|
});
|
|
68
68
|
}, Effect.scoped);
|
|
69
|
-
// One read serves every key identifier until it expires,
|
|
70
|
-
// unknown `kid` in an unauthenticated request never becomes traffic at
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// one's deadline; within the window the issuer simply was not reached.
|
|
74
|
-
const replayed = (cached, operation) => Effect.catchCause(cached, (cause) => Cause.hasInterrupts(cause)
|
|
75
|
-
? Effect.fail(new ProviderUnavailable({ operation }))
|
|
76
|
-
: Effect.failCause(cause));
|
|
77
|
-
const keys = replayed(read, "jwks.refresh");
|
|
69
|
+
// One read serves every key identifier until it expires, or for the cooldown if it
|
|
70
|
+
// failed, so an unknown `kid` in an unauthenticated request never becomes traffic at
|
|
71
|
+
// the issuer. A read every caller gave up on is dropped, and the next caller reads again.
|
|
72
|
+
const keys = yield* Effect.cachedWithTTL(document(), (exit) => Exit.isSuccess(exit) ? documentLifetime : cooldown);
|
|
78
73
|
const signingKey = Effect.fn("Verifier.signingKey")(function* (kid) {
|
|
79
74
|
const resolve = yield* keys;
|
|
80
75
|
return yield* Effect.tryPromise({
|
|
@@ -159,7 +154,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
159
154
|
const held = yield* Ref.make(undefined);
|
|
160
155
|
/** The list read now, or within the cooldown, or else the held one, while inside its window. */
|
|
161
156
|
const latest = Effect.gen(function* () {
|
|
162
|
-
const fetched = yield* Effect.result(
|
|
157
|
+
const fetched = yield* Effect.result(readList);
|
|
163
158
|
if (Result.isSuccess(fetched))
|
|
164
159
|
yield* Ref.set(held, fetched.success);
|
|
165
160
|
const list = yield* Ref.get(held);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerauth-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Effect-native clankerauth access-token and offline API-key verification, with optional effect-actions integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,17 +46,17 @@
|
|
|
46
46
|
"jose": "6.2.12"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@gjermundgaraba/effect-actions": "0.
|
|
50
|
-
"@modelcontextprotocol/client": "2.
|
|
51
|
-
"@types/node": "26.6.
|
|
52
|
-
"effect": "4.0.
|
|
49
|
+
"@gjermundgaraba/effect-actions": "0.11.0",
|
|
50
|
+
"@modelcontextprotocol/client": "2.3.1",
|
|
51
|
+
"@types/node": "26.6.4",
|
|
52
|
+
"effect": "4.0.2",
|
|
53
53
|
"typescript": "7.0.2",
|
|
54
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
55
|
-
"vite-plus": "1.
|
|
54
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
55
|
+
"vite-plus": "1.1.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@gjermundgaraba/effect-actions": "^0.
|
|
59
|
-
"effect": "^4.0.
|
|
58
|
+
"@gjermundgaraba/effect-actions": "^0.11.0",
|
|
59
|
+
"effect": "^4.0.2"
|
|
60
60
|
},
|
|
61
61
|
"peerDependenciesMeta": {
|
|
62
62
|
"@gjermundgaraba/effect-actions": {
|