@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 +126 -45
- package/dist/errors.d.ts +23 -12
- package/dist/errors.js +19 -19
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -1
- package/dist/request-policy.d.ts +38 -0
- package/dist/request-policy.js +57 -0
- package/dist/resource.d.ts +61 -19
- package/dist/resource.js +116 -31
- package/dist/session.d.ts +29 -0
- package/dist/session.js +33 -0
- package/dist/verify.d.ts +17 -6
- package/dist/verify.js +13 -6
- package/package.json +13 -3
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
|
|
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/
|
|
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
|
-
##
|
|
45
|
+
## One resource per application
|
|
36
46
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
scopes:
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
{
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
90
|
-
import
|
|
91
|
-
import {
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|
package/dist/resource.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
38
|
+
resource: identifier,
|
|
14
39
|
authorizationServers: [options.issuer],
|
|
15
|
-
scopesSupported
|
|
40
|
+
scopesSupported,
|
|
16
41
|
}),
|
|
17
42
|
catch: () => new ConfigurationError({ message: "Invalid resource metadata configuration" }),
|
|
18
43
|
});
|
|
19
|
-
const verifier = yield* Verifier.make(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
28
|
-
if (error instanceof
|
|
29
|
-
return
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
:
|
|
53
|
-
|
|
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;
|
package/dist/session.js
ADDED
|
@@ -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,
|
|
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,
|
|
24
|
-
readonly verifyToken: (token: string) => Effect.Effect<Principal,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
50
|
+
"@gjermundgaraba/effect-actions": "0.4.0"
|
|
41
51
|
},
|
|
42
52
|
"peerDependenciesMeta": {
|
|
43
53
|
"@gjermundgaraba/effect-actions": {
|