Repository navigation
feat(channels): agents tui logs in to Cloudflare Access by itself #2462
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "agents": patch | ||
| --- | ||
|
|
||
| `agents tui` now handles Cloudflare Access: for a URL behind Access, it gets a token from `cloudflared` (logging in through the browser the first time) and sends it on the WebSocket upgrade. `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` and explicit `--header` credentials still take precedence. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { accessHeaders, type AccessDeps } from "../web/tui/access"; | ||
|
|
||
| const TOKEN = "aaa.bbb.ccc"; | ||
| const LOGIN = "https://team.cloudflareaccess.com/cdn-cgi/access/login/app"; | ||
|
|
||
| function deps(options: { | ||
| location?: string; | ||
| tokens?: (string | undefined)[]; | ||
| missing?: boolean; | ||
| }) { | ||
| const calls: { url?: string; command?: string[] }[] = []; | ||
| const tokens = [...(options.tokens ?? [])]; | ||
| const value: AccessDeps = { | ||
| fetch: async (url) => { | ||
| calls.push({ url }); | ||
| return options.location | ||
| ? new Response(null, { | ||
| status: 302, | ||
| headers: { location: options.location } | ||
| }) | ||
| : new Response("ok"); | ||
| }, | ||
| run: async (command, args) => { | ||
| calls.push({ command: [command, ...args] }); | ||
| if (options.missing) throw new Error("Install cloudflared"); | ||
| if (args[1] === "login") return { code: 0, stdout: "" }; | ||
| const token = tokens.shift(); | ||
| return token | ||
| ? { code: 0, stdout: `${token}\n` } | ||
| : { code: 1, stdout: "" }; | ||
| }, | ||
| log: () => {} | ||
| }; | ||
| return { value, calls }; | ||
| } | ||
|
|
||
| describe("tui Access", () => { | ||
| it("sends nothing to a URL that is not behind Access", async () => { | ||
| const { value, calls } = deps({}); | ||
| expect(await accessHeaders("wss://h/channels/r", {}, value)).toEqual({}); | ||
| expect(calls).toEqual([{ url: "https://h/channels/r" }]); | ||
| }); | ||
|
|
||
| it("gets a token from cloudflared for the address being opened", async () => { | ||
| const { value, calls } = deps({ location: LOGIN, tokens: [TOKEN] }); | ||
| expect(await accessHeaders("wss://h/channels/r?as=a", {}, value)).toEqual({ | ||
| "cf-access-token": TOKEN | ||
| }); | ||
| expect(calls[1]).toEqual({ | ||
| command: ["cloudflared", "access", "token", "-app=https://h/channels/r"] | ||
| }); | ||
| }); | ||
|
|
||
| it("logs in when there is no token yet", async () => { | ||
| const { value, calls } = deps({ | ||
| location: LOGIN, | ||
| tokens: [undefined, TOKEN] | ||
| }); | ||
| expect(await accessHeaders("wss://h/c", {}, value)).toEqual({ | ||
| "cf-access-token": TOKEN | ||
| }); | ||
| expect(calls.map((call) => call.command?.[2])).toEqual([ | ||
| undefined, | ||
| "token", | ||
| "login", | ||
| "token" | ||
| ]); | ||
| }); | ||
|
|
||
| it("leaves credentials the caller sends alone", async () => { | ||
| const { value, calls } = deps({ location: LOGIN, tokens: [TOKEN] }); | ||
| expect( | ||
| await accessHeaders("wss://h/c", { "cf-access-client-id": "id" }, value) | ||
| ).toEqual({}); | ||
| expect(calls).toEqual([]); | ||
| }); | ||
|
|
||
| it("probes the channel path, which Access may protect alone", async () => { | ||
| const { value, calls } = deps({ location: LOGIN, tokens: [TOKEN] }); | ||
| await accessHeaders("wss://h/channels/team/one", {}, value); | ||
| expect(calls[0]).toEqual({ url: "https://h/channels/team/one" }); | ||
| }); | ||
|
|
||
| it("still logs in when the caller's cookie is not Access's", async () => { | ||
| const { value } = deps({ location: LOGIN, tokens: [TOKEN] }); | ||
| expect( | ||
| await accessHeaders("wss://h/c", { cookie: "theme=dark" }, value) | ||
| ).toEqual({ "cf-access-token": TOKEN }); | ||
| expect( | ||
| await accessHeaders( | ||
| "wss://h/c", | ||
| { cookie: "theme=dark; CF_Authorization=x" }, | ||
| value | ||
| ) | ||
| ).toEqual({}); | ||
| }); | ||
|
|
||
| it("ignores redirects that are not to Access", async () => { | ||
| const { value } = deps({ location: "https://h/login" }); | ||
| expect(await accessHeaders("wss://h/c", {}, value)).toEqual({}); | ||
| }); | ||
|
|
||
| it("explains how to continue when cloudflared is missing", async () => { | ||
| const { value } = deps({ location: LOGIN, missing: true }); | ||
| await expect(accessHeaders("wss://h/c", {}, value)).rejects.toThrow( | ||
| "Install cloudflared" | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { spawn } from "node:child_process"; | ||
|
|
||
| /** What finding an Access token needs, replaceable in tests. */ | ||
| export type AccessDeps = { | ||
| fetch(url: string, init: RequestInit): Promise<Response>; | ||
| /** Runs a command; `interactive` lets it use the terminal. */ | ||
| run( | ||
| command: string, | ||
| args: readonly string[], | ||
| interactive: boolean | ||
| ): Promise<{ code: number; stdout: string }>; | ||
| log(message: string): void; | ||
| }; | ||
|
|
||
| /** Whether the caller already sends Access credentials. */ | ||
| function authenticated(headers: Readonly<Record<string, string>>): boolean { | ||
| return ( | ||
| "cf-access-token" in headers || | ||
| "cf-access-client-id" in headers || | ||
| /(?:^|;\s*)CF_Authorization=/.test(headers.cookie ?? "") | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The `cf-access-token` header for a URL behind Cloudflare Access, from | ||
| * `cloudflared`. Logs in through the browser when there is no token yet. | ||
| * Returns no headers when the caller already sends credentials, or when the | ||
| * URL is not behind Access. | ||
| */ | ||
| export async function accessHeaders( | ||
| socketUrl: string, | ||
| headers: Readonly<Record<string, string>>, | ||
| deps: AccessDeps = nodeDeps | ||
| ): Promise<Record<string, string>> { | ||
| if (authenticated(headers)) return {}; | ||
| // Access can protect a path rather than the whole host, so probe and log | ||
| // in to the address being opened. | ||
| const url = new URL(socketUrl); | ||
| url.protocol = url.protocol === "ws:" ? "http:" : "https:"; | ||
| url.search = ""; | ||
| const app = url.toString(); | ||
| if (!(await behindAccess(app, deps))) return {}; | ||
|
|
||
| const token = async () => { | ||
| const { code, stdout } = await deps.run( | ||
| "cloudflared", | ||
| ["access", "token", `-app=${app}`], | ||
| false | ||
| ); | ||
| const value = stdout.trim(); | ||
| return code === 0 && /^[\w-]+\.[\w-]+\.[\w-]+$/.test(value) | ||
| ? value | ||
| : undefined; | ||
| }; | ||
|
|
||
| let value = await token(); | ||
| if (value === undefined) { | ||
| deps.log(`${app} is behind Cloudflare Access; logging in.`); | ||
| await deps.run("cloudflared", ["access", "login", app], true); | ||
| value = await token(); | ||
| } | ||
| if (value === undefined) { | ||
| throw new Error( | ||
| `Could not get a Cloudflare Access token for ${app}. Run \`cloudflared access login ${app}\`, or pass --header cf-access-token=<token>.` | ||
| ); | ||
| } | ||
| return { "cf-access-token": value }; | ||
| } | ||
|
|
||
| async function behindAccess(app: string, deps: AccessDeps) { | ||
| try { | ||
| const response = await deps.fetch(app, { redirect: "manual" }); | ||
| const location = response.headers.get("location"); | ||
| if (response.status < 300 || response.status >= 400 || !location) { | ||
| return false; | ||
| } | ||
| return new URL(location, app).hostname.endsWith(".cloudflareaccess.com"); | ||
| } catch { | ||
| // Unreachable: let the WebSocket report it. | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| const nodeDeps: AccessDeps = { | ||
| fetch: (url, init) => fetch(url, init), | ||
| run: (command, args, interactive) => | ||
| new Promise((resolve, reject) => { | ||
| const child = spawn(command, args, { | ||
| stdio: interactive ? "inherit" : ["ignore", "pipe", "ignore"] | ||
| }); | ||
| let stdout = ""; | ||
| child.stdout?.on("data", (data: Buffer) => { | ||
| stdout += data.toString(); | ||
| }); | ||
| child.on("error", (error: NodeJS.ErrnoException) => { | ||
| reject( | ||
| error.code === "ENOENT" | ||
| ? new Error( | ||
| "This URL is behind Cloudflare Access. Install cloudflared, or pass --header cf-access-token=<token>." | ||
| ) | ||
| : error | ||
| ); | ||
| }); | ||
| child.on("close", (code) => resolve({ code: code ?? 1, stdout })); | ||
| }), | ||
| log: (message) => console.error(message) | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { accessHeaders } from "./access"; | ||
| import { parseArgs, usage, UsageError } from "./args"; | ||
| import { runTui } from "./app"; | ||
|
|
||
|
|
@@ -7,12 +8,21 @@ export async function main(argv: readonly string[]): Promise<number> { | |
| console.log(usage); | ||
| return 0; | ||
| } | ||
| let args; | ||
| try { | ||
| await runTui(parseArgs(argv, process.env)); | ||
| return 0; | ||
| args = parseArgs(argv, process.env); | ||
| } catch (error) { | ||
| if (!(error instanceof UsageError)) throw error; | ||
| console.error(`${error.message}\n\n${usage}`); | ||
| return 2; | ||
| } | ||
| try { | ||
| const access = await accessHeaders(args.url, args.headers); | ||
| args = { ...args, headers: { ...args.headers, ...access } }; | ||
| } catch (error) { | ||
|
Comment on lines
+21
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Expired Access tokens strand live sessions When an Access token expires, Learn moreThe TUI resolves an Access token once, then sendHeadersOnUpgrade captures that token in the WebSocket wrapper. The client's reconnect loop reuses the wrapper without refreshing its headers. When Access expires the token, all future upgrade attempts use an expired token until the process exits. Example: A TUI starts at 09:00 with a token expiring at 10:00. If the socket drops at 10:05, every automatic retry uses the 09:00 token instead of obtaining a new one. Recommended fix: Refresh cloudflared credentials before retries after an Access rejection or before reconnecting when the JWT has expired. Make the WebSocket upgrade wrapper read updated headers on each connection and avoid restarting interactive login for unrelated connection failures. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| console.error(error instanceof Error ? error.message : String(error)); | ||
| return 1; | ||
| } | ||
| await runTui(args); | ||
| return 0; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Access scope differs from the description
The PR description specifies origin-based detection and login, while
cloudflaredreceives the channel path. Confirm path-scoped application matching and update the description.Was this helpful? React with 👍 or 👎 to provide feedback.