@gjermundgaraba/clankerauth-sdk 0.6.0 → 0.7.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
@@ -1,6 +1,6 @@
1
1
  # @gjermundgaraba/clankerauth-sdk
2
2
 
3
- Effect-native [Clanker Auth](https://github.com/gjermundgaraba/clankerauth) verification. Verifies JWT access tokens and API keys, including the tokens a reverse proxy obtains through the issuer's forward auth for browser sessions. Optional **effect-actions** integration provides request-scoped identity and OAuth discovery.
3
+ Effect-native [Clanker Auth](https://github.com/gjermundgaraba/clankerauth) verification. Verifies JWT access tokens and API keys, including the tokens a reverse proxy obtains through the issuer's forward auth for browser sessions. Optional **effect-actions** integration provides request-scoped identity, OAuth discovery, scope enforcement and a `session` contract a browser page can import on its own.
4
4
 
5
5
  ## Install
6
6
 
@@ -14,6 +14,17 @@ Install Effect in the application:
14
14
  vp add effect@4.0.0-rc.116
15
15
  ```
16
16
 
17
+ ## Entry points
18
+
19
+ | Import | Needs effect-actions | Browser safe | What it holds |
20
+ | ---------------------------------- | -------------------- | ------------ | ------------------------------------------------- |
21
+ | `…/clankerauth-sdk` | no | no | `Verifier`, `RequestPolicy` |
22
+ | `…/clankerauth-sdk/errors` | no | **yes** | the error schemas and `authenticationErrors` |
23
+ | `…/clankerauth-sdk/session` | yes | **yes** | the `session` contract, `Principal`, `signOutUrl` |
24
+ | `…/clankerauth-sdk/effect-actions` | yes | no | `Resource`, `CurrentPrincipal` |
25
+
26
+ 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.
27
+
17
28
  ## Verify tokens directly
18
29
 
19
30
  Core consumers do not need effect-actions or `skipLibCheck`.
@@ -21,32 +32,21 @@ Core consumers do not need effect-actions or `skipLibCheck`.
21
32
  ```ts
22
33
  import { Effect } from "effect";
23
34
  import { FetchHttpClient } from "effect/unstable/http";
24
- import { McpProtocol } from "effect/unstable/ai";
25
35
  import { Verifier } from "@gjermundgaraba/clankerauth-sdk";
26
36
 
27
37
  const makeVerifier = Verifier.make({
28
38
  issuer: "https://auth.internal/api/auth",
29
- resource: "https://notes.internal/api",
39
+ resource: "https://notes.internal/",
30
40
  requiredScopes: ["notes:read"],
31
41
  }).pipe(Effect.provide(FetchHttpClient.layer));
32
42
  // Acquire once, then use verifier.verify(authorization) or verifier.verifyToken(token).
33
43
  ```
34
44
 
35
- ## Protect effect-actions routes
36
-
37
- Install the optional peer dependency and import the integration subpath:
38
-
39
- ```sh
40
- vp add @gjermundgaraba/effect-actions@0.3.0
41
- ```
42
-
43
- Core and integration declarations both type-check without `skipLibCheck`.
44
-
45
45
  ## One resource per application
46
46
 
47
47
  Register **one** resource in the Clanker Auth dashboard, identified by the application's public origin root, written with its trailing slash: `https://notes.internal/`. That one resource covers `/api`, `/mcp` and any socket, with one RFC 9728 document at `/.well-known/oauth-protected-resource` and one `forward_auth` block in the proxy.
48
48
 
49
- The identifier must already be canonical: `new URL(id).href === id`. An MCP client sends `new URL(metadata.resource).href` and the access token's audience is that string, so `https://notes.internal` (no slash) would be rewritten by the client and no longer match the issuer's registered resource. `Resource.make` refuses it with `ConfigurationError` rather than let that fail later. The official client's own `checkResourceAllowed` accepts an origin-root resource for an endpoint beneath it.
49
+ `Resource.make` 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.
50
50
 
51
51
  Acquire the resource once at application construction, never per request.
52
52
 
@@ -55,6 +55,7 @@ import { Effect, Layer } from "effect";
55
55
  import { FetchHttpClient } from "effect/unstable/http";
56
56
  import { McpProtocol } from "effect/unstable/ai";
57
57
  import { Resource } from "@gjermundgaraba/clankerauth-sdk/effect-actions";
58
+ import { authenticationErrors } from "@gjermundgaraba/clankerauth-sdk/errors";
58
59
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
59
60
 
60
61
  // Http and app are your effect-actions HTTP binding and implemented group.
@@ -62,17 +63,24 @@ const routes = Layer.unwrap(
62
63
  Effect.gen(function* () {
63
64
  const notes = yield* Resource.make({
64
65
  issuer: "https://auth.internal/api/auth",
65
- resource: "https://notes.internal/",
66
- scopes: ["notes:read", "notes:write"],
67
- requiredScopes: ["notes:read"],
68
- writeScope: "notes:write",
66
+ publicUrl: new URL("https://notes.internal"),
67
+ scopes: { read: "notes:read", write: "notes:write" },
69
68
  });
70
69
 
71
70
  return Layer.mergeAll(
72
71
  notes.discovery.layer,
73
- Http.layer(app).pipe(Layer.provide(Resource.middleware(notes).layer)),
72
+ Http.layer({ before: notes.authorize }, app, notes.session).pipe(
73
+ Layer.provide(Resource.middleware(notes).layer),
74
+ ),
74
75
  ActionMcp.layerHttp(
75
- { name: "notes", version: "1.0.0", path: "/mcp", protocols: [McpProtocol.v2026_07_28] },
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
+ },
76
84
  app,
77
85
  ).pipe(Layer.provide(Resource.middleware(notes).layer)),
78
86
  );
@@ -80,38 +88,48 @@ const routes = Layer.unwrap(
80
88
  ).pipe(Layer.provide(FetchHttpClient.layer));
81
89
  ```
82
90
 
83
- Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the matching `@effect/platform-node@4.0.0-rc.116` package. Discovery is public; do not wrap it in authentication. Discovery cache policy belongs to the host. Authentication responses use `Cache-Control: no-store`.
91
+ Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the matching `@effect/platform-node@4.0.0-rc.116` package. Discovery is public; do not wrap it in authentication. Discovery cache policy belongs to the host. Every authentication response carries `Cache-Control: no-store`.
84
92
 
85
93
  The supplied `HttpClient` must not retry credential exchanges or follow redirects. The SDK overrides only FetchHttpClient's redirect policy to reject redirects, preserves other caller-provided fetch defaults, and never installs retry middleware.
86
94
 
87
95
  ## Scope enforcement
88
96
 
89
- An application names two scopes and writes no authorization code. Each action declares what it does to the resource, and the resource's `authorize` is the group's pre-handler hook: it runs before every handler on every surface — HTTP, MCP, Toolkit, CLI — so no handler can forget it.
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.
90
98
 
91
99
  ```ts
92
100
  import * as Action from "@gjermundgaraba/effect-actions/Action";
93
101
  import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
94
- import { InsufficientScope } from "@gjermundgaraba/clankerauth-sdk";
102
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
103
+ import { authenticationErrors } from "@gjermundgaraba/clankerauth-sdk/errors";
104
+ import { Session } from "@gjermundgaraba/clankerauth-sdk/session";
95
105
 
96
106
  const Notes = ActionGroup.make(
97
- { name: "notes", errors: [InsufficientScope] },
107
+ { name: "notes" },
98
108
  Action.make("list", { description: "List notes", access: "read", success: Notes }),
99
109
  Action.make("write", { description: "Write a note", access: "write", success: Note }),
100
110
  );
101
111
 
102
- // `notes` is the acquired Resource above, configured with `writeScope`.
103
- const app = Notes.implement(handlers, { before: notes.authorize });
112
+ // Declared on the surface, because the surface is what renders them — including the
113
+ // hook's own refusals, which is why the group no longer lists `InsufficientScope`.
114
+ const Http = ActionHttp.make({ apiPath: "/api", errors: authenticationErrors }, Notes, Session);
115
+
116
+ // 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);
104
118
  ```
105
119
 
106
- A read needs nothing beyond what verification already required. A write needs the configured `writeScope`; without one, `authorize` passes every action, so a single-scope resource is expressed by leaving it out. `InsufficientScope` is a 403 naming **only** the missing scope, so a refusal never enumerates the resource's permissions. Declare it on the group.
120
+ `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.
121
+
122
+ A forward-auth browser token carries **every** scope the resource defines, because the issuer mints it for the signed-in owner and not for a program. The write scope therefore gates agents and API keys, not the owner at a keyboard: give a read-only agent or key only `scopes.read` and the same rule refuses its writes.
123
+
124
+ 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.
107
125
 
108
- An action still 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.
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.
109
127
 
110
128
  Middleware authenticates the request and the hook authorizes it; neither filters MCP tool discovery.
111
129
 
112
130
  ## Outside the router
113
131
 
114
- A socket, a Node `upgrade` handler or a per-request endpoint verifies the same credential against the same resource, and renders the same refusal, with `admit`:
132
+ A socket, a Node `upgrade` handler or a per-request endpoint verifies the same credential against the same resource, and renders the same refusal, with `admit`. The access level is required, so the call says what the credential is for:
115
133
 
116
134
  ```ts
117
135
  const admission = await Effect.runPromise(notes.admit(request.headers.authorization, "write"));
@@ -125,7 +143,7 @@ if (!admission.ok) {
125
143
  connect(admission.principal);
126
144
  ```
127
145
 
128
- The refusal carries each error's own status (401/403/429/503), the RFC 6750 challenge including the no-credential case, `Cache-Control: no-store`, and the error's JSON encoding. Passing `"write"` demands the write scope before the socket is established. `Resource.middleware` is built on the same function, so a router and a socket can never answer differently.
146
+ The refusal carries each error's own status (401/403/429/503), the RFC 6750 challenge including the no-credential case, `Cache-Control: no-store`, and the error's JSON encoding. `Resource.middleware` is built on the same function at `"read"`, so a router and a socket can never answer differently.
129
147
 
130
148
  The resource also exposes `verifier.verify(authorization)` and `verifier.verifyToken(token)` as Effects for callers that render their own responses.
131
149
 
@@ -139,7 +157,6 @@ import { RequestPolicy } from "@gjermundgaraba/clankerauth-sdk";
139
157
  const policy = RequestPolicy.make({
140
158
  publicUrl: new URL("https://notes.internal"),
141
159
  allowedOrigins: ["wtf://app"], // a desktop shell's private renderer scheme
142
- exemptPaths: ["/healthz"], // a container probe carries neither header
143
160
  });
144
161
 
145
162
  const routes = protectedRoutes.pipe(Layer.provide(policy.middleware.layer));
@@ -156,19 +173,31 @@ if (
156
173
  }
157
174
  ```
158
175
 
159
- Both checks are raw string comparisons on the headers as sent. Nothing is parsed, so nothing throws and no normalization widens what is accepted: `notes.internal` and `notes.internal:443` are different hosts here, as they are to a browser's cookie. A request with no `Host` is refused unless its path is exempt. An absent `Origin` is allowed; a present one must match exactly. This is not authentication, and it does not set body limits or security headers — those stay the application's.
176
+ Both checks are raw string comparisons on the headers as sent. Nothing is parsed, so nothing throws and no normalization widens what is accepted: `notes.internal` and `notes.internal:443` are different hosts here, as they are to a browser's cookie. A request with no `Host` is refused. An absent `Origin` is allowed; a present one must match exactly. `/healthz` is the one path answered without either check, because a container probe reaches the app on its bind address and carries neither header; it is fixed, not configurable. This is not authentication, and it does not set body limits or security headers — those stay the application's.
160
177
 
161
- 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.
178
+ ## Browser applications
162
179
 
163
- 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.
180
+ A browser page holds no credential of its own: put it behind a reverse proxy with the issuer's forward auth, described in the Clanker Auth README, and the proxy adds the `Authorization` header this package verifies. What the page does need is who it is signed in as, and a way out:
164
181
 
165
- ## Browser applications
182
+ ```ts
183
+ import { Principal, Session, signOutUrl } from "@gjermundgaraba/clankerauth-sdk/session";
184
+
185
+ // `Session` is the contract; the server answers it with `resource.session`.
186
+ const principal: Principal = await api.session.whoami({ payload: {} });
187
+ link.href = signOutUrl(principal.issuer, `${location.origin}/`);
188
+ ```
166
189
 
167
- Browser apps do not use this package. Put them behind a reverse proxy with the issuer's forward auth, described in the Clanker Auth README; the proxy adds an `Authorization` header the same verifier above accepts.
190
+ `Principal` is `{ subject, issuer, scopes }`. `signOutUrl` points at the issuer's `/forward-auth/logout?rd=…`, which ends the issuer session and every app's forward cookie; it is a plain link. On the server, `resource.session` is the same group already implemented from the verified credential — pass it to the HTTP binding beside your own application, and delete the hand-written `whoami`.
168
191
 
169
192
  ## Errors and observability
170
193
 
171
- Schema-tagged errors: `Unauthorized` (401), `Forbidden` (403), `InsufficientScope` (403), `RateLimited` (429) and `ProviderUnavailable` (503). Invalid construction fails with `ConfigurationError`.
194
+ Schema-tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauthorized` (401), `InsufficientScope` (403), `RateLimited` (429) and `ProviderUnavailable` (503), collected as `authenticationErrors`. Invalid construction fails with `ConfigurationError`.
195
+
196
+ 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
+
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.
199
+
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.
172
201
 
173
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.
174
203
 
@@ -1,3 +1,2 @@
1
1
  export * as Resource from "./resource.ts";
2
2
  export { CurrentPrincipal } from "./resource.ts";
3
- export { InsufficientScope } from "./errors.ts";
@@ -1,3 +1,2 @@
1
1
  export * as Resource from "./resource.js";
2
2
  export { CurrentPrincipal } from "./resource.js";
3
- export { InsufficientScope } from "./errors.js";
package/dist/errors.d.ts CHANGED
@@ -1,9 +1,14 @@
1
- import { Effect, Schema } from "effect";
2
- import { type Headers, HttpServerResponse } from "effect/unstable/http";
1
+ /**
2
+ * Every refusal this package produces, as schemas. This entry point is browser-safe:
3
+ * a shared contract declares these on its surface and a browser client decodes them,
4
+ * without pulling token verification or its cryptography into the bundle.
5
+ *
6
+ * Schemas define public responses. Diagnostic causes are internal, non-enumerable fields.
7
+ */
8
+ import { Schema } from "effect";
3
9
  declare const Unauthorized_base: Schema.Class<Unauthorized, Schema.TaggedStruct<"Unauthorized", {
4
10
  readonly message: Schema.String;
5
11
  }>, import("effect/Cause").YieldableError>;
6
- /** Schemas define public responses. Diagnostic causes are internal, non-enumerable fields. */
7
12
  export declare class Unauthorized extends Unauthorized_base {
8
13
  readonly cause?: unknown;
9
14
  constructor(options: {
@@ -11,11 +16,6 @@ export declare class Unauthorized extends Unauthorized_base {
11
16
  readonly cause?: unknown;
12
17
  });
13
18
  }
14
- declare const Forbidden_base: Schema.Class<Forbidden, Schema.TaggedStruct<"Forbidden", {
15
- readonly message: Schema.String;
16
- }>, import("effect/Cause").YieldableError>;
17
- export declare class Forbidden extends Forbidden_base {
18
- }
19
19
  declare const RateLimited_base: Schema.Class<RateLimited, Schema.TaggedStruct<"RateLimited", {
20
20
  readonly message: Schema.String;
21
21
  }>, import("effect/Cause").YieldableError>;
@@ -45,18 +45,10 @@ declare const ConfigurationError_base: Schema.Class<ConfigurationError, Schema.T
45
45
  }>, import("effect/Cause").YieldableError>;
46
46
  export declare class ConfigurationError extends ConfigurationError_base {
47
47
  }
48
- export declare const authenticationErrors: readonly [typeof Unauthorized, typeof Forbidden, typeof RateLimited, typeof ProviderUnavailable];
49
- export type AuthenticationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable;
50
- /** What `Resource.admit` may refuse with: verification, plus the write-scope check. */
51
- export declare const admissionErrors: readonly [typeof Unauthorized, typeof Forbidden, typeof RateLimited, typeof ProviderUnavailable, typeof InsufficientScope];
52
- export type AdmissionError = AuthenticationError | InsufficientScope;
53
- /** An error schema: service-free, so encoding a refusal needs no request context. */
54
- export type ErrorSchema = Schema.Codec<unknown, unknown, never, never>;
55
- /** One declared error as a status and a JSON body. Undeclared errors are defects. */
56
- export declare const describeError: <const Schemas extends ReadonlyArray<ErrorSchema>>(schemas: Schemas) => (error: Schemas[number]["Type"]) => Effect.Effect<{
57
- status: number;
58
- body: string;
59
- }, never, never>;
60
- /** Encode one of the declared schemas as a JSON response with its `httpApiStatus`. */
61
- export declare const encodeError: <const Schemas extends ReadonlyArray<ErrorSchema>>(schemas: Schemas) => (error: Schemas[number]["Type"], headers?: Headers.Input) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>;
48
+ /**
49
+ * What verification, admission and the authorization hook refuse with — one list, so a
50
+ * surface declares `errors: authenticationErrors` and answers every refusal the same way.
51
+ */
52
+ export declare const authenticationErrors: readonly [typeof Unauthorized, typeof InsufficientScope, typeof RateLimited, typeof ProviderUnavailable];
53
+ export type AuthenticationError = Unauthorized | InsufficientScope | RateLimited | ProviderUnavailable;
62
54
  export {};
package/dist/errors.js CHANGED
@@ -1,14 +1,17 @@
1
- import { Effect, Schema, SchemaAST } from "effect";
2
- import { HttpServerResponse } from "effect/unstable/http";
3
- /** Schemas define public responses. Diagnostic causes are internal, non-enumerable fields. */
1
+ /**
2
+ * Every refusal this package produces, as schemas. This entry point is browser-safe:
3
+ * a shared contract declares these on its surface and a browser client decodes them,
4
+ * without pulling token verification or its cryptography into the bundle.
5
+ *
6
+ * Schemas define public responses. Diagnostic causes are internal, non-enumerable fields.
7
+ */
8
+ import { Schema } from "effect";
4
9
  export class Unauthorized extends Schema.TaggedError()("Unauthorized", { message: Schema.String }, { httpApiStatus: 401 }) {
5
10
  constructor(options) {
6
11
  super({ message: options.message });
7
12
  Object.defineProperty(this, "cause", { value: options.cause });
8
13
  }
9
14
  }
10
- export class Forbidden extends Schema.TaggedError()("Forbidden", { message: Schema.String }, { httpApiStatus: 403 }) {
11
- }
12
15
  export class RateLimited extends Schema.TaggedError()("RateLimited", { message: Schema.String }, { httpApiStatus: 429 }) {
13
16
  }
14
17
  export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
@@ -25,36 +28,13 @@ export class InsufficientScope extends Schema.TaggedError()("InsufficientScope",
25
28
  }
26
29
  export class ConfigurationError extends Schema.TaggedError()("ConfigurationError", { message: Schema.String }) {
27
30
  }
31
+ /**
32
+ * What verification, admission and the authorization hook refuse with — one list, so a
33
+ * surface declares `errors: authenticationErrors` and answers every refusal the same way.
34
+ */
28
35
  export const authenticationErrors = [
29
36
  Unauthorized,
30
- Forbidden,
37
+ InsufficientScope,
31
38
  RateLimited,
32
39
  ProviderUnavailable,
33
40
  ];
34
- /** What `Resource.admit` may refuse with: verification, plus the write-scope check. */
35
- export const admissionErrors = [...authenticationErrors, InsufficientScope];
36
- /** One declared error as a status and a JSON body. Undeclared errors are defects. */
37
- export const describeError = (schemas) => {
38
- const encoders = new Map(schemas.map((schema) => [
39
- schema,
40
- {
41
- status: SchemaAST.resolveAt("httpApiStatus")(schema.ast) ?? 500,
42
- encode: Schema.encodeUnknownEffect(Schema.toCodecJson(schema)),
43
- },
44
- ]));
45
- return (error) => {
46
- const schema = schemas.find((candidate) => Schema.is(candidate)(error));
47
- const encoder = schema === undefined ? undefined : encoders.get(schema);
48
- if (encoder === undefined)
49
- return Effect.die(new Error("Undeclared error"));
50
- return Effect.map(Effect.orDie(encoder.encode(error)), (body) => ({
51
- status: encoder.status,
52
- body: JSON.stringify(body),
53
- }));
54
- };
55
- };
56
- /** Encode one of the declared schemas as a JSON response with its `httpApiStatus`. */
57
- export const encodeError = (schemas) => {
58
- const describe = describeError(schemas);
59
- return (error, headers) => Effect.map(describe(error), ({ status, body }) => HttpServerResponse.text(body, { status, headers, contentType: "application/json" }));
60
- };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,2 @@
1
1
  export * as Verifier from "./verify.ts";
2
2
  export * as RequestPolicy from "./request-policy.ts";
3
- export type { Principal } from "./verify.ts";
4
- export { Unauthorized, Forbidden, InsufficientScope, RateLimited, ProviderUnavailable, ConfigurationError, authenticationErrors, admissionErrors, type AdmissionError, type AuthenticationError, } from "./errors.ts";
package/dist/index.js CHANGED
@@ -1,3 +1,2 @@
1
1
  export * as Verifier from "./verify.js";
2
2
  export * as RequestPolicy from "./request-policy.js";
3
- export { Unauthorized, Forbidden, InsufficientScope, RateLimited, ProviderUnavailable, ConfigurationError, authenticationErrors, admissionErrors, } from "./errors.js";
@@ -11,11 +11,6 @@ export interface Options {
11
11
  * whose renderer has a private scheme, such as `wtf://app`. No wildcards.
12
12
  */
13
13
  readonly allowedOrigins?: readonly string[];
14
- /**
15
- * Exact paths answered without either check, such as `/healthz`. A container probe
16
- * reaches the app on its bind address and carries neither header.
17
- */
18
- readonly exemptPaths?: readonly string[];
19
14
  }
20
15
  /** One request, as a Node `upgrade` handler or a router middleware sees it. */
21
16
  export interface Request {
@@ -10,6 +10,11 @@
10
10
  */
11
11
  import { Effect } from "effect";
12
12
  import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
13
+ /**
14
+ * The one path answered without either check. A container probe reaches the app on its
15
+ * bind address and carries neither header, and no application has a second such path.
16
+ */
17
+ const probePath = "/healthz";
13
18
  /** The path of a request target. Deliberately not `new URL`: this must not throw. */
14
19
  const pathOf = (target) => {
15
20
  const authority = target.indexOf("://");
@@ -28,9 +33,8 @@ const forbidden = HttpServerResponse.text("Forbidden", { status: 403 });
28
33
  export const make = (options) => {
29
34
  const host = options.publicUrl.host;
30
35
  const origins = new Set([options.publicUrl.origin, ...(options.allowedOrigins ?? [])]);
31
- const exempt = new Set(options.exemptPaths ?? []);
32
36
  const allows = (request) => {
33
- if (exempt.has(pathOf(request.target)))
37
+ if (pathOf(request.target) === probePath)
34
38
  return true;
35
39
  // A request with no Host is not a browser request for this app's origin.
36
40
  if (request.host !== host)
@@ -1,20 +1,31 @@
1
- import { Context, Effect } from "effect";
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, Forbidden, InsufficientScope, RateLimited, Unauthorized } from "./errors.ts";
5
- import type { AdmissionError } from "./errors.ts";
4
+ import { ConfigurationError, InsufficientScope, RateLimited, Unauthorized } from "./errors.ts";
6
5
  import * as Verifier from "./verify.ts";
7
6
  declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Verifier.Principal>;
8
7
  export declare class CurrentPrincipal extends CurrentPrincipal_base {
9
8
  }
10
- export interface Options extends Verifier.Options {
11
- readonly scopes: readonly string[];
9
+ /** What this resource's credentials must carry. */
10
+ export interface Scopes {
11
+ /** Required of every credential, so verification alone admits a read. */
12
+ readonly read: string;
12
13
  /**
13
- * The scope a write needs beyond `requiredScopes`. Omit for a single-scope
14
- * resource, where verification alone is the whole policy and `authorize` passes
15
- * every action.
14
+ * Additionally required by an `access: "write"` action and by `admit(..., "write")`.
15
+ * Omit for a single-scope application, where verification is the whole policy.
16
16
  */
17
- readonly writeScope?: string;
17
+ readonly write?: string;
18
+ }
19
+ export interface Options {
20
+ readonly issuer: string;
21
+ /**
22
+ * The origin browsers and agents reach this application at. The resource is its origin
23
+ * root, so any path here is discarded: one identifier covers `/api`, `/mcp` and sockets.
24
+ */
25
+ readonly publicUrl: URL;
26
+ readonly scopes: Scopes;
27
+ /** `false` rejects API keys without consulting the issuer; only OAuth access tokens are accepted. */
28
+ readonly apiKeys?: boolean;
18
29
  }
19
30
  /**
20
31
  * A refusal a caller outside the router can send as-is: an RFC 6750 challenge or a
@@ -33,7 +44,7 @@ export type Admission = {
33
44
  readonly ok: false;
34
45
  readonly refusal: Refusal;
35
46
  };
36
- /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per audience. */
47
+ /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per application. */
37
48
  export declare const make: (options: Options) => Effect.Effect<{
38
49
  verifier: {
39
50
  verifyToken: (token: string) => Effect.Effect<{
@@ -52,7 +63,7 @@ export declare const make: (options: Options) => Effect.Effect<{
52
63
  keyId: string;
53
64
  };
54
65
  expiresAt: undefined;
55
- }, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
66
+ }, InsufficientScope | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
56
67
  verify: (authorization: string | null | undefined) => Effect.Effect<{
57
68
  subject: string;
58
69
  scopes: string[];
@@ -69,24 +80,28 @@ export declare const make: (options: Options) => Effect.Effect<{
69
80
  keyId: string;
70
81
  };
71
82
  expiresAt: undefined;
72
- }, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
83
+ }, InsufficientScope | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
73
84
  };
74
85
  discovery: {
75
86
  layer: import("effect/Layer").Layer<never, never, import("effect/unstable/http/HttpRouter").HttpRouter>;
76
87
  metadataUrl: string;
77
88
  challenge: (challenge?: Authentication.BearerChallengeOptions) => string;
78
89
  };
79
- challenge: (error?: "invalid_token" | "insufficient_scope") => {
80
- "www-authenticate": string;
81
- };
82
- headers: (error: AdmissionError) => {
83
- [x: string]: string;
84
- };
85
- admit: (authorization: string | null | undefined, access?: Action.Access | undefined) => Effect.Effect<Admission, never, never>;
90
+ admit: (authorization: string | null | undefined, access: Action.Access) => Effect.Effect<Admission, never, never>;
86
91
  authorize: (action: Action.Any) => Effect.Effect<void, InsufficientScope, CurrentPrincipal>;
92
+ session: import("@gjermundgaraba/effect-actions/ActionGroup").Implementation<import("@gjermundgaraba/effect-actions/ActionGroup").Group<"session", readonly [Action.Action<"whoami", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{
93
+ readonly subject: Schema.NonEmptyString;
94
+ readonly issuer: Schema.NonEmptyString;
95
+ readonly scopes: Schema.$Array<Schema.String>;
96
+ }>, readonly [], "read", true, false>], []>, {
97
+ whoami: () => Effect.Effect<{
98
+ subject: string;
99
+ issuer: string;
100
+ scopes: readonly string[];
101
+ }, never, CurrentPrincipal>;
102
+ }, never, never>;
87
103
  resource: string;
88
104
  issuer: string;
89
- writeScope: string | undefined;
90
105
  }, ConfigurationError, import("effect/unstable/http/HttpClient").HttpClient>;
91
106
  export type Resource = Effect.Success<ReturnType<typeof make>>;
92
107
  /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
package/dist/resource.js CHANGED
@@ -1,38 +1,53 @@
1
- import { Context, Effect, Result } from "effect";
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 { admissionErrors, ConfigurationError, describeError, Forbidden, InsufficientScope, RateLimited, Unauthorized, } from "./errors.js";
4
+ import { authenticationErrors, ConfigurationError, InsufficientScope, RateLimited, Unauthorized, } from "./errors.js";
5
+ import { Session } from "./session.js";
5
6
  import * as Verifier from "./verify.js";
6
- const describe = describeError(admissionErrors);
7
+ const encoders = new Map(authenticationErrors.map((schema) => [
8
+ schema,
9
+ {
10
+ status: SchemaAST.resolveAt("httpApiStatus")(schema.ast) ?? 500,
11
+ encode: Schema.encodeUnknownEffect(Schema.toCodecJson(schema)),
12
+ },
13
+ ]));
14
+ /** One refusal as its own status and JSON body. An undeclared error is a defect. */
15
+ const describe = (error) => {
16
+ const schema = authenticationErrors.find((candidate) => Schema.is(candidate)(error));
17
+ const encoder = schema === undefined ? undefined : encoders.get(schema);
18
+ if (encoder === undefined)
19
+ return Effect.die(new Error("Undeclared error"));
20
+ return Effect.map(Effect.orDie(encoder.encode(error)), (body) => ({
21
+ status: encoder.status,
22
+ body: JSON.stringify(body),
23
+ }));
24
+ };
7
25
  export class CurrentPrincipal extends Context.Service()("@clankerauth/CurrentPrincipal") {
8
26
  }
9
- /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per audience. */
27
+ /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per application. */
10
28
  export const make = Effect.fn("Resource.make")(function* (options) {
29
+ // One resource per application, at the public origin root, trailing slash and all.
11
30
  // An MCP client sends `new URL(metadata.resource).href`, and the token's audience is
12
- // that string. Refuse an identifier that is not already in that form, rather than
13
- // normalizing it and leaving the issuer's registered resource to disagree: an origin
14
- // root is `https://app.example/`, with the trailing slash.
15
- yield* Effect.try({
16
- try: () => new URL(options.resource),
17
- catch: () => new ConfigurationError({ message: "Resource identifier is not a URL" }),
18
- }).pipe(Effect.filterOrFail((url) => url.href === options.resource, () => new ConfigurationError({
19
- message: `Resource identifier must be canonical: use ${new URL(options.resource).href}`,
20
- })));
31
+ // that string, so deriving it here is what keeps the two in agreement.
32
+ const identifier = new URL("/", options.publicUrl).href;
33
+ const scopesSupported = options.scopes.write === undefined
34
+ ? [options.scopes.read]
35
+ : [options.scopes.read, options.scopes.write];
21
36
  const discovery = yield* Effect.try({
22
37
  try: () => Authentication.protectedResource({
23
- resource: options.resource,
38
+ resource: identifier,
24
39
  authorizationServers: [options.issuer],
25
- scopesSupported: options.scopes,
40
+ scopesSupported,
26
41
  }),
27
42
  catch: () => new ConfigurationError({ message: "Invalid resource metadata configuration" }),
28
43
  });
29
- const verifier = yield* Verifier.make(options);
30
- const scope = options.requiredScopes?.length ? options.requiredScopes.join(" ") : undefined;
31
- const challengeValue = (error, missing) => discovery.challenge({ error, scope: missing ?? scope });
32
- /** Bearer challenge for this resource; no error code when the request carried no credentials. */
33
- const challenge = (error) => ({
34
- "www-authenticate": challengeValue(error),
44
+ const verifier = yield* Verifier.make({
45
+ issuer: options.issuer,
46
+ resource: identifier,
47
+ requiredScopes: [options.scopes.read],
48
+ apiKeys: options.apiKeys,
35
49
  });
50
+ const challengeValue = (error, missing) => discovery.challenge({ error, scope: missing ?? options.scopes.read });
36
51
  /**
37
52
  * The one header besides the content type that a refusal carries: an RFC 6750
38
53
  * challenge naming only the missing scope, or an RFC 6585 retry hint.
@@ -42,20 +57,12 @@ export const make = Effect.fn("Resource.make")(function* (options) {
42
57
  return ["retry-after", "60"];
43
58
  if (error instanceof InsufficientScope)
44
59
  return ["www-authenticate", challengeValue("insufficient_scope", error.scope)];
45
- if (error instanceof Forbidden)
46
- return ["www-authenticate", challengeValue("insufficient_scope")];
47
60
  // RFC 6750 §3.1: a request that carried no credentials gets no error code.
48
61
  if (error instanceof Unauthorized)
49
62
  return ["www-authenticate", credential ? challengeValue("invalid_token") : challengeValue()];
50
63
  return undefined;
51
64
  };
52
65
  const asRecord = (header) => header === undefined ? {} : { [header[0]]: header[1] };
53
- /** The header a caller rendering its own response adds for this failure. */
54
- const headers = (error) => asRecord(refusalHeader(error, true));
55
- /** The write scope, if one is configured and this principal lacks it. */
56
- const missingScope = (principal) => options.writeScope !== undefined && !principal.scopes.includes(options.writeScope)
57
- ? options.writeScope
58
- : undefined;
59
66
  const refuse = (error, authorization) => Effect.map(describe(error), ({ status, body }) => ({
60
67
  ok: false,
61
68
  refusal: {
@@ -68,12 +75,16 @@ export const make = Effect.fn("Resource.make")(function* (options) {
68
75
  body,
69
76
  },
70
77
  }));
78
+ /** The write scope, if one is configured and this principal lacks it. */
79
+ const missingScope = (principal) => options.scopes.write !== undefined && !principal.scopes.includes(options.scopes.write)
80
+ ? options.scopes.write
81
+ : undefined;
71
82
  /**
72
83
  * Verify one Authorization header value outside an Effect router: a Node `upgrade`
73
- * handler, a socket, a per-request endpoint. `access: "write"` also demands the
74
- * configured write scope, so a socket can require it before it is established.
84
+ * handler, a socket, a per-request endpoint. `access` is the same declaration an
85
+ * action carries, so `"write"` demands the write scope before a socket is established.
75
86
  */
76
- const admit = Effect.fn("Resource.admit")(function* (authorization, access = "read") {
87
+ const admit = Effect.fn("Resource.admit")(function* (authorization, access) {
77
88
  const verified = yield* Effect.result(verifier.verify(authorization));
78
89
  if (Result.isFailure(verified))
79
90
  return yield* refuse(verified.failure, authorization);
@@ -84,11 +95,11 @@ export const make = Effect.fn("Resource.make")(function* (options) {
84
95
  return { ok: true, principal };
85
96
  });
86
97
  /**
87
- * The effect-actions pre-handler hook. A read needs nothing beyond what verification
88
- * already required; a write needs the configured write scope. Without one it passes,
89
- * so a single-scope resource has no authorization code at all.
98
+ * The effect-actions pre-handler hook, bound once per surface. A read needs nothing
99
+ * beyond what verification already required; a write needs the configured write scope.
100
+ * Without one it passes, so a single-scope resource has no authorization code at all.
90
101
  */
91
- const authorize = (action) => action.access === "read" || options.writeScope === undefined
102
+ const authorize = (action) => action.access === "read" || options.scopes.write === undefined
92
103
  ? Effect.void
93
104
  : Effect.flatMap(CurrentPrincipal, (principal) => {
94
105
  const missing = missingScope(principal);
@@ -96,22 +107,29 @@ export const make = Effect.fn("Resource.make")(function* (options) {
96
107
  ? Effect.void
97
108
  : Effect.fail(new InsufficientScope({ scope: missing }));
98
109
  });
110
+ /** The `session` group of `@gjermundgaraba/clankerauth-sdk/session`, already answered. */
111
+ const session = Session.implement({
112
+ whoami: () => Effect.map(CurrentPrincipal, ({ subject, scopes }) => ({
113
+ subject,
114
+ issuer: options.issuer,
115
+ scopes,
116
+ })),
117
+ });
99
118
  return {
100
119
  verifier,
101
120
  discovery,
102
- challenge,
103
- headers,
104
121
  admit,
105
122
  authorize,
106
- resource: options.resource,
123
+ session,
124
+ resource: identifier,
107
125
  issuer: options.issuer,
108
- writeScope: options.writeScope,
109
126
  };
110
127
  });
111
128
  /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
112
129
  export const middleware = (resource) => Authentication.middleware(CurrentPrincipal, Effect.gen(function* () {
113
130
  const request = yield* HttpServerRequest.HttpServerRequest;
114
- const admission = yield* resource.admit(request.headers.authorization);
131
+ // Read level: an action's own `access` is what the authorization hook then enforces.
132
+ const admission = yield* resource.admit(request.headers.authorization, "read");
115
133
  if (admission.ok)
116
134
  return admission.principal;
117
135
  return yield* Effect.fail(HttpServerResponse.text(admission.refusal.body, {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The identity a browser page needs, without any verification code. This entry point is
3
+ * browser-safe: it declares the `session` contract and builds the issuer's sign-out URL,
4
+ * so a page imports it directly and a server answers it with `resource.session`.
5
+ */
6
+ import { Schema } from "effect";
7
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
8
+ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
9
+ /** The verified credential behind a request, as the resource server read it. */
10
+ export declare const Principal: Schema.Struct<{
11
+ readonly subject: Schema.NonEmptyString;
12
+ readonly issuer: Schema.NonEmptyString;
13
+ readonly scopes: Schema.$Array<Schema.String>;
14
+ }>;
15
+ export type Principal = typeof Principal.Type;
16
+ /**
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)`.
19
+ */
20
+ export declare const Session: ActionGroup.Group<"session", readonly [Action.Action<"whoami", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{
21
+ readonly subject: Schema.NonEmptyString;
22
+ readonly issuer: Schema.NonEmptyString;
23
+ readonly scopes: Schema.$Array<Schema.String>;
24
+ }>, readonly [], "read", true, false>], []>;
25
+ /**
26
+ * Sign out at the issuer, which ends its session and every app's forward cookie, then
27
+ * return to `returnTo`. It is a plain link: any page under the cookie domain may use it.
28
+ */
29
+ export declare const signOutUrl: (issuer: string, returnTo: string) => string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The identity a browser page needs, without any verification code. This entry point is
3
+ * browser-safe: it declares the `session` contract and builds the issuer's sign-out URL,
4
+ * so a page imports it directly and a server answers it with `resource.session`.
5
+ */
6
+ import { Schema } from "effect";
7
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
8
+ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
9
+ /** The verified credential behind a request, as the resource server read it. */
10
+ export const Principal = Schema.Struct({
11
+ subject: Schema.NonEmptyString,
12
+ issuer: Schema.NonEmptyString,
13
+ scopes: Schema.Array(Schema.String),
14
+ });
15
+ /**
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)`.
18
+ */
19
+ export const Session = ActionGroup.make({ name: "session" }, Action.make("whoami", {
20
+ description: "The verified subject, issuer and scopes behind this request's credential.",
21
+ access: "read",
22
+ success: Principal,
23
+ mcp: false,
24
+ }));
25
+ /**
26
+ * Sign out at the issuer, which ends its session and every app's forward cookie, then
27
+ * return to `returnTo`. It is a plain link: any page under the cookie domain may use it.
28
+ */
29
+ export const signOutUrl = (issuer, returnTo) => {
30
+ const url = new URL("/forward-auth/logout", issuer);
31
+ url.searchParams.set("rd", returnTo);
32
+ return url.href;
33
+ };
package/dist/verify.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Effect } from "effect";
2
2
  import { HttpClient } from "effect/unstable/http";
3
- import { ConfigurationError, Forbidden, ProviderUnavailable, RateLimited, Unauthorized } from "./errors.ts";
3
+ import { ConfigurationError, InsufficientScope, ProviderUnavailable, RateLimited, Unauthorized } from "./errors.ts";
4
+ import type { AuthenticationError } from "./errors.ts";
4
5
  export interface Principal {
5
6
  readonly subject: string;
6
7
  readonly scopes: readonly string[];
@@ -27,10 +28,9 @@ export interface Options {
27
28
  readonly apiKeys?: boolean;
28
29
  }
29
30
  export interface Verifier {
30
- readonly verify: (authorization: string | null | undefined) => Effect.Effect<Principal, VerificationError>;
31
- readonly verifyToken: (token: string) => Effect.Effect<Principal, VerificationError>;
31
+ readonly verify: (authorization: string | null | undefined) => Effect.Effect<Principal, AuthenticationError>;
32
+ readonly verifyToken: (token: string) => Effect.Effect<Principal, AuthenticationError>;
32
33
  }
33
- export type VerificationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable;
34
34
  /** Acquire once per resource; transport is selected by the application, not the SDK. */
35
35
  export declare const make: (options: Options) => Effect.Effect<{
36
36
  verifyToken: (token: string) => Effect.Effect<{
@@ -49,7 +49,7 @@ export declare const make: (options: Options) => Effect.Effect<{
49
49
  keyId: string;
50
50
  };
51
51
  expiresAt: undefined;
52
- }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
52
+ }, InsufficientScope | ProviderUnavailable | RateLimited | Unauthorized, never>;
53
53
  verify: (authorization: string | null | undefined) => Effect.Effect<{
54
54
  subject: string;
55
55
  scopes: string[];
@@ -66,5 +66,5 @@ export declare const make: (options: Options) => Effect.Effect<{
66
66
  keyId: string;
67
67
  };
68
68
  expiresAt: undefined;
69
- }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
69
+ }, InsufficientScope | ProviderUnavailable | RateLimited | Unauthorized, never>;
70
70
  }, ConfigurationError, HttpClient.HttpClient>;
package/dist/verify.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Clock, Effect, Exit, Schema, Semaphore } from "effect";
2
2
  import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
- import { ConfigurationError, Forbidden, ProviderUnavailable, RateLimited, Unauthorized, } from "./errors.js";
4
+ import { ConfigurationError, InsufficientScope, ProviderUnavailable, RateLimited, Unauthorized, } from "./errors.js";
5
5
  import { execute } from "./transport.js";
6
6
  const Jwks = Schema.Struct({
7
7
  keys: Schema.Array(Schema.Struct({
@@ -119,10 +119,10 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
119
119
  const verifyKey = Effect.fn("Verifier.apiKey")(function* (key) {
120
120
  const request = HttpClientRequest.post(endpoints.apiKey).pipe(HttpClientRequest.setHeader("authorization", `Bearer ${key}`), HttpClientRequest.bodyJsonUnsafe({ resource: options.resource }));
121
121
  const response = yield* execute(client, request);
122
- if (response.status === 401)
122
+ // The issuer answers 403 for a key with no grant on this resource. That is the same
123
+ // case as a token for another audience: the credential is not this resource's.
124
+ if (response.status === 401 || response.status === 403)
123
125
  return yield* unauthorized();
124
- if (response.status === 403)
125
- return yield* new Forbidden({ message: "Insufficient scope" });
126
126
  if (response.status === 429)
127
127
  return yield* new RateLimited({ message: "Authentication rate exceeded" });
128
128
  if (response.status !== 200)
@@ -151,8 +151,9 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
151
151
  ? Effect.fail(unauthorized())
152
152
  : verifyKey(token)
153
153
  : verifyJwt(token);
154
- if (options.requiredScopes?.some((scope) => !principal.scopes.includes(scope)))
155
- return yield* new Forbidden({ message: "Insufficient scope" });
154
+ const missing = options.requiredScopes?.find((scope) => !principal.scopes.includes(scope));
155
+ if (missing !== undefined)
156
+ return yield* new InsufficientScope({ scope: missing });
156
157
  return principal;
157
158
  }, Effect.timeoutOrElse({
158
159
  duration: "5 seconds",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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": {
@@ -12,11 +12,20 @@
12
12
  "dist"
13
13
  ],
14
14
  "type": "module",
15
+ "sideEffects": false,
15
16
  "exports": {
16
17
  ".": {
17
18
  "types": "./dist/index.d.ts",
18
19
  "import": "./dist/index.js"
19
20
  },
21
+ "./errors": {
22
+ "types": "./dist/errors.d.ts",
23
+ "import": "./dist/errors.js"
24
+ },
25
+ "./session": {
26
+ "types": "./dist/session.d.ts",
27
+ "import": "./dist/session.js"
28
+ },
20
29
  "./effect-actions": {
21
30
  "types": "./dist/effect-actions.d.ts",
22
31
  "import": "./dist/effect-actions.js"
@@ -30,7 +39,7 @@
30
39
  "jose": "6.2.12"
31
40
  },
32
41
  "devDependencies": {
33
- "@gjermundgaraba/effect-actions": "0.3.0",
42
+ "@gjermundgaraba/effect-actions": "0.4.1",
34
43
  "@modelcontextprotocol/client": "2.0.0",
35
44
  "@types/node": "26.6.2",
36
45
  "typescript": "7.0.2",
@@ -38,7 +47,7 @@
38
47
  "vite-plus": "0.3.3"
39
48
  },
40
49
  "peerDependencies": {
41
- "@gjermundgaraba/effect-actions": "0.3.0"
50
+ "@gjermundgaraba/effect-actions": "0.4.1"
42
51
  },
43
52
  "peerDependenciesMeta": {
44
53
  "@gjermundgaraba/effect-actions": {