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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-02 19:45:50 -05:00
parent c560c608e2
commit 743a4fc677
13 changed files with 386 additions and 32 deletions

View file

@ -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=
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=

View file

@ -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<string | false | null | undefined>) {
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() {
<span className="sr-only">Open user menu</span>
<img
alt=""
src={user.imageUrl}
src={user.avatarUrl}
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
/>
</MenuButton>
@ -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) => (
<MenuItem key={item.name}>
<a
href={item.href}
className="block px-4 py-2 text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
<MenuItem>
<Form method="POST" action="/auth/logout">
<button
type="submit"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
{item.name}
</a>
</MenuItem>
))}
Sign out
</button>
</Form>
</MenuItem>
</MenuItems>
</Menu>
</div>
@ -181,7 +184,7 @@ export default function AppShell() {
<div className="shrink-0">
<img
alt=""
src={user.imageUrl}
src={user.avatarUrl}
className="size-10 rounded-full outline -outline-offset-1 outline-line-strong"
/>
</div>
@ -204,16 +207,15 @@ export default function AppShell() {
</button>
</div>
<div className="mt-3 space-y-1 px-2">
{userNavigation.map((item) => (
<Form method="POST" action="/auth/logout">
<DisclosureButton
key={item.name}
as="a"
href={item.href}
className="block rounded-md px-3 py-2 text-base font-medium text-fg-muted hover:bg-overlay hover:text-fg"
as="button"
type="submit"
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-fg-muted hover:bg-overlay hover:text-fg"
>
{item.name}
Sign out
</DisclosureButton>
))}
</Form>
</div>
</div>
</DisclosurePanel>

View file

@ -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;
}

View file

@ -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<SessionData>({
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<ReturnType<typeof getSession>>) {
const storage = getSessionStorage();
return storage.commitSession(session);
}
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
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;
}

View file

@ -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"),

View file

@ -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 });
}

View file

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-page">
<div className="w-full max-w-md rounded-lg border border-line-strong bg-panel p-8 shadow-sm">
<h1 className="text-xl font-semibold text-fg">Sign in to Arc</h1>
<p className="mt-2 text-sm text-fg-muted">
Authenticate with your GitHub account to continue.
</p>
<form method="POST" className="mt-6">
<button
type="submit"
className="w-full rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-500 focus:outline-2 focus:outline-offset-2 focus:outline-teal-500"
>
Sign in with GitHub
</button>
</form>
</div>
</div>
);
}

View file

@ -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) },
});
}

View file

@ -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");
}

View file

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-page">
<div className="w-full max-w-md rounded-lg border border-line-strong bg-panel p-8 shadow-sm">
{success ? (
<>
<h1 className="text-xl font-semibold text-fg">
GitHub App registered
</h1>
<p className="mt-2 text-sm text-fg-muted">
Credentials have been written to <code>.env</code>. Restart the
app to pick up the new configuration.
</p>
</>
) : (
<>
<h1 className="text-xl font-semibold text-red-500">
Setup failed
</h1>
<p className="mt-2 text-sm text-fg-muted">{error}</p>
</>
)}
</div>
</div>
);
}

View file

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-page">
<div className="w-full max-w-md rounded-lg border border-line-strong bg-panel p-8 shadow-sm">
<h1 className="text-xl font-semibold text-fg">Set up Arc</h1>
<p className="mt-2 text-sm text-fg-muted">
Register a GitHub App to enable OAuth login and repository access.
</p>
<form
method="POST"
action="https://github.com/settings/apps/new"
className="mt-6"
>
<input type="hidden" name="manifest" value={manifest} />
<button
type="submit"
className="w-full rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-500 focus:outline-2 focus:outline-offset-2 focus:outline-teal-500"
>
Register GitHub App
</button>
</form>
</div>
</div>
);
}

View file

@ -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",

View file

@ -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=="],