@gjermundgaraba/clankerauth-sdk 0.22.0 → 0.23.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 CHANGED
@@ -17,19 +17,19 @@ 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.16.0
20
+ vp add @gjermundgaraba/effect-actions@^0.17.0
21
21
  ```
22
22
 
23
23
  ## Entry points
24
24
 
25
- | Import | Needs effect-actions | Browser safe | What it holds |
26
- | ---------------------------------- | -------------------- | ------------ | ------------------------------------------------------------------------------------- |
27
- | `…/clankerauth-sdk` | no | no | `Verifier`, `RequestPolicy` |
28
- | `…/clankerauth-sdk/errors` | no | **yes** | the verifier's errors; `ProviderUnavailable` is the one a descriptor declares |
29
- | `…/clankerauth-sdk/session` | yes | **yes** | `CurrentPrincipal` and its `Caller`, the `Whoami` contract, `Principal`, `signOutUrl` |
30
- | `…/clankerauth-sdk/effect-actions` | yes | no | `Resource` |
31
- | `…/clankerauth-sdk/key-list` | no | no | the key list format, for issuers and fakes |
32
- | `…/clankerauth-sdk/testing` | no | no | `startFakeIssuer`, for tests (Node only) |
25
+ | Import | Needs effect-actions | Browser safe | What it holds |
26
+ | ---------------------------------- | -------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------- |
27
+ | `…/clankerauth-sdk` | no | no | `Verifier`, `RequestPolicy` |
28
+ | `…/clankerauth-sdk/errors` | no | **yes** | the verifier's errors; `ProviderUnavailable` is the one a descriptor declares |
29
+ | `…/clankerauth-sdk/session` | yes | **yes** | `CurrentPrincipal` and its `Caller`, the `Whoami` contract, `Principal`, `signOutUrl`, `signOutPath`, `returnToParameter` |
30
+ | `…/clankerauth-sdk/effect-actions` | yes | no | `Resource` |
31
+ | `…/clankerauth-sdk/key-list` | no | no | the key list format and `keyPrefix`, for issuers and fakes |
32
+ | `…/clankerauth-sdk/testing` | no | no | `startFakeIssuer`, for tests (Node only) |
33
33
 
34
34
  The package is free of side effects, so a bundler drops what a page does not use. Errors are **not** re-exported from the root: a shared contract imports them from `/errors`, and nothing about that import pulls token verification into a browser bundle.
35
35
 
@@ -13,6 +13,8 @@
13
13
  import { Schema } from "effect";
14
14
  /** The list's JWT `typ`, so neither a list nor an access token can pass for the other. */
15
15
  export declare const type = "key-list+jwt";
16
+ /** Every API key the issuer creates begins with this, which tells it apart from a JWT. */
17
+ export declare const keyPrefix = "clankerauth_";
16
18
  /**
17
19
  * How long a list decides, in seconds from its `iat`: for as long as a resource server
18
20
  * cannot read a newer one, it keeps accepting the keys this one grants. So it is also the
package/dist/key-list.js CHANGED
@@ -14,6 +14,8 @@ import { Schema } from "effect";
14
14
  import { base64url } from "jose";
15
15
  /** The list's JWT `typ`, so neither a list nor an access token can pass for the other. */
16
16
  export const type = "key-list+jwt";
17
+ /** Every API key the issuer creates begins with this, which tells it apart from a JWT. */
18
+ export const keyPrefix = "clankerauth_";
17
19
  /**
18
20
  * How long a list decides, in seconds from its `iat`: for as long as a resource server
19
21
  * cannot read a newer one, it keeps accepting the keys this one grants. So it is also the
@@ -25,7 +25,7 @@ export interface Request {
25
25
  */
26
26
  export declare const make: (options: Options) => {
27
27
  allows: (request: Request) => boolean;
28
- /** Refuse anything `allows` rejects with an empty-bodied 403, before any handler. */
28
+ /** Refuse anything `allows` rejects with a 403 (body `Forbidden`) before any handler. */
29
29
  middleware: HttpRouter.Middleware<{
30
30
  provides: never;
31
31
  handles: never;
@@ -36,14 +36,13 @@ export const make = (options) => {
36
36
  const allows = (request) => {
37
37
  if (pathOf(request.target) === probePath)
38
38
  return true;
39
- // A request with no Host is not a browser request for this app's origin.
40
39
  if (request.host !== host)
41
40
  return false;
42
41
  return request.origin === undefined || origins.has(request.origin);
43
42
  };
44
43
  return {
45
44
  allows,
46
- /** Refuse anything `allows` rejects with an empty-bodied 403, before any handler. */
45
+ /** Refuse anything `allows` rejects with a 403 (body `Forbidden`) before any handler. */
47
46
  middleware: HttpRouter.middleware((httpEffect) => Effect.gen(function* () {
48
47
  const request = yield* HttpServerRequest.HttpServerRequest;
49
48
  const permitted = allows({
package/dist/resource.js CHANGED
@@ -45,11 +45,7 @@ const refusalOf = (error) => {
45
45
  */
46
46
  export const authenticate = (verifier, token) => Effect.mapError(verifier.verifyToken(Redacted.value(token)), refusalOf);
47
47
  const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, required }, options) {
48
- // One resource per application, at the public origin root, trailing slash and all.
49
- // An MCP client sends `new URL(metadata.resource).href`, and the token's audience is
50
- // that string, so deriving it here is what keeps the two in agreement.
51
48
  const identifier = new URL("/", options.publicUrl).href;
52
- // effect-actions checks every scope here is an OAuth scope token, when the provider builds.
53
49
  const metadata = {
54
50
  resource: identifier,
55
51
  authorizationServers: [options.issuer],
@@ -63,9 +59,6 @@ const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, r
63
59
  requiredScopes: [required],
64
60
  apiKeys: options.apiKeys,
65
61
  });
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
62
  const deadline = (effect) => Effect.timeoutOrElse(effect, {
70
63
  duration: "5 seconds",
71
64
  orElse: () => Effect.fail(new ProviderUnavailable({ operation: "verify.timeout" })),
@@ -80,7 +73,6 @@ const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, r
80
73
  */
81
74
  const principalOf = Effect.fn("Resource.principal")(function* (authorization, scope) {
82
75
  const token = Authentication.bearerTokenOf(authorization);
83
- // As a protected route refuses a request without one, before its verifier runs.
84
76
  if (Option.isNone(token))
85
77
  return yield* new Action.Unauthenticated({ message: "A bearer token is required." });
86
78
  const principal = yield* authenticate(verifier, token.value);
@@ -97,7 +89,6 @@ const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, r
97
89
  const verified = yield* Effect.result(principalOf(authorization, scope));
98
90
  if (Result.isSuccess(verified))
99
91
  return { ok: true, principal: verified.success };
100
- // A refusal, or the `ProviderUnavailable` the descriptor declares, as its routes send it.
101
92
  const web = HttpServerResponse.toWeb(Authentication.refusalResponse(verified.failure, {
102
93
  authentication,
103
94
  protectedResource,
@@ -121,12 +112,8 @@ const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, r
121
112
  const principal = yield* principalOf(authorization, scope);
122
113
  const now = yield* Clock.currentTimeMillis;
123
114
  const remaining = Math.max(0, (principal.expiresAt ?? Infinity) - now);
124
- // A key is checked again against each refreshed list, until it expires.
125
115
  if (principal.actor.kind === "key")
126
116
  return yield* Effect.sleep(Math.min(remaining, Duration.toMillis(keyListRefresh)));
127
- // An access token cannot be revoked and never becomes valid again, so its expiry is
128
- // all that ends it: no second JWKS read that an issuer outage could fail. It ends as
129
- // the verifier refuses an expired token.
130
117
  yield* Effect.sleep(remaining);
131
118
  return yield* refusalOf(new Unauthorized());
132
119
  }));
@@ -156,17 +143,11 @@ export const make = (authentication, declaration) => {
156
143
  const { scopes } = declaration;
157
144
  const service = Context.Service(`@clankerauth/Resource/${authentication.name}`);
158
145
  const layer = (options) => Layer.effect(service, build(authentication, declaration, options));
159
- // The descriptor as the verifier fails: `DeclaresUnavailable` holds that every alternative of
160
- // `E` declares `ProviderUnavailable`, which a generic `E` cannot show the types by itself.
161
146
  const declaring = authentication;
162
- // The descriptor's verifier: a token to its principal, a refusal, or `ProviderUnavailable`.
163
147
  const provider = Authentication.layer(declaring, Effect.gen(function* () {
164
148
  const { verifier, resource, protectedResource } = yield* service;
165
149
  const host = new URL(resource).host;
166
150
  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
151
  const noteHost = Effect.gen(function* () {
171
152
  if (yield* Ref.get(warned))
172
153
  return;
package/dist/session.d.ts CHANGED
@@ -47,6 +47,10 @@ export declare const Whoami: Action.Action<"whoami", Schema.$Record<Schema.Strin
47
47
  readonly issuer: Schema.NonEmptyString;
48
48
  readonly scopes: Schema.$Array<Schema.String>;
49
49
  }>, readonly never[], true, typeof CurrentPrincipal>;
50
+ /** The query parameter naming where the issuer sends the owner after signing in or out. */
51
+ export declare const returnToParameter = "rd";
52
+ /** Where the issuer signs the owner out and clears the forward cookie. */
53
+ export declare const signOutPath = "/forward-auth/logout";
50
54
  /**
51
55
  * Sign out at the issuer, which ends its session and every app's forward cookie, then
52
56
  * return to `returnTo`. It is a plain link: any page under the cookie domain may use it.
package/dist/session.js CHANGED
@@ -32,12 +32,16 @@ export const Whoami = Action.make("whoami", {
32
32
  caller: CurrentPrincipal,
33
33
  success: Principal,
34
34
  });
35
+ /** The query parameter naming where the issuer sends the owner after signing in or out. */
36
+ export const returnToParameter = "rd";
37
+ /** Where the issuer signs the owner out and clears the forward cookie. */
38
+ export const signOutPath = "/forward-auth/logout";
35
39
  /**
36
40
  * Sign out at the issuer, which ends its session and every app's forward cookie, then
37
41
  * return to `returnTo`. It is a plain link: any page under the cookie domain may use it.
38
42
  */
39
43
  export const signOutUrl = (issuer, returnTo) => {
40
- const url = new URL("/forward-auth/logout", issuer);
41
- url.searchParams.set("rd", returnTo);
44
+ const url = new URL(signOutPath, issuer);
45
+ url.searchParams.set(returnToParameter, returnTo);
42
46
  return url.href;
43
47
  };
package/dist/testing.js CHANGED
@@ -41,8 +41,6 @@ export const startFakeIssuer = async (options) => {
41
41
  if (Option.isNone(asked))
42
42
  return send(400, '{"error":"Invalid request"}');
43
43
  const { resource } = asked.value;
44
- // Like the real issuer, a list holds only what is granted on the resource it names,
45
- // and only what has not expired.
46
44
  const now = Date.now();
47
45
  const entries = await Promise.all([...keys].flatMap(([key, { keyId, permissions, expiresAt }]) => {
48
46
  const scopes = permissions[resource] ?? [];
@@ -85,7 +83,7 @@ export const startFakeIssuer = async (options) => {
85
83
  .sign(pair.privateKey);
86
84
  },
87
85
  apiKey: (permissions = { [options.resource]: options.scopes }, expiresAt) => {
88
- const key = `clankerauth_${randomBytes(48).toString("base64url")}`;
86
+ const key = `${KeyList.keyPrefix}${randomBytes(48).toString("base64url")}`;
89
87
  keys.set(key, {
90
88
  keyId: `key-${++minted}`,
91
89
  permissions,
package/dist/transport.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import { Context, Effect } from "effect";
2
2
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
3
3
  import { ProviderUnavailable } from "./errors.js";
4
- // Node transports do not follow redirects by default. Also pin FetchHttpClient's policy;
5
- // forwarding credentials through a redirect is not an authentication fallback.
6
4
  export const execute = (client, request) => Effect.contextWith((context) => HttpClient.withScope(client)
7
5
  .execute(request)
8
6
  .pipe(Effect.provideService(FetchHttpClient.RequestInit, {
package/dist/verify.js CHANGED
@@ -66,15 +66,11 @@ 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, 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
69
  const keys = yield* Effect.cachedWithTTL(document(), (exit) => Exit.isSuccess(exit) ? documentLifetime : cooldown);
73
70
  const signingKey = Effect.fn("Verifier.signingKey")(function* (kid) {
74
71
  const resolve = yield* keys;
75
72
  return yield* Effect.tryPromise({
76
73
  try: () => resolve({ alg: "EdDSA", kid }),
77
- // Selecting from fetched keys does no I/O: every failure is the credential's.
78
74
  catch: unauthorized,
79
75
  });
80
76
  });
@@ -89,8 +85,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
89
85
  try: () => decodeProtectedHeader(jws),
90
86
  catch: unauthorized,
91
87
  });
92
- // The issuer always names its key, so key selection is never ambiguous. Reject other
93
- // documents before doing provider I/O; JOSE still enforces its algorithm allowlist.
88
+ // why: refused before any JWKS read, so an unauthenticated request causes no issuer traffic.
94
89
  if (header.alg !== "EdDSA" || header.kid === undefined)
95
90
  return yield* unauthorized();
96
91
  const key = yield* signingKey(header.kid);
@@ -103,7 +98,6 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
103
98
  typ,
104
99
  currentDate: new Date(now),
105
100
  }),
106
- // Verification against a resolved key does no I/O either.
107
101
  catch: unauthorized,
108
102
  });
109
103
  return payload;
@@ -117,7 +111,6 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
117
111
  subject: claims.sub,
118
112
  scopes: claims.scope.split(" ").filter(Boolean),
119
113
  actor: { kind: "client", clientId: claims.client_id },
120
- // `jwtVerify` already checked this claim against the current time.
121
114
  expiresAt: claims.exp * 1000,
122
115
  };
123
116
  });
@@ -130,7 +123,6 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
130
123
  cause: { status: response.status },
131
124
  });
132
125
  const { list } = yield* HttpClientResponse.schemaBodyJson(KeyListResponse)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "key-list.decode", cause })));
133
- // A list that does not verify is no list: the held one keeps deciding.
134
126
  const rejected = (cause) => new ProviderUnavailable({ operation: "key-list.verify", cause });
135
127
  const payload = yield* verifySigned(list, KeyList.type).pipe(Effect.catchTag("Unauthorized", (cause) => Effect.fail(rejected(cause))));
136
128
  const claims = yield* Schema.decodeUnknownEffect(KeyListClaims)(payload).pipe(Effect.mapError(rejected));
@@ -140,8 +132,6 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
140
132
  entries: new Map(claims.keys.map((entry) => [entry.id, entry.sealed])),
141
133
  };
142
134
  }, Effect.scoped);
143
- // While a list is held, a failing issuer is otherwise invisible until that list runs
144
- // out. Reads are logged when they start failing and when they recover, not per retry.
145
135
  const failing = yield* Ref.make(false);
146
136
  const readList = yield* Effect.cachedWithTTL(listDocument().pipe(Effect.timeoutOrElse({
147
137
  duration: listReadTimeout,
@@ -149,8 +139,6 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
149
139
  }), Effect.tapError(({ operation }) => Effect.flatMap(Ref.getAndSet(failing, true), (was) => was
150
140
  ? Effect.void
151
141
  : 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);
152
- // The list is verified once, when it is read. Holding its entries, not the document,
153
- // is what lets keys verify through an outage that has already taken JWKS with it.
154
142
  const held = yield* Ref.make(undefined);
155
143
  /** The list read now, or within the cooldown, or else the held one, while inside its window. */
156
144
  const latest = Effect.gen(function* () {
@@ -158,7 +146,6 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
158
146
  if (Result.isSuccess(fetched))
159
147
  yield* Ref.set(held, fetched.success);
160
148
  const list = yield* Ref.get(held);
161
- // A list is held past its own check, so the window is checked on every use.
162
149
  if (list !== undefined && list.expiresAt > (yield* Clock.currentTimeMillis))
163
150
  return list;
164
151
  return yield* Result.isFailure(fetched)
@@ -172,17 +159,13 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
172
159
  });
173
160
  const now = yield* Clock.currentTimeMillis;
174
161
  const current = yield* Ref.get(held);
175
- // One read decides the keys it names for a minute, without the issuer.
176
162
  const list = current !== undefined &&
177
163
  now - current.readAt < Duration.toMillis(keyListRefresh) &&
178
164
  current.expiresAt > now
179
165
  ? current
180
166
  : yield* latest;
181
- // A key it does not name may have been created since, so the list is read again,
182
- // unless a read answered within the cooldown and there is nothing new to look in.
183
167
  const grant = (yield* open(list)) ??
184
168
  (yield* Effect.flatMap(latest, (next) => next === list ? Effect.succeed(undefined) : open(next)));
185
- // No entry is the same case as a token for another audience: not this resource's.
186
169
  if (grant === undefined)
187
170
  return yield* unauthorized();
188
171
  if (grant.expiresAt !== null && grant.expiresAt <= (yield* Clock.currentTimeMillis))
@@ -195,7 +178,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
195
178
  };
196
179
  });
197
180
  const verifyToken = Effect.fn("Verifier.verifyToken")(function* (token) {
198
- const principal = yield* token.startsWith("clankerauth_")
181
+ const principal = yield* token.startsWith(KeyList.keyPrefix)
199
182
  ? options.apiKeys === false
200
183
  ? Effect.fail(unauthorized())
201
184
  : verifyKey(token)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
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,16 +46,18 @@
46
46
  "jose": "6.2.12"
47
47
  },
48
48
  "devDependencies": {
49
- "@gjermundgaraba/effect-actions": "0.16.0",
49
+ "@effect/vitest": "4.0.4",
50
+ "@gjermundgaraba/effect-actions": "0.17.0",
50
51
  "@modelcontextprotocol/client": "2.3.1",
51
52
  "@types/node": "26.6.4",
52
53
  "effect": "4.0.4",
53
54
  "typescript": "7.0.2",
54
- "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
55
- "vite-plus": "1.1.0"
55
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
56
+ "vite-plus": "1.2.0",
57
+ "vitest": "5.0.4"
56
58
  },
57
59
  "peerDependencies": {
58
- "@gjermundgaraba/effect-actions": "^0.16.0",
60
+ "@gjermundgaraba/effect-actions": "^0.17.0",
59
61
  "effect": "^4.0.4"
60
62
  },
61
63
  "peerDependenciesMeta": {