@gjermundgaraba/clankerauth 0.16.0 → 0.18.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 (3) hide show
  1. package/README.md +16 -2
  2. package/dist/main.mjs +255 -202
  3. package/package.json +7 -6
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @gjermundgaraba/clankerauth
2
2
 
3
- The `clankerauth` command: sign in to a [clankerauth](https://github.com/gjermundgaraba/clankerauth) issuer as its owner and run every administration action over HTTP. It needs Node 26 or newer and an issuer at 0.16.0 or later.
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
4
 
5
5
  ```sh
6
6
  npm install --global @gjermundgaraba/clankerauth
@@ -12,7 +12,21 @@ clankerauth create-api-key --name "notes sync" \
12
12
  clankerauth logout
13
13
  ```
14
14
 
15
- `login` discovers the issuer, registers the command as a native OAuth client the first time, and asks the owner to approve in the browser: it prints the approval URL on stderr, opens it unless `--no-browser`, and listens for the redirect on a loopback port. `--read-only` asks for `clankerauth:read` alone, which lists and never changes. The sign-in is kept in `$XDG_CONFIG_HOME/clankerauth/credentials.json`, `~/.config/clankerauth/credentials.json` by default, readable by its owner alone. A command refreshes the access token once it has expired and saves the rotated refresh token. `logout` revokes the sign-in and deletes the file.
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.
16
30
 
17
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:
18
32
 
package/dist/main.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Console, Context, Deferred, Effect, Match, Option, Schema } from "effect";
2
+ import { Console, Context, Deferred, Effect, Option, Schema } from "effect";
3
3
  import { Argument, CliError, Command, Flag } from "effect/cli";
4
4
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
5
5
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
@@ -8,12 +8,12 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
8
8
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
9
9
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
10
10
  import { HttpApiSchema, HttpApiSecurity } from "effect/http-api";
11
- import { createHash, randomBytes } from "node:crypto";
11
+ import { randomBytes } from "node:crypto";
12
12
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
14
  import { dirname, join } from "node:path";
15
- import { spawn } from "node:child_process";
16
15
  import { createServer } from "node:http";
16
+ import * as oauth from "oauth4webapi";
17
17
  //#region ../../packages/admin-api/dist/index.js
18
18
  var BadRequest = class extends Schema.TaggedError()("BadRequest", { error: Schema.String }, { httpApiStatus: 400 }) {};
19
19
  var NotFound = class extends Schema.TaggedError()("NotFound", { error: Schema.String }, { httpApiStatus: 404 }) {};
@@ -365,46 +365,53 @@ const OwnerHttp = ActionHttp.make(Administration, {
365
365
  });
366
366
  //#endregion
367
367
  //#region package.json
368
- var version = "0.16.0";
368
+ var version = "0.18.0";
369
369
  //#endregion
370
370
  //#region src/credentials.ts
371
- /** The kept sign-in cannot be read or written. */
371
+ /** The kept sign-ins cannot be read or written. */
372
372
  var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { message: Schema.String }) {};
373
373
  const failed = (what) => (error) => new CredentialsError({ message: `${what} ${credentialsPath()}: ${error.message}` });
374
374
  /**
375
- * One owner sign-in: the issuer it is for, the client it registered as, and its tokens. The
376
- * access token lasts fifteen minutes and the refresh token rotates on every use, so the file
377
- * is rewritten whenever a command refreshes.
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
378
  */
379
- const Credentials = Schema.Struct({
380
- /** The issuer's origin, `CLANKERAUTH_BASE_URL`: where `/api/owner/<action>` is served. */
381
- url: Schema.String,
382
- /** The administration resource, the token audience: the issuer's origin root. */
379
+ const SignIn = Schema.Struct({
380
+ /** The token audience: a resource identifier, such as `https://notes.home.example/`. */
383
381
  resource: Schema.String,
384
- tokenEndpoint: Schema.String,
385
- /** Where `logout` revokes the refresh token; null when the issuer publishes none. */
386
- revocationEndpoint: Schema.NullOr(Schema.String),
387
- clientId: Schema.String,
388
382
  scope: Schema.String,
389
383
  accessToken: Schema.String,
390
384
  refreshToken: Schema.String,
391
385
  /** When the access token expires, as an ISO instant. */
392
386
  expiresAt: Schema.String
393
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
+ });
394
404
  const CredentialsJson = Schema.fromJsonString(Credentials);
395
- /** Where the sign-in is kept: `$XDG_CONFIG_HOME/clankerauth`, or `~/.config/clankerauth`. */
405
+ /** Where the sign-ins are kept: `$XDG_CONFIG_HOME/clankerauth`, or `~/.config/clankerauth`. */
396
406
  const credentialsPath = () => join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "clankerauth", "credentials.json");
397
- /** The sign-in, if there is one. A file that does not decode is a failure, not a sign-out. */
398
- const readCredentials = Effect.fn("Credentials.read")(function* () {
399
- const text = yield* Effect.tryPromise(() => readFile(credentialsPath(), "utf8")).pipe(Effect.map(Option.some), Effect.catchIf((error) => error.cause instanceof Error && "code" in error.cause && error.cause.code === "ENOENT", () => Effect.succeed(Option.none())));
400
- return yield* Option.match(text, {
401
- onNone: () => Effect.succeedNone,
402
- onSome: (json) => Effect.map(Schema.decodeEffect(CredentialsJson)(json), Option.some)
403
- });
404
- }, Effect.mapError(failed("Cannot read")));
405
407
  /**
406
- * Replace the sign-in, readable by its owner alone. Written beside it and renamed over it, so
407
- * a command interrupted mid-write never leaves a refresh token half-saved.
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.
408
415
  */
409
416
  const writeCredentials = Effect.fn("Credentials.write")(function* (credentials) {
410
417
  const path = credentialsPath();
@@ -419,108 +426,136 @@ const writeCredentials = Effect.fn("Credentials.write")(function* (credentials)
419
426
  await rename(staging, path);
420
427
  }).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
421
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
+ });
422
441
  const removeCredentials = () => Effect.tryPromise(() => rm(credentialsPath(), { force: true })).pipe(Effect.mapError(failed("Cannot remove")));
423
442
  //#endregion
424
443
  //#region src/oauth.ts
425
- /** Something the issuer answered that a sign-in or a refresh cannot go on from. */
444
+ /** Something the issuer answered that a sign-in, a refresh or a revocation cannot go on from. */
426
445
  var OAuthError = class extends Schema.TaggedError()("OAuthError", { message: Schema.String }) {};
427
- /** RFC 9728: where a resource says its tokens come from. */
428
- const ProtectedResource = Schema.Struct({
429
- resource: Schema.String,
430
- authorization_servers: Schema.NonEmptyArray(Schema.String)
431
- });
432
- /** RFC 8414: the endpoints a sign-in uses. */
433
- const AuthorizationServer = Schema.Struct({
446
+ /** The issuer's RFC 8414 metadata a sign-in uses, every endpoint required. */
447
+ const Server = Schema.Struct({
434
448
  issuer: Schema.String,
435
449
  authorization_endpoint: Schema.String,
436
450
  token_endpoint: Schema.String,
437
451
  registration_endpoint: Schema.String,
438
- revocation_endpoint: Schema.optionalKey(Schema.String)
439
- });
440
- const Registration = Schema.Struct({ client_id: Schema.String });
441
- const Tokens = Schema.Struct({
442
- access_token: Schema.String,
443
- refresh_token: Schema.String,
444
- expires_in: Schema.Number,
445
- scope: Schema.optionalKey(Schema.String)
446
- });
447
- /** An OAuth error response's fields, when the issuer sent one. */
448
- const ErrorBody = Schema.Struct({
449
- error: Schema.String,
450
- error_description: Schema.optionalKey(Schema.String)
451
- });
452
- /** Send `request` and decode its JSON as `schema`, failing with what the issuer said. */
453
- const exchange = (what, schema, request) => Effect.gen(function* () {
454
- const response = yield* Effect.tryPromise({
455
- try: request,
456
- catch: (error) => new OAuthError({ message: `${what}: the issuer cannot be reached (${String(error)})` })
457
- });
458
- const text = yield* Effect.promise(() => response.text());
459
- if (!response.ok) return yield* new OAuthError({ message: `${what}: ${Option.match(Schema.decodeUnknownOption(Schema.fromJsonString(ErrorBody))(text), {
460
- onNone: () => `HTTP ${response.status}`,
461
- onSome: (body) => body.error_description === void 0 ? body.error : `${body.error}: ${body.error_description}`
462
- })}` });
463
- return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(text).pipe(Effect.mapError((error) => new OAuthError({ message: `${what}: unexpected answer (${error.message})` })));
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 })
464
462
  });
465
463
  /**
466
- * The issuer at `url` as a sign-in reaches it: its administration resource, published at the
467
- * origin root's RFC 9728 document, and the authorization server that resource names.
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.
468
486
  */
469
487
  const discover = Effect.fn("OAuth.discover")(function* (url) {
470
- const resource = yield* exchange("Discovery", ProtectedResource, () => fetch(new URL("/.well-known/oauth-protected-resource", url)));
471
- const issuer = new URL(resource.authorization_servers[0]);
472
- const metadata = new URL(`/.well-known/oauth-authorization-server${issuer.pathname === "/" ? "" : issuer.pathname}`, issuer);
473
- const server = yield* exchange("Discovery", AuthorizationServer, () => fetch(metadata));
474
- return {
475
- resource: resource.resource,
476
- server
477
- };
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})` })));
478
497
  });
479
498
  /**
480
499
  * Register this CLI as a public native client. The issuer matches a loopback redirect without
481
500
  * its port (RFC 8252), so one registration serves every port a sign-in listens on.
482
501
  */
483
- const register = Effect.fn("OAuth.register")(function* (server, redirectUri) {
484
- return (yield* exchange("Registration", Registration, () => fetch(server.registration_endpoint, {
485
- method: "POST",
486
- headers: { "content-type": "application/json" },
487
- body: JSON.stringify({
488
- client_name: "clankerauth CLI",
489
- redirect_uris: [redirectUri],
490
- application_type: "native",
491
- token_endpoint_auth_method: "none",
492
- grant_types: ["authorization_code", "refresh_token"],
493
- response_types: ["code"]
494
- })
495
- }))).client_id;
496
- });
497
- /** Ask the token endpoint for tokens, with the grant's own form fields. */
498
- const token = (what, endpoint, form) => exchange(what, Tokens, () => fetch(endpoint, {
499
- method: "POST",
500
- headers: { "content-type": "application/x-www-form-urlencoded" },
501
- body: form
502
- }));
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
+ })));
503
526
  /** Revoke a refresh token, and with it every token of its family. */
504
- const revoke = (endpoint, clientId, refreshToken) => Effect.tryPromise(() => fetch(endpoint, {
505
- method: "POST",
506
- headers: { "content-type": "application/x-www-form-urlencoded" },
507
- body: new URLSearchParams({
508
- token: refreshToken,
509
- token_type_hint: "refresh_token",
510
- client_id: clientId
511
- })
512
- }));
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
+ })));
513
531
  //#endregion
514
532
  //#region src/login.ts
515
- /** What the owner grants: everything, or only listing. */
516
- const scopes = (readOnly) => readOnly ? "clankerauth:read offline_access" : "clankerauth:read clankerauth:write offline_access";
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
+ };
517
548
  /** The redirect a registration names; a sign-in adds the port it listens on. */
518
549
  const registeredRedirect = "http://127.0.0.1/callback";
519
550
  /** How long a sign-in waits for the owner to approve it in the browser. */
520
551
  const approvalWindow = "5 minutes";
521
- const expiry = (tokens) => new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
522
- /** What the callback page tells the browser; the terminal has the details. */
523
- const page = (heading) => `<!doctype html><meta charset="utf-8"><title>clankerauth</title><p>${heading}</p>`;
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>";
524
559
  /** Listen on a free loopback port for the one request the authorization redirect makes. */
525
560
  const listen = Effect.acquireRelease(Effect.gen(function* () {
526
561
  const received = yield* Deferred.make();
@@ -530,15 +565,8 @@ const listen = Effect.acquireRelease(Effect.gen(function* () {
530
565
  response.writeHead(404).end();
531
566
  return;
532
567
  }
533
- const query = url.searchParams;
534
- response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page(query.has("code") ? "Signed in. You can close this tab." : "Sign-in failed. The terminal says why."));
535
- Deferred.doneUnsafe(received, Effect.succeed({
536
- code: query.get("code"),
537
- state: query.get("state"),
538
- iss: query.get("iss"),
539
- error: query.get("error"),
540
- errorDescription: query.get("error_description")
541
- }));
568
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page);
569
+ Deferred.doneUnsafe(received, Effect.succeed(url.searchParams));
542
570
  });
543
571
  return {
544
572
  server,
@@ -552,40 +580,32 @@ const listen = Effect.acquireRelease(Effect.gen(function* () {
552
580
  server.closeAllConnections();
553
581
  server.close();
554
582
  }));
555
- /** Open `url` in the default browser, if the platform has a way to; never fails. */
556
- const openBrowser = (url) => Effect.sync(() => {
557
- const [command, ...args] = Match.value(process.platform).pipe(Match.when("darwin", () => ["open", url]), Match.when("win32", () => [
558
- "cmd",
559
- "/c",
560
- "start",
561
- "",
562
- url
563
- ]), Match.orElse(() => ["xdg-open", url]));
564
- try {
565
- spawn(command ?? "open", args, {
566
- stdio: "ignore",
567
- detached: true
568
- }).on("error", () => {}).unref();
569
- } catch {}
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
570
587
  });
571
588
  /**
572
- * Sign in to the issuer at `url` as its owner: discover it, register this CLI once per
573
- * issuer, send the owner to approve in the browser, and keep the tokens. What it prints on
574
- * stderr is for the person; stdout gets only the result.
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.
575
593
  */
576
- const login = Effect.fn("Cli.login")(function* (url, readOnly, browser) {
594
+ const login = Effect.fn("Cli.login")(function* (url, target, asked) {
577
595
  const origin = new URL(url).origin;
578
- const { resource, server } = yield* discover(origin);
579
- const previous = yield* readCredentials();
580
- const clientId = yield* Option.match(Option.filter(previous, (credentials) => credentials.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, {
581
601
  onSome: (credentials) => Effect.succeed(credentials.clientId),
582
- onNone: () => register(server, registeredRedirect)
602
+ onNone: () => register(origin, server, registeredRedirect)
583
603
  });
584
604
  const { port, received } = yield* listen;
585
605
  const redirectUri = `http://127.0.0.1:${port}/callback`;
586
- const verifier = randomBytes(32).toString("base64url");
587
- const state = randomBytes(16).toString("base64url");
588
- const scope = scopes(readOnly);
606
+ const verifier = oauth.generateRandomCodeVerifier();
607
+ const state = oauth.generateRandomState();
608
+ const scope = withRefresh(asked);
589
609
  const authorization = new URL(server.authorization_endpoint);
590
610
  authorization.search = new URLSearchParams({
591
611
  client_id: clientId,
@@ -593,81 +613,90 @@ const login = Effect.fn("Cli.login")(function* (url, readOnly, browser) {
593
613
  response_type: "code",
594
614
  scope,
595
615
  resource,
596
- code_challenge: createHash("sha256").update(verifier).digest("base64url"),
616
+ code_challenge: yield* Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)),
597
617
  code_challenge_method: "S256",
598
618
  state
599
619
  }).toString();
600
- yield* Effect.sync(() => process.stderr.write(`Approve the sign-in in your browser:\n\n ${authorization.href}\n\n`));
601
- if (browser) yield* openBrowser(authorization.href);
620
+ yield* Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${authorization.href}\n\n`));
602
621
  const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
603
622
  duration: approvalWindow,
604
623
  orElse: () => Effect.fail(new OAuthError({ message: "Sign-in: nobody approved it within five minutes" }))
605
624
  }));
606
- if (callback.state !== state) return yield* new OAuthError({ message: "Sign-in: the redirect's state does not match" });
607
- if (callback.iss !== null && callback.iss !== server.issuer) return yield* new OAuthError({ message: `Sign-in: the redirect came from ${callback.iss}` });
608
- if (callback.code === null) return yield* new OAuthError({ message: `Sign-in: ${callback.error ?? "no code"}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}` });
609
- const tokens = yield* token("Sign-in", server.token_endpoint, new URLSearchParams({
610
- grant_type: "authorization_code",
611
- client_id: clientId,
612
- code: callback.code,
613
- code_verifier: verifier,
614
- redirect_uri: redirectUri,
615
- resource
616
- }));
617
- const credentials = {
618
- url: origin,
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 = {
619
632
  resource,
620
- tokenEndpoint: server.token_endpoint,
621
- revocationEndpoint: server.revocation_endpoint ?? null,
622
- clientId,
623
633
  scope: tokens.scope ?? scope,
624
634
  accessToken: tokens.access_token,
625
635
  refreshToken: tokens.refresh_token,
626
636
  expiresAt: expiry(tokens)
627
637
  };
628
- yield* writeCredentials(credentials);
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);
629
652
  return {
630
653
  url: origin,
631
- scope: credentials.scope
654
+ resource,
655
+ scope: signIn.scope
632
656
  };
633
657
  }, Effect.scoped);
634
658
  /**
635
- * The sign-in's access token, refreshed first when it has expired or is about to: the
636
- * refresh token rotates, so the new pair is saved before the token is used.
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.
637
661
  */
638
- const accessToken = Effect.fn("Cli.accessToken")(function* (credentials) {
639
- if (Date.parse(credentials.expiresAt) - Date.now() > 6e4) return {
640
- credentials,
641
- token: credentials.accessToken
642
- };
643
- const tokens = yield* token("Refresh", credentials.tokenEndpoint, new URLSearchParams({
644
- grant_type: "refresh_token",
645
- client_id: credentials.clientId,
646
- refresh_token: credentials.refreshToken,
647
- resource: credentials.resource
648
- })).pipe(Effect.mapError((error) => new OAuthError({ message: `${error.message}. Run \`clankerauth login ${credentials.url}\` again.` })));
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.` })));
649
667
  const refreshed = {
650
- ...credentials,
651
- scope: tokens.scope ?? credentials.scope,
668
+ ...signIn,
669
+ scope: tokens.scope ?? signIn.scope,
652
670
  accessToken: tokens.access_token,
653
- refreshToken: tokens.refresh_token,
671
+ refreshToken: tokens.refresh_token ?? signIn.refreshToken,
654
672
  expiresAt: expiry(tokens)
655
673
  };
656
- yield* writeCredentials(refreshed);
657
- return {
658
- credentials: refreshed,
659
- token: refreshed.accessToken
660
- };
674
+ yield* saveSignIn(credentials, refreshed);
675
+ return refreshed.accessToken;
661
676
  });
662
- /** Forget the sign-in, revoking its refresh token first when the issuer can be reached. */
663
- const logout = Effect.fn("Cli.logout")(function* () {
664
- const credentials = yield* readCredentials();
665
- yield* Option.match(credentials, {
666
- onNone: () => Effect.void,
667
- onSome: ({ revocationEndpoint, clientId, refreshToken }) => revocationEndpoint === null ? Effect.void : Effect.ignore(revoke(revocationEndpoint, clientId, refreshToken))
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
668
698
  });
669
- yield* removeCredentials();
670
- return { signedOut: Option.isSome(credentials) };
699
+ return { signedOut: leaving.length > 0 };
671
700
  });
672
701
  //#endregion
673
702
  //#region src/main.ts
@@ -677,30 +706,54 @@ const refused = (error) => new CliError.UserError({
677
706
  userMessage: error.message
678
707
  });
679
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
+ });
680
721
  const loginCommand = Command.make("login", {
681
- url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, such as https://auth.example.com")),
682
- readOnly: Flag.Boolean("read-only").pipe(Flag.withDescription("Ask for clankerauth:read alone: list, never change"), Flag.withDefault(false)),
683
- browser: Flag.Boolean("browser").pipe(Flag.withDescription("Open the approval page; --no-browser only prints its URL"), Flag.withDefault(true))
684
- }, ({ url, readOnly, browser }) => login(url, readOnly, browser).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Sign in to an issuer as its owner. The sign-in is kept in ~/.config/clankerauth."));
685
- const logoutCommand = Command.make("logout", {}, () => logout().pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Revoke the sign-in and forget it."));
686
- /**
687
- * The client every administration command calls through: the signed-in issuer's origin and
688
- * a current access token, refreshed and saved first when it has expired.
689
- */
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. */
690
728
  const signedIn = Effect.gen(function* () {
691
729
  const stored = yield* readCredentials().pipe(Effect.mapError(refused));
692
730
  if (Option.isNone(stored)) return yield* new CliError.UserError({
693
731
  cause: "not signed in",
694
732
  userMessage: "Not signed in: run `clankerauth login <url>` first."
695
733
  });
696
- const { credentials, token } = yield* accessToken(stored.value).pipe(Effect.mapError(refused));
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));
697
749
  return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(credentials.url), HttpClientRequest.bearerToken(token)));
698
750
  });
699
751
  /** Every administration action, as a command of its own, called over `/api/owner`. */
700
- const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, signedIn)));
701
- const cli = Command.make("clankerauth").pipe(Command.withDescription("Administer a clankerauth issuer as its owner. Results are JSON on stdout; a created key or client secret is in it once."), Command.withSubcommands([
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([
702
754
  loginCommand,
703
755
  logoutCommand,
756
+ tokenCommand,
704
757
  ...administration
705
758
  ]));
706
759
  Command.run(cli, { version }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeServices.layer), ActionCli.logToStderr, NodeRuntime.runMain);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth",
3
- "version": "0.16.0",
3
+ "version": "0.18.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": {
@@ -19,13 +19,14 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@effect/platform-node": "4.0.2",
23
- "@gjermundgaraba/effect-actions": "0.11.0",
24
- "effect": "4.0.2"
22
+ "@effect/platform-node": "4.0.3",
23
+ "@gjermundgaraba/effect-actions": "0.13.0",
24
+ "effect": "4.0.3",
25
+ "oauth4webapi": "3.8.8"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@clankerauth/admin-api": "0.1.0",
28
- "@gjermundgaraba/clankerauth-dev": "0.16.0",
29
+ "@gjermundgaraba/clankerauth-dev": "0.18.0",
29
30
  "@types/node": "26.6.4",
30
31
  "typescript": "7.0.2",
31
32
  "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
@@ -36,6 +37,6 @@
36
37
  },
37
38
  "scripts": {
38
39
  "build": "vp pack",
39
- "test": "vp test"
40
+ "test": "vp pack && vp test"
40
41
  }
41
42
  }