@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.
@@ -1,21 +1,22 @@
1
- import { Context, Effect, Schema } from "effect";
2
- import type * as Action from "@gjermundgaraba/effect-actions/Action";
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 { ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.ts";
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
- declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Verifier.Principal>;
7
- export declare class CurrentPrincipal extends CurrentPrincipal_base {
8
- }
9
- /** What this resource's credentials must carry. */
10
- export interface Scopes {
11
- /** Required of every credential, so verification alone admits a read. */
12
- readonly read: string;
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: an RFC 6750 challenge or a
32
- * `Retry-After`, `Cache-Control: no-store`, and the error's own JSON encoding.
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
- /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per application. */
48
- export declare const make: (options: Options) => Effect.Effect<{
49
- verifier: Verifier.Verifier;
50
- discovery: {
51
- layer: import("effect/Layer").Layer<never, never, import("effect/http/HttpRouter").HttpRouter>;
52
- metadataUrl: string;
53
- challenge: (challenge?: Authentication.BearerChallengeOptions) => string;
54
- };
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>;
57
- authorize: (action: Action.Any) => Effect.Effect<void, InsufficientScope, CurrentPrincipal>;
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<{
59
- readonly subject: Schema.NonEmptyString;
60
- readonly issuer: Schema.NonEmptyString;
61
- readonly scopes: Schema.$Array<Schema.String>;
62
- }>, readonly [], "read", false>], never>, {
63
- whoami: () => Effect.Effect<{
64
- subject: string;
65
- issuer: string;
66
- scopes: readonly string[];
67
- }, never, CurrentPrincipal>;
68
- }, never, never>;
69
- resource: string;
70
- issuer: string;
71
- }, ConfigurationError, import("effect/http/HttpClient").HttpClient>;
72
- export type Resource = Effect.Success<ReturnType<typeof make>>;
73
- /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
74
- export declare const middleware: (resource: Resource) => import("effect/http/HttpRouter").Middleware<{
75
- provides: CurrentPrincipal;
76
- handles: never;
77
- error: never;
78
- requires: never;
79
- layerError: never;
80
- layerRequires: never;
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, Result, Schema, SchemaAST } from "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 { HttpServerRequest, HttpServerResponse } from "effect/http";
4
- import { authenticationErrors, ConfigurationError, InsufficientScope, ProviderUnavailable, Unauthorized, } from "./errors.js";
4
+ import { HttpRouter, HttpServerResponse } from "effect/http";
5
+ import { InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.js";
5
6
  import { keyListRefresh } from "./refresh.js";
6
- import { Session } from "./session.js";
7
+ import { CurrentPrincipal, Whoami } from "./session.js";
7
8
  import * as Verifier from "./verify.js";
8
- const encoders = new Map(authenticationErrors.map((schema) => [
9
- schema,
10
- {
11
- status: SchemaAST.resolveAt("httpApiStatus")(schema.ast) ?? 500,
12
- encode: Schema.encodeUnknownEffect(Schema.toCodecJson(schema)),
13
- },
14
- ]));
15
- /** One refusal as its own status and JSON body. An undeclared error is a defect. */
16
- const describe = (error) => {
17
- const schema = authenticationErrors.find((candidate) => Schema.is(candidate)(error));
18
- const encoder = schema === undefined ? undefined : encoders.get(schema);
19
- if (encoder === undefined)
20
- return Effect.die(new Error("Undeclared error"));
21
- return Effect.map(Effect.orDie(encoder.encode(error)), (body) => ({
22
- status: encoder.status,
23
- body: JSON.stringify(body),
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
- export class CurrentPrincipal extends Context.Service()("@clankerauth/CurrentPrincipal") {
27
- }
28
- /** One resource: its verifier, discovery, scope policy and refusals. Acquire once per application. */
29
- export const make = Effect.fn("Resource.make")(function* (options) {
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
- const scopesSupported = options.scopes.write === undefined
35
- ? [options.scopes.read]
36
- : [options.scopes.read, options.scopes.write];
37
- const discovery = yield* Effect.try({
38
- try: () => Authentication.protectedResource({
39
- resource: identifier,
40
- authorizationServers: [options.issuer],
41
- scopesSupported,
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: [options.scopes.read],
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 one header besides the content type that a refusal carries: an RFC 6750
66
- * challenge naming only the missing scope.
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 refusalHeader = (error, credential) => {
69
- if (error instanceof InsufficientScope)
70
- return ["www-authenticate", challengeValue("insufficient_scope", error.scope)];
71
- // RFC 6750 §3.1: a request that carried no credentials gets no error code.
72
- if (error instanceof Unauthorized)
73
- return ["www-authenticate", credential ? challengeValue("invalid_token") : challengeValue()];
74
- return undefined;
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. `access` is the same declaration an
104
- * action carries, so `"write"` demands the write scope before a socket is established.
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, access) {
107
- const verified = yield* Effect.result(principalFor(authorization, access));
108
- if (Result.isFailure(verified))
109
- return yield* refuse(verified.failure, authorization);
110
- return { ok: true, principal: verified.success };
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 `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
+ * 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, access) => Effect.forever(Effect.gen(function* () {
118
- const principal = yield* principalFor(authorization, access);
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({ message: "Authentication required" });
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
- /** Bearer authentication providing `CurrentPrincipal`, on the same admission `admit` renders. */
162
- export const middleware = (resource) => Authentication.middleware(CurrentPrincipal, Effect.gen(function* () {
163
- const request = yield* HttpServerRequest.HttpServerRequest;
164
- // Read level: an action's own `access` is what the authorization hook then enforces.
165
- const admission = yield* resource.admit(request.headers.authorization, "read");
166
- if (admission.ok)
167
- return admission.principal;
168
- return yield* Effect.fail(HttpServerResponse.text(admission.refusal.body, {
169
- status: admission.refusal.status,
170
- headers: admission.refusal.headers,
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 `session` contract and builds the issuer's sign-out URL,
4
- * so a page imports it directly and a server answers it with `resource.session`.
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 * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
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 `whoami` is its
18
- * own group and never an MCP tool. Bind it with `Http.layer([app, resource.session])`.
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 Session: ActionGroup.Group<"session", readonly [Action.Action<"whoami", Schema.$Record<Schema.String, Schema.Never>, Schema.Struct<{
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 [], "read", false>], never>;
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 {};