@gjermundgaraba/clankerauth-sdk 0.5.0 → 0.6.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
@@ -37,12 +37,18 @@ const makeVerifier = Verifier.make({
37
37
  Install the optional peer dependency and import the integration subpath:
38
38
 
39
39
  ```sh
40
- vp add @gjermundgaraba/effect-actions@0.2.0
40
+ vp add @gjermundgaraba/effect-actions@0.3.0
41
41
  ```
42
42
 
43
43
  Core and integration declarations both type-check without `skipLibCheck`.
44
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.
45
+ ## One resource per application
46
+
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
+
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.
50
+
51
+ Acquire the resource once at application construction, never per request.
46
52
 
47
53
  ```ts
48
54
  import { Effect, Layer } from "effect";
@@ -54,26 +60,21 @@ 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",
65
+ resource: "https://notes.internal/",
66
66
  scopes: ["notes:read", "notes:write"],
67
67
  requiredScopes: ["notes:read"],
68
+ writeScope: "notes:write",
68
69
  });
70
+
69
71
  return Layer.mergeAll(
70
- api.discovery.layer,
71
- mcp.discovery.layer,
72
- Http.layer(app).pipe(Layer.provide(Resource.middleware(api).layer)),
72
+ notes.discovery.layer,
73
+ Http.layer(app).pipe(Layer.provide(Resource.middleware(notes).layer)),
73
74
  ActionMcp.layerHttp(
74
75
  { name: "notes", version: "1.0.0", path: "/mcp", protocols: [McpProtocol.v2026_07_28] },
75
76
  app,
76
- ).pipe(Layer.provide(Resource.middleware(mcp).layer)),
77
+ ).pipe(Layer.provide(Resource.middleware(notes).layer)),
77
78
  );
78
79
  }),
79
80
  ).pipe(Layer.provide(FetchHttpClient.layer));
@@ -83,25 +84,79 @@ Serve this layer with Effect's `HttpRouter` and your Node server layer. If using
83
84
 
84
85
  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
86
 
86
- An action reads identity from `CurrentPrincipal` and enforces its own permissions:
87
+ ## Scope enforcement
88
+
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.
87
90
 
88
91
  ```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
- });
92
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
93
+ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
94
+ import { InsufficientScope } from "@gjermundgaraba/clankerauth-sdk";
95
+
96
+ const Notes = ActionGroup.make(
97
+ { name: "notes", errors: [InsufficientScope] },
98
+ Action.make("list", { description: "List notes", access: "read", success: Notes }),
99
+ Action.make("write", { description: "Write a note", access: "write", success: Note }),
100
+ );
101
+
102
+ // `notes` is the acquired Resource above, configured with `writeScope`.
103
+ const app = Notes.implement(handlers, { before: notes.authorize });
104
+ ```
105
+
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.
107
+
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.
109
+
110
+ Middleware authenticates the request and the hook authorizes it; neither filters MCP tool discovery.
111
+
112
+ ## Outside the router
113
+
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`:
115
+
116
+ ```ts
117
+ const admission = await Effect.runPromise(notes.admit(request.headers.authorization, "write"));
118
+
119
+ if (!admission.ok) {
120
+ const { status, headers, body } = admission.refusal;
121
+ response.writeHead(status, headers).end(body);
122
+ return;
123
+ }
124
+
125
+ connect(admission.principal);
100
126
  ```
101
127
 
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.
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.
129
+
130
+ The resource also exposes `verifier.verify(authorization)` and `verifier.verifyToken(token)` as Effects for callers that render their own responses.
131
+
132
+ ## Request policy
133
+
134
+ 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.
135
+
136
+ ```ts
137
+ import { RequestPolicy } from "@gjermundgaraba/clankerauth-sdk";
138
+
139
+ const policy = RequestPolicy.make({
140
+ publicUrl: new URL("https://notes.internal"),
141
+ allowedOrigins: ["wtf://app"], // a desktop shell's private renderer scheme
142
+ exemptPaths: ["/healthz"], // a container probe carries neither header
143
+ });
144
+
145
+ const routes = protectedRoutes.pipe(Layer.provide(policy.middleware.layer));
146
+
147
+ // The same decision from a Node upgrade handler, with no services:
148
+ if (
149
+ !policy.allows({
150
+ target: request.url,
151
+ host: request.headers.host,
152
+ origin: request.headers.origin,
153
+ })
154
+ ) {
155
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
156
+ }
157
+ ```
103
158
 
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.
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.
105
160
 
106
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.
107
162
 
@@ -113,7 +168,7 @@ Browser apps do not use this package. Put them behind a reverse proxy with the i
113
168
 
114
169
  ## Errors and observability
115
170
 
116
- Schema-tagged errors: `Unauthorized` (401), `Forbidden` (403), `RateLimited` (429) and `ProviderUnavailable` (503). Invalid construction fails with `ConfigurationError`.
171
+ Schema-tagged errors: `Unauthorized` (401), `Forbidden` (403), `InsufficientScope` (403), `RateLimited` (429) and `ProviderUnavailable` (503). Invalid construction fails with `ConfigurationError`.
117
172
 
118
173
  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
174
 
@@ -1,2 +1,3 @@
1
1
  export * as Resource from "./resource.ts";
2
2
  export { CurrentPrincipal } from "./resource.ts";
3
+ export { InsufficientScope } from "./errors.ts";
@@ -1,2 +1,3 @@
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
@@ -31,6 +31,15 @@ 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>;
@@ -38,6 +47,16 @@ export declare class ConfigurationError extends ConfigurationError_base {
38
47
  }
39
48
  export declare const authenticationErrors: readonly [typeof Unauthorized, typeof Forbidden, typeof RateLimited, typeof ProviderUnavailable];
40
49
  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]>;
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>;
43
62
  export {};
package/dist/errors.js CHANGED
@@ -17,6 +17,12 @@ export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailab
17
17
  Object.defineProperty(this, "cause", { value: options.cause });
18
18
  }
19
19
  }
20
+ /**
21
+ * A verified credential lacks one scope the requested access needs. It names only
22
+ * the missing scope, so a refusal never enumerates the resource's permissions.
23
+ */
24
+ export class InsufficientScope extends Schema.TaggedError()("InsufficientScope", { scope: Schema.String }, { httpApiStatus: 403 }) {
25
+ }
20
26
  export class ConfigurationError extends Schema.TaggedError()("ConfigurationError", { message: Schema.String }) {
21
27
  }
22
28
  export const authenticationErrors = [
@@ -25,16 +31,30 @@ export const authenticationErrors = [
25
31
  RateLimited,
26
32
  ProviderUnavailable,
27
33
  ];
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, {
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
+ {
36
41
  status: SchemaAST.resolveAt("httpApiStatus")(schema.ast) ?? 500,
37
- headers,
38
- }).pipe(Effect.orDie);
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
+ }));
39
54
  };
40
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,3 +1,4 @@
1
1
  export * as Verifier from "./verify.ts";
2
+ export * as RequestPolicy from "./request-policy.ts";
2
3
  export type { Principal } from "./verify.ts";
3
- export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, ConfigurationError, type AuthenticationError, } from "./errors.ts";
4
+ export { Unauthorized, Forbidden, InsufficientScope, RateLimited, ProviderUnavailable, ConfigurationError, authenticationErrors, admissionErrors, type AdmissionError, type AuthenticationError, } from "./errors.ts";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
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";
3
+ export { Unauthorized, Forbidden, InsufficientScope, RateLimited, ProviderUnavailable, ConfigurationError, authenticationErrors, admissionErrors, } from "./errors.js";
@@ -0,0 +1,43 @@
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
+ * 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
+ }
20
+ /** One request, as a Node `upgrade` handler or a router middleware sees it. */
21
+ export interface Request {
22
+ /** The request target: `/a?b` or an absolute URL. Only its path is read. */
23
+ readonly target: string;
24
+ readonly host: string | undefined;
25
+ readonly origin: string | undefined;
26
+ }
27
+ /**
28
+ * Build the policy once, at startup. `allows` is the whole decision and takes no
29
+ * services, so a Node `upgrade` handler applies the same rule as the router.
30
+ */
31
+ export declare const make: (options: Options) => {
32
+ allows: (request: Request) => boolean;
33
+ /** Refuse anything `allows` rejects with an empty-bodied 403, before any handler. */
34
+ middleware: HttpRouter.Middleware<{
35
+ provides: never;
36
+ handles: never;
37
+ error: never;
38
+ requires: never;
39
+ layerError: never;
40
+ layerRequires: never;
41
+ }>;
42
+ };
43
+ export type Policy = ReturnType<typeof make>;
@@ -0,0 +1,53 @@
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
+ /** The path of a request target. Deliberately not `new URL`: this must not throw. */
14
+ const pathOf = (target) => {
15
+ const authority = target.indexOf("://");
16
+ const start = authority === -1 ? 0 : target.indexOf("/", authority + 3);
17
+ if (start === -1)
18
+ return "/";
19
+ const rest = target.slice(start);
20
+ const stop = rest.search(/[?#]/u);
21
+ return stop === -1 ? rest : rest.slice(0, stop);
22
+ };
23
+ const forbidden = HttpServerResponse.text("Forbidden", { status: 403 });
24
+ /**
25
+ * Build the policy once, at startup. `allows` is the whole decision and takes no
26
+ * services, so a Node `upgrade` handler applies the same rule as the router.
27
+ */
28
+ export const make = (options) => {
29
+ const host = options.publicUrl.host;
30
+ const origins = new Set([options.publicUrl.origin, ...(options.allowedOrigins ?? [])]);
31
+ const exempt = new Set(options.exemptPaths ?? []);
32
+ const allows = (request) => {
33
+ if (exempt.has(pathOf(request.target)))
34
+ return true;
35
+ // A request with no Host is not a browser request for this app's origin.
36
+ if (request.host !== host)
37
+ return false;
38
+ return request.origin === undefined || origins.has(request.origin);
39
+ };
40
+ return {
41
+ allows,
42
+ /** Refuse anything `allows` rejects with an empty-bodied 403, before any handler. */
43
+ middleware: HttpRouter.middleware((httpEffect) => Effect.gen(function* () {
44
+ const request = yield* HttpServerRequest.HttpServerRequest;
45
+ const permitted = allows({
46
+ target: request.originalUrl,
47
+ host: request.headers.host,
48
+ origin: request.headers.origin,
49
+ });
50
+ return permitted ? yield* httpEffect : forbidden;
51
+ })),
52
+ };
53
+ };
@@ -1,15 +1,39 @@
1
1
  import { Context, Effect } 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, Forbidden, InsufficientScope, RateLimited, Unauthorized } from "./errors.ts";
5
+ import type { AdmissionError } from "./errors.ts";
5
6
  import * as Verifier from "./verify.ts";
6
7
  declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Verifier.Principal>;
7
8
  export declare class CurrentPrincipal extends CurrentPrincipal_base {
8
9
  }
9
10
  export interface Options extends Verifier.Options {
10
11
  readonly scopes: readonly string[];
12
+ /**
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.
16
+ */
17
+ readonly writeScope?: string;
11
18
  }
12
- /** One resource: its verifier and discovery. Acquire once per protected audience. */
19
+ /**
20
+ * A refusal a caller outside the router can send as-is: an RFC 6750 challenge or a
21
+ * `Retry-After`, `Cache-Control: no-store`, and the error's own JSON encoding.
22
+ */
23
+ export interface Refusal {
24
+ readonly status: number;
25
+ readonly headers: Readonly<Record<string, string>>;
26
+ readonly body: string;
27
+ }
28
+ /** What one Authorization header value amounts to for this resource. */
29
+ export type Admission = {
30
+ readonly ok: true;
31
+ readonly principal: Verifier.Principal;
32
+ } | {
33
+ readonly ok: false;
34
+ readonly refusal: Refusal;
35
+ };
36
+ /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per audience. */
13
37
  export declare const make: (options: Options) => Effect.Effect<{
14
38
  verifier: {
15
39
  verifyToken: (token: string) => Effect.Effect<{
@@ -19,6 +43,7 @@ export declare const make: (options: Options) => Effect.Effect<{
19
43
  kind: "client";
20
44
  clientId: string;
21
45
  };
46
+ expiresAt: number;
22
47
  } | {
23
48
  subject: string;
24
49
  scopes: readonly string[];
@@ -26,6 +51,7 @@ export declare const make: (options: Options) => Effect.Effect<{
26
51
  kind: "key";
27
52
  keyId: string;
28
53
  };
54
+ expiresAt: undefined;
29
55
  }, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
30
56
  verify: (authorization: string | null | undefined) => Effect.Effect<{
31
57
  subject: string;
@@ -34,6 +60,7 @@ export declare const make: (options: Options) => Effect.Effect<{
34
60
  kind: "client";
35
61
  clientId: string;
36
62
  };
63
+ expiresAt: number;
37
64
  } | {
38
65
  subject: string;
39
66
  scopes: readonly string[];
@@ -41,6 +68,7 @@ export declare const make: (options: Options) => Effect.Effect<{
41
68
  kind: "key";
42
69
  keyId: string;
43
70
  };
71
+ expiresAt: undefined;
44
72
  }, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
45
73
  };
46
74
  discovery: {
@@ -51,18 +79,17 @@ export declare const make: (options: Options) => Effect.Effect<{
51
79
  challenge: (error?: "invalid_token" | "insufficient_scope") => {
52
80
  "www-authenticate": string;
53
81
  };
54
- headers: (error: AuthenticationError) => {
55
- "www-authenticate": string;
56
- } | {
57
- "retry-after": string;
58
- } | {
59
- "retry-after"?: undefined;
82
+ headers: (error: AdmissionError) => {
83
+ [x: string]: string;
60
84
  };
85
+ admit: (authorization: string | null | undefined, access?: Action.Access | undefined) => Effect.Effect<Admission, never, never>;
86
+ authorize: (action: Action.Any) => Effect.Effect<void, InsufficientScope, CurrentPrincipal>;
61
87
  resource: string;
62
88
  issuer: string;
89
+ writeScope: string | undefined;
63
90
  }, ConfigurationError, import("effect/unstable/http/HttpClient").HttpClient>;
64
91
  export type Resource = Effect.Success<ReturnType<typeof make>>;
65
- /** Bearer authentication providing `CurrentPrincipal`. */
92
+ /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
66
93
  export declare const middleware: (resource: Resource) => import("effect/unstable/http/HttpRouter").Middleware<{
67
94
  provides: CurrentPrincipal;
68
95
  handles: never;
package/dist/resource.js CHANGED
@@ -1,13 +1,23 @@
1
- import { Context, Effect } from "effect";
1
+ import { Context, Effect, Result } 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 { admissionErrors, ConfigurationError, describeError, Forbidden, InsufficientScope, RateLimited, Unauthorized, } from "./errors.js";
5
5
  import * as Verifier from "./verify.js";
6
- const encode = encodeError(authenticationErrors);
6
+ const describe = describeError(admissionErrors);
7
7
  export class CurrentPrincipal extends Context.Service()("@clankerauth/CurrentPrincipal") {
8
8
  }
9
- /** One resource: its verifier and discovery. Acquire once per protected audience. */
9
+ /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per audience. */
10
10
  export const make = Effect.fn("Resource.make")(function* (options) {
11
+ // 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
+ })));
11
21
  const discovery = yield* Effect.try({
12
22
  try: () => Authentication.protectedResource({
13
23
  resource: options.resource,
@@ -18,37 +28,94 @@ export const make = Effect.fn("Resource.make")(function* (options) {
18
28
  });
19
29
  const verifier = yield* Verifier.make(options);
20
30
  const scope = options.requiredScopes?.length ? options.requiredScopes.join(" ") : undefined;
31
+ const challengeValue = (error, missing) => discovery.challenge({ error, scope: missing ?? scope });
21
32
  /** Bearer challenge for this resource; no error code when the request carried no credentials. */
22
33
  const challenge = (error) => ({
23
- "www-authenticate": discovery.challenge({ error, scope }),
34
+ "www-authenticate": challengeValue(error),
24
35
  });
25
- const headers = (error) => {
36
+ /**
37
+ * The one header besides the content type that a refusal carries: an RFC 6750
38
+ * challenge naming only the missing scope, or an RFC 6585 retry hint.
39
+ */
40
+ const refusalHeader = (error, credential) => {
26
41
  if (error instanceof RateLimited)
27
- return { "retry-after": "60" };
42
+ return ["retry-after", "60"];
43
+ if (error instanceof InsufficientScope)
44
+ return ["www-authenticate", challengeValue("insufficient_scope", error.scope)];
28
45
  if (error instanceof Forbidden)
29
- return challenge("insufficient_scope");
46
+ return ["www-authenticate", challengeValue("insufficient_scope")];
47
+ // RFC 6750 §3.1: a request that carried no credentials gets no error code.
30
48
  if (error instanceof Unauthorized)
31
- return challenge("invalid_token");
32
- return {};
49
+ return ["www-authenticate", credential ? challengeValue("invalid_token") : challengeValue()];
50
+ return undefined;
33
51
  };
52
+ 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
+ const refuse = (error, authorization) => Effect.map(describe(error), ({ status, body }) => ({
60
+ ok: false,
61
+ refusal: {
62
+ status,
63
+ headers: {
64
+ ...asRecord(refusalHeader(error, authorization !== undefined && authorization !== null)),
65
+ "content-type": "application/json",
66
+ "cache-control": "no-store",
67
+ },
68
+ body,
69
+ },
70
+ }));
71
+ /**
72
+ * 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.
75
+ */
76
+ const admit = Effect.fn("Resource.admit")(function* (authorization, access = "read") {
77
+ const verified = yield* Effect.result(verifier.verify(authorization));
78
+ if (Result.isFailure(verified))
79
+ return yield* refuse(verified.failure, authorization);
80
+ const principal = verified.success;
81
+ const missing = access === "write" ? missingScope(principal) : undefined;
82
+ if (missing !== undefined)
83
+ return yield* refuse(new InsufficientScope({ scope: missing }), authorization);
84
+ return { ok: true, principal };
85
+ });
86
+ /**
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.
90
+ */
91
+ const authorize = (action) => action.access === "read" || options.writeScope === undefined
92
+ ? Effect.void
93
+ : Effect.flatMap(CurrentPrincipal, (principal) => {
94
+ const missing = missingScope(principal);
95
+ return missing === undefined
96
+ ? Effect.void
97
+ : Effect.fail(new InsufficientScope({ scope: missing }));
98
+ });
34
99
  return {
35
100
  verifier,
36
101
  discovery,
37
102
  challenge,
38
103
  headers,
104
+ admit,
105
+ authorize,
39
106
  resource: options.resource,
40
107
  issuer: options.issuer,
108
+ writeScope: options.writeScope,
41
109
  };
42
110
  });
43
- /** Bearer authentication providing `CurrentPrincipal`. */
111
+ /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
44
112
  export const middleware = (resource) => Authentication.middleware(CurrentPrincipal, Effect.gen(function* () {
45
113
  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
- }))));
114
+ const admission = yield* resource.admit(request.headers.authorization);
115
+ if (admission.ok)
116
+ return admission.principal;
117
+ return yield* Effect.fail(HttpServerResponse.text(admission.refusal.body, {
118
+ status: admission.refusal.status,
119
+ headers: admission.refusal.headers,
120
+ }));
121
+ }));
package/dist/verify.d.ts CHANGED
@@ -11,6 +11,13 @@ export interface Principal {
11
11
  readonly kind: "key";
12
12
  readonly keyId: string;
13
13
  };
14
+ /**
15
+ * When this credential stops being valid, in epoch milliseconds: an access token's
16
+ * verified `exp`, so a host never decodes the token again. `undefined` for an API
17
+ * key, which has no token lifetime and is re-verified against the issuer on every
18
+ * request; a host that caches a key decision must choose its own bound.
19
+ */
20
+ readonly expiresAt: number | undefined;
14
21
  }
15
22
  export interface Options {
16
23
  readonly issuer: string;
@@ -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,6 +48,7 @@ export declare const make: (options: Options) => Effect.Effect<{
40
48
  kind: "key";
41
49
  keyId: string;
42
50
  };
51
+ expiresAt: undefined;
43
52
  }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
44
53
  verify: (authorization: string | null | undefined) => Effect.Effect<{
45
54
  subject: 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
  };
68
+ expiresAt: undefined;
58
69
  }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
59
70
  }, ConfigurationError, HttpClient.HttpClient>;
package/dist/verify.js CHANGED
@@ -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,6 +112,8 @@ 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) {
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {
@@ -30,14 +30,15 @@
30
30
  "jose": "6.2.12"
31
31
  },
32
32
  "devDependencies": {
33
- "@gjermundgaraba/effect-actions": "0.2.0",
33
+ "@gjermundgaraba/effect-actions": "0.3.0",
34
+ "@modelcontextprotocol/client": "2.0.0",
34
35
  "@types/node": "26.6.2",
35
36
  "typescript": "7.0.2",
36
37
  "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
37
38
  "vite-plus": "0.3.3"
38
39
  },
39
40
  "peerDependencies": {
40
- "@gjermundgaraba/effect-actions": "0.2.0"
41
+ "@gjermundgaraba/effect-actions": "0.3.0"
41
42
  },
42
43
  "peerDependenciesMeta": {
43
44
  "@gjermundgaraba/effect-actions": {