Skip to content

g1t/apps/web/app/routes/auth-github-callback.tsx

82 lines3,477 bytesCodeBlame
1import { Link, data, redirect } from "react-router";
2
3import type { Route } from "./+types/auth-github-callback";
4import { AuthCard } from "../components/auth-card";
5import { ContinueWithGithub } from "../components/github";
6import { PENDING_COOKIE, STATE_COOKIE, TWO_FACTOR_COOKIE, TWO_FACTOR_SECONDS, cookie, readCookie, stateMatches } from "../lib/github";
7import { githubSignIn } from "../lib/github.server";
8import { page } from "../lib/meta";
9import { safeNext } from "../lib/next";
10import { startSession } from "../lib/session.server";
11
12export function meta(args: Route.MetaArgs) {
13 return page(args, { title: "Signing in with GitHub · g1t" });
14}
15
16/**
17 * Where GitHub returns. The state must match the cookie this browser was
18 * given; identity then redeems it once, exchanges the code with the PKCE
19 * verifier, and says what happens next.
20 */
21export async function loader({ request }: Route.LoaderArgs) {
22 const url = new URL(request.url);
23 const clear = cookie(STATE_COOKIE, "", 0);
24 const failed = (error: string) => data({ error }, { headers: { "set-cookie": clear } });
25 if (url.searchParams.get("error")) {
26 // access_denied: the person cancelled on GitHub.
27 return failed("GitHub sign-in was cancelled.");
28 }
29 const code = url.searchParams.get("code");
30 const state = url.searchParams.get("state");
31 if (!code || !stateMatches(readCookie(request.headers.get("cookie"), STATE_COOKIE), state)) {
32 return failed("This sign-in did not start in this browser, or took too long. Start again.");
33 }
34 const finished = await githubSignIn.finish(state!, code);
35 if (!finished.ok) return failed(finished.error.message);
36 const done = finished.value;
37 const headers = new Headers({ "set-cookie": clear });
38 switch (done.kind) {
39 case "signed_in": {
40 // Two-factor authentication on: GitHub proved the account, a code
41 // from the app comes next.
42 const challenge = done.signedIn.twoFactorChallenge;
43 if (challenge) {
44 headers.append("set-cookie", cookie(TWO_FACTOR_COOKIE, challenge, TWO_FACTOR_SECONDS));
45 const next = safeNext(done.next);
46 throw redirect(`/login/two-factor${next === "/" ? "" : `?next=${encodeURIComponent(next)}`}`, { headers });
47 }
48 headers.append("set-cookie", startSession(done.signedIn.sessionToken));
49 throw redirect(safeNext(done.next), { headers });
50 }
51 case "linked":
52 throw redirect(safeNext(done.next === "/" ? "/settings/github" : done.next), { headers });
53 case "needs_link":
54 headers.append("set-cookie", cookie(PENDING_COOKIE, done.pending, 1800));
55 throw redirect(`/login?github=link${done.next === "/" ? "" : `&next=${encodeURIComponent(done.next)}`}`, { headers });
56 case "needs_username":
57 headers.append("set-cookie", cookie(PENDING_COOKIE, done.pending, 1800));
58 throw redirect("/auth/github/username", { headers });
59 }
60}
61
62export default function GithubCallback({ loaderData }: Route.ComponentProps) {
63 const error = loaderData?.error;
64 return (
65 <AuthCard
66 title="Signing in with GitHub"
67 subtitle="That did not work"
68 footer={
69 <Link to="/login" className="text-fg underline underline-offset-4">
70 Sign in another way
71 </Link>
72 }
73 >
74 <p className="text-sm text-danger" role="alert">
75 {error}
76 </p>
77 <div className="mt-6">
78 <ContinueWithGithub href="/auth/github" label="Try again with GitHub" />
79 </div>
80 </AuthCard>
81 );
82}