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 | describe("browserOpenCommand", () => { | 43 | describe("browserOpenCommand", () => { |
| 6 | it("opens Windows browser URLs without cmd.exe", () => { | 44 | it("opens Windows browser URLs without cmd.exe", () => { |
| @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines | |||
| 32 | expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); | 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 | readonly organizationId?: string; | 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 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; | 62 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; |
| 54 | const DEFAULT_INTERVAL_SECONDS = 5; | 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 | string | 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 | const post = async ( | 158 | const post = async ( |
| 130 | url: string, | 159 | url: string, |
| 131 | fields: Record<string, string | undefined>, | 160 | fields: Record<string, string | undefined>, |
| 132 | format: "form" | "json", | 161 | format: "form" | "json", |
| 162 | options: DeviceLoginHttpOptions = {}, | ||
| 133 | ) => | 163 | ) => |
| 134 | fetch(url, { | 164 | fetch(url, { |
| 135 | method: "POST", | 165 | method: "POST", |
| 136 | headers: { | 166 | headers: headersForUrl( |
| 137 | "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", | 167 | url, |
| 138 | accept: "application/json", | 168 | { |
| 139 | }, | 169 | "content-type": |
| 170 | format === "json" ? "application/json" : "application/x-www-form-urlencoded", | ||
| 171 | accept: "application/json", | ||
| 172 | }, | ||
| 173 | options, | ||
| 174 | ), | ||
| 140 | body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), | 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 | let response: Response; | 192 | let response: Response; |
| 193 | const url = cliLoginUrl(origin); | ||
| 155 | try { | 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 | } catch (cause) { | 198 | } catch (cause) { |
| 158 | throw new DeviceLoginError( | 199 | throw new DeviceLoginError( |
| 159 | `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, | 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 | const response = await post( | 230 | const response = await post( |
| 187 | discovery.deviceAuthorizationEndpoint, | 231 | discovery.deviceAuthorizationEndpoint, |
| 188 | { client_id: discovery.clientId, scope: discovery.scope }, | 232 | { client_id: discovery.clientId, scope: discovery.scope }, |
| 189 | discovery.requestFormat, | 233 | discovery.requestFormat, |
| 234 | options, | ||
| 190 | ); | 235 | ); |
| 191 | const body = await readJson(response); | 236 | const body = await readJson(response); |
| 192 | if (!response.ok) { | 237 | if (!response.ok) { |
| @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines | |||
| 220 | export const pollForDeviceTokens = async ( | 265 | export const pollForDeviceTokens = async ( |
| 221 | discovery: CliLoginDiscovery, | 266 | discovery: CliLoginDiscovery, |
| 222 | grant: DeviceCodeGrant, | 267 | grant: DeviceCodeGrant, |
| 223 | options: { readonly now?: () => number } = {}, | 268 | options: PollForDeviceTokensOptions = {}, |
| 224 | ): Promise<DeviceTokens> => { | 269 | ): Promise<DeviceTokens> => { |
| 225 | const now = options.now ?? (() => Date.now()); | 270 | const now = options.now ?? (() => Date.now()); |
| 226 | const deadline = now() + grant.expiresInSeconds * 1000; | 271 | const deadline = now() + grant.expiresInSeconds * 1000; |
| @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines | |||
| 240 | client_id: discovery.clientId, | 285 | client_id: discovery.clientId, |
| 241 | }, | 286 | }, |
| 242 | discovery.requestFormat, | 287 | discovery.requestFormat, |
| 288 | options, | ||
| 243 | ); | 289 | ); |
| 244 | const body = await readJson(response); | 290 | const body = await readJson(response); |
| 245 | 291 | ||
| @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines | |||
| 286 | readonly tokenEndpoint: string; | 332 | readonly tokenEndpoint: string; |
| 287 | readonly clientId: string; | 333 | readonly clientId: string; |
| 288 | readonly refreshToken: string; | 334 | readonly refreshToken: string; |
| 335 | readonly headers?: Readonly<Record<string, string>>; | ||
| 336 | readonly serverOrigin?: string; | ||
| 289 | }): Promise<DeviceTokens> => { | 337 | }): Promise<DeviceTokens> => { |
| 290 | const response = await post( | 338 | const response = await post( |
| 291 | input.tokenEndpoint, | 339 | input.tokenEndpoint, |
| @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines | |||
| 295 | client_id: input.clientId, | 343 | client_id: input.clientId, |
| 296 | }, | 344 | }, |
| 297 | "form", | 345 | "form", |
| 346 | input, | ||
| 298 | ); | 347 | ); |
| 299 | const body = await readJson(response); | 348 | const body = await readJson(response); |
| 300 | if (!response.ok) { | 349 | if (!response.ok) { |
| @@ -84,10 +84,13 @@ import { · 83 unmodified lines | |||
| 84 | getExecutorServerAuthorizationHeader, | 84 | getExecutorServerAuthorizationHeader, |
| 85 | normalizeExecutorServerConnection, | 85 | normalizeExecutorServerConnection, |
| 86 | normalizeExecutorServerOrigin, | 86 | normalizeExecutorServerOrigin, |
| 87 | resolveExecutorServerConfiguredHeaders, | ||
| 88 | resolveExecutorServerRequestHeaders, | ||
| 87 | type ExecutorLocalServerKind, | 89 | type ExecutorLocalServerKind, |
| 88 | type ExecutorLocalServerManifest, | 90 | type ExecutorLocalServerManifest, |
| 89 | type ExecutorServerConnection, | 91 | type ExecutorServerConnection, |
| 90 | type ExecutorServerConnectionInput, | 92 | type ExecutorServerConnectionInput, |
| 93 | type ExecutorServerHeaders, | ||
| 91 | } from "@executor-js/sdk/shared"; | 94 | } from "@executor-js/sdk/shared"; |
| 92 | import { | 95 | import { |
| 93 | decodeAccessTokenClaims, | 96 | decodeAccessTokenClaims, |
| @@ -752,7 +755,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; · 658 unmodified lines | |||
| 752 | 755 | ||
| 753 | const refreshOAuthConnection = ( | 756 | const refreshOAuthConnection = ( |
| 754 | connection: ExecutorServerConnection, | 757 | connection: ExecutorServerConnection, |
| 755 | ): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> => | 758 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 756 | Effect.gen(function* () { | 759 | Effect.gen(function* () { |
| 757 | const auth = connection.auth; | 760 | const auth = connection.auth; |
| 758 | if (!auth || auth.kind !== "oauth") return connection; | 761 | if (!auth || auth.kind !== "oauth") return connection; |
| @@ -763,8 +766,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines | |||
| 763 | const { refreshToken, tokenEndpoint, clientId } = auth; | 766 | const { refreshToken, tokenEndpoint, clientId } = auth; |
| 764 | if (!refreshToken || !tokenEndpoint || !clientId) return connection; | 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 | const refreshed = yield* Effect.tryPromise({ | 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 | catch: toError, | 782 | catch: toError, |
| 769 | // On a failed refresh, keep the existing token and let the eventual 401 | 783 | // On a failed refresh, keep the existing token and let the eventual 401 |
| 770 | // surface, better than blocking the command on a transient hiccup. | 784 | // surface, better than blocking the command on a transient hiccup. |
| @@ -1054,31 +1068,36 @@ const printExecutionOutcome = (input: { · 283 unmodified lines | |||
| 1054 | // Typed API client | 1068 | // Typed API client |
| 1055 | // --------------------------------------------------------------------------- | 1069 | // --------------------------------------------------------------------------- |
| 1056 | 1070 | ||
| 1057 | const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => { | 1071 | const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => |
| 1058 | const authorization = getExecutorServerAuthorizationHeader(connection); | 1072 | Effect.gen(function* () { |
| 1059 | return HttpApiClient.make(ExecutorApi, { | 1073 | const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe( |
| 1060 | baseUrl: connection.apiBaseUrl, | 1074 | Effect.mapError(toError), |
| 1061 | ...(authorization | 1075 | ); |
| 1062 | ? { | 1076 | return yield* HttpApiClient.make(ExecutorApi, { |
| 1063 | transformClient: HttpClient.mapRequest((request) => | 1077 | baseUrl: connection.apiBaseUrl, |
| 1064 | HttpClientRequest.setHeader(request, "authorization", authorization), | 1078 | ...(Object.keys(headers).length > 0 |
| 1065 | ), | 1079 | ? { |
| 1066 | } | 1080 | transformClient: HttpClient.mapRequest((request) => |
| 1067 | : {}), | 1081 | HttpClientRequest.setHeaders(request, headers), |
| 1068 | // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite | 1082 | ), |
| 1069 | // the transport-level error into the login hint. Without this the client | 1083 | } |
| 1070 | // fails decoding the unexpected status and prints the opaque | 1084 | : {}), |
| 1071 | // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) | 1085 | // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite |
| 1072 | // decode before this catch and pass through untouched. | 1086 | // the transport-level error into the login hint. Without this the client |
| 1073 | transformResponse: (effect) => | 1087 | // fails decoding the unexpected status and prints the opaque |
| 1074 | Effect.catchIf( | 1088 | // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) |
| 1075 | effect, | 1089 | // decode before this catch and pass through untouched. |
| 1076 | (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, | 1090 | transformResponse: (effect) => |
| 1077 | () => | 1091 | Effect.catchIf( |
| 1078 | Effect.fail(new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target }))), | 1092 | effect, |
| 1079 | ), | 1093 | (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, |
| 1094 | () => | ||
| 1095 | Effect.fail( | ||
| 1096 | new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })), | ||
| 1097 | ), | ||
| 1098 | ), | ||
| 1099 | }); | ||
| 1080 | }).pipe(Effect.provide(FetchHttpClient.layer)); | 1100 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 1081 | }; | ||
| 1082 | 1101 | ||
| 1083 | // --------------------------------------------------------------------------- | 1102 | // --------------------------------------------------------------------------- |
| 1084 | // Foreground session | 1103 | // Foreground session |
| @@ -2177,17 +2196,47 @@ const toolsCommand = Command.make("tools").pipe( · 1092 unmodified lines | |||
| 2177 | Command.withDescription("Discover available tools and integrations"), | 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 | const profileConnectionInput = (input: { | 2226 | const profileConnectionInput = (input: { |
| 2181 | readonly origin: string; | 2227 | readonly origin: string; |
| 2182 | readonly displayName: Option.Option<string>; | 2228 | readonly displayName: Option.Option<string>; |
| 2183 | readonly kind: Option.Option<"http" | "desktop-sidecar">; | 2229 | readonly kind: Option.Option<"http" | "desktop-sidecar">; |
| 2230 | readonly headerEnv: Option.Option<Record<string, string>>; | ||
| 2184 | }): ExecutorServerConnectionInput => { | 2231 | }): ExecutorServerConnectionInput => { |
| 2185 | const selectedKind = Option.getOrUndefined(input.kind); | 2232 | const selectedKind = Option.getOrUndefined(input.kind); |
| 2186 | const displayName = Option.getOrUndefined(input.displayName); | 2233 | const displayName = Option.getOrUndefined(input.displayName); |
| 2234 | const headers = parseHeaderEnvOption(input.headerEnv); | ||
| 2187 | return { | 2235 | return { |
| 2188 | kind: selectedKind ?? "http", | 2236 | kind: selectedKind ?? "http", |
| 2189 | origin: input.origin, | 2237 | origin: input.origin, |
| 2190 | ...(displayName ? { displayName } : {}), | 2238 | ...(displayName ? { displayName } : {}), |
| 2239 | ...(headers ? { headers } : {}), | ||
| 2191 | }; | 2240 | }; |
| 2192 | }; | 2241 | }; |
| 2193 | 2242 | ||
| @@ -2207,13 +2256,16 @@ const printServerProfiles = () => · 13 unmodified lines | |||
| 2207 | origin: profile.connection.origin, | 2256 | origin: profile.connection.origin, |
| 2208 | displayName: profile.connection.displayName, | 2257 | displayName: profile.connection.displayName, |
| 2209 | auth: profile.connection.auth ? "stored-auth" : "env-auth", | 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 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); | 2263 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); |
| 2212 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); | 2264 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); |
| 2213 | 2265 | ||
| 2214 | for (const row of rows) { | 2266 | for (const row of rows) { |
| 2215 | console.log( | 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 | Options.optional, | 2283 | Options.optional, |
| 2232 | Options.withDescription("Server kind. Defaults to http."), | 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 | makeDefault: Options.boolean("default").pipe( | 2292 | makeDefault: Options.boolean("default").pipe( |
| 2235 | Options.withDefault(false), | 2293 | Options.withDefault(false), |
| 2236 | Options.withDescription("Make this profile the default server."), | 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 | Effect.gen(function* () { | 2298 | Effect.gen(function* () { |
| 2241 | const profileName = validateCliServerConnectionProfileName(name); | 2299 | const profileName = validateCliServerConnectionProfileName(name); |
| 2242 | const store = yield* upsertCliServerConnectionProfile({ | 2300 | const store = yield* upsertCliServerConnectionProfile({ |
| 2243 | name: profileName, | 2301 | name: profileName, |
| 2244 | connection: profileConnectionInput({ origin, displayName, kind }), | 2302 | connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), |
| 2245 | makeDefault, | 2303 | makeDefault, |
| 2246 | }); | 2304 | }); |
| 2247 | const profile = findCliServerConnectionProfile(store, profileName); | 2305 | const profile = findCliServerConnectionProfile(store, profileName); |
| @@ -2440,12 +2498,18 @@ const loginCommand = Command.make( · 192 unmodified lines | |||
| 2440 | // The target may have been picked implicitly (default profile, or the | 2498 | // The target may have been picked implicitly (default profile, or the |
| 2441 | // hosted fallback): say where the login is going before the device flow. | 2499 | // hosted fallback): say where the login is going before the device flow. |
| 2442 | console.log(`Signing in to ${target.origin}`); | 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 | const discovery = yield* Effect.tryPromise({ | 2507 | const discovery = yield* Effect.tryPromise({ |
| 2444 | try: () => discoverCliLogin(target.origin), | 2508 | try: () => discoverCliLogin(target.origin, { headers }), |
| 2445 | catch: toError, | 2509 | catch: toError, |
| 2446 | }); | 2510 | }); |
| 2447 | const grant = yield* Effect.tryPromise({ | 2511 | const grant = yield* Effect.tryPromise({ |
| 2448 | try: () => requestDeviceCode(discovery), | 2512 | try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), |
| 2449 | catch: toError, | 2513 | catch: toError, |
| 2450 | }); | 2514 | }); |
| 2451 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; | 2515 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; |
| @@ -2456,7 +2520,7 @@ const loginCommand = Command.make( · 4 unmodified lines | |||
| 2456 | if (!noBrowser) openBrowser(verifyUrl); | 2520 | if (!noBrowser) openBrowser(verifyUrl); |
| 2457 | console.log("Waiting for you to approve in the browser..."); | 2521 | console.log("Waiting for you to approve in the browser..."); |
| 2458 | const tokens = yield* Effect.tryPromise({ | 2522 | const tokens = yield* Effect.tryPromise({ |
| 2459 | try: () => pollForDeviceTokens(discovery, grant), | 2523 | try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), |
| 2460 | catch: toError, | 2524 | catch: toError, |
| 2461 | }); | 2525 | }); |
| 2462 | 2526 | ||
| @@ -2481,6 +2545,9 @@ const loginCommand = Command.make( · 18 unmodified lines | |||
| 2481 | kind: "http", | 2545 | kind: "http", |
| 2482 | origin: target.origin, | 2546 | origin: target.origin, |
| 2483 | ...(email ? { displayName: email } : {}), | 2547 | ...(email ? { displayName: email } : {}), |
| 2548 | ...(target.profile?.connection.headers | ||
| 2549 | ? { headers: target.profile.connection.headers } | ||
| 2550 | : {}), | ||
| 2484 | auth: { | 2551 | auth: { |
| 2485 | kind: "oauth", | 2552 | kind: "oauth", |
| 2486 | accessToken: tokens.accessToken, | 2553 | accessToken: tokens.accessToken, |
| @@ -2543,6 +2610,7 @@ const logoutCommand = Command.make( · 56 unmodified lines | |||
| 2543 | kind: profile.connection.kind, | 2610 | kind: profile.connection.kind, |
| 2544 | origin: profile.connection.origin, | 2611 | origin: profile.connection.origin, |
| 2545 | displayName: profile.connection.displayName, | 2612 | displayName: profile.connection.displayName, |
| 2613 | ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), | ||
| 2546 | }, | 2614 | }, |
| 2547 | makeDefault: store.defaultProfile === profile.name, | 2615 | makeDefault: store.defaultProfile === profile.name, |
| 2548 | }); | 2616 | }); |
| @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 35 unmodified lines | |||
| 36 | connection: { | 36 | connection: { |
| 37 | origin: "https://executor.example/api", | 37 | origin: "https://executor.example/api", |
| 38 | auth: { kind: "bearer", token: "key_123" }, | 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 | makeDefault: true, | 43 | makeDefault: true, |
| 41 | }); | 44 | }); |
| @@ -50,6 +53,9 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines | |||
| 50 | kind: "bearer", | 53 | kind: "bearer", |
| 51 | token: "key_123", | 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 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); | 59 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); |
| 54 | } finally { | 60 | } finally { |
| 55 | rmSync(dataDir, { recursive: true, force: true }); | 61 | rmSync(dataDir, { recursive: true, force: true }); |
| @@ -114,6 +120,12 @@ describe("CLI server connection profiles", () => { · 58 unmodified lines | |||
| 114 | key: "desktop-sidecar", | 120 | key: "desktop-sidecar", |
| 115 | origin: "http://127.0.0.1:4789", | 121 | origin: "http://127.0.0.1:4789", |
| 116 | auth: { kind: "basic", username: "executor", password: "secret" }, | 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 | username: "executor", | 139 | username: "executor", |
| 128 | password: "secret", | 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 | const PersistedConnection = Schema.Struct({ | 70 | const PersistedConnection = Schema.Struct({ |
| 66 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), | 71 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), |
| 67 | key: Schema.optional(Schema.String), | 72 | key: Schema.optional(Schema.String), |
| @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line | |||
| 69 | apiBaseUrl: Schema.optional(Schema.String), | 74 | apiBaseUrl: Schema.optional(Schema.String), |
| 70 | displayName: Schema.optional(Schema.String), | 75 | displayName: Schema.optional(Schema.String), |
| 71 | auth: Schema.optional(PersistedAuth), | 76 | auth: Schema.optional(PersistedAuth), |
| 77 | headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), | ||
| 72 | }); | 78 | }); |
| 73 | 79 | ||
| 74 | const PersistedProfile = Schema.Struct({ | 80 | const PersistedProfile = Schema.Struct({ |