teamclaws 1.0.4 → 2.0.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.
@@ -0,0 +1,150 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { API_V2_BASE, OAUTH_TOKEN_URL } from "./constants.js";
3
+ import { TeamClawsError } from "./errors.js";
4
+ import { errorFromResponse, requestJson, } from "./http.js";
5
+ import { OsMachineKeyProvider, jwkThumbprint, } from "./machine-key.js";
6
+ import { isObject } from "./validation.js";
7
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
8
+ export class TargetAuthorization {
9
+ fetcher;
10
+ store;
11
+ keys;
12
+ now;
13
+ constructor(store, fetcher = fetch, keys = new OsMachineKeyProvider(store.root), now = Date.now) {
14
+ this.store = store;
15
+ this.fetcher = fetcher;
16
+ this.keys = keys;
17
+ this.now = now;
18
+ }
19
+ async requireGrant() {
20
+ const grant = await this.store.readAuthorization();
21
+ if (!grant)
22
+ throw new TeamClawsError("login_required", "Approve this CLI at TeamClaws with `teamclaws login` before using a workspace.", { exitCode: 3 });
23
+ return grant;
24
+ }
25
+ async accountHeadersFor(method, url) {
26
+ return this.accountHeadersForGrant(await this.requireGrant(), method, url);
27
+ }
28
+ async accountHeadersForGrant(grant, method, url) {
29
+ if (new URL(url).origin !== new URL(API_V2_BASE).origin)
30
+ throw new TeamClawsError("invalid_target", "A TeamClaws issuer URL is required.", { exitCode: 9 });
31
+ return {
32
+ "X-TeamClaws-Authorization-Id": grant.authorization_id,
33
+ DPoP: await this.dpop(grant.machine_key, method, url),
34
+ };
35
+ }
36
+ assertPairing(grant, credential) {
37
+ if (credential.version !== 2 ||
38
+ credential.requester_sub !== grant.sub ||
39
+ credential.authorization_id !== grant.authorization_id ||
40
+ credential.machine_jkt !== grant.machine_key.jkt ||
41
+ credential.pairing_id !== credential.device_id) {
42
+ throw new TeamClawsError("re_pair_required", "This pairing is not bound to the current TeamClaws authorization. Pair this workspace again.", { exitCode: 3 });
43
+ }
44
+ }
45
+ async headersFor(method, url, credential, signal) {
46
+ const grant = await this.requireGrant();
47
+ this.assertPairing(grant, credential);
48
+ if (new URL(url).origin !== credential.origin)
49
+ throw new TeamClawsError("target_mismatch", "The request target does not match the selected pairing.", { exitCode: 9 });
50
+ const pairingAuthorization = `Bearer ${credential.device_id}:${credential.device_token}`;
51
+ const exchangeUrl = `${API_V2_BASE}authorizations/${grant.authorization_id}/proofs/`;
52
+ const exchange = await requestJson(this.fetcher, exchangeUrl, {
53
+ method: "POST",
54
+ headers: {
55
+ DPoP: await this.dpop(grant.machine_key, "POST", exchangeUrl),
56
+ },
57
+ body: {
58
+ server_id: credential.server_id,
59
+ pairing_id: credential.pairing_id,
60
+ scope: "paired:invoke",
61
+ },
62
+ ...(signal ? { signal } : {}),
63
+ });
64
+ if (exchange.status < 200 || exchange.status >= 300)
65
+ throw errorFromResponse(exchange, "target_proof_denied");
66
+ if (!isObject(exchange.body) ||
67
+ exchange.body.token_type !== "DPoP" ||
68
+ exchange.body.scope !== "paired:invoke" ||
69
+ typeof exchange.body.access_token !== "string" ||
70
+ exchange.body.access_token.length < 16 ||
71
+ exchange.body.access_token.length > 8192 ||
72
+ !Number.isInteger(exchange.body.expires_in) ||
73
+ exchange.body.expires_in < 1 ||
74
+ exchange.body.expires_in > 300)
75
+ throw new TeamClawsError("invalid_response", "TeamClaws returned an invalid target proof.", { exitCode: 9 });
76
+ const proof = exchange.body.access_token;
77
+ return {
78
+ Authorization: pairingAuthorization,
79
+ "X-TeamClaws-Target-Proof": proof,
80
+ DPoP: await this.dpop(grant.machine_key, method, url, proof, pairingAuthorization),
81
+ };
82
+ }
83
+ async dpop(key, method, url, targetProof, pairingAuthorization) {
84
+ const parsed = new URL(url);
85
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password)
86
+ throw new TeamClawsError("invalid_target", "A canonical HTTPS target is required.", { exitCode: 9 });
87
+ const header = { typ: "dpop+jwt", alg: "ES256", jwk: key.public_jwk };
88
+ const payload = {
89
+ htm: method.toUpperCase(),
90
+ htu: `${parsed.origin}${parsed.pathname}`,
91
+ iat: Math.floor(this.now() / 1000),
92
+ jti: randomUUID(),
93
+ };
94
+ if (targetProof !== undefined)
95
+ payload.ath = hashAscii(targetProof);
96
+ if (pairingAuthorization !== undefined)
97
+ payload.pairing_ath = hashAscii(pairingAuthorization);
98
+ const signingInput = `${base64Json(header)}.${base64Json(payload)}`;
99
+ return `${signingInput}.${await this.keys.sign(key, signingInput)}`;
100
+ }
101
+ async tokenDpop(key) {
102
+ return this.dpop(key, "POST", OAUTH_TOKEN_URL);
103
+ }
104
+ }
105
+ export async function requestTargetJson(fetcher, authorization, url, credential, options = {}) {
106
+ const method = options.method ?? "GET";
107
+ const attempt = async () => requestJson(fetcher, url, {
108
+ ...options,
109
+ headers: {
110
+ ...options.headers,
111
+ ...(await authorization.headersFor(method, url, credential, options.signal)),
112
+ },
113
+ });
114
+ const first = await attempt();
115
+ if (first.status === 401 &&
116
+ isObject(first.body) &&
117
+ first.body.error === "target_proof_expired")
118
+ return attempt();
119
+ return first;
120
+ }
121
+ export function parseDurableAuthorization(value) {
122
+ if (!isObject(value) ||
123
+ value.version !== 2 ||
124
+ typeof value.authorization_id !== "string" ||
125
+ !uuid.test(value.authorization_id) ||
126
+ typeof value.sub !== "string" ||
127
+ value.sub.length < 1 ||
128
+ value.sub.length > 255 ||
129
+ !isObject(value.machine_key) ||
130
+ !["windows-cng", "posix-file"].includes(String(value.machine_key.provider)) ||
131
+ typeof value.machine_key.name !== "string" ||
132
+ !/^teamclaws-cli-[0-9a-f-]{36}$/u.test(value.machine_key.name) ||
133
+ !isObject(value.machine_key.public_jwk) ||
134
+ value.machine_key.public_jwk.kty !== "EC" ||
135
+ value.machine_key.public_jwk.crv !== "P-256" ||
136
+ typeof value.machine_key.public_jwk.x !== "string" ||
137
+ typeof value.machine_key.public_jwk.y !== "string" ||
138
+ typeof value.machine_key.jkt !== "string")
139
+ throw new TeamClawsError("invalid_credential_file", "The saved CLI authorization is invalid.", { exitCode: 9 });
140
+ const key = value.machine_key;
141
+ if (key.jkt !== jwkThumbprint(key.public_jwk))
142
+ throw new TeamClawsError("invalid_credential_file", "The saved CLI authorization key is invalid.", { exitCode: 9 });
143
+ return value;
144
+ }
145
+ function hashAscii(value) {
146
+ return createHash("sha256").update(value, "ascii").digest("base64url");
147
+ }
148
+ function base64Json(value) {
149
+ return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
150
+ }
@@ -227,6 +227,12 @@ export function parsePairingCredential(value) {
227
227
  return {
228
228
  device_id: normalizeUuid(value.device_id, "device identifier"),
229
229
  device_token: requireString(value.device_token, "device token", 8_192),
230
+ pairing_id: normalizeUuid(value.pairing_id, "pairing identifier"),
231
+ server_id: normalizeUuid(value.server_id, "server identifier"),
232
+ origin: canonicalWorkspaceOrigin(value.origin),
233
+ requester_sub: requireString(value.requester_sub, "requester subject", 255),
234
+ authorization_id: normalizeUuid(value.authorization_id, "authorization identifier"),
235
+ machine_jkt: requireThumbprint(value.machine_jkt),
230
236
  ...(Object.hasOwn(value, "external_chat_capabilities")
231
237
  ? {
232
238
  external_chat_capabilities: value.external_chat_capabilities,
@@ -317,13 +323,13 @@ export function parseSafeUserResponse(value) {
317
323
  exitCode: 9,
318
324
  });
319
325
  }
320
- assertExactKeys(value, ["id", "email", "display_name", "expires_at", "token_expired"], [], "account response");
321
- if (value.token_expired !== false) {
326
+ assertExactKeys(value, ["id", "email", "display_name", "authorization_id", "authorization_status"], [], "account response");
327
+ if (value.authorization_status !== "active") {
322
328
  throw new TeamClawsError("invalid_response", "TeamClaws returned an invalid account response.", {
323
329
  exitCode: 9,
324
330
  });
325
331
  }
326
- requireIsoDate(value.expires_at, "access-token expiry");
332
+ normalizeUuid(value.authorization_id, "authorization identifier");
327
333
  return {
328
334
  id: normalizeUuid(value.id, "user identifier"),
329
335
  display_name: requireString(value.display_name, "display name", 200),
@@ -405,44 +411,70 @@ export function parseLoginCredential(value) {
405
411
  export function parseWorkspaceCredential(value) {
406
412
  if (!isObject(value))
407
413
  throw invalidCredentialFile();
414
+ if (value.version === 1)
415
+ throw legacyPairingError();
408
416
  assertExactKeys(value, [
409
417
  "version",
410
418
  "server_id",
411
419
  "origin",
412
420
  "display_name",
413
421
  "device_id",
422
+ "pairing_id",
414
423
  "device_token",
424
+ "requester_sub",
425
+ "authorization_id",
426
+ "machine_jkt",
415
427
  "created_at",
416
428
  ], [], "workspace credential file");
417
- if (value.version !== 1)
429
+ if (value.version !== 2)
418
430
  throw invalidCredentialFile();
419
431
  return {
420
- version: 1,
432
+ version: 2,
421
433
  server_id: normalizeUuid(value.server_id),
422
434
  origin: canonicalWorkspaceOrigin(value.origin),
423
435
  display_name: requireString(value.display_name, "display name", 120),
424
436
  device_id: normalizeUuid(value.device_id, "device identifier"),
437
+ pairing_id: normalizeUuid(value.pairing_id, "pairing identifier"),
425
438
  device_token: requireSecret(value.device_token),
439
+ requester_sub: requireString(value.requester_sub, "requester subject", 255),
440
+ authorization_id: normalizeUuid(value.authorization_id, "authorization identifier"),
441
+ machine_jkt: requireThumbprint(value.machine_jkt),
426
442
  created_at: requireIsoDate(value.created_at, "credential creation date"),
427
443
  };
428
444
  }
429
445
  export function parseStatusWorkspaceCredential(value, expectedServerId) {
430
- if (!isObject(value) || value.version !== 1)
446
+ if (!isObject(value))
447
+ throw invalidCredentialFile();
448
+ if (value.version === 1)
449
+ throw legacyPairingError();
450
+ if (value.version !== 2)
431
451
  throw invalidCredentialFile();
432
452
  const serverId = normalizeUuid(value.server_id);
433
453
  if (serverId !== normalizeUuid(expectedServerId))
434
454
  throw invalidCredentialFile();
435
455
  return {
436
- version: 1,
456
+ version: 2,
437
457
  server_id: serverId,
438
458
  origin: canonicalWorkspaceOrigin(value.origin),
439
459
  display_name: typeof value.display_name === "string" && value.display_name.length > 0
440
460
  ? requireString(value.display_name, "display name", 120)
441
461
  : "Paired device",
442
462
  device_id: normalizeUuid(value.device_id, "device identifier"),
463
+ pairing_id: normalizeUuid(value.pairing_id, "pairing identifier"),
443
464
  device_token: requireSecret(value.device_token),
465
+ requester_sub: requireString(value.requester_sub, "requester subject", 255),
466
+ authorization_id: normalizeUuid(value.authorization_id, "authorization identifier"),
467
+ machine_jkt: requireThumbprint(value.machine_jkt),
444
468
  };
445
469
  }
470
+ function requireThumbprint(value) {
471
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(value))
472
+ throw invalidCredentialFile();
473
+ return value;
474
+ }
475
+ function legacyPairingError() {
476
+ return new TeamClawsError("re_pair_required", "This saved pairing predates bound CLI authorization. Pair this workspace again.", { exitCode: 3 });
477
+ }
446
478
  function requireSecret(value) {
447
479
  if (typeof value !== "string" || value.length < 8 || value.length > 8_192) {
448
480
  throw invalidCredentialFile();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaws",
3
- "version": "1.0.4",
3
+ "version": "2.0.0",
4
4
  "description": "Official TeamClaws command-line client and agent skills",
5
5
  "license": "PolyForm-Shield-1.0.0",
6
6
  "homepage": "https://teamclaws.com",
@@ -16,6 +16,7 @@ npx teamclaws login --json
16
16
  npx teamclaws login owner@example.com --json
17
17
  npx teamclaws auth status --json
18
18
  npx teamclaws logout --json
19
+ npx teamclaws logout --revoke --json
19
20
  ```
20
21
 
21
22
  Login uses OAuth 2.0 Device Authorization Grant against www.teamclaws.com. The
@@ -25,13 +26,15 @@ while the user signs in; do not restart it while awaiting approval. Confirm the
25
26
  returned account after `login_complete` and exit 0, not after opening a browser.
26
27
  Start without an email unless the user supplies one. Optional email is a bind
27
28
  sent to TeamClaws, not identity asserted by this process. Never transfer a
28
- model or harness token into this process. The command-line client stores its own
29
- protected login credential locally.
29
+ model or harness token into this process. The client stores an authorization ID
30
+ and protected local signing key; temporary OAuth tokens are discarded.
30
31
 
31
32
  Only treat the fixed structured events as safe. Never expose access tokens,
32
33
  refresh tokens, device codes, cookies, or authorization headers.
33
34
 
34
- Logout always removes the local command-line login credential. A
35
- `local_only_warning` status means local logout succeeded but remote revocation
36
- could not be confirmed. Completed Clawgate workspace credentials are not
37
- deleted by logout.
35
+ Ordinary logout removes the local authorization reference. Use `--revoke` only
36
+ when the user asks to revoke this CLI authorization at TeamClaws; the CLI waits
37
+ for confirmation before removing its local reference. If remote revocation is
38
+ uncertain, report that uncertainty and retain the local reference. Completed
39
+ Clawgate workspace credentials are not deleted by logout, but old pairings
40
+ without authorization binding require re-pairing.
@@ -28,7 +28,8 @@ explicit workspace address or server identifier is supplied. On `login_required`
28
28
  or `invalid_authentication` (exit 3), explain that login is required. For a
29
29
  status-only request, stop. For an authorized connect request, follow the login
30
30
  skill and resume after verified login. Do not retry with an explicit target to bypass login.
31
- The CLI verifies account authentication before workspace contact or approval.
31
+ The CLI requires its saved authorization and matching machine key. Existing
32
+ pairings without this binding require re-pairing.
32
33
 
33
34
  ## Status-only guardrail
34
35