@gjermundgaraba/clankerauth-sdk 0.12.0 → 0.14.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 +137 -74
- package/dist/effect-actions.d.ts +2 -1
- package/dist/effect-actions.js +0 -1
- package/dist/errors.d.ts +41 -22
- package/dist/errors.js +28 -16
- package/dist/resource.d.ts +133 -54
- package/dist/resource.js +173 -129
- package/dist/session.d.ts +34 -8
- package/dist/session.js +20 -10
- package/dist/testing.js +1 -1
- package/dist/verify.d.ts +5 -18
- package/dist/verify.js +10 -23
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @gjermundgaraba/clankerauth-sdk
|
|
2
2
|
|
|
3
|
-
Effect-native [clankerauth](https://github.com/gjermundgaraba/clankerauth) verification. Verifies JWT access tokens and API keys, both offline, including the tokens a reverse proxy obtains through the issuer's forward auth for browser sessions. Optional **effect-actions** integration provides
|
|
3
|
+
Effect-native [clankerauth](https://github.com/gjermundgaraba/clankerauth) verification. Verifies JWT access tokens and API keys, both offline, including the tokens a reverse proxy obtains through the issuer's forward auth for browser sessions. Optional **effect-actions** integration provides the verifier behind an authentication descriptor, OAuth discovery, scope enforcement and the `Whoami` contract, which a browser page imports on its own from `/session`.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -17,19 +17,19 @@ vp add effect
|
|
|
17
17
|
The `/session` and `/effect-actions` entry points also need effect-actions, an optional peer. Install this range: the SDK's declarations are built against it, and under `skipLibCheck` they silently degrade to `any` with another.
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
-
vp add @gjermundgaraba/effect-actions@^0.
|
|
20
|
+
vp add @gjermundgaraba/effect-actions@^0.11.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Entry points
|
|
24
24
|
|
|
25
|
-
| Import | Needs effect-actions | Browser safe | What it holds
|
|
26
|
-
| ---------------------------------- | -------------------- | ------------ |
|
|
27
|
-
| `…/clankerauth-sdk` | no | no | `Verifier`, `RequestPolicy`
|
|
28
|
-
| `…/clankerauth-sdk/errors` | no | **yes** | the
|
|
29
|
-
| `…/clankerauth-sdk/session` | yes | **yes** | the `
|
|
30
|
-
| `…/clankerauth-sdk/effect-actions` | yes | no | `Resource
|
|
31
|
-
| `…/clankerauth-sdk/key-list` | no | no | the key list format, for issuers and fakes
|
|
32
|
-
| `…/clankerauth-sdk/testing` | no | no | `startFakeIssuer`, for tests (Node only)
|
|
25
|
+
| Import | Needs effect-actions | Browser safe | What it holds |
|
|
26
|
+
| ---------------------------------- | -------------------- | ------------ | ------------------------------------------------------------------------------------- |
|
|
27
|
+
| `…/clankerauth-sdk` | no | no | `Verifier`, `RequestPolicy` |
|
|
28
|
+
| `…/clankerauth-sdk/errors` | no | **yes** | the verifier's errors; `ProviderUnavailable` is the one a descriptor declares |
|
|
29
|
+
| `…/clankerauth-sdk/session` | yes | **yes** | `CurrentPrincipal` and its `Caller`, the `Whoami` contract, `Principal`, `signOutUrl` |
|
|
30
|
+
| `…/clankerauth-sdk/effect-actions` | yes | no | `Resource` |
|
|
31
|
+
| `…/clankerauth-sdk/key-list` | no | no | the key list format, for issuers and fakes |
|
|
32
|
+
| `…/clankerauth-sdk/testing` | no | no | `startFakeIssuer`, for tests (Node only) |
|
|
33
33
|
|
|
34
34
|
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.
|
|
35
35
|
|
|
@@ -47,95 +47,157 @@ const makeVerifier = Verifier.make({
|
|
|
47
47
|
resource: "https://notes.internal/",
|
|
48
48
|
requiredScopes: ["notes:read"],
|
|
49
49
|
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
50
|
-
// Acquire once, then use verifier.
|
|
50
|
+
// Acquire once, then use verifier.verifyToken(token).
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
## One resource per application
|
|
54
54
|
|
|
55
55
|
Register **one** resource in the clankerauth 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.
|
|
56
56
|
|
|
57
|
-
`
|
|
57
|
+
A declared resource's `layer` 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.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Contracts state who may call them, `caller: CurrentPrincipal`, and a binding names how a caller proves it: an effect-actions authentication descriptor, declared once beside the contracts, where a browser client imports it too. The descriptor also declares what its verifier fails with besides a refusal: `ProviderUnavailable`, the issuer being unreachable.
|
|
60
60
|
|
|
61
61
|
```ts
|
|
62
|
-
|
|
62
|
+
// contracts.ts: browser-safe.
|
|
63
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
64
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
65
|
+
import { ProviderUnavailable } from "@gjermundgaraba/clankerauth-sdk/errors";
|
|
66
|
+
import { CurrentPrincipal, Whoami } from "@gjermundgaraba/clankerauth-sdk/session";
|
|
67
|
+
|
|
68
|
+
// A bearer token proving a `CurrentPrincipal`. Its name is unique per process. Refusals are
|
|
69
|
+
// effect-actions' built-in `Unauthenticated` and `Forbidden`, declared on every endpoint; the
|
|
70
|
+
// descriptor declares the one answer that is the SDK's own, the 503, which every protected
|
|
71
|
+
// endpoint of a binding naming it then declares, so a typed client decodes it.
|
|
72
|
+
export const Login = Authentication.make("notes.Login", CurrentPrincipal, {
|
|
73
|
+
error: ProviderUnavailable,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const Http = ActionHttp.make([List, Write, Whoami], { authentication: Login });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The server declares the resource once, at module level, from that descriptor, every scope it has and the one every credential must carry, `required`, one of `scopes` as the types check. It provides its `layer` and its `provider` once at application construction, never per request. `Resource.make` takes only a descriptor declaring `ProviderUnavailable`: one that does not is a type error there, naming `error: ProviderUnavailable`.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { Layer } from "effect";
|
|
63
83
|
import { FetchHttpClient } from "effect/http";
|
|
64
84
|
import { Resource } from "@gjermundgaraba/clankerauth-sdk/effect-actions";
|
|
65
|
-
import
|
|
85
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
66
86
|
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
87
|
+
import { Http, Login } from "./contracts.ts";
|
|
67
88
|
|
|
68
|
-
//
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
// The application's resource: the descriptor's name is its service's, unique in the process.
|
|
90
|
+
export const NotesResource = Resource.make(Login, {
|
|
91
|
+
scopes: ["notes:read", "notes:write"], // every scope it has, published in discovery
|
|
92
|
+
required: "notes:read", // every credential must carry it; every 401 names it
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// `app` is your implementation, behind your own authorizer, a module-level value: see
|
|
96
|
+
// "Scope enforcement".
|
|
97
|
+
const routes = Layer.mergeAll(
|
|
98
|
+
ActionHttp.layer(Http, [app, NotesResource.session]),
|
|
99
|
+
ActionMcp.layerHttp(app, { name: "notes", version: "1.0.0", authentication: Login }),
|
|
100
|
+
).pipe(
|
|
101
|
+
Layer.provide(NotesResource.provider),
|
|
102
|
+
Layer.provide(
|
|
103
|
+
NotesResource.layer({
|
|
72
104
|
issuer: "https://clankerauth.internal/api/auth",
|
|
73
105
|
publicUrl: new URL("https://notes.internal"),
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
notes.discovery.layer,
|
|
79
|
-
Http.layer([app, notes.session], { before: notes.authorize }).pipe(
|
|
80
|
-
Layer.provide(Resource.middleware(notes).layer),
|
|
81
|
-
),
|
|
82
|
-
ActionMcp.layerHttp([app], {
|
|
83
|
-
name: "notes",
|
|
84
|
-
version: "1.0.0",
|
|
85
|
-
path: "/mcp",
|
|
86
|
-
errors: authenticationErrors,
|
|
87
|
-
before: notes.authorize,
|
|
88
|
-
}).pipe(Layer.provide(Resource.middleware(notes).layer)),
|
|
89
|
-
);
|
|
90
|
-
}),
|
|
91
|
-
).pipe(Layer.provide(FetchHttpClient.layer));
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
Layer.provide(FetchHttpClient.layer),
|
|
109
|
+
);
|
|
92
110
|
```
|
|
93
111
|
|
|
94
|
-
Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the release that matches your Effect.
|
|
112
|
+
Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the release that matches your Effect. The scopes are the same in every deployment, so they are declared with the resource; `NotesResource.layer` takes what varies by deployment and builds the resource once: its verifier and refusals. `NotesResource.provider` is the descriptor's provider, effect-actions' `Authentication.layer`: it verifies every protected route and tool call before decoding, and publishes discovery itself, public and outside authentication; there is no discovery route to merge. Discovery cache policy belongs to the host. Every response to a request it authenticates carries `Cache-Control: no-store`. Middleware of your own runs as effect-actions runs it: router middleware provided after the provider, or an HTTP layer's `middleware`.
|
|
113
|
+
|
|
114
|
+
A surface whose callers hold API keys and never run an OAuth flow, such as a second listener beside the one that publishes the resource, sets `discovery: false`: it authenticates the same way, publishes nothing, and its challenge is a plain `Bearer`.
|
|
115
|
+
|
|
116
|
+
A process serving two resources, such as two listeners with different scopes, declares two descriptors and two resources: `Resource.make(Authentication.make("notes.admin", CurrentPrincipal, { error: ProviderUnavailable }), { scopes: ["notes:read", "notes:write"], required: "notes:read" })` and one for `"notes.machine"`, each named by its own binding. Each is a service of its own, so each surface reads its own resource, in one layer graph or several. The descriptor's name is the identity: declaring one name twice declares the same resource twice.
|
|
117
|
+
|
|
118
|
+
A declared resource is a `Resource.Declared<"notes.Login">`, and what its `layer` builds a `Resource.Built`; its descriptor is a `Resource.Login<"notes.Login">`. `Declared` and `Login` take what the descriptor declares as an optional second parameter, `ProviderUnavailable` alone when left out. `Declared` and `Built` take the resource's scopes as an optional parameter, last, which `make` infers from `scopes`: an annotation that leaves it out accepts any scope string. The entry point also exports each type a resource's declarations mention under a flat name (`DeclaredResource`, `BuiltResource`, `ResourceService`, `ResourceDeclaration`, `ResourceOptions`, `Login`, `Admission`, `Refusal`, `Principal`, `Verifier`); `CurrentPrincipal` and its `Caller` are `/session`'s, where declarations name them. So a package that exports its descriptor, its resource or its implementations emits declarations without an annotation.
|
|
95
119
|
|
|
96
120
|
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.
|
|
97
121
|
|
|
98
122
|
## Scope enforcement
|
|
99
123
|
|
|
100
|
-
|
|
124
|
+
The application decides which scope an action needs; the SDK verifies the credential and offers the check. Verification requires `required` of every credential, so an action that needs nothing more passes. Any other scope is the application's rule, stated as its implementation's authorizer with `requires(scope)`: it runs after the input is decoded and before every handler on every surface — HTTP, MCP, Toolkit, CLI — so no handler can forget it.
|
|
101
125
|
|
|
102
126
|
```ts
|
|
127
|
+
import { Effect } from "effect";
|
|
103
128
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
104
|
-
import
|
|
105
|
-
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
106
|
-
import { authenticationErrors } from "@gjermundgaraba/clankerauth-sdk/errors";
|
|
107
|
-
import { Session } from "@gjermundgaraba/clankerauth-sdk/session";
|
|
129
|
+
import { CurrentPrincipal } from "@gjermundgaraba/clankerauth-sdk/session";
|
|
108
130
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
131
|
+
const List = Action.make("list", {
|
|
132
|
+
description: "List notes",
|
|
133
|
+
readOnly: true,
|
|
134
|
+
caller: CurrentPrincipal,
|
|
135
|
+
success: Notes,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const Write = Action.make("write", {
|
|
139
|
+
description: "Write a note",
|
|
140
|
+
readOnly: false,
|
|
141
|
+
caller: CurrentPrincipal,
|
|
142
|
+
success: Note,
|
|
143
|
+
});
|
|
114
144
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
const
|
|
145
|
+
// The application's rule: a read needs only what verification required, a write needs
|
|
146
|
+
// `notes:write`. It reads no built resource, so this is a module-level value.
|
|
147
|
+
const authorize = (action: Action.Any) =>
|
|
148
|
+
action.readOnly ? Effect.void : NotesResource.requires("notes:write");
|
|
149
|
+
|
|
150
|
+
const app = Action.implement([List, Write], handlers, { authorize });
|
|
151
|
+
```
|
|
118
152
|
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
`requires(scope)` takes one of the resource's declared scopes, so a typo, `requires("notes:wirte")`, is a type error, as it is in `admitted`, `admit` and `watch`. It is an `Effect<void, Action.Forbidden, CurrentPrincipal>`: a caller without the scope is refused with a `Forbidden` (403) naming **only** that scope, so it never enumerates the resource's permissions. A single-scope resource, `{ scopes: ["notes:read"], required: "notes:read" }`, has nothing more to require: its implementations state `{ authorize: Action.allowAll }`. Each scope is an OAuth scope token, printable ASCII without spaces, `"` or `\`: effect-actions refuses one that is not when the provider builds the discovery it publishes. With `discovery: false` nothing checks them, and refusing an OAuth client for a declared scope that is no scope token is a defect, a 500: `Action.Forbidden`'s constructor throws on it.
|
|
154
|
+
|
|
155
|
+
A trusted local surface, such as stdio MCP or a local command, has no remote caller and no credential to verify. The process is the caller: `NotesResource.local(subject)` is its principal, `{ kind: "local" }`, holding every scope the resource declares and never expiring. The host provides it as effect-actions provides any local identity, and serves the same implementation, whose authorizer admits it, as it holds every scope:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const stdio = ActionMcp.runStdio(app, { name: "notes", version: "1.0.0" }).pipe(
|
|
159
|
+
Effect.provide(NodeStdio.layer),
|
|
160
|
+
Effect.provideService(CurrentPrincipal, NotesResource.local("owner")),
|
|
161
|
+
);
|
|
121
162
|
```
|
|
122
163
|
|
|
123
|
-
|
|
164
|
+
On a local `ActionCli` command, `Command.provideSync(CurrentPrincipal, NotesResource.local("owner"))`. A local surface builds no resource: `local` and `requires` read only the declared scopes and the caller.
|
|
124
165
|
|
|
125
|
-
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.
|
|
166
|
+
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. A scope the application requires therefore gates agents and API keys, not the owner at a keyboard: give a read-only agent or key only `notes:read` and the same rule refuses its writes.
|
|
126
167
|
|
|
127
|
-
An action reads identity from `CurrentPrincipal`, which contains `subject`, `scopes`, `actor` —
|
|
168
|
+
An action reads identity from `CurrentPrincipal`, a `Caller`, which contains `subject`, `scopes`, `actor` — `{ kind: "client", clientId }`, `{ kind: "key", keyId }`, or `{ kind: "local" }` for the process on a local surface — and `expiresAt`: an access token's verified `exp`, or an API key's expiry, in epoch milliseconds, so a host bounding a connection to its credential never decodes it again. It is `undefined` for a key that does not expire and for a local principal. A key can also be revoked before then; `watch`, below, ends a connection at whichever comes first. What a verifier proves, `Verifier.Principal`, is a client or a key only: it is what `verifyToken` and `admit` give, and every one is a `Caller`.
|
|
128
169
|
|
|
129
|
-
|
|
170
|
+
Every refusal is one of effect-actions' built-in errors, which every endpoint and tool declares and every client decodes: `Unauthenticated` (401) and `Forbidden` (403). An access token's `Forbidden` names the missing scope, and is answered, over MCP too, as a 403 with an `insufficient_scope` challenge an OAuth client steps up on. An API key cannot re-authorize, so its `Forbidden` names none: a plain 403, and a tool error over MCP. `Cache-Control: no-store` and the challenges come from effect-actions, around every request the provider authenticates. A request without a bearer token is refused before the verifier runs: `A bearer token is required.`
|
|
130
171
|
|
|
131
|
-
|
|
172
|
+
The provider authenticates the request and the application's authorizer authorizes it; neither filters MCP tool discovery.
|
|
173
|
+
|
|
174
|
+
## Routes of your own
|
|
175
|
+
|
|
176
|
+
A route the application serves itself, beside its actions, such as a page frame, a file download or an OpenAPI document, is admitted as the actions are with `admitted`, router middleware provided around it:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { Effect, Layer } from "effect";
|
|
180
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
181
|
+
import { CurrentPrincipal } from "@gjermundgaraba/clankerauth-sdk/session";
|
|
182
|
+
|
|
183
|
+
const frame = HttpRouter.add(
|
|
184
|
+
"GET",
|
|
185
|
+
"/frame",
|
|
186
|
+
Effect.map(CurrentPrincipal, ({ subject }) => HttpServerResponse.text(subject)),
|
|
187
|
+
).pipe(Layer.provide(NotesResource.admitted().layer));
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`admitted()` admits any verified caller; `admitted("notes:write")` also checks that scope inside it, with `requires`, as an authorizer checks an action's. It is effect-actions' `Authentication.protect` with the resource's descriptor, an `Authentication.Protection`, so the route is answered exactly as an action route answers the same request: a refusal with the same status, challenge, `Cache-Control` and JSON, the 503 when the issuer is unavailable, `Cache-Control: no-store` on every response unless the route states its own caching, and a refusal the route fails with itself, `Unauthenticated` or `Forbidden`, or a 401 it answers, with its challenge. An admitted route reads its caller from `CurrentPrincipal`. Its layer requires the resource's `provider`, which authenticates it: provide `provider` around the admitted routes as around the actions, once for both. It is Effect's own `HttpRouter.Middleware`, so `combine` composes middleware of your own reading the caller inside it: `myMiddleware.combine(NotesResource.admitted())`.
|
|
132
191
|
|
|
133
192
|
## Outside the router
|
|
134
193
|
|
|
135
|
-
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`.
|
|
194
|
+
A socket, a Node `upgrade` handler or a per-request endpoint, which holds a header and no routed request, verifies the same credential against the same resource, and renders the same refusal, with `admit`. Any verified caller is admitted, or with a scope, only one that also holds it:
|
|
136
195
|
|
|
137
196
|
```ts
|
|
138
|
-
|
|
197
|
+
// `notes` is the built resource: `yield* NotesResource.service`, under `NotesResource.layer`.
|
|
198
|
+
const admission = await Effect.runPromise(
|
|
199
|
+
notes.admit(request.headers.authorization, "notes:write"),
|
|
200
|
+
);
|
|
139
201
|
|
|
140
202
|
if (!admission.ok) {
|
|
141
203
|
const { status, headers, body } = admission.refusal;
|
|
@@ -146,20 +208,20 @@ if (!admission.ok) {
|
|
|
146
208
|
connect(admission.principal);
|
|
147
209
|
```
|
|
148
210
|
|
|
149
|
-
The refusal carries each error's own status (401/403/503), the RFC 6750 challenge including the no-credential case, `Cache-Control: no-store`, and the error's JSON encoding. `
|
|
211
|
+
The refusal carries each error's own status (401/403/503), the RFC 6750 challenge including the no-credential case, `Cache-Control: no-store`, and the error's JSON encoding. It is `admitted`'s check, rendered by effect-actions' `Authentication.refusalResponse`, the answer a route gets for the same refusal, so a router and a socket can never answer differently. `admission.principal` is a `Verifier.Principal`, a client or a key.
|
|
150
212
|
|
|
151
|
-
A connection that outlives its admission, such as a WebSocket, is held to the same header value and
|
|
213
|
+
A connection that outlives its admission, such as a WebSocket, is held to the same header value and scope with `watch`. It fails with the refusal `admit` would then answer, `Action.Refusal` or `ProviderUnavailable`: once an access token expires, or once a refreshed key list no longer grants the key, or no longer grants the scope. It never succeeds, so race it against the connection:
|
|
152
214
|
|
|
153
215
|
```ts
|
|
154
216
|
const session = Effect.raceFirst(
|
|
155
217
|
serve(socket),
|
|
156
|
-
notes.watch(request.headers.authorization, "write"),
|
|
218
|
+
notes.watch(request.headers.authorization, "notes:write"),
|
|
157
219
|
);
|
|
158
220
|
```
|
|
159
221
|
|
|
160
|
-
An access token cannot be revoked, so `watch` waits for its expiry and then fails `
|
|
222
|
+
An access token cannot be revoked, so `watch` waits for its expiry and then fails `Unauthenticated`, without asking the issuer again; a key is re-checked about once a minute against the list the resource last read, which is itself read about once a minute, so a revoked key ends a watched connection within about two minutes. A key needs the issuer only once that list is past its window.
|
|
161
223
|
|
|
162
|
-
The resource also exposes `verifier.
|
|
224
|
+
The resource also exposes `verifier.verifyToken(token)` as an Effect for callers that render their own responses; it takes the token, not the header, which effect-actions' `Authentication.bearerTokenOf(authorization)` reads as every surface does. A provider of your own, effect-actions' `Authentication.layer` around a verifier of your own, starts from `Resource.authenticate(verifier, token)`, given the token the descriptor's scheme decodes: the principal, the refusal a surface sends, or `ProviderUnavailable`, which your descriptor declares too, so a surface sends it as the 503.
|
|
163
225
|
|
|
164
226
|
## Request policy
|
|
165
227
|
|
|
@@ -194,14 +256,15 @@ Both checks are raw string comparisons on the headers as sent. Nothing is parsed
|
|
|
194
256
|
A browser page holds no credential of its own: put it behind a reverse proxy with the issuer's forward auth, described in the clankerauth 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:
|
|
195
257
|
|
|
196
258
|
```ts
|
|
197
|
-
import { Principal,
|
|
259
|
+
import { Principal, Whoami, signOutUrl } from "@gjermundgaraba/clankerauth-sdk/session";
|
|
198
260
|
|
|
199
|
-
// `
|
|
200
|
-
|
|
261
|
+
// `Whoami` is the contract, bound beside your own actions; the server answers it with
|
|
262
|
+
// its resource's `session`. `api` is `ActionHttp.fetchClient(Http)`.
|
|
263
|
+
const principal: Principal = await Effect.runPromise(api.whoami());
|
|
201
264
|
link.href = signOutUrl(principal.issuer, `${location.origin}/`);
|
|
202
265
|
```
|
|
203
266
|
|
|
204
|
-
`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, `
|
|
267
|
+
`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, a declared resource's `session`, `NotesResource.session`, is `Whoami` already implemented from the verified credential — pass it to `ActionHttp.layer` beside your own implementation, and delete the hand-written `whoami`.
|
|
205
268
|
|
|
206
269
|
## A fake issuer for tests
|
|
207
270
|
|
|
@@ -229,16 +292,16 @@ await auth.close();
|
|
|
229
292
|
|
|
230
293
|
## Errors and observability
|
|
231
294
|
|
|
232
|
-
|
|
295
|
+
Tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauthorized`, `InsufficientScope` and `ProviderUnavailable` are what the `Verifier` fails with. Only `ProviderUnavailable` (503) is a schema, which a resource's descriptor declares and every protected endpoint of a binding naming it then declares; the first two never reach the wire, as a `Resource` sends them as effect-actions' built-in `Unauthenticated` (401) and `Forbidden` (403). Invalid construction, such as an issuer that is no URL, fails with `ConfigurationError`.
|
|
233
296
|
|
|
234
|
-
There is one 403.
|
|
297
|
+
There is one 403. Only the verifier's own check fails with `InsufficientScope`: a credential that is valid but lacks a scope in `requiredScopes`, a resource's `required`. It names that scope and the credential's `actor`, which decides whether its `Forbidden` steps up. A scope asked for after verification, by `requires(scope)`, `admitted(scope)`, `admit(header, scope)` or `watch(header, scope)`, is refused with effect-actions' `Action.Forbidden` directly, decided by the same rule. A credential that is not this resource's at all — the wrong audience, or an API key with no entry in this resource's key list — is `Unauthorized`, because from the resource's side those are the same thing.
|
|
235
298
|
|
|
236
|
-
JWTs are checked against issuer, audience, EdDSA signature, token type, required claims, expiry and scopes. Sender-constrained tokens are rejected. One JWKS read answers every verification for a minute, so a key the issuer starts or stops publishing takes effect within that minute; a `kid` the current document does not publish is `Unauthorized` and never triggers a read, so a token with an invented `kid` cannot become provider traffic. A failed read
|
|
299
|
+
JWTs are checked against issuer, audience, EdDSA signature, token type, required claims, expiry and scopes. Sender-constrained tokens are rejected. One JWKS read answers every verification for a minute, so a key the issuer starts or stops publishing takes effect within that minute; a `kid` the current document does not publish is `Unauthorized` and never triggers a read, so a token with an invented `kid` cannot become provider traffic. A failed read answers `ProviderUnavailable` (503) for five seconds before the next attempt; a read every waiting caller gave up on is dropped, and the next verification reads again. API keys are verified offline, against the resource's key list: one document the issuer signs with its access-token key, holding a sealed entry per key granted on this resource, which only that key can find or open. The list is verified once, when it is read, and one read answers every key it names for a minute, without issuer traffic; so a key disabled, deleted or re-scoped keeps the access it had until the next read, within about a minute. A key the held list does not name may have been created, enabled or granted on this resource since, so the list is read again before the key is refused, unless it was read within the last five seconds: such a key works within seconds, and unknown keys reach the issuer at most once every five seconds. A key's own expiry is checked on every verification, so it applies at once. A failed read, or one that takes longer than three seconds, is no error while the last list is inside its own lifetime, which the issuer sets to 24 hours: keys keep verifying through an outage, JWKS or not, and the issuer is tried again after five seconds. A read every waiting caller gave up on is dropped, and the next key reads again. Past that lifetime, or before any list has been read, a key is `ProviderUnavailable`. Reads that start failing are logged once as a warning, and their recovery once at info level, so an outage the held list hides still shows in the application's logs without a line per retry. The last list read is the one held, whatever its `iat`: HTTPS to the issuer is what keeps an old list from being replayed, and one could only ever be replayed inside its own day. The issuer does not count key use, so rate limiting is the application's. A key is a bearer secret, and one granted on several resources can be replayed by any of them to the others; a resource you trust less gets its own key. A resource that accepts only OAuth access tokens sets `apiKeys: false`; key-shaped bearers then fail as `Unauthorized` without reading the key list. Admission through a `Resource` has a five-second deadline; the raw `Verifier` has none, so an in-process caller can await a provider call it cannot cancel. The exception is the key list read's own three seconds: shorter than that deadline, so an issuer that stops answering delays a key by at most that long, never fails it while a list is held.
|
|
237
300
|
|
|
238
301
|
The bundled issuer does not configure automatic signing-key rotation. Immediate-use rotation is supported, but tokens signed by a new key are rejected until the next JWKS read, up to a minute; a key list signed by it is not taken until then either, and the held list keeps deciding. Publish-before-use avoids that window; it is not mandatory. Key lists are re-signed on every read, so issued API keys are unaffected by rotation.
|
|
239
302
|
|
|
240
|
-
The SDK
|
|
303
|
+
The SDK's resource maps verification failures to effect-actions' refusals and its own 503; effect-actions supplies request-scoped identity, challenge headers and `Cache-Control: no-store` on every response to a request it authenticates; other cache policy is the host's. 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.
|
|
241
304
|
|
|
242
|
-
Operational failures retain their underlying `cause` for application-side Effect error handling. `ProviderUnavailable` and `Unauthorized` keep this diagnostic field
|
|
305
|
+
Operational failures retain their underlying `cause` for application-side Effect error handling. `ProviderUnavailable` and `Unauthorized` keep this diagnostic field non-enumerable, outside what is serialized. Causes may contain sensitive transport or provider details: inspect selectively, never serialize them into responses or log them indiscriminately.
|
|
243
306
|
|
|
244
307
|
MIT licensed.
|
package/dist/effect-actions.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export * as Resource from "./resource.ts";
|
|
2
|
-
export {
|
|
2
|
+
export type { Admission, Built as BuiltResource, Declaration as ResourceDeclaration, Declared as DeclaredResource, Login, Options as ResourceOptions, Refusal, Resource as ResourceService, } from "./resource.ts";
|
|
3
|
+
export type { Principal, Verifier } from "./verify.ts";
|
package/dist/effect-actions.js
CHANGED
package/dist/errors.d.ts
CHANGED
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* without pulling token verification or its cryptography into the bundle.
|
|
2
|
+
* What this package fails with. This entry point is browser-safe: a shared contract
|
|
3
|
+
* declares `ProviderUnavailable` on its authentication descriptor and a browser client
|
|
4
|
+
* decodes it, without pulling token verification or its cryptography into the bundle.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* `ProviderUnavailable` is the one wire schema. `Unauthorized` and `InsufficientScope` are
|
|
7
|
+
* what the verifier fails with in process: the effect-actions integration sends them as its
|
|
8
|
+
* built-in `Unauthenticated` (401) and `Forbidden` (403), which every endpoint and tool
|
|
9
|
+
* declares, so a descriptor declares only `error: ProviderUnavailable`, which every protected
|
|
10
|
+
* endpoint of a binding naming it then declares, and a binding nothing.
|
|
7
11
|
*/
|
|
8
12
|
import { Schema } from "effect";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
import type { Principal } from "./verify.ts";
|
|
14
|
+
declare const Unauthorized_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
15
|
+
readonly _tag: "Unauthorized";
|
|
16
|
+
} & Readonly<A>;
|
|
17
|
+
/**
|
|
18
|
+
* The credential is missing, malformed, expired, revoked, or not this resource's: one cause,
|
|
19
|
+
* so one message, `Authentication required`, unless another is given.
|
|
20
|
+
*/
|
|
21
|
+
export declare class Unauthorized extends Unauthorized_base<{
|
|
22
|
+
readonly message: string;
|
|
23
|
+
}> {
|
|
13
24
|
readonly cause?: unknown;
|
|
14
|
-
constructor(options
|
|
15
|
-
readonly message
|
|
25
|
+
constructor(options?: {
|
|
26
|
+
readonly message?: string;
|
|
16
27
|
readonly cause?: unknown;
|
|
17
28
|
});
|
|
18
29
|
}
|
|
19
30
|
declare const ProviderUnavailable_base: Schema.Class<ProviderUnavailable, Schema.TaggedStruct<"ProviderUnavailable", {
|
|
20
31
|
readonly operation: Schema.String;
|
|
21
32
|
}>, import("effect/Cause").YieldableError>;
|
|
33
|
+
/**
|
|
34
|
+
* The issuer could not be reached, so the credential could not be decided: a 503, not a
|
|
35
|
+
* refusal. A resource's descriptor declares it,
|
|
36
|
+
* `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
|
|
37
|
+
* Its public response is the schema. Diagnostic causes are internal, non-enumerable fields.
|
|
38
|
+
*/
|
|
22
39
|
export declare class ProviderUnavailable extends ProviderUnavailable_base {
|
|
23
40
|
readonly cause?: unknown;
|
|
24
41
|
constructor(options: {
|
|
@@ -26,24 +43,26 @@ export declare class ProviderUnavailable extends ProviderUnavailable_base {
|
|
|
26
43
|
readonly cause?: unknown;
|
|
27
44
|
});
|
|
28
45
|
}
|
|
29
|
-
declare const InsufficientScope_base:
|
|
30
|
-
readonly
|
|
31
|
-
}
|
|
46
|
+
declare const InsufficientScope_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
47
|
+
readonly _tag: "InsufficientScope";
|
|
48
|
+
} & Readonly<A>;
|
|
32
49
|
/**
|
|
33
|
-
* A verified credential lacks one
|
|
34
|
-
* the
|
|
50
|
+
* A verified credential lacks one of the verifier's `requiredScopes`, a resource's `required`:
|
|
51
|
+
* the only check failing with it, as `requires`, `admitted`, `admit` and `watch` refuse with
|
|
52
|
+
* `Action.Forbidden` directly. It names only the missing scope, so a refusal never enumerates
|
|
53
|
+
* the resource's permissions, and the credential's `actor`, which decides whether the caller
|
|
54
|
+
* can step up to it.
|
|
35
55
|
*/
|
|
36
|
-
export declare class InsufficientScope extends InsufficientScope_base
|
|
56
|
+
export declare class InsufficientScope extends InsufficientScope_base<{
|
|
57
|
+
readonly scope: string;
|
|
58
|
+
readonly actor: Principal["actor"];
|
|
59
|
+
}> {
|
|
37
60
|
}
|
|
38
61
|
declare const ConfigurationError_base: Schema.Class<ConfigurationError, Schema.TaggedStruct<"ConfigurationError", {
|
|
39
62
|
readonly message: Schema.String;
|
|
40
63
|
}>, import("effect/Cause").YieldableError>;
|
|
41
64
|
export declare class ConfigurationError extends ConfigurationError_base {
|
|
42
65
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
* surface declares `errors: authenticationErrors` and answers every refusal the same way.
|
|
46
|
-
*/
|
|
47
|
-
export declare const authenticationErrors: readonly [typeof Unauthorized, typeof InsufficientScope, typeof ProviderUnavailable];
|
|
48
|
-
export type AuthenticationError = InstanceType<(typeof authenticationErrors)[number]>;
|
|
66
|
+
/** What verification fails with. */
|
|
67
|
+
export type AuthenticationError = Unauthorized | InsufficientScope | ProviderUnavailable;
|
|
49
68
|
export {};
|
package/dist/errors.js
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* without pulling token verification or its cryptography into the bundle.
|
|
2
|
+
* What this package fails with. This entry point is browser-safe: a shared contract
|
|
3
|
+
* declares `ProviderUnavailable` on its authentication descriptor and a browser client
|
|
4
|
+
* decodes it, without pulling token verification or its cryptography into the bundle.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* `ProviderUnavailable` is the one wire schema. `Unauthorized` and `InsufficientScope` are
|
|
7
|
+
* what the verifier fails with in process: the effect-actions integration sends them as its
|
|
8
|
+
* built-in `Unauthenticated` (401) and `Forbidden` (403), which every endpoint and tool
|
|
9
|
+
* declares, so a descriptor declares only `error: ProviderUnavailable`, which every protected
|
|
10
|
+
* endpoint of a binding naming it then declares, and a binding nothing.
|
|
7
11
|
*/
|
|
8
|
-
import { Schema } from "effect";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
import { Data, Schema } from "effect";
|
|
13
|
+
/**
|
|
14
|
+
* The credential is missing, malformed, expired, revoked, or not this resource's: one cause,
|
|
15
|
+
* so one message, `Authentication required`, unless another is given.
|
|
16
|
+
*/
|
|
17
|
+
export class Unauthorized extends Data.TaggedError("Unauthorized") {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
super({ message: options.message ?? "Authentication required" });
|
|
12
20
|
Object.defineProperty(this, "cause", { value: options.cause });
|
|
13
21
|
}
|
|
14
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The issuer could not be reached, so the credential could not be decided: a 503, not a
|
|
25
|
+
* refusal. A resource's descriptor declares it,
|
|
26
|
+
* `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
|
|
27
|
+
* Its public response is the schema. Diagnostic causes are internal, non-enumerable fields.
|
|
28
|
+
*/
|
|
15
29
|
export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
|
|
16
30
|
constructor(options) {
|
|
17
31
|
super({ operation: options.operation });
|
|
@@ -19,15 +33,13 @@ export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailab
|
|
|
19
33
|
}
|
|
20
34
|
}
|
|
21
35
|
/**
|
|
22
|
-
* A verified credential lacks one
|
|
23
|
-
* the
|
|
36
|
+
* A verified credential lacks one of the verifier's `requiredScopes`, a resource's `required`:
|
|
37
|
+
* the only check failing with it, as `requires`, `admitted`, `admit` and `watch` refuse with
|
|
38
|
+
* `Action.Forbidden` directly. It names only the missing scope, so a refusal never enumerates
|
|
39
|
+
* the resource's permissions, and the credential's `actor`, which decides whether the caller
|
|
40
|
+
* can step up to it.
|
|
24
41
|
*/
|
|
25
|
-
export class InsufficientScope extends
|
|
42
|
+
export class InsufficientScope extends Data.TaggedError("InsufficientScope") {
|
|
26
43
|
}
|
|
27
44
|
export class ConfigurationError extends Schema.TaggedError()("ConfigurationError", { message: Schema.String }) {
|
|
28
45
|
}
|
|
29
|
-
/**
|
|
30
|
-
* What verification, admission and the authorization hook refuse with — one list, so a
|
|
31
|
-
* surface declares `errors: authenticationErrors` and answers every refusal the same way.
|
|
32
|
-
*/
|
|
33
|
-
export const authenticationErrors = [Unauthorized, InsufficientScope, ProviderUnavailable];
|