Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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