g1t/apps/web/app/routes/oauth-authorize.tsx

182 lines6,805 bytesCodeBlame
1import { CircleX, KeyRound } from "lucide-react";
2import { Form, redirect } from "react-router";
3
4import { decodeOAuthClient, isRegisteredRedirect } from "@g1t/contracts";
5
6import type { Route } from "./+types/oauth-authorize";
7import { page } from "../lib/meta";
8import { Button, ErrorText } from "../components/ui";
9import { ScopeChecklist } from "../components/token-scopes";
10import { identity } from "../lib/services.server";
11import { consentedScopes, requestedScopes } from "../lib/token-scopes";
12import { assertSameOrigin, requireUser } from "../lib/session.server";
13
14export function meta(args: Route.MetaArgs) {
15 return page(args, { title: "Sign in to an application · g1t" });
16}
17
18type Checked =
19 | { ok: false; problem: string }
20 | {
21 ok: true;
22 clientId: string;
23 clientName: string;
24 redirectUri: string;
25 codeChallenge: string;
26 state: string;
27 };
28
29/**
30 * Checks an authorization request. A request that names a client or a
31 * redirect address we cannot vouch for is never redirected anywhere; the
32 * person is told instead.
33 */
34function check(params: URLSearchParams | FormData): Checked {
35 const get = (key: string) => String(params.get(key) ?? "");
36 const client = decodeOAuthClient(get("client_id"));
37 if (!client) {
38 return { ok: false, problem: "This sign-in link names an application g1t does not recognise." };
39 }
40 const redirectUri = get("redirect_uri") || client.redirectUris[0];
41 if (!isRegisteredRedirect(client, redirectUri)) {
42 return {
43 ok: false,
44 problem: "This sign-in link would send you somewhere the application did not register.",
45 };
46 }
47 if (get("response_type") !== "code") {
48 return { ok: false, problem: "This sign-in link asks for a kind of access g1t does not offer." };
49 }
50 if (!get("code_challenge") || get("code_challenge_method") !== "S256") {
51 return {
52 ok: false,
53 problem: "This application did not protect its sign-in with PKCE (S256), which g1t requires.",
54 };
55 }
56 return {
57 ok: true,
58 clientId: get("client_id"),
59 clientName: client.name,
60 redirectUri,
61 codeChallenge: get("code_challenge"),
62 state: get("state"),
63 };
64}
65
66/** The application's redirect address with the outcome added to it. */
67function callback(redirectUri: string, params: Record<string, string>): string {
68 const url = new URL(redirectUri);
69 for (const [key, value] of Object.entries(params)) {
70 if (value) url.searchParams.set(key, value);
71 }
72 return url.toString();
73}
74
75/** Where an application sends a person to approve its sign-in. */
76export function loader({ request, context }: Route.LoaderArgs) {
77 const user = requireUser(context, request);
78 const { searchParams } = new URL(request.url);
79 return {
80 user,
81 request: check(searchParams),
82 // What the application asked for; nothing usable means the default set.
83 requested: requestedScopes(searchParams.get("scope")),
84 // Sent back unchanged when the person decides.
85 query: Object.fromEntries(searchParams),
86 };
87}
88
89export async function action({ request, context }: Route.ActionArgs) {
90 assertSameOrigin(request);
91 const user = requireUser(context, request);
92 const form = await request.formData();
93 const checked = check(form);
94 if (!checked.ok) return null;
95 if (form.get("decision") !== "approve") {
96 throw redirect(
97 callback(checked.redirectUri, { error: "access_denied", state: checked.state }),
98 );
99 }
100 // Only what the application asked for, never more, whatever the form says.
101 const scopes = consentedScopes(form, requestedScopes(String(form.get("scope") ?? "")));
102 if (scopes.length === 0) {
103 return { error: "Leave at least one box ticked, or deny." };
104 }
105 const { code } = await identity.oauthAuthorize(user, {
106 clientId: checked.clientId,
107 clientName: checked.clientName,
108 redirectUri: checked.redirectUri,
109 codeChallenge: checked.codeChallenge,
110 scopes,
111 });
112 throw redirect(
113 callback(checked.redirectUri, { code, state: checked.state, iss: "https://api.g1t.sh" }),
114 );
115}
116
117export default function Authorize({ loaderData, actionData }: Route.ComponentProps) {
118 const { user, request, requested, query } = loaderData;
119
120 if (!request.ok) {
121 return (
122 <main className="mx-auto max-w-md px-4 py-32 text-center">
123 <CircleX size={40} className="mx-auto text-muted" />
124 <h1 className="mt-6 text-2xl font-semibold tracking-tight">This link cannot be used</h1>
125 <p className="mt-2 text-muted">{request.problem}</p>
126 <p className="mt-2 text-sm text-faint">Nothing was given access to your account.</p>
127 </main>
128 );
129 }
130
131 const destination = new URL(request.redirectUri);
132 return (
133 <main className="mx-auto max-w-md px-4 py-24">
134 <KeyRound size={36} className="text-accent" />
135 <h1 className="mt-6 text-2xl font-semibold tracking-tight">
136 Sign in to {request.clientName}
137 </h1>
138 <p className="mt-2 text-muted">
139 <span className="font-medium text-fg">{request.clientName}</span> wants to act as{" "}
140 <span className="font-mono font-medium text-fg">{user.username}</span> on g1t.
141 </p>
142
143 <Form method="post" className="mt-6 space-y-6">
144 {Object.entries(query).map(([name, value]) => (
145 <input key={name} type="hidden" name={name} value={value} />
146 ))}
147 <section>
148 <h2 className="text-sm font-medium">It will be able to</h2>
149 <p className="mt-0.5 text-xs text-faint">
150 Everywhere you can, as you. Untick anything you would rather it could not do.
151 </p>
152 <div className="mt-3">
153 <ScopeChecklist initial={requested} only={requested} allowFull={false} />
154 </div>
155 </section>
156 <dl className="rounded-xl border border-line bg-surface p-4 text-sm">
157 <dt className="text-xs text-faint">You will be sent back to</dt>
158 <dd className="mt-0.5 font-mono text-[0.8125rem] break-all">
159 {destination.protocol === "https:" || destination.protocol === "http:"
160 ? destination.host + destination.pathname
161 : request.redirectUri}
162 </dd>
163 </dl>
164 <div>
165 <p className="text-xs text-faint">
166 Approve only if you started this from {request.clientName} yourself. You can change
167 what it may do, or sign it out, in Settings.
168 </p>
169 <ErrorText>{actionData?.error}</ErrorText>
170 <div className="mt-4 flex gap-2">
171 <Button variant="accent" type="submit" name="decision" value="approve">
172 Approve
173 </Button>
174 <Button variant="quiet" type="submit" name="decision" value="deny">
175 Deny
176 </Button>
177 </div>
178 </div>
179 </Form>
180 </main>
181 );
182}