flagon-io/g1t

public

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

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

166 lines5,849 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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