@gjermundgaraba/clankerauth 0.0.0-stage → 0.16.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/LICENSE +21 -0
- package/README.md +24 -2
- package/dist/main.mjs +708 -0
- package/package.json +38 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gjermund Garaba
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gjermundgaraba/clankerauth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `clankerauth` command: sign in to a [clankerauth](https://github.com/gjermundgaraba/clankerauth) issuer as its owner and run every administration action over HTTP. It needs Node 26 or newer and an issuer at 0.16.0 or later.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install --global @gjermundgaraba/clankerauth
|
|
7
|
+
|
|
8
|
+
clankerauth login https://clankerauth.home.example
|
|
9
|
+
clankerauth list-clients
|
|
10
|
+
clankerauth create-api-key --name "notes sync" \
|
|
11
|
+
--permissions '{"https://notes.home.example/":["notes:read"]}' --expires-at null
|
|
12
|
+
clankerauth logout
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`login` discovers the issuer, registers the command as a native OAuth client the first time, and asks the owner to approve in the browser: it prints the approval URL on stderr, opens it unless `--no-browser`, and listens for the redirect on a loopback port. `--read-only` asks for `clankerauth:read` alone, which lists and never changes. The sign-in is kept in `$XDG_CONFIG_HOME/clankerauth/credentials.json`, `~/.config/clankerauth/credentials.json` by default, readable by its owner alone. A command refreshes the access token once it has expired and saves the rotated refresh token. `logout` revokes the sign-in and deletes the file.
|
|
16
|
+
|
|
17
|
+
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
|
+
|
|
19
|
+
```sh
|
|
20
|
+
clankerauth create-api-key --name "machine" \
|
|
21
|
+
--permissions '{"https://creds.home.example/":["creds:machine"]}' --expires-at null \
|
|
22
|
+
| jq -r .key > key
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
MIT licensed.
|
package/dist/main.mjs
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Console, Context, Deferred, Effect, Match, Option, Schema } from "effect";
|
|
3
|
+
import { Argument, CliError, Command, Flag } from "effect/cli";
|
|
4
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
5
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
6
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
7
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
8
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
9
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
10
|
+
import { HttpApiSchema, HttpApiSecurity } from "effect/http-api";
|
|
11
|
+
import { createHash, 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 { spawn } from "node:child_process";
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
//#region ../../packages/admin-api/dist/index.js
|
|
18
|
+
var BadRequest = class extends Schema.TaggedError()("BadRequest", { error: Schema.String }, { httpApiStatus: 400 }) {};
|
|
19
|
+
var NotFound = class extends Schema.TaggedError()("NotFound", { error: Schema.String }, { httpApiStatus: 404 }) {};
|
|
20
|
+
var Conflict = class extends Schema.TaggedError()("Conflict", { error: Schema.String }, { httpApiStatus: 409 }) {};
|
|
21
|
+
var TooManyRequests = class extends Schema.TaggedError()("TooManyRequests", { error: Schema.String }, { httpApiStatus: 429 }) {};
|
|
22
|
+
/** The schema defines the public response; the diagnostic cause reaches logs, never a caller. */
|
|
23
|
+
var InternalServerError = class extends Schema.TaggedError()("InternalServerError", { error: Schema.String }, { httpApiStatus: 500 }) {
|
|
24
|
+
cause;
|
|
25
|
+
};
|
|
26
|
+
var ServiceUnavailable = class extends Schema.TaggedError()("ServiceUnavailable", { error: Schema.String }, { httpApiStatus: 503 }) {};
|
|
27
|
+
/**
|
|
28
|
+
* What every action may fail with, beside effect-actions' built-in `InvalidInput`,
|
|
29
|
+
* `Unauthenticated` and `Forbidden`, which every endpoint and tool declares.
|
|
30
|
+
*/
|
|
31
|
+
const errors = [
|
|
32
|
+
BadRequest,
|
|
33
|
+
NotFound,
|
|
34
|
+
Conflict,
|
|
35
|
+
TooManyRequests,
|
|
36
|
+
InternalServerError,
|
|
37
|
+
ServiceUnavailable
|
|
38
|
+
];
|
|
39
|
+
[...errors, Action.Forbidden];
|
|
40
|
+
/**
|
|
41
|
+
* The owner administering the issuer, as its authentication verified the request: what every
|
|
42
|
+
* administration action states, `caller: CurrentOwner`. Never supplied by action arguments or
|
|
43
|
+
* at startup.
|
|
44
|
+
*/
|
|
45
|
+
var CurrentOwner = class extends Context.Service()("clankerauth/CurrentOwner") {};
|
|
46
|
+
/**
|
|
47
|
+
* The dashboard's owner session: the issuer's session cookie, named `cookie`, which the browser
|
|
48
|
+
* sends. The provider names that cookie by the deployment, `__Secure-` prefixed over HTTPS, so
|
|
49
|
+
* the server builds this descriptor where it starts, from the name its sessions use, and builds
|
|
50
|
+
* both its binding and its `Authentication.layer` from it: that layer serves only a binding
|
|
51
|
+
* built from that very descriptor, not another naming the same cookie. A browser's binding,
|
|
52
|
+
* from `OwnerSession`, only calls. Its verifier reads the session through the provider, so it
|
|
53
|
+
* may fail as an action does, with any of `errors`, such as `ServiceUnavailable`, answered with
|
|
54
|
+
* its own status.
|
|
55
|
+
*/
|
|
56
|
+
const ownerSession = (cookie) => Authentication.make("clankerauth.OwnerSession", CurrentOwner, {
|
|
57
|
+
security: HttpApiSecurity.apiKey({
|
|
58
|
+
in: "cookie",
|
|
59
|
+
key: cookie
|
|
60
|
+
}),
|
|
61
|
+
error: errors
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* The browser's owner session. A page never needs the cookie's name, as the browser sends its
|
|
65
|
+
* cookies itself: this one names the provider's cookie over plain HTTP, which only the server
|
|
66
|
+
* would read.
|
|
67
|
+
*/
|
|
68
|
+
const OwnerSession = ownerSession("better-auth.session_token");
|
|
69
|
+
/**
|
|
70
|
+
* An administration client's OAuth access token for the issuer's origin root, `<baseURL>/`,
|
|
71
|
+
* over MCP or `OwnerHttp`. Its verifier reads the token's owner and client from the database,
|
|
72
|
+
* so it may fail as an action does, with any of `errors`: `ServiceUnavailable` when the token
|
|
73
|
+
* cannot be decided, or `InternalServerError`.
|
|
74
|
+
*/
|
|
75
|
+
const OwnerToken = Authentication.make("clankerauth.OwnerToken", CurrentOwner, { error: errors });
|
|
76
|
+
const SetupInput = Schema.Struct({
|
|
77
|
+
email: Schema.String,
|
|
78
|
+
password: Schema.String
|
|
79
|
+
});
|
|
80
|
+
const ClientAuthMethod = Schema.Literals([
|
|
81
|
+
"none",
|
|
82
|
+
"client_secret_basic",
|
|
83
|
+
"client_secret_post"
|
|
84
|
+
]);
|
|
85
|
+
const ApplicationType = Schema.Literals(["web", "native"]);
|
|
86
|
+
/** The resources a managed client may obtain, each named once. */
|
|
87
|
+
const ResourceSelection = Schema.Array(Schema.String).check(Schema.isUnique({ message: "Choose unique Resources" }));
|
|
88
|
+
/** Provider vocabulary: the server passes these fields through unchanged. */
|
|
89
|
+
const ClientInput = Schema.Struct({
|
|
90
|
+
client_name: Schema.String,
|
|
91
|
+
redirect_uris: Schema.Array(Schema.String),
|
|
92
|
+
resources: ResourceSelection,
|
|
93
|
+
token_endpoint_auth_method: ClientAuthMethod,
|
|
94
|
+
application_type: ApplicationType
|
|
95
|
+
});
|
|
96
|
+
/** The provider's update endpoint cannot change the authentication method. */
|
|
97
|
+
const ClientUpdateInput = Schema.Struct({
|
|
98
|
+
client_id: Schema.String,
|
|
99
|
+
client_name: Schema.String,
|
|
100
|
+
redirect_uris: Schema.Array(Schema.String),
|
|
101
|
+
application_type: ApplicationType
|
|
102
|
+
});
|
|
103
|
+
const ClientId = Schema.Struct({ client_id: Schema.String });
|
|
104
|
+
/** Without a resource, every authorization the client holds. */
|
|
105
|
+
const ClientRevokeInput = Schema.Struct({
|
|
106
|
+
client_id: Schema.String,
|
|
107
|
+
resource: Schema.optional(Schema.String)
|
|
108
|
+
});
|
|
109
|
+
const ClientBlockInput = Schema.Struct({
|
|
110
|
+
client_id: Schema.String,
|
|
111
|
+
blocked: Schema.Boolean
|
|
112
|
+
});
|
|
113
|
+
const Client = Schema.Struct({
|
|
114
|
+
onboarding: Schema.optional(Schema.Literals([
|
|
115
|
+
"managed",
|
|
116
|
+
"dcr",
|
|
117
|
+
"cimd"
|
|
118
|
+
])),
|
|
119
|
+
blocked: Schema.optional(Schema.Boolean),
|
|
120
|
+
client_id: Schema.String,
|
|
121
|
+
client_name: Schema.optional(Schema.String),
|
|
122
|
+
redirect_uris: Schema.Array(Schema.String),
|
|
123
|
+
token_endpoint_auth_method: Schema.optional(Schema.String),
|
|
124
|
+
application_type: Schema.optional(Schema.NullOr(Schema.String)),
|
|
125
|
+
scope: Schema.optional(Schema.String),
|
|
126
|
+
grant_types: Schema.optional(Schema.Array(Schema.String))
|
|
127
|
+
});
|
|
128
|
+
const ClientCredentials = Client.pipe(Schema.fieldsAssign({
|
|
129
|
+
client_secret: Schema.optional(Schema.String),
|
|
130
|
+
client_secret_expires_at: Schema.optional(Schema.Number)
|
|
131
|
+
}));
|
|
132
|
+
const Resource = Schema.Struct({
|
|
133
|
+
identifier: Schema.String,
|
|
134
|
+
name: Schema.String,
|
|
135
|
+
scopes: Schema.Array(Schema.String)
|
|
136
|
+
});
|
|
137
|
+
const ResourceSummary = Resource.pipe(Schema.fieldsAssign({ builtIn: Schema.Boolean }));
|
|
138
|
+
/**
|
|
139
|
+
* A resource as the owner writes it. The issuer trims each field before storing it, so the
|
|
140
|
+
* rules allow surrounding whitespace: a name that is not blank, and scopes that are OAuth
|
|
141
|
+
* scope tokens (RFC 6749 §3.3). Stored resources are `Resource`, which carries no rule.
|
|
142
|
+
*/
|
|
143
|
+
const ResourceInput = Schema.Struct({
|
|
144
|
+
identifier: Schema.String,
|
|
145
|
+
name: Schema.String.check(Schema.isPattern(/\S/u, { message: "Resources require a name" })),
|
|
146
|
+
scopes: Schema.Array(Schema.String.check(Schema.isPattern(/^\s*[\x21\x23-\x5B\x5D-\x7E]+\s*$/u, { message: "Resources require well-formed custom scopes" })))
|
|
147
|
+
});
|
|
148
|
+
const ResourceId = Schema.Struct({ identifier: Schema.String });
|
|
149
|
+
const ClientAccess = Schema.Struct({
|
|
150
|
+
client_id: Schema.String,
|
|
151
|
+
resource: Schema.String
|
|
152
|
+
});
|
|
153
|
+
const ClientAccessInput = Schema.Struct({
|
|
154
|
+
client_id: Schema.String,
|
|
155
|
+
resources: ResourceSelection
|
|
156
|
+
});
|
|
157
|
+
const ClientAccessResult = Schema.Struct({ clientAccess: Schema.Array(ClientAccess) });
|
|
158
|
+
const KeyPermissions = Schema.Record(Schema.String, Schema.Array(Schema.String));
|
|
159
|
+
/**
|
|
160
|
+
* Permissions as the owner grants them: at least one resource, each with scopes named once.
|
|
161
|
+
* A stored key's are `KeyPermissions`, which a key whose permissions no longer decode is
|
|
162
|
+
* listed with, empty.
|
|
163
|
+
*/
|
|
164
|
+
const KeyGrants = Schema.Record(Schema.String, Schema.Array(Schema.String).check(Schema.isMinLength(1, { message: "Select explicitly granted, currently available Resource scopes" }), Schema.isUnique({ message: "Select explicitly granted, currently available Resource scopes" }))).check(Schema.isMinProperties(1, { message: "Select at least one Resource and scope" }));
|
|
165
|
+
/** A key's name as the owner writes it; the issuer stores it trimmed. */
|
|
166
|
+
const KeyName = Schema.String.check(Schema.isMaxLength(100, { message: "Key names require 1–100 characters" }), Schema.isPattern(/\S/u, { message: "Key names require 1–100 characters" }));
|
|
167
|
+
/**
|
|
168
|
+
* Timestamps stay ISO strings on the wire and arrive as `DateTime.Utc` values. An input
|
|
169
|
+
* without an offset is read as UTC, so the instant an API caller means never depends on
|
|
170
|
+
* the host's time zone; every value this issuer writes carries `Z`.
|
|
171
|
+
*/
|
|
172
|
+
const Timestamp = Schema.DateTimeUtcFromString;
|
|
173
|
+
/**
|
|
174
|
+
* What a client holds for one resource: a consent, a live refresh token, or both. A
|
|
175
|
+
* managed client skips consent, so `approvedAt` is null for it and it shows only while it
|
|
176
|
+
* holds a refresh token. `scopes` are the consent's, else the newest token's.
|
|
177
|
+
* `refreshedAt` is when the newest live refresh token was issued, at sign-in or on refresh.
|
|
178
|
+
*/
|
|
179
|
+
const Connection = Schema.Struct({
|
|
180
|
+
client_id: Schema.String,
|
|
181
|
+
resource: Schema.String,
|
|
182
|
+
scopes: Schema.Array(Schema.String),
|
|
183
|
+
approvedAt: Schema.NullOr(Timestamp),
|
|
184
|
+
refreshedAt: Schema.NullOr(Timestamp)
|
|
185
|
+
});
|
|
186
|
+
const ApiKeyInput = Schema.Struct({
|
|
187
|
+
name: KeyName,
|
|
188
|
+
permissions: KeyGrants,
|
|
189
|
+
expiresAt: Schema.NullOr(Timestamp)
|
|
190
|
+
});
|
|
191
|
+
const ApiKeyId = Schema.Struct({ keyId: Schema.String });
|
|
192
|
+
const ApiKeyUpdate = Schema.Struct({
|
|
193
|
+
keyId: Schema.String,
|
|
194
|
+
name: Schema.optional(KeyName),
|
|
195
|
+
permissions: Schema.optional(KeyGrants),
|
|
196
|
+
enabled: Schema.optional(Schema.Boolean)
|
|
197
|
+
});
|
|
198
|
+
const MachineKey = Schema.Struct({
|
|
199
|
+
keyId: Schema.String,
|
|
200
|
+
name: Schema.String,
|
|
201
|
+
enabled: Schema.Boolean,
|
|
202
|
+
permissions: KeyPermissions,
|
|
203
|
+
expiresAt: Schema.NullOr(Timestamp),
|
|
204
|
+
createdAt: Timestamp
|
|
205
|
+
});
|
|
206
|
+
/** What every issuer action shares: any caller, and the contract's errors. */
|
|
207
|
+
const anyone = {
|
|
208
|
+
caller: Action.Anyone,
|
|
209
|
+
error: errors
|
|
210
|
+
};
|
|
211
|
+
/** What every administration action shares: the owner as its caller, and the contract's errors. */
|
|
212
|
+
const owned = {
|
|
213
|
+
caller: CurrentOwner,
|
|
214
|
+
error: errors
|
|
215
|
+
};
|
|
216
|
+
const IssuerActions = [
|
|
217
|
+
Action.make("setupStatus", {
|
|
218
|
+
description: "Check whether the issuer needs its first owner account.",
|
|
219
|
+
readOnly: true,
|
|
220
|
+
...anyone,
|
|
221
|
+
success: Schema.Struct({ required: Schema.Boolean })
|
|
222
|
+
}),
|
|
223
|
+
Action.make("setupOwner", {
|
|
224
|
+
description: "Create the first owner account.",
|
|
225
|
+
readOnly: false,
|
|
226
|
+
...anyone,
|
|
227
|
+
input: SetupInput,
|
|
228
|
+
success: Schema.Struct({ created: Schema.Boolean }).pipe(HttpApiSchema.status(201))
|
|
229
|
+
}),
|
|
230
|
+
Action.make("keyList", {
|
|
231
|
+
description: "The signed list a resource server verifies API keys against: one sealed entry per key granted on the resource, readable only with that key.",
|
|
232
|
+
readOnly: true,
|
|
233
|
+
...anyone,
|
|
234
|
+
input: Schema.Struct({ resource: Schema.String }),
|
|
235
|
+
success: Schema.Struct({ list: Schema.String })
|
|
236
|
+
})
|
|
237
|
+
];
|
|
238
|
+
const ClientListResult = Schema.Struct({
|
|
239
|
+
clients: Schema.Array(Client),
|
|
240
|
+
resources: Schema.Array(ResourceSummary),
|
|
241
|
+
clientAccess: Schema.Array(ClientAccess),
|
|
242
|
+
connections: Schema.Array(Connection),
|
|
243
|
+
email: Schema.String,
|
|
244
|
+
issuer: Schema.String
|
|
245
|
+
});
|
|
246
|
+
const Administration = [
|
|
247
|
+
Action.make("listClients", {
|
|
248
|
+
description: "List clients, resources, managed clients' resource access (what they may obtain), and every client's connections (what it holds).",
|
|
249
|
+
readOnly: true,
|
|
250
|
+
...owned,
|
|
251
|
+
success: ClientListResult
|
|
252
|
+
}),
|
|
253
|
+
Action.make("createClient", {
|
|
254
|
+
description: "Register a first-party OAuth client. Returns its secret once when confidential.",
|
|
255
|
+
readOnly: false,
|
|
256
|
+
...owned,
|
|
257
|
+
input: ClientInput,
|
|
258
|
+
success: ClientCredentials.pipe(HttpApiSchema.status(201))
|
|
259
|
+
}),
|
|
260
|
+
Action.make("updateClient", {
|
|
261
|
+
description: "Rename a first-party client or change its redirect URIs and application type.",
|
|
262
|
+
readOnly: false,
|
|
263
|
+
...owned,
|
|
264
|
+
input: ClientUpdateInput,
|
|
265
|
+
success: Client
|
|
266
|
+
}),
|
|
267
|
+
Action.make("deleteClient", {
|
|
268
|
+
description: "Delete a client and its stored authorization. An automatic client can register again; block it to keep it out.",
|
|
269
|
+
readOnly: false,
|
|
270
|
+
...owned,
|
|
271
|
+
input: ClientId,
|
|
272
|
+
success: Schema.Struct({ deleted: Schema.Boolean })
|
|
273
|
+
}),
|
|
274
|
+
Action.make("revokeClient", {
|
|
275
|
+
description: "Revoke a client’s stored authorization, for one resource or all. It must authorize again; an automatic client asks for consent.",
|
|
276
|
+
readOnly: false,
|
|
277
|
+
...owned,
|
|
278
|
+
input: ClientRevokeInput,
|
|
279
|
+
success: Schema.Struct({ revoked: Schema.Boolean })
|
|
280
|
+
}),
|
|
281
|
+
Action.make("blockClient", {
|
|
282
|
+
description: "Block or unblock OAuth authorization for a client.",
|
|
283
|
+
readOnly: false,
|
|
284
|
+
...owned,
|
|
285
|
+
input: ClientBlockInput,
|
|
286
|
+
success: Schema.Struct({ blocked: Schema.Boolean })
|
|
287
|
+
}),
|
|
288
|
+
Action.make("rotateClientSecret", {
|
|
289
|
+
description: "Rotate a confidential client secret. Returns the new secret once.",
|
|
290
|
+
readOnly: false,
|
|
291
|
+
...owned,
|
|
292
|
+
input: ClientId,
|
|
293
|
+
success: ClientCredentials
|
|
294
|
+
}),
|
|
295
|
+
Action.make("setClientAccess", {
|
|
296
|
+
description: "Set the resources a managed client may obtain. Removing one revokes its authorization for it. Automatic clients may ask for any resource with consent.",
|
|
297
|
+
readOnly: false,
|
|
298
|
+
...owned,
|
|
299
|
+
input: ClientAccessInput,
|
|
300
|
+
success: ClientAccessResult
|
|
301
|
+
}),
|
|
302
|
+
Action.make("createResource", {
|
|
303
|
+
description: "Create an OAuth resource and its scopes.",
|
|
304
|
+
readOnly: false,
|
|
305
|
+
...owned,
|
|
306
|
+
input: ResourceInput,
|
|
307
|
+
success: Resource.pipe(HttpApiSchema.status(201))
|
|
308
|
+
}),
|
|
309
|
+
Action.make("updateResource", {
|
|
310
|
+
description: "Update an OAuth resource and its scopes.",
|
|
311
|
+
readOnly: false,
|
|
312
|
+
...owned,
|
|
313
|
+
input: ResourceInput,
|
|
314
|
+
success: Resource
|
|
315
|
+
}),
|
|
316
|
+
Action.make("deleteResource", {
|
|
317
|
+
description: "Delete a resource, its client access and every client’s authorization for it. API-key permissions are retained; recreating the resource restores them.",
|
|
318
|
+
readOnly: false,
|
|
319
|
+
...owned,
|
|
320
|
+
input: ResourceId,
|
|
321
|
+
success: Schema.Struct({ deleted: Schema.Boolean })
|
|
322
|
+
}),
|
|
323
|
+
Action.make("listApiKeys", {
|
|
324
|
+
description: "List API key metadata without secret values.",
|
|
325
|
+
readOnly: true,
|
|
326
|
+
...owned,
|
|
327
|
+
success: Schema.Struct({ keys: Schema.Array(MachineKey) })
|
|
328
|
+
}),
|
|
329
|
+
Action.make("createApiKey", {
|
|
330
|
+
description: "Create a scoped API key. Returns its secret once.",
|
|
331
|
+
readOnly: false,
|
|
332
|
+
...owned,
|
|
333
|
+
input: ApiKeyInput,
|
|
334
|
+
success: MachineKey.pipe(Schema.fieldsAssign({ key: Schema.String }), HttpApiSchema.status(201))
|
|
335
|
+
}),
|
|
336
|
+
Action.make("updateApiKey", {
|
|
337
|
+
description: "Update an API key’s name, permissions or enabled state.",
|
|
338
|
+
readOnly: false,
|
|
339
|
+
...owned,
|
|
340
|
+
input: ApiKeyUpdate,
|
|
341
|
+
success: MachineKey
|
|
342
|
+
}),
|
|
343
|
+
Action.make("deleteApiKey", {
|
|
344
|
+
description: "Delete an API key.",
|
|
345
|
+
readOnly: false,
|
|
346
|
+
...owned,
|
|
347
|
+
input: ApiKeyId,
|
|
348
|
+
success: Schema.Struct({ deleted: Schema.Boolean })
|
|
349
|
+
})
|
|
350
|
+
];
|
|
351
|
+
/**
|
|
352
|
+
* Every action over HTTP, at `/api/<action>`, owner administration authenticated by
|
|
353
|
+
* `authentication`: the server's, from its deployment's cookie, or the browser's
|
|
354
|
+
* `OwnerSession`. Resource servers read `/api/keyList`, so that path is part of the protocol.
|
|
355
|
+
*/
|
|
356
|
+
const binding = (authentication) => ActionHttp.make([...Administration, ...IssuerActions], { authentication });
|
|
357
|
+
binding(OwnerSession);
|
|
358
|
+
/**
|
|
359
|
+
* Owner administration over HTTP for a client holding an access token, such as the CLI, at
|
|
360
|
+
* `/api/owner/<action>`: the actions `/mcp` serves, authenticated as it is, by `OwnerToken`.
|
|
361
|
+
*/
|
|
362
|
+
const OwnerHttp = ActionHttp.make(Administration, {
|
|
363
|
+
prefix: "/api/owner",
|
|
364
|
+
authentication: OwnerToken
|
|
365
|
+
});
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region package.json
|
|
368
|
+
var version = "0.16.0";
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/credentials.ts
|
|
371
|
+
/** The kept sign-in cannot be read or written. */
|
|
372
|
+
var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { message: Schema.String }) {};
|
|
373
|
+
const failed = (what) => (error) => new CredentialsError({ message: `${what} ${credentialsPath()}: ${error.message}` });
|
|
374
|
+
/**
|
|
375
|
+
* One owner sign-in: the issuer it is for, the client it registered as, and its tokens. The
|
|
376
|
+
* access token lasts fifteen minutes and the refresh token rotates on every use, so the file
|
|
377
|
+
* is rewritten whenever a command refreshes.
|
|
378
|
+
*/
|
|
379
|
+
const Credentials = Schema.Struct({
|
|
380
|
+
/** The issuer's origin, `CLANKERAUTH_BASE_URL`: where `/api/owner/<action>` is served. */
|
|
381
|
+
url: Schema.String,
|
|
382
|
+
/** The administration resource, the token audience: the issuer's origin root. */
|
|
383
|
+
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
|
+
scope: Schema.String,
|
|
389
|
+
accessToken: Schema.String,
|
|
390
|
+
refreshToken: Schema.String,
|
|
391
|
+
/** When the access token expires, as an ISO instant. */
|
|
392
|
+
expiresAt: Schema.String
|
|
393
|
+
});
|
|
394
|
+
const CredentialsJson = Schema.fromJsonString(Credentials);
|
|
395
|
+
/** Where the sign-in is kept: `$XDG_CONFIG_HOME/clankerauth`, or `~/.config/clankerauth`. */
|
|
396
|
+
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
|
+
/**
|
|
406
|
+
* Replace the sign-in, readable by its owner alone. Written beside it and renamed over it, so
|
|
407
|
+
* a command interrupted mid-write never leaves a refresh token half-saved.
|
|
408
|
+
*/
|
|
409
|
+
const writeCredentials = Effect.fn("Credentials.write")(function* (credentials) {
|
|
410
|
+
const path = credentialsPath();
|
|
411
|
+
const json = yield* Schema.encodeEffect(CredentialsJson)(credentials);
|
|
412
|
+
const staging = `${path}.${randomBytes(6).toString("hex")}`;
|
|
413
|
+
yield* Effect.tryPromise(async () => {
|
|
414
|
+
await mkdir(dirname(path), {
|
|
415
|
+
recursive: true,
|
|
416
|
+
mode: 448
|
|
417
|
+
});
|
|
418
|
+
await writeFile(staging, `${json}\n`, { mode: 384 });
|
|
419
|
+
await rename(staging, path);
|
|
420
|
+
}).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
|
|
421
|
+
}, Effect.mapError(failed("Cannot write")));
|
|
422
|
+
const removeCredentials = () => Effect.tryPromise(() => rm(credentialsPath(), { force: true })).pipe(Effect.mapError(failed("Cannot remove")));
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/oauth.ts
|
|
425
|
+
/** Something the issuer answered that a sign-in or a refresh cannot go on from. */
|
|
426
|
+
var OAuthError = class extends Schema.TaggedError()("OAuthError", { message: Schema.String }) {};
|
|
427
|
+
/** RFC 9728: where a resource says its tokens come from. */
|
|
428
|
+
const ProtectedResource = Schema.Struct({
|
|
429
|
+
resource: Schema.String,
|
|
430
|
+
authorization_servers: Schema.NonEmptyArray(Schema.String)
|
|
431
|
+
});
|
|
432
|
+
/** RFC 8414: the endpoints a sign-in uses. */
|
|
433
|
+
const AuthorizationServer = Schema.Struct({
|
|
434
|
+
issuer: Schema.String,
|
|
435
|
+
authorization_endpoint: Schema.String,
|
|
436
|
+
token_endpoint: Schema.String,
|
|
437
|
+
registration_endpoint: Schema.String,
|
|
438
|
+
revocation_endpoint: Schema.optionalKey(Schema.String)
|
|
439
|
+
});
|
|
440
|
+
const Registration = Schema.Struct({ client_id: Schema.String });
|
|
441
|
+
const Tokens = Schema.Struct({
|
|
442
|
+
access_token: Schema.String,
|
|
443
|
+
refresh_token: Schema.String,
|
|
444
|
+
expires_in: Schema.Number,
|
|
445
|
+
scope: Schema.optionalKey(Schema.String)
|
|
446
|
+
});
|
|
447
|
+
/** An OAuth error response's fields, when the issuer sent one. */
|
|
448
|
+
const ErrorBody = Schema.Struct({
|
|
449
|
+
error: Schema.String,
|
|
450
|
+
error_description: Schema.optionalKey(Schema.String)
|
|
451
|
+
});
|
|
452
|
+
/** Send `request` and decode its JSON as `schema`, failing with what the issuer said. */
|
|
453
|
+
const exchange = (what, schema, request) => Effect.gen(function* () {
|
|
454
|
+
const response = yield* Effect.tryPromise({
|
|
455
|
+
try: request,
|
|
456
|
+
catch: (error) => new OAuthError({ message: `${what}: the issuer cannot be reached (${String(error)})` })
|
|
457
|
+
});
|
|
458
|
+
const text = yield* Effect.promise(() => response.text());
|
|
459
|
+
if (!response.ok) return yield* new OAuthError({ message: `${what}: ${Option.match(Schema.decodeUnknownOption(Schema.fromJsonString(ErrorBody))(text), {
|
|
460
|
+
onNone: () => `HTTP ${response.status}`,
|
|
461
|
+
onSome: (body) => body.error_description === void 0 ? body.error : `${body.error}: ${body.error_description}`
|
|
462
|
+
})}` });
|
|
463
|
+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(text).pipe(Effect.mapError((error) => new OAuthError({ message: `${what}: unexpected answer (${error.message})` })));
|
|
464
|
+
});
|
|
465
|
+
/**
|
|
466
|
+
* The issuer at `url` as a sign-in reaches it: its administration resource, published at the
|
|
467
|
+
* origin root's RFC 9728 document, and the authorization server that resource names.
|
|
468
|
+
*/
|
|
469
|
+
const discover = Effect.fn("OAuth.discover")(function* (url) {
|
|
470
|
+
const resource = yield* exchange("Discovery", ProtectedResource, () => fetch(new URL("/.well-known/oauth-protected-resource", url)));
|
|
471
|
+
const issuer = new URL(resource.authorization_servers[0]);
|
|
472
|
+
const metadata = new URL(`/.well-known/oauth-authorization-server${issuer.pathname === "/" ? "" : issuer.pathname}`, issuer);
|
|
473
|
+
const server = yield* exchange("Discovery", AuthorizationServer, () => fetch(metadata));
|
|
474
|
+
return {
|
|
475
|
+
resource: resource.resource,
|
|
476
|
+
server
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
/**
|
|
480
|
+
* Register this CLI as a public native client. The issuer matches a loopback redirect without
|
|
481
|
+
* its port (RFC 8252), so one registration serves every port a sign-in listens on.
|
|
482
|
+
*/
|
|
483
|
+
const register = Effect.fn("OAuth.register")(function* (server, redirectUri) {
|
|
484
|
+
return (yield* exchange("Registration", Registration, () => fetch(server.registration_endpoint, {
|
|
485
|
+
method: "POST",
|
|
486
|
+
headers: { "content-type": "application/json" },
|
|
487
|
+
body: JSON.stringify({
|
|
488
|
+
client_name: "clankerauth CLI",
|
|
489
|
+
redirect_uris: [redirectUri],
|
|
490
|
+
application_type: "native",
|
|
491
|
+
token_endpoint_auth_method: "none",
|
|
492
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
493
|
+
response_types: ["code"]
|
|
494
|
+
})
|
|
495
|
+
}))).client_id;
|
|
496
|
+
});
|
|
497
|
+
/** Ask the token endpoint for tokens, with the grant's own form fields. */
|
|
498
|
+
const token = (what, endpoint, form) => exchange(what, Tokens, () => fetch(endpoint, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
501
|
+
body: form
|
|
502
|
+
}));
|
|
503
|
+
/** Revoke a refresh token, and with it every token of its family. */
|
|
504
|
+
const revoke = (endpoint, clientId, refreshToken) => Effect.tryPromise(() => fetch(endpoint, {
|
|
505
|
+
method: "POST",
|
|
506
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
507
|
+
body: new URLSearchParams({
|
|
508
|
+
token: refreshToken,
|
|
509
|
+
token_type_hint: "refresh_token",
|
|
510
|
+
client_id: clientId
|
|
511
|
+
})
|
|
512
|
+
}));
|
|
513
|
+
//#endregion
|
|
514
|
+
//#region src/login.ts
|
|
515
|
+
/** What the owner grants: everything, or only listing. */
|
|
516
|
+
const scopes = (readOnly) => readOnly ? "clankerauth:read offline_access" : "clankerauth:read clankerauth:write offline_access";
|
|
517
|
+
/** The redirect a registration names; a sign-in adds the port it listens on. */
|
|
518
|
+
const registeredRedirect = "http://127.0.0.1/callback";
|
|
519
|
+
/** How long a sign-in waits for the owner to approve it in the browser. */
|
|
520
|
+
const approvalWindow = "5 minutes";
|
|
521
|
+
const expiry = (tokens) => new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
522
|
+
/** What the callback page tells the browser; the terminal has the details. */
|
|
523
|
+
const page = (heading) => `<!doctype html><meta charset="utf-8"><title>clankerauth</title><p>${heading}</p>`;
|
|
524
|
+
/** Listen on a free loopback port for the one request the authorization redirect makes. */
|
|
525
|
+
const listen = Effect.acquireRelease(Effect.gen(function* () {
|
|
526
|
+
const received = yield* Deferred.make();
|
|
527
|
+
const server = createServer((request, response) => {
|
|
528
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
529
|
+
if (url.pathname !== "/callback") {
|
|
530
|
+
response.writeHead(404).end();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const query = url.searchParams;
|
|
534
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page(query.has("code") ? "Signed in. You can close this tab." : "Sign-in failed. The terminal says why."));
|
|
535
|
+
Deferred.doneUnsafe(received, Effect.succeed({
|
|
536
|
+
code: query.get("code"),
|
|
537
|
+
state: query.get("state"),
|
|
538
|
+
iss: query.get("iss"),
|
|
539
|
+
error: query.get("error"),
|
|
540
|
+
errorDescription: query.get("error_description")
|
|
541
|
+
}));
|
|
542
|
+
});
|
|
543
|
+
return {
|
|
544
|
+
server,
|
|
545
|
+
port: yield* Effect.callback((resume) => {
|
|
546
|
+
server.once("error", (error) => resume(Effect.fail(new OAuthError({ message: `Cannot listen for the redirect: ${error.message}` }))));
|
|
547
|
+
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server.address().port)));
|
|
548
|
+
}),
|
|
549
|
+
received
|
|
550
|
+
};
|
|
551
|
+
}), ({ server }) => Effect.sync(() => {
|
|
552
|
+
server.closeAllConnections();
|
|
553
|
+
server.close();
|
|
554
|
+
}));
|
|
555
|
+
/** Open `url` in the default browser, if the platform has a way to; never fails. */
|
|
556
|
+
const openBrowser = (url) => Effect.sync(() => {
|
|
557
|
+
const [command, ...args] = Match.value(process.platform).pipe(Match.when("darwin", () => ["open", url]), Match.when("win32", () => [
|
|
558
|
+
"cmd",
|
|
559
|
+
"/c",
|
|
560
|
+
"start",
|
|
561
|
+
"",
|
|
562
|
+
url
|
|
563
|
+
]), Match.orElse(() => ["xdg-open", url]));
|
|
564
|
+
try {
|
|
565
|
+
spawn(command ?? "open", args, {
|
|
566
|
+
stdio: "ignore",
|
|
567
|
+
detached: true
|
|
568
|
+
}).on("error", () => {}).unref();
|
|
569
|
+
} catch {}
|
|
570
|
+
});
|
|
571
|
+
/**
|
|
572
|
+
* Sign in to the issuer at `url` as its owner: discover it, register this CLI once per
|
|
573
|
+
* issuer, send the owner to approve in the browser, and keep the tokens. What it prints on
|
|
574
|
+
* stderr is for the person; stdout gets only the result.
|
|
575
|
+
*/
|
|
576
|
+
const login = Effect.fn("Cli.login")(function* (url, readOnly, browser) {
|
|
577
|
+
const origin = new URL(url).origin;
|
|
578
|
+
const { resource, server } = yield* discover(origin);
|
|
579
|
+
const previous = yield* readCredentials();
|
|
580
|
+
const clientId = yield* Option.match(Option.filter(previous, (credentials) => credentials.url === origin), {
|
|
581
|
+
onSome: (credentials) => Effect.succeed(credentials.clientId),
|
|
582
|
+
onNone: () => register(server, registeredRedirect)
|
|
583
|
+
});
|
|
584
|
+
const { port, received } = yield* listen;
|
|
585
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
586
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
587
|
+
const state = randomBytes(16).toString("base64url");
|
|
588
|
+
const scope = scopes(readOnly);
|
|
589
|
+
const authorization = new URL(server.authorization_endpoint);
|
|
590
|
+
authorization.search = new URLSearchParams({
|
|
591
|
+
client_id: clientId,
|
|
592
|
+
redirect_uri: redirectUri,
|
|
593
|
+
response_type: "code",
|
|
594
|
+
scope,
|
|
595
|
+
resource,
|
|
596
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
597
|
+
code_challenge_method: "S256",
|
|
598
|
+
state
|
|
599
|
+
}).toString();
|
|
600
|
+
yield* Effect.sync(() => process.stderr.write(`Approve the sign-in in your browser:\n\n ${authorization.href}\n\n`));
|
|
601
|
+
if (browser) yield* openBrowser(authorization.href);
|
|
602
|
+
const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
|
|
603
|
+
duration: approvalWindow,
|
|
604
|
+
orElse: () => Effect.fail(new OAuthError({ message: "Sign-in: nobody approved it within five minutes" }))
|
|
605
|
+
}));
|
|
606
|
+
if (callback.state !== state) return yield* new OAuthError({ message: "Sign-in: the redirect's state does not match" });
|
|
607
|
+
if (callback.iss !== null && callback.iss !== server.issuer) return yield* new OAuthError({ message: `Sign-in: the redirect came from ${callback.iss}` });
|
|
608
|
+
if (callback.code === null) return yield* new OAuthError({ message: `Sign-in: ${callback.error ?? "no code"}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}` });
|
|
609
|
+
const tokens = yield* token("Sign-in", server.token_endpoint, new URLSearchParams({
|
|
610
|
+
grant_type: "authorization_code",
|
|
611
|
+
client_id: clientId,
|
|
612
|
+
code: callback.code,
|
|
613
|
+
code_verifier: verifier,
|
|
614
|
+
redirect_uri: redirectUri,
|
|
615
|
+
resource
|
|
616
|
+
}));
|
|
617
|
+
const credentials = {
|
|
618
|
+
url: origin,
|
|
619
|
+
resource,
|
|
620
|
+
tokenEndpoint: server.token_endpoint,
|
|
621
|
+
revocationEndpoint: server.revocation_endpoint ?? null,
|
|
622
|
+
clientId,
|
|
623
|
+
scope: tokens.scope ?? scope,
|
|
624
|
+
accessToken: tokens.access_token,
|
|
625
|
+
refreshToken: tokens.refresh_token,
|
|
626
|
+
expiresAt: expiry(tokens)
|
|
627
|
+
};
|
|
628
|
+
yield* writeCredentials(credentials);
|
|
629
|
+
return {
|
|
630
|
+
url: origin,
|
|
631
|
+
scope: credentials.scope
|
|
632
|
+
};
|
|
633
|
+
}, Effect.scoped);
|
|
634
|
+
/**
|
|
635
|
+
* The sign-in's access token, refreshed first when it has expired or is about to: the
|
|
636
|
+
* refresh token rotates, so the new pair is saved before the token is used.
|
|
637
|
+
*/
|
|
638
|
+
const accessToken = Effect.fn("Cli.accessToken")(function* (credentials) {
|
|
639
|
+
if (Date.parse(credentials.expiresAt) - Date.now() > 6e4) return {
|
|
640
|
+
credentials,
|
|
641
|
+
token: credentials.accessToken
|
|
642
|
+
};
|
|
643
|
+
const tokens = yield* token("Refresh", credentials.tokenEndpoint, new URLSearchParams({
|
|
644
|
+
grant_type: "refresh_token",
|
|
645
|
+
client_id: credentials.clientId,
|
|
646
|
+
refresh_token: credentials.refreshToken,
|
|
647
|
+
resource: credentials.resource
|
|
648
|
+
})).pipe(Effect.mapError((error) => new OAuthError({ message: `${error.message}. Run \`clankerauth login ${credentials.url}\` again.` })));
|
|
649
|
+
const refreshed = {
|
|
650
|
+
...credentials,
|
|
651
|
+
scope: tokens.scope ?? credentials.scope,
|
|
652
|
+
accessToken: tokens.access_token,
|
|
653
|
+
refreshToken: tokens.refresh_token,
|
|
654
|
+
expiresAt: expiry(tokens)
|
|
655
|
+
};
|
|
656
|
+
yield* writeCredentials(refreshed);
|
|
657
|
+
return {
|
|
658
|
+
credentials: refreshed,
|
|
659
|
+
token: refreshed.accessToken
|
|
660
|
+
};
|
|
661
|
+
});
|
|
662
|
+
/** Forget the sign-in, revoking its refresh token first when the issuer can be reached. */
|
|
663
|
+
const logout = Effect.fn("Cli.logout")(function* () {
|
|
664
|
+
const credentials = yield* readCredentials();
|
|
665
|
+
yield* Option.match(credentials, {
|
|
666
|
+
onNone: () => Effect.void,
|
|
667
|
+
onSome: ({ revocationEndpoint, clientId, refreshToken }) => revocationEndpoint === null ? Effect.void : Effect.ignore(revoke(revocationEndpoint, clientId, refreshToken))
|
|
668
|
+
});
|
|
669
|
+
yield* removeCredentials();
|
|
670
|
+
return { signedOut: Option.isSome(credentials) };
|
|
671
|
+
});
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/main.ts
|
|
674
|
+
/** A failure of this CLI's own, rendered as its message on stderr, exiting 1. */
|
|
675
|
+
const refused = (error) => new CliError.UserError({
|
|
676
|
+
cause: error,
|
|
677
|
+
userMessage: error.message
|
|
678
|
+
});
|
|
679
|
+
const json = (value) => Console.log(JSON.stringify(value, null, 2));
|
|
680
|
+
const loginCommand = Command.make("login", {
|
|
681
|
+
url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, such as https://auth.example.com")),
|
|
682
|
+
readOnly: Flag.Boolean("read-only").pipe(Flag.withDescription("Ask for clankerauth:read alone: list, never change"), Flag.withDefault(false)),
|
|
683
|
+
browser: Flag.Boolean("browser").pipe(Flag.withDescription("Open the approval page; --no-browser only prints its URL"), Flag.withDefault(true))
|
|
684
|
+
}, ({ url, readOnly, browser }) => login(url, readOnly, browser).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Sign in to an issuer as its owner. The sign-in is kept in ~/.config/clankerauth."));
|
|
685
|
+
const logoutCommand = Command.make("logout", {}, () => logout().pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Revoke the sign-in and forget it."));
|
|
686
|
+
/**
|
|
687
|
+
* The client every administration command calls through: the signed-in issuer's origin and
|
|
688
|
+
* a current access token, refreshed and saved first when it has expired.
|
|
689
|
+
*/
|
|
690
|
+
const signedIn = Effect.gen(function* () {
|
|
691
|
+
const stored = yield* readCredentials().pipe(Effect.mapError(refused));
|
|
692
|
+
if (Option.isNone(stored)) return yield* new CliError.UserError({
|
|
693
|
+
cause: "not signed in",
|
|
694
|
+
userMessage: "Not signed in: run `clankerauth login <url>` first."
|
|
695
|
+
});
|
|
696
|
+
const { credentials, token } = yield* accessToken(stored.value).pipe(Effect.mapError(refused));
|
|
697
|
+
return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(credentials.url), HttpClientRequest.bearerToken(token)));
|
|
698
|
+
});
|
|
699
|
+
/** Every administration action, as a command of its own, called over `/api/owner`. */
|
|
700
|
+
const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, signedIn)));
|
|
701
|
+
const cli = Command.make("clankerauth").pipe(Command.withDescription("Administer a clankerauth issuer as its owner. Results are JSON on stdout; a created key or client secret is in it once."), Command.withSubcommands([
|
|
702
|
+
loginCommand,
|
|
703
|
+
logoutCommand,
|
|
704
|
+
...administration
|
|
705
|
+
]));
|
|
706
|
+
Command.run(cli, { version }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeServices.layer), ActionCli.logToStderr, NodeRuntime.runMain);
|
|
707
|
+
//#endregion
|
|
708
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerauth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.16.0",
|
|
4
|
+
"description": "The clankerauth command line: sign in to an issuer as its owner and administer it over HTTP.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/gjermundgaraba/clankerauth.git",
|
|
9
|
+
"directory": "apps/cli"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"clankerauth": "./dist/main.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@effect/platform-node": "4.0.2",
|
|
23
|
+
"@gjermundgaraba/effect-actions": "0.11.0",
|
|
24
|
+
"effect": "4.0.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@clankerauth/admin-api": "0.1.0",
|
|
28
|
+
"@gjermundgaraba/clankerauth-dev": "0.16.0",
|
|
29
|
+
"@types/node": "26.6.4",
|
|
30
|
+
"typescript": "7.0.2",
|
|
31
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
32
|
+
"vite-plus": "1.1.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=26"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "vp pack",
|
|
39
|
+
"test": "vp test"
|
|
40
|
+
}
|
|
6
41
|
}
|