audit-labs/gh-attest

GitHub Audit Evidence Extractor

clone: git clone https://gitbay.org/audit-labs/gh-attest.git

main: src/auth.ts · raw

  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  // Padding is bounded to two characters, so the quantifier is too — an
 98  // unbounded `=+$` backtracks super-linearly.
 99  return btoa(input).replaceAll("+", "-").replaceAll("/", "_").replace(/={1,2}$/, "");
100}
101
102function base64UrlDecode(input: string): string {
103  const padded = input.replaceAll("-", "+").replaceAll("_", "/");
104  const padding = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
105  return atob(padded + padding);
106}
107
108export async function signSession(payload: SessionPayload, secret: string): Promise<string> {
109  const body = base64UrlEncode(JSON.stringify(payload));
110  const signature = await hmacHex(body, secret);
111  return `${body}.${signature}`;
112}
113
114export async function verifySession(token: string, secret: string): Promise<SessionPayload | null> {
115  const [body, signature] = token.split(".");
116  if (!body || !signature) return null;
117
118  const expectedSignature = await hmacHex(body, secret);
119  if (!timingSafeEqualHex(signature, expectedSignature)) return null;
120
121  let payload: SessionPayload;
122  try {
123    payload = JSON.parse(base64UrlDecode(body)) as SessionPayload;
124  } catch {
125    return null;
126  }
127
128  if (payload.exp < Math.floor(Date.now() / 1000)) return null;
129  return payload;
130}
131
132export function newSessionPayload(
133  user: GithubUser,
134  installationId: number,
135  installationIds: number[],
136): SessionPayload {
137  return {
138    userId: user.id,
139    login: user.login,
140    installationId,
141    installationIds,
142    exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
143  };
144}
145
146export { SESSION_TTL_SECONDS };