@gjermundgaraba/clankerauth 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +10 -9
  2. package/dist/main.mjs +60 -333
  3. package/package.json +6 -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, 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.
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.24.0 or later.
4
4
 
5
5
  ```sh
6
6
  npm install --global @gjermundgaraba/clankerauth
@@ -12,22 +12,23 @@ clankerauth create-api-key --name "notes sync" \
12
12
  clankerauth logout
13
13
  ```
14
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.
15
+ `login <url>` signs in to the resource `url` identifies, reading it from its `/.well-known/oauth-protected-resource`: the issuer's own origin, `CLANKERAUTH_BASE_URL`, publishes administration, and an application's URL publishes its resource. It then discovers the issuer that resource names; registers the command there as a native OAuth client, a new one for each sign-in; prints the approval URL on stderr for the owner to open; and listens for the redirect on a loopback port. It asks for every scope the resource publishes, `clankerauth:read clankerauth:write` for administration, unless `--scope` names fewer: `--scope clankerauth:read` lists and never changes. Beside them it asks for `offline_access`, alone for a resource that publishes no scopes. A resource must publish that document, and so be identified by an `http:` or `https:` URL, to be signed in to.
16
+
17
+ The sign-ins are kept in `$XDG_CONFIG_HOME/clankerauth/sign-ins.json`, `~/.config/clankerauth/sign-ins.json` by default, readable by their owner alone: one per resource, each with its own issuer, so signing in at a second issuer leaves the first's alone. A file the command cannot read, such as one another version wrote, counts as signed out and the next `login` replaces it. A `login` to a resource already signed in to replaces that sign-in and revokes it. A command refreshes an access token as it is about to expire and saves the rotated refresh token, so a sign-in lasts while it is used at least every 30 days; when the issuer refuses a refresh, the error names the `login` that asks for the same scopes again. Every change to the file holds its lock, so commands and programs running at once refresh an expired sign-in once, and `logout` revokes the refresh token that is current. `logout` revokes every sign-in and deletes the file, and `logout <url>` the one whose resource that URL is under alone.
18
+
19
+ Administration commands act at the issuer of the one administration sign-in kept. Signed in to administration at several issuers, name one with `CLANKERAUTH_URL`, its origin; a value that is no URL is refused.
16
20
 
17
21
  ## Tokens for other commands
18
22
 
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:
23
+ `token <url>` prints a current access token for the resource signed in to that the URL is under, the most specific when several are, and nothing else, refreshing it first when it is about to expire; without a URL it prints administration's. Another command, or a script, gets its bearer token from there instead of keeping a key; a program written with Effect uses `token` of [`@gjermundgaraba/clankerauth-sdk/credentials`](../../packages/sdk/README.md) instead, which reads and refreshes the same sign-in:
20
24
 
21
25
  ```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/)" \
26
+ clankerauth login https://notes.home.example
27
+ curl -H "authorization: Bearer $(clankerauth token https://notes.home.example/api/list-notes)" \
25
28
  https://notes.home.example/api/list-notes
26
- clankerauth logout --resource https://notes.home.example/
29
+ clankerauth logout https://notes.home.example
27
30
  ```
28
31
 
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
32
  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
 
33
34
  ```sh
package/dist/main.mjs CHANGED
@@ -1,19 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { Console, Context, Deferred, Effect, Option, Schema } from "effect";
2
+ import { Config, Console, Context, Effect, Option, Redacted, Schema } from "effect";
3
3
  import { Argument, CliError, Command, Flag } from "effect/cli";
4
- import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
4
+ import { FetchHttpClient, HttpClient } from "effect/http";
5
5
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
6
6
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
7
- import * as Action from "@gjermundgaraba/effect-actions/Action";
8
7
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
8
+ import * as Credentials from "@gjermundgaraba/clankerauth-sdk/credentials";
9
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
9
10
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
10
11
  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
12
  //#region ../../packages/admin-api/dist/index.js
18
13
  /**
19
14
  * This issuer's own fault: a failed query, an unreadable row, an unexpected provider failure.
@@ -89,10 +84,6 @@ const ownerSession = (cookie) => Authentication.make("clankerauth.OwnerSession",
89
84
  const OwnerSession = ownerSession("better-auth.session_token");
90
85
  const OwnerToken = Authentication.make("clankerauth.OwnerToken", CurrentOwner, { error: undecided });
91
86
  const administrationIdentifier = (origin) => new URL("/", origin).href;
92
- const administrationScopes = {
93
- read: "clankerauth:read",
94
- write: "clankerauth:write"
95
- };
96
87
  const passwordRule = `Passwords have 8 to 128 characters`;
97
88
  const SetupInput = Schema.Struct({
98
89
  email: Schema.String.check(Schema.isPattern(/\S/u, { message: "Setup requires an email" })),
@@ -161,6 +152,11 @@ const ResourceInput = Schema.Struct({
161
152
  scopes: Schema.Array(Schema.String.check(Schema.isPattern(/^\s*[\x21\x23-\x5B\x5D-\x7E]+\s*$/u, { message: "Resources require well-formed custom scopes" })))
162
153
  });
163
154
  const ResourceId = Schema.Struct({ identifier: Schema.String });
155
+ /** What `putResource` stored, and whether it created the resource rather than updating it. */
156
+ const PutResourceResult = Schema.Struct({
157
+ resource: Resource,
158
+ created: Schema.Boolean
159
+ });
164
160
  const ClientAccess = Schema.Struct({
165
161
  client_id: Schema.String,
166
162
  resource: Schema.String
@@ -330,25 +326,21 @@ const Administration = [
330
326
  input: ClientAccessInput,
331
327
  success: ClientAccessResult
332
328
  }),
333
- Action.make("createResource", {
334
- description: "Create an OAuth resource and its scopes.",
335
- readOnly: false,
329
+ Action.make("getResource", {
330
+ description: "Get one resource and its scopes by identifier.",
331
+ readOnly: true,
336
332
  ...owned,
337
- error: [ProviderRefused, InternalServerError],
338
- input: ResourceInput,
339
- success: Resource.pipe(HttpApiSchema.status(201))
333
+ error: [ResourceNotFound, InternalServerError],
334
+ input: ResourceId,
335
+ success: ResourceSummary
340
336
  }),
341
- Action.make("updateResource", {
342
- description: "Update an OAuth resource and its scopes.",
337
+ Action.make("putResource", {
338
+ description: "Create an OAuth resource, or update the one with its identifier, to exactly these name and scopes.",
343
339
  readOnly: false,
344
340
  ...owned,
345
- error: [
346
- ResourceNotFound,
347
- ProviderRefused,
348
- InternalServerError
349
- ],
341
+ error: [ProviderRefused, InternalServerError],
350
342
  input: ResourceInput,
351
- success: Resource
343
+ success: PutResourceResult
352
344
  }),
353
345
  Action.make("deleteResource", {
354
346
  description: "Delete a resource, its client access and every client’s authorization for it. API-key permissions are retained; recreating the resource restores them.",
@@ -407,279 +399,7 @@ const OwnerHttp = ActionHttp.make(Administration, {
407
399
  });
408
400
  //#endregion
409
401
  //#region package.json
410
- var version = "0.23.1";
411
- //#endregion
412
- //#region src/credentials.ts
413
- /** The kept sign-ins cannot be read or written. */
414
- var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { message: Schema.String }) {};
415
- const failed = (what) => (error) => new CredentialsError({ message: `${what} ${credentialsPath()}: ${error.message}` });
416
- /**
417
- * The owner's authorization for one resource: administration or an application's. The
418
- * access token lasts fifteen minutes and the refresh token rotates on every use, so the
419
- * file is rewritten whenever a command refreshes.
420
- */
421
- const SignIn = Schema.Struct({
422
- /** The token audience: a resource identifier, such as `https://notes.home.example/`. */
423
- resource: Schema.String,
424
- scope: Schema.String,
425
- accessToken: Schema.String,
426
- refreshToken: Schema.String,
427
- /** When the access token expires, as an ISO instant. */
428
- expiresAt: Schema.String
429
- });
430
- /**
431
- * The issuer signed in to, the client this CLI registered there, and one sign-in per
432
- * resource. `server` is the part of the issuer's RFC 8414 metadata a refresh and a
433
- * revocation need, under its RFC names.
434
- */
435
- const Credentials = Schema.Struct({
436
- /** The issuer's origin, `CLANKERAUTH_BASE_URL`: where `/api/owner/<action>` is served. */
437
- url: Schema.String,
438
- server: Schema.Struct({
439
- issuer: Schema.String,
440
- token_endpoint: Schema.String,
441
- revocation_endpoint: Schema.String
442
- }),
443
- clientId: Schema.String,
444
- signIns: Schema.Array(SignIn)
445
- });
446
- const CredentialsJson = Schema.fromJsonString(Credentials);
447
- const credentialsPath = () => join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "clankerauth", "credentials.json");
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")));
449
- const writeCredentials = Effect.fn("Credentials.write")(function* (credentials) {
450
- const path = credentialsPath();
451
- const json = yield* Schema.encodeEffect(CredentialsJson)(credentials);
452
- const staging = `${path}.${randomBytes(6).toString("hex")}`;
453
- yield* Effect.tryPromise(async () => {
454
- await mkdir(dirname(path), {
455
- recursive: true,
456
- mode: 448
457
- });
458
- await writeFile(staging, `${json}\n`, { mode: 384 });
459
- await rename(staging, path);
460
- }).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
461
- }, Effect.mapError(failed("Cannot write")));
462
- const saveSignIn = Effect.fn("Credentials.saveSignIn")(function* (issuer, signIn) {
463
- const current = Option.filter(yield* readCredentials(), ({ url }) => url === issuer.url);
464
- const base = Option.getOrElse(current, () => issuer);
465
- yield* writeCredentials({
466
- ...base,
467
- signIns: [...base.signIns.filter(({ resource }) => resource !== signIn.resource), signIn]
468
- });
469
- });
470
- const removeCredentials = () => Effect.tryPromise(() => rm(credentialsPath(), { force: true })).pipe(Effect.mapError(failed("Cannot remove")));
471
- //#endregion
472
- //#region src/oauth.ts
473
- /** Something the issuer answered that a sign-in, a refresh or a revocation cannot go on from. */
474
- var OAuthError = class extends Schema.TaggedError()("OAuthError", { message: Schema.String }) {};
475
- /** The issuer's RFC 8414 metadata a sign-in uses, every endpoint required. */
476
- const Server = Schema.Struct({
477
- issuer: Schema.String,
478
- authorization_endpoint: Schema.String,
479
- token_endpoint: Schema.String,
480
- registration_endpoint: Schema.String,
481
- revocation_endpoint: Schema.String
482
- });
483
- const client = (clientId) => ({ client_id: clientId });
484
- const transport = (url) => ({ [oauth.allowInsecureRequests]: new URL(url).protocol === "http:" });
485
- /** The RFC 9728 check's failure: the metadata names another resource than the one asked for. */
486
- const NamedElsewhere = Schema.Struct({
487
- attribute: Schema.Literal("resource"),
488
- body: Schema.Struct({ resource: Schema.String })
489
- });
490
- const reason = (error) => {
491
- if (error instanceof oauth.ResponseBodyError || error instanceof oauth.AuthorizationResponseError) return [error.error, error.error_description].filter(Boolean).join(": ");
492
- if (error instanceof oauth.OperationProcessingError) {
493
- const named = Schema.decodeUnknownOption(NamedElsewhere)(error.cause);
494
- if (Option.isSome(named)) return `the issuer is at ${new URL(named.value.body.resource).origin}, its CLANKERAUTH_BASE_URL: sign in there`;
495
- if (error.cause instanceof Response) return `${error.message} (HTTP ${error.cause.status})`;
496
- }
497
- return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
498
- };
499
- const step = (what, run) => Effect.tryPromise({
500
- try: run,
501
- catch: (error) => new OAuthError({ message: `${what}: ${error instanceof Error ? reason(error) : String(error)}` })
502
- });
503
- const discover = Effect.fn("OAuth.discover")(function* (url) {
504
- const administration = new URL("/", url);
505
- const [named] = (yield* step(`Discovery at ${administration.origin}`, async () => oauth.processResourceDiscoveryResponse(administration, await oauth.resourceDiscoveryRequest(administration, transport(url))))).authorization_servers ?? [];
506
- if (named === void 0) return yield* new OAuthError({ message: "Discovery: the issuer names no authorization server" });
507
- const issuer = new URL(named);
508
- const server = yield* step("Discovery", async () => oauth.processDiscoveryResponse(issuer, await oauth.discoveryRequest(issuer, {
509
- algorithm: "oauth2",
510
- ...transport(url)
511
- })));
512
- return yield* Schema.decodeUnknownEffect(Server)(server).pipe(Effect.mapError((error) => new OAuthError({ message: `Discovery: the issuer lacks an endpoint (${error.message})` })));
513
- });
514
- const register = (url, server, redirectUri) => step("Registration", async () => oauth.processDynamicClientRegistrationResponse(await oauth.dynamicClientRegistrationRequest(server, {
515
- client_name: "clankerauth CLI",
516
- redirect_uris: [redirectUri],
517
- application_type: "native",
518
- token_endpoint_auth_method: "none",
519
- grant_types: ["authorization_code", "refresh_token"],
520
- response_types: ["code"]
521
- }, transport(url)))).pipe(Effect.map((registered) => registered.client_id));
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
- });
542
- const exchangeCode = (url, server, clientId, callback, expected, resource) => step("Sign-in", async () => {
543
- const parameters = oauth.validateAuthResponse(server, client(clientId), callback, expected.state);
544
- return oauth.processAuthorizationCodeResponse(server, client(clientId), await oauth.authorizationCodeGrantRequest(server, client(clientId), oauth.None(), parameters, expected.redirectUri, expected.verifier, {
545
- additionalParameters: { resource },
546
- ...transport(url)
547
- }));
548
- });
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, {
550
- additionalParameters: { resource },
551
- ...transport(credentials.url)
552
- })));
553
- const revoke = (credentials, refreshToken) => step("Revocation", async () => oauth.processRevocationResponse(await oauth.revocationRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
554
- additionalParameters: { token_type_hint: "refresh_token" },
555
- ...transport(credentials.url)
556
- })));
557
- //#endregion
558
- //#region src/login.ts
559
- const administrationScope = `${administrationScopes.read} ${administrationScopes.write}`;
560
- const withRefresh = (scope) => scope.split(" ").includes("offline_access") ? scope : `${scope} offline_access`;
561
- const loginHint = (url, resource, scope) => {
562
- const asked = scope?.split(" ").filter((granted) => granted !== "offline_access").join(" ");
563
- const administration = resource === administrationIdentifier(url);
564
- return `clankerauth login ${url}${administration ? "" : ` --resource ${resource}`}${administration && (asked === void 0 || asked === administrationScope) ? "" : ` --scope "${asked ?? "<scopes>"}"`}`;
565
- };
566
- const registeredRedirect = "http://127.0.0.1/callback";
567
- const approvalWindow = "5 minutes";
568
- const expiry = (tokens) => new Date(Date.now() + (tokens.expires_in ?? 0) * 1e3).toISOString();
569
- const page = "<!doctype html><meta charset=\"utf-8\"><title>clankerauth</title><p>Return to the terminal.</p>";
570
- const listen = Effect.acquireRelease(Effect.gen(function* () {
571
- const received = yield* Deferred.make();
572
- const server = createServer((request, response) => {
573
- const url = new URL(request.url ?? "/", "http://127.0.0.1");
574
- if (url.pathname !== "/callback") {
575
- response.writeHead(404).end();
576
- return;
577
- }
578
- response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page);
579
- Deferred.doneUnsafe(received, Effect.succeed(url.searchParams));
580
- });
581
- return {
582
- server,
583
- port: yield* Effect.callback((resume) => {
584
- server.once("error", (error) => resume(Effect.fail(new OAuthError({ message: `Cannot listen for the redirect: ${error.message}` }))));
585
- server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server.address().port)));
586
- }),
587
- received
588
- };
589
- }), ({ server }) => Effect.sync(() => {
590
- server.closeAllConnections();
591
- server.close();
592
- }));
593
- const revokeAll = (credentials, signIns) => Effect.forEach(signIns, ({ refreshToken }) => Effect.ignore(revoke(credentials, refreshToken)), {
594
- concurrency: "unbounded",
595
- discard: true
596
- });
597
- const login = Effect.fn("Cli.login")(function* (url, target, asked) {
598
- const origin = new URL(url).origin;
599
- const server = yield* discover(origin);
600
- const resource = Option.getOrElse(target, () => administrationIdentifier(origin));
601
- const kept = yield* readCredentials();
602
- const previous = Option.filter(kept, (credentials) => credentials.url === origin);
603
- const clientId = yield* Option.match(previous, {
604
- onSome: (credentials) => Effect.succeed(credentials.clientId),
605
- onNone: () => register(origin, server, registeredRedirect)
606
- });
607
- const { port, received } = yield* listen;
608
- const redirectUri = `http://127.0.0.1:${port}/callback`;
609
- const scope = withRefresh(asked);
610
- const { url: authorization, state, verifier } = yield* authorizationRequest(server, clientId, redirectUri, scope, resource);
611
- yield* Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${authorization.href}\n\n`));
612
- const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
613
- duration: approvalWindow,
614
- orElse: () => Effect.fail(new OAuthError({ message: "Sign-in: nobody approved it within five minutes" }))
615
- }));
616
- const tokens = yield* exchangeCode(origin, server, clientId, callback, {
617
- state,
618
- verifier,
619
- redirectUri
620
- }, resource);
621
- if (tokens.refresh_token === void 0) return yield* new OAuthError({ message: "Sign-in: the issuer issued no refresh token" });
622
- const signIn = {
623
- resource,
624
- scope: tokens.scope ?? scope,
625
- accessToken: tokens.access_token,
626
- refreshToken: tokens.refresh_token,
627
- expiresAt: expiry(tokens)
628
- };
629
- if (Option.isSome(kept) && Option.isNone(previous)) {
630
- yield* revokeAll(kept.value, kept.value.signIns);
631
- yield* removeCredentials();
632
- }
633
- yield* saveSignIn(Option.getOrElse(previous, () => ({
634
- url: origin,
635
- server: {
636
- issuer: server.issuer,
637
- token_endpoint: server.token_endpoint,
638
- revocation_endpoint: server.revocation_endpoint
639
- },
640
- clientId,
641
- signIns: []
642
- })), signIn);
643
- return {
644
- url: origin,
645
- resource,
646
- scope: signIn.scope
647
- };
648
- }, Effect.scoped);
649
- const accessToken = Effect.fn("Cli.accessToken")(function* (credentials, resource) {
650
- const signIn = credentials.signIns.find((held) => held.resource === resource);
651
- if (signIn === void 0) return yield* new OAuthError({ message: `Not signed in to ${resource}: run \`${loginHint(credentials.url, resource)}\` first.` });
652
- if (Date.parse(signIn.expiresAt) - Date.now() > 6e4) return signIn.accessToken;
653
- 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.` })));
654
- const refreshed = {
655
- ...signIn,
656
- scope: tokens.scope ?? signIn.scope,
657
- accessToken: tokens.access_token,
658
- refreshToken: tokens.refresh_token ?? signIn.refreshToken,
659
- expiresAt: expiry(tokens)
660
- };
661
- yield* saveSignIn(credentials, refreshed);
662
- return refreshed.accessToken;
663
- });
664
- const logout = Effect.fn("Cli.logout")(function* (resource) {
665
- const stored = yield* readCredentials();
666
- if (Option.isNone(stored)) {
667
- yield* removeCredentials();
668
- return { signedOut: false };
669
- }
670
- const credentials = stored.value;
671
- const leaving = credentials.signIns.filter((signIn) => Option.match(resource, {
672
- onNone: () => true,
673
- onSome: (named) => signIn.resource === named
674
- }));
675
- const staying = credentials.signIns.filter((signIn) => !leaving.includes(signIn));
676
- yield* revokeAll(credentials, leaving);
677
- yield* staying.length === 0 ? removeCredentials() : writeCredentials({
678
- ...credentials,
679
- signIns: staying
680
- });
681
- return { signedOut: leaving.length > 0 };
682
- });
402
+ var version = "0.24.0";
683
403
  //#endregion
684
404
  //#region src/main.ts
685
405
  const refused = (error) => new CliError.UserError({
@@ -687,41 +407,48 @@ const refused = (error) => new CliError.UserError({
687
407
  userMessage: error.message
688
408
  });
689
409
  const json = (value) => Console.log(JSON.stringify(value, null, 2));
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);
691
- const scopeFor = Effect.fn("Cli.scopeFor")(function* (resource, scope) {
692
- if (Option.isSome(scope)) return scope.value;
693
- if (Option.isNone(resource)) return administrationScope;
694
- const message = `Name the scopes to ask ${resource.value} for with --scope.`;
695
- return yield* new CliError.UserError({
696
- cause: message,
697
- userMessage: message
698
- });
699
- });
410
+ const urlArgument = (description) => Argument.String("url").pipe(Argument.withDescription(description), Argument.optional);
411
+ const approve = (page) => Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${page.href}\n\n`));
700
412
  const loginCommand = Command.make("login", {
701
- url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, its CLANKERAUTH_BASE_URL")),
702
- resource: resourceFlag("Sign in to this resource instead of administration, for `token` to use"),
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)
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."));
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."));
706
- const signedIn = Effect.gen(function* () {
707
- const stored = yield* readCredentials().pipe(Effect.mapError(refused));
708
- if (Option.isNone(stored)) return yield* new CliError.UserError({
709
- cause: "not signed in",
710
- userMessage: "Not signed in: run `clankerauth login <url>` first."
413
+ url: Argument.String("url").pipe(Argument.withDescription("The resource to sign in to: an application's URL, such as https://notes.example, or the issuer's origin, its CLANKERAUTH_BASE_URL, for administration")),
414
+ scope: Flag.String("scope").pipe(Flag.withDescription("The scopes to ask for, space-separated: every scope the resource publishes by default"), Flag.optional)
415
+ }, ({ url, scope }) => Credentials.signIn(url, {
416
+ scope: Option.getOrUndefined(scope),
417
+ approve
418
+ }).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Sign in as the issuer's owner to a resource: approve the URL it prints in the browser. The resource's issuer and scopes are read from its /.well-known/oauth-protected-resource. Sign-ins are kept in ~/.config/clankerauth, each beside the others, whatever its issuer."));
419
+ const logoutCommand = Command.make("logout", { url: urlArgument("Sign out of the resource this URL is under alone") }, ({ url }) => Credentials.signOut(Option.getOrUndefined(url)).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Revoke the sign-ins, or the one to the resource a URL is under, and forget them."));
420
+ const notSignedIn = new CliError.UserError({
421
+ cause: "not signed in",
422
+ userMessage: "Not signed in: run `clankerauth login <url>` first."
423
+ });
424
+ const administrationSignIn = Effect.gen(function* () {
425
+ const named = yield* Config.option(Config.String("CLANKERAUTH_URL")).pipe(Effect.mapError(refused));
426
+ if (Option.exists(named, (url) => !URL.canParse(url))) return yield* new CliError.UserError({
427
+ cause: "CLANKERAUTH_URL",
428
+ userMessage: "CLANKERAUTH_URL is no URL: set it to the issuer's origin, its CLANKERAUTH_BASE_URL."
711
429
  });
712
- return stored.value;
713
- });
714
- const tokenCommand = Command.make("token", { resource: resourceFlag("The resource whose token to print; administration by default") }, ({ resource }) => Effect.gen(function* () {
715
- const credentials = yield* signedIn;
716
- const token = yield* accessToken(credentials, Option.getOrElse(resource, () => administrationIdentifier(credentials.url))).pipe(Effect.mapError(refused));
717
- yield* Effect.sync(() => process.stdout.write(`${token}\n`));
718
- })).pipe(Command.withDescription("Print a current access token for a resource signed in to, refreshing it first when it has expired."));
719
- const administrationClient = Effect.gen(function* () {
720
- const credentials = yield* signedIn;
721
- const token = yield* accessToken(credentials, administrationIdentifier(credentials.url)).pipe(Effect.mapError(refused));
722
- return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(credentials.url), HttpClientRequest.bearerToken(token)));
723
- });
724
- const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, administrationClient)));
430
+ const kept = (yield* Credentials.list().pipe(Effect.mapError(refused))).filter((signIn) => signIn.resource === administrationIdentifier(signIn.url)).filter((signIn) => Option.match(named, {
431
+ onNone: () => true,
432
+ onSome: (url) => new URL(url).origin === signIn.url
433
+ }));
434
+ const [only, ...others] = kept;
435
+ if (only === void 0) return yield* notSignedIn;
436
+ if (others.length > 0) return yield* new CliError.UserError({
437
+ cause: "several issuers",
438
+ userMessage: `Signed in to administration at ${kept.map((signIn) => signIn.url).join(", ")}: name one with CLANKERAUTH_URL.`
439
+ });
440
+ return only;
441
+ });
442
+ const tokenCommand = Command.make("token", { url: urlArgument("An address under the resource whose token to print, such as the application's URL; administration by default") }, ({ url }) => Effect.gen(function* () {
443
+ const named = Option.isSome(url) ? url.value : (yield* administrationSignIn).resource;
444
+ const token = yield* Credentials.token({ url: named }).pipe(Effect.mapError(refused));
445
+ yield* Effect.sync(() => process.stdout.write(`${Redacted.value(token)}\n`));
446
+ })).pipe(Command.withDescription("Print a current access token for the resource signed in to that a URL is under, refreshing it first when it has expired."));
447
+ const administrationClient = Effect.flatMap(administrationSignIn, (signIn) => ActionHttp.client(OwnerHttp, {
448
+ baseUrl: signIn.url,
449
+ transformClient: (client) => HttpClient.catchTag(Credentials.authorizing({ url: signIn.resource })(client), ["SignInRequired", "TokenUnavailable"], (error) => Effect.fail(refused(error)))
450
+ }));
451
+ const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action, { client: administrationClient }));
725
452
  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([
726
453
  loginCommand,
727
454
  logoutCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth",
3
- "version": "0.23.1",
3
+ "version": "0.24.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,13 +21,13 @@
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.17.0",
25
- "effect": "4.0.4",
26
- "oauth4webapi": "3.8.8"
24
+ "@gjermundgaraba/clankerauth-sdk": "0.24.0",
25
+ "@gjermundgaraba/effect-actions": "0.18.0",
26
+ "effect": "4.0.4"
27
27
  },
28
28
  "devDependencies": {
29
- "@clankerauth/admin-api": "0.1.0",
30
- "@gjermundgaraba/clankerauth-dev": "0.23.1",
29
+ "@gjermundgaraba/clankerauth-admin-api": "0.24.0",
30
+ "@gjermundgaraba/clankerauth-dev": "0.24.0",
31
31
  "@types/node": "26.6.4",
32
32
  "typescript": "7.0.2",
33
33
  "vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",