@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/dist/session.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The identity a browser page needs, without any verification code. This entry point is
|
|
3
|
-
* browser-safe: it declares the
|
|
4
|
-
*
|
|
3
|
+
* browser-safe: it declares the identity a protected contract states, the `whoami` contract
|
|
4
|
+
* and the issuer's sign-out URL, so contracts, bindings and pages import it directly, and a
|
|
5
|
+
* server answers `Whoami` with its resource's `session`.
|
|
5
6
|
*/
|
|
6
|
-
import { Schema } from "effect";
|
|
7
|
+
import { Context, Schema } from "effect";
|
|
7
8
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The caller: what a protected contract states, `caller: CurrentPrincipal`, and what an
|
|
11
|
+
* authentication descriptor proves,
|
|
12
|
+
* `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
|
|
13
|
+
* Remotely, the resource's provider gives it per request, from the verified credential; on
|
|
14
|
+
* a trusted local surface, the host gives it the process, `Notes.local(subject)`.
|
|
15
|
+
*/
|
|
16
|
+
export class CurrentPrincipal extends Context.Service()("@clankerauth/CurrentPrincipal") {
|
|
17
|
+
}
|
|
9
18
|
/** The verified credential behind a request, as the resource server read it. */
|
|
10
19
|
export const Principal = Schema.Struct({
|
|
11
20
|
subject: Schema.NonEmptyString,
|
|
@@ -13,15 +22,16 @@ export const Principal = Schema.Struct({
|
|
|
13
22
|
scopes: Schema.Array(Schema.String),
|
|
14
23
|
});
|
|
15
24
|
/**
|
|
16
|
-
* The credential is a transport concern: a process surface has none, so `
|
|
17
|
-
*
|
|
25
|
+
* The credential is a transport concern: a process surface has none, so bind `Whoami` to
|
|
26
|
+
* HTTP alone, `ActionHttp.layer(Http, [app, Notes.session])`, and leave `Notes.session` out
|
|
27
|
+
* of MCP.
|
|
18
28
|
*/
|
|
19
|
-
export const
|
|
29
|
+
export const Whoami = Action.make("whoami", {
|
|
20
30
|
description: "The verified subject, issuer and scopes behind this request's credential.",
|
|
21
|
-
|
|
31
|
+
readOnly: true,
|
|
32
|
+
caller: CurrentPrincipal,
|
|
22
33
|
success: Principal,
|
|
23
|
-
|
|
24
|
-
}));
|
|
34
|
+
});
|
|
25
35
|
/**
|
|
26
36
|
* Sign out at the issuer, which ends its session and every app's forward cookie, then
|
|
27
37
|
* return to `returnTo`. It is a plain link: any page under the cookie domain may use it.
|
package/dist/testing.js
CHANGED
|
@@ -29,7 +29,7 @@ export const startFakeIssuer = async (options) => {
|
|
|
29
29
|
};
|
|
30
30
|
if (request.url === "/api/auth/jwks")
|
|
31
31
|
return send(failure ?? 200, JSON.stringify({ keys: [jwk] }));
|
|
32
|
-
if (request.url !== "/api/
|
|
32
|
+
if (request.url !== "/api/keyList" || request.method !== "POST")
|
|
33
33
|
return send(404, "{}");
|
|
34
34
|
lists++;
|
|
35
35
|
if (failure !== undefined)
|
package/dist/verify.d.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { Effect } from "effect";
|
|
|
2
2
|
import { HttpClient } from "effect/http";
|
|
3
3
|
import { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.ts";
|
|
4
4
|
import type { AuthenticationError } from "./errors.ts";
|
|
5
|
+
/**
|
|
6
|
+
* What a verified credential proves: an OAuth client's access token or an API key. A request's
|
|
7
|
+
* caller, `CurrentPrincipal`, is this or, on a trusted local surface, the process itself, which
|
|
8
|
+
* no verifier produces: `Caller` of the `/session` entry.
|
|
9
|
+
*/
|
|
5
10
|
export interface Principal {
|
|
6
11
|
readonly subject: string;
|
|
7
12
|
readonly scopes: readonly string[];
|
|
@@ -28,7 +33,6 @@ export interface Options {
|
|
|
28
33
|
readonly apiKeys?: boolean;
|
|
29
34
|
}
|
|
30
35
|
export interface Verifier {
|
|
31
|
-
readonly verify: (authorization: string | null | undefined) => Effect.Effect<Principal, AuthenticationError>;
|
|
32
36
|
readonly verifyToken: (token: string) => Effect.Effect<Principal, AuthenticationError>;
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
@@ -55,21 +59,4 @@ export declare const make: (options: Options) => Effect.Effect<{
|
|
|
55
59
|
};
|
|
56
60
|
expiresAt: number | undefined;
|
|
57
61
|
}, InsufficientScope | ProviderUnavailable | Unauthorized, never>;
|
|
58
|
-
verify: (authorization: string | null | undefined) => Effect.Effect<{
|
|
59
|
-
subject: string;
|
|
60
|
-
scopes: string[];
|
|
61
|
-
actor: {
|
|
62
|
-
kind: "client";
|
|
63
|
-
clientId: string;
|
|
64
|
-
};
|
|
65
|
-
expiresAt: number;
|
|
66
|
-
} | {
|
|
67
|
-
subject: string;
|
|
68
|
-
scopes: readonly string[];
|
|
69
|
-
actor: {
|
|
70
|
-
kind: "key";
|
|
71
|
-
keyId: string;
|
|
72
|
-
};
|
|
73
|
-
expiresAt: number | undefined;
|
|
74
|
-
}, InsufficientScope | ProviderUnavailable | Unauthorized, never>;
|
|
75
62
|
}, ConfigurationError, HttpClient.HttpClient>;
|
package/dist/verify.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Clock, Duration, Effect, Exit, Ref, Result, Schema } from "effect";
|
|
2
2
|
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
|
|
3
3
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
4
|
import { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized, } from "./errors.js";
|
|
@@ -26,7 +26,7 @@ const Claims = Schema.Struct({
|
|
|
26
26
|
/** The list document as the issuer's `keyList` action answers it. */
|
|
27
27
|
const KeyListResponse = Schema.Struct({ list: Schema.String });
|
|
28
28
|
const KeyListClaims = KeyList.Claims.pipe(Schema.fieldsAssign({ exp: Schema.Finite }));
|
|
29
|
-
const unauthorized = (cause) => new Unauthorized({
|
|
29
|
+
const unauthorized = (cause) => new Unauthorized({ cause });
|
|
30
30
|
/** How long a JWKS document answers verification before it is read again. */
|
|
31
31
|
const documentLifetime = "1 minute";
|
|
32
32
|
/**
|
|
@@ -50,7 +50,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
50
50
|
const endpoints = yield* Effect.try({
|
|
51
51
|
try: () => ({
|
|
52
52
|
jwks: new URL(`${options.issuer}/jwks`).href,
|
|
53
|
-
keyList: new URL("/api/
|
|
53
|
+
keyList: new URL("/api/keyList", options.issuer).href,
|
|
54
54
|
}),
|
|
55
55
|
catch: () => new ConfigurationError({ message: "Invalid issuer URL" }),
|
|
56
56
|
});
|
|
@@ -66,15 +66,10 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
66
66
|
keys: jwks.keys.map(({ key_ops, ...key }) => key_ops ? { ...key, key_ops: [...key_ops] } : key),
|
|
67
67
|
});
|
|
68
68
|
}, Effect.scoped);
|
|
69
|
-
// One read serves every key identifier until it expires,
|
|
70
|
-
// unknown `kid` in an unauthenticated request never becomes traffic at
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// one's deadline; within the window the issuer simply was not reached.
|
|
74
|
-
const replayed = (cached, operation) => Effect.catchCause(cached, (cause) => Cause.hasInterrupts(cause)
|
|
75
|
-
? Effect.fail(new ProviderUnavailable({ operation }))
|
|
76
|
-
: Effect.failCause(cause));
|
|
77
|
-
const keys = replayed(read, "jwks.refresh");
|
|
69
|
+
// One read serves every key identifier until it expires, or for the cooldown if it
|
|
70
|
+
// failed, so an unknown `kid` in an unauthenticated request never becomes traffic at
|
|
71
|
+
// the issuer. A read every caller gave up on is dropped, and the next caller reads again.
|
|
72
|
+
const keys = yield* Effect.cachedWithTTL(document(), (exit) => Exit.isSuccess(exit) ? documentLifetime : cooldown);
|
|
78
73
|
const signingKey = Effect.fn("Verifier.signingKey")(function* (kid) {
|
|
79
74
|
const resolve = yield* keys;
|
|
80
75
|
return yield* Effect.tryPromise({
|
|
@@ -159,7 +154,7 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
159
154
|
const held = yield* Ref.make(undefined);
|
|
160
155
|
/** The list read now, or within the cooldown, or else the held one, while inside its window. */
|
|
161
156
|
const latest = Effect.gen(function* () {
|
|
162
|
-
const fetched = yield* Effect.result(
|
|
157
|
+
const fetched = yield* Effect.result(readList);
|
|
163
158
|
if (Result.isSuccess(fetched))
|
|
164
159
|
yield* Ref.set(held, fetched.success);
|
|
165
160
|
const list = yield* Ref.get(held);
|
|
@@ -207,16 +202,8 @@ export const make = Effect.fn("Verifier.make")(function* (options) {
|
|
|
207
202
|
: verifyJwt(token);
|
|
208
203
|
const missing = options.requiredScopes?.find((scope) => !principal.scopes.includes(scope));
|
|
209
204
|
if (missing !== undefined)
|
|
210
|
-
return yield* new InsufficientScope({ scope: missing });
|
|
205
|
+
return yield* new InsufficientScope({ scope: missing, actor: principal.actor });
|
|
211
206
|
return principal;
|
|
212
207
|
});
|
|
213
|
-
return {
|
|
214
|
-
verifyToken,
|
|
215
|
-
verify: Effect.fn("Verifier.verify")(function* (authorization) {
|
|
216
|
-
const token = /^Bearer ([^\s,]+)$/iu.exec(authorization ?? "")?.[1];
|
|
217
|
-
if (!token)
|
|
218
|
-
return yield* unauthorized();
|
|
219
|
-
return yield* verifyToken(token);
|
|
220
|
-
}),
|
|
221
|
-
};
|
|
208
|
+
return { verifyToken };
|
|
222
209
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerauth-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Effect-native clankerauth access-token and offline API-key verification, with optional effect-actions integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,17 +46,17 @@
|
|
|
46
46
|
"jose": "6.2.12"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@gjermundgaraba/effect-actions": "0.
|
|
50
|
-
"@modelcontextprotocol/client": "2.
|
|
51
|
-
"@types/node": "26.6.
|
|
52
|
-
"effect": "4.0.
|
|
49
|
+
"@gjermundgaraba/effect-actions": "0.11.0",
|
|
50
|
+
"@modelcontextprotocol/client": "2.3.1",
|
|
51
|
+
"@types/node": "26.6.4",
|
|
52
|
+
"effect": "4.0.2",
|
|
53
53
|
"typescript": "7.0.2",
|
|
54
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
55
|
-
"vite-plus": "1.
|
|
54
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
55
|
+
"vite-plus": "1.1.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@gjermundgaraba/effect-actions": "^0.
|
|
59
|
-
"effect": "^4.0.
|
|
58
|
+
"@gjermundgaraba/effect-actions": "^0.11.0",
|
|
59
|
+
"effect": "^4.0.2"
|
|
60
60
|
},
|
|
61
61
|
"peerDependenciesMeta": {
|
|
62
62
|
"@gjermundgaraba/effect-actions": {
|