@gjermundgaraba/clankerauth-sdk 0.11.0 → 0.12.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 CHANGED
@@ -8,16 +8,16 @@ Effect-native [clankerauth](https://github.com/gjermundgaraba/clankerauth) verif
8
8
  vp add @gjermundgaraba/clankerauth-sdk
9
9
  ```
10
10
 
11
- Effect is a peer: the application installs Effect 4, still a release candidate, and the SDK shares that copy.
11
+ Effect is a peer: the application installs Effect 4, and the SDK shares that copy.
12
12
 
13
13
  ```sh
14
- vp add effect@rc
14
+ vp add effect
15
15
  ```
16
16
 
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.8.0
20
+ vp add @gjermundgaraba/effect-actions@^0.9.0
21
21
  ```
22
22
 
23
23
  ## Entry points
@@ -225,7 +225,7 @@ auth.keyLists(); // how many key lists were served
225
225
  await auth.close();
226
226
  ```
227
227
 
228
- `apiKey` takes permissions as the real issuer does, resource identifier to scopes, so one key can reach several resources. A resource server reads its key list at most once a minute, so a test that revokes a key and expects a refusal verifies with a fresh verifier, or advances its clock past that minute. To test against the whole issuer — sign-in, consent, forward auth — use `@gjermundgaraba/clankerauth-dev`.
228
+ `apiKey` takes permissions as the real issuer does, resource identifier to scopes, so one key can reach several resources. A resource server reads its key list once a minute for the keys it names, so a test that revokes a key and expects a refusal verifies with a fresh verifier, or advances its clock past that minute. A key minted after a verifier's read is refused until that read is five seconds old, so a test mints its keys before its first request. To test against the whole issuer — sign-in, consent, forward auth — use `@gjermundgaraba/clankerauth-dev`.
229
229
 
230
230
  ## Errors and observability
231
231
 
@@ -233,7 +233,7 @@ Schema-tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauth
233
233
 
234
234
  There is one 403. A credential that is valid but lacks a scope is `InsufficientScope`; 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.
235
235
 
236
- 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 unauthenticated requests cannot become provider traffic. A failed read, or one whose caller gave up, answers `ProviderUnavailable` (503) for five seconds before the next attempt. 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 for a minute; so disabling, deleting, re-scoping or expiring a key takes effect within about a minute, and a key the list does not hold is `Unauthorized` without further issuer traffic. A failed read, one that takes longer than three seconds, or one whose caller gave up, 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. 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.
236
+ 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, or one whose caller gave up, answers `ProviderUnavailable` (503) for five seconds before the next attempt. 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, one that takes longer than three seconds, or one whose caller gave up, 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. 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.
237
237
 
238
238
  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.
239
239
 
package/dist/refresh.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Duration } from "effect";
2
2
  /**
3
- * How long a key list answers verification before it is read again: the delay after
4
- * which a revoked, disabled, expired or re-scoped key is refused, and how often a watched
3
+ * How long a key list answers the keys it names before it is read again: the delay after
4
+ * which a revoked, disabled or re-scoped key loses what it had, and how often a watched
5
5
  * connection's key is checked again. Internal: not a package entry point.
6
6
  */
7
7
  export declare const keyListRefresh: Duration.Duration;
package/dist/refresh.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Duration } from "effect";
2
2
  /**
3
- * How long a key list answers verification before it is read again: the delay after
4
- * which a revoked, disabled, expired or re-scoped key is refused, and how often a watched
3
+ * How long a key list answers the keys it names before it is read again: the delay after
4
+ * which a revoked, disabled or re-scoped key loses what it had, and how often a watched
5
5
  * connection's key is checked again. Internal: not a package entry point.
6
6
  */
7
7
  export const keyListRefresh = Duration.minutes(1);
package/dist/verify.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Cause, Clock, Effect, Exit, Ref, Result, Schema } from "effect";
1
+ import { Cause, 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";
@@ -29,8 +29,11 @@ const KeyListClaims = KeyList.Claims.pipe(Schema.fieldsAssign({ exp: Schema.Fini
29
29
  const unauthorized = (cause) => new Unauthorized({ message: "Authentication required", cause });
30
30
  /** How long a JWKS document answers verification before it is read again. */
31
31
  const documentLifetime = "1 minute";
32
- /** How long a failed read holds off the next one. */
33
- const failureCooldown = "5 seconds";
32
+ /**
33
+ * How long a read holds off the next one: a failed read of either document, and any read
34
+ * of the key list, so a key the held list does not name reaches the issuer at most this often.
35
+ */
36
+ const cooldown = Duration.seconds(5);
34
37
  /**
35
38
  * How long a key list read may take. Under `Resource`'s five-second deadline, so a stalled
36
39
  * issuer fails the read, not the request, and a held list still decides.
@@ -65,7 +68,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
65
68
  }, Effect.scoped);
66
69
  // One read serves every key identifier until it expires, whatever its outcome, so an
67
70
  // unknown `kid` in an unauthenticated request never becomes traffic at the issuer.
68
- const read = yield* Effect.cachedWithTTL(document(), (exit) => Exit.isSuccess(exit) ? documentLifetime : failureCooldown);
71
+ const read = yield* Effect.cachedWithTTL(document(), (exit) => Exit.isSuccess(exit) ? documentLifetime : cooldown);
69
72
  // Replaying a read must not hand a later request the interruption of an earlier
70
73
  // one's deadline; within the window the issuer simply was not reached.
71
74
  const replayed = (cached, operation) => Effect.catchCause(cached, (cause) => Cause.hasInterrupts(cause)
@@ -137,6 +140,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
137
140
  const payload = yield* verifySigned(list, KeyList.type).pipe(Effect.catchTag("Unauthorized", (cause) => Effect.fail(rejected(cause))));
138
141
  const claims = yield* Schema.decodeUnknownEffect(KeyListClaims)(payload).pipe(Effect.mapError(rejected));
139
142
  return {
143
+ readAt: yield* Clock.currentTimeMillis,
140
144
  expiresAt: claims.exp * 1000,
141
145
  entries: new Map(claims.keys.map((entry) => [entry.id, entry.sealed])),
142
146
  };
@@ -149,17 +153,17 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
149
153
  orElse: () => Effect.fail(new ProviderUnavailable({ operation: "key-list.timeout" })),
150
154
  }), Effect.tapError(({ operation }) => Effect.flatMap(Ref.getAndSet(failing, true), (was) => was
151
155
  ? Effect.void
152
- : Effect.logWarning("clankerauth key list reads are failing; a held list decides until it expires", { operation }))), Effect.tap(() => Effect.flatMap(Ref.getAndSet(failing, false), (was) => was ? Effect.logInfo("clankerauth key list reads have recovered") : Effect.void))), (exit) => (Exit.isSuccess(exit) ? keyListRefresh : failureCooldown));
156
+ : Effect.logWarning("clankerauth key list reads are failing; a held list decides until it expires", { operation }))), Effect.tap(() => Effect.flatMap(Ref.getAndSet(failing, false), (was) => was ? Effect.logInfo("clankerauth key list reads have recovered") : Effect.void))), cooldown);
153
157
  // The list is verified once, when it is read. Holding its entries, not the document,
154
158
  // is what lets keys verify through an outage that has already taken JWKS with it.
155
159
  const held = yield* Ref.make(undefined);
156
- /** The last verified list, while it is inside its window. */
157
- const keyList = Effect.gen(function* () {
160
+ /** The list read now, or within the cooldown, or else the held one, while inside its window. */
161
+ const latest = Effect.gen(function* () {
158
162
  const fetched = yield* Effect.result(replayed(readList, "key-list.refresh"));
159
163
  if (Result.isSuccess(fetched))
160
164
  yield* Ref.set(held, fetched.success);
161
165
  const list = yield* Ref.get(held);
162
- // A read is cached past its own check, so the window is checked on every use.
166
+ // A list is held past its own check, so the window is checked on every use.
163
167
  if (list !== undefined && list.expiresAt > (yield* Clock.currentTimeMillis))
164
168
  return list;
165
169
  return yield* Result.isFailure(fetched)
@@ -167,11 +171,22 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
167
171
  : new ProviderUnavailable({ operation: "key-list.expired" });
168
172
  });
169
173
  const verifyKey = Effect.fn("Verifier.apiKey")(function* (key) {
170
- const list = yield* keyList;
171
- const grant = yield* Effect.tryPromise({
174
+ const open = (list) => Effect.tryPromise({
172
175
  try: () => KeyList.open(key, options.resource, list.entries),
173
176
  catch: unauthorized,
174
177
  });
178
+ const now = yield* Clock.currentTimeMillis;
179
+ const current = yield* Ref.get(held);
180
+ // One read decides the keys it names for a minute, without the issuer.
181
+ const list = current !== undefined &&
182
+ now - current.readAt < Duration.toMillis(keyListRefresh) &&
183
+ current.expiresAt > now
184
+ ? current
185
+ : yield* latest;
186
+ // A key it does not name may have been created since, so the list is read again,
187
+ // unless a read answered within the cooldown and there is nothing new to look in.
188
+ const grant = (yield* open(list)) ??
189
+ (yield* Effect.flatMap(latest, (next) => next === list ? Effect.succeed(undefined) : open(next)));
175
190
  // No entry is the same case as a token for another audience: not this resource's.
176
191
  if (grant === undefined)
177
192
  return yield* unauthorized();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.12.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.8.0",
49
+ "@gjermundgaraba/effect-actions": "0.9.0",
50
50
  "@modelcontextprotocol/client": "2.0.0",
51
51
  "@types/node": "26.6.2",
52
- "effect": "4.0.0-rc.118",
52
+ "effect": "4.0.0",
53
53
  "typescript": "7.0.2",
54
54
  "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
55
55
  "vite-plus": "1.0.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@gjermundgaraba/effect-actions": "^0.8.0",
59
- "effect": "^4.0.0-rc.118"
58
+ "@gjermundgaraba/effect-actions": "^0.9.0",
59
+ "effect": "^4.0.0"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "@gjermundgaraba/effect-actions": {