From 0937d3e86218e47d72e8411fb963f8ed57746603 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:04:54 -0400 Subject: [PATCH 1/4] Webhook relay multi-user hardening: device-flow app auth + repo-permission gate Worker: /github/repos/:owner/:repo/{events,status} now require a Bearer GitHub token with push/maintain/admin on the repo (GET /repos permissions, fallback /user + collaborator permission). Read-only/public tokens get 403 with no event or status leakage. Legacy /projects routes unchanged. Clients: hosted relay reads authenticate with an expiring GitHub App user token minted via GitHub device flow (never the user's PAT/gh token). New shared githubAppUserAuthService factory (desktop + ade-cli) owns the token store, single-flight refresh with clear-auth epoch fence, device-session lifecycle, and deduped audit logging. Settings panel gains the Authorize ADE device-flow UI (copyable code, auto-renew capped at 3); new typed `ade github app-auth login|status|clear` for headless setups. Live-verified against GitHub: device flow enabled, read-only app user token reports true repo role, collaborator fallback works, pending polls return HTTP 200. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 3 + apps/ade-cli/src/cli.ts | 208 ++++++++++++- apps/ade-cli/src/headlessLinearServices.ts | 29 +- .../src/main/services/adeActions/registry.ts | 7 +- .../automationIngressService.test.ts | 48 ++- .../automations/automationIngressService.ts | 31 +- .../main/services/github/githubAppUserAuth.ts | 209 +++++++++++++ .../github/githubAppUserAuthService.ts | 281 ++++++++++++++++++ .../main/services/github/githubRelayConfig.ts | 63 +++- .../services/github/githubService.test.ts | 194 +++++++++++- .../src/main/services/github/githubService.ts | 41 ++- .../src/main/services/ipc/registerIpc.ts | 35 +++ apps/desktop/src/preload/global.d.ts | 9 + apps/desktop/src/preload/preload.ts | 31 ++ apps/desktop/src/renderer/browserMock.ts | 44 +++ .../github/GitHubAppInstallPanel.tsx | 235 ++++++++++++++- apps/desktop/src/shared/ipc.ts | 4 + apps/desktop/src/shared/types/git.ts | 26 ++ apps/webhook-relay/README.md | 39 ++- apps/webhook-relay/src/relay.ts | 104 ++++++- apps/webhook-relay/test/relay.test.ts | 183 +++++++++++- docs/ARCHITECTURE.md | 7 +- docs/features/automations/README.md | 8 +- .../onboarding-and-settings/README.md | 14 +- 24 files changed, 1797 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/main/services/github/githubAppUserAuth.ts create mode 100644 apps/desktop/src/main/services/github/githubAppUserAuthService.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 9f9a66e7d..c77d4ed96 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -335,6 +335,9 @@ ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade actions run pty.resumeSession --arg sessionId=session-id ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr +ade --role cto github app-auth login # device-flow authorize the machine ADE GitHub App (headless/brain) +ade github app-auth status --text # show whether a GitHub App user token is stored (login, expiry) +ade --role cto github app-auth clear # remove the stored GitHub App authorization ade open ade://lane/ ade open --linear-issue ADE-123 --branch arul/ade-123-fix ade link lane diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 4ce83cc36..699061289 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -207,7 +207,8 @@ type CliPlan = | { kind: "init"; targetPath: string | null } | { kind: "cursor-cloud"; rest: string[] } | { kind: "deeplink"; rest: string[] } - | { kind: "skill"; rest: string[] }; + | { kind: "skill"; rest: string[] } + | { kind: "github-app-login"; maxWaitSec: number | null }; type CliConnection = { mode: "desktop-socket" | "runtime-socket" | "headless"; @@ -481,6 +482,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats $ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows + $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) $ ade automations list | create | run | runs Manage automation rules $ ade coordinator Call coordinator runtime tools $ ade tests list | run | stop | runs | logs Run configured test suites @@ -970,6 +972,30 @@ const HELP_BY_COMMAND: Record = { Flags: --app-name macOS app name to open. Defaults to ADE, ADE Beta, or ADE Alpha based on the installed CLI wrapper. +`, + github: `${ADE_BANNER} + ADE GitHub + + Authorize the machine-scoped ADE GitHub App so headless / brain setups (which + have no Settings panel) can use the hosted PR-sync webhook relay. Uses GitHub's + device flow: ADE prints a short user code and a verification URL, you approve + in a browser, and ADE stores the resulting user token in the machine credential + store. The token itself is never printed. + + $ ade --role cto github app-auth login Start device flow and wait for approval + $ ade github app-auth status --text Show whether a token is stored (login, expiry) + $ ade --role cto github app-auth clear Remove the stored authorization + $ ade github actions --text List raw github service actions + + Notes: + - login, clear (and the raw start/poll actions) require --role cto. + - login keeps one connection open for the whole device flow because the + device-auth session lives in runtime memory; do not split start and poll + across separate invocations in headless mode. + + Flags (login): + --max-wait Give up waiting after N seconds (default: GitHub's + device-code expiry, ~15 min). `, open: `${ADE_BANNER} ADE Open @@ -10046,6 +10072,7 @@ function buildCliPlan( quota: "usage", quotas: "usage", skills: "skill", + gh: "github", }; const primaryHelpKey = aliases[primary] ?? primary; if (hasHelpFlag(args)) { @@ -10289,9 +10316,55 @@ function buildCliPlan( ) return buildUpdatePlan(args); if (primary === "cursor") return buildCursorPlan(args); + if (primary === "github" || primary === "gh") return buildGithubPlan(args); throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); } +function buildGithubPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "app-auth"; + if (sub === "help") { + return { kind: "help", text: HELP_BY_COMMAND.github ?? topLevelHelpText() }; + } + if (sub === "actions") { + return { + kind: "execute", + label: "github actions", + formatter: "actions-list", + steps: [listActionsStep("actions", "github")], + }; + } + if (sub === "app-auth" || sub === "app" || sub === "auth") { + const mode = firstPositional(args) ?? "status"; + if (mode === "status" || mode === "show") { + return { + kind: "execute", + label: "github app-auth status", + steps: [actionStep("result", "github", "getAppUserAuthStatus")], + }; + } + if (mode === "login" || mode === "authorize" || mode === "start") { + const maxWaitSec = readIntOption(args, ["--max-wait", "--timeout-sec"]); + return { + kind: "github-app-login", + maxWaitSec: typeof maxWaitSec === "number" ? maxWaitSec : null, + }; + } + if (mode === "clear" || mode === "logout" || mode === "sign-out") { + return { + kind: "execute", + label: "github app-auth clear", + steps: [actionStep("result", "github", "clearAppUserAuth")], + }; + } + throw new CliUsageError( + "github app-auth supports status, login, or clear.", + ); + } + throw new CliUsageError( + "github supports app-auth (status | login | clear) and actions.", + ); +} + function buildCursorPlan(args: string[]): CliPlan { // ade cursor ... — only "cloud" is wired today. const surface = firstPositional(args); @@ -15464,6 +15537,136 @@ function graphWaitState(value: unknown): { }; } +/** + * Interactive GitHub App (device-flow) authorization for headless / brain + * setups that have no Settings panel. Device-auth session state lives in the + * runtime process memory, so start-then-poll must happen over a single live + * connection — a two-process `start` + `poll` split cannot share the session in + * headless mode. start/poll are CTO-only, so run this with `--role cto`. + * Progress is written to stderr; only the final auth status (never the token) + * is emitted on stdout. + */ +async function runGithubAppLogin( + plan: CliPlan & { kind: "github-app-login" }, + options: GlobalOptions, +): Promise<{ output: string; exitCode: number }> { + let connection: CliConnection; + try { + connection = await createConnection(options); + } catch (error) { + throw new CliExecutionError( + "Failed to initialize ADE CLI connection for github app-auth login.", + { + cause: error instanceof Error ? error.message : String(error), + nextAction: + "Verify --project-root points at an ADE project and run ade doctor --json.", + }, + ); + } + const runGithubAction = async ( + action: string, + actionArgs: JsonObject = {}, + ): Promise => { + let result: unknown; + try { + const raw = await connection.request("ade/actions/call", { + name: "run_ade_action", + arguments: { domain: "github", action, args: actionArgs }, + }); + // `ade/actions/call` returns an `{ ok: false, error }` envelope on the + // CTO gate rather than throwing; unwrapToolResult converts that to a throw. + result = unwrapActionEnvelope(unwrapToolResult(raw)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/elevated role/i.test(message)) { + throw new CliUsageError( + "github app-auth login authorizes the machine GitHub App and requires --role cto (e.g. `ade --role cto github app-auth login`).", + ); + } + throw error; + } + if (!isRecord(result)) { + throw new CliExecutionError( + `github.${action} returned an unexpected result.`, + { action }, + ); + } + return result; + }; + try { + const start = await runGithubAction("startAppUserDeviceAuth"); + const sessionId = asString(start.sessionId); + const userCode = asString(start.userCode); + const verificationUri = asString(start.verificationUri); + const verificationUriComplete = asString(start.verificationUriComplete); + const expiresAt = asString(start.expiresAt); + if (!sessionId || !userCode || !verificationUri) { + throw new CliExecutionError( + "GitHub device authorization did not start.", + { start }, + ); + } + let intervalSec = + typeof start.intervalSec === "number" && start.intervalSec > 0 + ? start.intervalSec + : 5; + const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN; + const maxWaitDeadlineMs = + plan.maxWaitSec != null ? Date.now() + plan.maxWaitSec * 1000 : Number.NaN; + const deadlineMs = Math.min( + Number.isFinite(expiresAtMs) ? expiresAtMs : Number.POSITIVE_INFINITY, + Number.isFinite(maxWaitDeadlineMs) + ? maxWaitDeadlineMs + : Number.POSITIVE_INFINITY, + ); + process.stderr.write( + `\nAuthorize the ADE GitHub App:\n` + + ` 1. Open ${verificationUri}\n` + + ` 2. Enter code: ${userCode}\n` + + (verificationUriComplete + ? ` (or open ${verificationUriComplete} to skip step 2)\n` + : "") + + `\nWaiting for authorization…\n`, + ); + + while (true) { + if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) { + process.stderr.write("GitHub device authorization timed out.\n"); + const status = await runGithubAction("getAppUserAuthStatus"); + return { + output: formatOutput( + { ...status, status: "expired", error: "timed_out" }, + options, + ), + exitCode: 1, + }; + } + await sleep(Math.max(1, intervalSec) * 1000); + const poll = await runGithubAction("pollAppUserDeviceAuth", { sessionId }); + const status = asString(poll.status); + const authStatus = isRecord(poll.authStatus) ? poll.authStatus : poll; + if (status === "authorized") { + process.stderr.write("GitHub App authorized.\n"); + return { output: formatOutput(authStatus, options), exitCode: 0 }; + } + if (status === "pending" || status === "slow_down") { + if (typeof poll.intervalSec === "number" && poll.intervalSec > 0) { + intervalSec = poll.intervalSec; + } + continue; + } + // expired | denied | error + const message = + asString(poll.message) ?? + `GitHub device authorization ${status ?? "failed"}.`; + process.stderr.write(`${message}\n`); + return { output: formatOutput(authStatus, options), exitCode: 1 }; + } + } finally { + await connection.close(); + } +} + async function executePlan( plan: CliPlan & { kind: "execute" }, options: GlobalOptions, @@ -15700,6 +15903,9 @@ async function runCli( if (plan.kind === "ade-code") { return await runAdeCode(plan.rest, parsed.options); } + if (plan.kind === "github-app-login") { + return await runGithubAppLogin(plan, parsed.options); + } const result = await executePlan(plan, parsed.options); if (plan.writeResultPath) { const payload = JSON.stringify( diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 7f81eb509..a911ff560 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -44,6 +44,9 @@ import { parseGitHubScopeHeaders, } from "../../desktop/src/shared/githubScopes"; import type { + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, + GitHubAppUserAuthStatus, GitHubStatus, LinearIngressEventRecord, WorkerAgentRun, @@ -61,6 +64,7 @@ import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader, } from "../../desktop/src/main/services/github/githubRelayConfig"; +import { createGitHubAppUserAuthService } from "../../desktop/src/main/services/github/githubAppUserAuthService"; import type { AdeRuntimePaths } from "./bootstrap"; import { createLinearClient as createLinearClientImpl } from "../../desktop/src/main/services/cto/linearClient"; import { createLinearIssueTracker as createLinearIssueTrackerImpl } from "../../desktop/src/main/services/cto/linearIssueTracker"; @@ -457,6 +461,12 @@ export function createHeadlessGitHubService( } = {}, ): HeadlessGitHubService { const credentialStore = new EncryptedFileCredentialStore(); + const appUserAuth = createGitHubAppUserAuthService({ + credentialStore, + logger, + fetchImpl: (input, init) => fetchGitHub(input, init ?? {}), + userAgent: "ade-cli", + }); const tokenKey = "github.token.v1"; let cachedStatus: Awaited< ReturnType @@ -1006,13 +1016,27 @@ export function createHeadlessGitHubService( const owner = args.owner?.trim(); const name = args.name?.trim(); const repo = owner && name ? { owner, name } : detectGitHubRepo(projectRoot); + const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null); return fetchGitHubAppInstallationStatus({ repo, secretReader: options.githubRelaySecretReader, forceRefresh: args.forceRefresh === true, - githubToken: getToken(), + githubAppUserToken, + auditLog: appUserAuth.auditLog, }); }, + getAppUserAuthStatus(): GitHubAppUserAuthStatus { + return appUserAuth.getAuthStatus(); + }, + async startAppUserDeviceAuth(): Promise { + return await appUserAuth.startDeviceAuth(); + }, + async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { + return await appUserAuth.pollDeviceAuth(args); + }, + clearAppUserAuth(): GitHubAppUserAuthStatus { + return appUserAuth.clearAuth(); + }, async getRepoOrThrow() { const repo = detectGitHubRepo(projectRoot); if (!repo) @@ -1029,6 +1053,9 @@ export function createHeadlessGitHubService( ); return token; }, + async getAppUserTokenForRelay() { + return await appUserAuth.getValidTokenForRelay(); + }, parseGitHubRepoFromRemoteUrl, parseNextLink: parseNextGitHubLink, setToken(nextToken: string) { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index aaf9c9296..2fd637401 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -144,7 +144,7 @@ export const ADE_ACTION_CTO_ONLY: Partial { } }); - it("polls the hosted repo relay with the existing GitHub token when no relay secret is configured", async () => { + it("refuses to poll the hosted repo relay without GitHub App user authorization", async () => { const updates: Array> = []; const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ events: [], @@ -305,7 +305,48 @@ describe("automationIngressService", () => { } as never, githubService: { detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), - getTokenOrThrow: vi.fn(() => "ghp_user_token"), + getAppUserTokenForRelay: vi.fn(async () => { + throw new Error("Authorize the ADE GitHub App with GitHub before using the hosted relay."); + }), + }, + listRules: () => [], + }); + + await service.pollNow(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(updates).toContainEqual(expect.objectContaining({ + githubRelay: expect.objectContaining({ + healthy: false, + status: "error", + lastError: "Authorize the ADE GitHub App with GitHub before using the hosted relay.", + }), + })); + }); + + it("polls the hosted repo relay with a GitHub App user token", async () => { + const updates: Array> = []; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + events: [], + nextCursor: null, + }), { headers: { "content-type": "application/json" } })); + const getAppUserTokenForRelay = vi.fn(async () => "ghu_app_user_token"); + + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: { + updateIngressStatus: (patch: Record) => updates.push(patch), + dispatchIngressTrigger: vi.fn(), + getIngressCursor: () => null, + setIngressCursor: vi.fn(), + getIngressStatus: () => ({}), + } as never, + secretService: { + getSecret: () => null, + } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay, }, listRules: () => [], }); @@ -316,10 +357,11 @@ describe("automationIngressService", () => { "https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/arul28/ADE/events", expect.objectContaining({ headers: expect.objectContaining({ - authorization: "Bearer ghp_user_token", + authorization: "Bearer ghu_app_user_token", }), }), ); + expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1); expect(updates).toContainEqual(expect.objectContaining({ githubRelay: expect.objectContaining({ configured: true, diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 3eaee31bb..3610e2500 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -6,7 +6,13 @@ import type { Logger } from "../logging/logger"; import type { createAutomationService } from "./automationService"; import type { AutomationSecretService } from "./automationSecretService"; import type { createPrService } from "../prs/prService"; -import { gitHubRelayAuthorizationToken, readGitHubRelayConfig, shouldUseLegacyGitHubRelayProjectRoute } from "../github/githubRelayConfig"; +import { + createGitHubRelayAuthAuditLog, + gitHubRelayAuthorizationToken, + readGitHubRelayConfig, + resolveHostedGitHubRelayAuthToken, + shouldUseLegacyGitHubRelayProjectRoute, +} from "../github/githubRelayConfig"; type AutomationIngressServiceArgs = { logger: Logger; @@ -15,7 +21,7 @@ type AutomationIngressServiceArgs = { secretService: AutomationSecretService; githubService?: { detectRepo: () => Promise | GitHubRepoRef | null; - getTokenOrThrow: () => string; + getAppUserTokenForRelay: () => Promise; } | null; listRules: () => AutomationRule[]; pollIntervalMs?: number; @@ -234,6 +240,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg let server: http.Server | null = null; let pollTimer: NodeJS.Timeout | null = null; let pollInFlight: Promise | null = null; + const auditHostedRelayAuthTokenUse = createGitHubRelayAuthAuditLog( + (event, metadata) => args.logger.info(event, metadata), + ); const updateGithubRelayStatus = (patch: Partial) => { args.automationService.updateIngressStatus({ @@ -447,11 +456,25 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg return; } if (cursor) eventsUrl.searchParams.set("after", cursor); - const githubToken = useLegacyProjectRoute ? null : args.githubService?.getTokenOrThrow(); - const authToken = useLegacyProjectRoute ? legacyAuthToken : githubToken; + const githubAppUserToken = useLegacyProjectRoute ? null : await args.githubService?.getAppUserTokenForRelay(); + const hostedAuth = useLegacyProjectRoute + ? null + : resolveHostedGitHubRelayAuthToken({ githubAppUserToken }); + if (hostedAuth && !hostedAuth.ok) { + throw new Error(hostedAuth.error); + } + const authToken = useLegacyProjectRoute ? legacyAuthToken : hostedAuth?.token ?? null; if (!authToken) { throw new Error("GitHub auth is required for relay polling."); } + if (hostedAuth?.ok && repo) { + auditHostedRelayAuthTokenUse("automations.github_hosted_relay_auth_token_used", { + origin: new URL(baseUrl).origin, + repo: `${repo.owner}/${repo.name}`, + tokenSource: "github-app-user-token", + route: "events", + }); + } updateGithubRelayStatus({ configured: true, apiBaseUrl: config.apiBaseUrl, diff --git a/apps/desktop/src/main/services/github/githubAppUserAuth.ts b/apps/desktop/src/main/services/github/githubAppUserAuth.ts new file mode 100644 index 000000000..395cba593 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuth.ts @@ -0,0 +1,209 @@ +export const ADE_GITHUB_APP_CLIENT_ID = "Iv23liy35Ed4L0oQODtl"; + +const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; +const GITHUB_OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token"; + +export type GitHubAppUserTokenRecord = { + accessToken: string; + tokenType: string; + scope: string | null; + expiresAt: string | null; + refreshToken: string | null; + refreshTokenExpiresAt: string | null; + userLogin: string | null; + updatedAt: string; +}; + +export type GitHubAppDeviceCode = { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string | null; + expiresAt: string; + intervalSec: number; +}; + +export type GitHubAppDevicePollResult = + | { + status: "pending" | "slow_down"; + intervalSec: number; + message: string | null; + } + | { + status: "authorized"; + token: GitHubAppUserTokenRecord; + } + | { + status: "expired" | "denied" | "error"; + message: string; + }; + +type FetchImpl = typeof fetch; + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function readNumber(source: Record, key: string): number | null { + const value = source[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function isoFromExpiresIn(seconds: number | null, now = Date.now()): string | null { + if (seconds == null || seconds <= 0) return null; + return new Date(now + Math.trunc(seconds) * 1000).toISOString(); +} + +function parseJsonRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +async function postGitHubOAuthForm(args: { + fetchImpl: FetchImpl; + url: string; + userAgent: string; + body: Record; +}): Promise> { + const response = await args.fetchImpl(args.url, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + "user-agent": args.userAgent, + }, + body: new URLSearchParams(args.body).toString(), + }); + const payload = parseJsonRecord(await response.json().catch(() => ({}))); + if (!response.ok) { + const message = readString(payload, "error_description") + || readString(payload, "error") + || `GitHub OAuth request failed (${response.status})`; + throw new Error(message); + } + return payload; +} + +export async function startGitHubAppDeviceFlow(args: { + clientId?: string | null; + fetchImpl?: FetchImpl; + userAgent: string; +}): Promise { + const clientId = args.clientId?.trim() || ADE_GITHUB_APP_CLIENT_ID; + const payload = await postGitHubOAuthForm({ + fetchImpl: args.fetchImpl ?? fetch, + url: GITHUB_DEVICE_CODE_URL, + userAgent: args.userAgent, + body: { client_id: clientId }, + }); + const deviceCode = readString(payload, "device_code"); + const userCode = readString(payload, "user_code"); + const verificationUri = readString(payload, "verification_uri"); + if (!deviceCode || !userCode || !verificationUri) { + throw new Error("GitHub device authorization response was missing required fields."); + } + const expiresIn = readNumber(payload, "expires_in") ?? 900; + const intervalSec = Math.max(1, readNumber(payload, "interval") ?? 5); + return { + deviceCode, + userCode, + verificationUri, + verificationUriComplete: readString(payload, "verification_uri_complete") || null, + expiresAt: isoFromExpiresIn(expiresIn) ?? new Date(Date.now() + expiresIn * 1000).toISOString(), + intervalSec, + }; +} + +export async function pollGitHubAppDeviceFlow(args: { + clientId?: string | null; + deviceCode: string; + intervalSec: number; + fetchImpl?: FetchImpl; + userAgent: string; + fetchUserLogin?: (accessToken: string) => Promise; +}): Promise { + const clientId = args.clientId?.trim() || ADE_GITHUB_APP_CLIENT_ID; + const payload = await postGitHubOAuthForm({ + fetchImpl: args.fetchImpl ?? fetch, + url: GITHUB_OAUTH_TOKEN_URL, + userAgent: args.userAgent, + body: { + client_id: clientId, + device_code: args.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }, + }); + const error = readString(payload, "error"); + if (error === "authorization_pending") { + return { status: "pending", intervalSec: args.intervalSec, message: null }; + } + if (error === "slow_down") { + return { + status: "slow_down", + intervalSec: args.intervalSec + 5, + message: readString(payload, "error_description") || "GitHub asked ADE to slow down polling.", + }; + } + if (error === "expired_token") { + return { status: "expired", message: readString(payload, "error_description") || "GitHub device authorization expired." }; + } + if (error === "access_denied") { + return { status: "denied", message: readString(payload, "error_description") || "GitHub authorization was denied." }; + } + if (error) { + return { status: "error", message: readString(payload, "error_description") || error }; + } + + const accessToken = readString(payload, "access_token"); + if (!accessToken) return { status: "error", message: "GitHub did not return a user access token." }; + const userLogin = args.fetchUserLogin ? await args.fetchUserLogin(accessToken).catch(() => null) : null; + return { + status: "authorized", + token: { + accessToken, + tokenType: readString(payload, "token_type") || "bearer", + scope: readString(payload, "scope") || null, + expiresAt: isoFromExpiresIn(readNumber(payload, "expires_in")), + refreshToken: readString(payload, "refresh_token") || null, + refreshTokenExpiresAt: isoFromExpiresIn(readNumber(payload, "refresh_token_expires_in")), + userLogin, + updatedAt: new Date().toISOString(), + }, + }; +} + +export async function refreshGitHubAppUserToken(args: { + clientId?: string | null; + refreshToken: string; + fetchImpl?: FetchImpl; + userAgent: string; + fetchUserLogin?: (accessToken: string) => Promise; +}): Promise { + const clientId = args.clientId?.trim() || ADE_GITHUB_APP_CLIENT_ID; + const payload = await postGitHubOAuthForm({ + fetchImpl: args.fetchImpl ?? fetch, + url: GITHUB_OAUTH_TOKEN_URL, + userAgent: args.userAgent, + body: { + client_id: clientId, + grant_type: "refresh_token", + refresh_token: args.refreshToken, + }, + }); + const accessToken = readString(payload, "access_token"); + if (!accessToken) throw new Error("GitHub did not return a refreshed user access token."); + const userLogin = args.fetchUserLogin ? await args.fetchUserLogin(accessToken).catch(() => null) : null; + return { + accessToken, + tokenType: readString(payload, "token_type") || "bearer", + scope: readString(payload, "scope") || null, + expiresAt: isoFromExpiresIn(readNumber(payload, "expires_in")), + refreshToken: readString(payload, "refresh_token") || args.refreshToken, + refreshTokenExpiresAt: isoFromExpiresIn(readNumber(payload, "refresh_token_expires_in")), + userLogin, + updatedAt: new Date().toISOString(), + }; +} + diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthService.ts b/apps/desktop/src/main/services/github/githubAppUserAuthService.ts new file mode 100644 index 000000000..7aea34f75 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuthService.ts @@ -0,0 +1,281 @@ +import { randomUUID } from "node:crypto"; +import type { + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, + GitHubAppUserAuthStatus, +} from "../../../shared/types"; +import { + ADE_GITHUB_APP_CLIENT_ID, + type GitHubAppDeviceCode, + type GitHubAppUserTokenRecord, + pollGitHubAppDeviceFlow, + refreshGitHubAppUserToken, + startGitHubAppDeviceFlow, +} from "./githubAppUserAuth"; +import { + createGitHubRelayAuthAuditLog, + type GitHubRelayAuthAuditLog, +} from "./githubRelayConfig"; +import { asString } from "../shared/utils"; + +const GITHUB_APP_USER_TOKEN_KEY = "github.appUserToken.v1"; +const GITHUB_APP_USER_TOKEN_REFRESH_SKEW_MS = 2 * 60_000; + +export type GitHubAppUserAuthCredentialStore = { + getSync(key: string): string | null | undefined; + setSync(key: string, value: string): void; + deleteSync(key: string): void; +}; + +type GitHubAppDeviceAuthSession = GitHubAppDeviceCode & { + sessionId: string; + intervalSec: number; +}; + +type GitHubAppUserAuthLogger = { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; +}; + +export function createGitHubAppUserAuthService(args: { + credentialStore?: GitHubAppUserAuthCredentialStore | null; + logger: GitHubAppUserAuthLogger; + fetchImpl: (input: string, init?: RequestInit) => Promise; + userAgent: string; +}): { + getAuthStatus(patch?: Partial): GitHubAppUserAuthStatus; + startDeviceAuth(): Promise; + pollDeviceAuth(args: { sessionId: string }): Promise; + clearAuth(): GitHubAppUserAuthStatus; + getValidTokenForRelay(): Promise; + auditLog: GitHubRelayAuthAuditLog; +} { + const appDeviceAuthSessions = new Map(); + let appUserTokenMemory: GitHubAppUserTokenRecord | null = null; + let refreshInFlight: Promise | null = null; + // Bumped by clearAuth so an in-flight refresh cannot re-persist a credential + // the user just cleared. + let authEpoch = 0; + + const auditLog = createGitHubRelayAuthAuditLog(args.logger.info.bind(args.logger)); + + const pruneExpiredDeviceAuthSessions = (requestedSessionId?: string): boolean => { + const now = Date.now(); + let requestedExpired = false; + for (const [sessionId, session] of appDeviceAuthSessions.entries()) { + if (Date.parse(session.expiresAt) <= now) { + appDeviceAuthSessions.delete(sessionId); + if (sessionId === requestedSessionId) requestedExpired = true; + } + } + return requestedExpired; + }; + + const readAppUserTokenRecord = (): GitHubAppUserTokenRecord | null => { + try { + const raw = args.credentialStore?.getSync(GITHUB_APP_USER_TOKEN_KEY)?.trim() || ""; + if (!raw) return appUserTokenMemory; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.accessToken !== "string" || !parsed.accessToken.trim()) return null; + return { + accessToken: parsed.accessToken.trim(), + tokenType: typeof parsed.tokenType === "string" && parsed.tokenType.trim() ? parsed.tokenType.trim() : "bearer", + scope: typeof parsed.scope === "string" && parsed.scope.trim() ? parsed.scope.trim() : null, + expiresAt: typeof parsed.expiresAt === "string" && parsed.expiresAt.trim() ? parsed.expiresAt.trim() : null, + refreshToken: typeof parsed.refreshToken === "string" && parsed.refreshToken.trim() ? parsed.refreshToken.trim() : null, + refreshTokenExpiresAt: + typeof parsed.refreshTokenExpiresAt === "string" && parsed.refreshTokenExpiresAt.trim() + ? parsed.refreshTokenExpiresAt.trim() + : null, + userLogin: typeof parsed.userLogin === "string" && parsed.userLogin.trim() ? parsed.userLogin.trim() : null, + updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt.trim() : new Date().toISOString(), + }; + } catch { + args.logger.warn("github.app_user_token_read_failed", { + error: "failed to parse stored app user token", + }); + return null; + } + }; + + const persistAppUserTokenRecord = (record: GitHubAppUserTokenRecord | null): void => { + appUserTokenMemory = record; + try { + if (record) { + args.credentialStore?.setSync(GITHUB_APP_USER_TOKEN_KEY, JSON.stringify(record)); + } else { + args.credentialStore?.deleteSync(GITHUB_APP_USER_TOKEN_KEY); + } + } catch (error) { + args.logger.warn("github.app_user_token_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + const appUserAuthStatus = (patch: Partial = {}): GitHubAppUserAuthStatus => { + const record = readAppUserTokenRecord(); + return { + configured: true, + tokenStored: Boolean(record?.accessToken), + userLogin: record?.userLogin ?? null, + expiresAt: record?.expiresAt ?? null, + refreshTokenExpiresAt: record?.refreshTokenExpiresAt ?? null, + checkedAt: new Date().toISOString(), + error: null, + ...patch, + }; + }; + + const isIsoAfter = (iso: string | null, cutoffMs: number): boolean => { + if (!iso) return false; + const time = Date.parse(iso); + return Number.isFinite(time) && time > cutoffMs; + }; + + const fetchAppUserLogin = async (accessToken: string): Promise => { + const response = await args.fetchImpl("https://api.github.com/user", { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${accessToken}`, + "user-agent": args.userAgent, + }, + }); + const payload = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) return null; + return asString(payload.login).trim() || null; + }; + + const getValidAppUserTokenForRelay = async (): Promise => { + const record = readAppUserTokenRecord(); + if (!record?.accessToken) { + throw new Error("Authorize the ADE GitHub App with GitHub before using the hosted relay."); + } + const refreshCutoff = Date.now() + GITHUB_APP_USER_TOKEN_REFRESH_SKEW_MS; + if (!record.expiresAt || isIsoAfter(record.expiresAt, refreshCutoff)) { + return record.accessToken; + } + if (!record.refreshToken || !isIsoAfter(record.refreshTokenExpiresAt, Date.now())) { + persistAppUserTokenRecord(null); + throw new Error("ADE GitHub App authorization expired. Re-authorize ADE with GitHub."); + } + if (!refreshInFlight) { + const epochAtStart = authEpoch; + refreshInFlight = refreshGitHubAppUserToken({ + clientId: ADE_GITHUB_APP_CLIENT_ID, + refreshToken: record.refreshToken, + fetchImpl: (input, init) => args.fetchImpl(String(input), init), + userAgent: args.userAgent, + fetchUserLogin: fetchAppUserLogin, + }).then((refreshed) => { + if (authEpoch === epochAtStart) persistAppUserTokenRecord(refreshed); + return refreshed; + }).finally(() => { + refreshInFlight = null; + }); + } + const refreshed = await refreshInFlight; + return refreshed.accessToken; + }; + + const startDeviceAuth = async (): Promise => { + pruneExpiredDeviceAuthSessions(); + const device = await startGitHubAppDeviceFlow({ + clientId: ADE_GITHUB_APP_CLIENT_ID, + fetchImpl: (input, init) => args.fetchImpl(String(input), init), + userAgent: args.userAgent, + }); + const sessionId = randomUUID(); + appDeviceAuthSessions.set(sessionId, { ...device, sessionId }); + return { + sessionId, + userCode: device.userCode, + verificationUri: device.verificationUri, + verificationUriComplete: device.verificationUriComplete, + expiresAt: device.expiresAt, + intervalSec: device.intervalSec, + }; + }; + + const pollDeviceAuth = async (pollArgs: { sessionId: string }): Promise => { + const requestedSessionExpired = pruneExpiredDeviceAuthSessions(pollArgs.sessionId); + const session = appDeviceAuthSessions.get(pollArgs.sessionId); + if (!session) { + if (requestedSessionExpired) { + return { + status: "expired", + intervalSec: null, + message: "GitHub device authorization expired.", + authStatus: appUserAuthStatus(), + }; + } + return { + status: "error", + intervalSec: null, + message: "GitHub device authorization session was not found.", + authStatus: appUserAuthStatus(), + }; + } + if (Date.parse(session.expiresAt) <= Date.now()) { + appDeviceAuthSessions.delete(pollArgs.sessionId); + return { + status: "expired", + intervalSec: null, + message: "GitHub device authorization expired.", + authStatus: appUserAuthStatus(), + }; + } + const result = await pollGitHubAppDeviceFlow({ + clientId: ADE_GITHUB_APP_CLIENT_ID, + deviceCode: session.deviceCode, + intervalSec: session.intervalSec, + fetchImpl: (input, init) => args.fetchImpl(String(input), init), + userAgent: args.userAgent, + fetchUserLogin: fetchAppUserLogin, + }); + if (result.status === "pending" || result.status === "slow_down") { + session.intervalSec = result.intervalSec; + appDeviceAuthSessions.set(session.sessionId, session); + return { + status: result.status, + intervalSec: result.intervalSec, + message: result.message, + authStatus: appUserAuthStatus(), + }; + } + appDeviceAuthSessions.delete(pollArgs.sessionId); + if (result.status === "authorized") { + persistAppUserTokenRecord(result.token); + return { + status: "authorized", + intervalSec: null, + message: null, + authStatus: appUserAuthStatus(), + }; + } + return { + status: result.status, + intervalSec: null, + message: result.message, + authStatus: appUserAuthStatus({ error: result.message }), + }; + }; + + const clearAuth = (): GitHubAppUserAuthStatus => { + authEpoch += 1; + persistAppUserTokenRecord(null); + appDeviceAuthSessions.clear(); + return appUserAuthStatus(); + }; + + return { + getAuthStatus: appUserAuthStatus, + startDeviceAuth, + pollDeviceAuth, + clearAuth, + getValidTokenForRelay: getValidAppUserTokenForRelay, + auditLog, + }; +} diff --git a/apps/desktop/src/main/services/github/githubRelayConfig.ts b/apps/desktop/src/main/services/github/githubRelayConfig.ts index 9a8f5b190..655cfc8d2 100644 --- a/apps/desktop/src/main/services/github/githubRelayConfig.ts +++ b/apps/desktop/src/main/services/github/githubRelayConfig.ts @@ -31,6 +31,28 @@ export type GitHubRelayConfig = { configured: boolean; }; +export type GitHubRelayHostedAuthTokenResolution = + | { + ok: true; + token: string; + } + | { + ok: false; + error: string; + }; + +export type GitHubRelayAuthAuditLog = (event: string, metadata: Record) => void; + +export function createGitHubRelayAuthAuditLog(emit: (event: string, metadata: Record) => void): GitHubRelayAuthAuditLog { + const seen = new Set(); + return (event, metadata) => { + const key = event + ":" + String(metadata.route ?? "") + ":" + String(metadata.repo ?? "") + ":" + String(metadata.tokenSource ?? ""); + if (seen.has(key)) return; + seen.add(key); + emit(event, metadata); + }; +} + function firstEnvValue(keys: readonly string[]): string | null { for (const key of keys) { const value = process.env[key]?.trim(); @@ -85,6 +107,22 @@ export function shouldUseLegacyGitHubRelayProjectRoute( return !config.usesHostedDefault; } +export function resolveHostedGitHubRelayAuthToken(args: { + githubAppUserToken?: string | null; +}): GitHubRelayHostedAuthTokenResolution { + const githubAppUserToken = args.githubAppUserToken?.trim(); + if (!githubAppUserToken) { + return { + ok: false, + error: "Authorize the ADE GitHub App with GitHub before using the hosted relay.", + }; + } + return { + ok: true, + token: githubAppUserToken, + }; +} + function baseStatus(repo: GitHubRepoRef | null, patch: Partial): GitHubAppInstallationStatus { return { repo, @@ -161,7 +199,8 @@ export async function fetchGitHubAppInstallationStatus(args: { secretReader?: GitHubRelaySecretReader | null; fetchImpl?: typeof fetch; forceRefresh?: boolean; - githubToken?: string | null; + githubAppUserToken?: string | null; + auditLog?: GitHubRelayAuthAuditLog | null; }): Promise { const config = readGitHubRelayConfig(args.secretReader); if (!args.repo) { @@ -181,13 +220,23 @@ export async function fetchGitHubAppInstallationStatus(args: { try { const baseUrl = config.apiBaseUrl!.replace(/\/+$/, ""); - const githubToken = args.githubToken?.trim(); + const githubAppUserToken = args.githubAppUserToken?.trim(); const legacyAuthToken = gitHubRelayAuthorizationToken(config); const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); const url = useLegacyProjectRoute ? `${baseUrl}/projects/${encodeURIComponent(config.remoteProjectId!)}/github/repos/${encodeURIComponent(args.repo.owner)}/${encodeURIComponent(args.repo.name)}/status${args.forceRefresh ? "?refresh=1" : ""}` : `${baseUrl}/github/repos/${encodeURIComponent(args.repo.owner)}/${encodeURIComponent(args.repo.name)}/status${args.forceRefresh ? "?refresh=1" : ""}`; - const authToken = useLegacyProjectRoute ? legacyAuthToken : githubToken; + const hostedAuth = useLegacyProjectRoute + ? null + : resolveHostedGitHubRelayAuthToken({ githubAppUserToken }); + if (hostedAuth && !hostedAuth.ok) { + return baseStatus(args.repo, { + relayConfigured: true, + state: "error", + error: hostedAuth.error, + }); + } + const authToken = useLegacyProjectRoute ? legacyAuthToken : hostedAuth?.token ?? null; if (!authToken) { return baseStatus(args.repo, { relayConfigured: true, @@ -195,6 +244,14 @@ export async function fetchGitHubAppInstallationStatus(args: { error: "GitHub auth is required to check the ADE GitHub App installation.", }); } + if (hostedAuth?.ok) { + args.auditLog?.("github.hosted_relay_auth_token_used", { + origin: new URL(baseUrl).origin, + repo: `${args.repo.owner}/${args.repo.name}`, + tokenSource: "github-app-user-token", + route: "status", + }); + } const response = await (args.fetchImpl ?? fetch)(url, { method: "GET", headers: { diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index e57371128..bdbe9f52b 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -1314,7 +1314,7 @@ describe("githubService.getAppInstallationStatus", () => { resetMocks(); }); - it("reports that GitHub auth is required before checking the hosted relay", async () => { + it("reports that GitHub App user authorization is required before checking the hosted relay", async () => { const status = await makeService().getAppInstallationStatus({ owner: "acme", name: "repo" }); expect(status).toMatchObject({ @@ -1322,13 +1322,39 @@ describe("githubService.getAppInstallationStatus", () => { relayConfigured: true, installed: false, state: "error", - error: "GitHub auth is required to check the ADE GitHub App installation.", + error: "Authorize the ADE GitHub App with GitHub before using the hosted relay.", }); expect(mockFetch).not.toHaveBeenCalled(); }); - it("checks the hosted relay with the user's existing GitHub token", async () => { + it("does not use the user's existing GitHub token for hosted relay checks", async () => { process.env.ADE_GITHUB_TOKEN = "ghp_user_token"; + + const status = await makeService().getAppInstallationStatus({ owner: "acme", name: "repo" }); + + expect(status).toMatchObject({ + repo: { owner: "acme", name: "repo" }, + relayConfigured: true, + installed: false, + state: "error", + error: "Authorize the ADE GitHub App with GitHub before using the hosted relay.", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("checks the hosted relay with a GitHub App user token", async () => { + process.env.ADE_GITHUB_TOKEN = "ghp_user_token"; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: "2999-01-01T00:00:00.000Z", + refreshToken: "ghr_refresh_token", + refreshTokenExpiresAt: "2999-06-01T00:00:00.000Z", + userLogin: "octocat", + updatedAt: "2026-06-30T00:00:00.000Z", + })); mockFetch.mockResolvedValueOnce(jsonResponse(200, { installed: true, state: "configured", @@ -1338,7 +1364,9 @@ describe("githubService.getAppInstallationStatus", () => { checkedAt: "2026-06-30T00:00:01.000Z", })); - const status = await makeService().getAppInstallationStatus({ owner: "acme", name: "repo" }); + const status = await makeService({ + credentialStore, + }).getAppInstallationStatus({ owner: "acme", name: "repo" }); expect(status).toMatchObject({ repo: { owner: "acme", name: "repo" }, @@ -1353,7 +1381,7 @@ describe("githubService.getAppInstallationStatus", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - authorization: "Bearer ghp_user_token", + authorization: "Bearer ghu_app_user_token", }), }), ); @@ -1429,6 +1457,162 @@ describe("githubService.getAppInstallationStatus", () => { }); }); +describe("githubService GitHub App user authorization", () => { + beforeEach(() => { + resetMocks(); + }); + + it("stores the GitHub App user token returned by device flow polling", async () => { + const credentialStore = new MemoryCredentialStore(); + const service = makeService({ credentialStore }); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + device_code: "device-code", + user_code: "ADE-CODE", + verification_uri: "https://github.com/login/device", + verification_uri_complete: "https://github.com/login/device?user_code=ADE-CODE", + expires_in: 900, + interval: 1, + })) + .mockResolvedValueOnce(jsonResponse(200, { + access_token: "ghu_app_user_token", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_refresh_token", + refresh_token_expires_in: 15_552_000, + })) + .mockResolvedValueOnce(jsonResponse(200, { login: "octocat" })); + + const start = await service.startAppUserDeviceAuth(); + const poll = await service.pollAppUserDeviceAuth({ sessionId: start.sessionId }); + + expect(start).toMatchObject({ + userCode: "ADE-CODE", + verificationUri: "https://github.com/login/device", + intervalSec: 1, + }); + expect(poll).toMatchObject({ + status: "authorized", + authStatus: { + tokenStored: true, + userLogin: "octocat", + }, + }); + expect(JSON.parse(credentialStore.getSync("github.appUserToken.v1") ?? "{}")).toMatchObject({ + accessToken: "ghu_app_user_token", + refreshToken: "ghr_refresh_token", + userLogin: "octocat", + }); + }); + + it("refreshes an expiring GitHub App user token before using it for the relay", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_old_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 10_000).toISOString(), + refreshToken: "ghr_refresh_token", + refreshTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + access_token: "ghu_new_token", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_new_refresh_token", + refresh_token_expires_in: 15_552_000, + })) + .mockResolvedValueOnce(jsonResponse(200, { login: "octocat" })); + + await expect(makeService({ credentialStore }).getAppUserTokenForRelay()).resolves.toBe("ghu_new_token"); + expect(JSON.parse(credentialStore.getSync("github.appUserToken.v1") ?? "{}")).toMatchObject({ + accessToken: "ghu_new_token", + refreshToken: "ghr_new_refresh_token", + }); + }); + + it("shares a single refresh across concurrent relay token requests", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_old_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 10_000).toISOString(), + refreshToken: "ghr_refresh_token", + refreshTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + let refreshCalls = 0; + mockFetch.mockImplementation(async (input: unknown) => { + if (String(input) === "https://github.com/login/oauth/access_token") { + refreshCalls += 1; + return jsonResponse(200, { + access_token: "ghu_new_token", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_new_refresh_token", + refresh_token_expires_in: 15_552_000, + }); + } + return jsonResponse(200, { login: "octocat" }); + }); + + const service = makeService({ credentialStore }); + const [first, second] = await Promise.all([ + service.getAppUserTokenForRelay(), + service.getAppUserTokenForRelay(), + ]); + + expect(first).toBe("ghu_new_token"); + expect(second).toBe("ghu_new_token"); + expect(refreshCalls).toBe(1); + expect(JSON.parse(credentialStore.getSync("github.appUserToken.v1") ?? "{}")).toMatchObject({ + accessToken: "ghu_new_token", + }); + }); + + it("does not re-persist a refreshed token after the user clears authorization", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_old_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 10_000).toISOString(), + refreshToken: "ghr_refresh_token", + refreshTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + let resolveRefresh!: (response: Response) => void; + const pendingRefresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + mockFetch.mockImplementation((input: unknown) => { + if (String(input) === "https://github.com/login/oauth/access_token") return pendingRefresh; + return Promise.resolve(jsonResponse(200, { login: "octocat" })); + }); + + const service = makeService({ credentialStore }); + const tokenPromise = service.getAppUserTokenForRelay(); + service.clearAppUserAuth(); + resolveRefresh(jsonResponse(200, { + access_token: "ghu_new_token", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_new_refresh_token", + refresh_token_expires_in: 15_552_000, + })); + + await expect(tokenPromise).resolves.toBe("ghu_new_token"); + expect(credentialStore.getSync("github.appUserToken.v1")).toBeNull(); + expect(service.getAppUserAuthStatus()).toMatchObject({ tokenStored: false, userLogin: null }); + }); +}); + describe("fetchAdeLatestRelease", () => { function releaseResponse(status: number, body: unknown): Response { return { diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 657726291..262b6ef2d 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -5,12 +5,21 @@ import { spawnSync } from "node:child_process"; import { safeStorage } from "electron"; import type { Logger } from "../logging/logger"; import { runGit } from "../git/git"; -import type { GitHubAppInstallationStatus, GitHubAutolink, GitHubRepoRef, GitHubStatus } from "../../../shared/types"; +import type { + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, + GitHubAppInstallationStatus, + GitHubAppUserAuthStatus, + GitHubAutolink, + GitHubRepoRef, + GitHubStatus, +} from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { getGitHubTokenAccessState, parseGitHubScopeHeaders } from "../../../shared/githubScopes"; import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig"; +import { createGitHubAppUserAuthService } from "./githubAppUserAuthService"; import { nowIso, asString } from "../shared/utils"; @@ -349,6 +358,12 @@ export function createGithubService({ const legacyTokenPath = path.join(legacyGithubStateDir, AUTH_STORE_FILE_NAME); const githubStateDir = path.join(appDataDir, "secrets", "github"); const tokenPath = path.join(githubStateDir, AUTH_STORE_FILE_NAME); + const appUserAuth = createGitHubAppUserAuthService({ + credentialStore, + logger, + fetchImpl: (input, init) => fetchGitHub(input, init ?? {}), + userAgent: "ade-desktop", + }); let tokenDecryptionFailed = false; let machineTokenReadFailed = false; @@ -1048,11 +1063,13 @@ export function createGithubService({ const owner = args.owner?.trim(); const name = args.name?.trim(); const repo = owner && name ? { owner, name } : await detectRepo(); + const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null); return fetchGitHubAppInstallationStatus({ repo, secretReader: githubRelaySecretReader, forceRefresh: args.forceRefresh === true, - githubToken: readAuthToken().token, + githubAppUserToken, + auditLog: appUserAuth.auditLog, }); }; @@ -1399,6 +1416,22 @@ export function createGithubService({ getStatus, + getAppUserAuthStatus(): GitHubAppUserAuthStatus { + return appUserAuth.getAuthStatus(); + }, + + async startAppUserDeviceAuth(): Promise { + return await appUserAuth.startDeviceAuth(); + }, + + async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { + return await appUserAuth.pollDeviceAuth(args); + }, + + clearAppUserAuth(): GitHubAppUserAuthStatus { + return appUserAuth.clearAuth(); + }, + setToken(token: string): void { persistToken(token); tokenDecryptionFailed = false; @@ -1427,6 +1460,10 @@ export function createGithubService({ return token; }, + async getAppUserTokenForRelay(): Promise { + return await appUserAuth.getValidTokenForRelay(); + }, + detectRepo, getAppInstallationStatus, apiRequest, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 16bf666f4..263c2dfec 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -165,6 +165,9 @@ import type { GitStashRefArgs, GitStashSummary, GitSyncArgs, + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, + GitHubAppUserAuthStatus, GitHubAutolink, GitHubRepoRef, GitHubStatus, @@ -7901,6 +7904,38 @@ export function registerIpc({ return status; }); + ipcMain.handle(IPC.githubGetAppUserAuthStatus, async (): Promise => { + const ctx = getCtx(); + return ctx.githubService.getAppUserAuthStatus(); + }); + + ipcMain.handle(IPC.githubStartAppUserDeviceAuth, async (): Promise => { + const ctx = getCtx(); + return await ctx.githubService.startAppUserDeviceAuth(); + }); + + ipcMain.handle( + IPC.githubPollAppUserDeviceAuth, + async (_event, arg: { sessionId?: string }): Promise => { + const ctx = getCtx(); + const sessionId = arg?.sessionId?.trim() ?? ""; + if (!sessionId) { + return { + status: "error", + intervalSec: null, + message: "GitHub device authorization session id is required.", + authStatus: ctx.githubService.getAppUserAuthStatus(), + }; + } + return await ctx.githubService.pollAppUserDeviceAuth({ sessionId }); + }, + ); + + ipcMain.handle(IPC.githubClearAppUserAuth, async (): Promise => { + const ctx = getCtx(); + return ctx.githubService.clearAppUserAuth(); + }); + const resolveGithubRepoRef = async ( githubService: ReturnType, arg?: { owner?: string; name?: string } | null diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 224fdd085..12940d827 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -304,7 +304,10 @@ import type { GitStashSummary, GitUpstreamSyncStatus, GitSyncArgs, + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, GitHubAppInstallationStatus, + GitHubAppUserAuthStatus, GitHubAutolink, GitHubRepoRef, GitHubStatus, @@ -1790,6 +1793,12 @@ declare global { }) => Promise<{ repo: GitHubRepoRef | null; hasOrigin: boolean }>; setToken: (token: string) => Promise; clearToken: () => Promise; + getAppUserAuthStatus: () => Promise; + startAppUserDeviceAuth: () => Promise; + pollAppUserDeviceAuth: (args: { + sessionId: string; + }) => Promise; + clearAppUserAuth: () => Promise; detectRepo: () => Promise<{ owner: string; name: string } | null>; listRepoAutolinks: (args?: { owner?: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a8f4d32d1..ae2968302 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -226,7 +226,10 @@ import type { GitStashSummary, GitUpstreamSyncStatus, GitSyncArgs, + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, GitHubAppInstallationStatus, + GitHubAppUserAuthStatus, GitHubAutolink, GitHubRepoRef, GitHubStatus, @@ -7077,6 +7080,34 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.githubClearToken), ), ), + getAppUserAuthStatus: async (): Promise => + callProjectRuntimeActionOr("github", "getAppUserAuthStatus", {}, () => + ipcRenderer.invoke(IPC.githubGetAppUserAuthStatus), + ), + startAppUserDeviceAuth: async (): Promise => + callProjectRuntimeActionOr("github", "startAppUserDeviceAuth", {}, () => + ipcRenderer.invoke(IPC.githubStartAppUserDeviceAuth), + ), + pollAppUserDeviceAuth: async (args: { sessionId: string }): Promise => + clearAround( + () => { + githubAppInstallationStatusCache.clear(); + }, + () => + callProjectRuntimeActionOr("github", "pollAppUserDeviceAuth", { args }, () => + ipcRenderer.invoke(IPC.githubPollAppUserDeviceAuth, args), + ), + ), + clearAppUserAuth: async (): Promise => + clearAround( + () => { + githubAppInstallationStatusCache.clear(); + }, + () => + callProjectRuntimeActionOr("github", "clearAppUserAuth", {}, () => + ipcRenderer.invoke(IPC.githubClearAppUserAuth), + ), + ), detectRepo: async (): Promise<{ owner: string; name: string } | null> => { const runtime = await callProjectRuntimeActionIfBound<{ owner: string; diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 9f323e651..6a049543c 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -5305,6 +5305,46 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { repoAccessError: null, connected: false, }), + getAppUserAuthStatus: resolved({ + configured: true, + tokenStored: true, + userLogin: "arul", + expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), + refreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(), + checkedAt: new Date().toISOString(), + error: null, + }), + startAppUserDeviceAuth: resolved({ + sessionId: "mock-github-device-session", + userCode: "ADE-MOCK", + verificationUri: "https://github.com/login/device", + verificationUriComplete: "https://github.com/login/device", + expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + intervalSec: 5, + }), + pollAppUserDeviceAuth: resolved({ + status: "authorized", + intervalSec: null, + message: null, + authStatus: { + configured: true, + tokenStored: true, + userLogin: "arul", + expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), + refreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(), + checkedAt: new Date().toISOString(), + error: null, + }, + }), + clearAppUserAuth: resolved({ + configured: true, + tokenStored: false, + userLogin: null, + expiresAt: null, + refreshTokenExpiresAt: null, + checkedAt: new Date().toISOString(), + error: null, + }), detectRepo: resolved({ owner: "arul28", name: "ADE" }), getAppInstallationStatus: resolved({ repo: { owner: "arul28", name: "ADE" }, @@ -5318,6 +5358,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { installationId: 123, repositorySelection: "all", lastSeenAt: new Date().toISOString(), + webhookEvents: ["installation", "installation_repositories", "pull_request"], + missingWebhookEvents: [], + webhookState: "active", + webhookLastSeenAt: new Date().toISOString(), checkedAt: new Date().toISOString(), error: null, }), diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx index 962662ce4..05fff204a 100644 --- a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx @@ -1,6 +1,11 @@ -import { useCallback, useEffect, useState, type CSSProperties } from "react"; -import type { GitHubAppInstallationStatus } from "../../../shared/types"; -import { ArrowClockwise, ArrowSquareOut, CheckCircle, WarningCircle, WebhooksLogo } from "@phosphor-icons/react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import type { + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, + GitHubAppInstallationStatus, + GitHubAppUserAuthStatus, +} from "../../../shared/types"; +import { ArrowClockwise, ArrowSquareOut, Check, CheckCircle, Copy, WarningCircle, WebhooksLogo } from "@phosphor-icons/react"; import { openExternalUrl } from "../../lib/openExternal"; import { COLORS, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; @@ -15,12 +20,23 @@ type GitHubAppInstallPanelProps = { export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstallPanelProps) { const compact = variant === "onboarding"; const [status, setStatus] = useState(null); + const [appAuth, setAppAuth] = useState(null); + const [deviceSession, setDeviceSession] = useState(null); + const [deviceMessage, setDeviceMessage] = useState(null); + const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); const [loading, setLoading] = useState(false); + const [authLoading, setAuthLoading] = useState(false); + const autoRenewCountRef = useRef(0); + const copyFeedbackTimeoutRef = useRef(null); + const appAuthRef = useRef(null); + appAuthRef.current = appAuth; const loadStatus = useCallback(async (forceRefresh = false) => { if (!window.ade?.github?.getAppInstallationStatus) return; setLoading(true); try { + const authStatus = await window.ade.github.getAppUserAuthStatus?.().catch(() => null); + setAppAuth(authStatus ?? null); setStatus(await window.ade.github.getAppInstallationStatus({ forceRefresh })); } catch (error) { setStatus({ @@ -47,11 +63,112 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall } }, []); + const startAppAuthorization = useCallback(async () => { + autoRenewCountRef.current = 0; + if (!window.ade?.github?.startAppUserDeviceAuth) return; + setAuthLoading(true); + setDeviceMessage(null); + setDeviceCodeCopied(false); + try { + const session = await window.ade.github.startAppUserDeviceAuth(); + setDeviceSession(session); + openExternalUrl(session.verificationUriComplete ?? session.verificationUri); + } catch (error) { + setDeviceMessage(error instanceof Error ? error.message : String(error)); + } finally { + setAuthLoading(false); + } + }, []); + + const copyDeviceCode = useCallback(async () => { + if (!deviceSession) return; + try { + await navigator.clipboard.writeText(deviceSession.userCode); + setDeviceCodeCopied(true); + if (copyFeedbackTimeoutRef.current != null) { + window.clearTimeout(copyFeedbackTimeoutRef.current); + } + copyFeedbackTimeoutRef.current = window.setTimeout(() => { + setDeviceCodeCopied(false); + copyFeedbackTimeoutRef.current = null; + }, 1500); + } catch (error) { + setDeviceMessage(error instanceof Error ? error.message : String(error)); + } + }, [deviceSession]); + + useEffect(() => { + if (!deviceSession || !window.ade?.github?.pollAppUserDeviceAuth) return; + let cancelled = false; + const timeout = window.setTimeout(async () => { + let result: GitHubAppDeviceAuthPollResult | null = null; + try { + result = await window.ade.github.pollAppUserDeviceAuth({ sessionId: deviceSession.sessionId }); + } catch (error) { + result = { + status: "error", + intervalSec: null, + message: error instanceof Error ? error.message : String(error), + authStatus: appAuthRef.current, + }; + } + if (cancelled || !result) return; + setAppAuth(result.authStatus); + if (result.status === "pending" || result.status === "slow_down") { + setDeviceMessage(result.message); + setDeviceSession({ ...deviceSession, intervalSec: result.intervalSec ?? deviceSession.intervalSec }); + return; + } + if (result.status === "expired") { + if (autoRenewCountRef.current >= 3 || !window.ade?.github?.startAppUserDeviceAuth) { + setDeviceSession(null); + setDeviceMessage("Code expired. Click Authorize ADE to get a new code."); + return; + } + try { + const nextSession = await window.ade.github.startAppUserDeviceAuth(); + if (cancelled) return; + autoRenewCountRef.current += 1; + setDeviceSession(nextSession); + setDeviceMessage("Previous code expired — use this new code."); + } catch (error) { + if (cancelled) return; + setDeviceSession(null); + setDeviceMessage(error instanceof Error ? error.message : String(error)); + } + return; + } + setDeviceSession(null); + setDeviceMessage(result.message); + if (result.status === "authorized") { + autoRenewCountRef.current = 0; + await loadStatus(true); + } + }, Math.max(1, deviceSession.intervalSec) * 1000); + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [deviceSession, loadStatus]); + + useEffect(() => { + setDeviceCodeCopied(false); + }, [deviceSession?.sessionId]); + + useEffect(() => { + return () => { + if (copyFeedbackTimeoutRef.current != null) { + window.clearTimeout(copyFeedbackTimeoutRef.current); + } + }; + }, []); + useEffect(() => { void loadStatus(false); }, [loadStatus]); - const view = statusView(status, loading); + const appAuthorized = appAuth?.tokenStored === true; + const view = statusView(status, loading, appAuthorized); const repoLabel = status?.repo ? `${status.repo.owner}/${status.repo.name}` : null; return ( @@ -78,11 +195,44 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall ))} + {deviceSession ? ( +
+

+ Enter this code at{" "} + +

+ + {deviceMessage ? null :

Waiting for GitHub authorization…

} +
+ ) : null} + {deviceMessage ?

{deviceMessage}

: null} + + {!appAuthorized && !deviceSession ?

One-time GitHub sign-off that lets ADE verify your repo access for instant PR updates.

: null} +
- {status?.installed ? null : ( + {!appAuthorized ? ( + ) : null} + {status?.installed ? null : ( + ) : null} {status?.installed ? null : (