audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
1import { hmacHex, timingSafeEqualHex } from "./crypto-utils";
2
3const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
4const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
5const GITHUB_API = "https://api.github.com";
6
7export const SESSION_COOKIE = "gh_attest_session";
8export const STATE_COOKIE = "gh_attest_oauth_state";
9const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12 hours
10export const STATE_TTL_SECONDS = 60 * 10; // 10 minutes
11
12export interface SessionPayload {
13 userId: number;
14 login: string;
15 /** The installation currently being viewed. */
16 installationId: number;
17 /**
18 * Every installation this user may view, captured at login. Kept inside the
19 * signed payload so switching can be authorised without re-querying GitHub
20 * and without trusting a client-supplied id. Optional so sessions issued
21 * before the switcher existed still verify.
22 */
23 installationIds?: number[];
24 exp: number;
25}
26
27export function buildAuthorizeUrl(env: Env, redirectUri: string, state: string): string {
28 const url = new URL(GITHUB_AUTHORIZE_URL);
29 url.searchParams.set("client_id", env.GITHUB_APP_CLIENT_ID);
30 url.searchParams.set("redirect_uri", redirectUri);
31 url.searchParams.set("state", state);
32 return url.toString();
33}
34
35interface GithubTokenResponse {
36 access_token?: string;
37 error?: string;
38 error_description?: string;
39}
40
41export async function exchangeCodeForToken(env: Env, code: string, redirectUri: string): Promise<string> {
42 const res = await fetch(GITHUB_TOKEN_URL, {
43 method: "POST",
44 headers: { Accept: "application/json", "Content-Type": "application/json" },
45 body: JSON.stringify({
46 client_id: env.GITHUB_APP_CLIENT_ID,
47 client_secret: env.GITHUB_APP_CLIENT_SECRET,
48 code,
49 redirect_uri: redirectUri,
50 }),
51 });
52
53 const data = (await res.json()) as GithubTokenResponse;
54 if (!res.ok || !data.access_token) {
55 throw new Error(data.error_description ?? `GitHub OAuth token exchange failed (${res.status})`);
56 }
57 return data.access_token;
58}
59
60interface GithubUser {
61 id: number;
62 login: string;
63}
64
65export async function fetchGithubUser(userAccessToken: string): Promise<GithubUser> {
66 const res = await fetch(`${GITHUB_API}/user`, {
67 headers: {
68 Authorization: `Bearer ${userAccessToken}`,
69 Accept: "application/vnd.github+json",
70 "User-Agent": "gh-attest",
71 },
72 });
73 if (!res.ok) throw new Error(`Failed to fetch GitHub user (${res.status})`);
74 return res.json();
75}
76
77interface GithubInstallationsResponse {
78 installations: Array<{ id: number }>;
79}
80
81// Installations the logged-in user themselves can see via this App —
82// scoped by GitHub to orgs/repos they actually have access to.
83export async function fetchUserInstallationIds(userAccessToken: string): Promise<number[]> {
84 const res = await fetch(`${GITHUB_API}/user/installations`, {
85 headers: {
86 Authorization: `Bearer ${userAccessToken}`,
87 Accept: "application/vnd.github+json",
88 "User-Agent": "gh-attest",
89 },
90 });
91 if (!res.ok) throw new Error(`Failed to fetch user installations (${res.status})`);
92 const data = (await res.json()) as GithubInstallationsResponse;
93 return data.installations.map((installation) => installation.id);
94}
95
96function base64UrlEncode(input: string): string {
97 return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
98}
99
100function base64UrlDecode(input: string): string {
101 const padded = input.replace(/-/g, "+").replace(/_/g, "/");
102 const padding = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
103 return atob(padded + padding);
104}
105
106export async function signSession(payload: SessionPayload, secret: string): Promise<string> {
107 const body = base64UrlEncode(JSON.stringify(payload));
108 const signature = await hmacHex(body, secret);
109 return `${body}.${signature}`;
110}
111
112export async function verifySession(token: string, secret: string): Promise<SessionPayload | null> {
113 const [body, signature] = token.split(".");
114 if (!body || !signature) return null;
115
116 const expectedSignature = await hmacHex(body, secret);
117 if (!timingSafeEqualHex(signature, expectedSignature)) return null;
118
119 let payload: SessionPayload;
120 try {
121 payload = JSON.parse(base64UrlDecode(body)) as SessionPayload;
122 } catch {
123 return null;
124 }
125
126 if (payload.exp < Math.floor(Date.now() / 1000)) return null;
127 return payload;
128}
129
130export function newSessionPayload(
131 user: GithubUser,
132 installationId: number,
133 installationIds: number[],
134): SessionPayload {
135 return {
136 userId: user.id,
137 login: user.login,
138 installationId,
139 installationIds,
140 exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
141 };
142}
143
144export { SESSION_TTL_SECONDS };