Skip to content

Commit 01663af

Browse files
brosandramariveraRhysSullivan
authored
feat(cli): support env-backed server headers (#1539)
Co-authored-by: Ramiro Rivera <ramarivera@gmail.com> Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 8ba64f6 commit 01663af

10 files changed

Lines changed: 538 additions & 43 deletions

File tree

apps/cli/src/device-login.test.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,44 @@
1-
import { describe, expect, it } from "@effect/vitest";
1+
import { afterEach, describe, expect, it } from "@effect/vitest";
22

3-
import { browserOpenCommand } from "./device-login";
3+
import {
4+
browserOpenCommand,
5+
discoverCliLogin,
6+
refreshDeviceTokens,
7+
requestDeviceCode,
8+
type CliLoginDiscovery,
9+
} from "./device-login";
10+
11+
const originalFetch = globalThis.fetch;
12+
13+
interface FetchCall {
14+
readonly url: string;
15+
readonly headers: Record<string, string>;
16+
}
17+
18+
const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19+
new Response(JSON.stringify(body), {
20+
status,
21+
headers: { "content-type": "application/json" },
22+
});
23+
24+
const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => {
25+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
26+
const url =
27+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28+
return Promise.resolve(handler(url, init));
29+
}) as typeof fetch;
30+
};
31+
32+
const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => {
33+
calls.push({
34+
url,
35+
headers: Object.fromEntries(new Headers(init?.headers).entries()),
36+
});
37+
};
38+
39+
afterEach(() => {
40+
globalThis.fetch = originalFetch;
41+
});
442

543
describe("browserOpenCommand", () => {
644
it("opens Windows browser URLs without cmd.exe", () => {
@@ -32,3 +70,81 @@ describe("browserOpenCommand", () => {
3270
expect(browserOpenCommand("not a url", "win32")).toBeUndefined();
3371
});
3472
});
73+
74+
describe("device login headers", () => {
75+
it("sends configured headers when discovering CLI login", async () => {
76+
const calls: Array<FetchCall> = [];
77+
installFetch((url, init) => {
78+
recordCall(calls, url, init);
79+
return responseJson({
80+
provider: "better-auth",
81+
deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
82+
tokenEndpoint: "https://executor.example/api/auth/device/token",
83+
clientId: "executor-cli",
84+
requestFormat: "json",
85+
});
86+
});
87+
88+
const discovery = await discoverCliLogin("https://executor.example", {
89+
headers: { "CF-Access-Client-Id": "client-id" },
90+
});
91+
92+
expect(discovery.clientId).toBe("executor-cli");
93+
expect(calls).toHaveLength(1);
94+
expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login");
95+
expect(calls[0]?.headers).toMatchObject({
96+
accept: "application/json",
97+
"cf-access-client-id": "client-id",
98+
});
99+
});
100+
101+
it("sends configured headers only to same-origin device endpoints", async () => {
102+
const calls: Array<FetchCall> = [];
103+
installFetch((url, init) => {
104+
recordCall(calls, url, init);
105+
if (url.endsWith("/api/auth/device/code")) {
106+
return responseJson({
107+
device_code: "device-code",
108+
user_code: "USER-CODE",
109+
verification_uri: "https://executor.example/device",
110+
expires_in: 300,
111+
interval: 5,
112+
});
113+
}
114+
return responseJson({
115+
access_token: "access-token",
116+
refresh_token: "refresh-token-2",
117+
expires_in: 600,
118+
});
119+
});
120+
const discovery: CliLoginDiscovery = {
121+
provider: "better-auth",
122+
deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
123+
tokenEndpoint: "https://accounts.example/oauth/token",
124+
clientId: "executor-cli",
125+
requestFormat: "form",
126+
};
127+
const headers = { "CF-Access-Client-Id": "client-id" };
128+
129+
await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers });
130+
await refreshDeviceTokens({
131+
tokenEndpoint: discovery.tokenEndpoint,
132+
clientId: discovery.clientId,
133+
refreshToken: "refresh-token",
134+
serverOrigin: "https://executor.example",
135+
headers,
136+
});
137+
138+
expect(calls).toHaveLength(2);
139+
expect(calls[0]?.headers).toMatchObject({
140+
accept: "application/json",
141+
"content-type": "application/x-www-form-urlencoded",
142+
"cf-access-client-id": "client-id",
143+
});
144+
expect(calls[1]?.headers).toMatchObject({
145+
accept: "application/json",
146+
"content-type": "application/x-www-form-urlencoded",
147+
});
148+
expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined();
149+
});
150+
});

apps/cli/src/device-login.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ export interface DeviceTokens {
5050
readonly organizationId?: string;
5151
}
5252

53+
export interface DeviceLoginHttpOptions {
54+
readonly headers?: Readonly<Record<string, string>>;
55+
readonly serverOrigin?: string;
56+
}
57+
58+
export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59+
readonly now?: () => number;
60+
}
61+
5362
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
5463
const DEFAULT_INTERVAL_SECONDS = 5;
5564

@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin
126135
string
127136
>;
128137

138+
const isSameOrigin = (url: string, origin: string): boolean => {
139+
try {
140+
return new URL(url).origin === new URL(origin).origin;
141+
} catch {
142+
return false;
143+
}
144+
};
145+
146+
const headersForUrl = (
147+
url: string,
148+
baseHeaders: Record<string, string>,
149+
options: DeviceLoginHttpOptions = {},
150+
): Record<string, string> => {
151+
const configured =
152+
options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin))
153+
? options.headers
154+
: {};
155+
return { ...configured, ...baseHeaders };
156+
};
157+
129158
const post = async (
130159
url: string,
131160
fields: Record<string, string | undefined>,
132161
format: "form" | "json",
162+
options: DeviceLoginHttpOptions = {},
133163
) =>
134164
fetch(url, {
135165
method: "POST",
136-
headers: {
137-
"content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",
138-
accept: "application/json",
139-
},
166+
headers: headersForUrl(
167+
url,
168+
{
169+
"content-type":
170+
format === "json" ? "application/json" : "application/x-www-form-urlencoded",
171+
accept: "application/json",
172+
},
173+
options,
174+
),
140175
body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141176
});
142177

@@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> =>
150185
}
151186
};
152187

153-
export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {
188+
export const discoverCliLogin = async (
189+
origin: string,
190+
options: DeviceLoginHttpOptions = {},
191+
): Promise<CliLoginDiscovery> => {
154192
let response: Response;
193+
const url = cliLoginUrl(origin);
155194
try {
156-
response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } });
195+
response = await fetch(url, {
196+
headers: headersForUrl(url, { accept: "application/json" }, options),
197+
});
157198
} catch (cause) {
158199
throw new DeviceLoginError(
159200
`Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscover
182223
};
183224
};
184225

185-
export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {
226+
export const requestDeviceCode = async (
227+
discovery: CliLoginDiscovery,
228+
options: DeviceLoginHttpOptions = {},
229+
): Promise<DeviceCodeGrant> => {
186230
const response = await post(
187231
discovery.deviceAuthorizationEndpoint,
188232
{ client_id: discovery.clientId, scope: discovery.scope },
189233
discovery.requestFormat,
234+
options,
190235
);
191236
const body = await readJson(response);
192237
if (!response.ok) {
@@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
220265
export const pollForDeviceTokens = async (
221266
discovery: CliLoginDiscovery,
222267
grant: DeviceCodeGrant,
223-
options: { readonly now?: () => number } = {},
268+
options: PollForDeviceTokensOptions = {},
224269
): Promise<DeviceTokens> => {
225270
const now = options.now ?? (() => Date.now());
226271
const deadline = now() + grant.expiresInSeconds * 1000;
@@ -240,6 +285,7 @@ export const pollForDeviceTokens = async (
240285
client_id: discovery.clientId,
241286
},
242287
discovery.requestFormat,
288+
options,
243289
);
244290
const body = await readJson(response);
245291

@@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: {
286332
readonly tokenEndpoint: string;
287333
readonly clientId: string;
288334
readonly refreshToken: string;
335+
readonly headers?: Readonly<Record<string, string>>;
336+
readonly serverOrigin?: string;
289337
}): Promise<DeviceTokens> => {
290338
const response = await post(
291339
input.tokenEndpoint,
@@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: {
295343
client_id: input.clientId,
296344
},
297345
"form",
346+
input,
298347
);
299348
const body = await readJson(response);
300349
if (!response.ok) {

0 commit comments

Comments
 (0)