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 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 valid TeamClaws CLI account login. Run `teamclaws login` first. The CLI
118
- refreshes and verifies that login before contacting the workspace or requesting
119
- pairing approval. Missing or invalid authentication exits with code 3 in both
120
- human and JSON modes. Help, version, login, auth status, and logout remain
121
- available. Saved workspace credentials are preserved when login is required.
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
- After account authentication, `pair status` reads the matching protected local
133
- workspace credential, performs at most one direct workspace verification with a
134
- five-second timeout, reports the result, and stops. It never starts pairing or
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
- Login credentials are stored in the private TeamClaws user directory. Completed
168
- workspace credentials are stored by stable server identifier beneath
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
- const token = await auth.requireAccessToken(parsed.action === "pair"
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(token, signal);
123
- await offerServerMetadata(inventory.servers, broker, token, parsed.json, output, dependencies.io, signal);
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(token, parsed.serverId, parsed.updates, signal);
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, token, parsed.json, output, dependencies.io, signal);
136
- const pending = await broker.startPairing(target, token, signal);
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" && args.length === 1)
228
- return { action: "logout", json };
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, token, json, output, io, signal) {
386
- const inventory = await broker.servers(token, signal);
387
- await offerServerMetadata(inventory.servers, broker, token, json, output, io, signal);
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, token, json, output, io, signal) {
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(token, server.server_id, signal);
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(token, server.server_id, updates, signal);
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 { ACCESS_TOKEN_MAX_LIFETIME_SECONDS, API_BASE, API_RESOURCE, OAUTH_CLIENT_ID, OAUTH_DEVICE_AUTHORIZATION_URL, OAUTH_LOGIN_TIMEOUT_MS, OAUTH_REVOKE_URL, OAUTH_TOKEN_URL, TEAMCLAWS_ORIGIN, } from "./constants.js";
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 pending = await this.startDevice(boundEmail, signal);
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 credential = await this.poll(pending, signal);
30
- const user = await this.fetchIdentity(credential.access_token, signal);
31
- await this.store.writeLogin(credential);
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 credential = await this.store.readLogin();
37
- if (!credential) {
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 active = await this.ensureActive(credential, signal);
43
- const user = await this.fetchIdentity(active.access_token, signal);
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 credential = await this.store.readLogin();
59
- let remoteConfirmed = true;
60
- if (credential) {
61
- try {
62
- await this.revoke(credential.refresh_token, signal);
63
- }
64
- catch {
65
- remoteConfirmed = false;
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.output.logoutComplete(remoteConfirmed);
70
- return remoteConfirmed;
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
- return parseTokenResponse(response.body, this.now());
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 refresh(refreshToken, signal) {
152
- try {
153
- const response = await requestJson(this.fetcher, OAUTH_TOKEN_URL, {
154
- method: "POST",
155
- form: new URLSearchParams({
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 login is no longer valid. Run `teamclaws login` to authenticate.", {
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
- return parseSafeUserResponse(response.body);
190
- }
191
- async revoke(refreshToken, signal) {
192
- const timeout = AbortSignal.timeout(10_000);
193
- const combined = signal ? AbortSignal.any([timeout, signal]) : timeout;
194
- const response = await this.fetcher(OAUTH_REVOKE_URL, {
195
- method: "POST",
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 parseTokenResponse(body, now) {
214
+ function validateBootstrapTokens(body) {
243
215
  if (!isObject(body))
244
216
  throw invalidTokenResponse();
245
- const accessToken = requireToken(body.access_token);
246
- const refreshToken = requireToken(body.refresh_token);
217
+ requireToken(body.access_token);
218
+ requireToken(body.refresh_token);
247
219
  if (body.token_type !== "Bearer")
248
220
  throw invalidTokenResponse();
249
- const expiresIn = Math.min(requireInteger(body.expires_in, "access-token lifetime", 1, 86_400), ACCESS_TOKEN_MAX_LIFETIME_SECONDS);
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();