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
| 19 | 19 | ||
| 20 | 20 | Working today: | |
| 21 | 21 | ||
| 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. | |
| 24 | 25 | - Workspaces that own repositories, with members and roles. | |
| 25 | 26 | - Public and private repositories, and git over HTTPS, including creating a | |
| 26 | 27 | repository by pushing to it. | |
| 37 | 38 | subscribers. | |
| 38 | 39 | ||
| 39 | 40 | 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. | |
| 42 | 42 | ||
| 43 | 43 | ## Try it | |
| 44 | 44 | ||
| 45 | 45 | ```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. | |
| 49 | 47 | ||
| 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 | |
| 53 | 50 | ||
| 54 | 51 | # 3. Ask it to open a pull request for an open issue. | |
| 55 | 52 | ``` |
| 11 | 11 | } from "@g1t/contracts"; | |
| 12 | 12 | ||
| 13 | 13 | import { handleMcp } from "./mcp"; | |
| 14 | + | import { MCP_CHALLENGE, oauth } from "./oauth"; | |
| 14 | 15 | import { openApiDocument } from "./openapi"; | |
| 15 | 16 | import { type ApiEnv, operations, operationsByName } from "./operations"; | |
| 16 | 17 | ||
| 104 | 105 | return c.json( | |
| 105 | 106 | { error: { code: "unauthenticated", message: "Invalid access token." } }, | |
| 106 | 107 | 401, | |
| 108 | + | // Tells an MCP client where to sign in again. | |
| 109 | + | { "www-authenticate": `${MCP_CHALLENGE}, error="invalid_token"` }, | |
| 107 | 110 | ); | |
| 108 | 111 | } | |
| 109 | 112 | } | |
| 111 | 114 | await next(); | |
| 112 | 115 | }); | |
| 113 | 116 | ||
| 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 | + | ||
| 114 | 121 | 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 | + | ); | |
| 117 | 137 | } | |
| 118 | − | await next(); | |
| 138 | + | return handleMcp(c.req.raw, c.get("services"), viewer); | |
| 119 | 139 | }); | |
| 120 | 140 | ||
| 121 | 141 | // Signing in from a tool. Accounts are created, and passwords typed, only |
| 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 | + | }); |
| 56 | 56 | ||
| 57 | 57 | A token has the full rights of your account. Scoped tokens are planned. | |
| 58 | 58 | ||
| 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 | + | ||
| 59 | 91 | ## Signing in from a tool | |
| 60 | 92 | ||
| 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: | |
| 63 | 96 | ||
| 64 | 97 | 1. The tool asks g1t for a code and shows you a link and a short code such | |
| 65 | 98 | as `WDJB-MJHT`. |
| 8 | 8 | ||
| 9 | 9 | ## Claude Code | |
| 10 | 10 | ||
| 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: | |
| 12 | 26 | ||
| 13 | 27 | ```sh | |
| 14 | 28 | claude mcp add --transport http g1t https://mcp.g1t.sh \ | |
| 96 | 110 | ## Other clients | |
| 97 | 111 | ||
| 98 | 112 | 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. |
| 24 | 24 | ||
| 25 | 25 | ## 2. Create an access token | |
| 26 | 26 | ||
| 27 | − | Git, the API and agents authenticate with an access token. Open | |
| 27 | + | Git and the API authenticate with an access token. Open | |
| 28 | 28 | [Settings](https://g1t.sh/settings), give the token a name and create it. Copy it | |
| 29 | 29 | immediately; it is shown once. | |
| 30 | 30 | ||
| 60 | 60 | Connect Claude Code to g1t: | |
| 61 | 61 | ||
| 62 | 62 | ```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 | |
| 65 | 64 | ``` | |
| 66 | 65 | ||
| 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: | |
| 68 | 68 | ||
| 69 | 69 | > Work on issue 1 of `<workspace>/my-project` on g1t. Open a pull request for | |
| 70 | 70 | > it and record your session as you go. |
| 33 | 33 | | `POST` | `/v1/device/code` | Start a sign-in. Body: `client_name`. | | |
| 34 | 34 | | `POST` | `/v1/device/token` | Ask whether it was approved. Body: `device_code`. | | |
| 35 | 35 | ||
| 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. | |
| 37 | 47 | ||
| 38 | 48 | ## Errors | |
| 39 | 49 |
| 30 | 30 | <TabsContent value="agent"> | |
| 31 | 31 | <CopyLine | |
| 32 | 32 | 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" | |
| 34 | 34 | /> | |
| 35 | 35 | <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{" "} | |
| 37 | 38 | <span className="font-mono text-fg">{path}</span>.{" "} | |
| 38 | 39 | <Link to="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4"> | |
| 39 | 40 | More |
| 196 | 196 | <div className="space-y-3"> | |
| 197 | 197 | <CopyLine | |
| 198 | 198 | 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" | |
| 200 | 200 | /> | |
| 201 | 201 | <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" /> | |
| 202 | 202 | <CopyLine |
| 9 | 9 | route("forgot", "routes/forgot.tsx"), | |
| 10 | 10 | route("reset", "routes/reset.tsx"), | |
| 11 | 11 | route("device", "routes/device.tsx"), | |
| 12 | + | route("oauth/authorize", "routes/oauth-authorize.tsx"), | |
| 12 | 13 | route("new", "routes/new.tsx"), | |
| 13 | 14 | route("settings", "routes/settings.tsx"), | |
| 14 | 15 | route("explore", "routes/explore.tsx", { id: "explore" }), |
| 128 | 128 | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 129 | 129 | <h2 className="font-medium">Connect an agent</h2> | |
| 130 | 130 | <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. | |
| 132 | 133 | </p> | |
| 133 | 134 | <div className="mt-4 space-y-2"> | |
| 134 | 135 | <CopyLine | |
| 135 | 136 | 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" | |
| 137 | 138 | /> | |
| 138 | 139 | </div> | |
| 139 | 140 | <Link |
| 2 | 2 | import { Form } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | 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"; | |
| 6 | 6 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 7 | 7 | ||
| 8 | 8 | export function meta({}: Route.MetaArgs) { | |
| 11 | 11 | ||
| 12 | 12 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 13 | 13 | const user = requireUser(context, request); | |
| 14 | − | const [keys, tokens] = await Promise.all([ | |
| 14 | + | const [keys, tokens, applications] = await Promise.all([ | |
| 15 | 15 | identity.listSshKeys(user), | |
| 16 | 16 | identity.listAccessTokens(user), | |
| 17 | + | identity.listOAuthGrants(user), | |
| 17 | 18 | ]); | |
| 18 | − | return { user, keys, tokens }; | |
| 19 | + | return { user, keys, tokens, applications }; | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | export async function action({ request, context }: Route.ActionArgs) { | |
| 46 | 47 | case "delete-token": | |
| 47 | 48 | await identity.removeAccessToken(user, id); | |
| 48 | 49 | return null; | |
| 50 | + | case "sign-out-application": | |
| 51 | + | await identity.revokeOAuthGrant(user, id); | |
| 52 | + | return null; | |
| 49 | 53 | } | |
| 50 | 54 | return null; | |
| 51 | 55 | } | |
| 52 | 56 | ||
| 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 | + | }) { | |
| 54 | 66 | return ( | |
| 55 | 67 | <Form method="post" className="ml-auto"> | |
| 56 | 68 | <input type="hidden" name="intent" value={intent} /> | |
| 57 | 69 | <input type="hidden" name="id" value={id} /> | |
| 58 | 70 | <Button variant="quiet" type="submit"> | |
| 59 | − | Delete | |
| 71 | + | {label} | |
| 60 | 72 | </Button> | |
| 61 | 73 | </Form> | |
| 62 | 74 | ); | |
| 66 | 78 | loaderData, | |
| 67 | 79 | actionData, | |
| 68 | 80 | }: Route.ComponentProps) { | |
| 69 | − | const { user, keys, tokens } = loaderData; | |
| 81 | + | const { user, keys, tokens, applications } = loaderData; | |
| 70 | 82 | return ( | |
| 71 | 83 | <main className="mx-auto max-w-2xl space-y-12 px-4 py-12"> | |
| 72 | 84 | <h1 className="text-xl font-semibold"> | |
| 133 | 145 | <Button type="submit">Create token</Button> | |
| 134 | 146 | </Form> | |
| 135 | 147 | </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> | |
| 136 | 179 | </main> | |
| 137 | 180 | ); | |
| 138 | 181 | } |
| 57 | 57 | --header "Authorization: Bearer $G1T_TOKEN" | |
| 58 | 58 | ``` | |
| 59 | 59 | ||
| 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 | + | ||
| 60 | 64 | 5. **Create a workspace** if `GET /v1/user` shows none. A workspace owns | |
| 61 | 65 | repositories and is the first part of their address. Ask the person what | |
| 62 | 66 | to call it; their username is a sensible default. | |
| 138 | 142 | - Forgotten password: https://g1t.sh/forgot (the person does this, in a | |
| 139 | 143 | browser). | |
| 140 | 144 | - 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. | |
| 141 | 148 | - 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. | |
| 143 | 150 | ||
| 144 | 151 | ## More | |
| 145 | 152 |
| 263 | 263 | pub slug: String, | |
| 264 | 264 | pub username: String, | |
| 265 | 265 | } | |
| 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 | + | } |
| 502 | 502 | | `mcp.g1t.sh` | Remote MCP server over streamable HTTP | | |
| 503 | 503 | ||
| 504 | 504 | 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 | |
| 506 | 508 | per resource (`repo:read`, `repo:write`, `issue:write`, `pull:write`). | |
| 507 | 509 | MCP clients, the CLI (device flow) and third-party apps all use it. Access | |
| 508 | 510 | tokens and SSH keys remain for git itself. | |
| 512 | 514 | | Component | Language | Runs on | Responsibility | | |
| 513 | 515 | | --- | --- | --- | --- | | |
| 514 | 516 | | `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 | | |
| 516 | 518 | | `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. | | |
| 517 | 519 | | `services/work` | Rust | Worker + D1 | Issues, pull requests, comments, sessions; later a Durable Object per repo for the landing queue and live state | | |
| 518 | 520 | | `services/events` | TypeScript, moving to Rust | Worker + Queues + D1 | The event bus: durable log, and one queue per subscribing service | | |
| 642 | 644 | ||
| 643 | 645 | Done: site with marketing page; separate docs site with API explorer; git | |
| 644 | 646 | 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 | |
| 646 | 649 | comments; pull requests in forks with diffs and sessions, several per | |
| 647 | 650 | issue; merging with a behind check, which resolves the issue and supersedes | |
| 648 | 651 | the rest; g1t agents in sandboxes with a choice of model; REST API, OpenAPI | |
| 649 | 652 | and MCP server; event bus. Identity, repos and work are in Rust. | |
| 650 | 653 | ||
| 651 | 654 | 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. | |
| 654 | 656 | 3. Port events and the API to Rust; event storage per the design above. | |
| 655 | 657 | 4. CLI with Claude Code hooks to record sessions automatically. | |
| 656 | 658 | 5. Acceptance checks run in sandboxes; review comments on lines. |
| 45 | 45 | deviceResolve: (userCode, user, approve) => | |
| 46 | 46 | call("device_resolve", { userCode, user, approve }), | |
| 47 | 47 | 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 }), | |
| 48 | 55 | createWorkspace: (user, slug, name) => call("create_workspace", { user, slug, name }), | |
| 49 | 56 | getWorkspace: (slug) => call("get_workspace", { slug }), | |
| 50 | 57 | listMembers: (slug, viewer) => call("list_members", { slug, viewer }), |
| 65 | 65 | | { status: "pending" | "denied" | "expired" } | |
| 66 | 66 | | { status: "approved"; token: string; user: User }; | |
| 67 | 67 | ||
| 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 | + | ||
| 68 | 96 | /** Accounts, credentials and sessions. */ | |
| 69 | 97 | export interface IdentityApi { | |
| 70 | 98 | /** Creates an account and signs it in. */ | |
| 92 | 120 | deviceResolve(userCode: string, user: User, approve: boolean): Promise<Result<boolean>>; | |
| 93 | 121 | deviceClaim(deviceCode: string): Promise<DeviceClaim>; | |
| 94 | 122 | ||
| 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 | + | ||
| 95 | 138 | createWorkspace(user: User, slug: string, name: string): Promise<Result<Workspace>>; | |
| 96 | 139 | /** Public details of a workspace, or null. */ | |
| 97 | 140 | getWorkspace(slug: string): Promise<Workspace | null>; |
| 3 | 3 | export * from "./identity"; | |
| 4 | 4 | export * from "./ids"; | |
| 5 | 5 | export * from "./names"; | |
| 6 | + | export * from "./oauth"; | |
| 6 | 7 | export * from "./repos"; | |
| 7 | 8 | export * from "./result"; | |
| 8 | 9 | export * from "./runner"; |
| 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 | + | } |
| 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 | 6 | mod crypto; | |
| 7 | 7 | mod device; | |
| 8 | 8 | mod email; | |
| 9 | + | mod oauth; | |
| 9 | 10 | mod workspaces; | |
| 10 | 11 | ||
| 11 | 12 | use g1t_contracts::identity::*; | |
| 585 | 586 | "list_members" => reply(&identity.list_members(args(body)?).await?), | |
| 586 | 587 | "add_member" => reply(&identity.add_member(args(body)?).await?), | |
| 587 | 588 | "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?), | |
| 588 | 594 | "device_start" => reply(&identity.device_start(args(body)?).await?), | |
| 589 | 595 | "device_lookup" => reply(&identity.device_lookup(args(body)?).await?), | |
| 590 | 596 | "device_resolve" => reply(&identity.device_resolve(args(body)?).await?), |
| 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 | + | } |