@gjermundgaraba/clankerauth 0.0.0-stage → 0.17.1

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -2
  3. package/dist/main.mjs +761 -0
  4. package/package.json +39 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gjermund Garaba
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # @gjermundgaraba/clankerauth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `clankerauth` command: sign in to a [clankerauth](https://github.com/gjermundgaraba/clankerauth) issuer as its owner, run every administration action over HTTP, and hand other commands access tokens for the issuer's resources. It needs Node 26 or newer and an issuer at 0.16.0 or later.
4
+
5
+ ```sh
6
+ npm install --global @gjermundgaraba/clankerauth
7
+
8
+ clankerauth login https://clankerauth.home.example
9
+ clankerauth list-clients
10
+ clankerauth create-api-key --name "notes sync" \
11
+ --permissions '{"https://notes.home.example/":["notes:read"]}' --expires-at null
12
+ clankerauth logout
13
+ ```
14
+
15
+ `login` discovers the issuer at the URL given, which must be its own origin, `CLANKERAUTH_BASE_URL`; registers the command as a native OAuth client the first time; prints the approval URL on stderr for the owner to open; and listens for the redirect on a loopback port. It asks for `clankerauth:read clankerauth:write`, unless `--scope` names fewer: `--scope clankerauth:read` lists and never changes. The sign-ins are kept in `$XDG_CONFIG_HOME/clankerauth/credentials.json`, `~/.config/clankerauth/credentials.json` by default, readable by their owner alone; a file the command cannot read, such as one another version wrote, counts as signed out and the next `login` replaces it. A command refreshes an access token once it has expired and saves the rotated refresh token, so a sign-in lasts while it is used at least every 30 days. `logout` revokes every sign-in and deletes the file.
16
+
17
+ ## Tokens for other commands
18
+
19
+ `login --resource <identifier> --scope <scopes>` signs in to one of the issuer's resources instead of administration, with its own consent and refresh token, kept beside the others. `token --resource <identifier>` prints a current access token for it, and nothing else, refreshing it first when it has expired; without `--resource` it prints administration's. Another command, or a script, gets its bearer token from there instead of keeping a key:
20
+
21
+ ```sh
22
+ clankerauth login https://clankerauth.home.example \
23
+ --resource https://notes.home.example/ --scope "notes:read notes:write"
24
+ curl -H "authorization: Bearer $(clankerauth token --resource https://notes.home.example/)" \
25
+ https://notes.home.example/api/list-notes
26
+ clankerauth logout --resource https://notes.home.example/
27
+ ```
28
+
29
+ The identifier is the resource's, exactly as the dashboard lists it. The file holds one issuer's sign-ins: signing in to another issuer revokes and forgets the kept ones first.
30
+
31
+ Every other command is an administration action, named after it in kebab case, with a flag per input field: `clankerauth <command> --help` lists them. A field that is not text takes JSON, such as `--permissions` above or `--expires-at null`. A command prints the action's result as JSON on stdout, and a refusal on stderr, exiting 1. A created API key or client secret is in that JSON once, so send it where it belongs:
32
+
33
+ ```sh
34
+ clankerauth create-api-key --name "machine" \
35
+ --permissions '{"https://creds.home.example/":["creds:machine"]}' --expires-at null \
36
+ | jq -r .key > key
37
+ ```
38
+
39
+ MIT licensed.
package/dist/main.mjs ADDED
@@ -0,0 +1,761 @@
1
+ #!/usr/bin/env node
2
+ import { Console, Context, Deferred, Effect, Option, Schema } from "effect";
3
+ import { Argument, CliError, Command, Flag } from "effect/cli";
4
+ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
5
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
6
+ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
7
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
8
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
9
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
10
+ import { HttpApiSchema, HttpApiSecurity } from "effect/http-api";
11
+ import { randomBytes } from "node:crypto";
12
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { createServer } from "node:http";
16
+ import * as oauth from "oauth4webapi";
17
+ //#region ../../packages/admin-api/dist/index.js
18
+ var BadRequest = class extends Schema.TaggedError()("BadRequest", { error: Schema.String }, { httpApiStatus: 400 }) {};
19
+ var NotFound = class extends Schema.TaggedError()("NotFound", { error: Schema.String }, { httpApiStatus: 404 }) {};
20
+ var Conflict = class extends Schema.TaggedError()("Conflict", { error: Schema.String }, { httpApiStatus: 409 }) {};
21
+ var TooManyRequests = class extends Schema.TaggedError()("TooManyRequests", { error: Schema.String }, { httpApiStatus: 429 }) {};
22
+ /** The schema defines the public response; the diagnostic cause reaches logs, never a caller. */
23
+ var InternalServerError = class extends Schema.TaggedError()("InternalServerError", { error: Schema.String }, { httpApiStatus: 500 }) {
24
+ cause;
25
+ };
26
+ var ServiceUnavailable = class extends Schema.TaggedError()("ServiceUnavailable", { error: Schema.String }, { httpApiStatus: 503 }) {};
27
+ /**
28
+ * What every action may fail with, beside effect-actions' built-in `InvalidInput`,
29
+ * `Unauthenticated` and `Forbidden`, which every endpoint and tool declares.
30
+ */
31
+ const errors = [
32
+ BadRequest,
33
+ NotFound,
34
+ Conflict,
35
+ TooManyRequests,
36
+ InternalServerError,
37
+ ServiceUnavailable
38
+ ];
39
+ [...errors, Action.Forbidden];
40
+ /**
41
+ * The owner administering the issuer, as its authentication verified the request: what every
42
+ * administration action states, `caller: CurrentOwner`. Never supplied by action arguments or
43
+ * at startup.
44
+ */
45
+ var CurrentOwner = class extends Context.Service()("clankerauth/CurrentOwner") {};
46
+ /**
47
+ * The dashboard's owner session: the issuer's session cookie, named `cookie`, which the browser
48
+ * sends. The provider names that cookie by the deployment, `__Secure-` prefixed over HTTPS, so
49
+ * the server builds this descriptor where it starts, from the name its sessions use, and builds
50
+ * both its binding and its `Authentication.layer` from it: that layer serves only a binding
51
+ * built from that very descriptor, not another naming the same cookie. A browser's binding,
52
+ * from `OwnerSession`, only calls. Its verifier reads the session through the provider, so it
53
+ * may fail as an action does, with any of `errors`, such as `ServiceUnavailable`, answered with
54
+ * its own status.
55
+ */
56
+ const ownerSession = (cookie) => Authentication.make("clankerauth.OwnerSession", CurrentOwner, {
57
+ security: HttpApiSecurity.apiKey({
58
+ in: "cookie",
59
+ key: cookie
60
+ }),
61
+ error: errors
62
+ });
63
+ /**
64
+ * The browser's owner session. A page never needs the cookie's name, as the browser sends its
65
+ * cookies itself: this one names the provider's cookie over plain HTTP, which only the server
66
+ * would read.
67
+ */
68
+ const OwnerSession = ownerSession("better-auth.session_token");
69
+ /**
70
+ * An administration client's OAuth access token for the issuer's origin root, `<baseURL>/`,
71
+ * over MCP or `OwnerHttp`. Its verifier reads the token's owner and client from the database,
72
+ * so it may fail as an action does, with any of `errors`: `ServiceUnavailable` when the token
73
+ * cannot be decided, or `InternalServerError`.
74
+ */
75
+ const OwnerToken = Authentication.make("clankerauth.OwnerToken", CurrentOwner, { error: errors });
76
+ const SetupInput = Schema.Struct({
77
+ email: Schema.String,
78
+ password: Schema.String
79
+ });
80
+ const ClientAuthMethod = Schema.Literals([
81
+ "none",
82
+ "client_secret_basic",
83
+ "client_secret_post"
84
+ ]);
85
+ const ApplicationType = Schema.Literals(["web", "native"]);
86
+ /** The resources a managed client may obtain, each named once. */
87
+ const ResourceSelection = Schema.Array(Schema.String).check(Schema.isUnique({ message: "Choose unique Resources" }));
88
+ /** Provider vocabulary: the server passes these fields through unchanged. */
89
+ const ClientInput = Schema.Struct({
90
+ client_name: Schema.String,
91
+ redirect_uris: Schema.Array(Schema.String),
92
+ resources: ResourceSelection,
93
+ token_endpoint_auth_method: ClientAuthMethod,
94
+ application_type: ApplicationType
95
+ });
96
+ /** The provider's update endpoint cannot change the authentication method. */
97
+ const ClientUpdateInput = Schema.Struct({
98
+ client_id: Schema.String,
99
+ client_name: Schema.String,
100
+ redirect_uris: Schema.Array(Schema.String),
101
+ application_type: ApplicationType
102
+ });
103
+ const ClientId = Schema.Struct({ client_id: Schema.String });
104
+ /** Without a resource, every authorization the client holds. */
105
+ const ClientRevokeInput = Schema.Struct({
106
+ client_id: Schema.String,
107
+ resource: Schema.optional(Schema.String)
108
+ });
109
+ const ClientBlockInput = Schema.Struct({
110
+ client_id: Schema.String,
111
+ blocked: Schema.Boolean
112
+ });
113
+ const Client = Schema.Struct({
114
+ onboarding: Schema.optional(Schema.Literals([
115
+ "managed",
116
+ "dcr",
117
+ "cimd"
118
+ ])),
119
+ blocked: Schema.optional(Schema.Boolean),
120
+ client_id: Schema.String,
121
+ client_name: Schema.optional(Schema.String),
122
+ redirect_uris: Schema.Array(Schema.String),
123
+ token_endpoint_auth_method: Schema.optional(Schema.String),
124
+ application_type: Schema.optional(Schema.NullOr(Schema.String)),
125
+ scope: Schema.optional(Schema.String),
126
+ grant_types: Schema.optional(Schema.Array(Schema.String))
127
+ });
128
+ const ClientCredentials = Client.pipe(Schema.fieldsAssign({
129
+ client_secret: Schema.optional(Schema.String),
130
+ client_secret_expires_at: Schema.optional(Schema.Number)
131
+ }));
132
+ const Resource = Schema.Struct({
133
+ identifier: Schema.String,
134
+ name: Schema.String,
135
+ scopes: Schema.Array(Schema.String)
136
+ });
137
+ const ResourceSummary = Resource.pipe(Schema.fieldsAssign({ builtIn: Schema.Boolean }));
138
+ /**
139
+ * A resource as the owner writes it. The issuer trims each field before storing it, so the
140
+ * rules allow surrounding whitespace: a name that is not blank, and scopes that are OAuth
141
+ * scope tokens (RFC 6749 §3.3). Stored resources are `Resource`, which carries no rule.
142
+ */
143
+ const ResourceInput = Schema.Struct({
144
+ identifier: Schema.String,
145
+ name: Schema.String.check(Schema.isPattern(/\S/u, { message: "Resources require a name" })),
146
+ scopes: Schema.Array(Schema.String.check(Schema.isPattern(/^\s*[\x21\x23-\x5B\x5D-\x7E]+\s*$/u, { message: "Resources require well-formed custom scopes" })))
147
+ });
148
+ const ResourceId = Schema.Struct({ identifier: Schema.String });
149
+ const ClientAccess = Schema.Struct({
150
+ client_id: Schema.String,
151
+ resource: Schema.String
152
+ });
153
+ const ClientAccessInput = Schema.Struct({
154
+ client_id: Schema.String,
155
+ resources: ResourceSelection
156
+ });
157
+ const ClientAccessResult = Schema.Struct({ clientAccess: Schema.Array(ClientAccess) });
158
+ const KeyPermissions = Schema.Record(Schema.String, Schema.Array(Schema.String));
159
+ /**
160
+ * Permissions as the owner grants them: at least one resource, each with scopes named once.
161
+ * A stored key's are `KeyPermissions`, which a key whose permissions no longer decode is
162
+ * listed with, empty.
163
+ */
164
+ const KeyGrants = Schema.Record(Schema.String, Schema.Array(Schema.String).check(Schema.isMinLength(1, { message: "Select explicitly granted, currently available Resource scopes" }), Schema.isUnique({ message: "Select explicitly granted, currently available Resource scopes" }))).check(Schema.isMinProperties(1, { message: "Select at least one Resource and scope" }));
165
+ /** A key's name as the owner writes it; the issuer stores it trimmed. */
166
+ const KeyName = Schema.String.check(Schema.isMaxLength(100, { message: "Key names require 1–100 characters" }), Schema.isPattern(/\S/u, { message: "Key names require 1–100 characters" }));
167
+ /**
168
+ * Timestamps stay ISO strings on the wire and arrive as `DateTime.Utc` values. An input
169
+ * without an offset is read as UTC, so the instant an API caller means never depends on
170
+ * the host's time zone; every value this issuer writes carries `Z`.
171
+ */
172
+ const Timestamp = Schema.DateTimeUtcFromString;
173
+ /**
174
+ * What a client holds for one resource: a consent, a live refresh token, or both. A
175
+ * managed client skips consent, so `approvedAt` is null for it and it shows only while it
176
+ * holds a refresh token. `scopes` are the consent's, else the newest token's.
177
+ * `refreshedAt` is when the newest live refresh token was issued, at sign-in or on refresh.
178
+ */
179
+ const Connection = Schema.Struct({
180
+ client_id: Schema.String,
181
+ resource: Schema.String,
182
+ scopes: Schema.Array(Schema.String),
183
+ approvedAt: Schema.NullOr(Timestamp),
184
+ refreshedAt: Schema.NullOr(Timestamp)
185
+ });
186
+ const ApiKeyInput = Schema.Struct({
187
+ name: KeyName,
188
+ permissions: KeyGrants,
189
+ expiresAt: Schema.NullOr(Timestamp)
190
+ });
191
+ const ApiKeyId = Schema.Struct({ keyId: Schema.String });
192
+ const ApiKeyUpdate = Schema.Struct({
193
+ keyId: Schema.String,
194
+ name: Schema.optional(KeyName),
195
+ permissions: Schema.optional(KeyGrants),
196
+ enabled: Schema.optional(Schema.Boolean)
197
+ });
198
+ const MachineKey = Schema.Struct({
199
+ keyId: Schema.String,
200
+ name: Schema.String,
201
+ enabled: Schema.Boolean,
202
+ permissions: KeyPermissions,
203
+ expiresAt: Schema.NullOr(Timestamp),
204
+ createdAt: Timestamp
205
+ });
206
+ /** What every issuer action shares: any caller, and the contract's errors. */
207
+ const anyone = {
208
+ caller: Action.Anyone,
209
+ error: errors
210
+ };
211
+ /** What every administration action shares: the owner as its caller, and the contract's errors. */
212
+ const owned = {
213
+ caller: CurrentOwner,
214
+ error: errors
215
+ };
216
+ const IssuerActions = [
217
+ Action.make("setupStatus", {
218
+ description: "Check whether the issuer needs its first owner account.",
219
+ readOnly: true,
220
+ ...anyone,
221
+ success: Schema.Struct({ required: Schema.Boolean })
222
+ }),
223
+ Action.make("setupOwner", {
224
+ description: "Create the first owner account.",
225
+ readOnly: false,
226
+ ...anyone,
227
+ input: SetupInput,
228
+ success: Schema.Struct({ created: Schema.Boolean }).pipe(HttpApiSchema.status(201))
229
+ }),
230
+ Action.make("keyList", {
231
+ description: "The signed list a resource server verifies API keys against: one sealed entry per key granted on the resource, readable only with that key.",
232
+ readOnly: true,
233
+ ...anyone,
234
+ input: Schema.Struct({ resource: Schema.String }),
235
+ success: Schema.Struct({ list: Schema.String })
236
+ })
237
+ ];
238
+ const ClientListResult = Schema.Struct({
239
+ clients: Schema.Array(Client),
240
+ resources: Schema.Array(ResourceSummary),
241
+ clientAccess: Schema.Array(ClientAccess),
242
+ connections: Schema.Array(Connection),
243
+ email: Schema.String,
244
+ issuer: Schema.String
245
+ });
246
+ const Administration = [
247
+ Action.make("listClients", {
248
+ description: "List clients, resources, managed clients' resource access (what they may obtain), and every client's connections (what it holds).",
249
+ readOnly: true,
250
+ ...owned,
251
+ success: ClientListResult
252
+ }),
253
+ Action.make("createClient", {
254
+ description: "Register a first-party OAuth client. Returns its secret once when confidential.",
255
+ readOnly: false,
256
+ ...owned,
257
+ input: ClientInput,
258
+ success: ClientCredentials.pipe(HttpApiSchema.status(201))
259
+ }),
260
+ Action.make("updateClient", {
261
+ description: "Rename a first-party client or change its redirect URIs and application type.",
262
+ readOnly: false,
263
+ ...owned,
264
+ input: ClientUpdateInput,
265
+ success: Client
266
+ }),
267
+ Action.make("deleteClient", {
268
+ description: "Delete a client and its stored authorization. An automatic client can register again; block it to keep it out.",
269
+ readOnly: false,
270
+ ...owned,
271
+ input: ClientId,
272
+ success: Schema.Struct({ deleted: Schema.Boolean })
273
+ }),
274
+ Action.make("revokeClient", {
275
+ description: "Revoke a client’s stored authorization, for one resource or all. It must authorize again; an automatic client asks for consent.",
276
+ readOnly: false,
277
+ ...owned,
278
+ input: ClientRevokeInput,
279
+ success: Schema.Struct({ revoked: Schema.Boolean })
280
+ }),
281
+ Action.make("blockClient", {
282
+ description: "Block or unblock OAuth authorization for a client.",
283
+ readOnly: false,
284
+ ...owned,
285
+ input: ClientBlockInput,
286
+ success: Schema.Struct({ blocked: Schema.Boolean })
287
+ }),
288
+ Action.make("rotateClientSecret", {
289
+ description: "Rotate a confidential client secret. Returns the new secret once.",
290
+ readOnly: false,
291
+ ...owned,
292
+ input: ClientId,
293
+ success: ClientCredentials
294
+ }),
295
+ Action.make("setClientAccess", {
296
+ description: "Set the resources a managed client may obtain. Removing one revokes its authorization for it. Automatic clients may ask for any resource with consent.",
297
+ readOnly: false,
298
+ ...owned,
299
+ input: ClientAccessInput,
300
+ success: ClientAccessResult
301
+ }),
302
+ Action.make("createResource", {
303
+ description: "Create an OAuth resource and its scopes.",
304
+ readOnly: false,
305
+ ...owned,
306
+ input: ResourceInput,
307
+ success: Resource.pipe(HttpApiSchema.status(201))
308
+ }),
309
+ Action.make("updateResource", {
310
+ description: "Update an OAuth resource and its scopes.",
311
+ readOnly: false,
312
+ ...owned,
313
+ input: ResourceInput,
314
+ success: Resource
315
+ }),
316
+ Action.make("deleteResource", {
317
+ description: "Delete a resource, its client access and every client’s authorization for it. API-key permissions are retained; recreating the resource restores them.",
318
+ readOnly: false,
319
+ ...owned,
320
+ input: ResourceId,
321
+ success: Schema.Struct({ deleted: Schema.Boolean })
322
+ }),
323
+ Action.make("listApiKeys", {
324
+ description: "List API key metadata without secret values.",
325
+ readOnly: true,
326
+ ...owned,
327
+ success: Schema.Struct({ keys: Schema.Array(MachineKey) })
328
+ }),
329
+ Action.make("createApiKey", {
330
+ description: "Create a scoped API key. Returns its secret once.",
331
+ readOnly: false,
332
+ ...owned,
333
+ input: ApiKeyInput,
334
+ success: MachineKey.pipe(Schema.fieldsAssign({ key: Schema.String }), HttpApiSchema.status(201))
335
+ }),
336
+ Action.make("updateApiKey", {
337
+ description: "Update an API key’s name, permissions or enabled state.",
338
+ readOnly: false,
339
+ ...owned,
340
+ input: ApiKeyUpdate,
341
+ success: MachineKey
342
+ }),
343
+ Action.make("deleteApiKey", {
344
+ description: "Delete an API key.",
345
+ readOnly: false,
346
+ ...owned,
347
+ input: ApiKeyId,
348
+ success: Schema.Struct({ deleted: Schema.Boolean })
349
+ })
350
+ ];
351
+ /**
352
+ * Every action over HTTP, at `/api/<action>`, owner administration authenticated by
353
+ * `authentication`: the server's, from its deployment's cookie, or the browser's
354
+ * `OwnerSession`. Resource servers read `/api/keyList`, so that path is part of the protocol.
355
+ */
356
+ const binding = (authentication) => ActionHttp.make([...Administration, ...IssuerActions], { authentication });
357
+ binding(OwnerSession);
358
+ /**
359
+ * Owner administration over HTTP for a client holding an access token, such as the CLI, at
360
+ * `/api/owner/<action>`: the actions `/mcp` serves, authenticated as it is, by `OwnerToken`.
361
+ */
362
+ const OwnerHttp = ActionHttp.make(Administration, {
363
+ prefix: "/api/owner",
364
+ authentication: OwnerToken
365
+ });
366
+ //#endregion
367
+ //#region package.json
368
+ var version = "0.17.1";
369
+ //#endregion
370
+ //#region src/credentials.ts
371
+ /** The kept sign-ins cannot be read or written. */
372
+ var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { message: Schema.String }) {};
373
+ const failed = (what) => (error) => new CredentialsError({ message: `${what} ${credentialsPath()}: ${error.message}` });
374
+ /**
375
+ * The owner's authorization for one resource: administration or an application's. The
376
+ * access token lasts fifteen minutes and the refresh token rotates on every use, so the
377
+ * file is rewritten whenever a command refreshes.
378
+ */
379
+ const SignIn = Schema.Struct({
380
+ /** The token audience: a resource identifier, such as `https://notes.home.example/`. */
381
+ resource: Schema.String,
382
+ scope: Schema.String,
383
+ accessToken: Schema.String,
384
+ refreshToken: Schema.String,
385
+ /** When the access token expires, as an ISO instant. */
386
+ expiresAt: Schema.String
387
+ });
388
+ /**
389
+ * The issuer signed in to, the client this CLI registered there, and one sign-in per
390
+ * resource. `server` is the part of the issuer's RFC 8414 metadata a refresh and a
391
+ * revocation need, under its RFC names.
392
+ */
393
+ const Credentials = Schema.Struct({
394
+ /** The issuer's origin, `CLANKERAUTH_BASE_URL`: where `/api/owner/<action>` is served. */
395
+ url: Schema.String,
396
+ server: Schema.Struct({
397
+ issuer: Schema.String,
398
+ token_endpoint: Schema.String,
399
+ revocation_endpoint: Schema.String
400
+ }),
401
+ clientId: Schema.String,
402
+ signIns: Schema.Array(SignIn)
403
+ });
404
+ const CredentialsJson = Schema.fromJsonString(Credentials);
405
+ /** Where the sign-ins are kept: `$XDG_CONFIG_HOME/clankerauth`, or `~/.config/clankerauth`. */
406
+ const credentialsPath = () => join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "clankerauth", "credentials.json");
407
+ /**
408
+ * The sign-ins, if there are any. A file this version cannot decode, such as one another
409
+ * version wrote, holds none: the next `login` replaces it.
410
+ */
411
+ const readCredentials = () => Effect.tryPromise(() => readFile(credentialsPath(), "utf8")).pipe(Effect.map(Schema.decodeOption(CredentialsJson)), Effect.catchIf((error) => error.cause instanceof Error && "code" in error.cause && error.cause.code === "ENOENT", () => Effect.succeedNone), Effect.mapError(failed("Cannot read")));
412
+ /**
413
+ * Replace the sign-ins, readable by their owner alone. Written beside the file and renamed
414
+ * over it, so a command interrupted mid-write never leaves a refresh token half-saved.
415
+ */
416
+ const writeCredentials = Effect.fn("Credentials.write")(function* (credentials) {
417
+ const path = credentialsPath();
418
+ const json = yield* Schema.encodeEffect(CredentialsJson)(credentials);
419
+ const staging = `${path}.${randomBytes(6).toString("hex")}`;
420
+ yield* Effect.tryPromise(async () => {
421
+ await mkdir(dirname(path), {
422
+ recursive: true,
423
+ mode: 448
424
+ });
425
+ await writeFile(staging, `${json}\n`, { mode: 384 });
426
+ await rename(staging, path);
427
+ }).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
428
+ }, Effect.mapError(failed("Cannot write")));
429
+ /**
430
+ * Keep `signIn` in place of the one for its resource. The file is read again first, so a
431
+ * command refreshing another resource meanwhile keeps its rotated refresh token too.
432
+ */
433
+ const saveSignIn = Effect.fn("Credentials.saveSignIn")(function* (issuer, signIn) {
434
+ const current = Option.filter(yield* readCredentials(), ({ url }) => url === issuer.url);
435
+ const base = Option.getOrElse(current, () => issuer);
436
+ yield* writeCredentials({
437
+ ...base,
438
+ signIns: [...base.signIns.filter(({ resource }) => resource !== signIn.resource), signIn]
439
+ });
440
+ });
441
+ const removeCredentials = () => Effect.tryPromise(() => rm(credentialsPath(), { force: true })).pipe(Effect.mapError(failed("Cannot remove")));
442
+ //#endregion
443
+ //#region src/oauth.ts
444
+ /** Something the issuer answered that a sign-in, a refresh or a revocation cannot go on from. */
445
+ var OAuthError = class extends Schema.TaggedError()("OAuthError", { message: Schema.String }) {};
446
+ /** The issuer's RFC 8414 metadata a sign-in uses, every endpoint required. */
447
+ const Server = Schema.Struct({
448
+ issuer: Schema.String,
449
+ authorization_endpoint: Schema.String,
450
+ token_endpoint: Schema.String,
451
+ registration_endpoint: Schema.String,
452
+ revocation_endpoint: Schema.String
453
+ });
454
+ /** This CLI as the issuer knows it: a public client, authenticating with nothing. */
455
+ const client = (clientId) => ({ client_id: clientId });
456
+ /** Plain HTTP is allowed for an issuer the owner named over it, such as one on loopback. */
457
+ const transport = (url) => ({ [oauth.allowInsecureRequests]: new URL(url).protocol === "http:" });
458
+ /** The RFC 9728 check's failure: the metadata names another resource than the one asked for. */
459
+ const NamedElsewhere = Schema.Struct({
460
+ attribute: Schema.Literal("resource"),
461
+ body: Schema.Struct({ resource: Schema.String })
462
+ });
463
+ /**
464
+ * What went wrong: what the issuer said when it sent an OAuth error, where it is when it
465
+ * names another address than the one asked, the status of an answer the library refused,
466
+ * or why a request failed.
467
+ */
468
+ const reason = (error) => {
469
+ if (error instanceof oauth.ResponseBodyError || error instanceof oauth.AuthorizationResponseError) return [error.error, error.error_description].filter(Boolean).join(": ");
470
+ if (error instanceof oauth.OperationProcessingError) {
471
+ const named = Schema.decodeUnknownOption(NamedElsewhere)(error.cause);
472
+ if (Option.isSome(named)) return `the issuer is at ${new URL(named.value.body.resource).origin}, its CLANKERAUTH_BASE_URL: sign in there`;
473
+ if (error.cause instanceof Response) return `${error.message} (HTTP ${error.cause.status})`;
474
+ }
475
+ return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
476
+ };
477
+ /** Run one step of the protocol, failing with what the issuer, or the library, said. */
478
+ const step = (what, run) => Effect.tryPromise({
479
+ try: run,
480
+ catch: (error) => new OAuthError({ message: `${what}: ${error instanceof Error ? reason(error) : String(error)}` })
481
+ });
482
+ /**
483
+ * The issuer at `url`, its origin: administration's RFC 9728 metadata at the origin root,
484
+ * which must name that root, and the RFC 8414 metadata of the issuer it names, which must
485
+ * name that issuer. Both checks are the library's.
486
+ */
487
+ const discover = Effect.fn("OAuth.discover")(function* (url) {
488
+ const administration = new URL("/", url);
489
+ const [named] = (yield* step(`Discovery at ${administration.origin}`, async () => oauth.processResourceDiscoveryResponse(administration, await oauth.resourceDiscoveryRequest(administration, transport(url))))).authorization_servers ?? [];
490
+ if (named === void 0) return yield* new OAuthError({ message: "Discovery: the issuer names no authorization server" });
491
+ const issuer = new URL(named);
492
+ const server = yield* step("Discovery", async () => oauth.processDiscoveryResponse(issuer, await oauth.discoveryRequest(issuer, {
493
+ algorithm: "oauth2",
494
+ ...transport(url)
495
+ })));
496
+ return yield* Schema.decodeUnknownEffect(Server)(server).pipe(Effect.mapError((error) => new OAuthError({ message: `Discovery: the issuer lacks an endpoint (${error.message})` })));
497
+ });
498
+ /**
499
+ * Register this CLI as a public native client. The issuer matches a loopback redirect without
500
+ * its port (RFC 8252), so one registration serves every port a sign-in listens on.
501
+ */
502
+ const register = (url, server, redirectUri) => step("Registration", async () => oauth.processDynamicClientRegistrationResponse(await oauth.dynamicClientRegistrationRequest(server, {
503
+ client_name: "clankerauth CLI",
504
+ redirect_uris: [redirectUri],
505
+ application_type: "native",
506
+ token_endpoint_auth_method: "none",
507
+ grant_types: ["authorization_code", "refresh_token"],
508
+ response_types: ["code"]
509
+ }, transport(url)))).pipe(Effect.map((registered) => registered.client_id));
510
+ /**
511
+ * Check the redirect a browser brought back (its state, the RFC 9207 issuer, an error the
512
+ * issuer sent instead of a code) and exchange its code for tokens.
513
+ */
514
+ const exchangeCode = (url, server, clientId, callback, expected, resource) => step("Sign-in", async () => {
515
+ const parameters = oauth.validateAuthResponse(server, client(clientId), callback, expected.state);
516
+ return oauth.processAuthorizationCodeResponse(server, client(clientId), await oauth.authorizationCodeGrantRequest(server, client(clientId), oauth.None(), parameters, expected.redirectUri, expected.verifier, {
517
+ additionalParameters: { resource },
518
+ ...transport(url)
519
+ }));
520
+ });
521
+ /** Refresh the tokens of one sign-in: the refresh token rotates. */
522
+ const refresh = (credentials, refreshToken, resource) => step("Refresh", async () => oauth.processRefreshTokenResponse(credentials.server, client(credentials.clientId), await oauth.refreshTokenGrantRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
523
+ additionalParameters: { resource },
524
+ ...transport(credentials.url)
525
+ })));
526
+ /** Revoke a refresh token, and with it every token of its family. */
527
+ const revoke = (credentials, refreshToken) => step("Revocation", async () => oauth.processRevocationResponse(await oauth.revocationRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
528
+ additionalParameters: { token_type_hint: "refresh_token" },
529
+ ...transport(credentials.url)
530
+ })));
531
+ //#endregion
532
+ //#region src/login.ts
533
+ /** The issuer's own resource, administration: its origin root, as discovery checks. */
534
+ const administrationOf = (url) => new URL("/", url).href;
535
+ /** The scopes a sign-in to administration asks for unless told otherwise. */
536
+ const administrationScopes = "clankerauth:read clankerauth:write";
537
+ /** A sign-in asks for a refresh token too, so it lasts while it is used. */
538
+ const withRefresh = (scope) => scope.split(" ").includes("offline_access") ? scope : `${scope} offline_access`;
539
+ /**
540
+ * The command that signs in to `resource` again: administration needs neither flag unless
541
+ * it was granted other scopes than its default, and another resource needs both.
542
+ */
543
+ const loginHint = (url, resource, scope) => {
544
+ const asked = scope?.split(" ").filter((granted) => granted !== "offline_access").join(" ");
545
+ const administration = resource === administrationOf(url);
546
+ return `clankerauth login ${url}${administration ? "" : ` --resource ${resource}`}${administration && (asked === void 0 || asked === "clankerauth:read clankerauth:write") ? "" : ` --scope "${asked ?? "<scopes>"}"`}`;
547
+ };
548
+ /** The redirect a registration names; a sign-in adds the port it listens on. */
549
+ const registeredRedirect = "http://127.0.0.1/callback";
550
+ /** How long a sign-in waits for the owner to approve it in the browser. */
551
+ const approvalWindow = "5 minutes";
552
+ /** When tokens issued now expire; an issuer that does not say is asked again next time. */
553
+ const expiry = (tokens) => new Date(Date.now() + (tokens.expires_in ?? 0) * 1e3).toISOString();
554
+ /**
555
+ * The callback page says nothing of the outcome, which is decided after the browser has
556
+ * its answer: the terminal reports it.
557
+ */
558
+ const page = "<!doctype html><meta charset=\"utf-8\"><title>clankerauth</title><p>Return to the terminal.</p>";
559
+ /** Listen on a free loopback port for the one request the authorization redirect makes. */
560
+ const listen = Effect.acquireRelease(Effect.gen(function* () {
561
+ const received = yield* Deferred.make();
562
+ const server = createServer((request, response) => {
563
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
564
+ if (url.pathname !== "/callback") {
565
+ response.writeHead(404).end();
566
+ return;
567
+ }
568
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page);
569
+ Deferred.doneUnsafe(received, Effect.succeed(url.searchParams));
570
+ });
571
+ return {
572
+ server,
573
+ port: yield* Effect.callback((resume) => {
574
+ server.once("error", (error) => resume(Effect.fail(new OAuthError({ message: `Cannot listen for the redirect: ${error.message}` }))));
575
+ server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server.address().port)));
576
+ }),
577
+ received
578
+ };
579
+ }), ({ server }) => Effect.sync(() => {
580
+ server.closeAllConnections();
581
+ server.close();
582
+ }));
583
+ /** Revoke these sign-ins' refresh tokens, as far as the issuer can be reached. */
584
+ const revokeAll = (credentials, signIns) => Effect.forEach(signIns, ({ refreshToken }) => Effect.ignore(revoke(credentials, refreshToken)), {
585
+ concurrency: "unbounded",
586
+ discard: true
587
+ });
588
+ /**
589
+ * Sign in to the issuer at `url` as its owner, for `resource`, administration when none:
590
+ * discover the issuer, register this CLI once per issuer, have the owner approve the URL
591
+ * printed on stderr, and keep the tokens beside the sign-ins to other resources. Signing
592
+ * in to another issuer than the kept one revokes and forgets the kept one's sign-ins.
593
+ */
594
+ const login = Effect.fn("Cli.login")(function* (url, target, asked) {
595
+ const origin = new URL(url).origin;
596
+ const server = yield* discover(origin);
597
+ const resource = Option.getOrElse(target, () => administrationOf(origin));
598
+ const kept = yield* readCredentials();
599
+ const previous = Option.filter(kept, (credentials) => credentials.url === origin);
600
+ const clientId = yield* Option.match(previous, {
601
+ onSome: (credentials) => Effect.succeed(credentials.clientId),
602
+ onNone: () => register(origin, server, registeredRedirect)
603
+ });
604
+ const { port, received } = yield* listen;
605
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
606
+ const verifier = oauth.generateRandomCodeVerifier();
607
+ const state = oauth.generateRandomState();
608
+ const scope = withRefresh(asked);
609
+ const authorization = new URL(server.authorization_endpoint);
610
+ authorization.search = new URLSearchParams({
611
+ client_id: clientId,
612
+ redirect_uri: redirectUri,
613
+ response_type: "code",
614
+ scope,
615
+ resource,
616
+ code_challenge: yield* Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)),
617
+ code_challenge_method: "S256",
618
+ state
619
+ }).toString();
620
+ yield* Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${authorization.href}\n\n`));
621
+ const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
622
+ duration: approvalWindow,
623
+ orElse: () => Effect.fail(new OAuthError({ message: "Sign-in: nobody approved it within five minutes" }))
624
+ }));
625
+ const tokens = yield* exchangeCode(origin, server, clientId, callback, {
626
+ state,
627
+ verifier,
628
+ redirectUri
629
+ }, resource);
630
+ if (tokens.refresh_token === void 0) return yield* new OAuthError({ message: "Sign-in: the issuer issued no refresh token" });
631
+ const signIn = {
632
+ resource,
633
+ scope: tokens.scope ?? scope,
634
+ accessToken: tokens.access_token,
635
+ refreshToken: tokens.refresh_token,
636
+ expiresAt: expiry(tokens)
637
+ };
638
+ if (Option.isSome(kept) && Option.isNone(previous)) {
639
+ yield* revokeAll(kept.value, kept.value.signIns);
640
+ yield* removeCredentials();
641
+ }
642
+ yield* saveSignIn(Option.getOrElse(previous, () => ({
643
+ url: origin,
644
+ server: {
645
+ issuer: server.issuer,
646
+ token_endpoint: server.token_endpoint,
647
+ revocation_endpoint: server.revocation_endpoint
648
+ },
649
+ clientId,
650
+ signIns: []
651
+ })), signIn);
652
+ return {
653
+ url: origin,
654
+ resource,
655
+ scope: signIn.scope
656
+ };
657
+ }, Effect.scoped);
658
+ /**
659
+ * A current access token for `resource`, refreshed first when it has expired or is about
660
+ * to: the refresh token rotates, so the new pair is saved before the token is used.
661
+ */
662
+ const accessToken = Effect.fn("Cli.accessToken")(function* (credentials, resource) {
663
+ const signIn = credentials.signIns.find((held) => held.resource === resource);
664
+ if (signIn === void 0) return yield* new OAuthError({ message: `Not signed in to ${resource}: run \`${loginHint(credentials.url, resource)}\` first.` });
665
+ if (Date.parse(signIn.expiresAt) - Date.now() > 6e4) return signIn.accessToken;
666
+ const tokens = yield* refresh(credentials, signIn.refreshToken, resource).pipe(Effect.mapError((error) => new OAuthError({ message: `${error.message}. Run \`${loginHint(credentials.url, resource, signIn.scope)}\` again.` })));
667
+ const refreshed = {
668
+ ...signIn,
669
+ scope: tokens.scope ?? signIn.scope,
670
+ accessToken: tokens.access_token,
671
+ refreshToken: tokens.refresh_token ?? signIn.refreshToken,
672
+ expiresAt: expiry(tokens)
673
+ };
674
+ yield* saveSignIn(credentials, refreshed);
675
+ return refreshed.accessToken;
676
+ });
677
+ /**
678
+ * Forget the sign-in to `resource`, or every one when none is named, revoking each refresh
679
+ * token first when the issuer can be reached. The file goes with the last sign-in, and
680
+ * with a file no sign-in can be read from.
681
+ */
682
+ const logout = Effect.fn("Cli.logout")(function* (resource) {
683
+ const stored = yield* readCredentials();
684
+ if (Option.isNone(stored)) {
685
+ yield* removeCredentials();
686
+ return { signedOut: false };
687
+ }
688
+ const credentials = stored.value;
689
+ const leaving = credentials.signIns.filter((signIn) => Option.match(resource, {
690
+ onNone: () => true,
691
+ onSome: (named) => signIn.resource === named
692
+ }));
693
+ const staying = credentials.signIns.filter((signIn) => !leaving.includes(signIn));
694
+ yield* revokeAll(credentials, leaving);
695
+ yield* staying.length === 0 ? removeCredentials() : writeCredentials({
696
+ ...credentials,
697
+ signIns: staying
698
+ });
699
+ return { signedOut: leaving.length > 0 };
700
+ });
701
+ //#endregion
702
+ //#region src/main.ts
703
+ /** A failure of this CLI's own, rendered as its message on stderr, exiting 1. */
704
+ const refused = (error) => new CliError.UserError({
705
+ cause: error,
706
+ userMessage: error.message
707
+ });
708
+ const json = (value) => Console.log(JSON.stringify(value, null, 2));
709
+ /** A resource identifier as the issuer keeps it: canonical, `https://notes.example/`. */
710
+ const resourceFlag = (description) => Flag.String("resource").pipe(Flag.withDescription(description), Flag.mapTryCatch((resource) => new URL(resource).href, () => "--resource must be a URL, such as https://notes.example/"), Flag.optional);
711
+ /** The scopes a sign-in asks for: `--scope`, which another resource than administration needs. */
712
+ const scopeFor = Effect.fn("Cli.scopeFor")(function* (resource, scope) {
713
+ if (Option.isSome(scope)) return scope.value;
714
+ if (Option.isNone(resource)) return administrationScopes;
715
+ const message = `Name the scopes to ask ${resource.value} for with --scope.`;
716
+ return yield* new CliError.UserError({
717
+ cause: message,
718
+ userMessage: message
719
+ });
720
+ });
721
+ const loginCommand = Command.make("login", {
722
+ url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, its CLANKERAUTH_BASE_URL")),
723
+ resource: resourceFlag("Sign in to this resource instead of administration, for `token` to use"),
724
+ scope: Flag.String("scope").pipe(Flag.withDescription(`The scopes to ask for, space-separated: needed with --resource, and "${administrationScopes}" for administration by default`), Flag.optional)
725
+ }, ({ url, resource, scope }) => scopeFor(resource, scope).pipe(Effect.flatMap((asked) => login(url, resource, asked).pipe(Effect.mapError(refused))), Effect.flatMap(json))).pipe(Command.withDescription("Sign in to an issuer as its owner, for administration or, with --resource, for another resource: approve the URL it prints in the browser. Sign-ins are kept in ~/.config/clankerauth."));
726
+ const logoutCommand = Command.make("logout", { resource: resourceFlag("Sign out of this resource alone") }, ({ resource }) => logout(resource).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Revoke the sign-ins, or the one to --resource, and forget them."));
727
+ /** The kept sign-ins, or how to make one. */
728
+ const signedIn = Effect.gen(function* () {
729
+ const stored = yield* readCredentials().pipe(Effect.mapError(refused));
730
+ if (Option.isNone(stored)) return yield* new CliError.UserError({
731
+ cause: "not signed in",
732
+ userMessage: "Not signed in: run `clankerauth login <url>` first."
733
+ });
734
+ return stored.value;
735
+ });
736
+ /** Prints the token alone, so a script takes the whole output as it. */
737
+ const tokenCommand = Command.make("token", { resource: resourceFlag("The resource whose token to print; administration by default") }, ({ resource }) => Effect.gen(function* () {
738
+ const credentials = yield* signedIn;
739
+ const token = yield* accessToken(credentials, Option.getOrElse(resource, () => administrationOf(credentials.url))).pipe(Effect.mapError(refused));
740
+ yield* Effect.sync(() => process.stdout.write(`${token}\n`));
741
+ })).pipe(Command.withDescription("Print a current access token for a resource signed in to, refreshing it first when it has expired."));
742
+ /**
743
+ * The client every administration command calls through: the signed-in issuer's origin and
744
+ * a current administration access token, refreshed and saved first when it has expired.
745
+ */
746
+ const administrationClient = Effect.gen(function* () {
747
+ const credentials = yield* signedIn;
748
+ const token = yield* accessToken(credentials, administrationOf(credentials.url)).pipe(Effect.mapError(refused));
749
+ return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(credentials.url), HttpClientRequest.bearerToken(token)));
750
+ });
751
+ /** Every administration action, as a command of its own, called over `/api/owner`. */
752
+ const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, administrationClient)));
753
+ const cli = Command.make("clankerauth").pipe(Command.withDescription("Administer a clankerauth issuer as its owner, and hand other commands its tokens. Results are JSON on stdout; a created key or client secret is in it once."), Command.withSubcommands([
754
+ loginCommand,
755
+ logoutCommand,
756
+ tokenCommand,
757
+ ...administration
758
+ ]));
759
+ Command.run(cli, { version }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeServices.layer), ActionCli.logToStderr, NodeRuntime.runMain);
760
+ //#endregion
761
+ export {};
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.17.1",
4
+ "description": "The clankerauth command line: sign in to an issuer as its owner and administer it over HTTP.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/gjermundgaraba/clankerauth.git",
9
+ "directory": "apps/cli"
10
+ },
11
+ "bin": {
12
+ "clankerauth": "./dist/main.mjs"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "type": "module",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@effect/platform-node": "4.0.2",
23
+ "@gjermundgaraba/effect-actions": "0.11.0",
24
+ "effect": "4.0.2",
25
+ "oauth4webapi": "3.8.8"
26
+ },
27
+ "devDependencies": {
28
+ "@clankerauth/admin-api": "0.1.0",
29
+ "@gjermundgaraba/clankerauth-dev": "0.17.1",
30
+ "@types/node": "26.6.4",
31
+ "typescript": "7.0.2",
32
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
33
+ "vite-plus": "1.1.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=26"
37
+ },
38
+ "scripts": {
39
+ "build": "vp pack",
40
+ "test": "vp pack && vp test"
41
+ }
6
42
  }