@gjermundgaraba/clankerauth 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/dist/main.mjs +255 -202
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @gjermundgaraba/clankerauth
|
|
2
2
|
|
|
3
|
-
The `clankerauth` command: sign in to a [clankerauth](https://github.com/gjermundgaraba/clankerauth) issuer as its owner
|
|
3
|
+
The `clankerauth` command: sign in to a [clankerauth](https://github.com/gjermundgaraba/clankerauth) issuer as its owner, run every administration action over HTTP, and hand other commands access tokens for the issuer's resources. It needs Node 26 or newer and an issuer at 0.16.0 or later.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install --global @gjermundgaraba/clankerauth
|
|
@@ -12,7 +12,21 @@ clankerauth create-api-key --name "notes sync" \
|
|
|
12
12
|
clankerauth logout
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
`login` discovers the issuer
|
|
15
|
+
`login` discovers the issuer at the URL given, which must be its own origin, `CLANKERAUTH_BASE_URL`; registers the command as a native OAuth client the first time; prints the approval URL on stderr for the owner to open; and listens for the redirect on a loopback port. It asks for `clankerauth:read clankerauth:write`, unless `--scope` names fewer: `--scope clankerauth:read` lists and never changes. The sign-ins are kept in `$XDG_CONFIG_HOME/clankerauth/credentials.json`, `~/.config/clankerauth/credentials.json` by default, readable by their owner alone; a file the command cannot read, such as one another version wrote, counts as signed out and the next `login` replaces it. A command refreshes an access token once it has expired and saves the rotated refresh token, so a sign-in lasts while it is used at least every 30 days. `logout` revokes every sign-in and deletes the file.
|
|
16
|
+
|
|
17
|
+
## Tokens for other commands
|
|
18
|
+
|
|
19
|
+
`login --resource <identifier> --scope <scopes>` signs in to one of the issuer's resources instead of administration, with its own consent and refresh token, kept beside the others. `token --resource <identifier>` prints a current access token for it, and nothing else, refreshing it first when it has expired; without `--resource` it prints administration's. Another command, or a script, gets its bearer token from there instead of keeping a key:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
clankerauth login https://clankerauth.home.example \
|
|
23
|
+
--resource https://notes.home.example/ --scope "notes:read notes:write"
|
|
24
|
+
curl -H "authorization: Bearer $(clankerauth token --resource https://notes.home.example/)" \
|
|
25
|
+
https://notes.home.example/api/list-notes
|
|
26
|
+
clankerauth logout --resource https://notes.home.example/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The identifier is the resource's, exactly as the dashboard lists it. The file holds one issuer's sign-ins: signing in to another issuer revokes and forgets the kept ones first.
|
|
16
30
|
|
|
17
31
|
Every other command is an administration action, named after it in kebab case, with a flag per input field: `clankerauth <command> --help` lists them. A field that is not text takes JSON, such as `--permissions` above or `--expires-at null`. A command prints the action's result as JSON on stdout, and a refusal on stderr, exiting 1. A created API key or client secret is in that JSON once, so send it where it belongs:
|
|
18
32
|
|
package/dist/main.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Console, Context, Deferred, Effect,
|
|
2
|
+
import { Console, Context, Deferred, Effect, Option, Schema } from "effect";
|
|
3
3
|
import { Argument, CliError, Command, Flag } from "effect/cli";
|
|
4
4
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
5
5
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
@@ -8,12 +8,12 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
|
8
8
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
9
9
|
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
10
10
|
import { HttpApiSchema, HttpApiSecurity } from "effect/http-api";
|
|
11
|
-
import {
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
|
-
import { spawn } from "node:child_process";
|
|
16
15
|
import { createServer } from "node:http";
|
|
16
|
+
import * as oauth from "oauth4webapi";
|
|
17
17
|
//#region ../../packages/admin-api/dist/index.js
|
|
18
18
|
var BadRequest = class extends Schema.TaggedError()("BadRequest", { error: Schema.String }, { httpApiStatus: 400 }) {};
|
|
19
19
|
var NotFound = class extends Schema.TaggedError()("NotFound", { error: Schema.String }, { httpApiStatus: 404 }) {};
|
|
@@ -365,46 +365,53 @@ const OwnerHttp = ActionHttp.make(Administration, {
|
|
|
365
365
|
});
|
|
366
366
|
//#endregion
|
|
367
367
|
//#region package.json
|
|
368
|
-
var version = "0.
|
|
368
|
+
var version = "0.18.0";
|
|
369
369
|
//#endregion
|
|
370
370
|
//#region src/credentials.ts
|
|
371
|
-
/** The kept sign-
|
|
371
|
+
/** The kept sign-ins cannot be read or written. */
|
|
372
372
|
var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { message: Schema.String }) {};
|
|
373
373
|
const failed = (what) => (error) => new CredentialsError({ message: `${what} ${credentialsPath()}: ${error.message}` });
|
|
374
374
|
/**
|
|
375
|
-
*
|
|
376
|
-
* access token lasts fifteen minutes and the refresh token rotates on every use, so the
|
|
377
|
-
* is rewritten whenever a command refreshes.
|
|
375
|
+
* The owner's authorization for one resource: administration or an application's. The
|
|
376
|
+
* access token lasts fifteen minutes and the refresh token rotates on every use, so the
|
|
377
|
+
* file is rewritten whenever a command refreshes.
|
|
378
378
|
*/
|
|
379
|
-
const
|
|
380
|
-
/** The
|
|
381
|
-
url: Schema.String,
|
|
382
|
-
/** The administration resource, the token audience: the issuer's origin root. */
|
|
379
|
+
const SignIn = Schema.Struct({
|
|
380
|
+
/** The token audience: a resource identifier, such as `https://notes.home.example/`. */
|
|
383
381
|
resource: Schema.String,
|
|
384
|
-
tokenEndpoint: Schema.String,
|
|
385
|
-
/** Where `logout` revokes the refresh token; null when the issuer publishes none. */
|
|
386
|
-
revocationEndpoint: Schema.NullOr(Schema.String),
|
|
387
|
-
clientId: Schema.String,
|
|
388
382
|
scope: Schema.String,
|
|
389
383
|
accessToken: Schema.String,
|
|
390
384
|
refreshToken: Schema.String,
|
|
391
385
|
/** When the access token expires, as an ISO instant. */
|
|
392
386
|
expiresAt: Schema.String
|
|
393
387
|
});
|
|
388
|
+
/**
|
|
389
|
+
* The issuer signed in to, the client this CLI registered there, and one sign-in per
|
|
390
|
+
* resource. `server` is the part of the issuer's RFC 8414 metadata a refresh and a
|
|
391
|
+
* revocation need, under its RFC names.
|
|
392
|
+
*/
|
|
393
|
+
const Credentials = Schema.Struct({
|
|
394
|
+
/** The issuer's origin, `CLANKERAUTH_BASE_URL`: where `/api/owner/<action>` is served. */
|
|
395
|
+
url: Schema.String,
|
|
396
|
+
server: Schema.Struct({
|
|
397
|
+
issuer: Schema.String,
|
|
398
|
+
token_endpoint: Schema.String,
|
|
399
|
+
revocation_endpoint: Schema.String
|
|
400
|
+
}),
|
|
401
|
+
clientId: Schema.String,
|
|
402
|
+
signIns: Schema.Array(SignIn)
|
|
403
|
+
});
|
|
394
404
|
const CredentialsJson = Schema.fromJsonString(Credentials);
|
|
395
|
-
/** Where the sign-
|
|
405
|
+
/** Where the sign-ins are kept: `$XDG_CONFIG_HOME/clankerauth`, or `~/.config/clankerauth`. */
|
|
396
406
|
const credentialsPath = () => join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "clankerauth", "credentials.json");
|
|
397
|
-
/** The sign-in, if there is one. A file that does not decode is a failure, not a sign-out. */
|
|
398
|
-
const readCredentials = Effect.fn("Credentials.read")(function* () {
|
|
399
|
-
const text = yield* Effect.tryPromise(() => readFile(credentialsPath(), "utf8")).pipe(Effect.map(Option.some), Effect.catchIf((error) => error.cause instanceof Error && "code" in error.cause && error.cause.code === "ENOENT", () => Effect.succeed(Option.none())));
|
|
400
|
-
return yield* Option.match(text, {
|
|
401
|
-
onNone: () => Effect.succeedNone,
|
|
402
|
-
onSome: (json) => Effect.map(Schema.decodeEffect(CredentialsJson)(json), Option.some)
|
|
403
|
-
});
|
|
404
|
-
}, Effect.mapError(failed("Cannot read")));
|
|
405
407
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
+
* The sign-ins, if there are any. A file this version cannot decode, such as one another
|
|
409
|
+
* version wrote, holds none: the next `login` replaces it.
|
|
410
|
+
*/
|
|
411
|
+
const readCredentials = () => Effect.tryPromise(() => readFile(credentialsPath(), "utf8")).pipe(Effect.map(Schema.decodeOption(CredentialsJson)), Effect.catchIf((error) => error.cause instanceof Error && "code" in error.cause && error.cause.code === "ENOENT", () => Effect.succeedNone), Effect.mapError(failed("Cannot read")));
|
|
412
|
+
/**
|
|
413
|
+
* Replace the sign-ins, readable by their owner alone. Written beside the file and renamed
|
|
414
|
+
* over it, so a command interrupted mid-write never leaves a refresh token half-saved.
|
|
408
415
|
*/
|
|
409
416
|
const writeCredentials = Effect.fn("Credentials.write")(function* (credentials) {
|
|
410
417
|
const path = credentialsPath();
|
|
@@ -419,108 +426,136 @@ const writeCredentials = Effect.fn("Credentials.write")(function* (credentials)
|
|
|
419
426
|
await rename(staging, path);
|
|
420
427
|
}).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
|
|
421
428
|
}, Effect.mapError(failed("Cannot write")));
|
|
429
|
+
/**
|
|
430
|
+
* Keep `signIn` in place of the one for its resource. The file is read again first, so a
|
|
431
|
+
* command refreshing another resource meanwhile keeps its rotated refresh token too.
|
|
432
|
+
*/
|
|
433
|
+
const saveSignIn = Effect.fn("Credentials.saveSignIn")(function* (issuer, signIn) {
|
|
434
|
+
const current = Option.filter(yield* readCredentials(), ({ url }) => url === issuer.url);
|
|
435
|
+
const base = Option.getOrElse(current, () => issuer);
|
|
436
|
+
yield* writeCredentials({
|
|
437
|
+
...base,
|
|
438
|
+
signIns: [...base.signIns.filter(({ resource }) => resource !== signIn.resource), signIn]
|
|
439
|
+
});
|
|
440
|
+
});
|
|
422
441
|
const removeCredentials = () => Effect.tryPromise(() => rm(credentialsPath(), { force: true })).pipe(Effect.mapError(failed("Cannot remove")));
|
|
423
442
|
//#endregion
|
|
424
443
|
//#region src/oauth.ts
|
|
425
|
-
/** Something the issuer answered that a sign-in or a
|
|
444
|
+
/** Something the issuer answered that a sign-in, a refresh or a revocation cannot go on from. */
|
|
426
445
|
var OAuthError = class extends Schema.TaggedError()("OAuthError", { message: Schema.String }) {};
|
|
427
|
-
/** RFC
|
|
428
|
-
const
|
|
429
|
-
resource: Schema.String,
|
|
430
|
-
authorization_servers: Schema.NonEmptyArray(Schema.String)
|
|
431
|
-
});
|
|
432
|
-
/** RFC 8414: the endpoints a sign-in uses. */
|
|
433
|
-
const AuthorizationServer = Schema.Struct({
|
|
446
|
+
/** The issuer's RFC 8414 metadata a sign-in uses, every endpoint required. */
|
|
447
|
+
const Server = Schema.Struct({
|
|
434
448
|
issuer: Schema.String,
|
|
435
449
|
authorization_endpoint: Schema.String,
|
|
436
450
|
token_endpoint: Schema.String,
|
|
437
451
|
registration_endpoint: Schema.String,
|
|
438
|
-
revocation_endpoint: Schema.
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const ErrorBody = Schema.Struct({
|
|
449
|
-
error: Schema.String,
|
|
450
|
-
error_description: Schema.optionalKey(Schema.String)
|
|
451
|
-
});
|
|
452
|
-
/** Send `request` and decode its JSON as `schema`, failing with what the issuer said. */
|
|
453
|
-
const exchange = (what, schema, request) => Effect.gen(function* () {
|
|
454
|
-
const response = yield* Effect.tryPromise({
|
|
455
|
-
try: request,
|
|
456
|
-
catch: (error) => new OAuthError({ message: `${what}: the issuer cannot be reached (${String(error)})` })
|
|
457
|
-
});
|
|
458
|
-
const text = yield* Effect.promise(() => response.text());
|
|
459
|
-
if (!response.ok) return yield* new OAuthError({ message: `${what}: ${Option.match(Schema.decodeUnknownOption(Schema.fromJsonString(ErrorBody))(text), {
|
|
460
|
-
onNone: () => `HTTP ${response.status}`,
|
|
461
|
-
onSome: (body) => body.error_description === void 0 ? body.error : `${body.error}: ${body.error_description}`
|
|
462
|
-
})}` });
|
|
463
|
-
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(text).pipe(Effect.mapError((error) => new OAuthError({ message: `${what}: unexpected answer (${error.message})` })));
|
|
452
|
+
revocation_endpoint: Schema.String
|
|
453
|
+
});
|
|
454
|
+
/** This CLI as the issuer knows it: a public client, authenticating with nothing. */
|
|
455
|
+
const client = (clientId) => ({ client_id: clientId });
|
|
456
|
+
/** Plain HTTP is allowed for an issuer the owner named over it, such as one on loopback. */
|
|
457
|
+
const transport = (url) => ({ [oauth.allowInsecureRequests]: new URL(url).protocol === "http:" });
|
|
458
|
+
/** The RFC 9728 check's failure: the metadata names another resource than the one asked for. */
|
|
459
|
+
const NamedElsewhere = Schema.Struct({
|
|
460
|
+
attribute: Schema.Literal("resource"),
|
|
461
|
+
body: Schema.Struct({ resource: Schema.String })
|
|
464
462
|
});
|
|
465
463
|
/**
|
|
466
|
-
*
|
|
467
|
-
*
|
|
464
|
+
* What went wrong: what the issuer said when it sent an OAuth error, where it is when it
|
|
465
|
+
* names another address than the one asked, the status of an answer the library refused,
|
|
466
|
+
* or why a request failed.
|
|
467
|
+
*/
|
|
468
|
+
const reason = (error) => {
|
|
469
|
+
if (error instanceof oauth.ResponseBodyError || error instanceof oauth.AuthorizationResponseError) return [error.error, error.error_description].filter(Boolean).join(": ");
|
|
470
|
+
if (error instanceof oauth.OperationProcessingError) {
|
|
471
|
+
const named = Schema.decodeUnknownOption(NamedElsewhere)(error.cause);
|
|
472
|
+
if (Option.isSome(named)) return `the issuer is at ${new URL(named.value.body.resource).origin}, its CLANKERAUTH_BASE_URL: sign in there`;
|
|
473
|
+
if (error.cause instanceof Response) return `${error.message} (HTTP ${error.cause.status})`;
|
|
474
|
+
}
|
|
475
|
+
return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
|
|
476
|
+
};
|
|
477
|
+
/** Run one step of the protocol, failing with what the issuer, or the library, said. */
|
|
478
|
+
const step = (what, run) => Effect.tryPromise({
|
|
479
|
+
try: run,
|
|
480
|
+
catch: (error) => new OAuthError({ message: `${what}: ${error instanceof Error ? reason(error) : String(error)}` })
|
|
481
|
+
});
|
|
482
|
+
/**
|
|
483
|
+
* The issuer at `url`, its origin: administration's RFC 9728 metadata at the origin root,
|
|
484
|
+
* which must name that root, and the RFC 8414 metadata of the issuer it names, which must
|
|
485
|
+
* name that issuer. Both checks are the library's.
|
|
468
486
|
*/
|
|
469
487
|
const discover = Effect.fn("OAuth.discover")(function* (url) {
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
};
|
|
488
|
+
const administration = new URL("/", url);
|
|
489
|
+
const [named] = (yield* step(`Discovery at ${administration.origin}`, async () => oauth.processResourceDiscoveryResponse(administration, await oauth.resourceDiscoveryRequest(administration, transport(url))))).authorization_servers ?? [];
|
|
490
|
+
if (named === void 0) return yield* new OAuthError({ message: "Discovery: the issuer names no authorization server" });
|
|
491
|
+
const issuer = new URL(named);
|
|
492
|
+
const server = yield* step("Discovery", async () => oauth.processDiscoveryResponse(issuer, await oauth.discoveryRequest(issuer, {
|
|
493
|
+
algorithm: "oauth2",
|
|
494
|
+
...transport(url)
|
|
495
|
+
})));
|
|
496
|
+
return yield* Schema.decodeUnknownEffect(Server)(server).pipe(Effect.mapError((error) => new OAuthError({ message: `Discovery: the issuer lacks an endpoint (${error.message})` })));
|
|
478
497
|
});
|
|
479
498
|
/**
|
|
480
499
|
* Register this CLI as a public native client. The issuer matches a loopback redirect without
|
|
481
500
|
* its port (RFC 8252), so one registration serves every port a sign-in listens on.
|
|
482
501
|
*/
|
|
483
|
-
const register =
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
502
|
+
const register = (url, server, redirectUri) => step("Registration", async () => oauth.processDynamicClientRegistrationResponse(await oauth.dynamicClientRegistrationRequest(server, {
|
|
503
|
+
client_name: "clankerauth CLI",
|
|
504
|
+
redirect_uris: [redirectUri],
|
|
505
|
+
application_type: "native",
|
|
506
|
+
token_endpoint_auth_method: "none",
|
|
507
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
508
|
+
response_types: ["code"]
|
|
509
|
+
}, transport(url)))).pipe(Effect.map((registered) => registered.client_id));
|
|
510
|
+
/**
|
|
511
|
+
* Check the redirect a browser brought back (its state, the RFC 9207 issuer, an error the
|
|
512
|
+
* issuer sent instead of a code) and exchange its code for tokens.
|
|
513
|
+
*/
|
|
514
|
+
const exchangeCode = (url, server, clientId, callback, expected, resource) => step("Sign-in", async () => {
|
|
515
|
+
const parameters = oauth.validateAuthResponse(server, client(clientId), callback, expected.state);
|
|
516
|
+
return oauth.processAuthorizationCodeResponse(server, client(clientId), await oauth.authorizationCodeGrantRequest(server, client(clientId), oauth.None(), parameters, expected.redirectUri, expected.verifier, {
|
|
517
|
+
additionalParameters: { resource },
|
|
518
|
+
...transport(url)
|
|
519
|
+
}));
|
|
520
|
+
});
|
|
521
|
+
/** Refresh the tokens of one sign-in: the refresh token rotates. */
|
|
522
|
+
const refresh = (credentials, refreshToken, resource) => step("Refresh", async () => oauth.processRefreshTokenResponse(credentials.server, client(credentials.clientId), await oauth.refreshTokenGrantRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
|
|
523
|
+
additionalParameters: { resource },
|
|
524
|
+
...transport(credentials.url)
|
|
525
|
+
})));
|
|
503
526
|
/** Revoke a refresh token, and with it every token of its family. */
|
|
504
|
-
const revoke = (
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
token: refreshToken,
|
|
509
|
-
token_type_hint: "refresh_token",
|
|
510
|
-
client_id: clientId
|
|
511
|
-
})
|
|
512
|
-
}));
|
|
527
|
+
const revoke = (credentials, refreshToken) => step("Revocation", async () => oauth.processRevocationResponse(await oauth.revocationRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
|
|
528
|
+
additionalParameters: { token_type_hint: "refresh_token" },
|
|
529
|
+
...transport(credentials.url)
|
|
530
|
+
})));
|
|
513
531
|
//#endregion
|
|
514
532
|
//#region src/login.ts
|
|
515
|
-
/**
|
|
516
|
-
const
|
|
533
|
+
/** The issuer's own resource, administration: its origin root, as discovery checks. */
|
|
534
|
+
const administrationOf = (url) => new URL("/", url).href;
|
|
535
|
+
/** The scopes a sign-in to administration asks for unless told otherwise. */
|
|
536
|
+
const administrationScopes = "clankerauth:read clankerauth:write";
|
|
537
|
+
/** A sign-in asks for a refresh token too, so it lasts while it is used. */
|
|
538
|
+
const withRefresh = (scope) => scope.split(" ").includes("offline_access") ? scope : `${scope} offline_access`;
|
|
539
|
+
/**
|
|
540
|
+
* The command that signs in to `resource` again: administration needs neither flag unless
|
|
541
|
+
* it was granted other scopes than its default, and another resource needs both.
|
|
542
|
+
*/
|
|
543
|
+
const loginHint = (url, resource, scope) => {
|
|
544
|
+
const asked = scope?.split(" ").filter((granted) => granted !== "offline_access").join(" ");
|
|
545
|
+
const administration = resource === administrationOf(url);
|
|
546
|
+
return `clankerauth login ${url}${administration ? "" : ` --resource ${resource}`}${administration && (asked === void 0 || asked === "clankerauth:read clankerauth:write") ? "" : ` --scope "${asked ?? "<scopes>"}"`}`;
|
|
547
|
+
};
|
|
517
548
|
/** The redirect a registration names; a sign-in adds the port it listens on. */
|
|
518
549
|
const registeredRedirect = "http://127.0.0.1/callback";
|
|
519
550
|
/** How long a sign-in waits for the owner to approve it in the browser. */
|
|
520
551
|
const approvalWindow = "5 minutes";
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
552
|
+
/** When tokens issued now expire; an issuer that does not say is asked again next time. */
|
|
553
|
+
const expiry = (tokens) => new Date(Date.now() + (tokens.expires_in ?? 0) * 1e3).toISOString();
|
|
554
|
+
/**
|
|
555
|
+
* The callback page says nothing of the outcome, which is decided after the browser has
|
|
556
|
+
* its answer: the terminal reports it.
|
|
557
|
+
*/
|
|
558
|
+
const page = "<!doctype html><meta charset=\"utf-8\"><title>clankerauth</title><p>Return to the terminal.</p>";
|
|
524
559
|
/** Listen on a free loopback port for the one request the authorization redirect makes. */
|
|
525
560
|
const listen = Effect.acquireRelease(Effect.gen(function* () {
|
|
526
561
|
const received = yield* Deferred.make();
|
|
@@ -530,15 +565,8 @@ const listen = Effect.acquireRelease(Effect.gen(function* () {
|
|
|
530
565
|
response.writeHead(404).end();
|
|
531
566
|
return;
|
|
532
567
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
Deferred.doneUnsafe(received, Effect.succeed({
|
|
536
|
-
code: query.get("code"),
|
|
537
|
-
state: query.get("state"),
|
|
538
|
-
iss: query.get("iss"),
|
|
539
|
-
error: query.get("error"),
|
|
540
|
-
errorDescription: query.get("error_description")
|
|
541
|
-
}));
|
|
568
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page);
|
|
569
|
+
Deferred.doneUnsafe(received, Effect.succeed(url.searchParams));
|
|
542
570
|
});
|
|
543
571
|
return {
|
|
544
572
|
server,
|
|
@@ -552,40 +580,32 @@ const listen = Effect.acquireRelease(Effect.gen(function* () {
|
|
|
552
580
|
server.closeAllConnections();
|
|
553
581
|
server.close();
|
|
554
582
|
}));
|
|
555
|
-
/**
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
"/c",
|
|
560
|
-
"start",
|
|
561
|
-
"",
|
|
562
|
-
url
|
|
563
|
-
]), Match.orElse(() => ["xdg-open", url]));
|
|
564
|
-
try {
|
|
565
|
-
spawn(command ?? "open", args, {
|
|
566
|
-
stdio: "ignore",
|
|
567
|
-
detached: true
|
|
568
|
-
}).on("error", () => {}).unref();
|
|
569
|
-
} catch {}
|
|
583
|
+
/** Revoke these sign-ins' refresh tokens, as far as the issuer can be reached. */
|
|
584
|
+
const revokeAll = (credentials, signIns) => Effect.forEach(signIns, ({ refreshToken }) => Effect.ignore(revoke(credentials, refreshToken)), {
|
|
585
|
+
concurrency: "unbounded",
|
|
586
|
+
discard: true
|
|
570
587
|
});
|
|
571
588
|
/**
|
|
572
|
-
* Sign in to the issuer at `url` as its owner
|
|
573
|
-
* issuer,
|
|
574
|
-
* stderr
|
|
589
|
+
* Sign in to the issuer at `url` as its owner, for `resource`, administration when none:
|
|
590
|
+
* discover the issuer, register this CLI once per issuer, have the owner approve the URL
|
|
591
|
+
* printed on stderr, and keep the tokens beside the sign-ins to other resources. Signing
|
|
592
|
+
* in to another issuer than the kept one revokes and forgets the kept one's sign-ins.
|
|
575
593
|
*/
|
|
576
|
-
const login = Effect.fn("Cli.login")(function* (url,
|
|
594
|
+
const login = Effect.fn("Cli.login")(function* (url, target, asked) {
|
|
577
595
|
const origin = new URL(url).origin;
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
const
|
|
596
|
+
const server = yield* discover(origin);
|
|
597
|
+
const resource = Option.getOrElse(target, () => administrationOf(origin));
|
|
598
|
+
const kept = yield* readCredentials();
|
|
599
|
+
const previous = Option.filter(kept, (credentials) => credentials.url === origin);
|
|
600
|
+
const clientId = yield* Option.match(previous, {
|
|
581
601
|
onSome: (credentials) => Effect.succeed(credentials.clientId),
|
|
582
|
-
onNone: () => register(server, registeredRedirect)
|
|
602
|
+
onNone: () => register(origin, server, registeredRedirect)
|
|
583
603
|
});
|
|
584
604
|
const { port, received } = yield* listen;
|
|
585
605
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
586
|
-
const verifier =
|
|
587
|
-
const state =
|
|
588
|
-
const scope =
|
|
606
|
+
const verifier = oauth.generateRandomCodeVerifier();
|
|
607
|
+
const state = oauth.generateRandomState();
|
|
608
|
+
const scope = withRefresh(asked);
|
|
589
609
|
const authorization = new URL(server.authorization_endpoint);
|
|
590
610
|
authorization.search = new URLSearchParams({
|
|
591
611
|
client_id: clientId,
|
|
@@ -593,81 +613,90 @@ const login = Effect.fn("Cli.login")(function* (url, readOnly, browser) {
|
|
|
593
613
|
response_type: "code",
|
|
594
614
|
scope,
|
|
595
615
|
resource,
|
|
596
|
-
code_challenge:
|
|
616
|
+
code_challenge: yield* Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)),
|
|
597
617
|
code_challenge_method: "S256",
|
|
598
618
|
state
|
|
599
619
|
}).toString();
|
|
600
|
-
yield* Effect.sync(() => process.stderr.write(`
|
|
601
|
-
if (browser) yield* openBrowser(authorization.href);
|
|
620
|
+
yield* Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${authorization.href}\n\n`));
|
|
602
621
|
const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
|
|
603
622
|
duration: approvalWindow,
|
|
604
623
|
orElse: () => Effect.fail(new OAuthError({ message: "Sign-in: nobody approved it within five minutes" }))
|
|
605
624
|
}));
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
code_verifier: verifier,
|
|
614
|
-
redirect_uri: redirectUri,
|
|
615
|
-
resource
|
|
616
|
-
}));
|
|
617
|
-
const credentials = {
|
|
618
|
-
url: origin,
|
|
625
|
+
const tokens = yield* exchangeCode(origin, server, clientId, callback, {
|
|
626
|
+
state,
|
|
627
|
+
verifier,
|
|
628
|
+
redirectUri
|
|
629
|
+
}, resource);
|
|
630
|
+
if (tokens.refresh_token === void 0) return yield* new OAuthError({ message: "Sign-in: the issuer issued no refresh token" });
|
|
631
|
+
const signIn = {
|
|
619
632
|
resource,
|
|
620
|
-
tokenEndpoint: server.token_endpoint,
|
|
621
|
-
revocationEndpoint: server.revocation_endpoint ?? null,
|
|
622
|
-
clientId,
|
|
623
633
|
scope: tokens.scope ?? scope,
|
|
624
634
|
accessToken: tokens.access_token,
|
|
625
635
|
refreshToken: tokens.refresh_token,
|
|
626
636
|
expiresAt: expiry(tokens)
|
|
627
637
|
};
|
|
628
|
-
|
|
638
|
+
if (Option.isSome(kept) && Option.isNone(previous)) {
|
|
639
|
+
yield* revokeAll(kept.value, kept.value.signIns);
|
|
640
|
+
yield* removeCredentials();
|
|
641
|
+
}
|
|
642
|
+
yield* saveSignIn(Option.getOrElse(previous, () => ({
|
|
643
|
+
url: origin,
|
|
644
|
+
server: {
|
|
645
|
+
issuer: server.issuer,
|
|
646
|
+
token_endpoint: server.token_endpoint,
|
|
647
|
+
revocation_endpoint: server.revocation_endpoint
|
|
648
|
+
},
|
|
649
|
+
clientId,
|
|
650
|
+
signIns: []
|
|
651
|
+
})), signIn);
|
|
629
652
|
return {
|
|
630
653
|
url: origin,
|
|
631
|
-
|
|
654
|
+
resource,
|
|
655
|
+
scope: signIn.scope
|
|
632
656
|
};
|
|
633
657
|
}, Effect.scoped);
|
|
634
658
|
/**
|
|
635
|
-
*
|
|
636
|
-
* refresh token rotates, so the new pair is saved before the token is used.
|
|
659
|
+
* A current access token for `resource`, refreshed first when it has expired or is about
|
|
660
|
+
* to: the refresh token rotates, so the new pair is saved before the token is used.
|
|
637
661
|
*/
|
|
638
|
-
const accessToken = Effect.fn("Cli.accessToken")(function* (credentials) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
};
|
|
643
|
-
const tokens = yield* token("Refresh", credentials.tokenEndpoint, new URLSearchParams({
|
|
644
|
-
grant_type: "refresh_token",
|
|
645
|
-
client_id: credentials.clientId,
|
|
646
|
-
refresh_token: credentials.refreshToken,
|
|
647
|
-
resource: credentials.resource
|
|
648
|
-
})).pipe(Effect.mapError((error) => new OAuthError({ message: `${error.message}. Run \`clankerauth login ${credentials.url}\` again.` })));
|
|
662
|
+
const accessToken = Effect.fn("Cli.accessToken")(function* (credentials, resource) {
|
|
663
|
+
const signIn = credentials.signIns.find((held) => held.resource === resource);
|
|
664
|
+
if (signIn === void 0) return yield* new OAuthError({ message: `Not signed in to ${resource}: run \`${loginHint(credentials.url, resource)}\` first.` });
|
|
665
|
+
if (Date.parse(signIn.expiresAt) - Date.now() > 6e4) return signIn.accessToken;
|
|
666
|
+
const tokens = yield* refresh(credentials, signIn.refreshToken, resource).pipe(Effect.mapError((error) => new OAuthError({ message: `${error.message}. Run \`${loginHint(credentials.url, resource, signIn.scope)}\` again.` })));
|
|
649
667
|
const refreshed = {
|
|
650
|
-
...
|
|
651
|
-
scope: tokens.scope ??
|
|
668
|
+
...signIn,
|
|
669
|
+
scope: tokens.scope ?? signIn.scope,
|
|
652
670
|
accessToken: tokens.access_token,
|
|
653
|
-
refreshToken: tokens.refresh_token,
|
|
671
|
+
refreshToken: tokens.refresh_token ?? signIn.refreshToken,
|
|
654
672
|
expiresAt: expiry(tokens)
|
|
655
673
|
};
|
|
656
|
-
yield*
|
|
657
|
-
return
|
|
658
|
-
credentials: refreshed,
|
|
659
|
-
token: refreshed.accessToken
|
|
660
|
-
};
|
|
674
|
+
yield* saveSignIn(credentials, refreshed);
|
|
675
|
+
return refreshed.accessToken;
|
|
661
676
|
});
|
|
662
|
-
/**
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
677
|
+
/**
|
|
678
|
+
* Forget the sign-in to `resource`, or every one when none is named, revoking each refresh
|
|
679
|
+
* token first when the issuer can be reached. The file goes with the last sign-in, and
|
|
680
|
+
* with a file no sign-in can be read from.
|
|
681
|
+
*/
|
|
682
|
+
const logout = Effect.fn("Cli.logout")(function* (resource) {
|
|
683
|
+
const stored = yield* readCredentials();
|
|
684
|
+
if (Option.isNone(stored)) {
|
|
685
|
+
yield* removeCredentials();
|
|
686
|
+
return { signedOut: false };
|
|
687
|
+
}
|
|
688
|
+
const credentials = stored.value;
|
|
689
|
+
const leaving = credentials.signIns.filter((signIn) => Option.match(resource, {
|
|
690
|
+
onNone: () => true,
|
|
691
|
+
onSome: (named) => signIn.resource === named
|
|
692
|
+
}));
|
|
693
|
+
const staying = credentials.signIns.filter((signIn) => !leaving.includes(signIn));
|
|
694
|
+
yield* revokeAll(credentials, leaving);
|
|
695
|
+
yield* staying.length === 0 ? removeCredentials() : writeCredentials({
|
|
696
|
+
...credentials,
|
|
697
|
+
signIns: staying
|
|
668
698
|
});
|
|
669
|
-
|
|
670
|
-
return { signedOut: Option.isSome(credentials) };
|
|
699
|
+
return { signedOut: leaving.length > 0 };
|
|
671
700
|
});
|
|
672
701
|
//#endregion
|
|
673
702
|
//#region src/main.ts
|
|
@@ -677,30 +706,54 @@ const refused = (error) => new CliError.UserError({
|
|
|
677
706
|
userMessage: error.message
|
|
678
707
|
});
|
|
679
708
|
const json = (value) => Console.log(JSON.stringify(value, null, 2));
|
|
709
|
+
/** A resource identifier as the issuer keeps it: canonical, `https://notes.example/`. */
|
|
710
|
+
const resourceFlag = (description) => Flag.String("resource").pipe(Flag.withDescription(description), Flag.mapTryCatch((resource) => new URL(resource).href, () => "--resource must be a URL, such as https://notes.example/"), Flag.optional);
|
|
711
|
+
/** The scopes a sign-in asks for: `--scope`, which another resource than administration needs. */
|
|
712
|
+
const scopeFor = Effect.fn("Cli.scopeFor")(function* (resource, scope) {
|
|
713
|
+
if (Option.isSome(scope)) return scope.value;
|
|
714
|
+
if (Option.isNone(resource)) return administrationScopes;
|
|
715
|
+
const message = `Name the scopes to ask ${resource.value} for with --scope.`;
|
|
716
|
+
return yield* new CliError.UserError({
|
|
717
|
+
cause: message,
|
|
718
|
+
userMessage: message
|
|
719
|
+
});
|
|
720
|
+
});
|
|
680
721
|
const loginCommand = Command.make("login", {
|
|
681
|
-
url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin,
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}, ({ url,
|
|
685
|
-
const logoutCommand = Command.make("logout", {}, () => logout().pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Revoke the sign-
|
|
686
|
-
/**
|
|
687
|
-
* The client every administration command calls through: the signed-in issuer's origin and
|
|
688
|
-
* a current access token, refreshed and saved first when it has expired.
|
|
689
|
-
*/
|
|
722
|
+
url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, its CLANKERAUTH_BASE_URL")),
|
|
723
|
+
resource: resourceFlag("Sign in to this resource instead of administration, for `token` to use"),
|
|
724
|
+
scope: Flag.String("scope").pipe(Flag.withDescription(`The scopes to ask for, space-separated: needed with --resource, and "${administrationScopes}" for administration by default`), Flag.optional)
|
|
725
|
+
}, ({ url, resource, scope }) => scopeFor(resource, scope).pipe(Effect.flatMap((asked) => login(url, resource, asked).pipe(Effect.mapError(refused))), Effect.flatMap(json))).pipe(Command.withDescription("Sign in to an issuer as its owner, for administration or, with --resource, for another resource: approve the URL it prints in the browser. Sign-ins are kept in ~/.config/clankerauth."));
|
|
726
|
+
const logoutCommand = Command.make("logout", { resource: resourceFlag("Sign out of this resource alone") }, ({ resource }) => logout(resource).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Revoke the sign-ins, or the one to --resource, and forget them."));
|
|
727
|
+
/** The kept sign-ins, or how to make one. */
|
|
690
728
|
const signedIn = Effect.gen(function* () {
|
|
691
729
|
const stored = yield* readCredentials().pipe(Effect.mapError(refused));
|
|
692
730
|
if (Option.isNone(stored)) return yield* new CliError.UserError({
|
|
693
731
|
cause: "not signed in",
|
|
694
732
|
userMessage: "Not signed in: run `clankerauth login <url>` first."
|
|
695
733
|
});
|
|
696
|
-
|
|
734
|
+
return stored.value;
|
|
735
|
+
});
|
|
736
|
+
/** Prints the token alone, so a script takes the whole output as it. */
|
|
737
|
+
const tokenCommand = Command.make("token", { resource: resourceFlag("The resource whose token to print; administration by default") }, ({ resource }) => Effect.gen(function* () {
|
|
738
|
+
const credentials = yield* signedIn;
|
|
739
|
+
const token = yield* accessToken(credentials, Option.getOrElse(resource, () => administrationOf(credentials.url))).pipe(Effect.mapError(refused));
|
|
740
|
+
yield* Effect.sync(() => process.stdout.write(`${token}\n`));
|
|
741
|
+
})).pipe(Command.withDescription("Print a current access token for a resource signed in to, refreshing it first when it has expired."));
|
|
742
|
+
/**
|
|
743
|
+
* The client every administration command calls through: the signed-in issuer's origin and
|
|
744
|
+
* a current administration access token, refreshed and saved first when it has expired.
|
|
745
|
+
*/
|
|
746
|
+
const administrationClient = Effect.gen(function* () {
|
|
747
|
+
const credentials = yield* signedIn;
|
|
748
|
+
const token = yield* accessToken(credentials, administrationOf(credentials.url)).pipe(Effect.mapError(refused));
|
|
697
749
|
return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(credentials.url), HttpClientRequest.bearerToken(token)));
|
|
698
750
|
});
|
|
699
751
|
/** Every administration action, as a command of its own, called over `/api/owner`. */
|
|
700
|
-
const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient,
|
|
701
|
-
const cli = Command.make("clankerauth").pipe(Command.withDescription("Administer a clankerauth issuer as its owner. Results are JSON on stdout; a created key or client secret is in it once."), Command.withSubcommands([
|
|
752
|
+
const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, administrationClient)));
|
|
753
|
+
const cli = Command.make("clankerauth").pipe(Command.withDescription("Administer a clankerauth issuer as its owner, and hand other commands its tokens. Results are JSON on stdout; a created key or client secret is in it once."), Command.withSubcommands([
|
|
702
754
|
loginCommand,
|
|
703
755
|
logoutCommand,
|
|
756
|
+
tokenCommand,
|
|
704
757
|
...administration
|
|
705
758
|
]));
|
|
706
759
|
Command.run(cli, { version }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeServices.layer), ActionCli.logToStderr, NodeRuntime.runMain);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "The clankerauth command line: sign in to an issuer as its owner and administer it over HTTP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,13 +19,14 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@effect/platform-node": "4.0.
|
|
23
|
-
"@gjermundgaraba/effect-actions": "0.
|
|
24
|
-
"effect": "4.0.
|
|
22
|
+
"@effect/platform-node": "4.0.3",
|
|
23
|
+
"@gjermundgaraba/effect-actions": "0.13.0",
|
|
24
|
+
"effect": "4.0.3",
|
|
25
|
+
"oauth4webapi": "3.8.8"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"@clankerauth/admin-api": "0.1.0",
|
|
28
|
-
"@gjermundgaraba/clankerauth-dev": "0.
|
|
29
|
+
"@gjermundgaraba/clankerauth-dev": "0.18.0",
|
|
29
30
|
"@types/node": "26.6.4",
|
|
30
31
|
"typescript": "7.0.2",
|
|
31
32
|
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
@@ -36,6 +37,6 @@
|
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "vp pack",
|
|
39
|
-
"test": "vp test"
|
|
40
|
+
"test": "vp pack && vp test"
|
|
40
41
|
}
|
|
41
42
|
}
|