190 lines
6.2 KiB
TypeScript
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],
|
|
);
|
|
}
|