@gjermundgaraba/clankerauth 0.23.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -9
- package/dist/main.mjs +76 -333
- package/package.json +11 -11
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.
|
|
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
|
|
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; opens the approval page in the browser and prints its URL on stderr, for a terminal with no browser to open, or alone with `--no-browser`; 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
|
-
`
|
|
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://
|
|
23
|
-
|
|
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
|
|
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,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { Config, Console, Context, Effect, Option, Redacted, Schema } from "effect";
|
|
3
4
|
import { Argument, CliError, Command, Flag } from "effect/cli";
|
|
4
|
-
import { FetchHttpClient, HttpClient
|
|
5
|
+
import { FetchHttpClient, HttpClient } from "effect/http";
|
|
5
6
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
6
7
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
7
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
8
8
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
9
|
+
import * as Credentials from "@gjermundgaraba/clankerauth-sdk/credentials";
|
|
10
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
9
11
|
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
10
12
|
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
13
|
//#region ../../packages/admin-api/dist/index.js
|
|
18
14
|
/**
|
|
19
15
|
* This issuer's own fault: a failed query, an unreadable row, an unexpected provider failure.
|
|
@@ -89,10 +85,6 @@ const ownerSession = (cookie) => Authentication.make("clankerauth.OwnerSession",
|
|
|
89
85
|
const OwnerSession = ownerSession("better-auth.session_token");
|
|
90
86
|
const OwnerToken = Authentication.make("clankerauth.OwnerToken", CurrentOwner, { error: undecided });
|
|
91
87
|
const administrationIdentifier = (origin) => new URL("/", origin).href;
|
|
92
|
-
const administrationScopes = {
|
|
93
|
-
read: "clankerauth:read",
|
|
94
|
-
write: "clankerauth:write"
|
|
95
|
-
};
|
|
96
88
|
const passwordRule = `Passwords have 8 to 128 characters`;
|
|
97
89
|
const SetupInput = Schema.Struct({
|
|
98
90
|
email: Schema.String.check(Schema.isPattern(/\S/u, { message: "Setup requires an email" })),
|
|
@@ -161,6 +153,11 @@ const ResourceInput = Schema.Struct({
|
|
|
161
153
|
scopes: Schema.Array(Schema.String.check(Schema.isPattern(/^\s*[\x21\x23-\x5B\x5D-\x7E]+\s*$/u, { message: "Resources require well-formed custom scopes" })))
|
|
162
154
|
});
|
|
163
155
|
const ResourceId = Schema.Struct({ identifier: Schema.String });
|
|
156
|
+
/** What `putResource` stored, and whether it created the resource rather than updating it. */
|
|
157
|
+
const PutResourceResult = Schema.Struct({
|
|
158
|
+
resource: Resource,
|
|
159
|
+
created: Schema.Boolean
|
|
160
|
+
});
|
|
164
161
|
const ClientAccess = Schema.Struct({
|
|
165
162
|
client_id: Schema.String,
|
|
166
163
|
resource: Schema.String
|
|
@@ -330,25 +327,21 @@ const Administration = [
|
|
|
330
327
|
input: ClientAccessInput,
|
|
331
328
|
success: ClientAccessResult
|
|
332
329
|
}),
|
|
333
|
-
Action.make("
|
|
334
|
-
description: "
|
|
335
|
-
readOnly:
|
|
330
|
+
Action.make("getResource", {
|
|
331
|
+
description: "Get one resource and its scopes by identifier.",
|
|
332
|
+
readOnly: true,
|
|
336
333
|
...owned,
|
|
337
|
-
error: [
|
|
338
|
-
input:
|
|
339
|
-
success:
|
|
334
|
+
error: [ResourceNotFound, InternalServerError],
|
|
335
|
+
input: ResourceId,
|
|
336
|
+
success: ResourceSummary
|
|
340
337
|
}),
|
|
341
|
-
Action.make("
|
|
342
|
-
description: "
|
|
338
|
+
Action.make("putResource", {
|
|
339
|
+
description: "Create an OAuth resource, or update the one with its identifier, to exactly these name and scopes.",
|
|
343
340
|
readOnly: false,
|
|
344
341
|
...owned,
|
|
345
|
-
error: [
|
|
346
|
-
ResourceNotFound,
|
|
347
|
-
ProviderRefused,
|
|
348
|
-
InternalServerError
|
|
349
|
-
],
|
|
342
|
+
error: [ProviderRefused, InternalServerError],
|
|
350
343
|
input: ResourceInput,
|
|
351
|
-
success:
|
|
344
|
+
success: PutResourceResult
|
|
352
345
|
}),
|
|
353
346
|
Action.make("deleteResource", {
|
|
354
347
|
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 +400,7 @@ const OwnerHttp = ActionHttp.make(Administration, {
|
|
|
407
400
|
});
|
|
408
401
|
//#endregion
|
|
409
402
|
//#region package.json
|
|
410
|
-
var version = "0.
|
|
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
|
-
});
|
|
403
|
+
var version = "0.25.0";
|
|
683
404
|
//#endregion
|
|
684
405
|
//#region src/main.ts
|
|
685
406
|
const refused = (error) => new CliError.UserError({
|
|
@@ -687,41 +408,63 @@ const refused = (error) => new CliError.UserError({
|
|
|
687
408
|
userMessage: error.message
|
|
688
409
|
});
|
|
689
410
|
const json = (value) => Console.log(JSON.stringify(value, null, 2));
|
|
690
|
-
const
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
411
|
+
const urlArgument = (description) => Argument.String("url").pipe(Argument.withDescription(description), Argument.optional);
|
|
412
|
+
const browserOpener = (url) => {
|
|
413
|
+
switch (process.platform) {
|
|
414
|
+
case "darwin": return ["open", [url]];
|
|
415
|
+
case "win32": return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
416
|
+
default: return ["xdg-open", [url]];
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const openInBrowser = (page) => Effect.sync(() => {
|
|
420
|
+
const [command, args] = browserOpener(page.href);
|
|
421
|
+
spawn(command, args, {
|
|
422
|
+
stdio: "ignore",
|
|
423
|
+
detached: true
|
|
424
|
+
}).on("error", () => void 0).unref();
|
|
425
|
+
});
|
|
426
|
+
const approve = (browser) => (page) => browser ? Effect.andThen(Effect.sync(() => process.stderr.write(`Approve the sign-in in your browser. If it did not open, open this URL:\n\n ${page.href}\n\n`)), openInBrowser(page)) : Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${page.href}\n\n`));
|
|
700
427
|
const loginCommand = Command.make("login", {
|
|
701
|
-
url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, its CLANKERAUTH_BASE_URL")),
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}, ({ url,
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
428
|
+
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")),
|
|
429
|
+
scope: Flag.String("scope").pipe(Flag.withDescription("The scopes to ask for, space-separated: every scope the resource publishes by default"), Flag.optional),
|
|
430
|
+
noBrowser: Flag.Boolean("no-browser").pipe(Flag.withDescription("Print the approval URL without opening it in the browser"), Flag.withDefault(false))
|
|
431
|
+
}, ({ url, scope, noBrowser }) => Credentials.signIn(url, {
|
|
432
|
+
scope: Option.getOrUndefined(scope),
|
|
433
|
+
approve: approve(!noBrowser)
|
|
434
|
+
}).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Sign in as the issuer's owner to a resource: approve the page it opens in the browser, or the URL it prints. 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."));
|
|
435
|
+
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."));
|
|
436
|
+
const notSignedIn = new CliError.UserError({
|
|
437
|
+
cause: "not signed in",
|
|
438
|
+
userMessage: "Not signed in: run `clankerauth login <url>` first."
|
|
439
|
+
});
|
|
440
|
+
const administrationSignIn = Effect.gen(function* () {
|
|
441
|
+
const named = yield* Config.option(Config.String("CLANKERAUTH_URL")).pipe(Effect.mapError(refused));
|
|
442
|
+
if (Option.exists(named, (url) => !URL.canParse(url))) return yield* new CliError.UserError({
|
|
443
|
+
cause: "CLANKERAUTH_URL",
|
|
444
|
+
userMessage: "CLANKERAUTH_URL is no URL: set it to the issuer's origin, its CLANKERAUTH_BASE_URL."
|
|
711
445
|
});
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
return
|
|
723
|
-
});
|
|
724
|
-
const
|
|
446
|
+
const kept = (yield* Credentials.list().pipe(Effect.mapError(refused))).filter((signIn) => signIn.resource === administrationIdentifier(signIn.url)).filter((signIn) => Option.match(named, {
|
|
447
|
+
onNone: () => true,
|
|
448
|
+
onSome: (url) => new URL(url).origin === signIn.url
|
|
449
|
+
}));
|
|
450
|
+
const [only, ...others] = kept;
|
|
451
|
+
if (only === void 0) return yield* notSignedIn;
|
|
452
|
+
if (others.length > 0) return yield* new CliError.UserError({
|
|
453
|
+
cause: "several issuers",
|
|
454
|
+
userMessage: `Signed in to administration at ${kept.map((signIn) => signIn.url).join(", ")}: name one with CLANKERAUTH_URL.`
|
|
455
|
+
});
|
|
456
|
+
return only;
|
|
457
|
+
});
|
|
458
|
+
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* () {
|
|
459
|
+
const named = Option.isSome(url) ? url.value : (yield* administrationSignIn).resource;
|
|
460
|
+
const token = yield* Credentials.token({ url: named }).pipe(Effect.mapError(refused));
|
|
461
|
+
yield* Effect.sync(() => process.stdout.write(`${Redacted.value(token)}\n`));
|
|
462
|
+
})).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."));
|
|
463
|
+
const administrationClient = Effect.flatMap(administrationSignIn, (signIn) => ActionHttp.client(OwnerHttp, {
|
|
464
|
+
baseUrl: signIn.url,
|
|
465
|
+
transformClient: (client) => HttpClient.catchTag(Credentials.authorizing({ url: signIn.resource })(client), ["SignInRequired", "TokenUnavailable"], (error) => Effect.fail(refused(error)))
|
|
466
|
+
}));
|
|
467
|
+
const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action, { client: administrationClient }));
|
|
725
468
|
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
469
|
loginCommand,
|
|
727
470
|
logoutCommand,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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,19 +19,19 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@effect/platform-node": "4.0.
|
|
23
|
-
"@effect/platform-node-shared": "4.0.
|
|
24
|
-
"@gjermundgaraba/
|
|
25
|
-
"effect": "
|
|
26
|
-
"
|
|
22
|
+
"@effect/platform-node": "4.0.5",
|
|
23
|
+
"@effect/platform-node-shared": "4.0.5",
|
|
24
|
+
"@gjermundgaraba/clankerauth-sdk": "0.25.0",
|
|
25
|
+
"@gjermundgaraba/effect-actions": "0.18.2",
|
|
26
|
+
"effect": "4.0.5"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@clankerauth
|
|
30
|
-
"@gjermundgaraba/clankerauth-dev": "0.
|
|
31
|
-
"@types/node": "26.6.
|
|
29
|
+
"@gjermundgaraba/clankerauth-admin-api": "0.25.0",
|
|
30
|
+
"@gjermundgaraba/clankerauth-dev": "0.25.0",
|
|
31
|
+
"@types/node": "26.6.5",
|
|
32
32
|
"typescript": "7.0.2",
|
|
33
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
34
|
-
"vite-plus": "1.
|
|
33
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
|
34
|
+
"vite-plus": "1.3.0"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=26"
|