g1t/apps/api/src/oauth.rs

306 lines10,923 bytesCodeBlame

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

API and MCP server in Rust; a public index at the API root1//! The OAuth 2.1 endpoints an application calls directly. The page where a
2//! person approves is on the site, at g1t.sh/oauth/authorize.
3//!
4//! Applications sign people in with the authorization code flow and PKCE.
5//! They are public clients: none holds a secret.
6
7use base64::Engine;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use g1t_contracts::Outcome;
10use g1t_contracts::identity::{OAuthExchangeArgs, OAuthRefreshArgs, OAuthTokens};
11use serde::Serialize;
12use serde_json::{Map, Value, json};
13use worker::{Request, Response, Result, Url};
14
15use crate::operations::Services;
16
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue17use crate::addresses::Addresses;
API and MCP server in Rust; a public index at the API root18
19const CLIENT_PREFIX: &str = "g1c_";
20const MAX_NAME_CHARS: usize = 80;
21const MAX_REDIRECTS: usize = 5;
22const MAX_URI_CHARS: usize = 500;
23/// Schemes that run or expose content instead of opening an application.
24const FORBIDDEN_SCHEMES: [&str; 6] = ["javascript", "data", "file", "blob", "vbscript", "about"];
25const LOOPBACK_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
26
27/// Whether an application may ask to be redirected here: an https address,
28/// http on this machine only, or an application's own scheme.
29fn is_valid_redirect_uri(uri: &str) -> bool {
30 let Ok(url) = Url::parse(uri) else {
31 return false;
32 };
33 if uri.len() > MAX_URI_CHARS || url.fragment().is_some() {
34 return false;
35 }
36 match url.scheme() {
37 "https" => true,
38 "http" => url
39 .host_str()
40 .is_some_and(|host| LOOPBACK_HOSTS.contains(&host)),
41 scheme => !FORBIDDEN_SCHEMES.contains(&scheme),
42 }
43}
44
45/// A client as its id carries it. The site decodes the same shape; see
46/// `packages/contracts/src/oauth.ts`.
47#[derive(Serialize)]
48struct Client<'a> {
49 n: &'a str,
50 r: &'a [String],
51}
52
53/// The client id for a client, or `None` if what it asks for is not
54/// allowed. Nothing is stored: the id is the registration itself, encoded,
55/// so this open endpoint cannot be used to fill a database.
56fn encode_client(name: &str, redirect_uris: &[String]) -> Option<(String, String)> {
57 let name: String = name.trim().chars().take(MAX_NAME_CHARS).collect();
58 let name = if name.is_empty() {
59 "An application".to_owned()
60 } else {
61 name
62 };
63 let allowed = !redirect_uris.is_empty()
64 && redirect_uris.len() <= MAX_REDIRECTS
65 && redirect_uris.iter().all(|uri| is_valid_redirect_uri(uri));
66 if !allowed {
67 return None;
68 }
69 let encoded = serde_json::to_string(&Client {
70 n: &name,
71 r: redirect_uris,
72 })
73 .ok()?;
74 Some((
75 format!("{CLIENT_PREFIX}{}", URL_SAFE_NO_PAD.encode(encoded)),
76 name,
77 ))
78}
79
80fn oauth_error(error: &str, description: &str) -> Result<Response> {
81 let mut response =
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API82 crate::reply(&json!({ "error": error, "error_description": description }))?
API and MCP server in Rust; a public index at the API root83 .with_status(400);
84 response.headers_mut().set("cache-control", "no-store")?;
85 Ok(response)
86}
87
88/// The request body as fields, whether sent as a form or as JSON.
89async fn fields(request: &mut Request) -> Map<String, Value> {
90 let json = request
91 .headers()
92 .get("content-type")
93 .ok()
94 .flatten()
95 .is_some_and(|kind| kind.contains("json"));
96 let body = request.text().await.unwrap_or_default();
97 if json {
98 return match serde_json::from_str(&body) {
99 Ok(Value::Object(fields)) => fields,
100 _ => Map::new(),
101 };
102 }
103 form_urlencoded::parse(body.as_bytes())
104 .map(|(name, value)| (name.into_owned(), Value::String(value.into_owned())))
105 .collect()
106}
107
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue108/// The issuer is the API (API_URL); people approve on the site (SITE_URL).
109fn server_metadata(addresses: &Addresses) -> Value {
110 let issuer = &addresses.api;
API and MCP server in Rust; a public index at the API root111 json!({
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue112 "issuer": issuer,
113 "authorization_endpoint": format!("{}/oauth/authorize", addresses.site),
114 "token_endpoint": format!("{issuer}/oauth/token"),
115 "registration_endpoint": format!("{issuer}/oauth/register"),
API and MCP server in Rust; a public index at the API root116 "response_types_supported": ["code"],
117 "grant_types_supported": ["authorization_code", "refresh_token"],
118 "code_challenge_methods_supported": ["S256"],
119 "token_endpoint_auth_methods_supported": ["none"],
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step120 // A client may ask for some of these with `scope`; the person
121 // approving can trim them. Asking for none gives the agent preset.
122 "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()),
API and MCP server in Rust; a public index at the API root123 "service_documentation": "https://docs.g1t.sh/guides/authentication/",
124 })
125}
126
127async fn register(request: &mut Request) -> Result<Response> {
128 let body = fields(request).await;
129 let redirect_uris: Vec<String> = body
130 .get("redirect_uris")
131 .and_then(Value::as_array)
132 .map(|uris| {
133 uris.iter()
134 .filter_map(|uri| uri.as_str().map(str::to_owned))
135 .collect()
136 })
137 .unwrap_or_default();
138 let name = body
139 .get("client_name")
140 .and_then(Value::as_str)
141 .unwrap_or_default();
142 let Some((client_id, client_name)) = encode_client(name, &redirect_uris) else {
143 return oauth_error(
144 "invalid_redirect_uri",
145 "Give one to five redirect_uris: https addresses, http on localhost, or the application's own scheme.",
146 );
147 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API148 Ok(crate::reply(&json!({
API and MCP server in Rust; a public index at the API root149 "client_id": client_id,
150 "client_name": client_name,
151 "redirect_uris": redirect_uris,
152 "grant_types": ["authorization_code", "refresh_token"],
153 "response_types": ["code"],
154 "token_endpoint_auth_method": "none",
155 }))?
156 .with_status(201))
157}
158
159async fn token(request: &mut Request, services: &Services) -> Result<Response> {
160 let body = fields(request).await;
161 let text = |key: &str| {
162 body.get(key)
163 .and_then(Value::as_str)
164 .unwrap_or_default()
165 .to_owned()
166 };
167 let issued: Outcome<OAuthTokens> = match text("grant_type").as_str() {
168 "authorization_code" => {
169 if text("code").is_empty()
170 || text("code_verifier").is_empty()
171 || text("client_id").is_empty()
172 {
173 return oauth_error(
174 "invalid_request",
175 "code, code_verifier and client_id are required.",
176 );
177 }
178 g1t_kit::call(
179 &services.identity,
180 "oauth_exchange",
181 &OAuthExchangeArgs {
182 code: text("code"),
183 code_verifier: text("code_verifier"),
184 client_id: text("client_id"),
185 redirect_uri: text("redirect_uri"),
186 },
187 )
188 .await?
189 }
190 "refresh_token" => {
191 if text("refresh_token").is_empty() || text("client_id").is_empty() {
192 return oauth_error(
193 "invalid_request",
194 "refresh_token and client_id are required.",
195 );
196 }
197 g1t_kit::call(
198 &services.identity,
199 "oauth_refresh",
200 &OAuthRefreshArgs {
201 refresh_token: text("refresh_token"),
202 client_id: text("client_id"),
203 },
204 )
205 .await?
206 }
207 _ => {
208 return oauth_error(
209 "unsupported_grant_type",
210 "grant_type must be authorization_code or refresh_token.",
211 );
212 }
213 };
214 let tokens = match issued {
215 Outcome::Ok(tokens) => tokens,
216 Outcome::Fail(failure) => return oauth_error("invalid_grant", &failure.message),
217 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API218 let mut response = crate::reply(&json!({
API and MCP server in Rust; a public index at the API root219 "access_token": tokens.access_token,
220 "token_type": "Bearer",
221 "expires_in": tokens.expires_in,
222 "refresh_token": tokens.refresh_token,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step223 "scope": tokens.scope,
API and MCP server in Rust; a public index at the API root224 }))?;
225 response.headers_mut().set("cache-control", "no-store")?;
226 Ok(response)
227}
228
229/// Answers the request if it is for an OAuth endpoint. These are served on
230/// both hosts: an MCP client looks for the metadata next to the MCP server.
231pub async fn handle(
232 request: &mut Request,
233 services: &Services,
234 method: &str,
235 path: &str,
236) -> Result<Option<Response>> {
237 let response = match (method, path) {
238 ("GET", "/.well-known/oauth-authorization-server") => {
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue239 crate::reply(&server_metadata(&services.addresses))?
API and MCP server in Rust; a public index at the API root240 }
241 // Asked for with or without the MCP server's path appended.
242 ("GET", path) if path.starts_with("/.well-known/oauth-protected-resource") => {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API243 crate::reply(&json!({
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue244 "resource": services.addresses.mcp,
245 "authorization_servers": [services.addresses.api],
API and MCP server in Rust; a public index at the API root246 "bearer_methods_supported": ["header"],
247 "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step248 "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()),
API and MCP server in Rust; a public index at the API root249 }))?
250 }
251 ("POST", "/oauth/register") => register(request).await?,
252 ("POST", "/oauth/token") => token(request, services).await?,
253 _ => return Ok(None),
254 };
255 Ok(Some(response))
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn uris(list: &[&str]) -> Vec<String> {
263 list.iter().map(|uri| (*uri).to_owned()).collect()
264 }
265
266 #[test]
267 fn a_client_id_matches_the_one_the_site_decodes() {
268 // Produced by `encodeOAuthClient` in packages/contracts/src/oauth.ts.
269 let (id, name) =
270 encode_client(" e2e MCP client ", &uris(&["http://localhost:1/callback"])).unwrap();
271 assert_eq!(
272 id,
273 "g1c_eyJuIjoiZTJlIE1DUCBjbGllbnQiLCJyIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MS9jYWxsYmFjayJdfQ"
274 );
275 assert_eq!(name, "e2e MCP client");
276 }
277
278 #[test]
279 fn redirects_are_https_loopback_or_an_application_scheme() {
280 for good in [
281 "https://example.com/cb",
282 "http://localhost:8123/cb",
283 "http://127.0.0.1/cb",
284 "cursor://anysphere.cursor-mcp/oauth/callback",
285 ] {
286 assert!(is_valid_redirect_uri(good), "{good}");
287 }
288 for bad in [
289 "http://evil.example/cb",
290 "javascript:alert(1)",
291 "data:text/html,x",
292 "https://example.com/cb#fragment",
293 "not a url",
294 ] {
295 assert!(!is_valid_redirect_uri(bad), "{bad}");
296 }
297 }
298
299 #[test]
300 fn a_client_needs_one_to_five_redirects() {
301 assert!(encode_client("x", &[]).is_none());
302 assert!(encode_client("x", &uris(&["https://a.example/cb"; 6])).is_none());
303 let (_, name) = encode_client("", &uris(&["https://a.example/cb"])).unwrap();
304 assert_eq!(name, "An application");
305 }
306}