Showing the changes to apps/cli/src (5 of 10 files). Show all
Unified · Split
| @@ -1,6 +1,44 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | |
| 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 | +}); | |
| 4 | 42 | |
| 5 | 43 | describe("browserOpenCommand", () => { |
| 6 | 44 | it("opens Windows browser URLs without cmd.exe", () => { |
| @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines | ||
| 32 | 70 | expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); |
| 33 | 71 | }); |
| 34 | 72 | }); |
| 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 | +}); | |
| @@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines | ||
| 50 | 50 | readonly organizationId?: string; |
| 51 | 51 | } |
| 52 | 52 | |
| 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 | + | |
| 53 | 62 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; |
| 54 | 63 | const DEFAULT_INTERVAL_SECONDS = 5; |
| 55 | 64 | |
| @@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines | ||
| 126 | 135 | string |
| 127 | 136 | >; |
| 128 | 137 | |
| 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 | + | |
| 129 | 158 | const post = async ( |
| 130 | 159 | url: string, |
| 131 | 160 | fields: Record<string, string | undefined>, |
| 132 | 161 | format: "form" | "json", |
| 162 | + options: DeviceLoginHttpOptions = {}, | |
| 133 | 163 | ) => |
| 134 | 164 | fetch(url, { |
| 135 | 165 | 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 | + ), | |
| 140 | 175 | body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), |
| 141 | 176 | }); |
| 142 | 177 | |
| @@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines | ||
| 150 | 185 | } |
| 151 | 186 | }; |
| 152 | 187 | |
| 153 | −export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => { | |
| 188 | +export const discoverCliLogin = async ( | |
| 189 | + origin: string, | |
| 190 | + options: DeviceLoginHttpOptions = {}, | |
| 191 | +): Promise<CliLoginDiscovery> => { | |
| 154 | 192 | let response: Response; |
| 193 | + const url = cliLoginUrl(origin); | |
| 155 | 194 | 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 | + }); | |
| 157 | 198 | } catch (cause) { |
| 158 | 199 | throw new DeviceLoginError( |
| 159 | 200 | `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 · 22 unmodified lines | ||
| 182 | 223 | }; |
| 183 | 224 | }; |
| 184 | 225 | |
| 185 | −export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => { | |
| 226 | +export const requestDeviceCode = async ( | |
| 227 | + discovery: CliLoginDiscovery, | |
| 228 | + options: DeviceLoginHttpOptions = {}, | |
| 229 | +): Promise<DeviceCodeGrant> => { | |
| 186 | 230 | const response = await post( |
| 187 | 231 | discovery.deviceAuthorizationEndpoint, |
| 188 | 232 | { client_id: discovery.clientId, scope: discovery.scope }, |
| 189 | 233 | discovery.requestFormat, |
| 234 | + options, | |
| 190 | 235 | ); |
| 191 | 236 | const body = await readJson(response); |
| 192 | 237 | if (!response.ok) { |
| @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines | ||
| 220 | 265 | export const pollForDeviceTokens = async ( |
| 221 | 266 | discovery: CliLoginDiscovery, |
| 222 | 267 | grant: DeviceCodeGrant, |
| 223 | − options: { readonly now?: () => number } = {}, | |
| 268 | + options: PollForDeviceTokensOptions = {}, | |
| 224 | 269 | ): Promise<DeviceTokens> => { |
| 225 | 270 | const now = options.now ?? (() => Date.now()); |
| 226 | 271 | const deadline = now() + grant.expiresInSeconds * 1000; |
| @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines | ||
| 240 | 285 | client_id: discovery.clientId, |
| 241 | 286 | }, |
| 242 | 287 | discovery.requestFormat, |
| 288 | + options, | |
| 243 | 289 | ); |
| 244 | 290 | const body = await readJson(response); |
| 245 | 291 | |
| @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines | ||
| 286 | 332 | readonly tokenEndpoint: string; |
| 287 | 333 | readonly clientId: string; |
| 288 | 334 | readonly refreshToken: string; |
| 335 | + readonly headers?: Readonly<Record<string, string>>; | |
| 336 | + readonly serverOrigin?: string; | |
| 289 | 337 | }): Promise<DeviceTokens> => { |
| 290 | 338 | const response = await post( |
| 291 | 339 | input.tokenEndpoint, |
| @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines | ||
| 295 | 343 | client_id: input.clientId, |
| 296 | 344 | }, |
| 297 | 345 | "form", |
| 346 | + input, | |
| 298 | 347 | ); |
| 299 | 348 | const body = await readJson(response); |
| 300 | 349 | if (!response.ok) { |
| @@ -84,10 +84,13 @@ import { · 83 unmodified lines | ||
| 84 | 84 | getExecutorServerAuthorizationHeader, |
| 85 | 85 | normalizeExecutorServerConnection, |
| 86 | 86 | normalizeExecutorServerOrigin, |
| 87 | + resolveExecutorServerConfiguredHeaders, | |
| 88 | + resolveExecutorServerRequestHeaders, | |
| 87 | 89 | type ExecutorLocalServerKind, |
| 88 | 90 | type ExecutorLocalServerManifest, |
| 89 | 91 | type ExecutorServerConnection, |
| 90 | 92 | type ExecutorServerConnectionInput, |
| 93 | + type ExecutorServerHeaders, | |
| 91 | 94 | } from "@executor-js/sdk/shared"; |
| 92 | 95 | import { |
| 93 | 96 | decodeAccessTokenClaims, |
| @@ -752,7 +755,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; · 658 unmodified lines | ||
| 752 | 755 | |
| 753 | 756 | const refreshOAuthConnection = ( |
| 754 | 757 | connection: ExecutorServerConnection, |
| 755 | −): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> => | |
| 758 | +): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => | |
| 756 | 759 | Effect.gen(function* () { |
| 757 | 760 | const auth = connection.auth; |
| 758 | 761 | if (!auth || auth.kind !== "oauth") return connection; |
| @@ -763,8 +766,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines | ||
| 763 | 766 | const { refreshToken, tokenEndpoint, clientId } = auth; |
| 764 | 767 | if (!refreshToken || !tokenEndpoint || !clientId) return connection; |
| 765 | 768 | |
| 769 | + const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe( | |
| 770 | + Effect.mapError(toError), | |
| 771 | + ); | |
| 772 | + | |
| 766 | 773 | const refreshed = yield* Effect.tryPromise({ |
| 767 | − try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }), | |
| 774 | + try: () => | |
| 775 | + refreshDeviceTokens({ | |
| 776 | + tokenEndpoint, | |
| 777 | + clientId, | |
| 778 | + refreshToken, | |
| 779 | + serverOrigin: connection.origin, | |
| 780 | + headers, | |
| 781 | + }), | |
| 768 | 782 | catch: toError, |
| 769 | 783 | // On a failed refresh, keep the existing token and let the eventual 401 |
| 770 | 784 | // surface, better than blocking the command on a transient hiccup. |
| @@ -1054,31 +1068,36 @@ const printExecutionOutcome = (input: { · 283 unmodified lines | ||
| 1054 | 1068 | // Typed API client |
| 1055 | 1069 | // --------------------------------------------------------------------------- |
| 1056 | 1070 | |
| 1057 | −const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => { | |
| 1058 | − const authorization = getExecutorServerAuthorizationHeader(connection); | |
| 1059 | − return HttpApiClient.make(ExecutorApi, { | |
| 1060 | − baseUrl: connection.apiBaseUrl, | |
| 1061 | − ...(authorization | |
| 1062 | − ? { | |
| 1063 | − transformClient: HttpClient.mapRequest((request) => | |
| 1064 | − HttpClientRequest.setHeader(request, "authorization", authorization), | |
| 1065 | − ), | |
| 1066 | − } | |
| 1067 | − : {}), | |
| 1068 | − // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite | |
| 1069 | − // the transport-level error into the login hint. Without this the client | |
| 1070 | − // fails decoding the unexpected status and prints the opaque | |
| 1071 | − // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) | |
| 1072 | − // decode before this catch and pass through untouched. | |
| 1073 | − transformResponse: (effect) => | |
| 1074 | − Effect.catchIf( | |
| 1075 | − effect, | |
| 1076 | − (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, | |
| 1077 | − () => | |
| 1078 | − Effect.fail(new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target }))), | |
| 1079 | − ), | |
| 1071 | +const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => | |
| 1072 | + Effect.gen(function* () { | |
| 1073 | + const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe( | |
| 1074 | + Effect.mapError(toError), | |
| 1075 | + ); | |
| 1076 | + return yield* HttpApiClient.make(ExecutorApi, { | |
| 1077 | + baseUrl: connection.apiBaseUrl, | |
| 1078 | + ...(Object.keys(headers).length > 0 | |
| 1079 | + ? { | |
| 1080 | + transformClient: HttpClient.mapRequest((request) => | |
| 1081 | + HttpClientRequest.setHeaders(request, headers), | |
| 1082 | + ), | |
| 1083 | + } | |
| 1084 | + : {}), | |
| 1085 | + // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite | |
| 1086 | + // the transport-level error into the login hint. Without this the client | |
| 1087 | + // fails decoding the unexpected status and prints the opaque | |
| 1088 | + // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) | |
| 1089 | + // decode before this catch and pass through untouched. | |
| 1090 | + transformResponse: (effect) => | |
| 1091 | + Effect.catchIf( | |
| 1092 | + effect, | |
| 1093 | + (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, | |
| 1094 | + () => | |
| 1095 | + Effect.fail( | |
| 1096 | + new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })), | |
| 1097 | + ), | |
| 1098 | + ), | |
| 1099 | + }); | |
| 1080 | 1100 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 1081 | −}; | |
| 1082 | 1101 | |
| 1083 | 1102 | // --------------------------------------------------------------------------- |
| 1084 | 1103 | // Foreground session |
| @@ -2177,17 +2196,47 @@ const toolsCommand = Command.make("tools").pipe( · 1092 unmodified lines | ||
| 2177 | 2196 | Command.withDescription("Discover available tools and integrations"), |
| 2178 | 2197 | ); |
| 2179 | 2198 | |
| 2199 | +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; | |
| 2200 | +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; | |
| 2201 | + | |
| 2202 | +const parseHeaderEnvOption = ( | |
| 2203 | + headerEnv: Option.Option<Record<string, string>>, | |
| 2204 | +): ExecutorServerHeaders | undefined => { | |
| 2205 | + const values = Option.getOrUndefined(headerEnv); | |
| 2206 | + if (!values) return undefined; | |
| 2207 | + const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {}; | |
| 2208 | + for (const [rawHeaderName, rawEnvName] of Object.entries(values)) { | |
| 2209 | + const headerName = rawHeaderName.trim(); | |
| 2210 | + const envName = rawEnvName.trim(); | |
| 2211 | + if (!HEADER_NAME_PATTERN.test(headerName)) { | |
| 2212 | + throw new Error( | |
| 2213 | + `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`, | |
| 2214 | + ); | |
| 2215 | + } | |
| 2216 | + if (!ENV_NAME_PATTERN.test(envName)) { | |
| 2217 | + throw new Error( | |
| 2218 | + `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`, | |
| 2219 | + ); | |
| 2220 | + } | |
| 2221 | + headers[headerName] = { kind: "env", name: envName }; | |
| 2222 | + } | |
| 2223 | + return Object.keys(headers).length > 0 ? headers : undefined; | |
| 2224 | +}; | |
| 2225 | + | |
| 2180 | 2226 | const profileConnectionInput = (input: { |
| 2181 | 2227 | readonly origin: string; |
| 2182 | 2228 | readonly displayName: Option.Option<string>; |
| 2183 | 2229 | readonly kind: Option.Option<"http" | "desktop-sidecar">; |
| 2230 | + readonly headerEnv: Option.Option<Record<string, string>>; | |
| 2184 | 2231 | }): ExecutorServerConnectionInput => { |
| 2185 | 2232 | const selectedKind = Option.getOrUndefined(input.kind); |
| 2186 | 2233 | const displayName = Option.getOrUndefined(input.displayName); |
| 2234 | + const headers = parseHeaderEnvOption(input.headerEnv); | |
| 2187 | 2235 | return { |
| 2188 | 2236 | kind: selectedKind ?? "http", |
| 2189 | 2237 | origin: input.origin, |
| 2190 | 2238 | ...(displayName ? { displayName } : {}), |
| 2239 | + ...(headers ? { headers } : {}), | |
| 2191 | 2240 | }; |
| 2192 | 2241 | }; |
| 2193 | 2242 | |
| @@ -2207,13 +2256,16 @@ const printServerProfiles = () => · 13 unmodified lines | ||
| 2207 | 2256 | origin: profile.connection.origin, |
| 2208 | 2257 | displayName: profile.connection.displayName, |
| 2209 | 2258 | auth: profile.connection.auth ? "stored-auth" : "env-auth", |
| 2259 | + headers: profile.connection.headers | |
| 2260 | + ? `${Object.keys(profile.connection.headers).length} header-env` | |
| 2261 | + : "no-headers", | |
| 2210 | 2262 | })); |
| 2211 | 2263 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); |
| 2212 | 2264 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); |
| 2213 | 2265 | |
| 2214 | 2266 | for (const row of rows) { |
| 2215 | 2267 | console.log( |
| 2216 | − `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`, | |
| 2268 | + `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`, | |
| 2217 | 2269 | ); |
| 2218 | 2270 | } |
| 2219 | 2271 | }); |
| @@ -2231,17 +2283,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines | ||
| 2231 | 2283 | Options.optional, |
| 2232 | 2284 | Options.withDescription("Server kind. Defaults to http."), |
| 2233 | 2285 | ), |
| 2286 | + headerEnv: Options.keyValuePair("header-env").pipe( | |
| 2287 | + Options.optional, | |
| 2288 | + Options.withDescription( | |
| 2289 | + "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.", | |
| 2290 | + ), | |
| 2291 | + ), | |
| 2234 | 2292 | makeDefault: Options.boolean("default").pipe( |
| 2235 | 2293 | Options.withDefault(false), |
| 2236 | 2294 | Options.withDescription("Make this profile the default server."), |
| 2237 | 2295 | ), |
| 2238 | 2296 | }, |
| 2239 | − ({ name, origin, displayName, kind, makeDefault }) => | |
| 2297 | + ({ name, origin, displayName, kind, headerEnv, makeDefault }) => | |
| 2240 | 2298 | Effect.gen(function* () { |
| 2241 | 2299 | const profileName = validateCliServerConnectionProfileName(name); |
| 2242 | 2300 | const store = yield* upsertCliServerConnectionProfile({ |
| 2243 | 2301 | name: profileName, |
| 2244 | − connection: profileConnectionInput({ origin, displayName, kind }), | |
| 2302 | + connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), | |
| 2245 | 2303 | makeDefault, |
| 2246 | 2304 | }); |
| 2247 | 2305 | const profile = findCliServerConnectionProfile(store, profileName); |
| @@ -2440,12 +2498,18 @@ const loginCommand = Command.make( · 192 unmodified lines | ||
| 2440 | 2498 | // The target may have been picked implicitly (default profile, or the |
| 2441 | 2499 | // hosted fallback): say where the login is going before the device flow. |
| 2442 | 2500 | console.log(`Signing in to ${target.origin}`); |
| 2501 | + const targetProfile = target.profile; | |
| 2502 | + const headers = targetProfile | |
| 2503 | + ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe( | |
| 2504 | + Effect.mapError(toError), | |
| 2505 | + ) | |
| 2506 | + : {}; | |
| 2443 | 2507 | const discovery = yield* Effect.tryPromise({ |
| 2444 | − try: () => discoverCliLogin(target.origin), | |
| 2508 | + try: () => discoverCliLogin(target.origin, { headers }), | |
| 2445 | 2509 | catch: toError, |
| 2446 | 2510 | }); |
| 2447 | 2511 | const grant = yield* Effect.tryPromise({ |
| 2448 | − try: () => requestDeviceCode(discovery), | |
| 2512 | + try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), | |
| 2449 | 2513 | catch: toError, |
| 2450 | 2514 | }); |
| 2451 | 2515 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; |
| @@ -2456,7 +2520,7 @@ const loginCommand = Command.make( · 4 unmodified lines | ||
| 2456 | 2520 | if (!noBrowser) openBrowser(verifyUrl); |
| 2457 | 2521 | console.log("Waiting for you to approve in the browser..."); |
| 2458 | 2522 | const tokens = yield* Effect.tryPromise({ |
| 2459 | − try: () => pollForDeviceTokens(discovery, grant), | |
| 2523 | + try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), | |
| 2460 | 2524 | catch: toError, |
| 2461 | 2525 | }); |
| 2462 | 2526 | |
| @@ -2481,6 +2545,9 @@ const loginCommand = Command.make( · 18 unmodified lines | ||
| 2481 | 2545 | kind: "http", |
| 2482 | 2546 | origin: target.origin, |
| 2483 | 2547 | ...(email ? { displayName: email } : {}), |
| 2548 | + ...(target.profile?.connection.headers | |
| 2549 | + ? { headers: target.profile.connection.headers } | |
| 2550 | + : {}), | |
| 2484 | 2551 | auth: { |
| 2485 | 2552 | kind: "oauth", |
| 2486 | 2553 | accessToken: tokens.accessToken, |
| @@ -2543,6 +2610,7 @@ const logoutCommand = Command.make( · 56 unmodified lines | ||
| 2543 | 2610 | kind: profile.connection.kind, |
| 2544 | 2611 | origin: profile.connection.origin, |
| 2545 | 2612 | displayName: profile.connection.displayName, |
| 2613 | + ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), | |
| 2546 | 2614 | }, |
| 2547 | 2615 | makeDefault: store.defaultProfile === profile.name, |
| 2548 | 2616 | }); |
| @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 35 unmodified lines | ||
| 36 | 36 | connection: { |
| 37 | 37 | origin: "https://executor.example/api", |
| 38 | 38 | auth: { kind: "bearer", token: "key_123" }, |
| 39 | + headers: { | |
| 40 | + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | |
| 41 | + }, | |
| 39 | 42 | }, |
| 40 | 43 | makeDefault: true, |
| 41 | 44 | }); |
| @@ -50,6 +53,9 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines | ||
| 50 | 53 | kind: "bearer", |
| 51 | 54 | token: "key_123", |
| 52 | 55 | }); |
| 56 | + expect(store.profiles[0]?.connection.headers).toEqual({ | |
| 57 | + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | |
| 58 | + }); | |
| 53 | 59 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); |
| 54 | 60 | } finally { |
| 55 | 61 | rmSync(dataDir, { recursive: true, force: true }); |
| @@ -114,6 +120,12 @@ describe("CLI server connection profiles", () => { · 58 unmodified lines | ||
| 114 | 120 | key: "desktop-sidecar", |
| 115 | 121 | origin: "http://127.0.0.1:4789", |
| 116 | 122 | auth: { kind: "basic", username: "executor", password: "secret" }, |
| 123 | + headers: { | |
| 124 | + "CF-Access-Client-Id": { | |
| 125 | + kind: "env", | |
| 126 | + name: "EXECUTOR_CF_ACCESS_CLIENT_ID", | |
| 127 | + }, | |
| 128 | + }, | |
| 117 | 129 | }, |
| 118 | 130 | }, |
| 119 | 131 | ], |
| @@ -127,5 +139,8 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines | ||
| 127 | 139 | username: "executor", |
| 128 | 140 | password: "secret", |
| 129 | 141 | }); |
| 142 | + expect(store.profiles[0]?.connection.headers).toEqual({ | |
| 143 | + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | |
| 144 | + }); | |
| 130 | 145 | }); |
| 131 | 146 | }); |
| @@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines | ||
| 62 | 62 | }), |
| 63 | 63 | ]); |
| 64 | 64 | |
| 65 | +const PersistedHeader = Schema.Struct({ | |
| 66 | + kind: Schema.Literal("env"), | |
| 67 | + name: Schema.String, | |
| 68 | +}); | |
| 69 | + | |
| 65 | 70 | const PersistedConnection = Schema.Struct({ |
| 66 | 71 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), |
| 67 | 72 | key: Schema.optional(Schema.String), |
| @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line | ||
| 69 | 74 | apiBaseUrl: Schema.optional(Schema.String), |
| 70 | 75 | displayName: Schema.optional(Schema.String), |
| 71 | 76 | auth: Schema.optional(PersistedAuth), |
| 77 | + headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), | |
| 72 | 78 | }); |
| 73 | 79 | |
| 74 | 80 | const PersistedProfile = Schema.Struct({ |