220 lines
9.6 KiB
TypeScript
220 lines
9.6 KiB
TypeScript
type AssetsBinding = { fetch(request: Request): Promise<Response> };
|
|
|
|
type Env = {
|
|
ASSETS: AssetsBinding;
|
|
GITHUB_CLIENT_ID?: string;
|
|
GITHUB_CLIENT_SECRET?: string;
|
|
GITHUB_OAUTH_CALLBACK_URL?: string;
|
|
SESSION_SECRET?: string;
|
|
};
|
|
|
|
type OAuthState = {
|
|
state: string;
|
|
verifier: string;
|
|
returnTo: string;
|
|
expiresAt: number;
|
|
};
|
|
|
|
type SessionUser = {
|
|
login: string;
|
|
avatarUrl: string;
|
|
profileUrl: string;
|
|
expiresAt: number;
|
|
};
|
|
|
|
const encoder = new TextEncoder();
|
|
const STATE_COOKIE = "dbx_oauth_state";
|
|
const SESSION_COOKIE = "dbx_contributor_session";
|
|
|
|
function base64UrlEncode(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
}
|
|
|
|
function base64UrlDecode(value: string): Uint8Array {
|
|
const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
const binary = atob(base64);
|
|
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
}
|
|
|
|
function asArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
}
|
|
|
|
async function hmacKey(secret: string) {
|
|
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
}
|
|
|
|
export async function signPayload(payload: object, secret: string): Promise<string> {
|
|
const encodedPayload = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
|
const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(encodedPayload));
|
|
return `${encodedPayload}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
}
|
|
|
|
export async function verifySignedPayload<T>(value: string | undefined, secret: string): Promise<T | null> {
|
|
if (!value) return null;
|
|
const [payload, signature] = value.split(".");
|
|
if (!payload || !signature) return null;
|
|
|
|
const valid = await crypto.subtle.verify("HMAC", await hmacKey(secret), asArrayBuffer(base64UrlDecode(signature)), encoder.encode(payload));
|
|
if (!valid) return null;
|
|
|
|
try {
|
|
return JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function sanitizeReturnTo(value: string | null): string {
|
|
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/en/contributors";
|
|
return value;
|
|
}
|
|
|
|
function parseCookies(request: Request): Record<string, string> {
|
|
return Object.fromEntries(
|
|
(request.headers.get("Cookie") ?? "")
|
|
.split(";")
|
|
.map((part) => part.trim())
|
|
.filter(Boolean)
|
|
.map((part) => {
|
|
const separator = part.indexOf("=");
|
|
return separator < 0 ? [part, ""] : [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function cookie(name: string, value: string, maxAge: number, path = "/"): string {
|
|
return `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=${path}; HttpOnly; Secure; SameSite=Lax`;
|
|
}
|
|
|
|
function randomToken(size = 32): string {
|
|
const bytes = new Uint8Array(size);
|
|
crypto.getRandomValues(bytes);
|
|
return base64UrlEncode(bytes);
|
|
}
|
|
|
|
async function codeChallenge(verifier: string): Promise<string> {
|
|
return base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
|
|
}
|
|
|
|
function json(data: unknown, status = 200, headers?: HeadersInit): Response {
|
|
return Response.json(data, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
|
}
|
|
|
|
function requiredConfig(env: Env): { clientId: string; clientSecret: string; sessionSecret: string } | null {
|
|
if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET || !env.SESSION_SECRET) return null;
|
|
return { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, sessionSecret: env.SESSION_SECRET };
|
|
}
|
|
|
|
async function startOAuth(request: Request, env: Env): Promise<Response> {
|
|
const config = requiredConfig(env);
|
|
if (!config) return json({ error: "GitHub OAuth is not configured" }, 503);
|
|
|
|
const url = new URL(request.url);
|
|
const state = randomToken();
|
|
const verifier = randomToken(48);
|
|
const returnTo = sanitizeReturnTo(url.searchParams.get("returnTo"));
|
|
const callbackUrl = env.GITHUB_OAUTH_CALLBACK_URL || `${url.origin}/api/auth/github/callback`;
|
|
const stateCookie = await signPayload({ state, verifier, returnTo, expiresAt: Date.now() + 10 * 60 * 1000 } satisfies OAuthState, config.sessionSecret);
|
|
const authorizeUrl = new URL("https://github.com/login/oauth/authorize");
|
|
authorizeUrl.searchParams.set("client_id", config.clientId);
|
|
authorizeUrl.searchParams.set("redirect_uri", callbackUrl);
|
|
authorizeUrl.searchParams.set("state", state);
|
|
authorizeUrl.searchParams.set("code_challenge", await codeChallenge(verifier));
|
|
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
Location: authorizeUrl.toString(),
|
|
"Set-Cookie": cookie(STATE_COOKIE, stateCookie, 10 * 60, "/api/auth/github"),
|
|
"Cache-Control": "no-store",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function finishOAuth(request: Request, env: Env): Promise<Response> {
|
|
const config = requiredConfig(env);
|
|
if (!config) return json({ error: "GitHub OAuth is not configured" }, 503);
|
|
|
|
const url = new URL(request.url);
|
|
const storedState = await verifySignedPayload<OAuthState>(parseCookies(request)[STATE_COOKIE], config.sessionSecret);
|
|
const code = url.searchParams.get("code");
|
|
const state = url.searchParams.get("state");
|
|
if (!storedState || storedState.expiresAt < Date.now() || !code || state !== storedState.state) return json({ error: "Invalid or expired OAuth state" }, 400);
|
|
|
|
const callbackUrl = env.GITHUB_OAUTH_CALLBACK_URL || `${url.origin}/api/auth/github/callback`;
|
|
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
method: "POST",
|
|
headers: { Accept: "application/json", "Content-Type": "application/json", "User-Agent": "dbx-contributors" },
|
|
body: JSON.stringify({ client_id: config.clientId, client_secret: config.clientSecret, code, redirect_uri: callbackUrl, code_verifier: storedState.verifier }),
|
|
});
|
|
const tokenData = (await tokenResponse.json()) as { access_token?: string; error?: string };
|
|
if (!tokenResponse.ok || !tokenData.access_token) return json({ error: tokenData.error || "GitHub token exchange failed" }, 502);
|
|
|
|
const userResponse = await fetch("https://api.github.com/user", {
|
|
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "dbx-contributors", "X-GitHub-Api-Version": "2022-11-28" },
|
|
});
|
|
const githubUser = (await userResponse.json()) as { login?: string; avatar_url?: string; html_url?: string };
|
|
if (!userResponse.ok || !githubUser.login) return json({ error: "Unable to read GitHub identity" }, 502);
|
|
|
|
// The access token is intentionally discarded after reading the public identity.
|
|
const session = await signPayload(
|
|
{
|
|
login: githubUser.login,
|
|
avatarUrl: githubUser.avatar_url || `https://github.com/${githubUser.login}.png`,
|
|
profileUrl: githubUser.html_url || `https://github.com/${githubUser.login}`,
|
|
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
|
} satisfies SessionUser,
|
|
config.sessionSecret,
|
|
);
|
|
|
|
const headers = new Headers({ Location: storedState.returnTo, "Cache-Control": "no-store" });
|
|
headers.append("Set-Cookie", cookie(STATE_COOKIE, "", 0, "/api/auth/github"));
|
|
headers.append("Set-Cookie", cookie(SESSION_COOKIE, session, 7 * 24 * 60 * 60));
|
|
return new Response(null, { status: 302, headers });
|
|
}
|
|
|
|
async function currentUser(request: Request, env: Env): Promise<Response> {
|
|
const config = requiredConfig(env);
|
|
if (!config) return json({ authenticated: false, configured: false });
|
|
|
|
const session = await verifySignedPayload<SessionUser>(parseCookies(request)[SESSION_COOKIE], config.sessionSecret);
|
|
if (!session || session.expiresAt < Date.now()) return json({ authenticated: false, configured: true });
|
|
return json({ authenticated: true, configured: true, user: { login: session.login, avatarUrl: session.avatarUrl, profileUrl: session.profileUrl } });
|
|
}
|
|
|
|
function logout(): Response {
|
|
return json({ authenticated: false }, 200, { "Set-Cookie": cookie(SESSION_COOKIE, "", 0) });
|
|
}
|
|
|
|
async function contributorAvatar(request: Request): Promise<Response> {
|
|
const login = new URL(request.url).searchParams.get("login") ?? "";
|
|
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login)) return json({ error: "Invalid GitHub login" }, 400);
|
|
|
|
const response = await fetch(`https://github.com/${login}.png?size=256`, { redirect: "follow" });
|
|
if (!response.ok || !response.body) return json({ error: "Avatar unavailable" }, 404);
|
|
|
|
return new Response(response.body, {
|
|
headers: {
|
|
"Content-Type": response.headers.get("Content-Type") || "image/png",
|
|
"Cache-Control": "public, max-age=86400, s-maxage=604800",
|
|
"Cross-Origin-Resource-Policy": "same-origin",
|
|
},
|
|
});
|
|
}
|
|
|
|
export default {
|
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
if (url.pathname === "/api/auth/github/start" && request.method === "GET") return startOAuth(request, env);
|
|
if (url.pathname === "/api/auth/github/callback" && request.method === "GET") return finishOAuth(request, env);
|
|
if (url.pathname === "/api/auth/me" && request.method === "GET") return currentUser(request, env);
|
|
if (url.pathname === "/api/auth/logout" && request.method === "POST") return logout();
|
|
if (url.pathname === "/api/contributor-avatar" && request.method === "GET") return contributorAvatar(request);
|
|
return env.ASSETS.fetch(request);
|
|
},
|
|
};
|