From 743a4fc6776ba8d15e29ee11564860751bb96d5e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 2 Mar 2026 19:45:50 -0500 Subject: [PATCH] Add GitHub App manifest registration and OAuth login Adds one-click GitHub App setup via the manifest flow, OAuth login via Arctic, and cookie-based sessions so the app shell shows the real authenticated user instead of a hardcoded placeholder. Co-Authored-By: Claude Opus 4.6 --- .env.example | 10 ++- apps/arc-web/app/layouts/app-shell.tsx | 58 ++++++++------- apps/arc-web/app/lib/github.server.ts | 16 ++++ apps/arc-web/app/lib/session.server.ts | 59 +++++++++++++++ apps/arc-web/app/routes.ts | 5 ++ apps/arc-web/app/routes/auth-callback.tsx | 45 +++++++++++ apps/arc-web/app/routes/auth-login.tsx | 36 +++++++++ apps/arc-web/app/routes/auth-logout.tsx | 10 +++ apps/arc-web/app/routes/redirect-home.tsx | 12 ++- apps/arc-web/app/routes/setup-callback.tsx | 87 ++++++++++++++++++++++ apps/arc-web/app/routes/setup.tsx | 60 +++++++++++++++ apps/arc-web/package.json | 5 +- bun.lock | 15 ++++ 13 files changed, 386 insertions(+), 32 deletions(-) create mode 100644 apps/arc-web/app/lib/github.server.ts create mode 100644 apps/arc-web/app/lib/session.server.ts create mode 100644 apps/arc-web/app/routes/auth-callback.tsx create mode 100644 apps/arc-web/app/routes/auth-login.tsx create mode 100644 apps/arc-web/app/routes/auth-logout.tsx create mode 100644 apps/arc-web/app/routes/setup-callback.tsx create mode 100644 apps/arc-web/app/routes/setup.tsx diff --git a/.env.example b/.env.example index 086f2b3a7..a15540478 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,15 @@ export KIMI_API_KEY= export MINIMAX_API_KEY= export OPENAI_API_KEY= export ZAI_API_KEY= + export ARC_JWT_PRIVATE_KEY= export ARC_JWT_PUBLIC_KEY= export ARC_API_BASE_URL= -export ARC_INSECURE_DISABLE_AUTHENTICATION= \ No newline at end of file +export ARC_INSECURE_DISABLE_AUTHENTICATION= + +export SESSION_SECRET= +export GITHUB_APP_ID= +export GITHUB_APP_CLIENT_ID= +export GITHUB_APP_CLIENT_SECRET= +export GITHUB_APP_WEBHOOK_SECRET= +export GITHUB_APP_PRIVATE_KEY= \ No newline at end of file diff --git a/apps/arc-web/app/layouts/app-shell.tsx b/apps/arc-web/app/layouts/app-shell.tsx index 1abe1570f..1f13e024d 100644 --- a/apps/arc-web/app/layouts/app-shell.tsx +++ b/apps/arc-web/app/layouts/app-shell.tsx @@ -20,15 +20,19 @@ import { SunIcon, XMarkIcon, } from "@heroicons/react/24/outline"; -import { Link, Outlet, useLocation, useMatches } from "react-router"; +import { Form, Link, Outlet, redirect, useLocation, useMatches } from "react-router"; import { useTheme } from "../lib/theme"; +import { isGitHubAppConfigured } from "../lib/github.server"; +import { requireUser } from "../lib/session.server"; +import type { Route } from "./+types/app-shell"; -const user = { - name: "Tom Cook", - email: "tom@example.com", - imageUrl: - "https://avatars.githubusercontent.com/u/19?v=4", -}; +export async function loader({ request }: Route.LoaderArgs) { + if (!isGitHubAppConfigured()) { + throw redirect("/setup"); + } + const user = await requireUser(request); + return { user }; +} const navigation = [ { name: "Start", href: "/start", icon: SparklesIcon }, @@ -40,13 +44,12 @@ const navigation = [ { name: "Settings", href: "/settings", icon: Cog6ToothIcon }, ]; -const userNavigation = [{ name: "Sign out", href: "#" }]; - function classNames(...classes: Array) { return classes.filter(Boolean).join(" "); } -export default function AppShell() { +export default function AppShell({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; const { pathname } = useLocation(); const matches = useMatches(); const { theme, toggle } = useTheme(); @@ -113,7 +116,7 @@ export default function AppShell() { Open user menu @@ -122,16 +125,16 @@ export default function AppShell() { transition className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in" > - {userNavigation.map((item) => ( - - +
+ +
+
@@ -181,7 +184,7 @@ export default function AppShell() {
@@ -204,16 +207,15 @@ export default function AppShell() {
- {userNavigation.map((item) => ( +
- {item.name} + Sign out - ))} +
diff --git a/apps/arc-web/app/lib/github.server.ts b/apps/arc-web/app/lib/github.server.ts new file mode 100644 index 000000000..5e70dde95 --- /dev/null +++ b/apps/arc-web/app/lib/github.server.ts @@ -0,0 +1,16 @@ +import { GitHub, generateState } from "arctic"; + +export { generateState }; + +export function getGitHubOAuth() { + const clientId = process.env.GITHUB_APP_CLIENT_ID; + const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET; + if (!clientId || !clientSecret) { + throw new Error("GitHub App is not configured"); + } + return new GitHub(clientId, clientSecret, null); +} + +export function isGitHubAppConfigured(): boolean { + return !!process.env.GITHUB_APP_CLIENT_ID; +} diff --git a/apps/arc-web/app/lib/session.server.ts b/apps/arc-web/app/lib/session.server.ts new file mode 100644 index 000000000..2fc597a6b --- /dev/null +++ b/apps/arc-web/app/lib/session.server.ts @@ -0,0 +1,59 @@ +import { createCookieSessionStorage, redirect } from "react-router"; + +interface SessionData { + githubLogin: string; + name: string; + email: string; + avatarUrl: string; + accessToken: string; +} + +function getSessionStorage() { + const secret = process.env.SESSION_SECRET; + if (!secret) { + throw new Error("SESSION_SECRET is not set"); + } + return createCookieSessionStorage({ + cookie: { + name: "__arc_session", + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + secrets: [secret], + path: "/", + }, + }); +} + +export async function getSession(request: Request) { + const storage = getSessionStorage(); + return storage.getSession(request.headers.get("Cookie")); +} + +export async function commitSession(session: Awaited>) { + const storage = getSessionStorage(); + return storage.commitSession(session); +} + +export async function destroySession(session: Awaited>) { + const storage = getSessionStorage(); + return storage.destroySession(session); +} + +export async function getUser(request: Request) { + const session = await getSession(request); + const githubLogin = session.get("githubLogin"); + if (!githubLogin) return null; + return { + githubLogin, + name: session.get("name") ?? githubLogin, + email: session.get("email") ?? "", + avatarUrl: session.get("avatarUrl") ?? "", + }; +} + +export async function requireUser(request: Request) { + const user = await getUser(request); + if (!user) throw redirect("/auth/login"); + return user; +} diff --git a/apps/arc-web/app/routes.ts b/apps/arc-web/app/routes.ts index 092f0c94f..4c839b183 100644 --- a/apps/arc-web/app/routes.ts +++ b/apps/arc-web/app/routes.ts @@ -7,6 +7,11 @@ import { export default [ index("routes/redirect-home.tsx"), + route("setup", "routes/setup.tsx"), + route("setup/callback", "routes/setup-callback.tsx"), + route("auth/login", "routes/auth-login.tsx"), + route("auth/callback", "routes/auth-callback.tsx"), + route("auth/logout", "routes/auth-logout.tsx"), layout("layouts/app-shell.tsx", [ route("start", "routes/start.tsx"), route("sessions/:sessionId", "routes/session-detail.tsx"), diff --git a/apps/arc-web/app/routes/auth-callback.tsx b/apps/arc-web/app/routes/auth-callback.tsx new file mode 100644 index 000000000..054b4cde1 --- /dev/null +++ b/apps/arc-web/app/routes/auth-callback.tsx @@ -0,0 +1,45 @@ +import { redirect } from "react-router"; +import { getGitHubOAuth } from "../lib/github.server"; +import { getSession, commitSession } from "../lib/session.server"; +import type { Route } from "./+types/auth-callback"; + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + const cookies = request.headers.get("Cookie") ?? ""; + const stateMatch = cookies.match(/arc_oauth_state=([^;]+)/); + const storedState = stateMatch?.[1]; + + if (!code || !state || state !== storedState) { + throw redirect("/auth/login"); + } + + const github = getGitHubOAuth(); + const tokens = await github.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + + const userResponse = await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const profile = (await userResponse.json()) as { + login: string; + name: string | null; + email: string | null; + avatar_url: string; + }; + + const session = await getSession(request); + session.set("githubLogin", profile.login); + session.set("name", profile.name ?? profile.login); + session.set("email", profile.email ?? ""); + session.set("avatarUrl", profile.avatar_url); + session.set("accessToken", accessToken); + + const headers = new Headers(); + headers.append("Set-Cookie", await commitSession(session)); + headers.append("Set-Cookie", "arc_oauth_state=; HttpOnly; Path=/; Max-Age=0"); + + return redirect("/start", { headers }); +} diff --git a/apps/arc-web/app/routes/auth-login.tsx b/apps/arc-web/app/routes/auth-login.tsx new file mode 100644 index 000000000..ffc57a0bc --- /dev/null +++ b/apps/arc-web/app/routes/auth-login.tsx @@ -0,0 +1,36 @@ +import { redirect } from "react-router"; +import { getGitHubOAuth, generateState } from "../lib/github.server"; +import type { Route } from "./+types/auth-login"; + +export function action({ request }: Route.ActionArgs) { + const github = getGitHubOAuth(); + const state = generateState(); + const authUrl = github.createAuthorizationURL(state, ["read:user", "user:email"]); + + return redirect(authUrl.toString(), { + headers: { + "Set-Cookie": `arc_oauth_state=${state}; HttpOnly; Path=/; Max-Age=600; SameSite=Lax`, + }, + }); +} + +export default function AuthLogin() { + return ( +
+
+

Sign in to Arc

+

+ Authenticate with your GitHub account to continue. +

+
+ +
+
+
+ ); +} diff --git a/apps/arc-web/app/routes/auth-logout.tsx b/apps/arc-web/app/routes/auth-logout.tsx new file mode 100644 index 000000000..86b8f4596 --- /dev/null +++ b/apps/arc-web/app/routes/auth-logout.tsx @@ -0,0 +1,10 @@ +import { redirect } from "react-router"; +import { getSession, destroySession } from "../lib/session.server"; +import type { Route } from "./+types/auth-logout"; + +export async function action({ request }: Route.ActionArgs) { + const session = await getSession(request); + return redirect("/auth/login", { + headers: { "Set-Cookie": await destroySession(session) }, + }); +} diff --git a/apps/arc-web/app/routes/redirect-home.tsx b/apps/arc-web/app/routes/redirect-home.tsx index d9bec8f3c..9afc0ee96 100644 --- a/apps/arc-web/app/routes/redirect-home.tsx +++ b/apps/arc-web/app/routes/redirect-home.tsx @@ -1,5 +1,15 @@ import { redirect } from "react-router"; +import { isGitHubAppConfigured } from "../lib/github.server"; +import { getUser } from "../lib/session.server"; +import type { Route } from "./+types/redirect-home"; -export function loader() { +export async function loader({ request }: Route.LoaderArgs) { + if (!isGitHubAppConfigured()) { + return redirect("/setup"); + } + const user = await getUser(request); + if (!user) { + return redirect("/auth/login"); + } return redirect("/start"); } diff --git a/apps/arc-web/app/routes/setup-callback.tsx b/apps/arc-web/app/routes/setup-callback.tsx new file mode 100644 index 000000000..eefee362f --- /dev/null +++ b/apps/arc-web/app/routes/setup-callback.tsx @@ -0,0 +1,87 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { Route } from "./+types/setup-callback"; + +const ENV_PATH = resolve(import.meta.dirname, "../../../../.env"); + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + if (!code) { + return { success: false, error: "Missing code parameter" }; + } + + const response = await fetch( + `https://api.github.com/app-manifests/${code}/conversions`, + { method: "POST", headers: { Accept: "application/vnd.github+json" } } + ); + + if (!response.ok) { + const body = await response.text(); + return { + success: false, + error: `GitHub API error: ${response.status} ${body}`, + }; + } + + const data = (await response.json()) as { + id: number; + client_id: string; + client_secret: string; + webhook_secret: string; + pem: string; + }; + + const sessionSecret = randomBytes(32).toString("hex"); + + let existing = ""; + try { + existing = await readFile(ENV_PATH, "utf-8"); + } catch { + // file doesn't exist yet + } + + const newVars = [ + `export SESSION_SECRET=${sessionSecret}`, + `export GITHUB_APP_ID=${data.id}`, + `export GITHUB_APP_CLIENT_ID=${data.client_id}`, + `export GITHUB_APP_CLIENT_SECRET=${data.client_secret}`, + `export GITHUB_APP_WEBHOOK_SECRET=${data.webhook_secret}`, + `export GITHUB_APP_PRIVATE_KEY="${data.pem}"`, + ].join("\n"); + + const envContent = existing ? `${existing.trimEnd()}\n\n${newVars}\n` : `${newVars}\n`; + await writeFile(ENV_PATH, envContent, "utf-8"); + + return { success: true, error: null }; +} + +export default function SetupCallback({ loaderData }: Route.ComponentProps) { + const { success, error } = loaderData; + + return ( +
+
+ {success ? ( + <> +

+ GitHub App registered +

+

+ Credentials have been written to .env. Restart the + app to pick up the new configuration. +

+ + ) : ( + <> +

+ Setup failed +

+

{error}

+ + )} +
+
+ ); +} diff --git a/apps/arc-web/app/routes/setup.tsx b/apps/arc-web/app/routes/setup.tsx new file mode 100644 index 000000000..1433a599b --- /dev/null +++ b/apps/arc-web/app/routes/setup.tsx @@ -0,0 +1,60 @@ +import { redirect } from "react-router"; +import { isGitHubAppConfigured } from "../lib/github.server"; +import type { Route } from "./+types/setup"; + +export function loader({ request }: Route.LoaderArgs) { + if (isGitHubAppConfigured()) { + return redirect("/"); + } + + const url = new URL(request.url); + const baseUrl = `${url.protocol}//${url.host}`; + const suffix = Math.random().toString(16).slice(2, 8); + + const manifest = { + name: `Arc-${suffix}`, + url: baseUrl, + redirect_url: `${baseUrl}/setup/callback`, + callback_urls: [`${baseUrl}/auth/callback`], + setup_url: `${baseUrl}/setup/callback`, + public: false, + default_permissions: { + contents: "write", + metadata: "read", + pull_requests: "write", + checks: "write", + issues: "write", + }, + default_events: [] as string[], + }; + + return { manifest: JSON.stringify(manifest), baseUrl }; +} + +export default function Setup({ loaderData }: Route.ComponentProps) { + const { manifest } = loaderData; + + return ( +
+
+

Set up Arc

+

+ Register a GitHub App to enable OAuth login and repository access. +

+
+ + +
+
+
+ ); +} diff --git a/apps/arc-web/package.json b/apps/arc-web/package.json index 8a1e888cc..bcf3a6a9d 100644 --- a/apps/arc-web/package.json +++ b/apps/arc-web/package.json @@ -16,15 +16,16 @@ "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@pierre/diffs": "^1.0.11", + "@qltysh/arc-api-client": "workspace:*", "@react-router/node": "7.12.0", "@react-router/serve": "7.12.0", "@viz-js/viz": "^3.24.0", + "arctic": "^3.7.0", "isbot": "^5.1.31", "jose": "^6.1.3", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router": "7.12.0", - "@qltysh/arc-api-client": "workspace:*" + "react-router": "7.12.0" }, "devDependencies": { "@react-router/dev": "7.12.0", diff --git a/bun.lock b/bun.lock index 17204edbc..82d1aa85b 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "@react-router/node": "7.12.0", "@react-router/serve": "7.12.0", "@viz-js/viz": "^3.24.0", + "arctic": "^3.7.0", "isbot": "^5.1.31", "jose": "^6.1.3", "react": "^19.2.4", @@ -185,6 +186,16 @@ "@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="], + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], + + "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], + + "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + "@pierre/diffs": ["@pierre/diffs@1.0.11", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw=="], "@qltysh/arc-api-client": ["@qltysh/arc-api-client@workspace:packages/arc-api-client"], @@ -337,6 +348,8 @@ "arc-web": ["arc-web@workspace:apps/arc-web"], + "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], @@ -741,6 +754,8 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],