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
91
92
93
94
95
96
97
98
99
100// GitHub OAuth helpers (PRD §9.1). GitHub is the only auth mechanism; the same
// token authenticates requests and opens PRs on /close, so `repo` scope is
// requested and required.
//
// Base URLs are passed in (not hardcoded) so a deployment can target GitHub
// Enterprise, and so the suite can drive the flow against a mock host.
const SCOPE = "repo";
export interface GitHubUser {
id: number;
login: string;
}
export function authorizeUrl(
clientId: string,
redirectUri: string,
state: string,
oauthBase: string,
): string {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: SCOPE,
state,
});
return `${oauthBase}/login/oauth/authorize?${params.toString()}`;
}
// Exchange an OAuth code (web flow) for an access token.
export async function exchangeCode(
clientId: string,
clientSecret: string,
code: string,
redirectUri: string,
oauthBase: string,
): Promise<string> {
const res = await fetch(`${oauthBase}/login/oauth/access_token`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }),
});
const json = (await res.json()) as { access_token?: string; error?: string; error_description?: string };
if (!json.access_token) throw new Error(json.error_description ?? json.error ?? "token exchange failed");
return json.access_token;
}
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
}
// Begin the device flow (CLI clients).
export async function startDeviceFlow(clientId: string, oauthBase: string): Promise<DeviceCodeResponse> {
const res = await fetch(`${oauthBase}/login/device/code`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ client_id: clientId, scope: SCOPE }),
});
if (!res.ok) throw new Error(`device flow start failed: ${res.status}`);
return (await res.json()) as DeviceCodeResponse;
}
export type DevicePoll =
| { status: "approved"; access_token: string }
| { status: "pending"; error: string };
// Poll for device-flow approval. Returns pending until the user approves.
export async function pollDeviceFlow(
clientId: string,
deviceCode: string,
oauthBase: string,
): Promise<DevicePoll> {
const res = await fetch(`${oauthBase}/login/oauth/access_token`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
const json = (await res.json()) as { access_token?: string; error?: string };
if (json.access_token) return { status: "approved", access_token: json.access_token };
return { status: "pending", error: json.error ?? "authorization_pending" };
}
// Identify the GitHub user behind a token (PRD §9.1: login is signup).
export async function fetchUser(token: string, apiBase: string): Promise<GitHubUser> {
const res = await fetch(`${apiBase}/user`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "User-Agent": "equalify-iris" },
});
if (!res.ok) throw new Error(`github user lookup failed: ${res.status}`);
const json = (await res.json()) as { id: number; login: string };
return { id: json.id, login: json.login };
}