@gjermundgaraba/clankerauth-sdk 0.12.0 → 0.13.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 +136 -73
- 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 +170 -126
- 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 +4 -12
- package/package.json +3 -3
package/dist/resource.d.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
import { Context, Effect,
|
|
2
|
-
import
|
|
1
|
+
import { Context, Effect, Layer, Redacted } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
3
|
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
4
|
-
import {
|
|
4
|
+
import { HttpRouter } from "effect/http";
|
|
5
|
+
import type { HttpClient } from "effect/http";
|
|
6
|
+
import type { HttpApiSecurity } from "effect/http-api";
|
|
7
|
+
import type { ConfigurationError } from "./errors.ts";
|
|
8
|
+
import { ProviderUnavailable } from "./errors.ts";
|
|
9
|
+
import { CurrentPrincipal, Whoami } from "./session.ts";
|
|
10
|
+
import type { Caller } from "./session.ts";
|
|
5
11
|
import * as Verifier from "./verify.ts";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* Additionally required by an `access: "write"` action and by `admit(..., "write")`.
|
|
15
|
-
* Omit for a single-scope application, where verification is the whole policy.
|
|
16
|
-
*/
|
|
17
|
-
readonly write?: string;
|
|
18
|
-
}
|
|
12
|
+
/**
|
|
13
|
+
* Verify a request's bearer token with `verifier`, failing as an effect-actions surface
|
|
14
|
+
* refuses: what a verifier of your own, given to `Authentication.layer`, yields before its
|
|
15
|
+
* own checks. A declared resource's `provider` does this; reach for it only with a verifier
|
|
16
|
+
* of your own, whose descriptor declares `ProviderUnavailable` too, or another error the
|
|
17
|
+
* verifier maps it to.
|
|
18
|
+
*/
|
|
19
|
+
export declare const authenticate: (verifier: Verifier.Verifier, token: Redacted.Redacted<string>) => Effect.Effect<Verifier.Principal, Action.Refusal | ProviderUnavailable>;
|
|
19
20
|
export interface Options {
|
|
20
21
|
readonly issuer: string;
|
|
21
22
|
/**
|
|
@@ -23,13 +24,18 @@ export interface Options {
|
|
|
23
24
|
* root, so any path here is discarded: one identifier covers `/api`, `/mcp` and sockets.
|
|
24
25
|
*/
|
|
25
26
|
readonly publicUrl: URL;
|
|
26
|
-
readonly scopes: Scopes;
|
|
27
27
|
/** `false` refuses API keys without reading the issuer's key list; only OAuth access tokens are accepted. */
|
|
28
28
|
readonly apiKeys?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* `false` publishes no RFC 9728 discovery and names none in a challenge: for a surface
|
|
31
|
+
* whose callers hold API keys and never run an OAuth flow, such as a second listener
|
|
32
|
+
* beside the one that publishes the resource.
|
|
33
|
+
*/
|
|
34
|
+
readonly discovery?: boolean;
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
31
|
-
* A refusal a caller outside the router can send as-is:
|
|
32
|
-
* `
|
|
37
|
+
* A refusal a caller outside the router can send as-is: the status, headers and JSON body
|
|
38
|
+
* `authentication` answers the same refusal with on a route.
|
|
33
39
|
*/
|
|
34
40
|
export interface Refusal {
|
|
35
41
|
readonly status: number;
|
|
@@ -44,39 +50,112 @@ export type Admission = {
|
|
|
44
50
|
readonly ok: false;
|
|
45
51
|
readonly refusal: Refusal;
|
|
46
52
|
};
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
53
|
+
/**
|
|
54
|
+
* What a declared resource's `layer` builds: its verifier and its refusals. An
|
|
55
|
+
* interface, as `Declared` is, so a consumer's emitted declarations name it. `Scope` is the
|
|
56
|
+
* resource's scopes, as `make` infers them: a scope `admit` and `watch` check is one of them.
|
|
57
|
+
*/
|
|
58
|
+
export interface Built<Scope extends string = string> {
|
|
59
|
+
/** The resource identifier: the public origin root, which a token's audience names. */
|
|
60
|
+
readonly resource: string;
|
|
61
|
+
readonly issuer: string;
|
|
62
|
+
/** What `authentication` publishes and names in every challenge; none with `discovery: false`. */
|
|
63
|
+
readonly protectedResource: Authentication.ProtectedResource | undefined;
|
|
64
|
+
readonly verifier: Verifier.Verifier;
|
|
65
|
+
/**
|
|
66
|
+
* What an Authorization header value amounts to, for a caller outside the router: any
|
|
67
|
+
* verified caller, or with `scope`, one that also holds it.
|
|
68
|
+
*/
|
|
69
|
+
readonly admit: (authorization: string | null | undefined, scope?: Scope) => Effect.Effect<Admission>;
|
|
70
|
+
/** Fails, with the refusal `admit` would now answer, once the credential it admitted stops being valid. */
|
|
71
|
+
readonly watch: (authorization: string | null | undefined, scope?: Scope) => Effect.Effect<never, Action.Refusal | ProviderUnavailable>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The service identity of the resource whose descriptor is named `Name`, so two resources are
|
|
75
|
+
* two services: each surface reads its own, whatever layer graph builds them.
|
|
76
|
+
*/
|
|
77
|
+
export interface Resource<Name extends string> {
|
|
78
|
+
readonly "~@clankerauth/Resource": Name;
|
|
79
|
+
}
|
|
80
|
+
/** What `make` requires a descriptor to declare: the issuer being unreachable. */
|
|
81
|
+
type Unavailable = readonly [typeof ProviderUnavailable];
|
|
82
|
+
/**
|
|
83
|
+
* A descriptor of `CurrentPrincipal` by bearer token, declaring `ProviderUnavailable`, as a
|
|
84
|
+
* binding and an MCP endpoint name it, beside the contracts:
|
|
85
|
+
* `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
|
|
86
|
+
* `E` is what it declares, that one error unless it declares more.
|
|
87
|
+
*/
|
|
88
|
+
export type Login<Name extends string = string, E extends Action.Errors = Unavailable> = Authentication.Descriptor<CurrentPrincipal, Caller, HttpApiSecurity.Http, Name, E>;
|
|
89
|
+
/**
|
|
90
|
+
* `unknown` when every alternative of `E` declares `ProviderUnavailable`, by what its schemas
|
|
91
|
+
* decode (one of them, or a `Schema.Union` holding it), and what names the omission otherwise:
|
|
92
|
+
* a descriptor that may declare it, such as `error: cond ? ProviderUnavailable : undefined`,
|
|
93
|
+
* does not.
|
|
94
|
+
*/
|
|
95
|
+
type DeclaresUnavailable<E extends Action.Errors> = [
|
|
96
|
+
E extends unknown ? (ProviderUnavailable extends E[number]["Type"] ? never : E) : never
|
|
97
|
+
] extends [never] ? unknown : {
|
|
98
|
+
readonly "Resource.make takes a descriptor declaring error: ProviderUnavailable": never;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* What a resource declares beside its descriptor, the same in every deployment: the
|
|
102
|
+
* application's scopes, and the one every credential must carry.
|
|
103
|
+
*/
|
|
104
|
+
export interface Declaration<Scope extends string = string> {
|
|
105
|
+
/**
|
|
106
|
+
* Every scope the resource has: published in discovery, and held by `local`'s principal.
|
|
107
|
+
* effect-actions refuses one that is no OAuth scope token when the provider builds that
|
|
108
|
+
* discovery; with `discovery: false`, nothing checks them.
|
|
109
|
+
*/
|
|
110
|
+
readonly scopes: readonly Scope[];
|
|
111
|
+
/**
|
|
112
|
+
* The scope every credential must carry, one of `scopes`: verification requires it, every
|
|
113
|
+
* 401 names it, and a first login requests it. Any other scope is the application's to
|
|
114
|
+
* require, with `requires`.
|
|
115
|
+
*/
|
|
116
|
+
readonly required: NoInfer<Scope>;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A declared resource, as `make` gives it. `E` is what its descriptor declares,
|
|
120
|
+
* `ProviderUnavailable` alone unless it declares more. `Scope` is its scopes, as `make`
|
|
121
|
+
* infers them: `requires`, `admitted`, `admit` and `watch` take only one of them, so a scope
|
|
122
|
+
* it does not declare is a type error. Where an exported declaration must be written out,
|
|
123
|
+
* annotate with all three:
|
|
124
|
+
* `Resource.Declared<"notes.Login", readonly [typeof ProviderUnavailable], "notes:read" | "notes:write">`.
|
|
125
|
+
*/
|
|
126
|
+
export interface Declared<Name extends string, E extends Action.Errors = Unavailable, Scope extends string = string> {
|
|
127
|
+
/** The descriptor the resource verifies, as given to `make`. */
|
|
128
|
+
readonly authentication: Login<Name, E>;
|
|
129
|
+
/** The built resource: `admit` and `watch` for a caller outside the router, `verifier`. */
|
|
130
|
+
readonly service: Context.Service<Resource<Name>, Built<Scope>>;
|
|
131
|
+
/** The resource, built once per layer graph: provide it beside the surfaces it guards. */
|
|
132
|
+
readonly layer: (options: Options) => Layer.Layer<Resource<Name>, ConfigurationError, HttpClient.HttpClient>;
|
|
133
|
+
/**
|
|
134
|
+
* The descriptor's provider: its verifier, reading the resource, which also publishes the
|
|
135
|
+
* resource's discovery. Provide it to every layer serving the resource's protected actions.
|
|
136
|
+
*/
|
|
137
|
+
readonly provider: Layer.Layer<Authentication.Provider<CurrentPrincipal, Name>, never, Resource<Name> | HttpRouter.HttpRouter>;
|
|
138
|
+
/** Refuses a caller without `scope`, for an authorizer the application writes. */
|
|
139
|
+
readonly requires: (scope: Scope) => Effect.Effect<void, Action.Forbidden, CurrentPrincipal>;
|
|
140
|
+
/** The host's own routes, admitted as the resource's actions are, holding `scope` if given. */
|
|
141
|
+
readonly admitted: (scope?: Scope) => Authentication.Protection<CurrentPrincipal, Name>;
|
|
142
|
+
/** The process itself as a principal, for a trusted local surface. */
|
|
143
|
+
readonly local: (subject: string) => Caller;
|
|
144
|
+
/** `Whoami`, answered from the verified credential. */
|
|
145
|
+
readonly session: Action.Implementation<typeof Whoami, {
|
|
146
|
+
readonly whoami: CurrentPrincipal;
|
|
147
|
+
}, never, Resource<Name>>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Declare an application's resource, at module level, by the descriptor its bindings and
|
|
151
|
+
* MCP endpoints name, its scopes and the one every credential carries:
|
|
152
|
+
* `const Notes = Resource.make(Login, { scopes: ["notes:read", "notes:write"], required: "notes:read" })`.
|
|
153
|
+
* The descriptor's name names its service, so two resources of one process are two services.
|
|
154
|
+
* What varies by deployment comes later, where the application starts, as
|
|
155
|
+
* `Notes.layer(options)`, provided once beside the surfaces. Which scope an action needs
|
|
156
|
+
* beyond `required` is the application's rule, written with `requires`. The descriptor
|
|
157
|
+
* declares `ProviderUnavailable`, which its verifier fails with when the issuer cannot be
|
|
158
|
+
* reached; one that does not is a type error here.
|
|
159
|
+
*/
|
|
160
|
+
export declare const make: <const Name extends string, E extends Action.Errors, const Scope extends string>(authentication: Login<Name, E> & DeclaresUnavailable<E>, declaration: Declaration<Scope>) => Declared<Name, E, Scope>;
|
|
82
161
|
export {};
|
package/dist/resource.js
CHANGED
|
@@ -1,51 +1,66 @@
|
|
|
1
|
-
import { Clock, Context, Duration, Effect,
|
|
1
|
+
import { Clock, Context, Duration, Effect, Layer, Option, Redacted, Result } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
3
|
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
5
|
+
import { InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.js";
|
|
5
6
|
import { keyListRefresh } from "./refresh.js";
|
|
6
|
-
import {
|
|
7
|
+
import { CurrentPrincipal, Whoami } from "./session.js";
|
|
7
8
|
import * as Verifier from "./verify.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
9
|
+
/**
|
|
10
|
+
* What a caller lacking `scope` is refused with, wherever the lack is found. Only an OAuth
|
|
11
|
+
* client can step up, so only its `Forbidden` names the scope: an API key or the process
|
|
12
|
+
* cannot re-authorize.
|
|
13
|
+
*/
|
|
14
|
+
const forbidden = (scope, actor) => {
|
|
15
|
+
const message = `Requires ${scope}.`;
|
|
16
|
+
return actor.kind === "client"
|
|
17
|
+
? new Action.Forbidden({ message, scopes: [scope] })
|
|
18
|
+
: new Action.Forbidden({ message });
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The one scope check, which `requires`, `admitted`, `admit` and `watch` share: what
|
|
22
|
+
* `principal` is refused for lacking `scope`, if one is asked for and it lacks it.
|
|
23
|
+
*/
|
|
24
|
+
const lacking = (principal, scope) => scope === undefined || principal.scopes.includes(scope)
|
|
25
|
+
? undefined
|
|
26
|
+
: forbidden(scope, principal.actor);
|
|
27
|
+
/**
|
|
28
|
+
* A verification failure as every effect-actions surface declares it: the built-in
|
|
29
|
+
* `Unauthenticated` and `Forbidden`, which every client decodes. The issuer being
|
|
30
|
+
* unreachable stays the SDK's own `ProviderUnavailable`, which the descriptor declares.
|
|
31
|
+
*/
|
|
32
|
+
const refusalOf = (error) => {
|
|
33
|
+
if (error instanceof Unauthorized)
|
|
34
|
+
return new Action.Unauthenticated({ message: error.message });
|
|
35
|
+
if (error instanceof InsufficientScope)
|
|
36
|
+
return forbidden(error.scope, error.actor);
|
|
37
|
+
return error;
|
|
25
38
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Verify a request's bearer token with `verifier`, failing as an effect-actions surface
|
|
41
|
+
* refuses: what a verifier of your own, given to `Authentication.layer`, yields before its
|
|
42
|
+
* own checks. A declared resource's `provider` does this; reach for it only with a verifier
|
|
43
|
+
* of your own, whose descriptor declares `ProviderUnavailable` too, or another error the
|
|
44
|
+
* verifier maps it to.
|
|
45
|
+
*/
|
|
46
|
+
export const authenticate = (verifier, token) => Effect.mapError(verifier.verifyToken(Redacted.value(token)), refusalOf);
|
|
47
|
+
const build = Effect.fn("Resource.layer")(function* (authentication, { scopes, required }, options) {
|
|
30
48
|
// One resource per application, at the public origin root, trailing slash and all.
|
|
31
49
|
// An MCP client sends `new URL(metadata.resource).href`, and the token's audience is
|
|
32
50
|
// that string, so deriving it here is what keeps the two in agreement.
|
|
33
51
|
const identifier = new URL("/", options.publicUrl).href;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}),
|
|
43
|
-
catch: () => new ConfigurationError({ message: "Invalid resource metadata configuration" }),
|
|
44
|
-
});
|
|
52
|
+
// effect-actions checks every scope here is an OAuth scope token, when the provider builds.
|
|
53
|
+
const metadata = {
|
|
54
|
+
resource: identifier,
|
|
55
|
+
authorizationServers: [options.issuer],
|
|
56
|
+
scopesSupported: scopes,
|
|
57
|
+
scopesRequired: [required],
|
|
58
|
+
};
|
|
59
|
+
const protectedResource = options.discovery === false ? undefined : metadata;
|
|
45
60
|
const unbounded = yield* Verifier.make({
|
|
46
61
|
issuer: options.issuer,
|
|
47
62
|
resource: identifier,
|
|
48
|
-
requiredScopes: [
|
|
63
|
+
requiredScopes: [required],
|
|
49
64
|
apiKeys: options.apiKeys,
|
|
50
65
|
});
|
|
51
66
|
// A request must not wait on an issuer that does not answer: the transport is
|
|
@@ -55,118 +70,147 @@ export const make = Effect.fn("Resource.make")(function* (options) {
|
|
|
55
70
|
duration: "5 seconds",
|
|
56
71
|
orElse: () => Effect.fail(new ProviderUnavailable({ operation: "verify.timeout" })),
|
|
57
72
|
});
|
|
58
|
-
const verify = (authorization) => deadline(unbounded.verify(authorization));
|
|
59
73
|
const verifier = {
|
|
60
|
-
verify,
|
|
61
74
|
verifyToken: (token) => deadline(unbounded.verifyToken(token)),
|
|
62
75
|
};
|
|
63
|
-
const challengeValue = (error, missing) => discovery.challenge({ error, scope: missing ?? options.scopes.read });
|
|
64
76
|
/**
|
|
65
|
-
* The
|
|
66
|
-
*
|
|
77
|
+
* The principal an Authorization header value proves, holding `scope` if one is asked
|
|
78
|
+
* for, or what a route refuses the same request with: `admit` renders it, `watch` fails
|
|
79
|
+
* with it.
|
|
67
80
|
*/
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const asRecord = (header) => header === undefined ? {} : { [header[0]]: header[1] };
|
|
77
|
-
const refuse = (error, authorization) => Effect.map(describe(error), ({ status, body }) => ({
|
|
78
|
-
ok: false,
|
|
79
|
-
refusal: {
|
|
80
|
-
status,
|
|
81
|
-
headers: {
|
|
82
|
-
...asRecord(refusalHeader(error, authorization !== undefined && authorization !== null)),
|
|
83
|
-
"content-type": "application/json",
|
|
84
|
-
"cache-control": "no-store",
|
|
85
|
-
},
|
|
86
|
-
body,
|
|
87
|
-
},
|
|
88
|
-
}));
|
|
89
|
-
/** The write scope, if one is configured and this principal lacks it. */
|
|
90
|
-
const missingScope = (principal) => options.scopes.write !== undefined && !principal.scopes.includes(options.scopes.write)
|
|
91
|
-
? options.scopes.write
|
|
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;
|
|
81
|
+
const principalOf = Effect.fn("Resource.principal")(function* (authorization, scope) {
|
|
82
|
+
const token = Authentication.bearerTokenOf(authorization);
|
|
83
|
+
// As a protected route refuses a request without one, before its verifier runs.
|
|
84
|
+
if (Option.isNone(token))
|
|
85
|
+
return yield* new Action.Unauthenticated({ message: "A bearer token is required." });
|
|
86
|
+
const principal = yield* authenticate(verifier, token.value);
|
|
87
|
+
const refusal = lacking(principal, scope);
|
|
88
|
+
return refusal === undefined ? principal : yield* refusal;
|
|
100
89
|
});
|
|
101
90
|
/**
|
|
102
91
|
* Verify one Authorization header value outside an Effect router: a Node `upgrade`
|
|
103
|
-
* handler, a socket, a per-request endpoint. `
|
|
104
|
-
*
|
|
92
|
+
* handler, a socket, a per-request endpoint. With `scope`, the credential must also hold
|
|
93
|
+
* it, so a socket that writes demands its scope before it is established. It is
|
|
94
|
+
* `admitted`'s check, given the header rather than the request.
|
|
105
95
|
*/
|
|
106
|
-
const admit = Effect.fn("Resource.admit")(function* (authorization,
|
|
107
|
-
const verified = yield* Effect.result(
|
|
108
|
-
if (Result.
|
|
109
|
-
return
|
|
110
|
-
|
|
96
|
+
const admit = Effect.fn("Resource.admit")(function* (authorization, scope) {
|
|
97
|
+
const verified = yield* Effect.result(principalOf(authorization, scope));
|
|
98
|
+
if (Result.isSuccess(verified))
|
|
99
|
+
return { ok: true, principal: verified.success };
|
|
100
|
+
// A refusal, or the `ProviderUnavailable` the descriptor declares, as its routes send it.
|
|
101
|
+
const web = HttpServerResponse.toWeb(Authentication.refusalResponse(verified.failure, {
|
|
102
|
+
authentication,
|
|
103
|
+
protectedResource,
|
|
104
|
+
authorization,
|
|
105
|
+
}));
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
refusal: {
|
|
109
|
+
status: web.status,
|
|
110
|
+
headers: Object.fromEntries(web.headers),
|
|
111
|
+
body: yield* Effect.promise(() => web.text()),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
111
114
|
});
|
|
112
115
|
/**
|
|
113
|
-
* Hold what `admit` let in, such as a socket, to the same header value and `
|
|
114
|
-
*
|
|
115
|
-
* loses the scope. Never succeeds; race it against the connection.
|
|
116
|
+
* Hold what `admit` let in, such as a socket, to the same header value and `scope`: fails
|
|
117
|
+
* with the refusal `admit` would now answer, once a token expires or a key is revoked,
|
|
118
|
+
* expires or loses the scope. Never succeeds; race it against the connection.
|
|
116
119
|
*/
|
|
117
|
-
const watch = (authorization,
|
|
118
|
-
const principal = yield*
|
|
120
|
+
const watch = (authorization, scope) => Effect.forever(Effect.gen(function* () {
|
|
121
|
+
const principal = yield* principalOf(authorization, scope);
|
|
119
122
|
const now = yield* Clock.currentTimeMillis;
|
|
120
123
|
const remaining = Math.max(0, (principal.expiresAt ?? Infinity) - now);
|
|
121
124
|
// A key is checked again against each refreshed list, until it expires.
|
|
122
125
|
if (principal.actor.kind === "key")
|
|
123
126
|
return yield* Effect.sleep(Math.min(remaining, Duration.toMillis(keyListRefresh)));
|
|
124
127
|
// 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.
|
|
128
|
+
// all that ends it: no second JWKS read that an issuer outage could fail. It ends as
|
|
129
|
+
// the verifier refuses an expired token.
|
|
126
130
|
yield* Effect.sleep(remaining);
|
|
127
|
-
return yield* new Unauthorized(
|
|
131
|
+
return yield* refusalOf(new Unauthorized());
|
|
128
132
|
}));
|
|
129
|
-
/**
|
|
130
|
-
* The effect-actions pre-handler hook, bound once per surface. A read needs nothing
|
|
131
|
-
* beyond what verification already required; a write needs the configured write scope.
|
|
132
|
-
* Without one it passes, so a single-scope resource has no authorization code at all.
|
|
133
|
-
*/
|
|
134
|
-
const authorize = (action) => action.access === "read" || options.scopes.write === undefined
|
|
135
|
-
? Effect.void
|
|
136
|
-
: Effect.flatMap(CurrentPrincipal, (principal) => {
|
|
137
|
-
const missing = missingScope(principal);
|
|
138
|
-
return missing === undefined
|
|
139
|
-
? Effect.void
|
|
140
|
-
: Effect.fail(new InsufficientScope({ scope: missing }));
|
|
141
|
-
});
|
|
142
|
-
/** The `session` group of `@gjermundgaraba/clankerauth-sdk/session`, already answered. */
|
|
143
|
-
const session = Session.implement({
|
|
144
|
-
whoami: () => Effect.map(CurrentPrincipal, ({ subject, scopes }) => ({
|
|
145
|
-
subject,
|
|
146
|
-
issuer: options.issuer,
|
|
147
|
-
scopes,
|
|
148
|
-
})),
|
|
149
|
-
});
|
|
150
133
|
return {
|
|
134
|
+
/** The resource identifier: the public origin root, which a token's audience names. */
|
|
135
|
+
resource: identifier,
|
|
136
|
+
issuer: options.issuer,
|
|
137
|
+
/** What `authentication` publishes and names in every challenge; none with `discovery: false`. */
|
|
138
|
+
protectedResource,
|
|
151
139
|
verifier,
|
|
152
|
-
discovery,
|
|
153
140
|
admit,
|
|
154
141
|
watch,
|
|
155
|
-
authorize,
|
|
156
|
-
session,
|
|
157
|
-
resource: identifier,
|
|
158
|
-
issuer: options.issuer,
|
|
159
142
|
};
|
|
160
143
|
});
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Declare an application's resource, at module level, by the descriptor its bindings and
|
|
146
|
+
* MCP endpoints name, its scopes and the one every credential carries:
|
|
147
|
+
* `const Notes = Resource.make(Login, { scopes: ["notes:read", "notes:write"], required: "notes:read" })`.
|
|
148
|
+
* The descriptor's name names its service, so two resources of one process are two services.
|
|
149
|
+
* What varies by deployment comes later, where the application starts, as
|
|
150
|
+
* `Notes.layer(options)`, provided once beside the surfaces. Which scope an action needs
|
|
151
|
+
* beyond `required` is the application's rule, written with `requires`. The descriptor
|
|
152
|
+
* declares `ProviderUnavailable`, which its verifier fails with when the issuer cannot be
|
|
153
|
+
* reached; one that does not is a type error here.
|
|
154
|
+
*/
|
|
155
|
+
export const make = (authentication, declaration) => {
|
|
156
|
+
const { scopes } = declaration;
|
|
157
|
+
const service = Context.Service(`@clankerauth/Resource/${authentication.name}`);
|
|
158
|
+
const layer = (options) => Layer.effect(service, build(authentication, declaration, options));
|
|
159
|
+
// The descriptor as the verifier fails: `DeclaresUnavailable` holds that every alternative of
|
|
160
|
+
// `E` declares `ProviderUnavailable`, which a generic `E` cannot show the types by itself.
|
|
161
|
+
const declaring = authentication;
|
|
162
|
+
// The descriptor's verifier: a token to its principal, a refusal, or `ProviderUnavailable`.
|
|
163
|
+
const provider = Authentication.layer(declaring, Effect.map(service, ({ verifier }) => (token) => authenticate(verifier, token)), { protectedResource: Effect.map(service, ({ protectedResource }) => protectedResource) });
|
|
164
|
+
/**
|
|
165
|
+
* Refuse a caller that lacks `scope`, as the application's authorizer decides:
|
|
166
|
+
* `Action.implement(actions, handlers, { authorize: (action) => action.readOnly ? Effect.void : Notes.requires("notes:write") })`.
|
|
167
|
+
* The `Forbidden` names the scope for an OAuth client, which steps up to it; an API key or
|
|
168
|
+
* the process cannot, so theirs names none.
|
|
169
|
+
*/
|
|
170
|
+
const requires = (scope) => Effect.flatMap(CurrentPrincipal, (principal) => {
|
|
171
|
+
const refusal = lacking(principal, scope);
|
|
172
|
+
return refusal === undefined ? Effect.void : Effect.fail(refusal);
|
|
173
|
+
});
|
|
174
|
+
/**
|
|
175
|
+
* The host's own routes behind the resource, provided around them:
|
|
176
|
+
* `HttpRouter.add("GET", "/frame", frame).pipe(Layer.provide(Notes.admitted().layer))`.
|
|
177
|
+
* effect-actions' `Authentication.protect` authenticates them with the descriptor's provider,
|
|
178
|
+
* as it does an action's route, and answers them as one; `scope`, if given, is checked
|
|
179
|
+
* inside it with `requires`. An admitted route reads its caller from `CurrentPrincipal`.
|
|
180
|
+
*/
|
|
181
|
+
const admitted = (scope) => scope === undefined
|
|
182
|
+
? Authentication.protect(authentication)
|
|
183
|
+
: HttpRouter.middleware((route) => Effect.andThen(requires(scope), route)).combine(Authentication.protect(authentication));
|
|
184
|
+
/**
|
|
185
|
+
* The process itself as the caller of a trusted local surface, such as stdio MCP or a local
|
|
186
|
+
* command, where no credential exists to verify: every scope the resource declares, no
|
|
187
|
+
* expiry. The application's authorizer runs against it as against a verified caller.
|
|
188
|
+
* `Effect.provideService(CurrentPrincipal, Notes.local("owner"))`.
|
|
189
|
+
*/
|
|
190
|
+
const local = (subject) => ({
|
|
191
|
+
subject,
|
|
192
|
+
scopes: [...scopes],
|
|
193
|
+
actor: { kind: "local" },
|
|
194
|
+
expiresAt: undefined,
|
|
195
|
+
});
|
|
196
|
+
/**
|
|
197
|
+
* `whoami` of `@gjermundgaraba/clankerauth-sdk/session`, already answered. The credential
|
|
198
|
+
* is a transport concern: a process surface has none, so serve it over HTTP alone,
|
|
199
|
+
* `ActionHttp.layer(Http, [app, Notes.session])`, and leave it out of MCP.
|
|
200
|
+
*/
|
|
201
|
+
const session = Action.implement(Whoami, Effect.map(service, ({ issuer }) => () => Effect.map(CurrentPrincipal, (principal) => ({
|
|
202
|
+
subject: principal.subject,
|
|
203
|
+
issuer,
|
|
204
|
+
scopes: principal.scopes,
|
|
205
|
+
}))), { authorize: Action.allowAll });
|
|
206
|
+
return {
|
|
207
|
+
authentication,
|
|
208
|
+
service,
|
|
209
|
+
layer,
|
|
210
|
+
provider,
|
|
211
|
+
requires,
|
|
212
|
+
admitted,
|
|
213
|
+
local,
|
|
214
|
+
session,
|
|
215
|
+
};
|
|
216
|
+
};
|
package/dist/session.d.ts
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
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
|
-
import
|
|
9
|
+
import type { Principal as Verified } from "./verify.ts";
|
|
10
|
+
/**
|
|
11
|
+
* Who calls, as `CurrentPrincipal` carries it: a verified credential's principal, the
|
|
12
|
+
* `Verifier`'s, or on a trusted local surface the process itself, `actor: { kind: "local" }`,
|
|
13
|
+
* which no verifier produces, with no `expiresAt`. Declared here, so a package declaring a
|
|
14
|
+
* descriptor of `CurrentPrincipal` from this entry alone names it in its emitted declarations.
|
|
15
|
+
*/
|
|
16
|
+
export interface Caller extends Omit<Verified, "actor"> {
|
|
17
|
+
readonly actor: Verified["actor"]
|
|
18
|
+
/** The process itself, on a trusted local surface: no credential was verified. */
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: "local";
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Caller>;
|
|
24
|
+
/**
|
|
25
|
+
* The caller: what a protected contract states, `caller: CurrentPrincipal`, and what an
|
|
26
|
+
* authentication descriptor proves,
|
|
27
|
+
* `Authentication.make("notes.Login", CurrentPrincipal, { error: ProviderUnavailable })`.
|
|
28
|
+
* Remotely, the resource's provider gives it per request, from the verified credential; on
|
|
29
|
+
* a trusted local surface, the host gives it the process, `Notes.local(subject)`.
|
|
30
|
+
*/
|
|
31
|
+
export declare class CurrentPrincipal extends CurrentPrincipal_base {
|
|
32
|
+
}
|
|
9
33
|
/** The verified credential behind a request, as the resource server read it. */
|
|
10
34
|
export declare const Principal: Schema.Struct<{
|
|
11
35
|
readonly subject: Schema.NonEmptyString;
|
|
@@ -14,16 +38,18 @@ export declare const Principal: Schema.Struct<{
|
|
|
14
38
|
}>;
|
|
15
39
|
export type Principal = typeof Principal.Type;
|
|
16
40
|
/**
|
|
17
|
-
* The credential is a transport concern: a process surface has none, so `
|
|
18
|
-
*
|
|
41
|
+
* The credential is a transport concern: a process surface has none, so bind `Whoami` to
|
|
42
|
+
* HTTP alone, `ActionHttp.layer(Http, [app, Notes.session])`, and leave `Notes.session` out
|
|
43
|
+
* of MCP.
|
|
19
44
|
*/
|
|
20
|
-
export declare const
|
|
45
|
+
export declare const Whoami: Action.Action<"whoami", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{
|
|
21
46
|
readonly subject: Schema.NonEmptyString;
|
|
22
47
|
readonly issuer: Schema.NonEmptyString;
|
|
23
48
|
readonly scopes: Schema.$Array<Schema.String>;
|
|
24
|
-
}>, readonly [],
|
|
49
|
+
}>, readonly never[], true, typeof CurrentPrincipal>;
|
|
25
50
|
/**
|
|
26
51
|
* Sign out at the issuer, which ends its session and every app's forward cookie, then
|
|
27
52
|
* return to `returnTo`. It is a plain link: any page under the cookie domain may use it.
|
|
28
53
|
*/
|
|
29
54
|
export declare const signOutUrl: (issuer: string, returnTo: string) => string;
|
|
55
|
+
export {};
|