Skip to content
306 linesCodeBlameRaw

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.

The runner base keeps its old comment, so the pushed base image still matches its folder1//! 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
Merge branch 'worktree-agent-aaf03bdceac799c89'17use crate::addresses::Addresses;
The runner base keeps its old comment, so the pushed base image still matches its folder18
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 =
82 crate::reply(&json!({ "error": error, "error_description": description }))?
83 .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
Merge branch 'worktree-agent-aaf03bdceac799c89'108/// 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;
The runner base keeps its old comment, so the pushed base image still matches its folder111 json!({
Merge branch 'worktree-agent-aaf03bdceac799c89'112 "issuer": issuer,
113 "authorization_endpoint": format!("{}/oauth/authorize", addresses.site),
114 "token_endpoint": format!("{issuer}/oauth/token"),
115 "registration_endpoint": format!("{issuer}/oauth/register"),
The runner base keeps its old comment, so the pushed base image still matches its folder116 "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"],
120 // A client may ask for some of these with `scope`; the person
121 // approving can trim them. Asking for none gives the agent preset.
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet122 "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(),
The runner base keeps its old comment, so the pushed base image still matches its folder123 "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 };
148 Ok(crate::reply(&json!({
149 "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 };
218 let mut response = crate::reply(&json!({
219 "access_token": tokens.access_token,
220 "token_type": "Bearer",
221 "expires_in": tokens.expires_in,
222 "refresh_token": tokens.refresh_token,
223 "scope": tokens.scope,
224 }))?;
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") => {
Merge branch 'worktree-agent-aaf03bdceac799c89'239 crate::reply(&server_metadata(&services.addresses))?
The runner base keeps its old comment, so the pushed base image still matches its folder240 }
241 // Asked for with or without the MCP server's path appended.
242 ("GET", path) if path.starts_with("/.well-known/oauth-protected-resource") => {
243 crate::reply(&json!({
Merge branch 'worktree-agent-aaf03bdceac799c89'244 "resource": services.addresses.mcp,
245 "authorization_servers": [services.addresses.api],
The runner base keeps its old comment, so the pushed base image still matches its folder246 "bearer_methods_supported": ["header"],
247 "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/",
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet248 "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(),
The runner base keeps its old comment, so the pushed base image still matches its folder249 }))?
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}

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