import { seal, sha256Base64Url } from "$lib/server/sealed"; import { internalUrl } from "$lib/auth/returnTo"; import type { GitHubEnv } from "./env"; export const CALLBACK_PATH = "/_internal/github/callback"; export interface GitHubState { landing: Landing; did: string; verifier: string; exp: number; } const STATE_TTL_MS = 10 * 60_000; export const newVerifier = () => crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""); export const pkceChallenge = (verifier: string) => sha256Base64Url(verifier); export const sealState = (secret: string, state: Omit) => seal({ ...state, exp: Date.now() + STATE_TTL_MS }, secret); export const authorizeUrl = (env: GitHubEnv, origin: string, state: string, challenge: string) => { const target = new URL(`${env.publicOrigin}/login/oauth/authorize`); target.searchParams.set("client_id", env.clientId); target.searchParams.set("redirect_uri", `${origin}${CALLBACK_PATH}`); target.searchParams.set("state", state); target.searchParams.set("code_challenge", challenge); target.searchParams.set("code_challenge_method", "S256"); return target.toString(); }; export const installUrl = (env: GitHubEnv, state: string) => `${env.publicOrigin}/apps/${encodeURIComponent(env.appSlug)}/installations/new?state=${encodeURIComponent(state)}`; export const tokenFromCode = async ( env: GitHubEnv, code: string, verifier: string, redirectUri: string, fetcher: typeof fetch = fetch ) => { const response = await fetcher(`${env.publicOrigin}/login/oauth/access_token`, { method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ client_id: env.clientId, client_secret: env.clientSecret, code, code_verifier: verifier, redirect_uri: redirectUri }) }); if (!response.ok) throw new Error(`oauth upstream ${response.status}`); const json = (await response.json()) as { access_token?: string }; if (!json.access_token) throw new Error("oauth token missing"); return json.access_token; }; export type Landing = | { readonly kind: "page"; readonly href: string } | { readonly kind: "home" } | { readonly kind: "refused" }; export const HOME_LANDING: Landing = { kind: "home" }; // allowed host pages for github connect round-trips const HOST_PAGES = new Set(["/repo/import/github", "/repo/migrate", "/repo/new", "/welcome"]); const NOTICE_PARAM = "github_error"; const REFUSED = "return_unexpected"; const INTERNAL_ORIGIN = "https://tangled.invalid"; export const landingFrom = (raw: string | null): Landing => { if (!raw) return HOME_LANDING; const url = internalUrl(raw); if (!url || !HOST_PAGES.has(url.pathname)) return { kind: "refused" }; url.searchParams.delete(NOTICE_PARAM); return { kind: "page", href: url.pathname + url.search }; }; export const landingHref = (landing: Landing, notice?: string): string => { const url = new URL(landing.kind === "page" ? landing.href : "/", INTERNAL_ORIGIN); if (landing.kind === "refused") url.searchParams.set(NOTICE_PARAM, REFUSED); if (notice) url.searchParams.set(NOTICE_PARAM, notice); return url.pathname + url.search; };