@gjermundgaraba/clankerauth-sdk 0.7.1 → 0.7.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
@@ -69,20 +69,17 @@ const routes = Layer.unwrap(
69
69
 
70
70
  return Layer.mergeAll(
71
71
  notes.discovery.layer,
72
- Http.layer({ before: notes.authorize }, app, notes.session).pipe(
72
+ Http.layer([app, notes.session], { before: notes.authorize }).pipe(
73
73
  Layer.provide(Resource.middleware(notes).layer),
74
74
  ),
75
- ActionMcp.layerHttp(
76
- {
77
- name: "notes",
78
- version: "1.0.0",
79
- path: "/mcp",
80
- protocols: [McpProtocol.v2026_07_28],
81
- errors: authenticationErrors,
82
- before: notes.authorize,
83
- },
84
- app,
85
- ).pipe(Layer.provide(Resource.middleware(notes).layer)),
75
+ ActionMcp.layerHttp([app], {
76
+ name: "notes",
77
+ version: "1.0.0",
78
+ path: "/mcp",
79
+ protocols: [McpProtocol.v2026_07_28],
80
+ errors: authenticationErrors,
81
+ before: notes.authorize,
82
+ }).pipe(Layer.provide(Resource.middleware(notes).layer)),
86
83
  );
87
84
  }),
88
85
  ).pipe(Layer.provide(FetchHttpClient.layer));
@@ -94,7 +91,7 @@ The supplied `HttpClient` must not retry credential exchanges or follow redirect
94
91
 
95
92
  ## Scope enforcement
96
93
 
97
- An application names its scopes once and writes no authorization code. Each action declares what it does to the resource, and `resource.authorize` is the surface's pre-handler hook: it runs before every handler on every surface — HTTP, MCP, Toolkit, CLI — so no handler can forget it.
94
+ An application names its scopes once and writes no authorization code. Each action declares what it does to the resource, and `resource.authorize` is the surface's pre-handler hook: it runs after the input is decoded and before every handler on every surface — HTTP, MCP, Toolkit, CLI — so no handler can forget it.
98
95
 
99
96
  ```ts
100
97
  import * as Action from "@gjermundgaraba/effect-actions/Action";
@@ -114,7 +111,7 @@ const Notes = ActionGroup.make(
114
111
  const Http = ActionHttp.make({ apiPath: "/api", errors: authenticationErrors }, Notes, Session);
115
112
 
116
113
  // And bound once per surface, so no handler and no group can forget it.
117
- const routes = Http.layer({ before: notes.authorize }, Notes.implement(handlers), notes.session);
114
+ const routes = Http.layer([Notes.implement(handlers), notes.session], { before: notes.authorize });
118
115
  ```
119
116
 
120
117
  `scopes.read` is required at verification, so a read needs nothing further. `scopes.write` is what an `access: "write"` action additionally needs; leave it out and every action passes, which is how a single-scope application is expressed. `InsufficientScope` is a 403 naming **only** the missing scope, so a refusal never enumerates the resource's permissions.
@@ -123,7 +120,7 @@ A forward-auth browser token carries **every** scope the resource defines, becau
123
120
 
124
121
  An action reads identity from `CurrentPrincipal`, which contains `subject`, `scopes`, `actor` — either `{ kind: "client", clientId }` or `{ kind: "key", keyId }` — and `expiresAt`: an access token's verified `exp` in epoch milliseconds, so a host bounding a connection to its credential never decodes the token again. It is `undefined` for an API key, which carries no token lifetime and is re-verified on every request; choose your own bound for those.
125
122
 
126
- A hook refusal is a plain declared error: effect-actions encodes it with the schema's status and `Cache-Control: no-store`, and adds no `WWW-Authenticate`. Challenge headers come from admission — `Resource.middleware` and `admit` — which is what a client onboarding through RFC 9728 reads. No current client reads the header on a scope refusal, because an MCP denial is a tool error.
123
+ A hook refusal is a plain declared error: effect-actions encodes it with the schema's status and adds no headers of its own; `Cache-Control: no-store` comes from `Resource.middleware`, which wraps every route it authenticates. Challenge headers come from admission — `Resource.middleware` and `admit` — which is what a client onboarding through RFC 9728 reads. No current client reads the header on a scope refusal, because an MCP denial is a tool error.
127
124
 
128
125
  Middleware authenticates the request and the hook authorizes it; neither filters MCP tool discovery.
129
126
 
@@ -195,11 +192,11 @@ Schema-tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauth
195
192
 
196
193
  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 grant here, which the issuer answers `403` for — is `Unauthorized`, because from the resource's side those are the same thing.
197
194
 
198
- JWTs are checked against issuer, audience, EdDSA signature, token type, required claims, expiry and scopes. Sender-constrained tokens are rejected. JWKS lookups are cached for ten minutes. Unknown keys trigger one refresh and resolution retry, with a thirty-second cooldown to bound provider traffic. Failed miss-triggered refreshes also cool down and return 503; still-valid cached keys remain usable. Initial lookup failures are not retained. Removed keys can remain trusted until cache expiry. API keys are checked online on **every request**, so revocation is effective immediately. A resource that accepts only OAuth access tokens sets `apiKeys: false`; key-shaped bearers then fail as `Unauthorized` without contacting the issuer. Verification has a five-second deadline.
195
+ 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 checked online on **every request**, so revocation is effective immediately. A resource that accepts only OAuth access tokens sets `apiKeys: false`; key-shaped bearers then fail as `Unauthorized` without contacting the issuer. 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.
199
196
 
200
- The bundled issuer does not configure automatic signing-key rotation. Immediate-use rotation is supported, but tokens signed by a new key can be rejected during the thirty-second cooldown after a successful lookup. Publish-before-use avoids that short window; it is not mandatory.
197
+ 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. Publish-before-use avoids that window; it is not mandatory.
201
198
 
202
- The SDK Resource adapter owns authentication error encoding and challenge headers; effect-actions supplies request-scoped identity and the no-store response policy. 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.
199
+ The SDK Resource adapter owns authentication error encoding and challenge headers; effect-actions supplies request-scoped identity, and its authentication middleware marks every response it answers or wraps `Cache-Control: no-store`; 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.
203
200
 
204
201
  Operational failures retain their underlying `cause` for application-side Effect error handling. `ProviderUnavailable` and `Unauthorized` keep this diagnostic field outside their public schemas; effect-actions serializes only those schemas. Causes may contain sensitive transport or provider details: inspect selectively, never serialize them into responses or log them indiscriminately.
205
202
 
@@ -1,7 +1,7 @@
1
1
  import { Context, Effect, Schema } from "effect";
2
2
  import type * as Action from "@gjermundgaraba/effect-actions/Action";
3
3
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
- import { ConfigurationError, InsufficientScope, RateLimited, Unauthorized } from "./errors.ts";
4
+ import { ConfigurationError, InsufficientScope } from "./errors.ts";
5
5
  import * as Verifier from "./verify.ts";
6
6
  declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Verifier.Principal>;
7
7
  export declare class CurrentPrincipal extends CurrentPrincipal_base {
@@ -46,42 +46,7 @@ export type Admission = {
46
46
  };
47
47
  /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per application. */
48
48
  export declare const make: (options: Options) => Effect.Effect<{
49
- verifier: {
50
- verifyToken: (token: string) => Effect.Effect<{
51
- subject: string;
52
- scopes: string[];
53
- actor: {
54
- kind: "client";
55
- clientId: string;
56
- };
57
- expiresAt: number;
58
- } | {
59
- subject: string;
60
- scopes: readonly string[];
61
- actor: {
62
- kind: "key";
63
- keyId: string;
64
- };
65
- expiresAt: undefined;
66
- }, InsufficientScope | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
67
- verify: (authorization: string | null | undefined) => Effect.Effect<{
68
- subject: string;
69
- scopes: string[];
70
- actor: {
71
- kind: "client";
72
- clientId: string;
73
- };
74
- expiresAt: number;
75
- } | {
76
- subject: string;
77
- scopes: readonly string[];
78
- actor: {
79
- kind: "key";
80
- keyId: string;
81
- };
82
- expiresAt: undefined;
83
- }, InsufficientScope | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
84
- };
49
+ verifier: Verifier.Verifier;
85
50
  discovery: {
86
51
  layer: import("effect/Layer").Layer<never, never, import("effect/unstable/http/HttpRouter").HttpRouter>;
87
52
  metadataUrl: string;
package/dist/resource.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Context, Effect, Result, Schema, SchemaAST } from "effect";
2
2
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
3
  import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4
- import { authenticationErrors, ConfigurationError, InsufficientScope, RateLimited, Unauthorized, } from "./errors.js";
4
+ import { authenticationErrors, ConfigurationError, InsufficientScope, ProviderUnavailable, RateLimited, Unauthorized, } from "./errors.js";
5
5
  import { Session } from "./session.js";
6
6
  import * as Verifier from "./verify.js";
7
7
  const encoders = new Map(authenticationErrors.map((schema) => [
@@ -41,12 +41,22 @@ export const make = Effect.fn("Resource.make")(function* (options) {
41
41
  }),
42
42
  catch: () => new ConfigurationError({ message: "Invalid resource metadata configuration" }),
43
43
  });
44
- const verifier = yield* Verifier.make({
44
+ const unbounded = yield* Verifier.make({
45
45
  issuer: options.issuer,
46
46
  resource: identifier,
47
47
  requiredScopes: [options.scopes.read],
48
48
  apiKeys: options.apiKeys,
49
49
  });
50
+ // A request must not wait on an issuer that does not answer: the transport is
51
+ // interrupted and the credential refused as unavailable.
52
+ const deadline = (effect) => Effect.timeoutOrElse(effect, {
53
+ duration: "5 seconds",
54
+ orElse: () => Effect.fail(new ProviderUnavailable({ operation: "verify.timeout" })),
55
+ });
56
+ const verifier = {
57
+ verify: (authorization) => deadline(unbounded.verify(authorization)),
58
+ verifyToken: (token) => deadline(unbounded.verifyToken(token)),
59
+ };
50
60
  const challengeValue = (error, missing) => discovery.challenge({ error, scope: missing ?? options.scopes.read });
51
61
  /**
52
62
  * The one header besides the content type that a refusal carries: an RFC 6750
package/dist/session.d.ts CHANGED
@@ -15,7 +15,7 @@ export declare const Principal: Schema.Struct<{
15
15
  export type Principal = typeof Principal.Type;
16
16
  /**
17
17
  * The credential is a transport concern: a process surface has none, so `whoami` is its
18
- * own group and never an MCP tool. Bind it with `Http.layer(app, resource.session)`.
18
+ * own group and never an MCP tool. Bind it with `Http.layer([app, resource.session])`.
19
19
  */
20
20
  export declare const Session: ActionGroup.Group<"session", readonly [Action.Action<"whoami", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{
21
21
  readonly subject: Schema.NonEmptyString;
package/dist/session.js CHANGED
@@ -14,7 +14,7 @@ export const Principal = Schema.Struct({
14
14
  });
15
15
  /**
16
16
  * The credential is a transport concern: a process surface has none, so `whoami` is its
17
- * own group and never an MCP tool. Bind it with `Http.layer(app, resource.session)`.
17
+ * own group and never an MCP tool. Bind it with `Http.layer([app, resource.session])`.
18
18
  */
19
19
  export const Session = ActionGroup.make({ name: "session" }, Action.make("whoami", {
20
20
  description: "The verified subject, issuer and scopes behind this request's credential.",
package/dist/verify.d.ts CHANGED
@@ -31,7 +31,11 @@ export interface Verifier {
31
31
  readonly verify: (authorization: string | null | undefined) => Effect.Effect<Principal, AuthenticationError>;
32
32
  readonly verifyToken: (token: string) => Effect.Effect<Principal, AuthenticationError>;
33
33
  }
34
- /** Acquire once per resource; transport is selected by the application, not the SDK. */
34
+ /**
35
+ * Acquire once per resource; transport is selected by the application, not the SDK.
36
+ * Verification runs to completion: a deadline is the request edge's policy, which
37
+ * `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
38
+ */
35
39
  export declare const make: (options: Options) => Effect.Effect<{
36
40
  verifyToken: (token: string) => Effect.Effect<{
37
41
  subject: string;
package/dist/verify.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Clock, Effect, Exit, Schema, Semaphore } from "effect";
1
+ import { Cause, Clock, DateTime, Effect, Exit, Schema } from "effect";
2
2
  import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
4
  import { ConfigurationError, InsufficientScope, ProviderUnavailable, RateLimited, Unauthorized, } from "./errors.js";
@@ -25,10 +25,18 @@ const KeyResponse = Schema.Struct({
25
25
  ownerId: Schema.NonEmptyString,
26
26
  resource: Schema.String,
27
27
  scopes: Schema.Array(Schema.NonEmptyString),
28
- expiresAt: Schema.NullOr(Schema.String),
28
+ expiresAt: Schema.NullOr(Schema.DateTimeUtcFromString),
29
29
  });
30
30
  const unauthorized = () => new Unauthorized({ message: "Authentication required" });
31
- /** Acquire once per resource; transport is selected by the application, not the SDK. */
31
+ /** How long a JWKS document answers verification before it is read again. */
32
+ const documentLifetime = "1 minute";
33
+ /** How long a failed read holds off the next one. */
34
+ const failureCooldown = "5 seconds";
35
+ /**
36
+ * Acquire once per resource; transport is selected by the application, not the SDK.
37
+ * Verification runs to completion: a deadline is the request edge's policy, which
38
+ * `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
39
+ */
32
40
  export const make = Effect.fn("Verifier.make")(function* (options) {
33
41
  const client = yield* HttpClient.HttpClient;
34
42
  const endpoints = yield* Effect.try({
@@ -38,49 +46,33 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
38
46
  }),
39
47
  catch: () => new ConfigurationError({ message: "Invalid issuer URL" }),
40
48
  });
41
- const refreshLock = yield* Semaphore.make(1);
42
- let refreshAfter = 0;
43
- let refreshFailure;
44
- const makeCachedKeys = Effect.cachedWithTTL(Effect.gen(function* () {
49
+ const document = Effect.fn("Verifier.jwks")(function* () {
45
50
  const response = yield* execute(client, HttpClientRequest.get(endpoints.jwks));
46
51
  if (response.status !== 200)
47
52
  return yield* new ProviderUnavailable({
48
53
  operation: "jwks.fetch",
49
54
  cause: { status: response.status },
50
55
  });
51
- const document = yield* HttpClientResponse.schemaBodyJson(Jwks)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "jwks.decode", cause })));
52
- refreshAfter = (yield* Clock.currentTimeMillis) + 30_000;
53
- refreshFailure = undefined;
56
+ const jwks = yield* HttpClientResponse.schemaBodyJson(Jwks)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "jwks.decode", cause })));
54
57
  return createLocalJWKSet({
55
- keys: document.keys.map(({ key_ops, ...key }) => key_ops ? { ...key, key_ops: [...key_ops] } : key),
58
+ keys: jwks.keys.map(({ key_ops, ...key }) => key_ops ? { ...key, key_ops: [...key_ops] } : key),
56
59
  });
57
- }).pipe(Effect.scoped), (exit) => (Exit.isSuccess(exit) ? "10 minutes" : 0));
58
- let keys = yield* makeCachedKeys;
59
- const resolveKey = (resolve, header) => Effect.tryPromise({
60
- try: () => resolve(header),
61
- // Selecting from fetched keys does no I/O: every failure is the credential's.
62
- catch: unauthorized,
63
- });
64
- const signingKey = Effect.fn("Verifier.signingKey")(function* (header) {
60
+ }, Effect.scoped);
61
+ // One read serves every key identifier until it expires, whatever its outcome, so an
62
+ // unknown `kid` in an unauthenticated request never becomes traffic at the issuer.
63
+ const read = yield* Effect.cachedWithTTL(document(), (exit) => Exit.isSuccess(exit) ? documentLifetime : failureCooldown);
64
+ // Replaying that read must not hand a later request the interruption of an earlier
65
+ // one's deadline; within the window the issuer simply was not reached.
66
+ const keys = Effect.catchCause(read, (cause) => Cause.hasInterrupts(cause)
67
+ ? Effect.fail(new ProviderUnavailable({ operation: "jwks.refresh" }))
68
+ : Effect.failCause(cause));
69
+ const signingKey = Effect.fn("Verifier.signingKey")(function* (kid) {
65
70
  const resolve = yield* keys;
66
- return yield* resolveKey(resolve, header).pipe(Effect.catchTag("Unauthorized", () => refreshLock.withPermit(Effect.gen(function* () {
67
- const current = yield* keys;
68
- // Another request may have refreshed while this one waited for admission.
69
- if (current !== resolve)
70
- return yield* resolveKey(current, header);
71
- const now = yield* Clock.currentTimeMillis;
72
- // Cool down failed or interrupted refreshes too, without discarding still-valid cached keys.
73
- if (now < refreshAfter)
74
- return yield* refreshFailure ?? unauthorized();
75
- refreshAfter = now + 30_000;
76
- refreshFailure = new ProviderUnavailable({ operation: "jwks.refresh" });
77
- const next = yield* makeCachedKeys;
78
- const refreshed = yield* next.pipe(Effect.tapError((error) => Effect.sync(() => {
79
- refreshFailure = error;
80
- })));
81
- keys = next;
82
- return yield* resolveKey(refreshed, header);
83
- }))));
71
+ return yield* Effect.tryPromise({
72
+ try: () => resolve({ alg: "EdDSA", kid }),
73
+ // Selecting from fetched keys does no I/O: every failure is the credential's.
74
+ catch: unauthorized,
75
+ });
84
76
  });
85
77
  const verifyJwt = Effect.fn("Verifier.jwt")(function* (token) {
86
78
  const header = yield* Effect.try({
@@ -91,7 +83,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
91
83
  // tokens before doing provider I/O; JOSE still enforces its algorithm allowlist.
92
84
  if (header.alg !== "EdDSA" || header.kid === undefined)
93
85
  return yield* unauthorized();
94
- const key = yield* signingKey(header);
86
+ const key = yield* signingKey(header.kid);
95
87
  const now = yield* Clock.currentTimeMillis;
96
88
  const { payload } = yield* Effect.tryPromise({
97
89
  try: () => jwtVerify(token, key, {
@@ -131,10 +123,10 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
131
123
  cause: { status: response.status },
132
124
  });
133
125
  const body = yield* HttpClientResponse.schemaBodyJson(KeyResponse)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "api-key.response", cause })));
134
- const expiry = body.expiresAt === null ? null : Date.parse(body.expiresAt);
135
- if (body.resource !== options.resource || (expiry !== null && !Number.isFinite(expiry)))
126
+ if (body.resource !== options.resource)
136
127
  return yield* new ProviderUnavailable({ operation: "api-key.response" });
137
- if (expiry !== null && expiry <= (yield* Clock.currentTimeMillis))
128
+ if (body.expiresAt !== null &&
129
+ DateTime.toEpochMillis(body.expiresAt) <= (yield* Clock.currentTimeMillis))
138
130
  return yield* unauthorized();
139
131
  return {
140
132
  subject: body.ownerId,
@@ -155,10 +147,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
155
147
  if (missing !== undefined)
156
148
  return yield* new InsufficientScope({ scope: missing });
157
149
  return principal;
158
- }, Effect.timeoutOrElse({
159
- duration: "5 seconds",
160
- orElse: () => Effect.fail(new ProviderUnavailable({ operation: "verify.timeout" })),
161
- }));
150
+ });
162
151
  return {
163
152
  verifyToken,
164
153
  verify: Effect.fn("Verifier.verify")(function* (authorization) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Effect-native Clanker Auth access-token and API-key verification, with optional effect-actions integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  "jose": "6.2.12"
40
40
  },
41
41
  "devDependencies": {
42
- "@gjermundgaraba/effect-actions": "0.4.1",
42
+ "@gjermundgaraba/effect-actions": "0.5.0",
43
43
  "@modelcontextprotocol/client": "2.0.0",
44
44
  "@types/node": "26.6.2",
45
45
  "typescript": "7.0.2",
@@ -47,7 +47,7 @@
47
47
  "vite-plus": "0.3.3"
48
48
  },
49
49
  "peerDependencies": {
50
- "@gjermundgaraba/effect-actions": "0.4.1"
50
+ "@gjermundgaraba/effect-actions": "0.5.0"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@gjermundgaraba/effect-actions": {