@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 +84 -29
- package/dist/effect-actions.d.ts +1 -0
- package/dist/effect-actions.js +1 -0
- package/dist/errors.d.ts +21 -2
- package/dist/errors.js +30 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/request-policy.d.ts +43 -0
- package/dist/request-policy.js +53 -0
- package/dist/resource.d.ts +37 -10
- package/dist/resource.js +88 -21
- package/dist/verify.d.ts +11 -0
- package/dist/verify.js +6 -0
- package/package.json +4 -3
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.
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
71
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
90
|
-
import
|
|
91
|
-
import {
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/effect-actions.d.ts
CHANGED
package/dist/effect-actions.js
CHANGED
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
|
-
/**
|
|
42
|
-
export declare const
|
|
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
|
-
/**
|
|
29
|
-
export const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
}
|
|
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
|
|
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
|
+
};
|
package/dist/resource.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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:
|
|
55
|
-
|
|
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 {
|
|
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
|
|
6
|
+
const describe = describeError(admissionErrors);
|
|
7
7
|
export class CurrentPrincipal extends Context.Service()("@clankerauth/CurrentPrincipal") {
|
|
8
8
|
}
|
|
9
|
-
/** One resource: its verifier and
|
|
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":
|
|
34
|
+
"www-authenticate": challengeValue(error),
|
|
24
35
|
});
|
|
25
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
41
|
+
"@gjermundgaraba/effect-actions": "0.3.0"
|
|
41
42
|
},
|
|
42
43
|
"peerDependenciesMeta": {
|
|
43
44
|
"@gjermundgaraba/effect-actions": {
|