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

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

This file's history is long; its oldest lines are credited to the oldest commit read.