import { seal, sha256Base64Url } from "#lib/server/sealed.ts";
import { internalUrl } from "#lib/auth/returnTo.ts";
import type { GitHubEnv } from "#lib/server/github/env.ts";
import type { GitHubError } from "#lib/github.ts";
import type { Fetch } from "#lib/fetch.ts";
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<GitHubState, "exp">) =>
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: 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: GitHubError = "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?: GitHubError): 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;
};