@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createServer, Server } from "node:http";
|
|
3
|
+
import { AddressInfo } from "node:net";
|
|
4
|
+
|
|
5
|
+
import { runDeviceFlow } from "../src/auth/device.js";
|
|
6
|
+
|
|
7
|
+
interface FakeDeviceGrant {
|
|
8
|
+
device_code: string;
|
|
9
|
+
user_code: string;
|
|
10
|
+
approved: boolean;
|
|
11
|
+
consumed: boolean;
|
|
12
|
+
denied: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildCloud(grants: Map<string, FakeDeviceGrant>) {
|
|
16
|
+
return new Promise<{ server: Server; url: string }>((resolve) => {
|
|
17
|
+
const s = createServer(async (req, res) => {
|
|
18
|
+
const chunks: Buffer[] = [];
|
|
19
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
20
|
+
const raw = Buffer.concat(chunks).toString();
|
|
21
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
22
|
+
|
|
23
|
+
const j = (status: number, payload: unknown) => {
|
|
24
|
+
res.statusCode = status;
|
|
25
|
+
res.setHeader("Content-Type", "application/json");
|
|
26
|
+
res.end(JSON.stringify(payload));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
30
|
+
if (url.pathname === "/api/v1/auth/device/code" && req.method === "POST") {
|
|
31
|
+
const device_code = `dvc_${Math.random().toString(36).slice(2, 14)}`;
|
|
32
|
+
const user_code = "BLUE-FROG";
|
|
33
|
+
grants.set(device_code, {
|
|
34
|
+
device_code,
|
|
35
|
+
user_code,
|
|
36
|
+
approved: false,
|
|
37
|
+
consumed: false,
|
|
38
|
+
denied: false,
|
|
39
|
+
});
|
|
40
|
+
return j(201, {
|
|
41
|
+
device_code,
|
|
42
|
+
user_code,
|
|
43
|
+
verification_uri: "http://example/device",
|
|
44
|
+
verification_uri_complete: `http://example/device?code=${user_code}`,
|
|
45
|
+
expires_in: 600,
|
|
46
|
+
interval: 1, // fast for tests
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (url.pathname === "/api/v1/auth/device/poll" && req.method === "POST") {
|
|
50
|
+
const g = grants.get(body.device_code);
|
|
51
|
+
if (!g) return j(400, { detail: "invalid_grant" });
|
|
52
|
+
if (g.denied) return j(400, { detail: "access_denied" });
|
|
53
|
+
if (g.consumed) return j(400, { detail: "invalid_grant" });
|
|
54
|
+
if (!g.approved) return j(200, { status: "pending" });
|
|
55
|
+
g.consumed = true;
|
|
56
|
+
return j(200, {
|
|
57
|
+
status: "approved",
|
|
58
|
+
api_key: "lc_fake_device_eeee1111ffff2222gggg3333hhhh4444",
|
|
59
|
+
user_id: "bob",
|
|
60
|
+
handle: "@bob",
|
|
61
|
+
scope: "read",
|
|
62
|
+
key_id: "key_fake_device",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return j(404, { detail: "not found" });
|
|
66
|
+
});
|
|
67
|
+
s.listen(0, "127.0.0.1", () => {
|
|
68
|
+
const port = (s.address() as AddressInfo).port;
|
|
69
|
+
resolve({ server: s, url: `http://127.0.0.1:${port}` });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("runDeviceFlow", () => {
|
|
75
|
+
it("polls until approved, returns lc_ key", async () => {
|
|
76
|
+
const grants = new Map<string, FakeDeviceGrant>();
|
|
77
|
+
const { server, url } = await buildCloud(grants);
|
|
78
|
+
try {
|
|
79
|
+
let prompted = false;
|
|
80
|
+
const promise = runDeviceFlow({
|
|
81
|
+
cloudUrl: url,
|
|
82
|
+
clientLabel: "test",
|
|
83
|
+
requestedScope: "read",
|
|
84
|
+
onUserPrompt: () => {
|
|
85
|
+
prompted = true;
|
|
86
|
+
// Simulate a user clicking Approve after a brief delay.
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
for (const g of grants.values()) g.approved = true;
|
|
89
|
+
}, 200);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const result = await promise;
|
|
93
|
+
expect(prompted).toBe(true);
|
|
94
|
+
expect(result.apiKey).toBe(
|
|
95
|
+
"lc_fake_device_eeee1111ffff2222gggg3333hhhh4444",
|
|
96
|
+
);
|
|
97
|
+
expect(result.scope).toBe("read");
|
|
98
|
+
expect(result.userId).toBe("bob");
|
|
99
|
+
} finally {
|
|
100
|
+
server.close();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("surfaces access_denied as DeviceFlowError", async () => {
|
|
105
|
+
const grants = new Map<string, FakeDeviceGrant>();
|
|
106
|
+
const { server, url } = await buildCloud(grants);
|
|
107
|
+
try {
|
|
108
|
+
const promise = runDeviceFlow({
|
|
109
|
+
cloudUrl: url,
|
|
110
|
+
clientLabel: "test",
|
|
111
|
+
requestedScope: "full",
|
|
112
|
+
onUserPrompt: () => {
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
for (const g of grants.values()) g.denied = true;
|
|
115
|
+
}, 100);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
await expect(promise).rejects.toMatchObject({
|
|
119
|
+
name: "DeviceFlowError",
|
|
120
|
+
code: "access_denied",
|
|
121
|
+
});
|
|
122
|
+
} finally {
|
|
123
|
+
server.close();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end loopback PKCE test driven against an in-process fake cloud.
|
|
3
|
+
*
|
|
4
|
+
* The fake cloud exposes /auth/oauth/initiate and /auth/oauth/token, plus
|
|
5
|
+
* a "portal Approve" simulator that the test drives directly: when the
|
|
6
|
+
* CLI hits initiate(), the test grabs the redirect_uri+state, generates
|
|
7
|
+
* an opaque code, and (a microtask later) hits the loopback callback as
|
|
8
|
+
* if the user had clicked Approve.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { createServer, Server } from "node:http";
|
|
12
|
+
import { AddressInfo } from "node:net";
|
|
13
|
+
|
|
14
|
+
import { runLoopback } from "../src/auth/loopback.js";
|
|
15
|
+
|
|
16
|
+
interface FakeGrant {
|
|
17
|
+
grant_id: string;
|
|
18
|
+
redirect_uri: string;
|
|
19
|
+
state: string;
|
|
20
|
+
code_challenge: string;
|
|
21
|
+
approved: boolean;
|
|
22
|
+
consumed: boolean;
|
|
23
|
+
code?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let cloud: Server;
|
|
27
|
+
let cloudUrl: string;
|
|
28
|
+
let grants: Map<string, FakeGrant>;
|
|
29
|
+
|
|
30
|
+
function buildCloud(): Promise<{ server: Server; url: string }> {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const s = createServer(async (req, res) => {
|
|
33
|
+
const chunks: Buffer[] = [];
|
|
34
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
35
|
+
const raw = Buffer.concat(chunks).toString();
|
|
36
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
37
|
+
|
|
38
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
39
|
+
const j = (status: number, payload: unknown) => {
|
|
40
|
+
res.statusCode = status;
|
|
41
|
+
res.setHeader("Content-Type", "application/json");
|
|
42
|
+
res.end(JSON.stringify(payload));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (url.pathname === "/api/v1/auth/oauth/initiate" && req.method === "POST") {
|
|
46
|
+
const grant_id = `grt_${Math.random().toString(36).slice(2, 10)}`;
|
|
47
|
+
grants.set(grant_id, {
|
|
48
|
+
grant_id,
|
|
49
|
+
redirect_uri: body.redirect_uri,
|
|
50
|
+
state: body.state,
|
|
51
|
+
code_challenge: body.code_challenge,
|
|
52
|
+
approved: false,
|
|
53
|
+
consumed: false,
|
|
54
|
+
});
|
|
55
|
+
return j(201, {
|
|
56
|
+
grant_id,
|
|
57
|
+
authorize_url: `${cloudUrl}/__fake_portal/authorize?grant_id=${grant_id}`,
|
|
58
|
+
expires_in: 600,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname === "/api/v1/auth/oauth/token" && req.method === "POST") {
|
|
62
|
+
const found = [...grants.values()].find((g) => g.code === body.code);
|
|
63
|
+
if (!found) return j(400, { detail: "invalid_grant" });
|
|
64
|
+
if (found.consumed) return j(400, { detail: "invalid_grant" });
|
|
65
|
+
found.consumed = true;
|
|
66
|
+
return j(200, {
|
|
67
|
+
api_key: "lc_fake_aaaa1111bbbb2222cccc3333dddd4444",
|
|
68
|
+
user_id: "alice",
|
|
69
|
+
handle: "@alice",
|
|
70
|
+
scope: "full",
|
|
71
|
+
key_id: "key_fake_aaaa1111",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return j(404, { detail: "not found" });
|
|
75
|
+
});
|
|
76
|
+
s.listen(0, "127.0.0.1", () => {
|
|
77
|
+
const port = (s.address() as AddressInfo).port;
|
|
78
|
+
resolve({ server: s, url: `http://127.0.0.1:${port}` });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
grants = new Map();
|
|
85
|
+
const c = await buildCloud();
|
|
86
|
+
cloud = c.server;
|
|
87
|
+
cloudUrl = c.url;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
cloud.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Simulate a user clicking Approve in the portal: hit the CLI's loopback
|
|
96
|
+
* callback URL with code + state extracted from the recorded grant.
|
|
97
|
+
*/
|
|
98
|
+
async function simulateApprove(grant: FakeGrant, code: string): Promise<void> {
|
|
99
|
+
grant.code = code;
|
|
100
|
+
grant.approved = true;
|
|
101
|
+
const url = new URL(grant.redirect_uri);
|
|
102
|
+
url.searchParams.set("code", code);
|
|
103
|
+
url.searchParams.set("state", grant.state);
|
|
104
|
+
await fetch(url.toString());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("runLoopback", () => {
|
|
108
|
+
it("mints lc_ key after browser-side approval", async () => {
|
|
109
|
+
let approvalSchedulingResolved: (() => void) | null = null;
|
|
110
|
+
const result = runLoopback({
|
|
111
|
+
cloudUrl,
|
|
112
|
+
clientLabel: "test",
|
|
113
|
+
requestedScope: "full",
|
|
114
|
+
timeoutMs: 5_000,
|
|
115
|
+
openBrowser: async (_url: string) => {
|
|
116
|
+
// Schedule the approval after the loopback server starts listening
|
|
117
|
+
// for callbacks.
|
|
118
|
+
setTimeout(async () => {
|
|
119
|
+
const g = [...grants.values()][0];
|
|
120
|
+
await simulateApprove(g!, "test_authcode_xxxxxxxxxxxxxxxxxxxxxxxxxxx");
|
|
121
|
+
approvalSchedulingResolved?.();
|
|
122
|
+
}, 50);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await new Promise<void>((r) => (approvalSchedulingResolved = r));
|
|
127
|
+
|
|
128
|
+
const r = await result;
|
|
129
|
+
expect(r.apiKey).toBe("lc_fake_aaaa1111bbbb2222cccc3333dddd4444");
|
|
130
|
+
expect(r.userId).toBe("alice");
|
|
131
|
+
expect(r.scope).toBe("full");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("throws recoverable LoopbackError when openBrowser fails", async () => {
|
|
135
|
+
await expect(
|
|
136
|
+
runLoopback({
|
|
137
|
+
cloudUrl,
|
|
138
|
+
clientLabel: "test",
|
|
139
|
+
requestedScope: "full",
|
|
140
|
+
timeoutMs: 1_000,
|
|
141
|
+
openBrowser: async () => {
|
|
142
|
+
throw new Error("no display");
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
).rejects.toMatchObject({
|
|
146
|
+
name: "LoopbackError",
|
|
147
|
+
recoverable: true,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("throws recoverable LoopbackError when callback times out", async () => {
|
|
152
|
+
await expect(
|
|
153
|
+
runLoopback({
|
|
154
|
+
cloudUrl,
|
|
155
|
+
clientLabel: "test",
|
|
156
|
+
requestedScope: "full",
|
|
157
|
+
timeoutMs: 200,
|
|
158
|
+
openBrowser: async () => {
|
|
159
|
+
// succeeds, but no approval is ever simulated → timeout
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
).rejects.toMatchObject({
|
|
163
|
+
name: "LoopbackError",
|
|
164
|
+
recoverable: true,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("throws non-recoverable LoopbackError on state mismatch", async () => {
|
|
169
|
+
await expect(
|
|
170
|
+
runLoopback({
|
|
171
|
+
cloudUrl,
|
|
172
|
+
clientLabel: "test",
|
|
173
|
+
requestedScope: "full",
|
|
174
|
+
timeoutMs: 5_000,
|
|
175
|
+
openBrowser: async () => {
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
const g = [...grants.values()][0]!;
|
|
178
|
+
const url = new URL(g.redirect_uri);
|
|
179
|
+
url.searchParams.set("code", "test_authcode_xxxxxxxxxxxxxxxx");
|
|
180
|
+
url.searchParams.set("state", "wrong-state");
|
|
181
|
+
void fetch(url.toString()); // fire-and-forget; loopback will resolve on its own
|
|
182
|
+
}, 50);
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
).rejects.toMatchObject({
|
|
186
|
+
name: "LoopbackError",
|
|
187
|
+
recoverable: false,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { deriveChallenge, generateState, generateVerifier } from "../src/auth/pkce.js";
|
|
4
|
+
|
|
5
|
+
describe("PKCE", () => {
|
|
6
|
+
it("generateVerifier produces 43–128 char URL-safe value", () => {
|
|
7
|
+
for (let i = 0; i < 5; i++) {
|
|
8
|
+
const v = generateVerifier();
|
|
9
|
+
expect(v.length).toBeGreaterThanOrEqual(43);
|
|
10
|
+
expect(v.length).toBeLessThanOrEqual(128);
|
|
11
|
+
expect(/^[A-Za-z0-9_-]+$/.test(v)).toBe(true);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("verifier values differ across calls", () => {
|
|
16
|
+
const a = generateVerifier();
|
|
17
|
+
const b = generateVerifier();
|
|
18
|
+
expect(a).not.toBe(b);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("deriveChallenge equals BASE64URL(SHA256(verifier))", () => {
|
|
22
|
+
const verifier = "test-verifier-12345678901234567890123456789012345678901234";
|
|
23
|
+
const expected = createHash("sha256").update(verifier).digest("base64url");
|
|
24
|
+
expect(deriveChallenge(verifier)).toBe(expected);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("generateState returns 16-byte URL-safe value", () => {
|
|
28
|
+
const s = generateState();
|
|
29
|
+
expect(s.length).toBeGreaterThanOrEqual(16);
|
|
30
|
+
expect(/^[A-Za-z0-9_-]+$/.test(s)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|