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