/** * GitHub OAuth integration helpers. * * Storage layout — fs_users.data.integrations.github: * { * login: "octocat", * accessToken: , // encrypted at rest * scope: "repo,read:user", * connectedAt: ISO, * } * * Tokens never leave the server. All API calls happen server-side; the * UI only ever sees the GitHub `login` and the list of repos. * * Scopes requested: `repo` (private+public read+write so we can mirror) * `read:user` (so we can show the connected username) */ import { encryptSecret, decryptSecret } from "@/lib/auth/secret-box"; import { query } from "@/lib/db-postgres"; const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? ""; const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? ""; const SCOPES = "repo,read:user"; export function isGithubOauthConfigured(): boolean { return CLIENT_ID.length > 0 && CLIENT_SECRET.length > 0; } /** Build the GitHub authorize URL. `state` MUST be unguessable per request. */ export function buildAuthorizeUrl(state: string, callbackUrl: string): string { const u = new URL("https://github.com/login/oauth/authorize"); u.searchParams.set("client_id", CLIENT_ID); u.searchParams.set("redirect_uri", callbackUrl); u.searchParams.set("scope", SCOPES); u.searchParams.set("state", state); u.searchParams.set("allow_signup", "true"); return u.toString(); } interface TokenExchangeResult { accessToken: string; scope: string; tokenType: string; } /** POST /login/oauth/access_token — exchange auth code for an access token. */ export async function exchangeCodeForToken( code: string, callbackUrl: string, ): Promise { const res = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code, redirect_uri: callbackUrl, }), }); const body = await res.json() as { access_token?: string; scope?: string; token_type?: string; error?: string; error_description?: string; }; if (!res.ok || !body.access_token) { throw new Error(body.error_description || body.error || "GitHub token exchange failed"); } return { accessToken: body.access_token, scope: body.scope ?? "", tokenType: body.token_type ?? "bearer", }; } /** GET /user — fetch the authenticated user's login + name. */ export async function getAuthenticatedUser(token: string) { const res = await fetch("https://api.github.com/user", { headers: { "Authorization": `Bearer ${token}`, "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }, }); if (!res.ok) { throw new Error(`GitHub /user failed: ${res.status} ${res.statusText}`); } return res.json() as Promise<{ login: string; name: string | null; avatar_url: string }>; } interface GithubRepo { id: number; name: string; full_name: string; private: boolean; description: string | null; default_branch: string; html_url: string; pushed_at: string | null; language: string | null; fork: boolean; } /** * GET /user/repos — list repos the authenticated user can access. * Returns at most `perPage` repos; we cap at 100 (GitHub max) and don't * paginate further. The picker UI does client-side filter on top. */ export async function listUserRepos(token: string, perPage = 100): Promise { const u = new URL("https://api.github.com/user/repos"); u.searchParams.set("per_page", String(perPage)); u.searchParams.set("sort", "pushed"); u.searchParams.set("affiliation", "owner,collaborator,organization_member"); const res = await fetch(u.toString(), { headers: { "Authorization": `Bearer ${token}`, "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }, }); if (!res.ok) { throw new Error(`GitHub /user/repos failed: ${res.status} ${res.statusText}`); } return res.json() as Promise; } // ───────────────────────────────────────────────────────────────────────────── // fs_users persistence // ───────────────────────────────────────────────────────────────────────────── export interface StoredGithubIntegration { login: string; accessToken: string; // encrypted scope: string; connectedAt: string; } /** Write the integration to fs_users.data.integrations.github. */ export async function persistGithubIntegration( email: string, login: string, plainToken: string, scope: string, ): Promise { const blob: StoredGithubIntegration = { login, accessToken: encryptSecret(plainToken), scope, connectedAt: new Date().toISOString(), }; await query( `UPDATE fs_users SET data = jsonb_set( jsonb_set(data, '{integrations}', COALESCE(data->'integrations','{}'::jsonb), true), '{integrations,github}', $2::jsonb, true ), updated_at = NOW() WHERE data->>'email' = $1`, [email, JSON.stringify(blob)], ); } /** Read + decrypt the GitHub access token for a user, if any. */ export async function loadGithubIntegration( email: string, ): Promise<{ login: string; token: string; scope: string } | null> { const rows = await query<{ blob: StoredGithubIntegration | null }>( `SELECT data->'integrations'->'github' AS blob FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, [email], ); const blob = rows[0]?.blob; if (!blob || !blob.accessToken) return null; try { return { login: blob.login, token: decryptSecret(blob.accessToken), scope: blob.scope }; } catch { return null; } } /** Drop the stored integration. */ export async function disconnectGithubIntegration(email: string): Promise { await query( `UPDATE fs_users SET data = data #- '{integrations,github}', updated_at = NOW() WHERE data->>'email' = $1`, [email], ); }