@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.
- package/README.md +10 -9
- package/dist/main.mjs +60 -333
- 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.
|
|
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; 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
|
-
`
|
|
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,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Console, Context,
|
|
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
|
|
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("
|
|
334
|
-
description: "
|
|
335
|
-
readOnly:
|
|
329
|
+
Action.make("getResource", {
|
|
330
|
+
description: "Get one resource and its scopes by identifier.",
|
|
331
|
+
readOnly: true,
|
|
336
332
|
...owned,
|
|
337
|
-
error: [
|
|
338
|
-
input:
|
|
339
|
-
success:
|
|
333
|
+
error: [ResourceNotFound, InternalServerError],
|
|
334
|
+
input: ResourceId,
|
|
335
|
+
success: ResourceSummary
|
|
340
336
|
}),
|
|
341
|
-
Action.make("
|
|
342
|
-
description: "
|
|
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:
|
|
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.
|
|
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
|
|
691
|
-
const
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
return
|
|
723
|
-
});
|
|
724
|
-
const
|
|
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.
|
|
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/
|
|
25
|
-
"effect": "
|
|
26
|
-
"
|
|
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
|
|
30
|
-
"@gjermundgaraba/clankerauth-dev": "0.
|
|
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",
|