@gjermundgaraba/clankerauth-sdk 0.9.0 → 0.11.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 +51 -14
- package/dist/errors.d.ts +2 -7
- package/dist/errors.js +1 -8
- package/dist/key-list.d.ts +51 -0
- package/dist/key-list.js +77 -0
- package/dist/refresh.d.ts +7 -0
- package/dist/refresh.js +7 -0
- package/dist/request-policy.d.ts +1 -1
- package/dist/request-policy.js +1 -1
- package/dist/resource.d.ts +6 -5
- package/dist/resource.js +37 -14
- package/dist/testing.d.ts +46 -0
- package/dist/testing.js +108 -0
- package/dist/transport.d.ts +2 -2
- package/dist/transport.js +1 -1
- package/dist/verify.d.ts +11 -10
- package/dist/verify.js +89 -42
- package/package.json +16 -7
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, 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.
|
|
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 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
|
|
|
@@ -8,16 +8,16 @@ Effect-native [clankerauth](https://github.com/gjermundgaraba/clankerauth) verif
|
|
|
8
8
|
vp add @gjermundgaraba/clankerauth-sdk
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Effect is a peer: the application installs Effect 4, still a release candidate, and the SDK shares that copy.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
vp add effect@
|
|
14
|
+
vp add effect@rc
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
The `/session` and `/effect-actions` entry points also need effect-actions
|
|
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
|
|
20
|
+
vp add @gjermundgaraba/effect-actions@^0.8.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Entry points
|
|
@@ -28,6 +28,8 @@ vp add @gjermundgaraba/effect-actions@0.7.0
|
|
|
28
28
|
| `…/clankerauth-sdk/errors` | no | **yes** | the error schemas and `authenticationErrors` |
|
|
29
29
|
| `…/clankerauth-sdk/session` | yes | **yes** | the `session` contract, `Principal`, `signOutUrl` |
|
|
30
30
|
| `…/clankerauth-sdk/effect-actions` | yes | no | `Resource`, `CurrentPrincipal` |
|
|
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) |
|
|
31
33
|
|
|
32
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.
|
|
33
35
|
|
|
@@ -37,7 +39,7 @@ Core consumers do not need effect-actions or `skipLibCheck`.
|
|
|
37
39
|
|
|
38
40
|
```ts
|
|
39
41
|
import { Effect } from "effect";
|
|
40
|
-
import { FetchHttpClient } from "effect/
|
|
42
|
+
import { FetchHttpClient } from "effect/http";
|
|
41
43
|
import { Verifier } from "@gjermundgaraba/clankerauth-sdk";
|
|
42
44
|
|
|
43
45
|
const makeVerifier = Verifier.make({
|
|
@@ -58,7 +60,7 @@ Acquire the resource once at application construction, never per request.
|
|
|
58
60
|
|
|
59
61
|
```ts
|
|
60
62
|
import { Effect, Layer } from "effect";
|
|
61
|
-
import { FetchHttpClient } from "effect/
|
|
63
|
+
import { FetchHttpClient } from "effect/http";
|
|
62
64
|
import { Resource } from "@gjermundgaraba/clankerauth-sdk/effect-actions";
|
|
63
65
|
import { authenticationErrors } from "@gjermundgaraba/clankerauth-sdk/errors";
|
|
64
66
|
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
@@ -89,7 +91,7 @@ const routes = Layer.unwrap(
|
|
|
89
91
|
).pipe(Layer.provide(FetchHttpClient.layer));
|
|
90
92
|
```
|
|
91
93
|
|
|
92
|
-
Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the
|
|
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. Discovery is public; do not wrap it in authentication. Discovery cache policy belongs to the host. Every authentication response carries `Cache-Control: no-store`.
|
|
93
95
|
|
|
94
96
|
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.
|
|
95
97
|
|
|
@@ -122,7 +124,7 @@ const routes = Http.layer([Notes.implement(handlers), notes.session], { before:
|
|
|
122
124
|
|
|
123
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. 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.
|
|
124
126
|
|
|
125
|
-
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
|
|
127
|
+
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`, 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. A key can also be revoked before then; `watch`, below, ends a connection at whichever comes first.
|
|
126
128
|
|
|
127
129
|
A hook refusal is a plain declared error: effect-actions encodes it with the schema's status and adds no headers of its own; `Cache-Control: no-store` comes from `Resource.middleware`, which wraps every route it authenticates. 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.
|
|
128
130
|
|
|
@@ -144,7 +146,18 @@ if (!admission.ok) {
|
|
|
144
146
|
connect(admission.principal);
|
|
145
147
|
```
|
|
146
148
|
|
|
147
|
-
The refusal carries each error's own status (401/403/
|
|
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. `Resource.middleware` is built on the same function at `"read"`, so a router and a socket can never answer differently.
|
|
150
|
+
|
|
151
|
+
A connection that outlives its admission, such as a WebSocket, is held to the same header value and access level with `watch`. It fails with the first refusal: once an access token expires, or once a refreshed key list no longer grants the key, or no longer grants the write scope. It never succeeds, so race it against the connection:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const session = Effect.raceFirst(
|
|
155
|
+
serve(socket),
|
|
156
|
+
notes.watch(request.headers.authorization, "write"),
|
|
157
|
+
);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
An access token cannot be revoked, so `watch` waits for its expiry and then fails `Unauthorized`, 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.
|
|
148
161
|
|
|
149
162
|
The resource also exposes `verifier.verify(authorization)` and `verifier.verifyToken(token)` as Effects for callers that render their own responses.
|
|
150
163
|
|
|
@@ -190,15 +203,39 @@ link.href = signOutUrl(principal.issuer, `${location.origin}/`);
|
|
|
190
203
|
|
|
191
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, `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`.
|
|
192
205
|
|
|
206
|
+
## A fake issuer for tests
|
|
207
|
+
|
|
208
|
+
`@gjermundgaraba/clankerauth-sdk/testing` is the issuer an application test usually wants: the two endpoints a resource server actually calls, and nothing else. It signs with a real EdDSA key and publishes it as JWKS, so every token and key list still travels the resource server's own verifier — signature, issuer, audience, type, claims, scopes, each key's sealed entry — without starting the whole issuer. It runs on Node.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { startFakeIssuer } from "@gjermundgaraba/clankerauth-sdk/testing";
|
|
212
|
+
|
|
213
|
+
const auth = await startFakeIssuer({
|
|
214
|
+
resource: "http://127.0.0.1:8080/",
|
|
215
|
+
scopes: ["notes:read", "notes:write"],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
auth.issuer; // configure the resource server with this
|
|
219
|
+
const token = await auth.sign(); // every claim is overridable: auth.sign({ exp, aud, scope })
|
|
220
|
+
const key = auth.apiKey(); // every configured scope on the configured resource
|
|
221
|
+
const hosts = auth.apiKey({ [hostA]: ["host:write"], [hostB]: ["host:write"] }, expiresAt);
|
|
222
|
+
auth.revoke(key); // gone from the next key list, as the real issuer behaves
|
|
223
|
+
auth.fail(503); // both endpoints answer this instead; auth.fail() restores them
|
|
224
|
+
auth.keyLists(); // how many key lists were served
|
|
225
|
+
await auth.close();
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`apiKey` takes permissions as the real issuer does, resource identifier to scopes, so one key can reach several resources. A resource server reads its key list at most once a minute, so a test that revokes a key and expects a refusal verifies with a fresh verifier, or advances its clock past that minute. To test against the whole issuer — sign-in, consent, forward auth — use `@gjermundgaraba/clankerauth-dev`.
|
|
229
|
+
|
|
193
230
|
## Errors and observability
|
|
194
231
|
|
|
195
|
-
Schema-tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauthorized` (401), `InsufficientScope` (403)
|
|
232
|
+
Schema-tagged errors, all from `@gjermundgaraba/clankerauth-sdk/errors`: `Unauthorized` (401), `InsufficientScope` (403) and `ProviderUnavailable` (503), collected as `authenticationErrors`. Invalid construction fails with `ConfigurationError`.
|
|
196
233
|
|
|
197
|
-
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
|
|
234
|
+
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 entry in this resource's key list — is `Unauthorized`, because from the resource's side those are the same thing.
|
|
198
235
|
|
|
199
|
-
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 unauthenticated requests cannot become provider traffic. A failed read, or one whose caller gave up, answers `ProviderUnavailable` (503) for five seconds before the next attempt. API keys are
|
|
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 unauthenticated requests cannot become provider traffic. A failed read, or one whose caller gave up, answers `ProviderUnavailable` (503) for five seconds before the next attempt. 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 for a minute; so disabling, deleting, re-scoping or expiring a key takes effect within about a minute, and a key the list does not hold is `Unauthorized` without further issuer traffic. A failed read, one that takes longer than three seconds, or one whose caller gave up, 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. 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.
|
|
200
237
|
|
|
201
|
-
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. Publish-before-use avoids that window; it is not mandatory.
|
|
238
|
+
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.
|
|
202
239
|
|
|
203
240
|
The SDK Resource adapter owns authentication error encoding and challenge headers; effect-actions supplies request-scoped identity, and its authentication middleware marks every response it answers or wraps `Cache-Control: no-store`; 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.
|
|
204
241
|
|
package/dist/errors.d.ts
CHANGED
|
@@ -16,11 +16,6 @@ export declare class Unauthorized extends Unauthorized_base {
|
|
|
16
16
|
readonly cause?: unknown;
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
|
-
declare const RateLimited_base: Schema.Class<RateLimited, Schema.TaggedStruct<"RateLimited", {
|
|
20
|
-
readonly message: Schema.String;
|
|
21
|
-
}>, import("effect/Cause").YieldableError>;
|
|
22
|
-
export declare class RateLimited extends RateLimited_base {
|
|
23
|
-
}
|
|
24
19
|
declare const ProviderUnavailable_base: Schema.Class<ProviderUnavailable, Schema.TaggedStruct<"ProviderUnavailable", {
|
|
25
20
|
readonly operation: Schema.String;
|
|
26
21
|
}>, import("effect/Cause").YieldableError>;
|
|
@@ -49,6 +44,6 @@ export declare class ConfigurationError extends ConfigurationError_base {
|
|
|
49
44
|
* What verification, admission and the authorization hook refuse with — one list, so a
|
|
50
45
|
* surface declares `errors: authenticationErrors` and answers every refusal the same way.
|
|
51
46
|
*/
|
|
52
|
-
export declare const authenticationErrors: readonly [typeof Unauthorized, typeof InsufficientScope, typeof
|
|
53
|
-
export type AuthenticationError =
|
|
47
|
+
export declare const authenticationErrors: readonly [typeof Unauthorized, typeof InsufficientScope, typeof ProviderUnavailable];
|
|
48
|
+
export type AuthenticationError = InstanceType<(typeof authenticationErrors)[number]>;
|
|
54
49
|
export {};
|
package/dist/errors.js
CHANGED
|
@@ -12,8 +12,6 @@ export class Unauthorized extends Schema.TaggedError()("Unauthorized", { message
|
|
|
12
12
|
Object.defineProperty(this, "cause", { value: options.cause });
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
-
export class RateLimited extends Schema.TaggedError()("RateLimited", { message: Schema.String }, { httpApiStatus: 429 }) {
|
|
16
|
-
}
|
|
17
15
|
export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
|
|
18
16
|
constructor(options) {
|
|
19
17
|
super({ operation: options.operation });
|
|
@@ -32,9 +30,4 @@ export class ConfigurationError extends Schema.TaggedError()("ConfigurationError
|
|
|
32
30
|
* What verification, admission and the authorization hook refuse with — one list, so a
|
|
33
31
|
* surface declares `errors: authenticationErrors` and answers every refusal the same way.
|
|
34
32
|
*/
|
|
35
|
-
export const authenticationErrors = [
|
|
36
|
-
Unauthorized,
|
|
37
|
-
InsufficientScope,
|
|
38
|
-
RateLimited,
|
|
39
|
-
ProviderUnavailable,
|
|
40
|
-
];
|
|
33
|
+
export const authenticationErrors = [Unauthorized, InsufficientScope, ProviderUnavailable];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key list: how an issuer publishes the API keys granted on one resource, so its
|
|
3
|
+
* resource server verifies them offline without learning who else holds what.
|
|
4
|
+
*
|
|
5
|
+
* Each entry is found by, and sealed under, values derived from the key's SHA-256 digest
|
|
6
|
+
* — what the issuer stores — and the resource identifier. Only a holder of the key can
|
|
7
|
+
* locate or read its entry. Anyone else still sees how many entries a resource has, when
|
|
8
|
+
* they come and go, and roughly how large each grant is. A digest cannot be reversed, so
|
|
9
|
+
* neither the list nor the issuer's store yields a key.
|
|
10
|
+
*
|
|
11
|
+
* Sealing is exported for issuers, including fakes in tests; a resource server only opens.
|
|
12
|
+
*/
|
|
13
|
+
import { Schema } from "effect";
|
|
14
|
+
/** The list's JWT `typ`, so neither a list nor an access token can pass for the other. */
|
|
15
|
+
export declare const type = "key-list+jwt";
|
|
16
|
+
/**
|
|
17
|
+
* How long a list decides, in seconds from its `iat`: for as long as a resource server
|
|
18
|
+
* cannot read a newer one, it keeps accepting the keys this one grants. So it is also the
|
|
19
|
+
* longest a revoked key stays usable during an issuer outage.
|
|
20
|
+
*/
|
|
21
|
+
export declare const lifetime: number;
|
|
22
|
+
export declare const Entry: Schema.Struct<{
|
|
23
|
+
readonly id: Schema.String;
|
|
24
|
+
readonly sealed: Schema.String;
|
|
25
|
+
}>;
|
|
26
|
+
export type Entry = typeof Entry.Type;
|
|
27
|
+
/** The claims a list carries besides `iss`, `aud`, `iat` and `exp`. */
|
|
28
|
+
export declare const Claims: Schema.Struct<{
|
|
29
|
+
readonly keys: Schema.$Array<Schema.Struct<{
|
|
30
|
+
readonly id: Schema.String;
|
|
31
|
+
readonly sealed: Schema.String;
|
|
32
|
+
}>>;
|
|
33
|
+
}>;
|
|
34
|
+
/** What one key may do on the list's resource. */
|
|
35
|
+
export declare const Grant: Schema.Struct<{
|
|
36
|
+
readonly keyId: Schema.NonEmptyString;
|
|
37
|
+
readonly ownerId: Schema.NonEmptyString;
|
|
38
|
+
readonly scopes: Schema.$Array<Schema.NonEmptyString>;
|
|
39
|
+
/** Epoch milliseconds; `null` for a key that does not expire. */
|
|
40
|
+
readonly expiresAt: Schema.NullOr<Schema.Finite>;
|
|
41
|
+
}>;
|
|
42
|
+
export type Grant = typeof Grant.Type;
|
|
43
|
+
/** The digest an issuer stores for a key: SHA-256 of the whole key, prefix included. */
|
|
44
|
+
export declare const digest: (key: string) => Promise<Uint8Array<ArrayBuffer>>;
|
|
45
|
+
/** One key's entry on one resource, from the digest the issuer stores. */
|
|
46
|
+
export declare const seal: (keyDigest: Uint8Array<ArrayBuffer>, resource: string, grant: Grant) => Promise<Entry>;
|
|
47
|
+
/**
|
|
48
|
+
* What a list grants this key on its resource, or `undefined` when the list has no entry
|
|
49
|
+
* for it. Rejects only for an entry that does not open, which the issuer never writes.
|
|
50
|
+
*/
|
|
51
|
+
export declare const open: (key: string, resource: string, entries: ReadonlyMap<string, string>) => Promise<Grant | undefined>;
|
package/dist/key-list.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key list: how an issuer publishes the API keys granted on one resource, so its
|
|
3
|
+
* resource server verifies them offline without learning who else holds what.
|
|
4
|
+
*
|
|
5
|
+
* Each entry is found by, and sealed under, values derived from the key's SHA-256 digest
|
|
6
|
+
* — what the issuer stores — and the resource identifier. Only a holder of the key can
|
|
7
|
+
* locate or read its entry. Anyone else still sees how many entries a resource has, when
|
|
8
|
+
* they come and go, and roughly how large each grant is. A digest cannot be reversed, so
|
|
9
|
+
* neither the list nor the issuer's store yields a key.
|
|
10
|
+
*
|
|
11
|
+
* Sealing is exported for issuers, including fakes in tests; a resource server only opens.
|
|
12
|
+
*/
|
|
13
|
+
import { Schema } from "effect";
|
|
14
|
+
import { base64url } from "jose";
|
|
15
|
+
/** The list's JWT `typ`, so neither a list nor an access token can pass for the other. */
|
|
16
|
+
export const type = "key-list+jwt";
|
|
17
|
+
/**
|
|
18
|
+
* How long a list decides, in seconds from its `iat`: for as long as a resource server
|
|
19
|
+
* cannot read a newer one, it keeps accepting the keys this one grants. So it is also the
|
|
20
|
+
* longest a revoked key stays usable during an issuer outage.
|
|
21
|
+
*/
|
|
22
|
+
export const lifetime = 24 * 60 * 60;
|
|
23
|
+
export const Entry = Schema.Struct({ id: Schema.String, sealed: Schema.String });
|
|
24
|
+
/** The claims a list carries besides `iss`, `aud`, `iat` and `exp`. */
|
|
25
|
+
export const Claims = Schema.Struct({ keys: Schema.Array(Entry) });
|
|
26
|
+
/** What one key may do on the list's resource. */
|
|
27
|
+
export const Grant = Schema.Struct({
|
|
28
|
+
keyId: Schema.NonEmptyString,
|
|
29
|
+
ownerId: Schema.NonEmptyString,
|
|
30
|
+
scopes: Schema.Array(Schema.NonEmptyString),
|
|
31
|
+
/** Epoch milliseconds; `null` for a key that does not expire. */
|
|
32
|
+
expiresAt: Schema.NullOr(Schema.Finite),
|
|
33
|
+
});
|
|
34
|
+
const GrantJson = Schema.fromJsonString(Grant);
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
/** The digest an issuer stores for a key: SHA-256 of the whole key, prefix included. */
|
|
37
|
+
export const digest = async (key) => new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(key)));
|
|
38
|
+
/** One key's entry ID and sealing key on one resource. */
|
|
39
|
+
const derive = async (keyDigest, resource) => {
|
|
40
|
+
const material = await crypto.subtle.importKey("raw", keyDigest, "HKDF", false, ["deriveBits"]);
|
|
41
|
+
const bits = new Uint8Array(await crypto.subtle.deriveBits({
|
|
42
|
+
name: "HKDF",
|
|
43
|
+
hash: "SHA-256",
|
|
44
|
+
salt: new Uint8Array(),
|
|
45
|
+
info: encoder.encode(`clankerauth key list\0${resource}`),
|
|
46
|
+
}, material, 512));
|
|
47
|
+
return {
|
|
48
|
+
id: base64url.encode(bits.subarray(0, 32)),
|
|
49
|
+
key: await crypto.subtle.importKey("raw", bits.subarray(32), "AES-GCM", false, [
|
|
50
|
+
"encrypt",
|
|
51
|
+
"decrypt",
|
|
52
|
+
]),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** One key's entry on one resource, from the digest the issuer stores. */
|
|
56
|
+
export const seal = async (keyDigest, resource, grant) => {
|
|
57
|
+
const { id, key } = await derive(keyDigest, resource);
|
|
58
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
59
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(Schema.encodeSync(GrantJson)(grant)));
|
|
60
|
+
const sealed = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
61
|
+
sealed.set(iv);
|
|
62
|
+
sealed.set(new Uint8Array(ciphertext), iv.length);
|
|
63
|
+
return { id, sealed: base64url.encode(sealed) };
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* What a list grants this key on its resource, or `undefined` when the list has no entry
|
|
67
|
+
* for it. Rejects only for an entry that does not open, which the issuer never writes.
|
|
68
|
+
*/
|
|
69
|
+
export const open = async (key, resource, entries) => {
|
|
70
|
+
const derived = await derive(await digest(key), resource);
|
|
71
|
+
const sealed = entries.get(derived.id);
|
|
72
|
+
if (sealed === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const bytes = new Uint8Array(base64url.decode(sealed));
|
|
75
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.subarray(0, 12) }, derived.key, bytes.subarray(12));
|
|
76
|
+
return Schema.decodeUnknownSync(GrantJson)(new TextDecoder().decode(plaintext));
|
|
77
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Duration } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* How long a key list answers verification before it is read again: the delay after
|
|
4
|
+
* which a revoked, disabled, expired or re-scoped key is refused, and how often a watched
|
|
5
|
+
* connection's key is checked again. Internal: not a package entry point.
|
|
6
|
+
*/
|
|
7
|
+
export declare const keyListRefresh: Duration.Duration;
|
package/dist/refresh.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Duration } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* How long a key list answers verification before it is read again: the delay after
|
|
4
|
+
* which a revoked, disabled, expired or re-scoped key is refused, and how often a watched
|
|
5
|
+
* connection's key is checked again. Internal: not a package entry point.
|
|
6
|
+
*/
|
|
7
|
+
export const keyListRefresh = Duration.minutes(1);
|
package/dist/request-policy.d.ts
CHANGED
package/dist/request-policy.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* and `notes.example:443` are different hosts here, as they are to a browser's cookie.
|
|
10
10
|
*/
|
|
11
11
|
import { Effect } from "effect";
|
|
12
|
-
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/
|
|
12
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
13
13
|
/**
|
|
14
14
|
* The one path answered without either check. A container probe reaches the app on its
|
|
15
15
|
* bind address and carries neither header, and no application has a second such path.
|
package/dist/resource.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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, InsufficientScope } from "./errors.ts";
|
|
4
|
+
import { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.ts";
|
|
5
5
|
import * as Verifier from "./verify.ts";
|
|
6
6
|
declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Verifier.Principal>;
|
|
7
7
|
export declare class CurrentPrincipal extends CurrentPrincipal_base {
|
|
@@ -24,7 +24,7 @@ export interface Options {
|
|
|
24
24
|
*/
|
|
25
25
|
readonly publicUrl: URL;
|
|
26
26
|
readonly scopes: Scopes;
|
|
27
|
-
/** `false`
|
|
27
|
+
/** `false` refuses API keys without reading the issuer's key list; only OAuth access tokens are accepted. */
|
|
28
28
|
readonly apiKeys?: boolean;
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
@@ -48,11 +48,12 @@ export type Admission = {
|
|
|
48
48
|
export declare const make: (options: Options) => Effect.Effect<{
|
|
49
49
|
verifier: Verifier.Verifier;
|
|
50
50
|
discovery: {
|
|
51
|
-
layer: import("effect/Layer").Layer<never, never, import("effect/
|
|
51
|
+
layer: import("effect/Layer").Layer<never, never, import("effect/http/HttpRouter").HttpRouter>;
|
|
52
52
|
metadataUrl: string;
|
|
53
53
|
challenge: (challenge?: Authentication.BearerChallengeOptions) => string;
|
|
54
54
|
};
|
|
55
55
|
admit: (authorization: string | null | undefined, access: Action.Access) => Effect.Effect<Admission, never, never>;
|
|
56
|
+
watch: (authorization: string | null | undefined, access: Action.Access) => Effect.Effect<never, InsufficientScope | ProviderUnavailable | Unauthorized, never>;
|
|
56
57
|
authorize: (action: Action.Any) => Effect.Effect<void, InsufficientScope, CurrentPrincipal>;
|
|
57
58
|
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<{
|
|
58
59
|
readonly subject: Schema.NonEmptyString;
|
|
@@ -67,10 +68,10 @@ export declare const make: (options: Options) => Effect.Effect<{
|
|
|
67
68
|
}, never, never>;
|
|
68
69
|
resource: string;
|
|
69
70
|
issuer: string;
|
|
70
|
-
}, ConfigurationError, import("effect/
|
|
71
|
+
}, ConfigurationError, import("effect/http/HttpClient").HttpClient>;
|
|
71
72
|
export type Resource = Effect.Success<ReturnType<typeof make>>;
|
|
72
73
|
/** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
|
|
73
|
-
export declare const middleware: (resource: Resource) => import("effect/
|
|
74
|
+
export declare const middleware: (resource: Resource) => import("effect/http/HttpRouter").Middleware<{
|
|
74
75
|
provides: CurrentPrincipal;
|
|
75
76
|
handles: never;
|
|
76
77
|
error: never;
|
package/dist/resource.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Context, Effect, Result, Schema, SchemaAST } from "effect";
|
|
1
|
+
import { Clock, Context, Duration, Effect, Result, Schema, SchemaAST } from "effect";
|
|
2
2
|
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
3
|
-
import { HttpServerRequest, HttpServerResponse } from "effect/
|
|
4
|
-
import { authenticationErrors, ConfigurationError, InsufficientScope, ProviderUnavailable,
|
|
3
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
4
|
+
import { authenticationErrors, ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized, } from "./errors.js";
|
|
5
|
+
import { keyListRefresh } from "./refresh.js";
|
|
5
6
|
import { Session } from "./session.js";
|
|
6
7
|
import * as Verifier from "./verify.js";
|
|
7
8
|
const encoders = new Map(authenticationErrors.map((schema) => [
|
|
@@ -48,23 +49,23 @@ export const make = Effect.fn("Resource.make")(function* (options) {
|
|
|
48
49
|
apiKeys: options.apiKeys,
|
|
49
50
|
});
|
|
50
51
|
// A request must not wait on an issuer that does not answer: the transport is
|
|
51
|
-
// interrupted and the credential refused as unavailable.
|
|
52
|
+
// interrupted and the credential refused as unavailable. A key list read gives up
|
|
53
|
+
// sooner, so a held list still decides a key.
|
|
52
54
|
const deadline = (effect) => Effect.timeoutOrElse(effect, {
|
|
53
55
|
duration: "5 seconds",
|
|
54
56
|
orElse: () => Effect.fail(new ProviderUnavailable({ operation: "verify.timeout" })),
|
|
55
57
|
});
|
|
58
|
+
const verify = (authorization) => deadline(unbounded.verify(authorization));
|
|
56
59
|
const verifier = {
|
|
57
|
-
verify
|
|
60
|
+
verify,
|
|
58
61
|
verifyToken: (token) => deadline(unbounded.verifyToken(token)),
|
|
59
62
|
};
|
|
60
63
|
const challengeValue = (error, missing) => discovery.challenge({ error, scope: missing ?? options.scopes.read });
|
|
61
64
|
/**
|
|
62
65
|
* The one header besides the content type that a refusal carries: an RFC 6750
|
|
63
|
-
* challenge naming only the missing scope
|
|
66
|
+
* challenge naming only the missing scope.
|
|
64
67
|
*/
|
|
65
68
|
const refusalHeader = (error, credential) => {
|
|
66
|
-
if (error instanceof RateLimited)
|
|
67
|
-
return ["retry-after", "60"];
|
|
68
69
|
if (error instanceof InsufficientScope)
|
|
69
70
|
return ["www-authenticate", challengeValue("insufficient_scope", error.scope)];
|
|
70
71
|
// RFC 6750 §3.1: a request that carried no credentials gets no error code.
|
|
@@ -89,21 +90,42 @@ export const make = Effect.fn("Resource.make")(function* (options) {
|
|
|
89
90
|
const missingScope = (principal) => options.scopes.write !== undefined && !principal.scopes.includes(options.scopes.write)
|
|
90
91
|
? options.scopes.write
|
|
91
92
|
: undefined;
|
|
93
|
+
/** The principal an Authorization header value has for `access`, or its refusal. */
|
|
94
|
+
const principalFor = Effect.fn("Resource.principal")(function* (authorization, access) {
|
|
95
|
+
const principal = yield* verify(authorization);
|
|
96
|
+
const missing = access === "write" ? missingScope(principal) : undefined;
|
|
97
|
+
if (missing !== undefined)
|
|
98
|
+
return yield* new InsufficientScope({ scope: missing });
|
|
99
|
+
return principal;
|
|
100
|
+
});
|
|
92
101
|
/**
|
|
93
102
|
* Verify one Authorization header value outside an Effect router: a Node `upgrade`
|
|
94
103
|
* handler, a socket, a per-request endpoint. `access` is the same declaration an
|
|
95
104
|
* action carries, so `"write"` demands the write scope before a socket is established.
|
|
96
105
|
*/
|
|
97
106
|
const admit = Effect.fn("Resource.admit")(function* (authorization, access) {
|
|
98
|
-
const verified = yield* Effect.result(
|
|
107
|
+
const verified = yield* Effect.result(principalFor(authorization, access));
|
|
99
108
|
if (Result.isFailure(verified))
|
|
100
109
|
return yield* refuse(verified.failure, authorization);
|
|
101
|
-
|
|
102
|
-
const missing = access === "write" ? missingScope(principal) : undefined;
|
|
103
|
-
if (missing !== undefined)
|
|
104
|
-
return yield* refuse(new InsufficientScope({ scope: missing }), authorization);
|
|
105
|
-
return { ok: true, principal };
|
|
110
|
+
return { ok: true, principal: verified.success };
|
|
106
111
|
});
|
|
112
|
+
/**
|
|
113
|
+
* Hold what `admit` let in, such as a socket, to the same header value and `access`:
|
|
114
|
+
* fails with the first refusal, once a token expires or a key is revoked, expires or
|
|
115
|
+
* loses the scope. Never succeeds; race it against the connection.
|
|
116
|
+
*/
|
|
117
|
+
const watch = (authorization, access) => Effect.forever(Effect.gen(function* () {
|
|
118
|
+
const principal = yield* principalFor(authorization, access);
|
|
119
|
+
const now = yield* Clock.currentTimeMillis;
|
|
120
|
+
const remaining = Math.max(0, (principal.expiresAt ?? Infinity) - now);
|
|
121
|
+
// A key is checked again against each refreshed list, until it expires.
|
|
122
|
+
if (principal.actor.kind === "key")
|
|
123
|
+
return yield* Effect.sleep(Math.min(remaining, Duration.toMillis(keyListRefresh)));
|
|
124
|
+
// An access token cannot be revoked and never becomes valid again, so its expiry is
|
|
125
|
+
// all that ends it: no second JWKS read that an issuer outage could fail.
|
|
126
|
+
yield* Effect.sleep(remaining);
|
|
127
|
+
return yield* new Unauthorized({ message: "Authentication required" });
|
|
128
|
+
}));
|
|
107
129
|
/**
|
|
108
130
|
* The effect-actions pre-handler hook, bound once per surface. A read needs nothing
|
|
109
131
|
* beyond what verification already required; a write needs the configured write scope.
|
|
@@ -129,6 +151,7 @@ export const make = Effect.fn("Resource.make")(function* (options) {
|
|
|
129
151
|
verifier,
|
|
130
152
|
discovery,
|
|
131
153
|
admit,
|
|
154
|
+
watch,
|
|
132
155
|
authorize,
|
|
133
156
|
session,
|
|
134
157
|
resource: identifier,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Claims set on a signed token. Each one overrides this issuer's default. */
|
|
2
|
+
export interface TokenClaims {
|
|
3
|
+
readonly iss?: string;
|
|
4
|
+
readonly sub?: string;
|
|
5
|
+
readonly aud?: string | Array<string>;
|
|
6
|
+
readonly iat?: number;
|
|
7
|
+
/** Epoch seconds. Default: five minutes from now. */
|
|
8
|
+
readonly exp?: number;
|
|
9
|
+
/** Space-separated, as the wire carries it. */
|
|
10
|
+
readonly scope?: string;
|
|
11
|
+
readonly client_id?: string;
|
|
12
|
+
/** Proof of possession, for checking that a sender-constrained token is refused. */
|
|
13
|
+
readonly cnf?: {
|
|
14
|
+
readonly jkt: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface FakeIssuerOptions {
|
|
18
|
+
/** The resource identifier tokens are minted for: an application's public origin root. */
|
|
19
|
+
readonly resource: string;
|
|
20
|
+
/** The scopes a signed token, and a key minted without permissions, carry. */
|
|
21
|
+
readonly scopes: readonly string[];
|
|
22
|
+
/** Who every credential belongs to. Default `"owner"`. */
|
|
23
|
+
readonly subject?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Resource identifier to the scopes a key has there, as the real issuer takes them. */
|
|
26
|
+
export type Permissions = Readonly<Record<string, readonly string[]>>;
|
|
27
|
+
export interface FakeIssuer {
|
|
28
|
+
/** What a resource server is configured with: `http://127.0.0.1:<port>/api/auth`. */
|
|
29
|
+
readonly issuer: string;
|
|
30
|
+
/** Sign an access token this issuer's JWKS verifies. */
|
|
31
|
+
readonly sign: (claims?: TokenClaims, typ?: string) => Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Mint an API key, listed in each granted resource's key list from its next read.
|
|
34
|
+
* Default permissions: every configured scope on the configured resource. `expiresAt`
|
|
35
|
+
* is when the key stops verifying; default never. Key IDs are `key-1`, `key-2`, ….
|
|
36
|
+
*/
|
|
37
|
+
readonly apiKey: (permissions?: Permissions, expiresAt?: Date) => string;
|
|
38
|
+
/** Leave a minted key out of every key list from now on, as the real issuer does. */
|
|
39
|
+
readonly revoke: (key: string) => void;
|
|
40
|
+
/** Answer both endpoints with this status instead of serving them; omit to restore. */
|
|
41
|
+
readonly fail: (status?: number) => void;
|
|
42
|
+
/** Key lists served, so a test can see when a resource server reads its next one. */
|
|
43
|
+
readonly keyLists: () => number;
|
|
44
|
+
readonly close: () => Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export declare const startFakeIssuer: (options: FakeIssuerOptions) => Promise<FakeIssuer>;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A signing fake issuer for tests: the two endpoints a resource server actually calls,
|
|
3
|
+
* and nothing else. Use it where a real issuer would make a test slow or awkward — a
|
|
4
|
+
* token that must expire in two seconds, a key granted on someone else's resource, an
|
|
5
|
+
* outage — and `@gjermundgaraba/clankerauth-dev` where the protocol itself is under test.
|
|
6
|
+
*
|
|
7
|
+
* It signs with a real EdDSA key and publishes it as JWKS, so every token and key list
|
|
8
|
+
* still travels the resource server's own verifier: signature, issuer, audience, type,
|
|
9
|
+
* claims, scopes, and each key's sealed entry. Node only.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { Option, Schema } from "effect";
|
|
14
|
+
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
15
|
+
import * as KeyList from "./key-list.js";
|
|
16
|
+
const ListRequest = Schema.fromJsonString(Schema.Struct({ resource: Schema.String }));
|
|
17
|
+
export const startFakeIssuer = async (options) => {
|
|
18
|
+
const subject = options.subject ?? "owner";
|
|
19
|
+
const pair = await generateKeyPair("EdDSA");
|
|
20
|
+
const jwk = { ...(await exportJWK(pair.publicKey)), kid: "fixture", alg: "EdDSA", use: "sig" };
|
|
21
|
+
const keys = new Map();
|
|
22
|
+
let minted = 0;
|
|
23
|
+
let lists = 0;
|
|
24
|
+
let failure;
|
|
25
|
+
const server = createServer(async (request, response) => {
|
|
26
|
+
const send = (status, body) => {
|
|
27
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
28
|
+
response.end(body);
|
|
29
|
+
};
|
|
30
|
+
if (request.url === "/api/auth/jwks")
|
|
31
|
+
return send(failure ?? 200, JSON.stringify({ keys: [jwk] }));
|
|
32
|
+
if (request.url !== "/api/issuer/keyList" || request.method !== "POST")
|
|
33
|
+
return send(404, "{}");
|
|
34
|
+
lists++;
|
|
35
|
+
if (failure !== undefined)
|
|
36
|
+
return send(failure, "{}");
|
|
37
|
+
let text = "";
|
|
38
|
+
for await (const chunk of request)
|
|
39
|
+
text += String(chunk);
|
|
40
|
+
const asked = Schema.decodeUnknownOption(ListRequest)(text);
|
|
41
|
+
if (Option.isNone(asked))
|
|
42
|
+
return send(400, '{"error":"Invalid request"}');
|
|
43
|
+
const { resource } = asked.value;
|
|
44
|
+
// Like the real issuer, a list holds only what is granted on the resource it names,
|
|
45
|
+
// and only what has not expired.
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const entries = await Promise.all([...keys].flatMap(([key, { keyId, permissions, expiresAt }]) => {
|
|
48
|
+
const scopes = permissions[resource] ?? [];
|
|
49
|
+
return scopes.length && (expiresAt === null || expiresAt > now)
|
|
50
|
+
? [
|
|
51
|
+
KeyList.digest(key).then((digest) => KeyList.seal(digest, resource, { keyId, ownerId: subject, scopes, expiresAt })),
|
|
52
|
+
]
|
|
53
|
+
: [];
|
|
54
|
+
}));
|
|
55
|
+
const iat = Math.floor(now / 1000);
|
|
56
|
+
const list = await new SignJWT({ keys: entries })
|
|
57
|
+
.setProtectedHeader({ alg: "EdDSA", kid: "fixture", typ: KeyList.type })
|
|
58
|
+
.setIssuer(issuer)
|
|
59
|
+
.setAudience(resource)
|
|
60
|
+
.setIssuedAt(iat)
|
|
61
|
+
.setExpirationTime(iat + KeyList.lifetime)
|
|
62
|
+
.sign(pair.privateKey);
|
|
63
|
+
return send(200, JSON.stringify({ list }));
|
|
64
|
+
});
|
|
65
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
66
|
+
const address = server.address();
|
|
67
|
+
if (!(address instanceof Object))
|
|
68
|
+
throw new Error("The fake issuer failed to bind a TCP port");
|
|
69
|
+
const issuer = `http://127.0.0.1:${address.port}/api/auth`;
|
|
70
|
+
return {
|
|
71
|
+
issuer,
|
|
72
|
+
sign: (claims = {}, typ = "at+jwt") => {
|
|
73
|
+
const now = Math.floor(Date.now() / 1000);
|
|
74
|
+
return new SignJWT({
|
|
75
|
+
iss: issuer,
|
|
76
|
+
sub: subject,
|
|
77
|
+
aud: options.resource,
|
|
78
|
+
iat: now,
|
|
79
|
+
exp: now + 300,
|
|
80
|
+
client_id: "fixture",
|
|
81
|
+
scope: options.scopes.join(" "),
|
|
82
|
+
...claims,
|
|
83
|
+
})
|
|
84
|
+
.setProtectedHeader({ alg: "EdDSA", kid: "fixture", typ })
|
|
85
|
+
.sign(pair.privateKey);
|
|
86
|
+
},
|
|
87
|
+
apiKey: (permissions = { [options.resource]: options.scopes }, expiresAt) => {
|
|
88
|
+
const key = `clankerauth_${randomBytes(48).toString("base64url")}`;
|
|
89
|
+
keys.set(key, {
|
|
90
|
+
keyId: `key-${++minted}`,
|
|
91
|
+
permissions,
|
|
92
|
+
expiresAt: expiresAt?.getTime() ?? null,
|
|
93
|
+
});
|
|
94
|
+
return key;
|
|
95
|
+
},
|
|
96
|
+
revoke: (key) => {
|
|
97
|
+
keys.delete(key);
|
|
98
|
+
},
|
|
99
|
+
fail: (status) => {
|
|
100
|
+
failure = status;
|
|
101
|
+
},
|
|
102
|
+
keyLists: () => lists,
|
|
103
|
+
close: () => new Promise((resolve, reject) => {
|
|
104
|
+
server.closeAllConnections();
|
|
105
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
};
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
-
import { HttpClient, HttpClientRequest } from "effect/
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
3
|
import { ProviderUnavailable } from "./errors.ts";
|
|
4
|
-
export declare const execute: (client: HttpClient.HttpClient, request: HttpClientRequest.HttpClientRequest) => Effect.Effect<import("effect/
|
|
4
|
+
export declare const execute: (client: HttpClient.HttpClient, request: HttpClientRequest.HttpClientRequest) => Effect.Effect<import("effect/http/HttpClientResponse").HttpClientResponse, ProviderUnavailable, import("effect/Scope").Scope>;
|
package/dist/transport.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context, Effect } from "effect";
|
|
2
|
-
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
3
3
|
import { ProviderUnavailable } from "./errors.js";
|
|
4
4
|
// Node transports do not follow redirects by default. Also pin FetchHttpClient's policy;
|
|
5
5
|
// forwarding credentials through a redirect is not an authentication fallback.
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
-
import { HttpClient } from "effect/
|
|
3
|
-
import { ConfigurationError, InsufficientScope, ProviderUnavailable,
|
|
2
|
+
import { HttpClient } from "effect/http";
|
|
3
|
+
import { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.ts";
|
|
4
4
|
import type { AuthenticationError } from "./errors.ts";
|
|
5
5
|
export interface Principal {
|
|
6
6
|
readonly subject: string;
|
|
@@ -14,9 +14,9 @@ export interface Principal {
|
|
|
14
14
|
};
|
|
15
15
|
/**
|
|
16
16
|
* When this credential stops being valid, in epoch milliseconds: an access token's
|
|
17
|
-
* verified `exp`, so a host never decodes the token again
|
|
18
|
-
* key
|
|
19
|
-
*
|
|
17
|
+
* verified `exp`, so a host never decodes the token again, or an API key's expiry.
|
|
18
|
+
* `undefined` for a key that does not expire. A key can also be revoked before then;
|
|
19
|
+
* `Resource.watch` ends a connection at whichever comes first.
|
|
20
20
|
*/
|
|
21
21
|
readonly expiresAt: number | undefined;
|
|
22
22
|
}
|
|
@@ -24,7 +24,7 @@ export interface Options {
|
|
|
24
24
|
readonly issuer: string;
|
|
25
25
|
readonly resource: string;
|
|
26
26
|
readonly requiredScopes?: readonly string[];
|
|
27
|
-
/** `false`
|
|
27
|
+
/** `false` refuses API keys without reading the issuer's key list; only OAuth access tokens are accepted. */
|
|
28
28
|
readonly apiKeys?: boolean;
|
|
29
29
|
}
|
|
30
30
|
export interface Verifier {
|
|
@@ -35,6 +35,7 @@ export interface Verifier {
|
|
|
35
35
|
* Acquire once per resource; transport is selected by the application, not the SDK.
|
|
36
36
|
* Verification runs to completion: a deadline is the request edge's policy, which
|
|
37
37
|
* `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
|
|
38
|
+
* The one exception is a key list read, which gives up after three seconds.
|
|
38
39
|
*/
|
|
39
40
|
export declare const make: (options: Options) => Effect.Effect<{
|
|
40
41
|
verifyToken: (token: string) => Effect.Effect<{
|
|
@@ -52,8 +53,8 @@ export declare const make: (options: Options) => Effect.Effect<{
|
|
|
52
53
|
kind: "key";
|
|
53
54
|
keyId: string;
|
|
54
55
|
};
|
|
55
|
-
expiresAt: undefined;
|
|
56
|
-
}, InsufficientScope | ProviderUnavailable |
|
|
56
|
+
expiresAt: number | undefined;
|
|
57
|
+
}, InsufficientScope | ProviderUnavailable | Unauthorized, never>;
|
|
57
58
|
verify: (authorization: string | null | undefined) => Effect.Effect<{
|
|
58
59
|
subject: string;
|
|
59
60
|
scopes: string[];
|
|
@@ -69,6 +70,6 @@ export declare const make: (options: Options) => Effect.Effect<{
|
|
|
69
70
|
kind: "key";
|
|
70
71
|
keyId: string;
|
|
71
72
|
};
|
|
72
|
-
expiresAt: undefined;
|
|
73
|
-
}, InsufficientScope | ProviderUnavailable |
|
|
73
|
+
expiresAt: number | undefined;
|
|
74
|
+
}, InsufficientScope | ProviderUnavailable | Unauthorized, never>;
|
|
74
75
|
}, ConfigurationError, HttpClient.HttpClient>;
|
package/dist/verify.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { Cause, Clock,
|
|
1
|
+
import { Cause, Clock, Effect, Exit, Ref, Result, Schema } from "effect";
|
|
2
2
|
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
|
|
3
|
-
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/
|
|
4
|
-
import { ConfigurationError, InsufficientScope, ProviderUnavailable,
|
|
3
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
|
+
import { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized, } from "./errors.js";
|
|
5
|
+
import * as KeyList from "./key-list.js";
|
|
6
|
+
import { keyListRefresh } from "./refresh.js";
|
|
5
7
|
import { execute } from "./transport.js";
|
|
6
8
|
const Jwks = Schema.Struct({
|
|
7
9
|
keys: Schema.Array(Schema.Struct({
|
|
@@ -18,31 +20,34 @@ const Claims = Schema.Struct({
|
|
|
18
20
|
sub: Schema.NonEmptyString,
|
|
19
21
|
client_id: Schema.NonEmptyString,
|
|
20
22
|
scope: Schema.String,
|
|
23
|
+
iat: Schema.Finite,
|
|
21
24
|
exp: Schema.Finite,
|
|
22
25
|
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
scopes: Schema.Array(Schema.NonEmptyString),
|
|
28
|
-
expiresAt: Schema.NullOr(Schema.DateTimeUtcFromString),
|
|
29
|
-
});
|
|
30
|
-
const unauthorized = () => new Unauthorized({ message: "Authentication required" });
|
|
26
|
+
/** The list document as the issuer's `keyList` action answers it. */
|
|
27
|
+
const KeyListResponse = Schema.Struct({ list: Schema.String });
|
|
28
|
+
const KeyListClaims = KeyList.Claims.pipe(Schema.fieldsAssign({ exp: Schema.Finite }));
|
|
29
|
+
const unauthorized = (cause) => new Unauthorized({ message: "Authentication required", cause });
|
|
31
30
|
/** How long a JWKS document answers verification before it is read again. */
|
|
32
31
|
const documentLifetime = "1 minute";
|
|
33
32
|
/** How long a failed read holds off the next one. */
|
|
34
33
|
const failureCooldown = "5 seconds";
|
|
34
|
+
/**
|
|
35
|
+
* How long a key list read may take. Under `Resource`'s five-second deadline, so a stalled
|
|
36
|
+
* issuer fails the read, not the request, and a held list still decides.
|
|
37
|
+
*/
|
|
38
|
+
const listReadTimeout = "3 seconds";
|
|
35
39
|
/**
|
|
36
40
|
* Acquire once per resource; transport is selected by the application, not the SDK.
|
|
37
41
|
* Verification runs to completion: a deadline is the request edge's policy, which
|
|
38
42
|
* `Resource` applies, so an in-process caller can await a provider call it cannot cancel.
|
|
43
|
+
* The one exception is a key list read, which gives up after three seconds.
|
|
39
44
|
*/
|
|
40
45
|
export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
41
46
|
const client = yield* HttpClient.HttpClient;
|
|
42
47
|
const endpoints = yield* Effect.try({
|
|
43
48
|
try: () => ({
|
|
44
49
|
jwks: new URL(`${options.issuer}/jwks`).href,
|
|
45
|
-
|
|
50
|
+
keyList: new URL("/api/issuer/keyList", options.issuer).href,
|
|
46
51
|
}),
|
|
47
52
|
catch: () => new ConfigurationError({ message: "Invalid issuer URL" }),
|
|
48
53
|
});
|
|
@@ -61,11 +66,12 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
61
66
|
// One read serves every key identifier until it expires, whatever its outcome, so an
|
|
62
67
|
// unknown `kid` in an unauthenticated request never becomes traffic at the issuer.
|
|
63
68
|
const read = yield* Effect.cachedWithTTL(document(), (exit) => Exit.isSuccess(exit) ? documentLifetime : failureCooldown);
|
|
64
|
-
// Replaying
|
|
69
|
+
// Replaying a read must not hand a later request the interruption of an earlier
|
|
65
70
|
// one's deadline; within the window the issuer simply was not reached.
|
|
66
|
-
const
|
|
67
|
-
? Effect.fail(new ProviderUnavailable({ operation
|
|
71
|
+
const replayed = (cached, operation) => Effect.catchCause(cached, (cause) => Cause.hasInterrupts(cause)
|
|
72
|
+
? Effect.fail(new ProviderUnavailable({ operation }))
|
|
68
73
|
: Effect.failCause(cause));
|
|
74
|
+
const keys = replayed(read, "jwks.refresh");
|
|
69
75
|
const signingKey = Effect.fn("Verifier.signingKey")(function* (kid) {
|
|
70
76
|
const resolve = yield* keys;
|
|
71
77
|
return yield* Effect.tryPromise({
|
|
@@ -74,29 +80,38 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
74
80
|
catch: unauthorized,
|
|
75
81
|
});
|
|
76
82
|
});
|
|
77
|
-
|
|
83
|
+
/**
|
|
84
|
+
* A document this issuer signed for this resource: EdDSA under a key it names, with the
|
|
85
|
+
* expected `typ`, issuer and audience, and not expired. Which claims it must carry is
|
|
86
|
+
* its schema's to say. `Unauthorized` means it is not to be trusted; what that amounts
|
|
87
|
+
* to is the caller's to say.
|
|
88
|
+
*/
|
|
89
|
+
const verifySigned = Effect.fn("Verifier.signed")(function* (jws, typ) {
|
|
78
90
|
const header = yield* Effect.try({
|
|
79
|
-
try: () => decodeProtectedHeader(
|
|
91
|
+
try: () => decodeProtectedHeader(jws),
|
|
80
92
|
catch: unauthorized,
|
|
81
93
|
});
|
|
82
94
|
// The issuer always names its key, so key selection is never ambiguous. Reject other
|
|
83
|
-
//
|
|
95
|
+
// documents before doing provider I/O; JOSE still enforces its algorithm allowlist.
|
|
84
96
|
if (header.alg !== "EdDSA" || header.kid === undefined)
|
|
85
97
|
return yield* unauthorized();
|
|
86
98
|
const key = yield* signingKey(header.kid);
|
|
87
99
|
const now = yield* Clock.currentTimeMillis;
|
|
88
100
|
const { payload } = yield* Effect.tryPromise({
|
|
89
|
-
try: () => jwtVerify(
|
|
101
|
+
try: () => jwtVerify(jws, key, {
|
|
90
102
|
issuer: options.issuer,
|
|
91
103
|
audience: options.resource,
|
|
92
104
|
algorithms: ["EdDSA"],
|
|
93
|
-
typ
|
|
94
|
-
requiredClaims: ["sub", "client_id", "scope", "iat", "exp"],
|
|
105
|
+
typ,
|
|
95
106
|
currentDate: new Date(now),
|
|
96
107
|
}),
|
|
97
108
|
// Verification against a resolved key does no I/O either.
|
|
98
109
|
catch: unauthorized,
|
|
99
110
|
});
|
|
111
|
+
return payload;
|
|
112
|
+
});
|
|
113
|
+
const verifyJwt = Effect.fn("Verifier.jwt")(function* (token) {
|
|
114
|
+
const payload = yield* verifySigned(token, "at+jwt");
|
|
100
115
|
if (payload.cnf !== undefined)
|
|
101
116
|
return yield* unauthorized();
|
|
102
117
|
const claims = yield* Schema.decodeUnknownEffect(Claims)(payload).pipe(Effect.mapError(unauthorized));
|
|
@@ -108,35 +123,67 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
108
123
|
expiresAt: claims.exp * 1000,
|
|
109
124
|
};
|
|
110
125
|
});
|
|
111
|
-
const
|
|
112
|
-
const request = HttpClientRequest.post(endpoints.
|
|
126
|
+
const listDocument = Effect.fn("Verifier.keyList")(function* () {
|
|
127
|
+
const request = HttpClientRequest.post(endpoints.keyList).pipe(HttpClientRequest.bodyJsonUnsafe({ resource: options.resource }));
|
|
113
128
|
const response = yield* execute(client, request);
|
|
114
|
-
// The issuer answers 403 for a key with no grant on this resource. That is the same
|
|
115
|
-
// case as a token for another audience: the credential is not this resource's.
|
|
116
|
-
if (response.status === 401 || response.status === 403)
|
|
117
|
-
return yield* unauthorized();
|
|
118
|
-
if (response.status === 429)
|
|
119
|
-
return yield* new RateLimited({ message: "Authentication rate exceeded" });
|
|
120
129
|
if (response.status !== 200)
|
|
121
130
|
return yield* new ProviderUnavailable({
|
|
122
|
-
operation: "
|
|
131
|
+
operation: "key-list.fetch",
|
|
123
132
|
cause: { status: response.status },
|
|
124
133
|
});
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return yield* unauthorized();
|
|
134
|
+
const { list } = yield* HttpClientResponse.schemaBodyJson(KeyListResponse)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "key-list.decode", cause })));
|
|
135
|
+
// A list that does not verify is no list: the held one keeps deciding.
|
|
136
|
+
const rejected = (cause) => new ProviderUnavailable({ operation: "key-list.verify", cause });
|
|
137
|
+
const payload = yield* verifySigned(list, KeyList.type).pipe(Effect.catchTag("Unauthorized", (cause) => Effect.fail(rejected(cause))));
|
|
138
|
+
const claims = yield* Schema.decodeUnknownEffect(KeyListClaims)(payload).pipe(Effect.mapError(rejected));
|
|
131
139
|
return {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
actor: { kind: "key", keyId: body.keyId },
|
|
135
|
-
// A key's own expiry is enforced above, on every verification; it is not a
|
|
136
|
-
// lifetime a caller may hold a decision for.
|
|
137
|
-
expiresAt: undefined,
|
|
140
|
+
expiresAt: claims.exp * 1000,
|
|
141
|
+
entries: new Map(claims.keys.map((entry) => [entry.id, entry.sealed])),
|
|
138
142
|
};
|
|
139
143
|
}, Effect.scoped);
|
|
144
|
+
// While a list is held, a failing issuer is otherwise invisible until that list runs
|
|
145
|
+
// out. Reads are logged when they start failing and when they recover, not per retry.
|
|
146
|
+
const failing = yield* Ref.make(false);
|
|
147
|
+
const readList = yield* Effect.cachedWithTTL(listDocument().pipe(Effect.timeoutOrElse({
|
|
148
|
+
duration: listReadTimeout,
|
|
149
|
+
orElse: () => Effect.fail(new ProviderUnavailable({ operation: "key-list.timeout" })),
|
|
150
|
+
}), Effect.tapError(({ operation }) => Effect.flatMap(Ref.getAndSet(failing, true), (was) => was
|
|
151
|
+
? Effect.void
|
|
152
|
+
: Effect.logWarning("clankerauth key list reads are failing; a held list decides until it expires", { operation }))), Effect.tap(() => Effect.flatMap(Ref.getAndSet(failing, false), (was) => was ? Effect.logInfo("clankerauth key list reads have recovered") : Effect.void))), (exit) => (Exit.isSuccess(exit) ? keyListRefresh : failureCooldown));
|
|
153
|
+
// The list is verified once, when it is read. Holding its entries, not the document,
|
|
154
|
+
// is what lets keys verify through an outage that has already taken JWKS with it.
|
|
155
|
+
const held = yield* Ref.make(undefined);
|
|
156
|
+
/** The last verified list, while it is inside its window. */
|
|
157
|
+
const keyList = Effect.gen(function* () {
|
|
158
|
+
const fetched = yield* Effect.result(replayed(readList, "key-list.refresh"));
|
|
159
|
+
if (Result.isSuccess(fetched))
|
|
160
|
+
yield* Ref.set(held, fetched.success);
|
|
161
|
+
const list = yield* Ref.get(held);
|
|
162
|
+
// A read is cached past its own check, so the window is checked on every use.
|
|
163
|
+
if (list !== undefined && list.expiresAt > (yield* Clock.currentTimeMillis))
|
|
164
|
+
return list;
|
|
165
|
+
return yield* Result.isFailure(fetched)
|
|
166
|
+
? fetched.failure
|
|
167
|
+
: new ProviderUnavailable({ operation: "key-list.expired" });
|
|
168
|
+
});
|
|
169
|
+
const verifyKey = Effect.fn("Verifier.apiKey")(function* (key) {
|
|
170
|
+
const list = yield* keyList;
|
|
171
|
+
const grant = yield* Effect.tryPromise({
|
|
172
|
+
try: () => KeyList.open(key, options.resource, list.entries),
|
|
173
|
+
catch: unauthorized,
|
|
174
|
+
});
|
|
175
|
+
// No entry is the same case as a token for another audience: not this resource's.
|
|
176
|
+
if (grant === undefined)
|
|
177
|
+
return yield* unauthorized();
|
|
178
|
+
if (grant.expiresAt !== null && grant.expiresAt <= (yield* Clock.currentTimeMillis))
|
|
179
|
+
return yield* unauthorized();
|
|
180
|
+
return {
|
|
181
|
+
subject: grant.ownerId,
|
|
182
|
+
scopes: grant.scopes,
|
|
183
|
+
actor: { kind: "key", keyId: grant.keyId },
|
|
184
|
+
expiresAt: grant.expiresAt ?? undefined,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
140
187
|
const verifyToken = Effect.fn("Verifier.verifyToken")(function* (token) {
|
|
141
188
|
const principal = yield* token.startsWith("clankerauth_")
|
|
142
189
|
? options.apiKeys === false
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerauth-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Effect-native clankerauth access-token and API-key verification, with optional effect-actions integration",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Effect-native clankerauth access-token and offline API-key verification, with optional effect-actions integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -29,25 +29,34 @@
|
|
|
29
29
|
"./effect-actions": {
|
|
30
30
|
"types": "./dist/effect-actions.d.ts",
|
|
31
31
|
"import": "./dist/effect-actions.js"
|
|
32
|
+
},
|
|
33
|
+
"./key-list": {
|
|
34
|
+
"types": "./dist/key-list.d.ts",
|
|
35
|
+
"import": "./dist/key-list.js"
|
|
36
|
+
},
|
|
37
|
+
"./testing": {
|
|
38
|
+
"types": "./dist/testing.d.ts",
|
|
39
|
+
"import": "./dist/testing.js"
|
|
32
40
|
}
|
|
33
41
|
},
|
|
34
42
|
"publishConfig": {
|
|
35
43
|
"access": "public"
|
|
36
44
|
},
|
|
37
45
|
"dependencies": {
|
|
38
|
-
"effect": "4.0.0-rc.117",
|
|
39
46
|
"jose": "6.2.12"
|
|
40
47
|
},
|
|
41
48
|
"devDependencies": {
|
|
42
|
-
"@gjermundgaraba/effect-actions": "0.
|
|
49
|
+
"@gjermundgaraba/effect-actions": "0.8.0",
|
|
43
50
|
"@modelcontextprotocol/client": "2.0.0",
|
|
44
51
|
"@types/node": "26.6.2",
|
|
52
|
+
"effect": "4.0.0-rc.118",
|
|
45
53
|
"typescript": "7.0.2",
|
|
46
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.
|
|
47
|
-
"vite-plus": "0.
|
|
54
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
|
|
55
|
+
"vite-plus": "1.0.0"
|
|
48
56
|
},
|
|
49
57
|
"peerDependencies": {
|
|
50
|
-
"@gjermundgaraba/effect-actions": "^0.
|
|
58
|
+
"@gjermundgaraba/effect-actions": "^0.8.0",
|
|
59
|
+
"effect": "^4.0.0-rc.118"
|
|
51
60
|
},
|
|
52
61
|
"peerDependenciesMeta": {
|
|
53
62
|
"@gjermundgaraba/effect-actions": {
|