@gjermundgaraba/clankerauth-sdk 0.26.0 → 0.26.2

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
@@ -340,9 +340,9 @@ Tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauthorized`
340
340
 
341
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.
342
342
 
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 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.
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 runs its two fetches together, the list and the JWKS it is verified with when none is held, and gives each three seconds, so a slow fetch does not cut the other short. A key list read therefore ends within three seconds, whatever stalls, and never fails a key while a list is held.
344
344
 
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.
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. The same holds on a read with no JWKS held: its JWKS fetch runs beside the list's, so a list signed by a key minted after that fetch began fails verification until the next JWKS read. The bundled issuer mints its signing key when it starts, so a fresh issuer's first list is signed by the key its JWKS publishes. 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.
346
346
 
347
347
  The SDK's resource maps verification failures to effect-actions' refusals and its own 503; effect-actions supplies request-scoped identity, challenge headers and `Cache-Control: no-store` on every response to a request it authenticates; other cache policy is the host's. Defects and interruption are not relabeled as authentication rejection. Named effects supply tracing boundaries; application logging/tracing layers remain caller-owned. No `onFailure` callbacks or hidden runtime.
348
348
 
package/dist/verify.d.ts CHANGED
@@ -85,7 +85,8 @@ export declare const store: () => Store;
85
85
  * request costs nothing: every read it makes runs in the verification that needs it.
86
86
  * Verification runs to completion: a deadline is the request edge's policy, which
87
87
  * `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
88
- * The one exception is a key list read, which gives up after three seconds.
88
+ * The one exception is a key list read, which runs its fetches together and gives up on each
89
+ * after three seconds.
89
90
  */
90
91
  export declare const make: (options: Options) => Effect.Effect<{
91
92
  verifyToken: (token: string) => Effect.Effect<{
package/dist/verify.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Clock, Duration, Effect, Result, Schema } from "effect";
1
+ import { Clock, Duration, Effect, Fiber, 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";
@@ -37,17 +37,23 @@ const documentLifetime = Duration.minutes(1);
37
37
  */
38
38
  const cooldown = Duration.seconds(5);
39
39
  /**
40
- * How long a key list read may take. Under `Resource`'s five-second deadline, so a stalled
41
- * issuer fails the read, not the request, and a held list still decides.
40
+ * How long each fetch of a key list read may take, run together: the list, and the JWKS it
41
+ * is verified with when none is held. Under `Resource`'s five-second deadline, so an issuer
42
+ * that stops answering either fails the read, not the request, and a held list still decides.
42
43
  */
43
- const listReadTimeout = "3 seconds";
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
+ });
44
49
  /**
45
50
  * A verifier over `options.store`; transport is selected by the application, not the SDK.
46
51
  * It holds nothing but its options, the store and the HTTP client, so acquiring one per
47
52
  * request costs nothing: every read it makes runs in the verification that needs it.
48
53
  * Verification runs to completion: a deadline is the request edge's policy, which
49
54
  * `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
50
- * The one exception is a key list read, which gives up after three seconds.
55
+ * The one exception is a key list read, which runs its fetches together and gives up on each
56
+ * after three seconds.
51
57
  */
52
58
  export const make = Effect.fn("Verifier.make")(function* (options) {
53
59
  const client = yield* HttpClient.HttpClient;
@@ -72,26 +78,28 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
72
78
  /** The keys read within their lifetime, a failed read's failure within its cooldown, or a read now. */
73
79
  const keys = Effect.gen(function* () {
74
80
  const settled = options.store.jwks.get(options.issuer);
75
- const now = yield* Clock.currentTimeMillis;
81
+ const startedAt = yield* Clock.currentTimeMillis;
76
82
  if (settled !== undefined) {
77
83
  const answers = "keys" in settled ? documentLifetime : cooldown;
78
- if (now - settled.readAt < Duration.toMillis(answers))
84
+ if (startedAt - settled.readAt < Duration.toMillis(answers))
79
85
  return "keys" in settled
80
86
  ? settled.keys
81
87
  : yield* new ProviderUnavailable({ operation: settled.failure });
82
88
  }
83
89
  const read = yield* Effect.result(document());
84
90
  const readAt = yield* Clock.currentTimeMillis;
85
- // why: a failure never replaces an outcome that ended after it began; a success always writes.
86
- if (Result.isSuccess(read) ||
87
- (options.store.jwks.get(options.issuer)?.readAt ?? -Infinity) < now)
88
- options.store.jwks.set(options.issuer, Result.isSuccess(read)
89
- ? { readAt, keys: read.success }
90
- : { readAt, failure: read.failure.operation });
91
- return Result.isSuccess(read) ? read.success : yield* read.failure;
91
+ if (Result.isSuccess(read)) {
92
+ options.store.jwks.set(options.issuer, { readAt, keys: read.success });
93
+ return read.success;
94
+ }
95
+ // why: a failure never replaces an outcome that ended after it began.
96
+ if ((options.store.jwks.get(options.issuer)?.readAt ?? -Infinity) >= startedAt)
97
+ return yield* read.failure;
98
+ options.store.jwks.set(options.issuer, { readAt, failure: read.failure.operation });
99
+ return yield* read.failure;
92
100
  });
93
- const signingKey = Effect.fn("Verifier.signingKey")(function* (kid) {
94
- const jwks = { keys: [...(yield* keys)] };
101
+ const signingKey = Effect.fn("Verifier.signingKey")(function* (kid, held) {
102
+ const jwks = { keys: [...(yield* held)] };
95
103
  return yield* Effect.tryPromise({
96
104
  try: () => createLocalJWKSet(jwks)({ alg: "EdDSA", kid }),
97
105
  catch: unauthorized,
@@ -101,9 +109,9 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
101
109
  * A document this issuer signed for this resource: EdDSA under a key it names, with the
102
110
  * expected `typ`, issuer and audience, and not expired. Which claims it must carry is
103
111
  * its schema's to say. `Unauthorized` means it is not to be trusted; what that amounts
104
- * to is the caller's to say.
112
+ * to is the caller's to say. Its signing keys are `held`'s, the store's JWKS by default.
105
113
  */
106
- const verifySigned = Effect.fn("Verifier.signed")(function* (jws, typ) {
114
+ const verifySigned = Effect.fn("Verifier.signed")(function* (jws, typ, held = keys) {
107
115
  const header = yield* Effect.try({
108
116
  try: () => decodeProtectedHeader(jws),
109
117
  catch: unauthorized,
@@ -111,7 +119,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
111
119
  // why: refused before any JWKS read, so an unauthenticated request causes no issuer traffic.
112
120
  if (header.alg !== "EdDSA" || header.kid === undefined)
113
121
  return yield* unauthorized();
114
- const key = yield* signingKey(header.kid);
122
+ const key = yield* signingKey(header.kid, held);
115
123
  const now = yield* Clock.currentTimeMillis;
116
124
  const { payload } = yield* Effect.tryPromise({
117
125
  try: () => jwtVerify(jws, key, {
@@ -139,15 +147,20 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
139
147
  });
140
148
  const listDocument = Effect.fn("Verifier.keyList")(function* () {
141
149
  const request = HttpClientRequest.post(endpoints.keyList).pipe(HttpClientRequest.bodyJsonUnsafe({ resource: options.resource }));
142
- const response = yield* execute(client, request);
143
- if (response.status !== 200)
144
- return yield* new ProviderUnavailable({
145
- operation: "key-list.fetch",
146
- cause: { status: response.status },
147
- });
148
- const { list } = yield* HttpClientResponse.schemaBodyJson(KeyListResponse)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "key-list.decode", cause })));
150
+ const fetched = Effect.gen(function* () {
151
+ const response = yield* execute(client, request);
152
+ if (response.status !== 200)
153
+ return yield* new ProviderUnavailable({
154
+ operation: "key-list.fetch",
155
+ cause: { status: response.status },
156
+ });
157
+ return yield* HttpClientResponse.schemaBodyJson(KeyListResponse)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "key-list.decode", cause })));
158
+ });
159
+ // why: the JWKS read runs beside the list's, on its own deadline, so neither spends the other's.
160
+ const signing = yield* Effect.forkScoped(keys.pipe(deadline("jwks.timeout")));
161
+ const { list } = yield* fetched.pipe(deadline("key-list.timeout"));
149
162
  const rejected = (cause) => new ProviderUnavailable({ operation: "key-list.verify", cause });
150
- const payload = yield* verifySigned(list, KeyList.type).pipe(Effect.catchTag("Unauthorized", (cause) => Effect.fail(rejected(cause))));
163
+ const payload = yield* verifySigned(list, KeyList.type, Fiber.join(signing)).pipe(Effect.catchTag("Unauthorized", (cause) => Effect.fail(rejected(cause))));
151
164
  const claims = yield* Schema.decodeUnknownEffect(KeyListClaims)(payload).pipe(Effect.mapError(rejected));
152
165
  return {
153
166
  readAt: yield* Clock.currentTimeMillis,
@@ -167,10 +180,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
167
180
  return settled.failure === undefined
168
181
  ? undefined
169
182
  : yield* new ProviderUnavailable({ operation: settled.failure });
170
- const read = yield* Effect.result(listDocument().pipe(Effect.timeoutOrElse({
171
- duration: listReadTimeout,
172
- orElse: () => Effect.fail(new ProviderUnavailable({ operation: "key-list.timeout" })),
173
- })));
183
+ const read = yield* Effect.result(listDocument());
174
184
  const readAt = yield* Clock.currentTimeMillis;
175
185
  const before = options.store.keyLists.get(listed);
176
186
  const failing = before?.failure !== undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
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": {