~james/core

core/web/src/lib/server/github/oauth.ts -rw-r--r-- 3.2 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
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;
};