@gjermundgaraba/clankerauth-sdk 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,28 +32,23 @@ 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
45
+ ## One resource per application
36
46
 
37
- Install the optional peer dependency and import the integration subpath:
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.
38
48
 
39
- ```sh
40
- vp add @gjermundgaraba/effect-actions@0.2.0
41
- ```
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.
42
50
 
43
- Core and integration declarations both type-check without `skipLibCheck`.
44
-
45
- Register a resource in the Clanker Auth dashboard. Its identifier is the exact audience URL, not just an origin. Acquire one resource capability per protected audience at application construction, not per request.
51
+ Acquire the resource once at application construction, never per request.
46
52
 
47
53
  ```ts
48
54
  import { Effect, Layer } from "effect";
@@ -54,66 +60,141 @@ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
54
60
  // Http and app are your effect-actions HTTP binding and implemented group.
55
61
  const routes = Layer.unwrap(
56
62
  Effect.gen(function* () {
57
- const api = yield* Resource.make({
58
- issuer: "https://auth.internal/api/auth",
59
- resource: "https://notes.internal/api",
60
- scopes: ["notes:read", "notes:write"],
61
- requiredScopes: ["notes:read"],
62
- });
63
- const mcp = yield* Resource.make({
63
+ const notes = yield* Resource.make({
64
64
  issuer: "https://auth.internal/api/auth",
65
- resource: "https://notes.internal/mcp",
66
- scopes: ["notes:read", "notes:write"],
67
- requiredScopes: ["notes:read"],
65
+ publicUrl: new URL("https://notes.internal"),
66
+ scopes: { read: "notes:read", write: "notes:write" },
68
67
  });
68
+
69
69
  return Layer.mergeAll(
70
- api.discovery.layer,
71
- mcp.discovery.layer,
72
- Http.layer(app).pipe(Layer.provide(Resource.middleware(api).layer)),
70
+ notes.discovery.layer,
71
+ Http.layer({ before: notes.authorize }, app, notes.session).pipe(
72
+ Layer.provide(Resource.middleware(notes).layer),
73
+ ),
73
74
  ActionMcp.layerHttp(
74
- { name: "notes", version: "1.0.0", path: "/mcp", protocols: [McpProtocol.v2026_07_28] },
75
+ {
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
+ },
75
83
  app,
76
- ).pipe(Layer.provide(Resource.middleware(mcp).layer)),
84
+ ).pipe(Layer.provide(Resource.middleware(notes).layer)),
77
85
  );
78
86
  }),
79
87
  ).pipe(Layer.provide(FetchHttpClient.layer));
80
88
  ```
81
89
 
82
- 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`.
90
+ 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`.
83
91
 
84
92
  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.
85
93
 
86
- An action reads identity from `CurrentPrincipal` and enforces its own permissions:
94
+ ## Scope enforcement
95
+
96
+ 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.
87
97
 
88
98
  ```ts
89
- import { Effect } from "effect";
90
- import { Forbidden } from "@gjermundgaraba/clankerauth-sdk";
91
- import { CurrentPrincipal } from "@gjermundgaraba/clankerauth-sdk/effect-actions";
92
-
93
- const write = Effect.gen(function* () {
94
- const principal = yield* CurrentPrincipal;
95
- if (!principal.scopes.includes("notes:write")) {
96
- return yield* new Forbidden({ message: "Write permission required" });
97
- }
98
- return principal.subject;
99
- });
99
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
100
+ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
101
+ import { authenticationErrors } from "@gjermundgaraba/clankerauth-sdk/errors";
102
+
103
+ const Notes = ActionGroup.make(
104
+ { name: "notes" },
105
+ Action.make("list", { description: "List notes", access: "read", success: Notes }),
106
+ Action.make("write", { description: "Write a note", access: "write", success: Note }),
107
+ );
108
+
109
+ // Declared on the surface, because the surface is what renders them — including the
110
+ // hook's own refusals, which is why the group no longer lists `InsufficientScope`.
111
+ const Http = ActionHttp.make({ apiPath: "/api", errors: authenticationErrors }, Notes, Session);
112
+
113
+ // And bound once per surface, so no handler and no group can forget it.
114
+ const routes = Http.layer({ before: notes.authorize }, Notes.implement(handlers), notes.session);
100
115
  ```
101
116
 
102
- Declare `Forbidden` in that action/group's error schemas. Middleware authenticates the request; it does not infer per-action policy or filter MCP tool discovery.
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.
103
118
 
104
- A principal contains `subject`, `scopes`, and `actor`: either `{ kind: "client", clientId }` or `{ kind: "key", keyId }`. The resource also exposes `verifier.verify(authorization)` and `verifier.verifyToken(token)` as Effects.
119
+ 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.
105
120
 
106
- 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.
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.
107
122
 
108
- 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.
123
+ 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.
124
+
125
+ Middleware authenticates the request and the hook authorizes it; neither filters MCP tool discovery.
126
+
127
+ ## Outside the router
128
+
129
+ 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:
130
+
131
+ ```ts
132
+ const admission = await Effect.runPromise(notes.admit(request.headers.authorization, "write"));
133
+
134
+ if (!admission.ok) {
135
+ const { status, headers, body } = admission.refusal;
136
+ response.writeHead(status, headers).end(body);
137
+ return;
138
+ }
139
+
140
+ connect(admission.principal);
141
+ ```
142
+
143
+ 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.
144
+
145
+ The resource also exposes `verifier.verify(authorization)` and `verifier.verifyToken(token)` as Effects for callers that render their own responses.
146
+
147
+ ## Request policy
148
+
149
+ Forward auth makes a browser's credential ambient: the proxy attaches an `Authorization` header to whatever the browser sends, including a cross-site navigation. So an application behind it checks where a request came from before anything reads the credential.
150
+
151
+ ```ts
152
+ import { RequestPolicy } from "@gjermundgaraba/clankerauth-sdk";
153
+
154
+ const policy = RequestPolicy.make({
155
+ publicUrl: new URL("https://notes.internal"),
156
+ allowedOrigins: ["wtf://app"], // a desktop shell's private renderer scheme
157
+ });
158
+
159
+ const routes = protectedRoutes.pipe(Layer.provide(policy.middleware.layer));
160
+
161
+ // The same decision from a Node upgrade handler, with no services:
162
+ if (
163
+ !policy.allows({
164
+ target: request.url,
165
+ host: request.headers.host,
166
+ origin: request.headers.origin,
167
+ })
168
+ ) {
169
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
170
+ }
171
+ ```
172
+
173
+ 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.
109
174
 
110
175
  ## Browser applications
111
176
 
112
- 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.
177
+ 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:
178
+
179
+ ```ts
180
+ import { Principal, Session, signOutUrl } from "@gjermundgaraba/clankerauth-sdk/session";
181
+
182
+ // `Session` is the contract; the server answers it with `resource.session`.
183
+ const principal: Principal = await api.session.whoami({ payload: {} });
184
+ link.href = signOutUrl(principal.issuer, `${location.origin}/`);
185
+ ```
186
+
187
+ `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`.
113
188
 
114
189
  ## Errors and observability
115
190
 
116
- Schema-tagged errors: `Unauthorized` (401), `Forbidden` (403), `RateLimited` (429) and `ProviderUnavailable` (503). Invalid construction fails with `ConfigurationError`.
191
+ 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`.
192
+
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.
194
+
195
+ 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.
196
+
197
+ 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.
117
198
 
118
199
  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.
119
200
 
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>;
@@ -31,13 +31,24 @@ export declare class ProviderUnavailable extends ProviderUnavailable_base {
31
31
  readonly cause?: unknown;
32
32
  });
33
33
  }
34
+ declare const InsufficientScope_base: Schema.Class<InsufficientScope, Schema.TaggedStruct<"InsufficientScope", {
35
+ readonly scope: Schema.String;
36
+ }>, import("effect/Cause").YieldableError>;
37
+ /**
38
+ * A verified credential lacks one scope the requested access needs. It names only
39
+ * the missing scope, so a refusal never enumerates the resource's permissions.
40
+ */
41
+ export declare class InsufficientScope extends InsufficientScope_base {
42
+ }
34
43
  declare const ConfigurationError_base: Schema.Class<ConfigurationError, Schema.TaggedStruct<"ConfigurationError", {
35
44
  readonly message: Schema.String;
36
45
  }>, import("effect/Cause").YieldableError>;
37
46
  export declare class ConfigurationError extends ConfigurationError_base {
38
47
  }
39
- export declare const authenticationErrors: readonly [typeof Unauthorized, typeof Forbidden, typeof RateLimited, typeof ProviderUnavailable];
40
- export type AuthenticationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable;
41
- /** Encode one of the declared schemas as JSON with its `httpApiStatus`. Undeclared errors are defects. */
42
- export declare const encodeError: <const Schemas extends ReadonlyArray<Schema.Top>>(schemas: Schemas) => (error: Schemas[number]["Type"], headers?: Headers.Input) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, { [K in keyof Schemas]: Schemas[K]["EncodingServices"]; }[number]>;
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;
43
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 }) {
@@ -17,24 +20,21 @@ export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailab
17
20
  Object.defineProperty(this, "cause", { value: options.cause });
18
21
  }
19
22
  }
23
+ /**
24
+ * A verified credential lacks one scope the requested access needs. It names only
25
+ * the missing scope, so a refusal never enumerates the resource's permissions.
26
+ */
27
+ export class InsufficientScope extends Schema.TaggedError()("InsufficientScope", { scope: Schema.String }, { httpApiStatus: 403 }) {
28
+ }
20
29
  export class ConfigurationError extends Schema.TaggedError()("ConfigurationError", { message: Schema.String }) {
21
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
+ */
22
35
  export const authenticationErrors = [
23
36
  Unauthorized,
24
- Forbidden,
37
+ InsufficientScope,
25
38
  RateLimited,
26
39
  ProviderUnavailable,
27
40
  ];
28
- /** Encode one of the declared schemas as JSON with its `httpApiStatus`. Undeclared errors are defects. */
29
- export const encodeError = (schemas) => {
30
- const encode = HttpServerResponse.schemaJson(Schema.Union(schemas));
31
- return (error, headers) => {
32
- const schema = schemas.find((schema) => Schema.is(schema)(error));
33
- if (schema === undefined)
34
- return Effect.die(new Error("Undeclared error"));
35
- return encode(error, {
36
- status: SchemaAST.resolveAt("httpApiStatus")(schema.ast) ?? 500,
37
- headers,
38
- }).pipe(Effect.orDie);
39
- };
40
- };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  export * as Verifier from "./verify.ts";
2
- export type { Principal } from "./verify.ts";
3
- export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, ConfigurationError, type AuthenticationError, } from "./errors.ts";
2
+ export * as RequestPolicy from "./request-policy.ts";
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export * as Verifier from "./verify.js";
2
- export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, ConfigurationError, } from "./errors.js";
2
+ export * as RequestPolicy from "./request-policy.js";
@@ -0,0 +1,38 @@
1
+ import { HttpRouter } from "effect/unstable/http";
2
+ export interface Options {
3
+ /**
4
+ * The origin browsers reach this app at. The application has already validated it;
5
+ * its `host` (with port, when the scheme's default is not used) must equal the Host
6
+ * header, and its `origin` is the one Origin accepted by default.
7
+ */
8
+ readonly publicUrl: URL;
9
+ /**
10
+ * Origins accepted besides the public one, compared exactly. For a desktop shell
11
+ * whose renderer has a private scheme, such as `wtf://app`. No wildcards.
12
+ */
13
+ readonly allowedOrigins?: readonly string[];
14
+ }
15
+ /** One request, as a Node `upgrade` handler or a router middleware sees it. */
16
+ export interface Request {
17
+ /** The request target: `/a?b` or an absolute URL. Only its path is read. */
18
+ readonly target: string;
19
+ readonly host: string | undefined;
20
+ readonly origin: string | undefined;
21
+ }
22
+ /**
23
+ * Build the policy once, at startup. `allows` is the whole decision and takes no
24
+ * services, so a Node `upgrade` handler applies the same rule as the router.
25
+ */
26
+ export declare const make: (options: Options) => {
27
+ allows: (request: Request) => boolean;
28
+ /** Refuse anything `allows` rejects with an empty-bodied 403, before any handler. */
29
+ middleware: HttpRouter.Middleware<{
30
+ provides: never;
31
+ handles: never;
32
+ error: never;
33
+ requires: never;
34
+ layerError: never;
35
+ layerRequires: never;
36
+ }>;
37
+ };
38
+ export type Policy = ReturnType<typeof make>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which callers this app answers at all. Forward auth makes a browser's credential
3
+ * ambient: the proxy attaches an Authorization header to whatever the browser sends,
4
+ * including a cross-site navigation. So the app checks the two headers that say where
5
+ * a request came from, before anything reads the credential.
6
+ *
7
+ * Both checks are raw string comparisons on the headers as sent. Nothing is parsed,
8
+ * so nothing throws and no normalization can widen what is accepted: `notes.example`
9
+ * and `notes.example:443` are different hosts here, as they are to a browser's cookie.
10
+ */
11
+ import { Effect } from "effect";
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";
18
+ /** The path of a request target. Deliberately not `new URL`: this must not throw. */
19
+ const pathOf = (target) => {
20
+ const authority = target.indexOf("://");
21
+ const start = authority === -1 ? 0 : target.indexOf("/", authority + 3);
22
+ if (start === -1)
23
+ return "/";
24
+ const rest = target.slice(start);
25
+ const stop = rest.search(/[?#]/u);
26
+ return stop === -1 ? rest : rest.slice(0, stop);
27
+ };
28
+ const forbidden = HttpServerResponse.text("Forbidden", { status: 403 });
29
+ /**
30
+ * Build the policy once, at startup. `allows` is the whole decision and takes no
31
+ * services, so a Node `upgrade` handler applies the same rule as the router.
32
+ */
33
+ export const make = (options) => {
34
+ const host = options.publicUrl.host;
35
+ const origins = new Set([options.publicUrl.origin, ...(options.allowedOrigins ?? [])]);
36
+ const allows = (request) => {
37
+ if (pathOf(request.target) === probePath)
38
+ return true;
39
+ // A request with no Host is not a browser request for this app's origin.
40
+ if (request.host !== host)
41
+ return false;
42
+ return request.origin === undefined || origins.has(request.origin);
43
+ };
44
+ return {
45
+ allows,
46
+ /** Refuse anything `allows` rejects with an empty-bodied 403, before any handler. */
47
+ middleware: HttpRouter.middleware((httpEffect) => Effect.gen(function* () {
48
+ const request = yield* HttpServerRequest.HttpServerRequest;
49
+ const permitted = allows({
50
+ target: request.originalUrl,
51
+ host: request.headers.host,
52
+ origin: request.headers.origin,
53
+ });
54
+ return permitted ? yield* httpEffect : forbidden;
55
+ })),
56
+ };
57
+ };
@@ -1,15 +1,50 @@
1
- import { Context, Effect } from "effect";
1
+ import { Context, Effect, Schema } from "effect";
2
+ import type * as Action from "@gjermundgaraba/effect-actions/Action";
2
3
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
- import { ConfigurationError, Forbidden, RateLimited, Unauthorized } from "./errors.ts";
4
- import type { AuthenticationError } from "./errors.ts";
4
+ import { ConfigurationError, InsufficientScope, RateLimited, Unauthorized } 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 {
8
8
  }
9
- export interface Options extends Verifier.Options {
10
- 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;
13
+ /**
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
+ */
17
+ readonly write?: string;
11
18
  }
12
- /** One resource: its verifier and discovery. Acquire once per protected audience. */
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;
29
+ }
30
+ /**
31
+ * A refusal a caller outside the router can send as-is: an RFC 6750 challenge or a
32
+ * `Retry-After`, `Cache-Control: no-store`, and the error's own JSON encoding.
33
+ */
34
+ export interface Refusal {
35
+ readonly status: number;
36
+ readonly headers: Readonly<Record<string, string>>;
37
+ readonly body: string;
38
+ }
39
+ /** What one Authorization header value amounts to for this resource. */
40
+ export type Admission = {
41
+ readonly ok: true;
42
+ readonly principal: Verifier.Principal;
43
+ } | {
44
+ readonly ok: false;
45
+ readonly refusal: Refusal;
46
+ };
47
+ /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per application. */
13
48
  export declare const make: (options: Options) => Effect.Effect<{
14
49
  verifier: {
15
50
  verifyToken: (token: string) => Effect.Effect<{
@@ -19,6 +54,7 @@ export declare const make: (options: Options) => Effect.Effect<{
19
54
  kind: "client";
20
55
  clientId: string;
21
56
  };
57
+ expiresAt: number;
22
58
  } | {
23
59
  subject: string;
24
60
  scopes: readonly string[];
@@ -26,7 +62,8 @@ export declare const make: (options: Options) => Effect.Effect<{
26
62
  kind: "key";
27
63
  keyId: string;
28
64
  };
29
- }, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
65
+ expiresAt: undefined;
66
+ }, InsufficientScope | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
30
67
  verify: (authorization: string | null | undefined) => Effect.Effect<{
31
68
  subject: string;
32
69
  scopes: string[];
@@ -34,6 +71,7 @@ export declare const make: (options: Options) => Effect.Effect<{
34
71
  kind: "client";
35
72
  clientId: string;
36
73
  };
74
+ expiresAt: number;
37
75
  } | {
38
76
  subject: string;
39
77
  scopes: readonly string[];
@@ -41,28 +79,32 @@ export declare const make: (options: Options) => Effect.Effect<{
41
79
  kind: "key";
42
80
  keyId: string;
43
81
  };
44
- }, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
82
+ expiresAt: undefined;
83
+ }, InsufficientScope | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
45
84
  };
46
85
  discovery: {
47
86
  layer: import("effect/Layer").Layer<never, never, import("effect/unstable/http/HttpRouter").HttpRouter>;
48
87
  metadataUrl: string;
49
88
  challenge: (challenge?: Authentication.BearerChallengeOptions) => string;
50
89
  };
51
- challenge: (error?: "invalid_token" | "insufficient_scope") => {
52
- "www-authenticate": string;
53
- };
54
- headers: (error: AuthenticationError) => {
55
- "www-authenticate": string;
56
- } | {
57
- "retry-after": string;
58
- } | {
59
- "retry-after"?: undefined;
60
- };
90
+ admit: (authorization: string | null | undefined, access: Action.Access) => Effect.Effect<Admission, never, never>;
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>;
61
103
  resource: string;
62
104
  issuer: string;
63
105
  }, ConfigurationError, import("effect/unstable/http/HttpClient").HttpClient>;
64
106
  export type Resource = Effect.Success<ReturnType<typeof make>>;
65
- /** Bearer authentication providing `CurrentPrincipal`. */
107
+ /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
66
108
  export declare const middleware: (resource: Resource) => import("effect/unstable/http/HttpRouter").Middleware<{
67
109
  provides: CurrentPrincipal;
68
110
  handles: never;
package/dist/resource.js CHANGED
@@ -1,54 +1,139 @@
1
- import { Context, Effect } from "effect";
1
+ import { Context, Effect, Result, Schema, SchemaAST } from "effect";
2
2
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
- import { HttpServerRequest } from "effect/unstable/http";
4
- import { authenticationErrors, ConfigurationError, encodeError, Forbidden, RateLimited, Unauthorized, } from "./errors.js";
3
+ import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
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 encode = encodeError(authenticationErrors);
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 and discovery. Acquire once per protected 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.
30
+ // An MCP client sends `new URL(metadata.resource).href`, and the token's audience is
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];
11
36
  const discovery = yield* Effect.try({
12
37
  try: () => Authentication.protectedResource({
13
- resource: options.resource,
38
+ resource: identifier,
14
39
  authorizationServers: [options.issuer],
15
- scopesSupported: options.scopes,
40
+ scopesSupported,
16
41
  }),
17
42
  catch: () => new ConfigurationError({ message: "Invalid resource metadata configuration" }),
18
43
  });
19
- const verifier = yield* Verifier.make(options);
20
- const scope = options.requiredScopes?.length ? options.requiredScopes.join(" ") : undefined;
21
- /** Bearer challenge for this resource; no error code when the request carried no credentials. */
22
- const challenge = (error) => ({
23
- "www-authenticate": discovery.challenge({ error, scope }),
44
+ const verifier = yield* Verifier.make({
45
+ issuer: options.issuer,
46
+ resource: identifier,
47
+ requiredScopes: [options.scopes.read],
48
+ apiKeys: options.apiKeys,
24
49
  });
25
- const headers = (error) => {
50
+ const challengeValue = (error, missing) => discovery.challenge({ error, scope: missing ?? options.scopes.read });
51
+ /**
52
+ * The one header besides the content type that a refusal carries: an RFC 6750
53
+ * challenge naming only the missing scope, or an RFC 6585 retry hint.
54
+ */
55
+ const refusalHeader = (error, credential) => {
26
56
  if (error instanceof RateLimited)
27
- return { "retry-after": "60" };
28
- if (error instanceof Forbidden)
29
- return challenge("insufficient_scope");
57
+ return ["retry-after", "60"];
58
+ if (error instanceof InsufficientScope)
59
+ return ["www-authenticate", challengeValue("insufficient_scope", error.scope)];
60
+ // RFC 6750 §3.1: a request that carried no credentials gets no error code.
30
61
  if (error instanceof Unauthorized)
31
- return challenge("invalid_token");
32
- return {};
62
+ return ["www-authenticate", credential ? challengeValue("invalid_token") : challengeValue()];
63
+ return undefined;
33
64
  };
65
+ const asRecord = (header) => header === undefined ? {} : { [header[0]]: header[1] };
66
+ const refuse = (error, authorization) => Effect.map(describe(error), ({ status, body }) => ({
67
+ ok: false,
68
+ refusal: {
69
+ status,
70
+ headers: {
71
+ ...asRecord(refusalHeader(error, authorization !== undefined && authorization !== null)),
72
+ "content-type": "application/json",
73
+ "cache-control": "no-store",
74
+ },
75
+ body,
76
+ },
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;
82
+ /**
83
+ * Verify one Authorization header value outside an Effect router: a Node `upgrade`
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.
86
+ */
87
+ const admit = Effect.fn("Resource.admit")(function* (authorization, access) {
88
+ const verified = yield* Effect.result(verifier.verify(authorization));
89
+ if (Result.isFailure(verified))
90
+ return yield* refuse(verified.failure, authorization);
91
+ const principal = verified.success;
92
+ const missing = access === "write" ? missingScope(principal) : undefined;
93
+ if (missing !== undefined)
94
+ return yield* refuse(new InsufficientScope({ scope: missing }), authorization);
95
+ return { ok: true, principal };
96
+ });
97
+ /**
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.
101
+ */
102
+ const authorize = (action) => action.access === "read" || options.scopes.write === undefined
103
+ ? Effect.void
104
+ : Effect.flatMap(CurrentPrincipal, (principal) => {
105
+ const missing = missingScope(principal);
106
+ return missing === undefined
107
+ ? Effect.void
108
+ : Effect.fail(new InsufficientScope({ scope: missing }));
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
+ });
34
118
  return {
35
119
  verifier,
36
120
  discovery,
37
- challenge,
38
- headers,
39
- resource: options.resource,
121
+ admit,
122
+ authorize,
123
+ session,
124
+ resource: identifier,
40
125
  issuer: options.issuer,
41
126
  };
42
127
  });
43
- /** Bearer authentication providing `CurrentPrincipal`. */
128
+ /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
44
129
  export const middleware = (resource) => Authentication.middleware(CurrentPrincipal, Effect.gen(function* () {
45
130
  const request = yield* HttpServerRequest.HttpServerRequest;
46
- return yield* resource.verifier.verify(request.headers.authorization);
47
- }).pipe(Effect.catch((error) => Effect.gen(function* () {
48
- const request = yield* HttpServerRequest.HttpServerRequest;
49
- // RFC 6750 §3.1: a request that carried no credentials gets no error code.
50
- const headers = error instanceof Unauthorized && request.headers.authorization === undefined
51
- ? resource.challenge()
52
- : resource.headers(error);
53
- return yield* Effect.flatMap(encode(error, headers), Effect.fail);
54
- }))));
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");
133
+ if (admission.ok)
134
+ return admission.principal;
135
+ return yield* Effect.fail(HttpServerResponse.text(admission.refusal.body, {
136
+ status: admission.refusal.status,
137
+ headers: admission.refusal.headers,
138
+ }));
139
+ }));
@@ -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[];
@@ -11,6 +12,13 @@ export interface Principal {
11
12
  readonly kind: "key";
12
13
  readonly keyId: string;
13
14
  };
15
+ /**
16
+ * When this credential stops being valid, in epoch milliseconds: an access token's
17
+ * verified `exp`, so a host never decodes the token again. `undefined` for an API
18
+ * key, which has no token lifetime and is re-verified against the issuer on every
19
+ * request; a host that caches a key decision must choose its own bound.
20
+ */
21
+ readonly expiresAt: number | undefined;
14
22
  }
15
23
  export interface Options {
16
24
  readonly issuer: string;
@@ -20,10 +28,9 @@ export interface Options {
20
28
  readonly apiKeys?: boolean;
21
29
  }
22
30
  export interface Verifier {
23
- readonly verify: (authorization: string | null | undefined) => Effect.Effect<Principal, VerificationError>;
24
- 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>;
25
33
  }
26
- export type VerificationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable;
27
34
  /** Acquire once per resource; transport is selected by the application, not the SDK. */
28
35
  export declare const make: (options: Options) => Effect.Effect<{
29
36
  verifyToken: (token: string) => Effect.Effect<{
@@ -33,6 +40,7 @@ export declare const make: (options: Options) => Effect.Effect<{
33
40
  kind: "client";
34
41
  clientId: string;
35
42
  };
43
+ expiresAt: number;
36
44
  } | {
37
45
  subject: string;
38
46
  scopes: readonly string[];
@@ -40,7 +48,8 @@ export declare const make: (options: Options) => Effect.Effect<{
40
48
  kind: "key";
41
49
  keyId: string;
42
50
  };
43
- }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
51
+ expiresAt: undefined;
52
+ }, InsufficientScope | ProviderUnavailable | RateLimited | Unauthorized, never>;
44
53
  verify: (authorization: string | null | undefined) => Effect.Effect<{
45
54
  subject: string;
46
55
  scopes: string[];
@@ -48,6 +57,7 @@ export declare const make: (options: Options) => Effect.Effect<{
48
57
  kind: "client";
49
58
  clientId: string;
50
59
  };
60
+ expiresAt: number;
51
61
  } | {
52
62
  subject: string;
53
63
  scopes: readonly string[];
@@ -55,5 +65,6 @@ export declare const make: (options: Options) => Effect.Effect<{
55
65
  kind: "key";
56
66
  keyId: string;
57
67
  };
58
- }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
68
+ expiresAt: undefined;
69
+ }, InsufficientScope | ProviderUnavailable | RateLimited | Unauthorized, never>;
59
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({
@@ -18,6 +18,7 @@ const Claims = Schema.Struct({
18
18
  sub: Schema.NonEmptyString,
19
19
  client_id: Schema.NonEmptyString,
20
20
  scope: Schema.String,
21
+ exp: Schema.Finite,
21
22
  });
22
23
  const KeyResponse = Schema.Struct({
23
24
  keyId: Schema.NonEmptyString,
@@ -111,15 +112,17 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
111
112
  subject: claims.sub,
112
113
  scopes: claims.scope.split(" ").filter(Boolean),
113
114
  actor: { kind: "client", clientId: claims.client_id },
115
+ // `jwtVerify` already checked this claim against the current time.
116
+ expiresAt: claims.exp * 1000,
114
117
  };
115
118
  });
116
119
  const verifyKey = Effect.fn("Verifier.apiKey")(function* (key) {
117
120
  const request = HttpClientRequest.post(endpoints.apiKey).pipe(HttpClientRequest.setHeader("authorization", `Bearer ${key}`), HttpClientRequest.bodyJsonUnsafe({ resource: options.resource }));
118
121
  const response = yield* execute(client, request);
119
- 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)
120
125
  return yield* unauthorized();
121
- if (response.status === 403)
122
- return yield* new Forbidden({ message: "Insufficient scope" });
123
126
  if (response.status === 429)
124
127
  return yield* new RateLimited({ message: "Authentication rate exceeded" });
125
128
  if (response.status !== 200)
@@ -137,6 +140,9 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
137
140
  subject: body.ownerId,
138
141
  scopes: body.scopes,
139
142
  actor: { kind: "key", keyId: body.keyId },
143
+ // A key's own expiry is enforced above, on every verification; it is not a
144
+ // lifetime a caller may hold a decision for.
145
+ expiresAt: undefined,
140
146
  };
141
147
  }, Effect.scoped);
142
148
  const verifyToken = Effect.fn("Verifier.verifyToken")(function* (token) {
@@ -145,8 +151,9 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
145
151
  ? Effect.fail(unauthorized())
146
152
  : verifyKey(token)
147
153
  : verifyJwt(token);
148
- if (options.requiredScopes?.some((scope) => !principal.scopes.includes(scope)))
149
- 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 });
150
157
  return principal;
151
158
  }, Effect.timeoutOrElse({
152
159
  duration: "5 seconds",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
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,14 +39,15 @@
30
39
  "jose": "6.2.12"
31
40
  },
32
41
  "devDependencies": {
33
- "@gjermundgaraba/effect-actions": "0.2.0",
42
+ "@gjermundgaraba/effect-actions": "0.4.0",
43
+ "@modelcontextprotocol/client": "2.0.0",
34
44
  "@types/node": "26.6.2",
35
45
  "typescript": "7.0.2",
36
46
  "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
37
47
  "vite-plus": "0.3.3"
38
48
  },
39
49
  "peerDependencies": {
40
- "@gjermundgaraba/effect-actions": "0.2.0"
50
+ "@gjermundgaraba/effect-actions": "0.4.0"
41
51
  },
42
52
  "peerDependenciesMeta": {
43
53
  "@gjermundgaraba/effect-actions": {