@vellumai/cli 0.8.11-dev.202606121945.5dce801 → 0.8.11-dev.202606122025.f06346f
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/package.json +1 -1
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/workos-pkce.test.ts +312 -0
- package/src/commands/login.ts +123 -59
- package/src/lib/workos-pkce.ts +160 -0
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { startLoopbackListener } from "../commands/login.js";
|
|
4
|
+
|
|
5
|
+
/** Resolve "settled"/"pending" — proves whether `waitForCode` resolved. */
|
|
6
|
+
async function settleState(p: Promise<unknown>): Promise<"settled" | "pending"> {
|
|
7
|
+
return Promise.race([
|
|
8
|
+
p.then(
|
|
9
|
+
() => "settled" as const,
|
|
10
|
+
() => "settled" as const,
|
|
11
|
+
),
|
|
12
|
+
new Promise<"pending">((r) => setTimeout(() => r("pending"), 50)),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("startLoopbackListener", () => {
|
|
17
|
+
test("rejects a state-mismatched callback (CSRF) without settling", async () => {
|
|
18
|
+
const listener = await startLoopbackListener("expected-state");
|
|
19
|
+
try {
|
|
20
|
+
// Wrong state — the load-bearing CSRF check. Any local process can
|
|
21
|
+
// hit the loopback port, so a mismatched state must NOT deliver a code.
|
|
22
|
+
const res = await fetch(`${listener.redirectUri}?code=evil&state=wrong`);
|
|
23
|
+
expect(res.status).toBe(404);
|
|
24
|
+
expect(await settleState(listener.waitForCode)).toBe("pending");
|
|
25
|
+
|
|
26
|
+
// Wrong path on the right port is also ignored.
|
|
27
|
+
const noise = await fetch(
|
|
28
|
+
`${listener.redirectUri.replace("/auth/callback", "/evil")}?state=expected-state&code=c`,
|
|
29
|
+
);
|
|
30
|
+
expect(noise.status).toBe(404);
|
|
31
|
+
expect(await settleState(listener.waitForCode)).toBe("pending");
|
|
32
|
+
|
|
33
|
+
// A state-matched callback then settles it — the listener kept
|
|
34
|
+
// listening through the noise above.
|
|
35
|
+
const ok = await fetch(
|
|
36
|
+
`${listener.redirectUri}?code=good-code&state=expected-state`,
|
|
37
|
+
);
|
|
38
|
+
expect(ok.status).toBe(200);
|
|
39
|
+
expect(await listener.waitForCode).toBe("good-code");
|
|
40
|
+
} finally {
|
|
41
|
+
listener.close();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects on an error callback with the matching state", async () => {
|
|
46
|
+
const listener = await startLoopbackListener("st");
|
|
47
|
+
try {
|
|
48
|
+
const settled = listener.waitForCode.then(
|
|
49
|
+
() => null,
|
|
50
|
+
(e: Error) => e,
|
|
51
|
+
);
|
|
52
|
+
const res = await fetch(`${listener.redirectUri}?error=access_denied&state=st`);
|
|
53
|
+
expect(res.status).toBe(400);
|
|
54
|
+
const err = await settled;
|
|
55
|
+
expect(err?.message).toMatch(/access_denied/);
|
|
56
|
+
} finally {
|
|
57
|
+
listener.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("close rejects a pending waiter with the given reason", async () => {
|
|
62
|
+
const listener = await startLoopbackListener("st");
|
|
63
|
+
const settled = listener.waitForCode.then(
|
|
64
|
+
() => null,
|
|
65
|
+
(e: Error) => e,
|
|
66
|
+
);
|
|
67
|
+
listener.close("Login timed out. Please try again.");
|
|
68
|
+
const err = await settled;
|
|
69
|
+
expect(err?.message).toMatch(/timed out/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildAuthorizeUrl,
|
|
5
|
+
exchangeAccessTokenForSession,
|
|
6
|
+
exchangeCodeWithWorkos,
|
|
7
|
+
fetchWorkosClientId,
|
|
8
|
+
generatePkcePair,
|
|
9
|
+
selectWorkosClientId,
|
|
10
|
+
} from "../lib/workos-pkce.js";
|
|
11
|
+
|
|
12
|
+
const originalFetch = globalThis.fetch;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
globalThis.fetch = originalFetch;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Route fetch responses by URL substring so each WorkOS/platform call can be
|
|
20
|
+
* inspected. The module talks to remote hosts via loopbackSafeFetch, which
|
|
21
|
+
* delegates to globalThis.fetch.
|
|
22
|
+
*/
|
|
23
|
+
function mockFetchByUrl(responses: Record<string, () => Response>) {
|
|
24
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
25
|
+
globalThis.fetch = mock(
|
|
26
|
+
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
27
|
+
const url = String(input);
|
|
28
|
+
calls.push({ url, init });
|
|
29
|
+
const match = Object.entries(responses).find(([prefix]) =>
|
|
30
|
+
url.includes(prefix),
|
|
31
|
+
);
|
|
32
|
+
if (!match) throw new Error(`Unexpected fetch: ${url}`);
|
|
33
|
+
return match[1]();
|
|
34
|
+
},
|
|
35
|
+
) as unknown as typeof globalThis.fetch;
|
|
36
|
+
return calls;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("generatePkcePair", () => {
|
|
40
|
+
test("challenge is the base64url sha256 of the verifier", async () => {
|
|
41
|
+
const { verifier, challenge } = generatePkcePair();
|
|
42
|
+
const digest = await crypto.subtle.digest(
|
|
43
|
+
"SHA-256",
|
|
44
|
+
new TextEncoder().encode(verifier),
|
|
45
|
+
);
|
|
46
|
+
const expected = Buffer.from(digest).toString("base64url");
|
|
47
|
+
expect(challenge).toBe(expected);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("verifiers are unique", () => {
|
|
51
|
+
expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("buildAuthorizeUrl", () => {
|
|
56
|
+
const base = {
|
|
57
|
+
clientId: "client_123",
|
|
58
|
+
redirectUri: "http://127.0.0.1:4242/auth/callback",
|
|
59
|
+
challenge: "chal",
|
|
60
|
+
state: "st",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test("targets user_management/authorize with PKCE and authkit defaults", () => {
|
|
64
|
+
const url = new URL(buildAuthorizeUrl(base));
|
|
65
|
+
expect(url.origin).toBe("https://api.workos.com");
|
|
66
|
+
expect(url.pathname).toBe("/user_management/authorize");
|
|
67
|
+
expect(url.searchParams.get("client_id")).toBe("client_123");
|
|
68
|
+
expect(url.searchParams.get("redirect_uri")).toBe(base.redirectUri);
|
|
69
|
+
expect(url.searchParams.get("response_type")).toBe("code");
|
|
70
|
+
expect(url.searchParams.get("scope")).toBe("openid profile email");
|
|
71
|
+
expect(url.searchParams.get("code_challenge")).toBe("chal");
|
|
72
|
+
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|
73
|
+
expect(url.searchParams.get("state")).toBe("st");
|
|
74
|
+
expect(url.searchParams.get("provider")).toBe("authkit");
|
|
75
|
+
// Session reuse: never force a fresh IdP login.
|
|
76
|
+
expect(url.searchParams.has("prompt")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("provider hint replaces authkit", () => {
|
|
80
|
+
const url = new URL(
|
|
81
|
+
buildAuthorizeUrl({ ...base, providerHint: "GoogleOAuth" }),
|
|
82
|
+
);
|
|
83
|
+
expect(url.searchParams.get("provider")).toBe("GoogleOAuth");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("login hint is forwarded", () => {
|
|
87
|
+
const url = new URL(buildAuthorizeUrl({ ...base, loginHint: "a@b.co" }));
|
|
88
|
+
expect(url.searchParams.get("login_hint")).toBe("a@b.co");
|
|
89
|
+
|
|
90
|
+
const noHint = new URL(buildAuthorizeUrl(base));
|
|
91
|
+
expect(noHint.searchParams.has("login_hint")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("selectWorkosClientId", () => {
|
|
96
|
+
// During coexistence the platform lists two providers that share the
|
|
97
|
+
// "workos-oidc" id; only the OAuth2 one (no discovery URL) is usable.
|
|
98
|
+
const legacy = {
|
|
99
|
+
id: "workos-oidc",
|
|
100
|
+
name: "WorkOS OIDC",
|
|
101
|
+
client_id: "client_connect",
|
|
102
|
+
flows: ["provider_redirect", "provider_token"],
|
|
103
|
+
openid_configuration_url:
|
|
104
|
+
"https://x.authkit.app/.well-known/openid-configuration",
|
|
105
|
+
};
|
|
106
|
+
const modern = {
|
|
107
|
+
id: "workos-oidc",
|
|
108
|
+
name: "WorkOS",
|
|
109
|
+
client_id: "client_um",
|
|
110
|
+
flows: ["provider_redirect", "provider_token"],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
test("picks the OAuth2 entry during coexistence", () => {
|
|
114
|
+
expect(selectWorkosClientId([legacy, modern])).toBe("client_um");
|
|
115
|
+
expect(selectWorkosClientId([modern, legacy])).toBe("client_um");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("returns null when the platform lacks token auth", () => {
|
|
119
|
+
const preTokenAuth = { ...modern, flows: ["provider_redirect"] };
|
|
120
|
+
expect(selectWorkosClientId([legacy, preTokenAuth])).toBeNull();
|
|
121
|
+
expect(selectWorkosClientId([])).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("ignores an entry missing client_id", () => {
|
|
125
|
+
const noClientId = { id: "workos-oidc", flows: ["provider_token"] };
|
|
126
|
+
expect(selectWorkosClientId([noClientId])).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("fetchWorkosClientId", () => {
|
|
131
|
+
test("resolves the client id from the headless config", async () => {
|
|
132
|
+
const calls = mockFetchByUrl({
|
|
133
|
+
"/_allauth/app/v1/config": () =>
|
|
134
|
+
new Response(
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
data: {
|
|
137
|
+
socialaccount: {
|
|
138
|
+
providers: [
|
|
139
|
+
{
|
|
140
|
+
id: "workos-oidc",
|
|
141
|
+
client_id: "client_um",
|
|
142
|
+
flows: ["provider_token"],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
{ status: 200 },
|
|
149
|
+
),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(await fetchWorkosClientId("https://platform.example")).toBe(
|
|
153
|
+
"client_um",
|
|
154
|
+
);
|
|
155
|
+
expect(calls[0]!.url).toBe(
|
|
156
|
+
"https://platform.example/_allauth/app/v1/config",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("derives the config URL from the origin, ignoring any path", async () => {
|
|
161
|
+
const calls = mockFetchByUrl({
|
|
162
|
+
"/_allauth/app/v1/config": () =>
|
|
163
|
+
new Response(
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
data: {
|
|
166
|
+
socialaccount: {
|
|
167
|
+
providers: [
|
|
168
|
+
{ client_id: "client_um", flows: ["provider_token"] },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
{ status: 200 },
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await fetchWorkosClientId("https://platform.example/some/path");
|
|
178
|
+
expect(calls[0]!.url).toBe(
|
|
179
|
+
"https://platform.example/_allauth/app/v1/config",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("throws a clear error when no token-auth provider is advertised", async () => {
|
|
184
|
+
mockFetchByUrl({
|
|
185
|
+
"/_allauth/app/v1/config": () =>
|
|
186
|
+
new Response(
|
|
187
|
+
JSON.stringify({ data: { socialaccount: { providers: [] } } }),
|
|
188
|
+
{ status: 200 },
|
|
189
|
+
),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await expect(
|
|
193
|
+
fetchWorkosClientId("https://platform.example"),
|
|
194
|
+
).rejects.toThrow(/does not advertise/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("throws on a non-OK config response", async () => {
|
|
198
|
+
mockFetchByUrl({
|
|
199
|
+
"/_allauth/app/v1/config": () => new Response("nope", { status: 500 }),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await expect(
|
|
203
|
+
fetchWorkosClientId("https://platform.example"),
|
|
204
|
+
).rejects.toThrow(/500/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("exchangeCodeWithWorkos", () => {
|
|
209
|
+
test("posts a public-client PKCE exchange and returns the access token", async () => {
|
|
210
|
+
const calls = mockFetchByUrl({
|
|
211
|
+
"/user_management/authenticate": () =>
|
|
212
|
+
new Response(JSON.stringify({ access_token: "at_1", user: {} }), {
|
|
213
|
+
status: 200,
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const token = await exchangeCodeWithWorkos({
|
|
218
|
+
clientId: "client_um",
|
|
219
|
+
code: "c",
|
|
220
|
+
verifier: "v",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(token).toBe("at_1");
|
|
224
|
+
expect(calls[0]!.url).toBe(
|
|
225
|
+
"https://api.workos.com/user_management/authenticate",
|
|
226
|
+
);
|
|
227
|
+
const body = JSON.parse(String(calls[0]!.init?.body));
|
|
228
|
+
// Public client: no secret, no API key.
|
|
229
|
+
expect(body).toEqual({
|
|
230
|
+
client_id: "client_um",
|
|
231
|
+
grant_type: "authorization_code",
|
|
232
|
+
code: "c",
|
|
233
|
+
code_verifier: "v",
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("throws with upstream detail on failure", async () => {
|
|
238
|
+
mockFetchByUrl({
|
|
239
|
+
"/user_management/authenticate": () =>
|
|
240
|
+
new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
241
|
+
status: 400,
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await expect(
|
|
246
|
+
exchangeCodeWithWorkos({ clientId: "c", code: "x", verifier: "v" }),
|
|
247
|
+
).rejects.toThrow(/400/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("throws when the exchange returns no access token", async () => {
|
|
251
|
+
mockFetchByUrl({
|
|
252
|
+
"/user_management/authenticate": () =>
|
|
253
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await expect(
|
|
257
|
+
exchangeCodeWithWorkos({ clientId: "c", code: "x", verifier: "v" }),
|
|
258
|
+
).rejects.toThrow(/no access token/);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("exchangeAccessTokenForSession", () => {
|
|
263
|
+
test("posts the headless token payload and returns the session token", async () => {
|
|
264
|
+
const calls = mockFetchByUrl({
|
|
265
|
+
"/_allauth/app/v1/auth/provider/token": () =>
|
|
266
|
+
new Response(JSON.stringify({ meta: { session_token: "sess_1" } }), {
|
|
267
|
+
status: 200,
|
|
268
|
+
}),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const token = await exchangeAccessTokenForSession(
|
|
272
|
+
"https://platform.example",
|
|
273
|
+
"client_um",
|
|
274
|
+
"at_1",
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(token).toBe("sess_1");
|
|
278
|
+
expect(calls[0]!.url).toBe(
|
|
279
|
+
"https://platform.example/_allauth/app/v1/auth/provider/token",
|
|
280
|
+
);
|
|
281
|
+
const body = JSON.parse(String(calls[0]!.init?.body));
|
|
282
|
+
expect(body).toEqual({
|
|
283
|
+
provider: "workos",
|
|
284
|
+
process: "login",
|
|
285
|
+
token: { client_id: "client_um", access_token: "at_1" },
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("throws on a rejected token", async () => {
|
|
290
|
+
mockFetchByUrl({
|
|
291
|
+
"/_allauth/app/v1/auth/provider/token": () =>
|
|
292
|
+
new Response(JSON.stringify({ errors: [{ code: "invalid_token" }] }), {
|
|
293
|
+
status: 400,
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await expect(
|
|
298
|
+
exchangeAccessTokenForSession("https://platform.example", "c", "bad"),
|
|
299
|
+
).rejects.toThrow(/400/);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("throws when the session exchange returns no session token", async () => {
|
|
303
|
+
mockFetchByUrl({
|
|
304
|
+
"/_allauth/app/v1/auth/provider/token": () =>
|
|
305
|
+
new Response(JSON.stringify({ meta: {} }), { status: 200 }),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await expect(
|
|
309
|
+
exchangeAccessTokenForSession("https://platform.example", "c", "at"),
|
|
310
|
+
).rejects.toThrow(/no session token/);
|
|
311
|
+
});
|
|
312
|
+
});
|
package/src/commands/login.ts
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
import { createServer } from "http";
|
|
2
1
|
import { spawn } from "child_process";
|
|
3
2
|
import { randomBytes } from "crypto";
|
|
3
|
+
import { createServer } from "http";
|
|
4
|
+
import type { AddressInfo } from "net";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
getActiveAssistant,
|
|
7
|
-
resolveAssistant,
|
|
8
8
|
loadAllAssistants,
|
|
9
9
|
removeAssistantEntry,
|
|
10
|
+
resolveAssistant,
|
|
10
11
|
setActiveAssistant,
|
|
11
12
|
} from "../lib/assistant-config";
|
|
12
13
|
import { computeDeviceId } from "../lib/guardian-token";
|
|
13
|
-
import {
|
|
14
|
-
fetchAssistantIngressUrl,
|
|
15
|
-
fetchCurrentVersion,
|
|
16
|
-
} from "../lib/upgrade-lifecycle.js";
|
|
17
14
|
import {
|
|
18
15
|
clearPlatformToken,
|
|
19
16
|
ensureSelfHostedLocalRegistration,
|
|
@@ -21,7 +18,6 @@ import {
|
|
|
21
18
|
fetchOrganizationId,
|
|
22
19
|
fetchPlatformAssistants,
|
|
23
20
|
getPlatformUrl,
|
|
24
|
-
getWebUrl,
|
|
25
21
|
injectCredentialsIntoAssistant,
|
|
26
22
|
readGatewayCredential,
|
|
27
23
|
readPlatformToken,
|
|
@@ -29,6 +25,18 @@ import {
|
|
|
29
25
|
savePlatformToken,
|
|
30
26
|
} from "../lib/platform-client";
|
|
31
27
|
import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
|
|
28
|
+
import {
|
|
29
|
+
fetchAssistantIngressUrl,
|
|
30
|
+
fetchCurrentVersion,
|
|
31
|
+
} from "../lib/upgrade-lifecycle.js";
|
|
32
|
+
import {
|
|
33
|
+
CALLBACK_PATH,
|
|
34
|
+
buildAuthorizeUrl,
|
|
35
|
+
exchangeAccessTokenForSession,
|
|
36
|
+
exchangeCodeWithWorkos,
|
|
37
|
+
fetchWorkosClientId,
|
|
38
|
+
generatePkcePair,
|
|
39
|
+
} from "../lib/workos-pkce";
|
|
32
40
|
|
|
33
41
|
const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
|
|
34
42
|
|
|
@@ -41,7 +49,11 @@ function escapeHtml(s: string): string {
|
|
|
41
49
|
.replace(/'/g, "'");
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
function renderLoginPage(
|
|
52
|
+
function renderLoginPage(
|
|
53
|
+
title: string,
|
|
54
|
+
subtitle: string,
|
|
55
|
+
success: boolean,
|
|
56
|
+
): string {
|
|
45
57
|
const checkmarkSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
46
58
|
<circle cx="28" cy="28" r="28" fill="var(--positive-bg)"/>
|
|
47
59
|
<path class="check" d="M17 28.5L24.5 36L39 21" stroke="var(--positive-fg)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
@@ -175,78 +187,131 @@ function openBrowser(url: string): void {
|
|
|
175
187
|
child.unref();
|
|
176
188
|
}
|
|
177
189
|
|
|
190
|
+
export interface LoopbackListener {
|
|
191
|
+
/** The full `http://127.0.0.1:<port>/auth/callback` redirect URI. */
|
|
192
|
+
redirectUri: string;
|
|
193
|
+
/** Resolves with the authorization code once the state-matched callback arrives. */
|
|
194
|
+
waitForCode: Promise<string>;
|
|
195
|
+
/** Tear down the server, rejecting any pending waiter with `reason`. */
|
|
196
|
+
close: (reason?: string) => void;
|
|
197
|
+
}
|
|
198
|
+
|
|
178
199
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
200
|
+
* Bind an ephemeral 127.0.0.1 listener and wait for the OAuth redirect.
|
|
201
|
+
* Exported for tests; production callers go through `workosPkceLogin`.
|
|
181
202
|
*/
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
export function startLoopbackListener(
|
|
204
|
+
expectedState: string,
|
|
205
|
+
): Promise<LoopbackListener> {
|
|
206
|
+
return new Promise((resolveListener, rejectListener) => {
|
|
207
|
+
let settle: {
|
|
208
|
+
resolve: (code: string) => void;
|
|
209
|
+
reject: (err: Error) => void;
|
|
210
|
+
};
|
|
211
|
+
const waitForCode = new Promise<string>((resolve, reject) => {
|
|
212
|
+
settle = { resolve, reject };
|
|
213
|
+
});
|
|
185
214
|
|
|
186
215
|
const server = createServer((req, res) => {
|
|
187
|
-
const url = new URL(req.url ?? "/",
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
217
|
+
if (
|
|
218
|
+
url.pathname !== CALLBACK_PATH ||
|
|
219
|
+
url.searchParams.get("state") !== expectedState
|
|
220
|
+
) {
|
|
190
221
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
191
222
|
res.end("Not found");
|
|
192
223
|
return;
|
|
193
224
|
}
|
|
194
225
|
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
if (receivedState !== state) {
|
|
226
|
+
const error = url.searchParams.get("error");
|
|
227
|
+
const code = url.searchParams.get("code");
|
|
228
|
+
if (error || !code) {
|
|
199
229
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
200
|
-
res.end(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
res.end(
|
|
231
|
+
renderLoginPage(
|
|
232
|
+
"Login Failed",
|
|
233
|
+
"Please try again from your terminal.",
|
|
234
|
+
false,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
server.close();
|
|
238
|
+
settle.reject(
|
|
239
|
+
new Error(
|
|
240
|
+
`Authentication failed: ${error ?? "no authorization code received"}`,
|
|
241
|
+
),
|
|
242
|
+
);
|
|
209
243
|
return;
|
|
210
244
|
}
|
|
211
245
|
|
|
212
246
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
213
|
-
res.end(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
function cleanup(error: string | null, token?: string): void {
|
|
222
|
-
clearTimeout(timeout);
|
|
247
|
+
res.end(
|
|
248
|
+
renderLoginPage(
|
|
249
|
+
"Login Successful",
|
|
250
|
+
"You can close this window and return to your terminal.",
|
|
251
|
+
true,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
223
254
|
server.close();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
} else if (token) {
|
|
227
|
-
resolve(token);
|
|
228
|
-
} else {
|
|
229
|
-
reject(new Error("Unknown error during login."));
|
|
230
|
-
}
|
|
231
|
-
}
|
|
255
|
+
settle.resolve(code);
|
|
256
|
+
});
|
|
232
257
|
|
|
233
|
-
server.on("error",
|
|
258
|
+
server.on("error", rejectListener);
|
|
234
259
|
server.listen(0, "127.0.0.1", () => {
|
|
235
260
|
const addr = server.address();
|
|
236
261
|
if (!addr || typeof addr === "string") {
|
|
237
|
-
|
|
262
|
+
rejectListener(new Error("Failed to start local server."));
|
|
238
263
|
return;
|
|
239
264
|
}
|
|
265
|
+
const { port } = addr as AddressInfo;
|
|
266
|
+
resolveListener({
|
|
267
|
+
redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
|
|
268
|
+
waitForCode,
|
|
269
|
+
close: (reason?: string) => {
|
|
270
|
+
server.close();
|
|
271
|
+
settle.reject(new Error(reason ?? "Login cancelled."));
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** App-held WorkOS PKCE login */
|
|
279
|
+
async function workosPkceLogin(platformUrl: string): Promise<string> {
|
|
280
|
+
const clientId = await fetchWorkosClientId(platformUrl);
|
|
281
|
+
const { verifier, challenge } = generatePkcePair();
|
|
282
|
+
const state = randomBytes(32).toString("hex");
|
|
240
283
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
284
|
+
const listener = await startLoopbackListener(state);
|
|
285
|
+
const timeout = setTimeout(() => {
|
|
286
|
+
listener.close("Login timed out. Please try again.");
|
|
287
|
+
}, LOGIN_TIMEOUT_MS);
|
|
244
288
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
289
|
+
try {
|
|
290
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
291
|
+
clientId,
|
|
292
|
+
redirectUri: listener.redirectUri,
|
|
293
|
+
challenge,
|
|
294
|
+
state,
|
|
248
295
|
});
|
|
249
|
-
|
|
296
|
+
|
|
297
|
+
console.log("Opening browser for login...");
|
|
298
|
+
console.log(`If the browser doesn't open, visit: ${authorizeUrl}`);
|
|
299
|
+
openBrowser(authorizeUrl);
|
|
300
|
+
|
|
301
|
+
const code = await listener.waitForCode;
|
|
302
|
+
const accessToken = await exchangeCodeWithWorkos({
|
|
303
|
+
clientId,
|
|
304
|
+
code,
|
|
305
|
+
verifier,
|
|
306
|
+
});
|
|
307
|
+
return await exchangeAccessTokenForSession(
|
|
308
|
+
platformUrl,
|
|
309
|
+
clientId,
|
|
310
|
+
accessToken,
|
|
311
|
+
);
|
|
312
|
+
} finally {
|
|
313
|
+
clearTimeout(timeout);
|
|
314
|
+
}
|
|
250
315
|
}
|
|
251
316
|
|
|
252
317
|
export async function login(): Promise<void> {
|
|
@@ -306,11 +371,10 @@ export async function login(): Promise<void> {
|
|
|
306
371
|
}
|
|
307
372
|
}
|
|
308
373
|
|
|
309
|
-
// If no --token flag, use
|
|
374
|
+
// If no --token flag, use app-held WorkOS PKCE login.
|
|
310
375
|
if (!token) {
|
|
311
|
-
const webUrl = getWebUrl();
|
|
312
376
|
try {
|
|
313
|
-
token = await
|
|
377
|
+
token = await workosPkceLogin(getPlatformUrl());
|
|
314
378
|
} catch (error) {
|
|
315
379
|
console.error(`❌ ${error instanceof Error ? error.message : error}`);
|
|
316
380
|
process.exit(1);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-held PKCE login against WorkOS User Management (RFC 8252 loopback).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
8
|
+
|
|
9
|
+
const WORKOS_API_BASE_URL = "https://api.workos.com";
|
|
10
|
+
const PROVIDER_ID = "workos";
|
|
11
|
+
const SCOPE = "openid profile email";
|
|
12
|
+
|
|
13
|
+
// Use a loopback callback: `http://127.0.0.1:*/auth/callback`
|
|
14
|
+
export const CALLBACK_PATH = "/auth/callback";
|
|
15
|
+
|
|
16
|
+
export interface PkcePair {
|
|
17
|
+
verifier: string;
|
|
18
|
+
challenge: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generatePkcePair(): PkcePair {
|
|
22
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
23
|
+
const challenge = crypto
|
|
24
|
+
.createHash("sha256")
|
|
25
|
+
.update(verifier)
|
|
26
|
+
.digest("base64url");
|
|
27
|
+
return { verifier, challenge };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AuthorizeUrlOptions {
|
|
31
|
+
clientId: string;
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
challenge: string;
|
|
34
|
+
state: string;
|
|
35
|
+
loginHint?: string;
|
|
36
|
+
providerHint?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
|
|
40
|
+
const url = new URL("/user_management/authorize", WORKOS_API_BASE_URL);
|
|
41
|
+
url.searchParams.set("client_id", options.clientId);
|
|
42
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
43
|
+
url.searchParams.set("response_type", "code");
|
|
44
|
+
url.searchParams.set("scope", SCOPE);
|
|
45
|
+
url.searchParams.set("code_challenge", options.challenge);
|
|
46
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
47
|
+
url.searchParams.set("state", options.state);
|
|
48
|
+
// No `prompt`: lets the browser's existing IdP session be reused.
|
|
49
|
+
url.searchParams.set("provider", options.providerHint || "authkit");
|
|
50
|
+
if (options.loginHint) url.searchParams.set("login_hint", options.loginHint);
|
|
51
|
+
return url.toString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface HeadlessProviderEntry {
|
|
55
|
+
id: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
client_id?: string;
|
|
58
|
+
flows?: string[];
|
|
59
|
+
openid_configuration_url?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick the OAuth2 WorkOS provider from the headless config. During the
|
|
64
|
+
* coexistence window two entries share the "workos-oidc" id; the usable one
|
|
65
|
+
* has token auth and no OIDC discovery URL. Null if none.
|
|
66
|
+
*/
|
|
67
|
+
export function selectWorkosClientId(
|
|
68
|
+
providers: HeadlessProviderEntry[],
|
|
69
|
+
): string | null {
|
|
70
|
+
const entry = providers.find(
|
|
71
|
+
(p) =>
|
|
72
|
+
!p.openid_configuration_url &&
|
|
73
|
+
(p.flows ?? []).includes("provider_token") &&
|
|
74
|
+
typeof p.client_id === "string",
|
|
75
|
+
);
|
|
76
|
+
return entry?.client_id ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function fetchWorkosClientId(
|
|
80
|
+
platformUrl: string,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
const url = `${new URL(platformUrl).origin}/_allauth/app/v1/config`;
|
|
83
|
+
const response = await loopbackSafeFetch(url);
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`Failed to fetch auth config (${response.status})`);
|
|
86
|
+
}
|
|
87
|
+
const body = (await response.json()) as {
|
|
88
|
+
data?: { socialaccount?: { providers?: HeadlessProviderEntry[] } };
|
|
89
|
+
};
|
|
90
|
+
const clientId = selectWorkosClientId(
|
|
91
|
+
body.data?.socialaccount?.providers ?? [],
|
|
92
|
+
);
|
|
93
|
+
if (!clientId) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"Platform does not advertise a token-auth WorkOS provider; cannot start PKCE login.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return clientId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Exchange the authorization code at WorkOS as a public client. */
|
|
102
|
+
export async function exchangeCodeWithWorkos(options: {
|
|
103
|
+
clientId: string;
|
|
104
|
+
code: string;
|
|
105
|
+
verifier: string;
|
|
106
|
+
}): Promise<string> {
|
|
107
|
+
const response = await loopbackSafeFetch(
|
|
108
|
+
`${WORKOS_API_BASE_URL}/user_management/authenticate`,
|
|
109
|
+
{
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "Content-Type": "application/json" },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
client_id: options.clientId,
|
|
114
|
+
grant_type: "authorization_code",
|
|
115
|
+
code: options.code,
|
|
116
|
+
code_verifier: options.verifier,
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const body = await response.text();
|
|
122
|
+
throw new Error(
|
|
123
|
+
`WorkOS code exchange failed (${response.status}): ${body}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const data = (await response.json()) as { access_token?: string };
|
|
127
|
+
if (!data.access_token) {
|
|
128
|
+
throw new Error("WorkOS code exchange returned no access token.");
|
|
129
|
+
}
|
|
130
|
+
return data.access_token;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Exchange the WorkOS access token for a platform session token. */
|
|
134
|
+
export async function exchangeAccessTokenForSession(
|
|
135
|
+
platformUrl: string,
|
|
136
|
+
clientId: string,
|
|
137
|
+
accessToken: string,
|
|
138
|
+
): Promise<string> {
|
|
139
|
+
const url = `${new URL(platformUrl).origin}/_allauth/app/v1/auth/provider/token`;
|
|
140
|
+
const response = await loopbackSafeFetch(url, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
provider: PROVIDER_ID,
|
|
145
|
+
process: "login",
|
|
146
|
+
token: { client_id: clientId, access_token: accessToken },
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const body = await response.text();
|
|
151
|
+
throw new Error(`Session exchange failed (${response.status}): ${body}`);
|
|
152
|
+
}
|
|
153
|
+
const data = (await response.json()) as {
|
|
154
|
+
meta?: { session_token?: string };
|
|
155
|
+
};
|
|
156
|
+
if (!data.meta?.session_token) {
|
|
157
|
+
throw new Error("Session exchange returned no session token.");
|
|
158
|
+
}
|
|
159
|
+
return data.meta.session_token;
|
|
160
|
+
}
|