teamclaws 1.0.4 → 2.0.1
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 +92 -12
- package/dist/app.js +23 -19
- package/dist/auth.js +74 -109
- package/dist/broker.js +37 -18
- package/dist/constants.js +2 -1
- package/dist/external-chat.js +20 -19
- package/dist/http.js +2 -0
- package/dist/machine-key.js +245 -0
- package/dist/pairing.js +50 -42
- package/dist/shell.js +5 -6
- package/dist/storage.js +16 -0
- package/dist/target-authorization.js +150 -0
- package/dist/validation.js +39 -7
- package/package.json +1 -1
- package/skills/teamclaws-login/SKILL.md +24 -8
- package/skills/teamclaws-onboard/SKILL.md +89 -19
- package/skills/teamclaws-onboard/references/workspace-uses.md +164 -0
- package/skills/teamclaws-pair/SKILL.md +88 -19
- package/skills/teamclaws-project/SKILL.md +37 -3
package/README.md
CHANGED
|
@@ -43,6 +43,34 @@ live hosting. Login and pairing mechanics remain in their respective skills;
|
|
|
43
43
|
[teamclaws-project](skills/teamclaws-project/SKILL.md) covers project work through
|
|
44
44
|
the existing paired shell. Status-only requests do not start onboarding.
|
|
45
45
|
|
|
46
|
+
Before any browser authorization, the agent explains the process: sign this
|
|
47
|
+
computer's CLI into the user's TeamClaws account through the browser, choose a
|
|
48
|
+
workspace and approve pairing with that server in the browser, then choose useful
|
|
49
|
+
work and set up its environment on the server. It explains the purpose and access
|
|
50
|
+
of each approval before presenting a link, adapting to steps already completed.
|
|
51
|
+
The conversation starts with that context rather than an unexplained request to
|
|
52
|
+
click an authorization link.
|
|
53
|
+
|
|
54
|
+
Agents keep their turn and the original login/pairing process alive while the
|
|
55
|
+
user approves in the browser, monitor its output, and continue automatically
|
|
56
|
+
after terminal success. No "I approved" message is needed. An execution
|
|
57
|
+
environment that must yield needs a supported completion callback or automatic
|
|
58
|
+
wakeup that preserves the process; a saved skill alone cannot supply that
|
|
59
|
+
runtime capability.
|
|
60
|
+
|
|
61
|
+
After verified pairing, the agent refreshes `teamclaws servers list --json` and
|
|
62
|
+
presents each workspace's name, description, and readiness in a Markdown list or
|
|
63
|
+
code block, or plain text with one option per line. It then suggests concrete
|
|
64
|
+
uses tied to the user's work and continues an already authorized task. An
|
|
65
|
+
explicit request to stop at pairing takes precedence. Pairing and status-only
|
|
66
|
+
requests never implicitly authorize deployment, sharing, or a remote chat.
|
|
67
|
+
|
|
68
|
+
The bundled [workspace capability guide](skills/teamclaws-onboard/references/workspace-uses.md)
|
|
69
|
+
explains complete jobs, long-running services, external APIs and servers, agents
|
|
70
|
+
with browser access, development and production hosting, and sharing. Agents
|
|
71
|
+
should use it to propose relevant work in the user's vocabulary and verify each
|
|
72
|
+
selected host's prerequisites before promising a capability is ready.
|
|
73
|
+
|
|
46
74
|
### Onboarding output contract
|
|
47
75
|
|
|
48
76
|
Existing event names and schema version remain unchanged. The
|
|
@@ -51,6 +79,36 @@ Existing event names and schema version remain unchanged. The
|
|
|
51
79
|
login keeps running. Signed-out `auth_status` includes the non-executing
|
|
52
80
|
`login_command: "teamclaws login --json"`; status never starts device authorization.
|
|
53
81
|
|
|
82
|
+
`pairing_browser_required` supplies the safe approval URL and public `user_code`.
|
|
83
|
+
Some hosts include a validated `agent_action` with the legacy type
|
|
84
|
+
`deliver_and_stop`. The agent displays its approval message and continues
|
|
85
|
+
monitoring the original process; that field does not end the agent's turn. The
|
|
86
|
+
CLI directly polls at the host-provided interval, handles pending and slow-down
|
|
87
|
+
responses, and verifies the approved credential before `pairing_complete`.
|
|
88
|
+
Success requires that terminal event and exit 0. Denial, expiry, cancellation,
|
|
89
|
+
and errors end the attempt without automatic restart. An interrupted output
|
|
90
|
+
stream leaves the outcome unconfirmed and must be reconciled before retrying.
|
|
91
|
+
|
|
92
|
+
### Approval continuation verification
|
|
93
|
+
|
|
94
|
+
The development tests exercise pending approval, automatic credential completion,
|
|
95
|
+
denial, expiry, cancellation, and a referenced polling timer. The delayed
|
|
96
|
+
approval process test runs the compiled pairing client with stdin disconnected:
|
|
97
|
+
a fixture simulates the owner's later browser approval, and the same child
|
|
98
|
+
process must emit exactly one `pairing_complete` and exit 0 without a chat reply.
|
|
99
|
+
Package validation also checks that the capability reference ships with the
|
|
100
|
+
skills and matches the source bytes by SHA-256.
|
|
101
|
+
|
|
102
|
+
For acceptance in an agent environment, load the updated bundled skills, select
|
|
103
|
+
an authorized test workspace, and start pairing. Confirm the approval link is
|
|
104
|
+
visible while the turn stays active. Approve in the browser without sending a
|
|
105
|
+
chat message. The agent must observe terminal success, refresh and format the
|
|
106
|
+
workspace inventory, and continue the authorized task or suggest a relevant next
|
|
107
|
+
action. Repeat denial and expiry to confirm it stops without creating another
|
|
108
|
+
request. Test a required-yield environment separately if automatic wakeup is
|
|
109
|
+
used. Local CLI tests establish process behavior, not automatic loading or
|
|
110
|
+
compliance in every agent environment.
|
|
111
|
+
|
|
54
112
|
`servers list` requests `GET /api/cli/v1/servers/?include_inventory=1&include_description=1&include_editable_metadata=1`. Updated
|
|
55
113
|
brokers return `servers` and `workspace_count`: the count of all non-deleted
|
|
56
114
|
owned and eligible shared workspaces, including those without a hostname yet.
|
|
@@ -71,6 +129,14 @@ purchase a workspace. Existing owners whose machines are being prepared do not
|
|
|
71
129
|
need another purchase. Targetless pairing requires explicit selection even for
|
|
72
130
|
one result; agents present aliases and pass only user-selected stable IDs.
|
|
73
131
|
|
|
132
|
+
During onboarding or the post-pairing inventory, agents suggest a recognizable
|
|
133
|
+
name for the selected editable workspace if its displayed alias matches
|
|
134
|
+
`workspace-` followed by 12 lowercase hexadecimal characters, such as
|
|
135
|
+
`workspace-07d9e4893885`. Suggestions should reflect the user's project or intended
|
|
136
|
+
use and wait for their choice before renaming. A customized display name is kept
|
|
137
|
+
even when its hostname still follows that pattern. Combine this suggestion with
|
|
138
|
+
any metadata prompt already being shown rather than asking twice.
|
|
139
|
+
|
|
74
140
|
Owners can edit their server's user-defined name and description:
|
|
75
141
|
|
|
76
142
|
```text
|
|
@@ -100,6 +166,7 @@ npx teamclaws login
|
|
|
100
166
|
npx teamclaws login <email>
|
|
101
167
|
npx teamclaws auth status
|
|
102
168
|
npx teamclaws logout
|
|
169
|
+
npx teamclaws logout --revoke
|
|
103
170
|
npx teamclaws servers list
|
|
104
171
|
npx teamclaws pair status <server-identifier-or-canonical-workspace-address>
|
|
105
172
|
npx teamclaws pair
|
|
@@ -114,11 +181,14 @@ Human-readable output is the default. Add `--json` for the strict, versioned,
|
|
|
114
181
|
newline-delimited JSON event stream intended for automation.
|
|
115
182
|
|
|
116
183
|
Pairing (including explicit targets), server listing, pair status, chat, and shell
|
|
117
|
-
require a
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
184
|
+
require a saved TeamClaws CLI authorization and its local machine key. Run
|
|
185
|
+
`teamclaws login` first. Login uses the browser approval to bind a new key to
|
|
186
|
+
your TeamClaws account; the CLI discards the temporary OAuth tokens. Later
|
|
187
|
+
commands sign fresh proofs without repeating browser approval. Missing or
|
|
188
|
+
revoked authorization exits with code 3 in both human and JSON modes. Help,
|
|
189
|
+
version, login, auth status, and logout remain available. Saved workspace
|
|
190
|
+
credentials are preserved when login is required, but pairings made before this
|
|
191
|
+
authorization protocol must be paired again.
|
|
122
192
|
|
|
123
193
|
Pairing is broad privileged delegation to the approved workspace. The client
|
|
124
194
|
does not request or select scopes, profiles, capability bundles, roles, user
|
|
@@ -129,10 +199,10 @@ The client never accepts a model or harness access token. An agent that already
|
|
|
129
199
|
discovered a workspace passes only the selected stable server identifier to the
|
|
130
200
|
`pair` command.
|
|
131
201
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
five-second timeout, reports the result, and
|
|
135
|
-
loads external-chat resources.
|
|
202
|
+
`pair status` reads the matching protected local workspace credential, obtains
|
|
203
|
+
a short-lived target proof for that bound pairing, performs at most one direct
|
|
204
|
+
workspace verification with a five-second timeout, reports the result, and
|
|
205
|
+
stops. It never starts pairing or loads external-chat resources.
|
|
136
206
|
|
|
137
207
|
After pairing, `chat start`, `chat send`, and `chat watch` use the protected
|
|
138
208
|
workspace credential directly against that exact workspace. They verify the
|
|
@@ -145,6 +215,12 @@ monitoring without creating another turn.
|
|
|
145
215
|
|
|
146
216
|
## Host shell commands
|
|
147
217
|
|
|
218
|
+
For authorized workspace administration, first check pairing status with
|
|
219
|
+
`teamclaws pair status <target> --json`. If `not_paired`, complete CLI login and pairing
|
|
220
|
+
approval for that workspace before using the shell. A linked AI provider or
|
|
221
|
+
TCChat conversation is not required for paired-shell access; remote chat is a
|
|
222
|
+
separate operation.
|
|
223
|
+
|
|
148
224
|
`teamclaws shell <target> --command <text> [--json]` uses an existing saved
|
|
149
225
|
pairing to execute the exact command text on the workspace host. Quote the
|
|
150
226
|
command as one local shell argument; quotes, pipes and newlines inside that
|
|
@@ -164,12 +240,16 @@ execution outcome unknown; inspect the host before deciding to run it again.
|
|
|
164
240
|
|
|
165
241
|
## Local storage
|
|
166
242
|
|
|
167
|
-
|
|
168
|
-
|
|
243
|
+
The CLI stores the approved authorization ID, account ID, and public key
|
|
244
|
+
reference in its private user directory. The private signing key remains in
|
|
245
|
+
Windows CNG or, on Linux and macOS, a separate protected PKCS#8 key file.
|
|
246
|
+
Completed workspace credentials are stored by stable server identifier beneath
|
|
169
247
|
`~/.teamclaws/clawgate-devices/<server-uuid>.json`. Writes are atomic. Unix
|
|
170
248
|
directories use mode `0700` and files use mode `0600`; Windows uses
|
|
171
249
|
inheritance-disabled access-control entries limited to the current user and the
|
|
172
|
-
Windows system account.
|
|
250
|
+
Windows system account. Ordinary `logout` removes local authorization access;
|
|
251
|
+
`logout --revoke` waits for TeamClaws to confirm server-side revocation before
|
|
252
|
+
removing the local reference. Neither command deletes saved pairings.
|
|
173
253
|
|
|
174
254
|
The package is licensed under the PolyForm Shield License 1.0.0. Paid service
|
|
175
255
|
access is enforced by TeamClaws servers, not by the client license.
|
package/dist/app.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Output } from "./output.js";
|
|
|
8
8
|
import { PairingClient } from "./pairing.js";
|
|
9
9
|
import { runShell, shellFetch } from "./shell.js";
|
|
10
10
|
import { CredentialStore } from "./storage.js";
|
|
11
|
+
import { TargetAuthorization } from "./target-authorization.js";
|
|
11
12
|
import { installRelease } from "./update.js";
|
|
12
13
|
import { normalizeUuid, parsePairingTarget } from "./validation.js";
|
|
13
14
|
export async function run(arguments_, dependencies = {}, signal) {
|
|
@@ -68,6 +69,7 @@ export async function run(arguments_, dependencies = {}, signal) {
|
|
|
68
69
|
return code;
|
|
69
70
|
}
|
|
70
71
|
const store = dependencies.store ?? new CredentialStore();
|
|
72
|
+
const targetAuthorization = new TargetAuthorization(store, dependencies.fetcher);
|
|
71
73
|
const auth = new AuthService({
|
|
72
74
|
store,
|
|
73
75
|
output,
|
|
@@ -77,9 +79,11 @@ export async function run(arguments_, dependencies = {}, signal) {
|
|
|
77
79
|
: {}),
|
|
78
80
|
});
|
|
79
81
|
const broker = new BrokerClient({
|
|
82
|
+
authorization: targetAuthorization,
|
|
80
83
|
...(dependencies.fetcher ? { fetcher: dependencies.fetcher } : {}),
|
|
81
84
|
});
|
|
82
85
|
const pairing = new PairingClient({
|
|
86
|
+
authorization: targetAuthorization,
|
|
83
87
|
store,
|
|
84
88
|
output,
|
|
85
89
|
...(dependencies.fetcher ? { fetcher: dependencies.fetcher } : {}),
|
|
@@ -88,6 +92,7 @@ export async function run(arguments_, dependencies = {}, signal) {
|
|
|
88
92
|
: {}),
|
|
89
93
|
});
|
|
90
94
|
const externalChat = new ExternalChatClient({
|
|
95
|
+
authorization: targetAuthorization,
|
|
91
96
|
store,
|
|
92
97
|
output,
|
|
93
98
|
...(dependencies.fetcher ? { fetcher: dependencies.fetcher } : {}),
|
|
@@ -101,15 +106,13 @@ export async function run(arguments_, dependencies = {}, signal) {
|
|
|
101
106
|
await auth.status(signal);
|
|
102
107
|
return 0;
|
|
103
108
|
case "logout":
|
|
104
|
-
await auth.logout(signal);
|
|
109
|
+
await auth.logout(signal, parsed.revoke);
|
|
105
110
|
return 0;
|
|
106
111
|
}
|
|
107
|
-
|
|
108
|
-
? "Authenticate in the TeamClaws CLI with `teamclaws login` before making a pairing request."
|
|
109
|
-
: "Authenticate in the TeamClaws CLI with `teamclaws login` before running this command.", signal);
|
|
112
|
+
await targetAuthorization.requireGrant();
|
|
110
113
|
switch (parsed.action) {
|
|
111
114
|
case "shell": {
|
|
112
|
-
const result = await runShell(store, dependencies.fetcher ?? shellFetch, parsePairingTarget(parsed.target), parsed.command, signal);
|
|
115
|
+
const result = await runShell(store, dependencies.fetcher ?? shellFetch, parsePairingTarget(parsed.target), parsed.command, targetAuthorization, signal);
|
|
113
116
|
if (parsed.json)
|
|
114
117
|
stdout.write(`${JSON.stringify(result)}\n`);
|
|
115
118
|
else {
|
|
@@ -119,21 +122,21 @@ export async function run(arguments_, dependencies = {}, signal) {
|
|
|
119
122
|
return result.exit_code;
|
|
120
123
|
}
|
|
121
124
|
case "servers_list": {
|
|
122
|
-
const inventory = await broker.servers(
|
|
123
|
-
await offerServerMetadata(inventory.servers, broker,
|
|
125
|
+
const inventory = await broker.servers(signal);
|
|
126
|
+
await offerServerMetadata(inventory.servers, broker, parsed.json, output, dependencies.io, signal);
|
|
124
127
|
output.servers(inventory.servers, inventory.workspace_count);
|
|
125
128
|
return 0;
|
|
126
129
|
}
|
|
127
130
|
case "servers_update": {
|
|
128
|
-
const server = await broker.updateServerMetadata(
|
|
131
|
+
const server = await broker.updateServerMetadata(parsed.serverId, parsed.updates, signal);
|
|
129
132
|
output.serverMetadataUpdated(server);
|
|
130
133
|
return 0;
|
|
131
134
|
}
|
|
132
135
|
case "pair": {
|
|
133
136
|
const target = parsed.target
|
|
134
137
|
? parsePairingTarget(parsed.target)
|
|
135
|
-
: await discoverTarget(broker,
|
|
136
|
-
const pending = await broker.startPairing(target,
|
|
138
|
+
: await discoverTarget(broker, parsed.json, output, dependencies.io, signal);
|
|
139
|
+
const pending = await broker.startPairing(target, signal);
|
|
137
140
|
await pairing.complete(pending, broker.currentDeviceName(), parsed.noOpen, signal);
|
|
138
141
|
return 0;
|
|
139
142
|
}
|
|
@@ -224,8 +227,9 @@ export function parseArguments(arguments_) {
|
|
|
224
227
|
}
|
|
225
228
|
throw usageError();
|
|
226
229
|
}
|
|
227
|
-
if (args[0] === "logout" &&
|
|
228
|
-
|
|
230
|
+
if (args[0] === "logout" &&
|
|
231
|
+
(args.length === 1 || (args.length === 2 && args[1] === "--revoke")))
|
|
232
|
+
return { action: "logout", json, revoke: args.length === 2 };
|
|
229
233
|
if (args[0] === "auth" && args[1] === "status" && args.length === 2) {
|
|
230
234
|
return { action: "auth_status", json };
|
|
231
235
|
}
|
|
@@ -382,9 +386,9 @@ function nonNegativeInteger(value) {
|
|
|
382
386
|
throw usageError();
|
|
383
387
|
return parsed;
|
|
384
388
|
}
|
|
385
|
-
async function discoverTarget(broker,
|
|
386
|
-
const inventory = await broker.servers(
|
|
387
|
-
await offerServerMetadata(inventory.servers, broker,
|
|
389
|
+
async function discoverTarget(broker, json, output, io, signal) {
|
|
390
|
+
const inventory = await broker.servers(signal);
|
|
391
|
+
await offerServerMetadata(inventory.servers, broker, json, output, io, signal);
|
|
388
392
|
const servers = inventory.servers.filter((server) => server.pairing_ready);
|
|
389
393
|
if (servers.length === 0) {
|
|
390
394
|
throw new TeamClawsError("no_pairing_ready_workspaces", inventory.workspace_count === 0
|
|
@@ -399,7 +403,7 @@ async function discoverTarget(broker, token, json, output, io, signal) {
|
|
|
399
403
|
const selected = await promptForServer(servers, io?.input ?? process.stdin, io?.stdout ?? process.stdout);
|
|
400
404
|
return { server_id: selected.server_id };
|
|
401
405
|
}
|
|
402
|
-
async function offerServerMetadata(servers, broker,
|
|
406
|
+
async function offerServerMetadata(servers, broker, json, output, io, signal) {
|
|
403
407
|
const interactive = io?.interactive ?? (process.stdin.isTTY && process.stdout.isTTY);
|
|
404
408
|
if (!json && !interactive)
|
|
405
409
|
return;
|
|
@@ -409,7 +413,7 @@ async function offerServerMetadata(servers, broker, token, json, output, io, sig
|
|
|
409
413
|
server.description === undefined ||
|
|
410
414
|
(server.custom_alias.trim() !== "" && server.description.trim() !== ""))
|
|
411
415
|
continue;
|
|
412
|
-
const claimed = await broker.claimMetadataPrompt(
|
|
416
|
+
const claimed = await broker.claimMetadataPrompt(server.server_id, signal);
|
|
413
417
|
Object.assign(server, claimed.server);
|
|
414
418
|
if (!claimed.should_prompt)
|
|
415
419
|
continue;
|
|
@@ -432,7 +436,7 @@ async function offerServerMetadata(servers, broker, token, json, output, io, sig
|
|
|
432
436
|
if (description.trim())
|
|
433
437
|
updates.description = description;
|
|
434
438
|
if (Object.keys(updates).length > 0) {
|
|
435
|
-
const updated = await broker.updateServerMetadata(
|
|
439
|
+
const updated = await broker.updateServerMetadata(server.server_id, updates, signal);
|
|
436
440
|
Object.assign(server, updated);
|
|
437
441
|
output.serverMetadataUpdated(updated);
|
|
438
442
|
}
|
|
@@ -467,5 +471,5 @@ function usageError() {
|
|
|
467
471
|
});
|
|
468
472
|
}
|
|
469
473
|
export function helpText() {
|
|
470
|
-
return `TeamClaws ${PACKAGE_VERSION}\n\nUsage:\n teamclaws update [--json]\n teamclaws shell <target> --command <text> [--json]\n teamclaws login [email] [--json]\n teamclaws auth status [--json]\n teamclaws logout [--json]\n teamclaws servers list [--json]\n teamclaws servers update <server-id> [--name <text>] [--description <text>] [--json]\n teamclaws pair status <server-identifier-or-canonical-workspace-address> [--json]\n teamclaws pair [server-identifier-or-canonical-workspace-address] [--no-open] [--json]\n teamclaws chat list <target> [--json]\n teamclaws chat capabilities <target> [--json]\n teamclaws chat start <target> --message <text> [selection options] [--json]\n teamclaws chat send <target> --session-id <uuid> --message <text> [--json]\n teamclaws chat watch <target> --session-id <uuid> --request-id <uuid> [resume cursors] [--json]\n\nPair status performs one bounded direct verification and stops. Pairing grants the standard host-issued credential. Chat commands use that credential directly against the selected workspace.\n`;
|
|
474
|
+
return `TeamClaws ${PACKAGE_VERSION}\n\nUsage:\n teamclaws update [--json]\n teamclaws shell <target> --command <text> [--json]\n teamclaws login [email] [--json]\n teamclaws auth status [--json]\n teamclaws logout [--revoke] [--json]\n teamclaws servers list [--json]\n teamclaws servers update <server-id> [--name <text>] [--description <text>] [--json]\n teamclaws pair status <server-identifier-or-canonical-workspace-address> [--json]\n teamclaws pair [server-identifier-or-canonical-workspace-address] [--no-open] [--json]\n teamclaws chat list <target> [--json]\n teamclaws chat capabilities <target> [--json]\n teamclaws chat start <target> --message <text> [selection options] [--json]\n teamclaws chat send <target> --session-id <uuid> --message <text> [--json]\n teamclaws chat watch <target> --session-id <uuid> --request-id <uuid> [resume cursors] [--json]\n\nPair status performs one bounded direct verification and stops. Pairing grants the standard host-issued credential. Chat commands use that credential directly against the selected workspace.\n`;
|
|
471
475
|
}
|
package/dist/auth.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { API_BASE, API_V2_BASE, API_RESOURCE, OAUTH_CLIENT_ID, OAUTH_DEVICE_AUTHORIZATION_URL, OAUTH_LOGIN_TIMEOUT_MS, OAUTH_TOKEN_URL, TEAMCLAWS_ORIGIN, } from "./constants.js";
|
|
2
2
|
import { TeamClawsError } from "./errors.js";
|
|
3
3
|
import { errorFromResponse, requestJson } from "./http.js";
|
|
4
4
|
import { assertExactKeys, isObject, parseSafeUserResponse, requireHttpsUrl, requireInteger, requireString, } from "./validation.js";
|
|
5
5
|
import { openExternalBrowser } from "./browser.js";
|
|
6
|
+
import { OsMachineKeyProvider, } from "./machine-key.js";
|
|
7
|
+
import { TargetAuthorization, } from "./target-authorization.js";
|
|
6
8
|
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
7
9
|
export class AuthService {
|
|
8
10
|
fetcher;
|
|
@@ -12,6 +14,7 @@ export class AuthService {
|
|
|
12
14
|
now;
|
|
13
15
|
wait;
|
|
14
16
|
loginTimeoutMs;
|
|
17
|
+
machineKeys;
|
|
15
18
|
constructor(options) {
|
|
16
19
|
this.fetcher = options.fetcher ?? fetch;
|
|
17
20
|
this.store = options.store;
|
|
@@ -20,33 +23,47 @@ export class AuthService {
|
|
|
20
23
|
this.now = options.now ?? Date.now;
|
|
21
24
|
this.wait = options.wait ?? waitFor;
|
|
22
25
|
this.loginTimeoutMs = options.loginTimeoutMs ?? OAUTH_LOGIN_TIMEOUT_MS;
|
|
26
|
+
this.machineKeys =
|
|
27
|
+
options.machineKeys ?? new OsMachineKeyProvider(this.store.root);
|
|
23
28
|
}
|
|
24
29
|
async login(email, signal) {
|
|
25
30
|
const boundEmail = email === undefined ? undefined : parseLoginEmail(email);
|
|
26
|
-
const
|
|
31
|
+
const machineKey = await this.machineKeys.create();
|
|
32
|
+
const pending = await this.startDevice(boundEmail, machineKey, signal);
|
|
27
33
|
this.output.loginBrowser(pending.verification_uri_complete);
|
|
28
34
|
await this.openBrowser(pending.verification_uri_complete).catch(() => false);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const { authorization_id, sub, machine_jkt } = await this.poll(pending, machineKey, signal);
|
|
36
|
+
if (machine_jkt !== machineKey.jkt)
|
|
37
|
+
throw invalidTokenResponse();
|
|
38
|
+
const grant = {
|
|
39
|
+
version: 2,
|
|
40
|
+
authorization_id,
|
|
41
|
+
sub,
|
|
42
|
+
machine_key: machineKey,
|
|
43
|
+
};
|
|
44
|
+
const user = await this.fetchIdentity(grant, signal);
|
|
45
|
+
if (user.id !== sub)
|
|
46
|
+
throw invalidTokenResponse();
|
|
47
|
+
await this.store.writeAuthorization(grant);
|
|
48
|
+
await this.store.removeLogin();
|
|
32
49
|
this.output.loginComplete(user);
|
|
33
50
|
return user;
|
|
34
51
|
}
|
|
35
52
|
async status(signal) {
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
53
|
+
const grant = await this.store.readAuthorization();
|
|
54
|
+
if (!grant) {
|
|
38
55
|
this.output.authStatus("signed_out");
|
|
39
56
|
return undefined;
|
|
40
57
|
}
|
|
41
58
|
try {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
this.output.authStatus("signed_in", user, active.expires_at);
|
|
59
|
+
const user = await this.fetchIdentity(grant, signal);
|
|
60
|
+
this.output.authStatus("signed_in", user);
|
|
45
61
|
return user;
|
|
46
62
|
}
|
|
47
63
|
catch (error) {
|
|
48
64
|
if (error instanceof TeamClawsError &&
|
|
49
65
|
error.code === "invalid_authentication") {
|
|
66
|
+
await this.store.removeAuthorization();
|
|
50
67
|
await this.store.removeLogin();
|
|
51
68
|
this.output.authStatus("signed_out");
|
|
52
69
|
return undefined;
|
|
@@ -54,48 +71,30 @@ export class AuthService {
|
|
|
54
71
|
throw error;
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
|
-
async logout(signal) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
74
|
+
async logout(signal, revokeGrant = false) {
|
|
75
|
+
const grant = await this.store.readAuthorization();
|
|
76
|
+
if (revokeGrant && grant) {
|
|
77
|
+
const url = `${API_V2_BASE}authorizations/${grant.authorization_id}/revoke/`;
|
|
78
|
+
const signer = new TargetAuthorization(this.store, this.fetcher, this.machineKeys, this.now);
|
|
79
|
+
const response = await requestJson(this.fetcher, url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: await signer.accountHeadersForGrant(grant, "POST", url),
|
|
82
|
+
...(signal ? { signal } : {}),
|
|
83
|
+
});
|
|
84
|
+
if (response.status !== 204)
|
|
85
|
+
throw errorFromResponse(response, "remote_logout_unconfirmed");
|
|
67
86
|
}
|
|
68
87
|
await this.store.removeLogin();
|
|
69
|
-
this.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
async accessToken(signal) {
|
|
73
|
-
const credential = await this.store.readLogin();
|
|
74
|
-
if (!credential)
|
|
75
|
-
return undefined;
|
|
76
|
-
const active = await this.ensureActive(credential, signal);
|
|
77
|
-
return active.access_token;
|
|
78
|
-
}
|
|
79
|
-
async requireAccessToken(message, signal) {
|
|
80
|
-
const token = await this.accessToken(signal);
|
|
81
|
-
if (!token) {
|
|
82
|
-
throw new TeamClawsError("login_required", message, { exitCode: 3 });
|
|
83
|
-
}
|
|
84
|
-
await this.fetchIdentity(token, signal);
|
|
85
|
-
return token;
|
|
86
|
-
}
|
|
87
|
-
async ensureActive(credential, signal) {
|
|
88
|
-
const expiresAt = Date.parse(credential.expires_at);
|
|
89
|
-
if (expiresAt - this.now() > 30_000)
|
|
90
|
-
return credential;
|
|
91
|
-
const refreshed = await this.refresh(credential.refresh_token, signal);
|
|
92
|
-
await this.store.writeLogin(refreshed);
|
|
93
|
-
return refreshed;
|
|
88
|
+
await this.store.removeAuthorization();
|
|
89
|
+
this.output.logoutComplete(true);
|
|
90
|
+
return true;
|
|
94
91
|
}
|
|
95
|
-
async startDevice(email, signal) {
|
|
92
|
+
async startDevice(email, machineKey, signal) {
|
|
96
93
|
const form = new URLSearchParams({
|
|
97
94
|
client_id: OAUTH_CLIENT_ID,
|
|
98
95
|
resource: API_RESOURCE,
|
|
96
|
+
scope: "basic",
|
|
97
|
+
machine_jwk: JSON.stringify(machineKey.public_jwk),
|
|
99
98
|
});
|
|
100
99
|
if (email)
|
|
101
100
|
form.set("email", email);
|
|
@@ -109,7 +108,7 @@ export class AuthService {
|
|
|
109
108
|
}
|
|
110
109
|
return parseDeviceAuthorization(response.body);
|
|
111
110
|
}
|
|
112
|
-
async poll(pending, signal) {
|
|
111
|
+
async poll(pending, machineKey, signal) {
|
|
113
112
|
const startedAt = this.now();
|
|
114
113
|
const deadline = Math.min(startedAt + pending.expires_in * 1_000, startedAt + this.loginTimeoutMs);
|
|
115
114
|
let intervalSeconds = pending.interval;
|
|
@@ -119,6 +118,9 @@ export class AuthService {
|
|
|
119
118
|
break;
|
|
120
119
|
const response = await requestJson(this.fetcher, OAUTH_TOKEN_URL, {
|
|
121
120
|
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
DPoP: await new TargetAuthorization(this.store, this.fetcher, this.machineKeys, this.now).tokenDpop(machineKey),
|
|
123
|
+
},
|
|
122
124
|
form: new URLSearchParams({
|
|
123
125
|
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
124
126
|
device_code: pending.device_code,
|
|
@@ -128,7 +130,14 @@ export class AuthService {
|
|
|
128
130
|
...(signal ? { signal } : {}),
|
|
129
131
|
});
|
|
130
132
|
if (response.status >= 200 && response.status < 300) {
|
|
131
|
-
|
|
133
|
+
if (!isObject(response.body))
|
|
134
|
+
throw invalidTokenResponse();
|
|
135
|
+
validateBootstrapTokens(response.body);
|
|
136
|
+
return {
|
|
137
|
+
authorization_id: requireString(response.body.authorization_id, "authorization ID", 36),
|
|
138
|
+
sub: requireString(response.body.sub, "user subject", 255),
|
|
139
|
+
machine_jkt: requireString(response.body.machine_jkt, "machine key thumbprint", 43),
|
|
140
|
+
};
|
|
132
141
|
}
|
|
133
142
|
const code = oauthErrorCode(response.body);
|
|
134
143
|
if (code === "authorization_pending") {
|
|
@@ -148,65 +157,28 @@ export class AuthService {
|
|
|
148
157
|
}
|
|
149
158
|
throw new TeamClawsError("login_timeout", "CLI sign-in timed out after 10 minutes.", { exitCode: 3 });
|
|
150
159
|
}
|
|
151
|
-
async
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
grant_type: "refresh_token",
|
|
157
|
-
refresh_token: refreshToken,
|
|
158
|
-
client_id: OAUTH_CLIENT_ID,
|
|
159
|
-
resource: API_RESOURCE,
|
|
160
|
-
}),
|
|
161
|
-
...(signal ? { signal } : {}),
|
|
162
|
-
});
|
|
163
|
-
if (response.status < 200 || response.status >= 300) {
|
|
164
|
-
throw oauthError(response.status, response.body);
|
|
165
|
-
}
|
|
166
|
-
return parseTokenResponse(response.body, this.now());
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
if (error instanceof TeamClawsError &&
|
|
170
|
-
error.code === "invalid_authentication") {
|
|
171
|
-
await this.store.removeLogin();
|
|
172
|
-
}
|
|
173
|
-
throw error;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
async fetchIdentity(accessToken, signal) {
|
|
177
|
-
const response = await requestJson(this.fetcher, `${API_BASE}me/`, {
|
|
178
|
-
headers: { Authorization: `Bearer ${accessToken}` },
|
|
160
|
+
async fetchIdentity(grant, signal) {
|
|
161
|
+
const url = `${API_BASE}me/`;
|
|
162
|
+
const signer = new TargetAuthorization(this.store, this.fetcher, this.machineKeys, this.now);
|
|
163
|
+
const response = await requestJson(this.fetcher, url, {
|
|
164
|
+
headers: await signer.accountHeadersForGrant(grant, "GET", url),
|
|
179
165
|
...(signal ? { signal } : {}),
|
|
180
166
|
});
|
|
181
167
|
if (response.status === 401) {
|
|
182
|
-
throw new TeamClawsError("invalid_authentication", "The TeamClaws
|
|
168
|
+
throw new TeamClawsError("invalid_authentication", "The TeamClaws CLI authorization is inactive. Run `teamclaws login` to authorize this computer again.", {
|
|
183
169
|
exitCode: 3,
|
|
184
170
|
});
|
|
185
171
|
}
|
|
186
172
|
if (response.status < 200 || response.status >= 300) {
|
|
187
173
|
throw errorFromResponse(response, "identity_unavailable");
|
|
188
174
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
headers: {
|
|
197
|
-
Accept: "application/json",
|
|
198
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
199
|
-
},
|
|
200
|
-
body: new URLSearchParams({
|
|
201
|
-
token: refreshToken,
|
|
202
|
-
client_id: OAUTH_CLIENT_ID,
|
|
203
|
-
}),
|
|
204
|
-
redirect: "error",
|
|
205
|
-
signal: combined,
|
|
206
|
-
});
|
|
207
|
-
if (response.status < 200 || response.status >= 300) {
|
|
208
|
-
throw new TeamClawsError("remote_logout_unconfirmed", "Remote logout was not confirmed.");
|
|
209
|
-
}
|
|
175
|
+
if (!isObject(response.body) ||
|
|
176
|
+
response.body.authorization_id !== grant.authorization_id)
|
|
177
|
+
throw invalidTokenResponse();
|
|
178
|
+
const user = parseSafeUserResponse(response.body);
|
|
179
|
+
if (user.id !== grant.sub)
|
|
180
|
+
throw invalidTokenResponse();
|
|
181
|
+
return user;
|
|
210
182
|
}
|
|
211
183
|
}
|
|
212
184
|
function parseDeviceAuthorization(body) {
|
|
@@ -239,21 +211,14 @@ function parseDeviceAuthorization(body) {
|
|
|
239
211
|
interval: requireInteger(body.interval, "interval", 1, 60),
|
|
240
212
|
};
|
|
241
213
|
}
|
|
242
|
-
function
|
|
214
|
+
function validateBootstrapTokens(body) {
|
|
243
215
|
if (!isObject(body))
|
|
244
216
|
throw invalidTokenResponse();
|
|
245
|
-
|
|
246
|
-
|
|
217
|
+
requireToken(body.access_token);
|
|
218
|
+
requireToken(body.refresh_token);
|
|
247
219
|
if (body.token_type !== "Bearer")
|
|
248
220
|
throw invalidTokenResponse();
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
version: 1,
|
|
252
|
-
token_type: "Bearer",
|
|
253
|
-
access_token: accessToken,
|
|
254
|
-
refresh_token: refreshToken,
|
|
255
|
-
expires_at: new Date(now + expiresIn * 1_000).toISOString(),
|
|
256
|
-
};
|
|
221
|
+
requireInteger(body.expires_in, "access-token lifetime", 1, 86_400);
|
|
257
222
|
}
|
|
258
223
|
function parseLoginEmail(value) {
|
|
259
224
|
const email = value.trim();
|