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

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