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