Repository navigation
Expand file tree
/
Copy pathgithub-client.ts
More file actions
301 lines (275 loc) · 9.85 KB
/
Copy pathgithub-client.ts
File metadata and controls
301 lines (275 loc) · 9.85 KB
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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
/**
* Thin client wrapper for the GitHub OAuth flow.
*
* Exposes three functions matching specs/api/auth.md:
* - exchangeCodeForToken: POST /login/oauth/access_token with PKCE verifier
* - fetchGitHubUser: GET /user
* - fetchGitHubEmails: GET /user/emails
*
* Each function throws `GitHubApiError` on transport failure or non-2xx.
* The route handler catches that and surfaces `github_unreachable` to the SPA.
*
* The token-exchange endpoint is on github.com, while the user/emails endpoints
* are on api.github.com — that's GitHub's split, not ours.
*/
const TOKEN_URL = 'https://github.com/login/oauth/access_token';
const USER_URL = 'https://api.github.com/user';
const EMAILS_URL = 'https://api.github.com/user/emails';
const USER_AGENT = 'codeforphilly-ng';
export interface GitHubUser {
readonly id: number;
readonly login: string;
readonly name: string | null;
readonly avatar_url?: string;
/** Reputation facts from the same /user response (specs/api/auth.md step 5). */
readonly created_at: string | null;
readonly public_repos: number | null;
readonly followers: number | null;
readonly following: number | null;
readonly type: string | null;
}
export type GitHubProbeStatus = 'ok' | 'gone';
export interface GitHubProbeResult {
readonly status: GitHubProbeStatus;
/** Present when status is `ok`. */
readonly user: GitHubUser | null;
}
export interface GitHubEmail {
readonly email: string;
readonly primary: boolean;
readonly verified: boolean;
readonly visibility?: string | null;
}
export class GitHubApiError extends Error {
readonly code: string;
readonly status?: number;
override readonly cause?: unknown;
constructor(message: string, code: string, opts?: { status?: number; cause?: unknown }) {
super(message);
this.name = 'GitHubApiError';
this.code = code;
if (opts?.status !== undefined) this.status = opts.status;
if (opts?.cause !== undefined) this.cause = opts.cause;
}
}
export interface GitHubTokenExchange {
/** OAuth client id of the registered GitHub OAuth App. */
readonly clientId: string;
/** OAuth client secret. */
readonly clientSecret: string;
/** Authorization code returned to /github/callback. */
readonly code: string;
/** PKCE verifier matching the challenge sent at /start. */
readonly codeVerifier: string;
/** Same redirect_uri sent at /start (some IdPs require it on exchange). */
readonly redirectUri: string;
}
/**
* Exchange the authorization code for a GitHub access token.
*
* GitHub returns JSON when `Accept: application/json` is set (default form
* is x-www-form-urlencoded). We use JSON so we don't have to parse it.
*/
export async function exchangeCodeForToken(
params: GitHubTokenExchange,
): Promise<string> {
let res: Response;
try {
res = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': USER_AGENT,
},
body: JSON.stringify({
client_id: params.clientId,
client_secret: params.clientSecret,
code: params.code,
code_verifier: params.codeVerifier,
redirect_uri: params.redirectUri,
}),
});
} catch (err) {
throw new GitHubApiError('GitHub token exchange transport error', 'github_unreachable', { cause: err });
}
if (!res.ok) {
throw new GitHubApiError(
`GitHub token exchange returned ${res.status}`,
'github_unreachable',
{ status: res.status },
);
}
const body = (await res.json().catch(() => null)) as
| { access_token?: string; error?: string; error_description?: string }
| null;
if (!body || typeof body !== 'object') {
throw new GitHubApiError('GitHub token exchange returned invalid JSON', 'github_unreachable');
}
if (body.error) {
// GitHub returned 200 with an error payload — surface as github_unreachable
// (the user-facing error message is the same; we log details server-side).
throw new GitHubApiError(
`GitHub token exchange error: ${body.error}`,
'github_unreachable',
);
}
if (!body.access_token || typeof body.access_token !== 'string') {
throw new GitHubApiError('GitHub token exchange missing access_token', 'github_unreachable');
}
return body.access_token;
}
async function ghGet(url: string, accessToken: string): Promise<unknown> {
let res: Response;
try {
res = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'User-Agent': USER_AGENT,
},
});
} catch (err) {
throw new GitHubApiError(`GitHub API transport error: ${url}`, 'github_unreachable', { cause: err });
}
if (!res.ok) {
throw new GitHubApiError(
`GitHub API ${url} returned ${res.status}`,
'github_unreachable',
{ status: res.status },
);
}
return res.json();
}
export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser> {
const body = (await ghGet(USER_URL, accessToken)) as Partial<GitHubUser> | null;
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
throw new GitHubApiError('GitHub /user returned unexpected shape', 'github_unreachable');
}
return toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string });
}
function toGitHubUser(body: Partial<GitHubUser> & { id: number; login: string }): GitHubUser {
return {
id: body.id,
login: body.login,
name: typeof body.name === 'string' ? body.name : null,
...(typeof body.avatar_url === 'string' ? { avatar_url: body.avatar_url } : {}),
created_at: typeof body.created_at === 'string' ? body.created_at : null,
public_repos: typeof body.public_repos === 'number' ? body.public_repos : null,
followers: typeof body.followers === 'number' ? body.followers : null,
following: typeof body.following === 'number' ? body.following : null,
type: typeof body.type === 'string' ? body.type : null,
};
}
/**
* Is the linked GitHub account still there? `GET /user/{id}` authenticated
* with the OAuth app's client credentials (5,000 req/h). GitHub answers 404
* once it has deleted or suspended the account; that is the signal. Any other
* non-2xx throws so the caller keeps its previous record rather than
* misreading an outage as a verdict.
*/
export async function probeGitHubUser(
githubUserId: number,
clientId: string,
clientSecret: string,
opts: { readonly timeoutMs?: number } = {},
): Promise<GitHubProbeResult> {
const url = `https://api.github.com/user/${githubUserId}`;
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
let res: Response;
try {
res = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Basic ${basic}`,
Accept: 'application/vnd.github+json',
'User-Agent': USER_AGENT,
},
signal: AbortSignal.timeout(opts.timeoutMs ?? 4000),
});
} catch (err) {
throw new GitHubApiError(`GitHub API transport error: ${url}`, 'github_unreachable', { cause: err });
}
if (res.status === 404) return { status: 'gone', user: null };
if (!res.ok) {
throw new GitHubApiError(`GitHub API ${url} returned ${res.status}`, 'github_unreachable', {
status: res.status,
});
}
const body = (await res.json().catch(() => null)) as Partial<GitHubUser> | null;
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
throw new GitHubApiError('GitHub /user/{id} returned unexpected shape', 'github_unreachable');
}
return { status: 'ok', user: toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string }) };
}
export async function fetchGitHubEmails(accessToken: string): Promise<GitHubEmail[]> {
const body = await ghGet(EMAILS_URL, accessToken);
if (!Array.isArray(body)) {
throw new GitHubApiError('GitHub /user/emails returned non-array', 'github_unreachable');
}
const emails: GitHubEmail[] = [];
for (const entry of body) {
if (
entry &&
typeof entry === 'object' &&
typeof (entry as GitHubEmail).email === 'string' &&
typeof (entry as GitHubEmail).primary === 'boolean' &&
typeof (entry as GitHubEmail).verified === 'boolean'
) {
emails.push({
email: (entry as GitHubEmail).email,
primary: (entry as GitHubEmail).primary,
verified: (entry as GitHubEmail).verified,
});
}
}
return emails;
}
/**
* Resolved GitHub identity passed downstream to the matching algorithm.
*
* `emails` is filtered to verified-only; `primaryEmail` is the verified primary
* if there is one, else the first verified email, else null.
*/
export interface ResolvedGitHubIdentity {
readonly id: number;
readonly login: string;
readonly name: string | null;
readonly emails: readonly GitHubEmail[];
readonly primaryEmail: string | null;
/**
* The full /user snapshot, kept so sign-in can record reputation facts.
* Absent when the identity was rebuilt from a claim-pending token rather
* than a live GitHub response.
*/
readonly user?: GitHubUser;
}
export function resolveIdentitySnapshot(
user: GitHubUser,
rawEmails: readonly GitHubEmail[],
): ResolvedGitHubIdentity {
const verified = rawEmails.filter((e) => e.verified);
const primary = verified.find((e) => e.primary) ?? verified[0] ?? null;
return {
id: user.id,
login: user.login,
name: user.name,
emails: verified,
primaryEmail: primary?.email.toLowerCase() ?? null,
user,
};
}
/** Shape the reputation facts for the private profile (specs/behaviors/private-storage.md). */
export function githubFactsFrom(user: GitHubUser, status: GitHubProbeStatus, checkedAt: string) {
return {
login: user.login,
accountCreatedAt: user.created_at,
publicRepos: user.public_repos,
followers: user.followers,
following: user.following,
type: user.type,
status,
checkedAt,
};
}