@gjermundgaraba/clankerauth 0.22.0 → 0.23.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.
Files changed (2) hide show
  1. package/dist/main.mjs +155 -183
  2. package/package.json +5 -5
package/dist/main.mjs CHANGED
@@ -15,67 +15,88 @@ import { dirname, join } from "node:path";
15
15
  import { createServer } from "node:http";
16
16
  import * as oauth from "oauth4webapi";
17
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
18
  /**
28
- * What every action may fail with, beside effect-actions' built-in `InvalidInput`,
29
- * `Unauthenticated` and `Forbidden`, which every endpoint and tool declares.
19
+ * This issuer's own fault: a failed query, an unreadable row, an unexpected provider failure.
20
+ * The schema defines the public response; the diagnostic cause reaches logs, never a caller.
30
21
  */
31
- const errors = [
32
- BadRequest,
33
- NotFound,
34
- Conflict,
35
- TooManyRequests,
36
- InternalServerError,
37
- ServiceUnavailable
38
- ];
39
- [...errors, Action.Forbidden];
22
+ var InternalServerError = class InternalServerError extends Schema.TaggedError()("InternalServerError", { message: Schema.String }, { httpApiStatus: 500 }) {
23
+ cause;
24
+ static of = (cause) => {
25
+ const error = new InternalServerError({ message: "Request could not be completed" });
26
+ error.cause = cause;
27
+ return error;
28
+ };
29
+ };
30
+ /** The issuer could not decide the request now, such as when its session store is unreachable. */
31
+ var ServiceUnavailable = class extends Schema.TaggedError()("ServiceUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {};
32
+ var ClientNotFound = class ClientNotFound extends Schema.TaggedError()("ClientNotFound", {
33
+ clientId: Schema.String,
34
+ message: Schema.String
35
+ }, { httpApiStatus: 404 }) {
36
+ static of = (clientId) => new ClientNotFound({
37
+ clientId,
38
+ message: `No client ${clientId} is registered`
39
+ });
40
+ };
41
+ var ResourceNotFound = class ResourceNotFound extends Schema.TaggedError()("ResourceNotFound", {
42
+ identifier: Schema.String,
43
+ message: Schema.String
44
+ }, { httpApiStatus: 404 }) {
45
+ static of = (identifier) => new ResourceNotFound({
46
+ identifier,
47
+ message: `No resource ${identifier} exists`
48
+ });
49
+ };
50
+ var ApiKeyNotFound = class ApiKeyNotFound extends Schema.TaggedError()("ApiKeyNotFound", {
51
+ keyId: Schema.String,
52
+ message: Schema.String
53
+ }, { httpApiStatus: 404 }) {
54
+ static of = (keyId) => new ApiKeyNotFound({
55
+ keyId,
56
+ message: `No API key ${keyId} exists`
57
+ });
58
+ };
40
59
  /**
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.
60
+ * The client registered itself, through DCR or CIMD: the owner can revoke, block or delete it,
61
+ * but its access and registration are its own, and consent decides what it gets.
44
62
  */
63
+ var ClientIsAutomatic = class ClientIsAutomatic extends Schema.TaggedError()("ClientIsAutomatic", {
64
+ clientId: Schema.String,
65
+ message: Schema.String
66
+ }, { httpApiStatus: 409 }) {
67
+ static of = (clientId) => new ClientIsAutomatic({
68
+ clientId,
69
+ message: `Client ${clientId} registered itself; revoke, block or delete it instead`
70
+ });
71
+ };
72
+ /** First-run setup has already created the owner. */
73
+ var OwnerExists = class OwnerExists extends Schema.TaggedError()("OwnerExists", { message: Schema.String }, { httpApiStatus: 409 }) {
74
+ static of = () => new OwnerExists({ message: "Setup already completed" });
75
+ };
76
+ /** The provider refused the request's input on a rule of its own, which `message` states. */
77
+ var ProviderRefused = class extends Schema.TaggedError()("ProviderRefused", { message: Schema.String }, { httpApiStatus: 400 }) {};
78
+ /** A request body the issuer could not read, on a route that is not an action. */
79
+ var UnreadableRequest = class extends Schema.TaggedError()("UnreadableRequest", { message: Schema.String }, { httpApiStatus: 400 }) {};
80
+ const undecided = [ServiceUnavailable, InternalServerError];
45
81
  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
82
  const ownerSession = (cookie) => Authentication.make("clankerauth.OwnerSession", CurrentOwner, {
57
83
  security: HttpApiSecurity.apiKey({
58
84
  in: "cookie",
59
85
  key: cookie
60
86
  }),
61
- error: errors
87
+ error: undecided
62
88
  });
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
89
  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 });
90
+ const OwnerToken = Authentication.make("clankerauth.OwnerToken", CurrentOwner, { error: undecided });
91
+ const administrationIdentifier = (origin) => new URL("/", origin).href;
92
+ const administrationScopes = {
93
+ read: "clankerauth:read",
94
+ write: "clankerauth:write"
95
+ };
96
+ const passwordRule = `Passwords have 8 to 128 characters`;
76
97
  const SetupInput = Schema.Struct({
77
- email: Schema.String,
78
- password: Schema.String
98
+ email: Schema.String.check(Schema.isPattern(/\S/u, { message: "Setup requires an email" })),
99
+ password: Schema.String.check(Schema.isMinLength(8, { message: passwordRule }), Schema.isMaxLength(128, { message: passwordRule }))
79
100
  });
80
101
  const ClientAuthMethod = Schema.Literals([
81
102
  "none",
@@ -85,7 +106,7 @@ const ClientAuthMethod = Schema.Literals([
85
106
  const ApplicationType = Schema.Literals(["web", "native"]);
86
107
  /** The resources a managed client may obtain, each named once. */
87
108
  const ResourceSelection = Schema.Array(Schema.String).check(Schema.isUnique({ message: "Choose unique Resources" }));
88
- /** Provider vocabulary: the server passes these fields through unchanged. */
109
+ /** The provider's registration fields, passed through unchanged, and the client's `resources`. */
89
110
  const ClientInput = Schema.Struct({
90
111
  client_name: Schema.String,
91
112
  redirect_uris: Schema.Array(Schema.String),
@@ -93,7 +114,6 @@ const ClientInput = Schema.Struct({
93
114
  token_endpoint_auth_method: ClientAuthMethod,
94
115
  application_type: ApplicationType
95
116
  });
96
- /** The provider's update endpoint cannot change the authentication method. */
97
117
  const ClientUpdateInput = Schema.Struct({
98
118
  client_id: Schema.String,
99
119
  client_name: Schema.String,
@@ -135,11 +155,6 @@ const Resource = Schema.Struct({
135
155
  scopes: Schema.Array(Schema.String)
136
156
  });
137
157
  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
158
  const ResourceInput = Schema.Struct({
144
159
  identifier: Schema.String,
145
160
  name: Schema.String.check(Schema.isPattern(/\S/u, { message: "Resources require a name" })),
@@ -164,11 +179,6 @@ const KeyPermissions = Schema.Record(Schema.String, Schema.Array(Schema.String))
164
179
  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
180
  /** A key's name as the owner writes it; the issuer stores it trimmed. */
166
181
  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
182
  const Timestamp = Schema.DateTimeUtcFromString;
173
183
  /**
174
184
  * What a client holds for one resource: a consent, a live refresh token, or both. A
@@ -195,7 +205,7 @@ const ApiKeyUpdate = Schema.Struct({
195
205
  permissions: Schema.optional(KeyGrants),
196
206
  enabled: Schema.optional(Schema.Boolean)
197
207
  });
198
- const MachineKey = Schema.Struct({
208
+ const ApiKey = Schema.Struct({
199
209
  keyId: Schema.String,
200
210
  name: Schema.String,
201
211
  enabled: Schema.Boolean,
@@ -203,27 +213,25 @@ const MachineKey = Schema.Struct({
203
213
  expiresAt: Schema.NullOr(Timestamp),
204
214
  createdAt: Timestamp
205
215
  });
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 anyone = { caller: Action.Anyone };
217
+ const owned = { caller: CurrentOwner };
216
218
  const IssuerActions = [
217
219
  Action.make("setupStatus", {
218
220
  description: "Check whether the issuer needs its first owner account.",
219
221
  readOnly: true,
220
222
  ...anyone,
223
+ error: [InternalServerError],
221
224
  success: Schema.Struct({ required: Schema.Boolean })
222
225
  }),
223
226
  Action.make("setupOwner", {
224
227
  description: "Create the first owner account.",
225
228
  readOnly: false,
226
229
  ...anyone,
230
+ error: [
231
+ OwnerExists,
232
+ ProviderRefused,
233
+ InternalServerError
234
+ ],
227
235
  input: SetupInput,
228
236
  success: Schema.Struct({ created: Schema.Boolean }).pipe(HttpApiSchema.status(201))
229
237
  }),
@@ -231,6 +239,7 @@ const IssuerActions = [
231
239
  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
240
  readOnly: true,
233
241
  ...anyone,
242
+ error: [InternalServerError],
234
243
  input: Schema.Struct({ resource: Schema.String }),
235
244
  success: Schema.Struct({ list: Schema.String })
236
245
  })
@@ -248,12 +257,14 @@ const Administration = [
248
257
  description: "List clients, resources, managed clients' resource access (what they may obtain), and every client's connections (what it holds).",
249
258
  readOnly: true,
250
259
  ...owned,
260
+ error: [InternalServerError],
251
261
  success: ClientListResult
252
262
  }),
253
263
  Action.make("createClient", {
254
264
  description: "Register a first-party OAuth client. Returns its secret once when confidential.",
255
265
  readOnly: false,
256
266
  ...owned,
267
+ error: [ProviderRefused, InternalServerError],
257
268
  input: ClientInput,
258
269
  success: ClientCredentials.pipe(HttpApiSchema.status(201))
259
270
  }),
@@ -261,6 +272,12 @@ const Administration = [
261
272
  description: "Rename a first-party client or change its redirect URIs and application type.",
262
273
  readOnly: false,
263
274
  ...owned,
275
+ error: [
276
+ ClientNotFound,
277
+ ClientIsAutomatic,
278
+ ProviderRefused,
279
+ InternalServerError
280
+ ],
264
281
  input: ClientUpdateInput,
265
282
  success: Client
266
283
  }),
@@ -268,6 +285,7 @@ const Administration = [
268
285
  description: "Delete a client and its stored authorization. An automatic client can register again; block it to keep it out.",
269
286
  readOnly: false,
270
287
  ...owned,
288
+ error: [ClientNotFound, InternalServerError],
271
289
  input: ClientId,
272
290
  success: Schema.Struct({ deleted: Schema.Boolean })
273
291
  }),
@@ -275,6 +293,7 @@ const Administration = [
275
293
  description: "Revoke a client’s stored authorization, for one resource or all. It must authorize again; an automatic client asks for consent.",
276
294
  readOnly: false,
277
295
  ...owned,
296
+ error: [ClientNotFound, InternalServerError],
278
297
  input: ClientRevokeInput,
279
298
  success: Schema.Struct({ revoked: Schema.Boolean })
280
299
  }),
@@ -282,6 +301,7 @@ const Administration = [
282
301
  description: "Block or unblock OAuth authorization for a client.",
283
302
  readOnly: false,
284
303
  ...owned,
304
+ error: [ClientNotFound, InternalServerError],
285
305
  input: ClientBlockInput,
286
306
  success: Schema.Struct({ blocked: Schema.Boolean })
287
307
  }),
@@ -289,6 +309,12 @@ const Administration = [
289
309
  description: "Rotate a confidential client secret. Returns the new secret once.",
290
310
  readOnly: false,
291
311
  ...owned,
312
+ error: [
313
+ ClientNotFound,
314
+ ClientIsAutomatic,
315
+ ProviderRefused,
316
+ InternalServerError
317
+ ],
292
318
  input: ClientId,
293
319
  success: ClientCredentials
294
320
  }),
@@ -296,6 +322,11 @@ const Administration = [
296
322
  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
323
  readOnly: false,
298
324
  ...owned,
325
+ error: [
326
+ ClientNotFound,
327
+ ClientIsAutomatic,
328
+ InternalServerError
329
+ ],
299
330
  input: ClientAccessInput,
300
331
  success: ClientAccessResult
301
332
  }),
@@ -303,6 +334,7 @@ const Administration = [
303
334
  description: "Create an OAuth resource and its scopes.",
304
335
  readOnly: false,
305
336
  ...owned,
337
+ error: [ProviderRefused, InternalServerError],
306
338
  input: ResourceInput,
307
339
  success: Resource.pipe(HttpApiSchema.status(201))
308
340
  }),
@@ -310,6 +342,11 @@ const Administration = [
310
342
  description: "Update an OAuth resource and its scopes.",
311
343
  readOnly: false,
312
344
  ...owned,
345
+ error: [
346
+ ResourceNotFound,
347
+ ProviderRefused,
348
+ InternalServerError
349
+ ],
313
350
  input: ResourceInput,
314
351
  success: Resource
315
352
  }),
@@ -317,6 +354,7 @@ const Administration = [
317
354
  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
355
  readOnly: false,
319
356
  ...owned,
357
+ error: [ResourceNotFound, InternalServerError],
320
358
  input: ResourceId,
321
359
  success: Schema.Struct({ deleted: Schema.Boolean })
322
360
  }),
@@ -324,48 +362,52 @@ const Administration = [
324
362
  description: "List API key metadata without secret values.",
325
363
  readOnly: true,
326
364
  ...owned,
327
- success: Schema.Struct({ keys: Schema.Array(MachineKey) })
365
+ error: [InternalServerError],
366
+ success: Schema.Struct({ keys: Schema.Array(ApiKey) })
328
367
  }),
329
368
  Action.make("createApiKey", {
330
369
  description: "Create a scoped API key. Returns its secret once.",
331
370
  readOnly: false,
332
371
  ...owned,
372
+ error: [ProviderRefused, InternalServerError],
333
373
  input: ApiKeyInput,
334
- success: MachineKey.pipe(Schema.fieldsAssign({ key: Schema.String }), HttpApiSchema.status(201))
374
+ success: ApiKey.pipe(Schema.fieldsAssign({ key: Schema.String }), HttpApiSchema.status(201))
335
375
  }),
336
376
  Action.make("updateApiKey", {
337
377
  description: "Update an API key’s name, permissions or enabled state.",
338
378
  readOnly: false,
339
379
  ...owned,
380
+ error: [
381
+ ApiKeyNotFound,
382
+ ProviderRefused,
383
+ InternalServerError
384
+ ],
340
385
  input: ApiKeyUpdate,
341
- success: MachineKey
386
+ success: ApiKey
342
387
  }),
343
388
  Action.make("deleteApiKey", {
344
389
  description: "Delete an API key.",
345
390
  readOnly: false,
346
391
  ...owned,
392
+ error: [ApiKeyNotFound, InternalServerError],
347
393
  input: ApiKeyId,
348
394
  success: Schema.Struct({ deleted: Schema.Boolean })
349
395
  })
350
396
  ];
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
- */
397
+ [.../* @__PURE__ */ new Set([
398
+ ...[...IssuerActions, ...Administration].flatMap((action) => action.error),
399
+ ...undecided,
400
+ UnreadableRequest
401
+ ])];
356
402
  const binding = (authentication) => ActionHttp.make([...Administration, ...IssuerActions], { authentication });
357
403
  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
404
  const OwnerHttp = ActionHttp.make(Administration, {
363
405
  prefix: "/api/owner",
364
406
  authentication: OwnerToken
365
407
  });
366
408
  //#endregion
367
409
  //#region package.json
368
- var version = "0.22.0";
410
+ var version = "0.23.0";
369
411
  //#endregion
370
412
  //#region src/credentials.ts
371
413
  /** The kept sign-ins cannot be read or written. */
@@ -402,17 +444,8 @@ const Credentials = Schema.Struct({
402
444
  signIns: Schema.Array(SignIn)
403
445
  });
404
446
  const CredentialsJson = Schema.fromJsonString(Credentials);
405
- /** Where the sign-ins are kept: `$XDG_CONFIG_HOME/clankerauth`, or `~/.config/clankerauth`. */
406
447
  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
448
  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
449
  const writeCredentials = Effect.fn("Credentials.write")(function* (credentials) {
417
450
  const path = credentialsPath();
418
451
  const json = yield* Schema.encodeEffect(CredentialsJson)(credentials);
@@ -426,10 +459,6 @@ const writeCredentials = Effect.fn("Credentials.write")(function* (credentials)
426
459
  await rename(staging, path);
427
460
  }).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
428
461
  }, 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
462
  const saveSignIn = Effect.fn("Credentials.saveSignIn")(function* (issuer, signIn) {
434
463
  const current = Option.filter(yield* readCredentials(), ({ url }) => url === issuer.url);
435
464
  const base = Option.getOrElse(current, () => issuer);
@@ -451,20 +480,13 @@ const Server = Schema.Struct({
451
480
  registration_endpoint: Schema.String,
452
481
  revocation_endpoint: Schema.String
453
482
  });
454
- /** This CLI as the issuer knows it: a public client, authenticating with nothing. */
455
483
  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
484
  const transport = (url) => ({ [oauth.allowInsecureRequests]: new URL(url).protocol === "http:" });
458
485
  /** The RFC 9728 check's failure: the metadata names another resource than the one asked for. */
459
486
  const NamedElsewhere = Schema.Struct({
460
487
  attribute: Schema.Literal("resource"),
461
488
  body: Schema.Struct({ resource: Schema.String })
462
489
  });
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
490
  const reason = (error) => {
469
491
  if (error instanceof oauth.ResponseBodyError || error instanceof oauth.AuthorizationResponseError) return [error.error, error.error_description].filter(Boolean).join(": ");
470
492
  if (error instanceof oauth.OperationProcessingError) {
@@ -474,16 +496,10 @@ const reason = (error) => {
474
496
  }
475
497
  return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
476
498
  };
477
- /** Run one step of the protocol, failing with what the issuer, or the library, said. */
478
499
  const step = (what, run) => Effect.tryPromise({
479
500
  try: run,
480
501
  catch: (error) => new OAuthError({ message: `${what}: ${error instanceof Error ? reason(error) : String(error)}` })
481
502
  });
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
503
  const discover = Effect.fn("OAuth.discover")(function* (url) {
488
504
  const administration = new URL("/", url);
489
505
  const [named] = (yield* step(`Discovery at ${administration.origin}`, async () => oauth.processResourceDiscoveryResponse(administration, await oauth.resourceDiscoveryRequest(administration, transport(url))))).authorization_servers ?? [];
@@ -495,10 +511,6 @@ const discover = Effect.fn("OAuth.discover")(function* (url) {
495
511
  })));
496
512
  return yield* Schema.decodeUnknownEffect(Server)(server).pipe(Effect.mapError((error) => new OAuthError({ message: `Discovery: the issuer lacks an endpoint (${error.message})` })));
497
513
  });
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
514
  const register = (url, server, redirectUri) => step("Registration", async () => oauth.processDynamicClientRegistrationResponse(await oauth.dynamicClientRegistrationRequest(server, {
503
515
  client_name: "clankerauth CLI",
504
516
  redirect_uris: [redirectUri],
@@ -507,10 +519,26 @@ const register = (url, server, redirectUri) => step("Registration", async () =>
507
519
  grant_types: ["authorization_code", "refresh_token"],
508
520
  response_types: ["code"]
509
521
  }, 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
- */
522
+ const authorizationRequest = Effect.fn("OAuth.authorizationRequest")(function* (server, clientId, redirectUri, scope, resource) {
523
+ const verifier = oauth.generateRandomCodeVerifier();
524
+ const state = oauth.generateRandomState();
525
+ const url = new URL(server.authorization_endpoint);
526
+ url.search = new URLSearchParams({
527
+ client_id: clientId,
528
+ redirect_uri: redirectUri,
529
+ response_type: "code",
530
+ scope,
531
+ resource,
532
+ code_challenge: yield* Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)),
533
+ code_challenge_method: "S256",
534
+ state
535
+ }).toString();
536
+ return {
537
+ url,
538
+ state,
539
+ verifier
540
+ };
541
+ });
514
542
  const exchangeCode = (url, server, clientId, callback, expected, resource) => step("Sign-in", async () => {
515
543
  const parameters = oauth.validateAuthResponse(server, client(clientId), callback, expected.state);
516
544
  return oauth.processAuthorizationCodeResponse(server, client(clientId), await oauth.authorizationCodeGrantRequest(server, client(clientId), oauth.None(), parameters, expected.redirectUri, expected.verifier, {
@@ -518,45 +546,27 @@ const exchangeCode = (url, server, clientId, callback, expected, resource) => st
518
546
  ...transport(url)
519
547
  }));
520
548
  });
521
- /** Refresh the tokens of one sign-in: the refresh token rotates. */
522
549
  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
550
  additionalParameters: { resource },
524
551
  ...transport(credentials.url)
525
552
  })));
526
- /** Revoke a refresh token, and with it every token of its family. */
527
553
  const revoke = (credentials, refreshToken) => step("Revocation", async () => oauth.processRevocationResponse(await oauth.revocationRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
528
554
  additionalParameters: { token_type_hint: "refresh_token" },
529
555
  ...transport(credentials.url)
530
556
  })));
531
557
  //#endregion
532
558
  //#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. */
559
+ const administrationScope = `${administrationScopes.read} ${administrationScopes.write}`;
538
560
  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
561
  const loginHint = (url, resource, scope) => {
544
562
  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>"}"`}`;
563
+ const administration = resource === administrationIdentifier(url);
564
+ return `clankerauth login ${url}${administration ? "" : ` --resource ${resource}`}${administration && (asked === void 0 || asked === administrationScope) ? "" : ` --scope "${asked ?? "<scopes>"}"`}`;
547
565
  };
548
- /** The redirect a registration names; a sign-in adds the port it listens on. */
549
566
  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
567
  const approvalWindow = "5 minutes";
552
- /** When tokens issued now expire; an issuer that does not say is asked again next time. */
553
568
  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
569
  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
570
  const listen = Effect.acquireRelease(Effect.gen(function* () {
561
571
  const received = yield* Deferred.make();
562
572
  const server = createServer((request, response) => {
@@ -580,21 +590,14 @@ const listen = Effect.acquireRelease(Effect.gen(function* () {
580
590
  server.closeAllConnections();
581
591
  server.close();
582
592
  }));
583
- /** Revoke these sign-ins' refresh tokens, as far as the issuer can be reached. */
584
593
  const revokeAll = (credentials, signIns) => Effect.forEach(signIns, ({ refreshToken }) => Effect.ignore(revoke(credentials, refreshToken)), {
585
594
  concurrency: "unbounded",
586
595
  discard: true
587
596
  });
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
597
  const login = Effect.fn("Cli.login")(function* (url, target, asked) {
595
598
  const origin = new URL(url).origin;
596
599
  const server = yield* discover(origin);
597
- const resource = Option.getOrElse(target, () => administrationOf(origin));
600
+ const resource = Option.getOrElse(target, () => administrationIdentifier(origin));
598
601
  const kept = yield* readCredentials();
599
602
  const previous = Option.filter(kept, (credentials) => credentials.url === origin);
600
603
  const clientId = yield* Option.match(previous, {
@@ -603,20 +606,8 @@ const login = Effect.fn("Cli.login")(function* (url, target, asked) {
603
606
  });
604
607
  const { port, received } = yield* listen;
605
608
  const redirectUri = `http://127.0.0.1:${port}/callback`;
606
- const verifier = oauth.generateRandomCodeVerifier();
607
- const state = oauth.generateRandomState();
608
609
  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();
610
+ const { url: authorization, state, verifier } = yield* authorizationRequest(server, clientId, redirectUri, scope, resource);
620
611
  yield* Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${authorization.href}\n\n`));
621
612
  const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
622
613
  duration: approvalWindow,
@@ -655,10 +646,6 @@ const login = Effect.fn("Cli.login")(function* (url, target, asked) {
655
646
  scope: signIn.scope
656
647
  };
657
648
  }, 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
649
  const accessToken = Effect.fn("Cli.accessToken")(function* (credentials, resource) {
663
650
  const signIn = credentials.signIns.find((held) => held.resource === resource);
664
651
  if (signIn === void 0) return yield* new OAuthError({ message: `Not signed in to ${resource}: run \`${loginHint(credentials.url, resource)}\` first.` });
@@ -674,11 +661,6 @@ const accessToken = Effect.fn("Cli.accessToken")(function* (credentials, resourc
674
661
  yield* saveSignIn(credentials, refreshed);
675
662
  return refreshed.accessToken;
676
663
  });
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
664
  const logout = Effect.fn("Cli.logout")(function* (resource) {
683
665
  const stored = yield* readCredentials();
684
666
  if (Option.isNone(stored)) {
@@ -700,18 +682,15 @@ const logout = Effect.fn("Cli.logout")(function* (resource) {
700
682
  });
701
683
  //#endregion
702
684
  //#region src/main.ts
703
- /** A failure of this CLI's own, rendered as its message on stderr, exiting 1. */
704
685
  const refused = (error) => new CliError.UserError({
705
686
  cause: error,
706
687
  userMessage: error.message
707
688
  });
708
689
  const json = (value) => Console.log(JSON.stringify(value, null, 2));
709
- /** A resource identifier as the issuer keeps it: canonical, `https://notes.example/`. */
710
690
  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
691
  const scopeFor = Effect.fn("Cli.scopeFor")(function* (resource, scope) {
713
692
  if (Option.isSome(scope)) return scope.value;
714
- if (Option.isNone(resource)) return administrationScopes;
693
+ if (Option.isNone(resource)) return administrationScope;
715
694
  const message = `Name the scopes to ask ${resource.value} for with --scope.`;
716
695
  return yield* new CliError.UserError({
717
696
  cause: message,
@@ -721,10 +700,9 @@ const scopeFor = Effect.fn("Cli.scopeFor")(function* (resource, scope) {
721
700
  const loginCommand = Command.make("login", {
722
701
  url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, its CLANKERAUTH_BASE_URL")),
723
702
  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)
703
+ scope: Flag.String("scope").pipe(Flag.withDescription(`The scopes to ask for, space-separated: needed with --resource, and "${administrationScope}" for administration by default`), Flag.optional)
725
704
  }, ({ 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
705
  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
706
  const signedIn = Effect.gen(function* () {
729
707
  const stored = yield* readCredentials().pipe(Effect.mapError(refused));
730
708
  if (Option.isNone(stored)) return yield* new CliError.UserError({
@@ -733,22 +711,16 @@ const signedIn = Effect.gen(function* () {
733
711
  });
734
712
  return stored.value;
735
713
  });
736
- /** Prints the token alone, so a script takes the whole output as it. */
737
714
  const tokenCommand = Command.make("token", { resource: resourceFlag("The resource whose token to print; administration by default") }, ({ resource }) => Effect.gen(function* () {
738
715
  const credentials = yield* signedIn;
739
- const token = yield* accessToken(credentials, Option.getOrElse(resource, () => administrationOf(credentials.url))).pipe(Effect.mapError(refused));
716
+ const token = yield* accessToken(credentials, Option.getOrElse(resource, () => administrationIdentifier(credentials.url))).pipe(Effect.mapError(refused));
740
717
  yield* Effect.sync(() => process.stdout.write(`${token}\n`));
741
718
  })).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
719
  const administrationClient = Effect.gen(function* () {
747
720
  const credentials = yield* signedIn;
748
- const token = yield* accessToken(credentials, administrationOf(credentials.url)).pipe(Effect.mapError(refused));
721
+ const token = yield* accessToken(credentials, administrationIdentifier(credentials.url)).pipe(Effect.mapError(refused));
749
722
  return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(credentials.url), HttpClientRequest.bearerToken(token)));
750
723
  });
751
- /** Every administration action, as a command of its own, called over `/api/owner`. */
752
724
  const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, administrationClient)));
753
725
  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
726
  loginCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "The clankerauth command line: sign in to an issuer as its owner and administer it over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,17 +21,17 @@
21
21
  "dependencies": {
22
22
  "@effect/platform-node": "4.0.4",
23
23
  "@effect/platform-node-shared": "4.0.4",
24
- "@gjermundgaraba/effect-actions": "0.16.0",
24
+ "@gjermundgaraba/effect-actions": "0.17.0",
25
25
  "effect": "4.0.4",
26
26
  "oauth4webapi": "3.8.8"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@clankerauth/admin-api": "0.1.0",
30
- "@gjermundgaraba/clankerauth-dev": "0.22.0",
30
+ "@gjermundgaraba/clankerauth-dev": "0.23.0",
31
31
  "@types/node": "26.6.4",
32
32
  "typescript": "7.0.2",
33
- "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
34
- "vite-plus": "1.1.0"
33
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
34
+ "vite-plus": "1.2.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=26"