@gjermundgaraba/clankerauth-sdk 0.23.0 → 0.24.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 CHANGED
@@ -17,7 +17,7 @@ vp add effect
17
17
  The `/session` and `/effect-actions` entry points also need effect-actions, an optional peer. Install this range: the SDK's declarations are built against it, and under `skipLibCheck` they silently degrade to `any` with another.
18
18
 
19
19
  ```sh
20
- vp add @gjermundgaraba/effect-actions@^0.17.0
20
+ vp add @gjermundgaraba/effect-actions@^0.18.0
21
21
  ```
22
22
 
23
23
  ## Entry points
@@ -29,10 +29,38 @@ vp add @gjermundgaraba/effect-actions@^0.17.0
29
29
  | `…/clankerauth-sdk/session` | yes | **yes** | `CurrentPrincipal` and its `Caller`, the `Whoami` contract, `Principal`, `signOutUrl`, `signOutPath`, `returnToParameter` |
30
30
  | `…/clankerauth-sdk/effect-actions` | yes | no | `Resource` |
31
31
  | `…/clankerauth-sdk/key-list` | no | no | the key list format and `keyPrefix`, for issuers and fakes |
32
+ | `…/clankerauth-sdk/credentials` | no | no | `token` and `authorizing`, a program's bearer token for an application; the owner's sign-ins the CLI keeps (Node only) |
32
33
  | `…/clankerauth-sdk/testing` | no | no | `startFakeIssuer`, for tests (Node only) |
33
34
 
34
35
  The package is free of side effects, so a bundler drops what a page does not use. Errors are **not** re-exported from the root: a shared contract imports them from `/errors`, and nothing about that import pulls token verification into a browser bundle.
35
36
 
37
+ ## Call an application from a program
38
+
39
+ A program calling an application behind clankerauth, such as the application's own CLI, takes its bearer token from `token` of `/credentials`, or has `authorizing` send it with each request. The owner signs in once with `clankerauth login https://notes.internal`, which reads the resource, its issuer and its scopes from the application's `/.well-known/oauth-protected-resource`; `token` then refreshes that sign-in as it expires. A token the environment gives, an access token or an API key, is returned instead when it is set, so the same program runs unattended.
40
+
41
+ ```ts
42
+ import { Config, Effect } from "effect";
43
+ import { HttpClient } from "effect/http";
44
+ import * as Credentials from "@gjermundgaraba/clankerauth-sdk/credentials";
45
+
46
+ const notes = Credentials.authorizing({
47
+ url: "https://notes.internal",
48
+ token: Config.Redacted("NOTES_TOKEN"),
49
+ });
50
+
51
+ const program = Effect.gen(function* () {
52
+ const client = notes(yield* HttpClient.HttpClient);
53
+
54
+ return yield* client.get("https://notes.internal/api/notes");
55
+ });
56
+ ```
57
+
58
+ `authorizing` takes the options `token` does and is an effect-actions client's `transformClient`: `ActionHttp.client(NotesHttp, { baseUrl, transformClient: notes })` gives methods that fail with `SignInRequired` and `TokenUnavailable` too, and a long run is refreshed as it goes.
59
+
60
+ `url` is any address under the resource, such as the one the program calls: the sign-in used is the one whose resource holds it, the most specific when several do. `token` fails with `SignInRequired` when there is no sign-in to use, its `command` the `clankerauth login` to run, and with `TokenUnavailable` when the file or the issuer fails. A token is never within a minute of expiring, so ask again for each call or each run. Programs and the CLI share the sign-in file, `~/.config/clankerauth/sign-ins.json`, one sign-in per resource whatever its issuer: every change to it holds its lock, so an expired sign-in is refreshed once, whoever asks, and a refresh that is interrupted still keeps the token the issuer rotated.
61
+
62
+ A program that signs its user in itself, as the CLI's `login` does, calls `signIn(url, { scope, approve })`: `approve` shows the owner the issuer's approval page, and the sign-in is kept for `token`, revoking any it replaces. `list` gives the sign-ins kept, and `signOut(url?)` forgets and revokes the one whose resource holds `url`, or every one.
63
+
36
64
  ## Verify tokens directly
37
65
 
38
66
  Core consumers do not need effect-actions or `skipLibCheck`.
@@ -0,0 +1,150 @@
1
+ import { Config, Effect, Redacted, Schema } from "effect";
2
+ import { HttpClient } from "effect/http";
3
+ declare const SignInRequired_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
4
+ readonly _tag: "SignInRequired";
5
+ } & Readonly<A>;
6
+ /**
7
+ * No sign-in to `resource` can give a token: none is kept, or the issuer refused to refresh it.
8
+ * Only the owner can mend it, by running `command`, which `message` names.
9
+ */
10
+ export declare class SignInRequired extends SignInRequired_base<{
11
+ readonly resource: string;
12
+ /** The command that signs in again, such as `clankerauth login https://notes.example/`. */
13
+ readonly command: string;
14
+ readonly message: string;
15
+ }> {
16
+ }
17
+ declare const TokenUnavailable_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
18
+ readonly _tag: "TokenUnavailable";
19
+ } & Readonly<A>;
20
+ /**
21
+ * The sign-in file could not be read, written or locked, or the issuer could not be reached or
22
+ * answered something unusable: `message` says which, for a person.
23
+ */
24
+ export declare class TokenUnavailable extends TokenUnavailable_base<{
25
+ readonly message: string;
26
+ }> {
27
+ readonly cause?: unknown;
28
+ constructor(options: {
29
+ readonly message: string;
30
+ readonly cause?: unknown;
31
+ });
32
+ }
33
+ declare const SignInFailed_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
34
+ readonly _tag: "SignInFailed";
35
+ } & Readonly<A>;
36
+ /**
37
+ * A sign-in did not finish: the application, its issuer or the owner's approval failed, as
38
+ * `message` says, for a person.
39
+ */
40
+ export declare class SignInFailed extends SignInFailed_base<{
41
+ readonly message: string;
42
+ }> {
43
+ }
44
+ /**
45
+ * The owner's authorization for one resource, with all a refresh needs: the issuer, and the
46
+ * client the sign-in registered there. The access token lasts fifteen minutes and the refresh
47
+ * token rotates on every use, so the file is rewritten whenever one is refreshed.
48
+ */
49
+ export declare const SignIn: Schema.Struct<{
50
+ /** The token audience: a resource identifier, such as `https://notes.home.example/`. */
51
+ readonly resource: Schema.String;
52
+ /** The issuer's origin, `CLANKERAUTH_BASE_URL`: where `/api/owner/<action>` is served. */
53
+ readonly url: Schema.String;
54
+ readonly server: Schema.Struct<{
55
+ readonly issuer: Schema.String;
56
+ readonly authorization_endpoint: Schema.String;
57
+ readonly token_endpoint: Schema.String;
58
+ readonly registration_endpoint: Schema.String;
59
+ readonly revocation_endpoint: Schema.String;
60
+ }>;
61
+ readonly clientId: Schema.String;
62
+ /** The scopes granted, `offline_access` among them. */
63
+ readonly scope: Schema.String;
64
+ readonly accessToken: Schema.String;
65
+ readonly refreshToken: Schema.String;
66
+ /** When the access token expires, as an ISO instant. */
67
+ readonly expiresAt: Schema.String;
68
+ }>;
69
+ export type SignIn = typeof SignIn.Type;
70
+ /** The file's path, read from the environment on every call. */
71
+ export declare const path: () => string;
72
+ /** Every sign-in kept; none when there is no file, or one no version of it can be read from. */
73
+ export declare const list: () => Effect.Effect<{
74
+ readonly resource: string;
75
+ readonly url: string;
76
+ readonly server: {
77
+ readonly issuer: string;
78
+ readonly authorization_endpoint: string;
79
+ readonly token_endpoint: string;
80
+ readonly registration_endpoint: string;
81
+ readonly revocation_endpoint: string;
82
+ };
83
+ readonly clientId: string;
84
+ readonly scope: string;
85
+ readonly accessToken: string;
86
+ readonly refreshToken: string;
87
+ readonly expiresAt: string;
88
+ }[], TokenUnavailable, never>;
89
+ export interface TokenOptions {
90
+ /**
91
+ * Where the program calls the application, or any address under its resource: the sign-in
92
+ * used is the one whose resource holds this URL, the most specific when several do.
93
+ */
94
+ readonly url: string | URL;
95
+ /**
96
+ * A bearer token the environment may give, an API key or an access token, returned instead
97
+ * of any sign-in when it is set: `Config.Redacted("NOTES_TOKEN")`.
98
+ */
99
+ readonly token?: Config.Config<Redacted.Redacted<string>>;
100
+ }
101
+ /**
102
+ * A program's bearer token for the application at `options.url`: the environment's when it
103
+ * gives one, so the program runs unattended, and otherwise the owner's sign-in whose resource
104
+ * holds the URL, which `clankerauth login <url>` keeps and this refreshes. Ask again for each
105
+ * call, or each run: a token is never within a minute of expiring.
106
+ */
107
+ export declare const token: (options: TokenOptions) => Effect.Effect<Redacted.Redacted<string>, SignInRequired | TokenUnavailable, never>;
108
+ /**
109
+ * A client sending the bearer token `token` gives for `options` with each request, so a long run
110
+ * is refreshed as it goes: an effect-actions client's `transformClient`, whose methods then fail
111
+ * with `SignInRequired` and `TokenUnavailable` too.
112
+ *
113
+ * ```ts
114
+ * const notes = yield* ActionHttp.client(NotesHttp, {
115
+ * baseUrl: "https://notes.home.example",
116
+ * transformClient: Credentials.authorizing({ url: "https://notes.home.example/" }),
117
+ * });
118
+ * ```
119
+ */
120
+ export declare const authorizing: (options: TokenOptions) => (client: HttpClient.HttpClient) => HttpClient.HttpClient.With<import("effect/http/HttpClientError").HttpClientError | SignInRequired | TokenUnavailable, never>;
121
+ export interface SignInOptions {
122
+ /** The scopes to ask for, space-separated; every scope the resource publishes when left out. */
123
+ readonly scope?: string | undefined;
124
+ /**
125
+ * Show the owner the issuer's approval page, such as by printing its URL. The sign-in then
126
+ * waits up to five minutes for the approval.
127
+ */
128
+ readonly approve: (page: URL) => Effect.Effect<void>;
129
+ }
130
+ /**
131
+ * Sign in as the issuer's owner to the resource `url` identifies, read from its
132
+ * `/.well-known/oauth-protected-resource`, and keep the sign-in, replacing and revoking any to
133
+ * that resource. The issuer is the one the resource names; the sign-in registers a native OAuth
134
+ * client there, asks for `offline_access` beside the scopes, and listens for the redirect on a
135
+ * loopback port.
136
+ */
137
+ export declare const signIn: (url: string, options: SignInOptions) => Effect.Effect<{
138
+ url: string;
139
+ resource: string;
140
+ scope: string;
141
+ }, SignInFailed | TokenUnavailable, never>;
142
+ /**
143
+ * Forget the sign-in whose resource holds `url`, or every one, holding the file's lock so a
144
+ * refresh never slips between, then revoke it. A sign-in is forgotten even when the issuer does
145
+ * not answer its revocation.
146
+ */
147
+ export declare const signOut: (url?: string | URL | undefined) => Effect.Effect<{
148
+ signedOut: boolean;
149
+ }, TokenUnavailable, never>;
150
+ export {};
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The owner's sign-ins: `signIn` keeps one in `~/.config/clankerauth/sign-ins.json` (under
3
+ * `XDG_CONFIG_HOME` when set), and `token` gives a current access token from it, refreshed as it
4
+ * is about to expire. The CLI and every program reading the file share this module, so a refresh
5
+ * by one is the token of all: every change to the file holds its lock, and a process waiting on
6
+ * it reads what the other saved. Node only.
7
+ *
8
+ * A program calling an application takes its token with `token`, then sends it, or has
9
+ * `authorizing` send a current one with each request:
10
+ *
11
+ * ```ts
12
+ * const bearer = yield* Credentials.token({
13
+ * url: "https://notes.home.example/api/notes",
14
+ * token: Config.Redacted("NOTES_TOKEN"),
15
+ * });
16
+ * ```
17
+ */
18
+ import { randomBytes } from "node:crypto";
19
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
20
+ import { createServer } from "node:http";
21
+ import { homedir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import * as oauth from "oauth4webapi";
24
+ import lockfile from "proper-lockfile";
25
+ import { Clock, Config, Data, Deferred, Effect, Option, Redacted, Schema } from "effect";
26
+ import { HttpClient, HttpClientRequest } from "effect/http";
27
+ /**
28
+ * No sign-in to `resource` can give a token: none is kept, or the issuer refused to refresh it.
29
+ * Only the owner can mend it, by running `command`, which `message` names.
30
+ */
31
+ export class SignInRequired extends Data.TaggedError("SignInRequired") {
32
+ }
33
+ /**
34
+ * The sign-in file could not be read, written or locked, or the issuer could not be reached or
35
+ * answered something unusable: `message` says which, for a person.
36
+ */
37
+ export class TokenUnavailable extends Data.TaggedError("TokenUnavailable") {
38
+ constructor(options) {
39
+ super({ message: options.message });
40
+ Object.defineProperty(this, "cause", { value: options.cause });
41
+ }
42
+ }
43
+ /**
44
+ * A sign-in did not finish: the application, its issuer or the owner's approval failed, as
45
+ * `message` says, for a person.
46
+ */
47
+ export class SignInFailed extends Data.TaggedError("SignInFailed") {
48
+ }
49
+ /** The issuer's RFC 8414 metadata a sign-in, a refresh and a revocation use, under its RFC names. */
50
+ const Server = Schema.Struct({
51
+ issuer: Schema.String,
52
+ authorization_endpoint: Schema.String,
53
+ token_endpoint: Schema.String,
54
+ registration_endpoint: Schema.String,
55
+ revocation_endpoint: Schema.String,
56
+ });
57
+ /**
58
+ * The owner's authorization for one resource, with all a refresh needs: the issuer, and the
59
+ * client the sign-in registered there. The access token lasts fifteen minutes and the refresh
60
+ * token rotates on every use, so the file is rewritten whenever one is refreshed.
61
+ */
62
+ export const SignIn = Schema.Struct({
63
+ /** The token audience: a resource identifier, such as `https://notes.home.example/`. */
64
+ resource: Schema.String,
65
+ /** The issuer's origin, `CLANKERAUTH_BASE_URL`: where `/api/owner/<action>` is served. */
66
+ url: Schema.String,
67
+ server: Server,
68
+ clientId: Schema.String,
69
+ /** The scopes granted, `offline_access` among them. */
70
+ scope: Schema.String,
71
+ accessToken: Schema.String,
72
+ refreshToken: Schema.String,
73
+ /** When the access token expires, as an ISO instant. */
74
+ expiresAt: Schema.String,
75
+ });
76
+ /** The file: each sign-in, by its resource. */
77
+ const SignIns = Schema.Struct({ signIns: Schema.Record(Schema.String, SignIn) });
78
+ const SignInsJson = Schema.fromJsonString(SignIns);
79
+ const none = { signIns: {} };
80
+ /** The file's path, read from the environment on every call. */
81
+ export const path = () => join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "clankerauth", "sign-ins.json");
82
+ const causeOf = (error) => error.cause instanceof Error ? error.cause.message : error.message;
83
+ const failed = (what) => (error) => new TokenUnavailable({ message: `${what} ${path()}: ${causeOf(error)}`, cause: error });
84
+ const hasCode = (code) => (error) => error.cause instanceof Error && "code" in error.cause && error.cause.code === code;
85
+ const read = () => Effect.tryPromise(() => readFile(path(), "utf8")).pipe(Effect.map((json) => Option.getOrElse(Schema.decodeOption(SignInsJson)(json), () => none)), Effect.catchIf(hasCode("ENOENT"), () => Effect.succeed(none)), Effect.mapError(failed("Cannot read")));
86
+ /** Every sign-in kept; none when there is no file, or one no version of it can be read from. */
87
+ export const list = () => Effect.map(read(), ({ signIns }) => Object.values(signIns));
88
+ const persist = Effect.fn("Credentials.persist")(function* (signIns) {
89
+ const file = path();
90
+ if (Object.keys(signIns.signIns).length === 0)
91
+ return yield* Effect.tryPromise(() => rm(file, { force: true }));
92
+ const json = yield* Schema.encodeEffect(SignInsJson)(signIns);
93
+ // why: staged and renamed, so an interrupted write never leaves a refresh token half-saved.
94
+ const staging = `${file}.${randomBytes(6).toString("hex")}`;
95
+ yield* Effect.tryPromise(async () => {
96
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
97
+ await writeFile(staging, `${json}\n`, { mode: 0o600 });
98
+ await rename(staging, file);
99
+ }).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
100
+ }, Effect.mapError(failed("Cannot write")));
101
+ /** Wait this long for another process to let the file go: well past a refresh's deadline. */
102
+ const lockRetries = { retries: 150, factor: 1, minTimeout: 100, maxTimeout: 100 };
103
+ const save = (change) => Effect.flatMap(read(), (before) => Effect.as(persist(change(before)), before));
104
+ /**
105
+ * Run `use` holding the file's lock, `sign-ins.json.lock` beside it, given the one way to write
106
+ * the file. Each write reads the file again and changes only its own sign-in, so even one whose
107
+ * lock another process took over, as one stalled past the lock's ten seconds loses it, keeps
108
+ * what that process saved.
109
+ */
110
+ const locked = (use) => Effect.acquireUseRelease(Effect.tryPromise(async () => {
111
+ await mkdir(dirname(path()), { recursive: true, mode: 0o700 });
112
+ return lockfile.lock(path(), {
113
+ realpath: false,
114
+ retries: lockRetries,
115
+ update: 1000,
116
+ // why: throwing here would crash the program, and the writes need the lock no longer.
117
+ onCompromised: () => undefined,
118
+ });
119
+ }).pipe(Effect.mapError(failed("Cannot lock"))), () => use(save), (release) => Effect.promise(() => release().catch(() => undefined)));
120
+ /** How long one request to an application or an issuer may take: well under a lock's wait. */
121
+ const requestDeadline = 10_000;
122
+ const requestOptions = (url) => ({
123
+ // why: plain HTTP only for an address named with `http:`, such as one on loopback.
124
+ [oauth.allowInsecureRequests]: new URL(url).protocol === "http:",
125
+ signal: AbortSignal.timeout(requestDeadline),
126
+ });
127
+ /** The RFC 9728 check's failure: the metadata names another resource than the one asked for. */
128
+ const NamedElsewhere = Schema.Struct({
129
+ attribute: Schema.Literal("resource"),
130
+ body: Schema.Struct({ resource: Schema.String }),
131
+ });
132
+ /** Why `oauth4webapi` failed, for a person. */
133
+ const reason = (error) => {
134
+ if (error instanceof oauth.ResponseBodyError || error instanceof oauth.AuthorizationResponseError)
135
+ return [error.error, error.error_description].filter(Boolean).join(": ");
136
+ if (error instanceof oauth.OperationProcessingError) {
137
+ const named = Schema.decodeUnknownOption(NamedElsewhere)(error.cause);
138
+ if (Option.isSome(named))
139
+ return `it publishes the resource ${named.value.body.resource}: sign in to that address`;
140
+ if (error.cause instanceof Response)
141
+ return `${error.message} (HTTP ${error.cause.status})`;
142
+ }
143
+ return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
144
+ };
145
+ const refreshScope = "offline_access";
146
+ /** The scopes of `scope` a sign-in asks for, beside `offline_access`, which every one asks for. */
147
+ const asked = (scope) => scope.split(" ").filter((named) => named !== "" && named !== refreshScope);
148
+ /** `url` as a resource is matched against: without its query or fragment. */
149
+ const located = (url) => Effect.try({
150
+ try: () => {
151
+ const parsed = new URL(url);
152
+ parsed.search = "";
153
+ parsed.hash = "";
154
+ return parsed;
155
+ },
156
+ catch: (cause) => new TokenUnavailable({ message: `${String(url)} is no URL`, cause }),
157
+ });
158
+ const holds = (resource, url) => url.href === resource || url.href.startsWith(resource.endsWith("/") ? resource : `${resource}/`);
159
+ /** The sign-in whose resource holds `url`, the most specific when several do. */
160
+ const signInFor = (signIns, url) => Object.values(signIns.signIns)
161
+ .filter((signIn) => holds(signIn.resource, url))
162
+ .toSorted((first, second) => second.resource.length - first.resource.length)
163
+ .at(0);
164
+ const held = Effect.fn("Credentials.held")(function* (url) {
165
+ const signIn = signInFor(yield* read(), url);
166
+ if (signIn !== undefined)
167
+ return signIn;
168
+ const resource = new URL("/", url).href;
169
+ const command = `clankerauth login ${resource}`;
170
+ return yield* new SignInRequired({
171
+ resource,
172
+ command,
173
+ message: `Not signed in to ${resource}: run \`${command}\` first.`,
174
+ });
175
+ });
176
+ const signInOf = (grant, tokens, refreshToken, now) => ({
177
+ resource: grant.resource,
178
+ url: grant.url,
179
+ server: grant.server,
180
+ clientId: grant.clientId,
181
+ scope: tokens.scope ?? grant.scope,
182
+ accessToken: tokens.access_token,
183
+ refreshToken,
184
+ expiresAt: new Date(now + (tokens.expires_in ?? 0) * 1000).toISOString(),
185
+ });
186
+ const refresh = (signIn) => Effect.tryPromise({
187
+ try: async () => oauth.processRefreshTokenResponse(signIn.server, { client_id: signIn.clientId }, await oauth.refreshTokenGrantRequest(signIn.server, { client_id: signIn.clientId }, oauth.None(), signIn.refreshToken, { additionalParameters: { resource: signIn.resource }, ...requestOptions(signIn.url) })),
188
+ catch: (error) => {
189
+ if (!(error instanceof oauth.ResponseBodyError) ||
190
+ (error.status !== 400 && error.status !== 401))
191
+ return new TokenUnavailable({
192
+ message: `Refresh: ${error instanceof Error ? reason(error) : String(error)}`,
193
+ cause: error,
194
+ });
195
+ const scopes = asked(signIn.scope);
196
+ const command = [
197
+ `clankerauth login ${signIn.resource}`,
198
+ ...(scopes.length === 0 ? [] : [`--scope "${scopes.join(" ")}"`]),
199
+ ].join(" ");
200
+ return new SignInRequired({
201
+ resource: signIn.resource,
202
+ command,
203
+ message: `Refresh: ${reason(error)}. Run \`${command}\` again.`,
204
+ });
205
+ },
206
+ });
207
+ /** A token is refreshed this long before it expires, so none expires on its way. */
208
+ const refreshMargin = 60_000;
209
+ const current = (signIn) => Effect.map(Clock.currentTimeMillis, (now) => Date.parse(signIn.expiresAt) - now > refreshMargin
210
+ ? Option.some(Redacted.make(signIn.accessToken))
211
+ : Option.none());
212
+ const accessToken = Effect.fn("Credentials.accessToken")(function* (url) {
213
+ const unlocked = yield* current(yield* held(url));
214
+ if (Option.isSome(unlocked))
215
+ return unlocked.value;
216
+ return yield* locked((save) => Effect.gen(function* () {
217
+ const signIn = yield* held(url);
218
+ const refreshedMeanwhile = yield* current(signIn);
219
+ if (Option.isSome(refreshedMeanwhile))
220
+ return refreshedMeanwhile.value;
221
+ const tokens = yield* refresh(signIn);
222
+ const now = yield* Clock.currentTimeMillis;
223
+ const refreshed = signInOf(signIn, tokens, tokens.refresh_token ?? signIn.refreshToken, now);
224
+ yield* save(({ signIns }) =>
225
+ // why: a sign-in another process changed meanwhile, having taken the lock over, is newer.
226
+ signIns[signIn.resource]?.refreshToken === signIn.refreshToken
227
+ ? { signIns: { ...signIns, [signIn.resource]: refreshed } }
228
+ : { signIns });
229
+ return Redacted.make(refreshed.accessToken);
230
+ }).pipe(
231
+ // why: the issuer rotates the refresh token as it answers, so an interrupted refresh would lose the only one left.
232
+ Effect.uninterruptible));
233
+ });
234
+ /**
235
+ * A program's bearer token for the application at `options.url`: the environment's when it
236
+ * gives one, so the program runs unattended, and otherwise the owner's sign-in whose resource
237
+ * holds the URL, which `clankerauth login <url>` keeps and this refreshes. Ask again for each
238
+ * call, or each run: a token is never within a minute of expiring.
239
+ */
240
+ export const token = Effect.fn("Credentials.token")(function* (options) {
241
+ if (options.token !== undefined) {
242
+ const given = yield* Config.option(options.token).pipe(Effect.mapError((error) => new TokenUnavailable({ message: `The token given is unreadable: ${error.message}` })));
243
+ if (Option.isSome(given))
244
+ return given.value;
245
+ }
246
+ return yield* accessToken(yield* located(options.url));
247
+ });
248
+ /**
249
+ * A client sending the bearer token `token` gives for `options` with each request, so a long run
250
+ * is refreshed as it goes: an effect-actions client's `transformClient`, whose methods then fail
251
+ * with `SignInRequired` and `TokenUnavailable` too.
252
+ *
253
+ * ```ts
254
+ * const notes = yield* ActionHttp.client(NotesHttp, {
255
+ * baseUrl: "https://notes.home.example",
256
+ * transformClient: Credentials.authorizing({ url: "https://notes.home.example/" }),
257
+ * });
258
+ * ```
259
+ */
260
+ export const authorizing = (options) => (client) => HttpClient.mapRequestEffect(client, (request) => Effect.map(token(options), (bearer) => HttpClientRequest.bearerToken(request, bearer)));
261
+ const step = (what, run) => Effect.tryPromise({
262
+ try: run,
263
+ catch: (error) => new SignInFailed({
264
+ message: `${what}: ${error instanceof Error ? reason(error) : String(error)}`,
265
+ }),
266
+ });
267
+ const discover = Effect.fn("Credentials.discover")(function* (url) {
268
+ const resource = yield* Effect.try({
269
+ try: () => new URL(url),
270
+ catch: () => new SignInFailed({ message: `${url} is no URL, such as https://notes.example/` }),
271
+ });
272
+ const what = `Discovery at ${resource.origin}`;
273
+ const metadata = yield* step(what, async () => oauth.processResourceDiscoveryResponse(resource, await oauth.resourceDiscoveryRequest(resource, requestOptions(resource))));
274
+ const [named] = metadata.authorization_servers ?? [];
275
+ if (named === undefined)
276
+ return yield* new SignInFailed({ message: `${what}: it names no authorization server` });
277
+ const issuer = yield* Effect.try({
278
+ try: () => new URL(named),
279
+ catch: () => new SignInFailed({ message: `${what}: its authorization server ${named} is no URL` }),
280
+ });
281
+ const server = yield* step(what, async () => oauth.processDiscoveryResponse(issuer, await oauth.discoveryRequest(issuer, { algorithm: "oauth2", ...requestOptions(issuer) })));
282
+ return {
283
+ resource: metadata.resource,
284
+ scopes: metadata.scopes_supported ?? [],
285
+ server: yield* Schema.decodeUnknownEffect(Server)(server).pipe(Effect.mapError((error) => new SignInFailed({ message: `${what}: the issuer lacks an endpoint (${error.message})` }))),
286
+ };
287
+ });
288
+ // why: loopback redirects match without their port (RFC 8252), so one registration serves all.
289
+ const registeredRedirect = "http://127.0.0.1/callback";
290
+ const register = (server) => step("Registration", async () => oauth.processDynamicClientRegistrationResponse(await oauth.dynamicClientRegistrationRequest(server, {
291
+ client_name: "clankerauth CLI",
292
+ redirect_uris: [registeredRedirect],
293
+ application_type: "native",
294
+ token_endpoint_auth_method: "none",
295
+ grant_types: ["authorization_code", "refresh_token"],
296
+ response_types: ["code"],
297
+ }, requestOptions(server.issuer)))).pipe(Effect.map((registered) => registered.client_id));
298
+ const page = '<!doctype html><meta charset="utf-8"><title>clankerauth</title><p>Return to the terminal.</p>';
299
+ const listen = Effect.acquireRelease(Effect.gen(function* () {
300
+ const received = yield* Deferred.make();
301
+ const server = createServer((request, response) => {
302
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
303
+ if (url.pathname !== "/callback") {
304
+ response.writeHead(404).end();
305
+ return;
306
+ }
307
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page);
308
+ Deferred.doneUnsafe(received, Effect.succeed(url.searchParams));
309
+ });
310
+ const port = yield* Effect.callback((resume) => {
311
+ server.once("error", (error) => resume(Effect.fail(new SignInFailed({ message: `Cannot listen for the redirect: ${error.message}` }))));
312
+ server.listen(0, "127.0.0.1", () =>
313
+ // SAFETY: a listening TCP server's address is an AddressInfo, not a pipe's string.
314
+ resume(Effect.succeed(server.address().port)));
315
+ });
316
+ return { server, port, received };
317
+ }), ({ server }) => Effect.sync(() => {
318
+ server.closeAllConnections();
319
+ server.close();
320
+ }));
321
+ const authorizationPage = Effect.fn("Credentials.authorizationPage")(function* (server, clientId, redirectUri, scope, resource) {
322
+ const verifier = oauth.generateRandomCodeVerifier();
323
+ const state = oauth.generateRandomState();
324
+ const url = new URL(server.authorization_endpoint);
325
+ url.search = new URLSearchParams({
326
+ client_id: clientId,
327
+ redirect_uri: redirectUri,
328
+ response_type: "code",
329
+ scope,
330
+ resource,
331
+ code_challenge: yield* Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)),
332
+ code_challenge_method: "S256",
333
+ state,
334
+ }).toString();
335
+ return { url, state, verifier };
336
+ });
337
+ const exchangeCode = (server, clientId, callback, expected, resource) => step("Sign-in", async () => {
338
+ const client = { client_id: clientId };
339
+ const parameters = oauth.validateAuthResponse(server, client, callback, expected.state);
340
+ return oauth.processAuthorizationCodeResponse(server, client, await oauth.authorizationCodeGrantRequest(server, client, oauth.None(), parameters, expected.redirectUri, expected.verifier, { additionalParameters: { resource }, ...requestOptions(server.issuer) }));
341
+ });
342
+ const revoke = (signIn) => Effect.ignore(Effect.tryPromise(async () => oauth.processRevocationResponse(await oauth.revocationRequest(signIn.server, { client_id: signIn.clientId }, oauth.None(), signIn.refreshToken, {
343
+ additionalParameters: { token_type_hint: "refresh_token" },
344
+ ...requestOptions(signIn.url),
345
+ }))));
346
+ /**
347
+ * Sign in as the issuer's owner to the resource `url` identifies, read from its
348
+ * `/.well-known/oauth-protected-resource`, and keep the sign-in, replacing and revoking any to
349
+ * that resource. The issuer is the one the resource names; the sign-in registers a native OAuth
350
+ * client there, asks for `offline_access` beside the scopes, and listens for the redirect on a
351
+ * loopback port.
352
+ */
353
+ export const signIn = Effect.fn("Credentials.signIn")(function* (url, options) {
354
+ const { resource, scopes, server } = yield* discover(url);
355
+ const scope = [...asked(options.scope ?? scopes.join(" ")), refreshScope].join(" ");
356
+ const clientId = yield* register(server);
357
+ const { port, received } = yield* listen;
358
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
359
+ const approval = yield* authorizationPage(server, clientId, redirectUri, scope, resource);
360
+ yield* options.approve(approval.url);
361
+ const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
362
+ duration: "5 minutes",
363
+ orElse: () => Effect.fail(new SignInFailed({ message: "Sign-in: nobody approved it within five minutes" })),
364
+ }));
365
+ const tokens = yield* exchangeCode(server, clientId, callback, { ...approval, redirectUri }, resource);
366
+ if (tokens.refresh_token === undefined)
367
+ return yield* new SignInFailed({ message: "Sign-in: the issuer issued no refresh token" });
368
+ const kept = signInOf({ resource, url: new URL(server.issuer).origin, server, clientId, scope }, tokens, tokens.refresh_token, yield* Clock.currentTimeMillis);
369
+ const before = yield* locked((save) => save(({ signIns }) => ({ signIns: { ...signIns, [resource]: kept } })));
370
+ const replaced = before.signIns[resource];
371
+ if (replaced !== undefined)
372
+ yield* revoke(replaced);
373
+ return { url: kept.url, resource, scope: kept.scope };
374
+ }, Effect.scoped);
375
+ /**
376
+ * Forget the sign-in whose resource holds `url`, or every one, holding the file's lock so a
377
+ * refresh never slips between, then revoke it. A sign-in is forgotten even when the issuer does
378
+ * not answer its revocation.
379
+ */
380
+ export const signOut = Effect.fn("Credentials.signOut")(function* (url) {
381
+ const target = url === undefined ? undefined : yield* located(url);
382
+ const leaving = (signIns) => target === undefined
383
+ ? Object.values(signIns.signIns)
384
+ : Option.toArray(Option.fromNullishOr(signInFor(signIns, target)));
385
+ const before = yield* locked((save) => save((signIns) => {
386
+ const left = leaving(signIns);
387
+ return {
388
+ signIns: Object.fromEntries(Object.entries(signIns.signIns).filter(([kept]) => left.every((signIn) => signIn.resource !== kept))),
389
+ };
390
+ }));
391
+ const left = leaving(before);
392
+ yield* Effect.forEach(left, revoke, { concurrency: "unbounded", discard: true });
393
+ return { signedOut: left.length > 0 };
394
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.23.0",
3
+ "version": "0.24.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": {
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/key-list.d.ts",
35
35
  "import": "./dist/key-list.js"
36
36
  },
37
+ "./credentials": {
38
+ "types": "./dist/credentials.d.ts",
39
+ "import": "./dist/credentials.js"
40
+ },
37
41
  "./testing": {
38
42
  "types": "./dist/testing.d.ts",
39
43
  "import": "./dist/testing.js"
@@ -43,13 +47,16 @@
43
47
  "access": "public"
44
48
  },
45
49
  "dependencies": {
46
- "jose": "6.2.12"
50
+ "jose": "6.2.12",
51
+ "oauth4webapi": "3.8.8",
52
+ "proper-lockfile": "4.1.2"
47
53
  },
48
54
  "devDependencies": {
49
55
  "@effect/vitest": "4.0.4",
50
- "@gjermundgaraba/effect-actions": "0.17.0",
56
+ "@gjermundgaraba/effect-actions": "0.18.0",
51
57
  "@modelcontextprotocol/client": "2.3.1",
52
58
  "@types/node": "26.6.4",
59
+ "@types/proper-lockfile": "4.1.4",
53
60
  "effect": "4.0.4",
54
61
  "typescript": "7.0.2",
55
62
  "vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
@@ -57,7 +64,7 @@
57
64
  "vitest": "5.0.4"
58
65
  },
59
66
  "peerDependencies": {
60
- "@gjermundgaraba/effect-actions": "^0.17.0",
67
+ "@gjermundgaraba/effect-actions": "^0.18.0",
61
68
  "effect": "^4.0.4"
62
69
  },
63
70
  "peerDependenciesMeta": {