Commit

OAuth 2.1 sign-in for MCP clients and other applications

Connecting an MCP client now needs only the server's address: the client is told to sign in, registers itself, and sends the person to a consent page on g1t. No token is pasted. - identity: authorization codes with PKCE (S256), grants with rotating refresh tokens, listing and revoking grants - client registration stores nothing: a client id encodes its own name and redirect addresses, so the open endpoint cannot be used to fill a database - api: authorization server and protected resource metadata, registration and token endpoints; the MCP server answers unauthenticated requests with 401 and a pointer to the metadata - site: consent page at /oauth/authorize, which never redirects to an address the client did not register; connected applications in Settings - docs, llms.txt, README and plan updated

syntaqxcommitted Parentedf69f0Browse files
23 files+999−400/23 viewed
+7−10
1919
2020 Working today:
2121
22−- Accounts with email verification and password reset; sign-in from a tool
23− by approving a code in the browser.
22+- Accounts with email verification and password reset. Applications sign
23+ in through the browser with OAuth 2.1, so connecting an MCP client needs
24+ no pasted token; tools without a browser use a device code.
2425 - Workspaces that own repositories, with members and roles.
2526 - Public and private repositories, and git over HTTPS, including creating a
2627 repository by pushing to it.
3738 subscribers.
3839
3940 Not built yet: pull requests from branches, server-side merge commits,
40−review comments on lines, running acceptance checks, OAuth sign-in for MCP,
41−git over SSH. See the build order in the plan.
41+review comments on lines, running acceptance checks, git over SSH. See the build order in the plan.
4242
4343 ## Try it
4444
4545 ```sh
46−# 1. Create an account at https://g1t.sh/register, a workspace, and an
47−# access token in Settings.
48−export G1T_TOKEN=g1t_…
46+# 1. Create an account and a workspace at https://g1t.sh/register.
4947
50−# 2. Connect Claude Code.
51−claude mcp add --transport http g1t https://mcp.g1t.sh \
52− --header "Authorization: Bearer $G1T_TOKEN"
48+# 2. Connect Claude Code, then run /mcp in it to sign in through your browser.
49+claude mcp add --transport http g1t https://mcp.g1t.sh
5350
5451 # 3. Ask it to open a pull request for an open issue.
5552 ```
+23−3
1111 } from "@g1t/contracts";
1212
1313 import { handleMcp } from "./mcp";
14+import { MCP_CHALLENGE, oauth } from "./oauth";
1415 import { openApiDocument } from "./openapi";
1516 import { type ApiEnv, operations, operationsByName } from "./operations";
1617
104105 return c.json(
105106 { error: { code: "unauthenticated", message: "Invalid access token." } },
106107 401,
108+ // Tells an MCP client where to sign in again.
109+ { "www-authenticate": `${MCP_CHALLENGE}, error="invalid_token"` },
107110 );
108111 }
109112 }
111114 await next();
112115 });
113116
117+// Signing in with OAuth. Served on both hosts: an MCP client looks for the
118+// metadata next to the MCP server.
119+app.route("/", oauth);
120+
114121 app.all("*", async (c, next) => {
115− if (new URL(c.req.url).hostname.startsWith("mcp.")) {
116− return handleMcp(c.req.raw, c.get("services"), c.get("viewer"));
122+ if (!new URL(c.req.url).hostname.startsWith("mcp.")) return next();
123+ const viewer = c.get("viewer");
124+ // The MCP server needs a signed-in user. Saying so this way is what
125+ // makes a client open the browser to sign in.
126+ if (!viewer) {
127+ return c.json(
128+ {
129+ error: {
130+ code: "unauthenticated",
131+ message: "Sign in to use the g1t MCP server.",
132+ },
133+ },
134+ 401,
135+ { "www-authenticate": MCP_CHALLENGE },
136+ );
117137 }
118− await next();
138+ return handleMcp(c.req.raw, c.get("services"), viewer);
119139 });
120140
121141 // Signing in from a tool. Accounts are created, and passwords typed, only
+139−0
1+import { Hono } from "hono";
2+
3+import {
4+ type IdentityApi,
5+ decodeOAuthClient,
6+ encodeOAuthClient,
7+} from "@g1t/contracts";
8+
9+/**
10+ * The OAuth 2.1 endpoints an application calls directly. The page where a
11+ * person approves is on the site, at g1t.sh/oauth/authorize.
12+ *
13+ * Applications sign people in with the authorization code flow and PKCE.
14+ * They are public clients: none holds a secret.
15+ */
16+const ISSUER = "https://api.g1t.sh";
17+const MCP_RESOURCE = "https://mcp.g1t.sh";
18+
19+/** Authorization server metadata (RFC 8414). */
20+const SERVER_METADATA = {
21+ issuer: ISSUER,
22+ authorization_endpoint: "https://g1t.sh/oauth/authorize",
23+ token_endpoint: `${ISSUER}/oauth/token`,
24+ registration_endpoint: `${ISSUER}/oauth/register`,
25+ response_types_supported: ["code"],
26+ grant_types_supported: ["authorization_code", "refresh_token"],
27+ code_challenge_methods_supported: ["S256"],
28+ token_endpoint_auth_methods_supported: ["none"],
29+ service_documentation: "https://docs.g1t.sh/guides/authentication/",
30+};
31+
32+/** What an MCP client is told when it must sign in first (RFC 9728). */
33+export const MCP_CHALLENGE = `Bearer resource_metadata="${MCP_RESOURCE}/.well-known/oauth-protected-resource"`;
34+
35+type Fields = Record<string, unknown>;
36+
37+function oauthError(error: string, description: string, status: 400 | 401 = 400) {
38+ return Response.json(
39+ { error, error_description: description },
40+ { status, headers: { "cache-control": "no-store" } },
41+ );
42+}
43+
44+/** The request body as fields, whether sent as a form or as JSON. */
45+async function fields(request: Request): Promise<Fields> {
46+ try {
47+ if (request.headers.get("content-type")?.includes("json")) return await request.json();
48+ return Object.fromEntries(await request.formData());
49+ } catch {
50+ return {};
51+ }
52+}
53+
54+export const oauth = new Hono<{ Variables: { services: { IDENTITY: IdentityApi } } }>();
55+
56+oauth.get("/.well-known/oauth-authorization-server", (c) => c.json(SERVER_METADATA));
57+
58+// Served for the MCP server, with or without its path appended.
59+oauth.get("/.well-known/oauth-protected-resource/*", protectedResource);
60+oauth.get("/.well-known/oauth-protected-resource", protectedResource);
61+
62+function protectedResource() {
63+ return Response.json({
64+ resource: MCP_RESOURCE,
65+ authorization_servers: [ISSUER],
66+ bearer_methods_supported: ["header"],
67+ resource_documentation: "https://docs.g1t.sh/guides/bring-your-own-agent/",
68+ });
69+}
70+
71+// Dynamic client registration (RFC 7591). Nothing is stored: the client id
72+// returned is the registration itself, encoded.
73+oauth.post("/oauth/register", async (c) => {
74+ const body = await fields(c.req.raw);
75+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.map(String) : [];
76+ const name = typeof body.client_name === "string" ? body.client_name : "";
77+ const clientId = encodeOAuthClient({ name, redirectUris });
78+ if (!clientId) {
79+ return oauthError(
80+ "invalid_redirect_uri",
81+ "Give one to five redirect_uris: https addresses, http on localhost, or the application's own scheme.",
82+ );
83+ }
84+ const client = decodeOAuthClient(clientId)!;
85+ return c.json(
86+ {
87+ client_id: clientId,
88+ client_name: client.name,
89+ redirect_uris: client.redirectUris,
90+ grant_types: ["authorization_code", "refresh_token"],
91+ response_types: ["code"],
92+ token_endpoint_auth_method: "none",
93+ },
94+ 201,
95+ );
96+});
97+
98+oauth.post("/oauth/token", async (c) => {
99+ const body = await fields(c.req.raw);
100+ const text = (key: string) => (typeof body[key] === "string" ? (body[key] as string) : "");
101+ const identity = c.get("services").IDENTITY;
102+
103+ let result;
104+ switch (text("grant_type")) {
105+ case "authorization_code":
106+ if (!text("code") || !text("code_verifier") || !text("client_id")) {
107+ return oauthError("invalid_request", "code, code_verifier and client_id are required.");
108+ }
109+ result = await identity.oauthExchange(
110+ text("code"),
111+ text("code_verifier"),
112+ text("client_id"),
113+ text("redirect_uri"),
114+ );
115+ break;
116+ case "refresh_token":
117+ if (!text("refresh_token") || !text("client_id")) {
118+ return oauthError("invalid_request", "refresh_token and client_id are required.");
119+ }
120+ result = await identity.oauthRefresh(text("refresh_token"), text("client_id"));
121+ break;
122+ default:
123+ return oauthError(
124+ "unsupported_grant_type",
125+ "grant_type must be authorization_code or refresh_token.",
126+ );
127+ }
128+ if (!result.ok) return oauthError("invalid_grant", result.error.message);
129+ return c.json(
130+ {
131+ access_token: result.value.accessToken,
132+ token_type: "Bearer",
133+ expires_in: result.value.expiresIn,
134+ refresh_token: result.value.refreshToken,
135+ },
136+ 200,
137+ { "cache-control": "no-store" },
138+ );
139+});
+35−2
5656
5757 A token has the full rights of your account. Scoped tokens are planned.
5858
59+## Signing in with OAuth
60+
61+Applications that can open your browser, such as an agent connecting to the
62+[MCP server](/guides/bring-your-own-agent/), sign you in with OAuth 2.1.
63+You see a page on g1t naming the application and where it will send you
64+back, and you approve or deny. The application never sees your password and
65+there is no token to copy.
66+
67+Applications you have approved are listed under **Connected applications**
68+in [Settings](https://g1t.sh/settings). Signing one out ends its access at
69+once.
70+
71+For people building a client:
72+
73+| | |
74+| --- | --- |
75+| Metadata | `https://api.g1t.sh/.well-known/oauth-authorization-server` |
76+| Authorization | `https://g1t.sh/oauth/authorize` |
77+| Token | `https://api.g1t.sh/oauth/token` |
78+| Registration | `https://api.g1t.sh/oauth/register` |
79+
80+- The flow is authorization code with PKCE. `S256` is required.
81+- Clients are public: there are no client secrets.
82+- Register with `client_name` and `redirect_uris`. A redirect address is an
83+ `https` URL, `http` on `localhost`, or the application's own scheme. A
84+ client on `localhost` may use any port.
85+- Registration stores nothing. The client id it returns encodes what was
86+ registered, so it cannot be used to fill g1t with junk.
87+- An access token lasts 30 days. The refresh token returned with it works
88+ once and returns the next pair; the previous access token stops working.
89+- An authorization code lasts five minutes and works once.
90+
5991 ## Signing in from a tool
6092
61−An agent or command-line tool gets a token without ever handling your
62−password, the same way `gh auth login` works:
93+A tool that cannot receive a redirect, such as a script on a remote machine,
94+gets a token without ever handling your password, the same way
95+`gh auth login` works:
6396
6497 1. The tool asks g1t for a code and shows you a link and a short code such
6598 as `WDJB-MJHT`.
+25−3
88
99 ## Claude Code
1010
11−Create an [access token](https://g1t.sh/settings), then:
11+```sh
12+claude mcp add --transport http g1t https://mcp.g1t.sh
13+```
14+
15+Then run `/mcp` inside Claude Code and choose **g1t** to sign in. Your
16+browser opens on g1t, you approve, and Claude Code is connected. There is no
17+token to copy. It shows up under **Connected applications** in
18+[Settings](https://g1t.sh/settings), where you can sign it out.
19+
20+The agent also needs to push with git, which asks for a username and a
21+password: use your g1t username and an
22+[access token](/guides/authentication/#access-tokens).
23+
24+To skip the browser, for a script or a machine without one, pass a token
25+instead:
1226
1327 ```sh
1428 claude mcp add --transport http g1t https://mcp.g1t.sh \
96110 ## Other clients
97111
98112 The server speaks MCP over streamable HTTP and answers each request with
99−JSON. It needs one header, `Authorization: Bearer <token>`. Reading public
100−data works without a token.
113+JSON. Every request needs to be signed in.
114+
115+A client that supports MCP authorization needs only the URL. An
116+unauthenticated request is answered with `401` and a pointer to
117+`https://mcp.g1t.sh/.well-known/oauth-protected-resource`, from which the
118+client finds g1t's authorization server, registers itself, and sends you to
119+your browser. See [signing in with OAuth](/guides/authentication/#signing-in-with-oauth).
120+
121+A client that does not can send `Authorization: Bearer <token>` with an
122+access token.
+4−4
2424
2525 ## 2. Create an access token
2626
27−Git, the API and agents authenticate with an access token. Open
27+Git and the API authenticate with an access token. Open
2828 [Settings](https://g1t.sh/settings), give the token a name and create it. Copy it
2929 immediately; it is shown once.
3030
6060 Connect Claude Code to g1t:
6161
6262 ```sh
63−claude mcp add --transport http g1t https://mcp.g1t.sh \
64− --header "Authorization: Bearer $G1T_TOKEN"
63+claude mcp add --transport http g1t https://mcp.g1t.sh
6564 ```
6665
67−Then ask it to work on the issue:
66+Run `/mcp` in Claude Code and choose **g1t**. Your browser opens, you
67+approve, and it is connected. Then ask it to work on the issue:
6868
6969 > Work on issue 1 of `<workspace>/my-project` on g1t. Open a pull request for
7070 > it and record your session as you go.
+11−1
3333 | `POST` | `/v1/device/code` | Start a sign-in. Body: `client_name`. |
3434 | `POST` | `/v1/device/token` | Ask whether it was approved. Body: `device_code`. |
3535
36−Accounts are created in a browser only. There is no registration endpoint.
36+Applications that can open a browser use OAuth instead. See
37+[signing in with OAuth](/guides/authentication/#signing-in-with-oauth).
38+
39+| Method | Path | |
40+| --- | --- | --- |
41+| `GET` | `/.well-known/oauth-authorization-server` | Where the endpoints are. |
42+| `POST` | `/oauth/register` | Register a client. Body: `client_name`, `redirect_uris`. |
43+| `POST` | `/oauth/token` | Exchange a code, or refresh. Form-encoded or JSON. |
44+
45+Accounts are created in a browser only. There is no registration endpoint
46+for accounts.
3747
3848 ## Errors
3949
+3−2
3030 <TabsContent value="agent">
3131 <CopyLine
3232 prompt
33− text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"'
33+ text="claude mcp add --transport http g1t https://mcp.g1t.sh"
3434 />
3535 <p className="mt-2 text-xs text-muted">
36− Connects Claude Code to g1t. Then ask it to work on an issue in{" "}
36+ Connects Claude Code to g1t; it signs in through your browser.
37+ Then ask it to work on an issue in{" "}
3738 <span className="font-mono text-fg">{path}</span>.{" "}
3839 <Link to="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4">
3940 More
+1−1
196196 <div className="space-y-3">
197197 <CopyLine
198198 prompt
199− text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"'
199+ text="claude mcp add --transport http g1t https://mcp.g1t.sh"
200200 />
201201 <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" />
202202 <CopyLine
+1−0
99 route("forgot", "routes/forgot.tsx"),
1010 route("reset", "routes/reset.tsx"),
1111 route("device", "routes/device.tsx"),
12+ route("oauth/authorize", "routes/oauth-authorize.tsx"),
1213 route("new", "routes/new.tsx"),
1314 route("settings", "routes/settings.tsx"),
1415 route("explore", "routes/explore.tsx", { id: "explore" }),
+3−2
128128 <div className="rounded-xl border border-line bg-surface p-5">
129129 <h2 className="font-medium">Connect an agent</h2>
130130 <p className="mt-1.5 text-sm text-muted">
131− Create an access token in settings, then add g1t to Claude Code.
131+ Add g1t to Claude Code, then run /mcp in it to sign in through
132+ your browser.
132133 </p>
133134 <div className="mt-4 space-y-2">
134135 <CopyLine
135136 prompt
136− text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"'
137+ text="claude mcp add --transport http g1t https://mcp.g1t.sh"
137138 />
138139 </div>
139140 <Link
+166−0
1+import { CircleX, KeyRound } from "lucide-react";
2+import { Form, redirect } from "react-router";
3+
4+import { decodeOAuthClient, isRegisteredRedirect } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/oauth-authorize";
7+import { Button } from "../components/ui";
8+import { identity } from "../lib/services.server";
9+import { assertSameOrigin, requireUser } from "../lib/session.server";
10+
11+export function meta({}: Route.MetaArgs) {
12+ return [{ title: "Sign in to an application · g1t" }];
13+}
14+
15+type 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+ */
31+function 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. */
64+function 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. */
73+export 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+
84+export 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+
106+export 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+}
+49−6
22 import { Form } from "react-router";
33
44 import type { Route } from "./+types/settings";
5−import { Button, ErrorText, Field, Input } from "../components/ui";
5+import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui";
66 import { assertSameOrigin, requireUser } from "../lib/session.server";
77
88 export function meta({}: Route.MetaArgs) {
1111
1212 export async function loader({ request, context }: Route.LoaderArgs) {
1313 const user = requireUser(context, request);
14− const [keys, tokens] = await Promise.all([
14+ const [keys, tokens, applications] = await Promise.all([
1515 identity.listSshKeys(user),
1616 identity.listAccessTokens(user),
17+ identity.listOAuthGrants(user),
1718 ]);
18− return { user, keys, tokens };
19+ return { user, keys, tokens, applications };
1920 }
2021
2122 export async function action({ request, context }: Route.ActionArgs) {
4647 case "delete-token":
4748 await identity.removeAccessToken(user, id);
4849 return null;
50+ case "sign-out-application":
51+ await identity.revokeOAuthGrant(user, id);
52+ return null;
4953 }
5054 return null;
5155 }
5256
53−function DeleteButton({ intent, id }: { intent: string; id: string }) {
57+function DeleteButton({
58+ intent,
59+ id,
60+ label = "Delete",
61+}: {
62+ intent: string;
63+ id: string;
64+ label?: string;
65+}) {
5466 return (
5567 <Form method="post" className="ml-auto">
5668 <input type="hidden" name="intent" value={intent} />
5769 <input type="hidden" name="id" value={id} />
5870 <Button variant="quiet" type="submit">
59− Delete
71+ {label}
6072 </Button>
6173 </Form>
6274 );
6678 loaderData,
6779 actionData,
6880 }: Route.ComponentProps) {
69− const { user, keys, tokens } = loaderData;
81+ const { user, keys, tokens, applications } = loaderData;
7082 return (
7183 <main className="mx-auto max-w-2xl space-y-12 px-4 py-12">
7284 <h1 className="text-xl font-semibold">
133145 <Button type="submit">Create token</Button>
134146 </Form>
135147 </section>
148+
149+ <section>
150+ <h2 className="font-medium">Connected applications</h2>
151+ <p className="mt-1 text-sm text-muted">
152+ Applications you signed in to through your browser, such as an agent
153+ connected to the g1t MCP server. Signing one out ends its access at
154+ once.
155+ </p>
156+ {applications.length === 0 ? (
157+ <p className="mt-4 text-sm text-faint">None yet.</p>
158+ ) : (
159+ <ul className="mt-4 divide-y divide-line rounded-md border border-line">
160+ {applications.map((application) => (
161+ <li key={application.id} className="flex items-center gap-4 px-4 py-3">
162+ <div>
163+ <p className="text-sm">{application.clientName}</p>
164+ <p className="text-xs text-faint">
165+ Connected <TimeAgo at={application.createdAt} /> · last used{" "}
166+ <TimeAgo at={application.lastUsedAt} />
167+ </p>
168+ </div>
169+ <DeleteButton
170+ intent="sign-out-application"
171+ id={application.id}
172+ label="Sign out"
173+ />
174+ </li>
175+ ))}
176+ </ul>
177+ )}
178+ </section>
136179 </main>
137180 );
138181 }
+8−1
5757 --header "Authorization: Bearer $G1T_TOKEN"
5858 ```
5959
60+ Without the header, a client that supports MCP authorization signs the
61+ person in through their browser instead (in Claude Code: `/mcp`, then
62+ choose g1t). The MCP server always needs one or the other.
63+
6064 5. **Create a workspace** if `GET /v1/user` shows none. A workspace owns
6165 repositories and is the first part of their address. Ask the person what
6266 to call it; their username is a sensible default.
138142 - Forgotten password: https://g1t.sh/forgot (the person does this, in a
139143 browser).
140144 - Times are RFC 3339 in UTC.
145+- OAuth 2.1 for applications: metadata at
146+ `https://api.g1t.sh/.well-known/oauth-authorization-server`; authorization
147+ code with PKCE (S256), public clients, dynamic registration.
141148 - Not available yet: pull requests from branches, merge commits made on
142− the server, running checks automatically, OAuth sign-in for MCP.
149+ the server, running checks automatically.
143150
144151 ## More
145152
+63−0
263263 pub slug: String,
264264 pub username: String,
265265 }
266+
267+/// `oauth_authorize`: the signed-in person approved an application. The
268+/// caller has checked the client and that it may be redirected to
269+/// `redirect_uri`. Returns `OAuthCode`.
270+#[derive(Debug, Serialize, Deserialize)]
271+#[serde(rename_all = "camelCase")]
272+pub struct OAuthAuthorizeArgs {
273+ pub user: User,
274+ pub client_id: String,
275+ /// Shown wherever the application's access is listed.
276+ pub client_name: String,
277+ pub redirect_uri: String,
278+ /// PKCE challenge, method S256.
279+ pub code_challenge: String,
280+}
281+
282+#[derive(Debug, Serialize, Deserialize)]
283+pub struct OAuthCode {
284+ pub code: String,
285+}
286+
287+/// `oauth_exchange`: redeems an authorization code.
288+/// Returns `Outcome<OAuthTokens>`.
289+#[derive(Debug, Serialize, Deserialize)]
290+#[serde(rename_all = "camelCase")]
291+pub struct OAuthExchangeArgs {
292+ pub code: String,
293+ pub code_verifier: String,
294+ pub client_id: String,
295+ pub redirect_uri: String,
296+}
297+
298+/// `oauth_refresh`: trades a refresh token for new tokens.
299+/// Returns `Outcome<OAuthTokens>`.
300+#[derive(Debug, Serialize, Deserialize)]
301+#[serde(rename_all = "camelCase")]
302+pub struct OAuthRefreshArgs {
303+ pub refresh_token: String,
304+ pub client_id: String,
305+}
306+
307+#[derive(Debug, Serialize, Deserialize)]
308+#[serde(rename_all = "camelCase")]
309+pub struct OAuthTokens {
310+ pub access_token: String,
311+ /// Works once; using it returns the next one.
312+ pub refresh_token: String,
313+ /// Seconds until the access token stops working.
314+ pub expires_in: u64,
315+}
316+
317+/// An application a person has signed in to. Listed by `list_oauth_grants`
318+/// and ended by `revoke_oauth_grant`.
319+#[derive(Debug, Serialize, Deserialize)]
320+#[serde(rename_all = "camelCase")]
321+pub struct OAuthGrant {
322+ pub id: String,
323+ pub client_name: String,
324+ /// RFC 3339.
325+ pub created_at: String,
326+ /// RFC 3339.
327+ pub last_used_at: String,
328+}
+7−5
502502 | `mcp.g1t.sh` | Remote MCP server over streamable HTTP |
503503
504504 g1t is its own OAuth 2.1 authorization server: authorization code with PKCE,
505−dynamic client registration, discovery metadata, refresh tokens, and scopes
505+dynamic client registration that stores nothing (a client id encodes its
506+own registration, so the open endpoint cannot be used to fill a database),
507+discovery metadata and rotating refresh tokens. Still to come: scopes
506508 per resource (`repo:read`, `repo:write`, `issue:write`, `pull:write`).
507509 MCP clients, the CLI (device flow) and third-party apps all use it. Access
508510 tokens and SSH keys remain for git itself.
512514 | Component | Language | Runs on | Responsibility |
513515 | --- | --- | --- | --- |
514516 | `crates/contracts`, `packages/contracts` | Rust, TypeScript | — | The interface of every service, the event catalogue, shared types. Services and clients depend on this, never on each other's code. |
515−| `services/identity` | Rust | Worker + D1 | Accounts, workspaces and memberships, sessions, SSH keys, access tokens, device sign-in; later the OAuth server |
517+| `services/identity` | Rust | Worker + D1 | Accounts, workspaces and memberships, sessions, SSH keys, access tokens, device sign-in, OAuth codes and grants |
516518 | `services/repos` | Rust | Worker + D1 + Artifacts | Repository registry, contents, forks, diffs, landing, git over HTTPS. Storage sits behind a `GitStore` port with an Artifacts adapter. |
517519 | `services/work` | Rust | Worker + D1 | Issues, pull requests, comments, sessions; later a Durable Object per repo for the landing queue and live state |
518520 | `services/events` | TypeScript, moving to Rust | Worker + Queues + D1 | The event bus: durable log, and one queue per subscribing service |
642644
643645 Done: site with marketing page; separate docs site with API explorer; git
644646 over HTTPS; accounts with registration, email verification, password reset
645−and device sign-in; workspaces with members; issues with labels, checks and
647+and device sign-in; an OAuth 2.1 server, so MCP clients sign in through the
648+browser with no token to paste; workspaces with members; issues with labels, checks and
646649 comments; pull requests in forks with diffs and sessions, several per
647650 issue; merging with a behind check, which resolves the issue and supersedes
648651 the rest; g1t agents in sandboxes with a choice of model; REST API, OpenAPI
649652 and MCP server; event bus. Identity, repos and work are in Rust.
650653
651654 1. Pull requests from branches pushed to the repository.
652−2. OAuth server, so MCP clients sign in through the browser with no token
653− to paste.
655+2. Scopes on OAuth grants and access tokens.
654656 3. Port events and the API to Rust; event storage per the design above.
655657 4. CLI with Claude Code hooks to record sessions automatically.
656658 5. Acceptance checks run in sandboxes; review comments on lines.
+7−0
4545 deviceResolve: (userCode, user, approve) =>
4646 call("device_resolve", { userCode, user, approve }),
4747 deviceClaim: (deviceCode) => call("device_claim", { deviceCode }),
48+ oauthAuthorize: (user, approval) => call("oauth_authorize", { user, ...approval }),
49+ oauthExchange: (code, codeVerifier, clientId, redirectUri) =>
50+ call("oauth_exchange", { code, codeVerifier, clientId, redirectUri }),
51+ oauthRefresh: (refreshToken, clientId) =>
52+ call("oauth_refresh", { refreshToken, clientId }),
53+ listOAuthGrants: (user) => call("list_oauth_grants", { user }),
54+ revokeOAuthGrant: (user, id) => call("revoke_oauth_grant", { user, id }),
4855 createWorkspace: (user, slug, name) => call("create_workspace", { user, slug, name }),
4956 getWorkspace: (slug) => call("get_workspace", { slug }),
5057 listMembers: (slug, viewer) => call("list_members", { slug, viewer }),
+43−0
6565 | { status: "pending" | "denied" | "expired" }
6666 | { status: "approved"; token: string; user: User };
6767
68+/** What the site passes on once a person has approved an application. */
69+export type OAuthApproval = {
70+ clientId: string;
71+ /** Shown wherever the application's access is listed. */
72+ clientName: string;
73+ redirectUri: string;
74+ /** PKCE challenge, method S256. */
75+ codeChallenge: string;
76+};
77+
78+export type OAuthTokens = {
79+ accessToken: string;
80+ /** Works once; using it returns the next one. */
81+ refreshToken: string;
82+ /** Seconds until the access token stops working. */
83+ expiresIn: number;
84+};
85+
86+/** An application a person has signed in to. */
87+export type OAuthGrant = {
88+ id: string;
89+ clientName: string;
90+ /** RFC 3339. */
91+ createdAt: string;
92+ /** RFC 3339. */
93+ lastUsedAt: string;
94+};
95+
6896 /** Accounts, credentials and sessions. */
6997 export interface IdentityApi {
7098 /** Creates an account and signs it in. */
92120 deviceResolve(userCode: string, user: User, approve: boolean): Promise<Result<boolean>>;
93121 deviceClaim(deviceCode: string): Promise<DeviceClaim>;
94122
123+ /**
124+ * OAuth 2.1 for applications that sign a person in through the browser.
125+ * The caller has checked the client and its redirect address; this
126+ * returns the one-time code the application exchanges for tokens.
127+ */
128+ oauthAuthorize(user: User, approval: OAuthApproval): Promise<{ code: string }>;
129+ /** Redeems a code. It works once, for that client, with the PKCE verifier. */
130+ oauthExchange(code: string, codeVerifier: string, clientId: string, redirectUri: string): Promise<Result<OAuthTokens>>;
131+ /** Trades a refresh token for new tokens; the old ones stop working. */
132+ oauthRefresh(refreshToken: string, clientId: string): Promise<Result<OAuthTokens>>;
133+ /** Applications the user has signed in to, most recently used first. */
134+ listOAuthGrants(user: User): Promise<OAuthGrant[]>;
135+ /** Signs an application out. */
136+ revokeOAuthGrant(user: User, id: string): Promise<void>;
137+
95138 createWorkspace(user: User, slug: string, name: string): Promise<Result<Workspace>>;
96139 /** Public details of a workspace, or null. */
97140 getWorkspace(slug: string): Promise<Workspace | null>;
+1−0
33 export * from "./identity";
44 export * from "./ids";
55 export * from "./names";
6+export * from "./oauth";
67 export * from "./repos";
78 export * from "./result";
89 export * from "./runner";
+107−0
1+/**
2+ * OAuth clients. A client is not stored anywhere: its id is its name and
3+ * redirect addresses, encoded. Registering one therefore writes nothing,
4+ * and anyone holding a client id can read what it claims to be.
5+ *
6+ * That is safe because a public client has no secret to protect: what
7+ * stops a stolen authorization code being used is PKCE, and what a person
8+ * approves is the redirect address shown to them.
9+ */
10+export type OAuthClient = {
11+ /** Shown to the person approving, e.g. "Claude Code". */
12+ name: string;
13+ redirectUris: string[];
14+};
15+
16+const PREFIX = "g1c_";
17+const MAX_NAME_CHARS = 80;
18+const MAX_REDIRECTS = 5;
19+const MAX_URI_CHARS = 500;
20+/** Schemes that run or expose content instead of opening an application. */
21+const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "file:", "blob:", "vbscript:", "about:"]);
22+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
23+
24+function toBase64Url(text: string): string {
25+ const bytes = new TextEncoder().encode(text);
26+ let binary = "";
27+ for (const byte of bytes) binary += String.fromCharCode(byte);
28+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
29+}
30+
31+function fromBase64Url(encoded: string): string {
32+ const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/"));
33+ return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
34+}
35+
36+function parse(uri: string): URL | null {
37+ try {
38+ return new URL(uri);
39+ } catch {
40+ return null;
41+ }
42+}
43+
44+function isLoopback(url: URL): boolean {
45+ return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
46+}
47+
48+/**
49+ * Whether an application may ask to be redirected here: an https address,
50+ * http on this machine only, or an application's own scheme.
51+ */
52+export function isValidRedirectUri(uri: string): boolean {
53+ const url = parse(uri);
54+ if (!url || uri.length > MAX_URI_CHARS || url.hash) return false;
55+ if (url.protocol === "https:") return true;
56+ if (url.protocol === "http:") return isLoopback(url);
57+ return !FORBIDDEN_SCHEMES.has(url.protocol);
58+}
59+
60+/** The client id for a client, or null if what it asks for is not allowed. */
61+export function encodeOAuthClient(client: OAuthClient): string | null {
62+ const name = client.name.trim().slice(0, MAX_NAME_CHARS) || "An application";
63+ const { redirectUris } = client;
64+ if (
65+ redirectUris.length === 0 ||
66+ redirectUris.length > MAX_REDIRECTS ||
67+ !redirectUris.every(isValidRedirectUri)
68+ ) {
69+ return null;
70+ }
71+ return PREFIX + toBase64Url(JSON.stringify({ n: name, r: redirectUris }));
72+}
73+
74+export function decodeOAuthClient(clientId: string): OAuthClient | null {
75+ if (!clientId.startsWith(PREFIX)) return null;
76+ try {
77+ const { n, r } = JSON.parse(fromBase64Url(clientId.slice(PREFIX.length)));
78+ if (typeof n !== "string" || !Array.isArray(r)) return null;
79+ const client = { name: n, redirectUris: r.map(String) };
80+ // Decoding applies the same rules as encoding, so a hand-made id gains nothing.
81+ return encodeOAuthClient(client) ? client : null;
82+ } catch {
83+ return null;
84+ }
85+}
86+
87+/**
88+ * Whether `uri` is one of the client's redirect addresses. Addresses must
89+ * match exactly, except that an application listening on this machine may
90+ * use any port, since it cannot know which will be free.
91+ */
92+export function isRegisteredRedirect(client: OAuthClient, uri: string): boolean {
93+ const asked = parse(uri);
94+ if (!asked) return false;
95+ return client.redirectUris.some((registered) => {
96+ if (registered === uri) return true;
97+ const known = parse(registered);
98+ return (
99+ known !== null &&
100+ isLoopback(known) &&
101+ isLoopback(asked) &&
102+ known.hostname === asked.hostname &&
103+ known.pathname === asked.pathname &&
104+ known.search === asked.search
105+ );
106+ });
107+}
+31−0
1+-- OAuth 2.1 sign-in for applications, such as MCP clients.
2+-- Clients are not stored: a client id carries its own name and redirect
3+-- addresses, so registering one writes nothing.
4+
5+CREATE TABLE oauth_codes (
6+ -- SHA-256 of the authorization code.
7+ id TEXT PRIMARY KEY,
8+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
9+ client_id TEXT NOT NULL,
10+ client_name TEXT NOT NULL,
11+ redirect_uri TEXT NOT NULL,
12+ -- PKCE, method S256.
13+ code_challenge TEXT NOT NULL,
14+ expires_at TEXT NOT NULL
15+);
16+
17+-- One per application a person has signed in to.
18+CREATE TABLE oauth_grants (
19+ id TEXT PRIMARY KEY,
20+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
21+ client_id TEXT NOT NULL,
22+ client_name TEXT NOT NULL,
23+ -- SHA-256 of the current refresh token; replaced on every use.
24+ refresh_hash TEXT UNIQUE,
25+ -- The access token issued with it, revoked when the grant is refreshed.
26+ access_token_id TEXT,
27+ created_at TEXT NOT NULL,
28+ last_used_at TEXT NOT NULL,
29+ expires_at TEXT
30+);
31+CREATE INDEX oauth_grants_by_user ON oauth_grants (user_id);
+6−0
66 mod crypto;
77 mod device;
88 mod email;
9+mod oauth;
910 mod workspaces;
1011
1112 use g1t_contracts::identity::*;
585586 "list_members" => reply(&identity.list_members(args(body)?).await?),
586587 "add_member" => reply(&identity.add_member(args(body)?).await?),
587588 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
589+ "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
590+ "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
591+ "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
592+ "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
593+ "revoke_oauth_grant" => reply(&identity.revoke_oauth_grant(args(body)?).await?),
588594 "device_start" => reply(&identity.device_start(args(body)?).await?),
589595 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
590596 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
+259−0
1+//! OAuth 2.1 authorization for applications, such as MCP clients, that sign
2+//! a person in through their browser: authorization code with PKCE, and
3+//! rotating refresh tokens.
4+//!
5+//! This service issues and redeems codes and tokens. Who the client is and
6+//! where it may be redirected is decided by the callers: the site, which
7+//! shows the consent page, and the API, which serves the token endpoint.
8+
9+use base64::Engine;
10+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11+use g1t_contracts::identity::*;
12+use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
13+use g1t_contracts::{FailureCode, Outcome, User, new_id};
14+use g1t_kit::now_ms;
15+use serde::Deserialize;
16+use sha2::{Digest, Sha256};
17+use worker::Result;
18+
19+use crate::{Identity, crypto};
20+
21+const CODE_TTL_SECONDS: u64 = 5 * 60;
22+const ACCESS_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
23+const REFRESH_TTL_SECONDS: u64 = 180 * 24 * 60 * 60;
24+const REFRESH_PREFIX: &str = "g1r_";
25+
26+#[derive(Deserialize)]
27+struct CodeRow {
28+ user_id: String,
29+ client_id: String,
30+ client_name: String,
31+ redirect_uri: String,
32+ code_challenge: String,
33+}
34+
35+#[derive(Deserialize)]
36+struct GrantRow {
37+ id: String,
38+ user_id: String,
39+ client_id: String,
40+ client_name: String,
41+ access_token_id: Option<String>,
42+}
43+
44+#[derive(Deserialize)]
45+struct GrantListRow {
46+ id: String,
47+ client_name: String,
48+ created_at: String,
49+ last_used_at: String,
50+}
51+
52+/// Whether `verifier` is the secret behind an S256 `challenge` (RFC 7636).
53+fn pkce_matches(verifier: &str, challenge: &str) -> bool {
54+ URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) == challenge
55+}
56+
57+fn invalid_grant<T>(message: &str) -> Outcome<T> {
58+ Outcome::fail(FailureCode::Invalid, message)
59+}
60+
61+impl Identity {
62+ /// Records that `user` approved the client and returns the one-time
63+ /// code the client exchanges for tokens.
64+ pub async fn oauth_authorize(&self, a: OAuthAuthorizeArgs) -> Result<OAuthCode> {
65+ let code = crypto::random_hex(32);
66+ self.db
67+ .prepare(format!(
68+ "INSERT INTO oauth_codes
69+ (id, user_id, client_id, client_name, redirect_uri, code_challenge, expires_at)
70+ VALUES (?, ?, ?, ?, ?, ?, {})",
71+ sql_after(CODE_TTL_SECONDS)
72+ ))
73+ .bind(&[
74+ crypto::sha256_hex(&code).into(),
75+ a.user.id.into(),
76+ a.client_id.into(),
77+ a.client_name.into(),
78+ a.redirect_uri.into(),
79+ a.code_challenge.into(),
80+ ])?
81+ .run()
82+ .await?;
83+ Ok(OAuthCode { code })
84+ }
85+
86+ /// Redeems an authorization code. A code works once, only for the client
87+ /// and redirect it was issued to, and only with the PKCE verifier.
88+ pub async fn oauth_exchange(&self, a: OAuthExchangeArgs) -> Result<Outcome<OAuthTokens>> {
89+ let id = crypto::sha256_hex(&a.code);
90+ let row = self
91+ .db
92+ .prepare(format!(
93+ "DELETE FROM oauth_codes WHERE id = ? AND expires_at > {SQL_NOW}
94+ RETURNING user_id, client_id, client_name, redirect_uri, code_challenge"
95+ ))
96+ .bind(&[id.into()])?
97+ .first::<CodeRow>(None)
98+ .await?;
99+ let Some(row) = row else {
100+ return Ok(invalid_grant(
101+ "That code is not valid, has expired, or was already used.",
102+ ));
103+ };
104+ if row.client_id != a.client_id || row.redirect_uri != a.redirect_uri {
105+ return Ok(invalid_grant("That code was issued to a different client."));
106+ }
107+ if !pkce_matches(&a.code_verifier, &row.code_challenge) {
108+ return Ok(invalid_grant("The code verifier does not match."));
109+ }
110+ let now = now_ms();
111+ let grant_id = new_id("oag", now);
112+ self.db
113+ .prepare(
114+ "INSERT INTO oauth_grants
115+ (id, user_id, client_id, client_name, created_at, last_used_at)
116+ VALUES (?, ?, ?, ?, ?, ?)",
117+ )
118+ .bind(&[
119+ grant_id.as_str().into(),
120+ row.user_id.as_str().into(),
121+ row.client_id.into(),
122+ row.client_name.as_str().into(),
123+ rfc3339(now).into(),
124+ rfc3339(now).into(),
125+ ])?
126+ .run()
127+ .await?;
128+ Ok(Outcome::Ok(
129+ self.issue_oauth_tokens(&grant_id, &row.user_id, &row.client_name)
130+ .await?,
131+ ))
132+ }
133+
134+ /// Trades a refresh token for new tokens. The refresh token and the
135+ /// access token issued with it stop working.
136+ pub async fn oauth_refresh(&self, a: OAuthRefreshArgs) -> Result<Outcome<OAuthTokens>> {
137+ let row = self
138+ .db
139+ .prepare(format!(
140+ "SELECT id, user_id, client_id, client_name, access_token_id FROM oauth_grants
141+ WHERE refresh_hash = ? AND expires_at > {SQL_NOW}"
142+ ))
143+ .bind(&[crypto::sha256_hex(&a.refresh_token).into()])?
144+ .first::<GrantRow>(None)
145+ .await?;
146+ let Some(row) = row.filter(|row| row.client_id == a.client_id) else {
147+ return Ok(invalid_grant(
148+ "That refresh token is not valid or has expired. Sign in again.",
149+ ));
150+ };
151+ if let Some(previous) = &row.access_token_id {
152+ self.db
153+ .prepare("DELETE FROM access_tokens WHERE id = ?")
154+ .bind(&[previous.as_str().into()])?
155+ .run()
156+ .await?;
157+ }
158+ Ok(Outcome::Ok(
159+ self.issue_oauth_tokens(&row.id, &row.user_id, &row.client_name)
160+ .await?,
161+ ))
162+ }
163+
164+ /// A new access token and refresh token for a grant.
165+ async fn issue_oauth_tokens(
166+ &self,
167+ grant_id: &str,
168+ user_id: &str,
169+ client_name: &str,
170+ ) -> Result<OAuthTokens> {
171+ let access = self
172+ .create_access_token(CreateAccessTokenArgs {
173+ user: User {
174+ id: user_id.to_owned(),
175+ ..User::default()
176+ },
177+ name: client_name.to_owned(),
178+ ttl_seconds: Some(ACCESS_TTL_SECONDS),
179+ })
180+ .await?;
181+ let refresh_token = format!("{REFRESH_PREFIX}{}", crypto::random_hex(32));
182+ self.db
183+ .prepare(format!(
184+ "UPDATE oauth_grants
185+ SET refresh_hash = ?, access_token_id = ?, last_used_at = {SQL_NOW},
186+ expires_at = {}
187+ WHERE id = ?",
188+ sql_after(REFRESH_TTL_SECONDS)
189+ ))
190+ .bind(&[
191+ crypto::sha256_hex(&refresh_token).into(),
192+ access.info.id.into(),
193+ grant_id.into(),
194+ ])?
195+ .run()
196+ .await?;
197+ Ok(OAuthTokens {
198+ access_token: access.token,
199+ refresh_token,
200+ expires_in: ACCESS_TTL_SECONDS,
201+ })
202+ }
203+
204+ /// Applications the user has signed in to, most recently used first.
205+ pub async fn list_oauth_grants(&self, a: UserArgs) -> Result<Vec<OAuthGrant>> {
206+ let rows = self
207+ .db
208+ .prepare(format!(
209+ "SELECT id, client_name, created_at, last_used_at FROM oauth_grants
210+ WHERE user_id = ? AND expires_at > {SQL_NOW} ORDER BY last_used_at DESC"
211+ ))
212+ .bind(&[a.user.id.into()])?
213+ .all()
214+ .await?
215+ .results::<GrantListRow>()?;
216+ Ok(rows
217+ .into_iter()
218+ .map(|row| OAuthGrant {
219+ id: row.id,
220+ client_name: row.client_name,
221+ created_at: row.created_at,
222+ last_used_at: row.last_used_at,
223+ })
224+ .collect())
225+ }
226+
227+ /// Signs an application out: its refresh token and access token stop
228+ /// working.
229+ pub async fn revoke_oauth_grant(&self, a: RemoveArgs) -> Result<()> {
230+ self.db
231+ .batch(vec![
232+ self.db
233+ .prepare(
234+ "DELETE FROM access_tokens WHERE id =
235+ (SELECT access_token_id FROM oauth_grants WHERE id = ? AND user_id = ?)",
236+ )
237+ .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
238+ self.db
239+ .prepare("DELETE FROM oauth_grants WHERE id = ? AND user_id = ?")
240+ .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
241+ ])
242+ .await?;
243+ Ok(())
244+ }
245+}
246+
247+#[cfg(test)]
248+mod tests {
249+ use super::pkce_matches;
250+
251+ #[test]
252+ fn pkce_verifier_matches_its_challenge() {
253+ // The example from RFC 7636, appendix B.
254+ let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
255+ let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
256+ assert!(pkce_matches(verifier, challenge));
257+ assert!(!pkce_matches("something else", challenge));
258+ }
259+}