@linkedclaw/cli 0.1.6 → 0.2.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/dist/bin.js +433 -34
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/decide.ts +48 -0
- package/src/auth/device.ts +203 -0
- package/src/auth/loopback.ts +239 -0
- package/src/auth/pkce.ts +22 -0
- package/src/commands/auth.ts +96 -10
- package/test/auth-decide.test.ts +38 -0
- package/test/auth-device.test.ts +126 -0
- package/test/auth-loopback.test.ts +190 -0
- package/test/auth-pkce.test.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linkedclaw/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "LinkedClaw command-line interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"js-yaml": "^4.1.0",
|
|
15
15
|
"open": "^10.1.0",
|
|
16
16
|
"ws": "^8.0.0",
|
|
17
|
-
"@linkedclaw/consumer-runtime": "^0.9.3",
|
|
18
17
|
"@linkedclaw/consumer": "^0.9.2",
|
|
19
|
-
"@linkedclaw/
|
|
20
|
-
"@linkedclaw/provider-runtime": "^0.9.1"
|
|
18
|
+
"@linkedclaw/consumer-runtime": "^0.10.0",
|
|
19
|
+
"@linkedclaw/provider-runtime": "^0.9.1",
|
|
20
|
+
"@linkedclaw/provider": "^0.9.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristics for picking the loopback flow vs forcing the device flow.
|
|
3
|
+
*
|
|
4
|
+
* We try loopback first by default — it's lower-friction (just a click
|
|
5
|
+
* in the browser; no code typing). When the environment looks like it
|
|
6
|
+
* can't reach a localhost server in the user's browser (SSH, SSH-tunneled
|
|
7
|
+
* dev, headless Linux, container, codespace), we switch to device flow
|
|
8
|
+
* before even attempting loopback.
|
|
9
|
+
*
|
|
10
|
+
* On any loopback failure (browser launch error, callback never received
|
|
11
|
+
* within timeout, port-bind failure), the caller can fall through to the
|
|
12
|
+
* device flow as a recoverable backup.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { hostname, platform } from "node:os";
|
|
16
|
+
|
|
17
|
+
export interface DetectedClient {
|
|
18
|
+
label: string;
|
|
19
|
+
packageVersion: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function detectLabel(packageVersion: string): string {
|
|
23
|
+
const host = hostname();
|
|
24
|
+
return truncate(
|
|
25
|
+
`linkedclaw-cli/${packageVersion} on ${platform()} (host: ${host})`,
|
|
26
|
+
120,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function truncate(s: string, n: number): string {
|
|
31
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isHeadless(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
35
|
+
if (env.SSH_CLIENT || env.SSH_TTY) return true;
|
|
36
|
+
if (env.CODESPACES === "true") return true;
|
|
37
|
+
if (env.LINKEDCLAW_FORCE_DEVICE_FLOW === "1") return true;
|
|
38
|
+
|
|
39
|
+
// Container hint — Docker, podman.
|
|
40
|
+
if (existsSync("/.dockerenv") || existsSync("/run/.containerenv")) return true;
|
|
41
|
+
|
|
42
|
+
// Linux desktop check: with no DISPLAY/WAYLAND_DISPLAY assume headless.
|
|
43
|
+
if (platform() === "linux") {
|
|
44
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device authorization grant flow (RFC 8628).
|
|
3
|
+
*
|
|
4
|
+
* 1. POST /api/v1/auth/device/code
|
|
5
|
+
* → cloud creates pending device-flow grant, returns {device_code,
|
|
6
|
+
* user_code, verification_uri, verification_uri_complete,
|
|
7
|
+
* expires_in, interval}.
|
|
8
|
+
* 2. CLI prints the user_code + URL, optionally opens the URL in a
|
|
9
|
+
* browser.
|
|
10
|
+
* 3. CLI polls POST /api/v1/auth/device/poll {device_code}
|
|
11
|
+
* until the response flips from {status:"pending"} to
|
|
12
|
+
* {status:"approved", api_key:"lc_…"}.
|
|
13
|
+
* 4. On HTTP 400 (expired_token | access_denied | invalid_grant) we
|
|
14
|
+
* surface the corresponding DeviceFlowError and exit non-zero.
|
|
15
|
+
*/
|
|
16
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
17
|
+
|
|
18
|
+
export class DeviceFlowError extends Error {
|
|
19
|
+
constructor(message: string, public code: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "DeviceFlowError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DeviceFlowResult {
|
|
26
|
+
apiKey: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
handle: string | null;
|
|
29
|
+
scope: string;
|
|
30
|
+
keyId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DeviceCodeResponse {
|
|
34
|
+
device_code: string;
|
|
35
|
+
user_code: string;
|
|
36
|
+
verification_uri: string;
|
|
37
|
+
verification_uri_complete: string;
|
|
38
|
+
expires_in: number;
|
|
39
|
+
interval: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DeviceFlowOptions {
|
|
43
|
+
cloudUrl: string;
|
|
44
|
+
clientLabel: string;
|
|
45
|
+
requestedScope: string;
|
|
46
|
+
openBrowser?: (url: string) => Promise<void>;
|
|
47
|
+
onUserPrompt: (info: DeviceCodeResponse) => void;
|
|
48
|
+
// for tests
|
|
49
|
+
pollOverride?: (deviceCode: string) => Promise<unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runDeviceFlow(opts: DeviceFlowOptions): Promise<DeviceFlowResult> {
|
|
53
|
+
const init = await issueDeviceCode(opts.cloudUrl, {
|
|
54
|
+
client_label: opts.clientLabel,
|
|
55
|
+
requested_scope: opts.requestedScope,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Best-effort browser launch; failures are silent — user has the URL printed.
|
|
59
|
+
if (opts.openBrowser) {
|
|
60
|
+
try {
|
|
61
|
+
await opts.openBrowser(init.verification_uri_complete);
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore — code was already printed to terminal
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
opts.onUserPrompt(init);
|
|
67
|
+
|
|
68
|
+
const deadline = Date.now() + init.expires_in * 1000;
|
|
69
|
+
let interval = init.interval;
|
|
70
|
+
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
const before = Date.now();
|
|
73
|
+
const result = await pollOnce(
|
|
74
|
+
opts.cloudUrl,
|
|
75
|
+
init.device_code,
|
|
76
|
+
opts.pollOverride,
|
|
77
|
+
);
|
|
78
|
+
if (result.kind === "approved") {
|
|
79
|
+
return {
|
|
80
|
+
apiKey: result.api_key,
|
|
81
|
+
userId: result.user_id,
|
|
82
|
+
handle: result.handle ?? null,
|
|
83
|
+
scope: result.scope,
|
|
84
|
+
keyId: result.key_id,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (result.kind === "slow_down") {
|
|
88
|
+
interval = Math.min(interval * 2, 30);
|
|
89
|
+
}
|
|
90
|
+
const elapsed = Date.now() - before;
|
|
91
|
+
const wait = Math.max(0, interval * 1000 - elapsed);
|
|
92
|
+
await sleep(wait);
|
|
93
|
+
}
|
|
94
|
+
throw new DeviceFlowError(
|
|
95
|
+
"Login window expired. Run `linkedclaw login` to retry.",
|
|
96
|
+
"expired_token",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface PollPending {
|
|
101
|
+
kind: "pending";
|
|
102
|
+
}
|
|
103
|
+
interface PollSlowDown {
|
|
104
|
+
kind: "slow_down";
|
|
105
|
+
}
|
|
106
|
+
interface PollApproved {
|
|
107
|
+
kind: "approved";
|
|
108
|
+
api_key: string;
|
|
109
|
+
user_id: string;
|
|
110
|
+
handle: string | null;
|
|
111
|
+
scope: string;
|
|
112
|
+
key_id: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type PollResult = PollPending | PollSlowDown | PollApproved;
|
|
116
|
+
|
|
117
|
+
async function pollOnce(
|
|
118
|
+
cloudUrl: string,
|
|
119
|
+
deviceCode: string,
|
|
120
|
+
override?: (deviceCode: string) => Promise<unknown>,
|
|
121
|
+
): Promise<PollResult> {
|
|
122
|
+
const raw = override
|
|
123
|
+
? await override(deviceCode)
|
|
124
|
+
: await rawPoll(cloudUrl, deviceCode);
|
|
125
|
+
return interpretPoll(raw);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function rawPoll(cloudUrl: string, deviceCode: string): Promise<unknown> {
|
|
129
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/device/poll`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
133
|
+
});
|
|
134
|
+
if (resp.status === 400) {
|
|
135
|
+
let detail = "invalid_grant";
|
|
136
|
+
try {
|
|
137
|
+
const j = (await resp.json()) as { detail?: string };
|
|
138
|
+
if (j.detail) detail = j.detail;
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
if (detail === "slow_down") return { __slow: true };
|
|
143
|
+
throw new DeviceFlowError(_humanize(detail), detail);
|
|
144
|
+
}
|
|
145
|
+
if (resp.status !== 200) {
|
|
146
|
+
throw new DeviceFlowError(`Poll failed: HTTP ${resp.status}`, "http_error");
|
|
147
|
+
}
|
|
148
|
+
return await resp.json();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function interpretPoll(raw: unknown): PollResult {
|
|
152
|
+
if (raw && typeof raw === "object" && "__slow" in raw) {
|
|
153
|
+
return { kind: "slow_down" };
|
|
154
|
+
}
|
|
155
|
+
const obj = (raw ?? {}) as Record<string, unknown>;
|
|
156
|
+
if (obj.status === "pending") return { kind: "pending" };
|
|
157
|
+
if (obj.status === "approved") {
|
|
158
|
+
return {
|
|
159
|
+
kind: "approved",
|
|
160
|
+
api_key: String(obj.api_key),
|
|
161
|
+
user_id: String(obj.user_id),
|
|
162
|
+
handle: (obj.handle as string | null) ?? null,
|
|
163
|
+
scope: String(obj.scope),
|
|
164
|
+
key_id: String(obj.key_id),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
throw new DeviceFlowError(`Unexpected poll status: ${obj.status}`, "protocol_error");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _humanize(code: string): string {
|
|
171
|
+
switch (code) {
|
|
172
|
+
case "expired_token":
|
|
173
|
+
return "Login window expired. Run `linkedclaw login` to retry.";
|
|
174
|
+
case "access_denied":
|
|
175
|
+
return "Authorization denied by user.";
|
|
176
|
+
case "invalid_grant":
|
|
177
|
+
return "Authorization session is no longer valid.";
|
|
178
|
+
default:
|
|
179
|
+
return code;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function issueDeviceCode(
|
|
184
|
+
cloudUrl: string,
|
|
185
|
+
body: Record<string, unknown>,
|
|
186
|
+
): Promise<DeviceCodeResponse> {
|
|
187
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/device/code`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "Content-Type": "application/json" },
|
|
190
|
+
body: JSON.stringify(body),
|
|
191
|
+
});
|
|
192
|
+
if (resp.status !== 201) {
|
|
193
|
+
let detail = `${resp.status}`;
|
|
194
|
+
try {
|
|
195
|
+
const j = (await resp.json()) as { detail?: string };
|
|
196
|
+
if (j.detail) detail = j.detail;
|
|
197
|
+
} catch {
|
|
198
|
+
// ignore
|
|
199
|
+
}
|
|
200
|
+
throw new DeviceFlowError(`/auth/device/code ${resp.status}: ${detail}`, "init_failed");
|
|
201
|
+
}
|
|
202
|
+
return (await resp.json()) as DeviceCodeResponse;
|
|
203
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback OAuth flow with PKCE (RFC 7636 + RFC 8252 §7.3).
|
|
3
|
+
*
|
|
4
|
+
* Steps:
|
|
5
|
+
* 1. POST /api/v1/auth/oauth/initiate → cloud creates pending grant,
|
|
6
|
+
* returns authorize_url + grant_id + expires_in.
|
|
7
|
+
* 2. Bind a TCP listener on 127.0.0.1:<random free port>; build a
|
|
8
|
+
* redirect_uri pointing at it.
|
|
9
|
+
* 3. Open the user's browser to authorize_url.
|
|
10
|
+
* 4. User clicks Approve in portal; portal redirects browser to
|
|
11
|
+
* redirect_uri?code=...&state=....
|
|
12
|
+
* 5. Local listener receives the GET, captures `code`, replies with a
|
|
13
|
+
* simple "you can close this tab" HTML, then shuts down.
|
|
14
|
+
* 6. POST /api/v1/auth/oauth/token { grant_type, code, code_verifier }
|
|
15
|
+
* → cloud verifies PKCE, mints lc_… key, returns it.
|
|
16
|
+
*
|
|
17
|
+
* Errors are wrapped in LoopbackError so callers can distinguish
|
|
18
|
+
* recoverable (fall back to device flow) from non-recoverable.
|
|
19
|
+
*/
|
|
20
|
+
import { createServer, Server } from "node:http";
|
|
21
|
+
import { AddressInfo } from "node:net";
|
|
22
|
+
|
|
23
|
+
import { deriveChallenge, generateState, generateVerifier } from "./pkce.js";
|
|
24
|
+
|
|
25
|
+
export class LoopbackError extends Error {
|
|
26
|
+
constructor(message: string, public recoverable: boolean) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "LoopbackError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LoopbackResult {
|
|
33
|
+
apiKey: string;
|
|
34
|
+
userId: string;
|
|
35
|
+
handle: string | null;
|
|
36
|
+
scope: string;
|
|
37
|
+
keyId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface LoopbackOptions {
|
|
41
|
+
cloudUrl: string;
|
|
42
|
+
clientLabel: string;
|
|
43
|
+
requestedScope: string;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
openBrowser: (url: string) => Promise<void>;
|
|
46
|
+
onUserPrompt?: (verificationUrl: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SUCCESS_HTML = `<!doctype html>
|
|
50
|
+
<html><head><meta charset="utf-8"><title>LinkedClaw — Authorized</title></head>
|
|
51
|
+
<body style="font-family:system-ui,sans-serif;text-align:center;padding:48px">
|
|
52
|
+
<h2>✅ Authorized</h2>
|
|
53
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
54
|
+
</body></html>`;
|
|
55
|
+
|
|
56
|
+
const ERROR_HTML = (msg: string) => `<!doctype html>
|
|
57
|
+
<html><head><meta charset="utf-8"><title>LinkedClaw — Error</title></head>
|
|
58
|
+
<body style="font-family:system-ui,sans-serif;text-align:center;padding:48px">
|
|
59
|
+
<h2>❌ Authorization failed</h2>
|
|
60
|
+
<p>${msg}</p>
|
|
61
|
+
<p>Return to your terminal — the CLI will retry or fall back automatically.</p>
|
|
62
|
+
</body></html>`;
|
|
63
|
+
|
|
64
|
+
export async function runLoopback(opts: LoopbackOptions): Promise<LoopbackResult> {
|
|
65
|
+
const verifier = generateVerifier();
|
|
66
|
+
const challenge = deriveChallenge(verifier);
|
|
67
|
+
const state = generateState();
|
|
68
|
+
|
|
69
|
+
// 1. Bind a free port. `port: 0` lets the OS pick.
|
|
70
|
+
const server = createServer();
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
server.once("error", reject);
|
|
73
|
+
server.listen(0, "127.0.0.1", () => {
|
|
74
|
+
server.removeListener("error", reject);
|
|
75
|
+
resolve();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const port = (server.address() as AddressInfo).port;
|
|
79
|
+
const redirectUri = `http://127.0.0.1:${port}/cb`;
|
|
80
|
+
|
|
81
|
+
let initResp: { grant_id: string; authorize_url: string; expires_in: number };
|
|
82
|
+
try {
|
|
83
|
+
initResp = await initiate(opts.cloudUrl, {
|
|
84
|
+
client_label: opts.clientLabel,
|
|
85
|
+
requested_scope: opts.requestedScope,
|
|
86
|
+
redirect_uri: redirectUri,
|
|
87
|
+
code_challenge: challenge,
|
|
88
|
+
code_challenge_method: "S256",
|
|
89
|
+
state,
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
server.close();
|
|
93
|
+
throw new LoopbackError(
|
|
94
|
+
`Failed to start authorization: ${(err as Error).message}`,
|
|
95
|
+
// network failure shouldn't fall through to device flow — it'll fail there too.
|
|
96
|
+
false,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. Try to launch the browser. On failure, fall through.
|
|
101
|
+
try {
|
|
102
|
+
await opts.openBrowser(initResp.authorize_url);
|
|
103
|
+
} catch {
|
|
104
|
+
server.close();
|
|
105
|
+
throw new LoopbackError(
|
|
106
|
+
"Could not open browser; switching to device flow.",
|
|
107
|
+
true,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (opts.onUserPrompt) opts.onUserPrompt(initResp.authorize_url);
|
|
111
|
+
|
|
112
|
+
// 3. Wait for the callback.
|
|
113
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
114
|
+
const callbackParams = await waitForCallback(server, timeoutMs).catch((err) => {
|
|
115
|
+
server.close();
|
|
116
|
+
throw err;
|
|
117
|
+
});
|
|
118
|
+
server.close();
|
|
119
|
+
|
|
120
|
+
if (callbackParams.error) {
|
|
121
|
+
throw new LoopbackError(`Authorization denied: ${callbackParams.error}`, false);
|
|
122
|
+
}
|
|
123
|
+
if (!callbackParams.code) {
|
|
124
|
+
throw new LoopbackError("Authorization callback missing code.", true);
|
|
125
|
+
}
|
|
126
|
+
if (callbackParams.state !== state) {
|
|
127
|
+
throw new LoopbackError("Authorization callback state mismatch.", false);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Exchange code for key.
|
|
131
|
+
const tokenResp = await exchangeToken(opts.cloudUrl, {
|
|
132
|
+
grant_type: "authorization_code",
|
|
133
|
+
code: callbackParams.code,
|
|
134
|
+
code_verifier: verifier,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
apiKey: tokenResp.api_key,
|
|
138
|
+
userId: tokenResp.user_id,
|
|
139
|
+
handle: tokenResp.handle ?? null,
|
|
140
|
+
scope: tokenResp.scope,
|
|
141
|
+
keyId: tokenResp.key_id,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface CallbackParams {
|
|
146
|
+
code?: string;
|
|
147
|
+
state?: string;
|
|
148
|
+
error?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function waitForCallback(server: Server, timeoutMs: number): Promise<CallbackParams> {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
reject(new LoopbackError("Browser callback timed out.", true));
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
|
|
157
|
+
server.on("request", (req, res) => {
|
|
158
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
159
|
+
if (url.pathname !== "/cb") {
|
|
160
|
+
res.statusCode = 404;
|
|
161
|
+
res.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const params: CallbackParams = {
|
|
165
|
+
code: url.searchParams.get("code") ?? undefined,
|
|
166
|
+
state: url.searchParams.get("state") ?? undefined,
|
|
167
|
+
error: url.searchParams.get("error") ?? undefined,
|
|
168
|
+
};
|
|
169
|
+
const html = params.error
|
|
170
|
+
? ERROR_HTML(params.error)
|
|
171
|
+
: params.code
|
|
172
|
+
? SUCCESS_HTML
|
|
173
|
+
: ERROR_HTML("Missing code parameter");
|
|
174
|
+
res.statusCode = params.code ? 200 : 400;
|
|
175
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
176
|
+
res.end(html);
|
|
177
|
+
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
resolve(params);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function initiate(
|
|
185
|
+
cloudUrl: string,
|
|
186
|
+
body: Record<string, unknown>,
|
|
187
|
+
): Promise<{ grant_id: string; authorize_url: string; expires_in: number }> {
|
|
188
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/oauth/initiate`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify(body),
|
|
192
|
+
});
|
|
193
|
+
if (resp.status !== 201) {
|
|
194
|
+
const detail = await safeDetail(resp);
|
|
195
|
+
throw new Error(`/auth/oauth/initiate ${resp.status}: ${detail}`);
|
|
196
|
+
}
|
|
197
|
+
return (await resp.json()) as {
|
|
198
|
+
grant_id: string;
|
|
199
|
+
authorize_url: string;
|
|
200
|
+
expires_in: number;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function exchangeToken(
|
|
205
|
+
cloudUrl: string,
|
|
206
|
+
body: Record<string, unknown>,
|
|
207
|
+
): Promise<{
|
|
208
|
+
api_key: string;
|
|
209
|
+
user_id: string;
|
|
210
|
+
handle: string | null;
|
|
211
|
+
scope: string;
|
|
212
|
+
key_id: string;
|
|
213
|
+
}> {
|
|
214
|
+
const resp = await fetch(`${cloudUrl}/api/v1/auth/oauth/token`, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "Content-Type": "application/json" },
|
|
217
|
+
body: JSON.stringify(body),
|
|
218
|
+
});
|
|
219
|
+
if (resp.status !== 200) {
|
|
220
|
+
const detail = await safeDetail(resp);
|
|
221
|
+
throw new LoopbackError(`Token exchange failed: ${detail}`, false);
|
|
222
|
+
}
|
|
223
|
+
return (await resp.json()) as {
|
|
224
|
+
api_key: string;
|
|
225
|
+
user_id: string;
|
|
226
|
+
handle: string | null;
|
|
227
|
+
scope: string;
|
|
228
|
+
key_id: string;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function safeDetail(resp: Response): Promise<string> {
|
|
233
|
+
try {
|
|
234
|
+
const j = (await resp.json()) as { detail?: string };
|
|
235
|
+
return j.detail ?? `${resp.status}`;
|
|
236
|
+
} catch {
|
|
237
|
+
return `${resp.status}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
package/src/auth/pkce.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 7636 PKCE helpers.
|
|
3
|
+
*
|
|
4
|
+
* generateVerifier: returns a base64url-encoded random 64-byte string,
|
|
5
|
+
* trimmed to a 43–128 char `code_verifier` per RFC 7636 §4.1.
|
|
6
|
+
* deriveChallenge: returns BASE64URL(SHA256(verifier)), the `code_challenge`
|
|
7
|
+
* we send to the cloud /auth/oauth/initiate alongside `code_challenge_method: "S256"`.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
export function generateVerifier(): string {
|
|
12
|
+
// 64 raw bytes → ~86 base64url chars. Spec range is [43,128].
|
|
13
|
+
return randomBytes(64).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function deriveChallenge(verifier: string): string {
|
|
17
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function generateState(): string {
|
|
21
|
+
return randomBytes(16).toString("base64url");
|
|
22
|
+
}
|
package/src/commands/auth.ts
CHANGED
|
@@ -2,24 +2,110 @@ import { Command } from "commander";
|
|
|
2
2
|
import { configPath, readFileConfig, writeFileConfig, DEFAULT_CLOUD_URL } from "../config.js";
|
|
3
3
|
import { buildContext } from "../context.js";
|
|
4
4
|
import { runCommand, readLine } from "../output.js";
|
|
5
|
+
import { detectLabel, isHeadless } from "../auth/decide.js";
|
|
6
|
+
import { LoopbackError, runLoopback } from "../auth/loopback.js";
|
|
7
|
+
import { runDeviceFlow } from "../auth/device.js";
|
|
8
|
+
|
|
9
|
+
const CLI_VERSION = "0.2.0";
|
|
5
10
|
|
|
6
11
|
export function registerAuthCommands(program: Command): void {
|
|
7
12
|
program
|
|
8
13
|
.command("login")
|
|
9
|
-
.description(
|
|
10
|
-
|
|
14
|
+
.description(
|
|
15
|
+
"Authenticate via browser (loopback PKCE; falls back to device code if headless).",
|
|
16
|
+
)
|
|
17
|
+
.option("--api-key <key>", "Skip browser; store key directly")
|
|
18
|
+
.option("--paste", "Skip browser; prompt for key on stdin")
|
|
19
|
+
.option("--device", "Force device flow (skip loopback)")
|
|
20
|
+
.option("--scope <scope>", "Requested scope: full | read | invoke", "full")
|
|
21
|
+
.option("--label <label>", "Client label override (default: auto-detected)")
|
|
11
22
|
.option("--cloud-url <url>", "Override cloud URL")
|
|
12
23
|
.action(async (opts) => {
|
|
13
24
|
await runCommand(async () => {
|
|
14
|
-
let apiKey = opts.apiKey as string | undefined;
|
|
15
|
-
if (!apiKey) {
|
|
16
|
-
apiKey = await readLine("Paste API key: ");
|
|
17
|
-
}
|
|
18
|
-
if (!apiKey) throw new Error("empty api key");
|
|
19
25
|
const prev = readFileConfig();
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const cloudUrl =
|
|
27
|
+
(opts.cloudUrl as string | undefined) ??
|
|
28
|
+
(prev.cloudUrl as string | undefined) ??
|
|
29
|
+
process.env.LINKEDCLAW_CLOUD_URL ??
|
|
30
|
+
DEFAULT_CLOUD_URL;
|
|
31
|
+
|
|
32
|
+
if (opts.apiKey || opts.paste) {
|
|
33
|
+
let apiKey = opts.apiKey as string | undefined;
|
|
34
|
+
if (!apiKey) apiKey = await readLine("Paste API key: ");
|
|
35
|
+
if (!apiKey) throw new Error("empty api key");
|
|
36
|
+
writeFileConfig({ ...prev, apiKey, cloudUrl });
|
|
37
|
+
return { ok: true, mode: "paste", path: configPath() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const clientLabel = (opts.label as string | undefined) ?? detectLabel(CLI_VERSION);
|
|
41
|
+
const requestedScope = (opts.scope as string | undefined) ?? "full";
|
|
42
|
+
|
|
43
|
+
const openBrowser = async (url: string): Promise<void> => {
|
|
44
|
+
const open = (await import("open")).default;
|
|
45
|
+
await open(url);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const forceDevice = Boolean(opts.device) || isHeadless();
|
|
49
|
+
|
|
50
|
+
if (!forceDevice) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await runLoopback({
|
|
53
|
+
cloudUrl,
|
|
54
|
+
clientLabel,
|
|
55
|
+
requestedScope,
|
|
56
|
+
openBrowser,
|
|
57
|
+
onUserPrompt: (url) => {
|
|
58
|
+
process.stderr.write(
|
|
59
|
+
`Opened browser to ${url}\nApprove the request to continue.\n`,
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
writeFileConfig({ ...prev, apiKey: result.apiKey, cloudUrl });
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
mode: "loopback",
|
|
67
|
+
path: configPath(),
|
|
68
|
+
user_id: result.userId,
|
|
69
|
+
handle: result.handle,
|
|
70
|
+
scope: result.scope,
|
|
71
|
+
key_id: result.keyId,
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err instanceof LoopbackError && err.recoverable) {
|
|
75
|
+
process.stderr.write(`${err.message}\n`);
|
|
76
|
+
// fall through to device flow
|
|
77
|
+
} else {
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await runDeviceFlow({
|
|
84
|
+
cloudUrl,
|
|
85
|
+
clientLabel,
|
|
86
|
+
requestedScope,
|
|
87
|
+
openBrowser,
|
|
88
|
+
onUserPrompt: (info) => {
|
|
89
|
+
process.stderr.write(
|
|
90
|
+
`\n🔓 LinkedClaw login\n\n` +
|
|
91
|
+
`Open this URL in your browser:\n ${info.verification_uri_complete}\n\n` +
|
|
92
|
+
`Or visit ${info.verification_uri} and enter:\n ${info.user_code}\n\n` +
|
|
93
|
+
`Waiting for approval... (${Math.floor(info.expires_in / 60)}m ${
|
|
94
|
+
info.expires_in % 60
|
|
95
|
+
}s remaining)\n`,
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
writeFileConfig({ ...prev, apiKey: result.apiKey, cloudUrl });
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
mode: "device",
|
|
103
|
+
path: configPath(),
|
|
104
|
+
user_id: result.userId,
|
|
105
|
+
handle: result.handle,
|
|
106
|
+
scope: result.scope,
|
|
107
|
+
key_id: result.keyId,
|
|
108
|
+
};
|
|
23
109
|
});
|
|
24
110
|
});
|
|
25
111
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectLabel, isHeadless } from "../src/auth/decide.js";
|
|
3
|
+
|
|
4
|
+
describe("isHeadless", () => {
|
|
5
|
+
it("returns true when SSH_TTY is set", () => {
|
|
6
|
+
expect(isHeadless({ SSH_TTY: "/dev/pts/0" } as NodeJS.ProcessEnv)).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns true when SSH_CLIENT is set", () => {
|
|
10
|
+
expect(isHeadless({ SSH_CLIENT: "1.2.3.4 1234 22" } as NodeJS.ProcessEnv)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns true when CODESPACES is true", () => {
|
|
14
|
+
expect(isHeadless({ CODESPACES: "true" } as NodeJS.ProcessEnv)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns true when LINKEDCLAW_FORCE_DEVICE_FLOW is 1", () => {
|
|
18
|
+
expect(isHeadless({ LINKEDCLAW_FORCE_DEVICE_FLOW: "1" } as NodeJS.ProcessEnv)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns false on local desktop env (with DISPLAY or non-Linux)", () => {
|
|
22
|
+
// On Mac (the dev box this runs on), platform is darwin → not headless.
|
|
23
|
+
expect(isHeadless({} as NodeJS.ProcessEnv)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("detectLabel", () => {
|
|
28
|
+
it("includes the version, platform, and host", () => {
|
|
29
|
+
const label = detectLabel("0.2.0");
|
|
30
|
+
expect(label).toMatch(/^linkedclaw-cli\/0\.2\.0 on /);
|
|
31
|
+
expect(label).toContain("(host:");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("truncates at 120 chars", () => {
|
|
35
|
+
const label = detectLabel("0.2.0");
|
|
36
|
+
expect(label.length).toBeLessThanOrEqual(120);
|
|
37
|
+
});
|
|
38
|
+
});
|