Files
vibn-frontend/lib/integrations/github.ts

190 lines
6.2 KiB
TypeScript

/**
* GitHub OAuth integration helpers.
*
* Storage layout — fs_users.data.integrations.github:
* {
* login: "octocat",
* accessToken: <encryptSecret(token)>, // 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<TokenExchangeResult> {
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<GithubRepo[]> {
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<GithubRepo[]>;
}
// ─────────────────────────────────────────────────────────────────────────────
// 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<void> {
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<void> {
await query(
`UPDATE fs_users
SET data = data #- '{integrations,github}',
updated_at = NOW()
WHERE data->>'email' = $1`,
[email],
);
}