Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

327 linesCodeBlameRaw
1//! 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
17use crate::addresses::Addresses;
18
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
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;
111 json!({
112 "issuer": issuer,
113 // RFC 9207 binds callbacks to this issuer even though approval is
114 // hosted on SITE_URL. Codex requires this for split-origin OAuth.
115 "authorization_response_iss_parameter_supported": true,
116 "authorization_endpoint": format!("{}/oauth/authorize", addresses.site),
117 "token_endpoint": format!("{issuer}/oauth/token"),
118 "registration_endpoint": format!("{issuer}/oauth/register"),
119 "response_types_supported": ["code"],
120 "grant_types_supported": ["authorization_code", "refresh_token"],
121 "code_challenge_methods_supported": ["S256"],
122 "token_endpoint_auth_methods_supported": ["none"],
123 // A client may ask for some of these with `scope`; the person
124 // approving can trim them. Asking for none gives the agent preset.
125 "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(),
126 "service_documentation": "https://docs.g1t.sh/guides/authentication/",
127 })
128}
129
130async fn register(request: &mut Request) -> Result<Response> {
131 let body = fields(request).await;
132 let redirect_uris: Vec<String> = body
133 .get("redirect_uris")
134 .and_then(Value::as_array)
135 .map(|uris| {
136 uris.iter()
137 .filter_map(|uri| uri.as_str().map(str::to_owned))
138 .collect()
139 })
140 .unwrap_or_default();
141 let name = body
142 .get("client_name")
143 .and_then(Value::as_str)
144 .unwrap_or_default();
145 let Some((client_id, client_name)) = encode_client(name, &redirect_uris) else {
146 return oauth_error(
147 "invalid_redirect_uri",
148 "Give one to five redirect_uris: https addresses, http on localhost, or the application's own scheme.",
149 );
150 };
151 Ok(crate::reply(&json!({
152 "client_id": client_id,
153 "client_name": client_name,
154 "redirect_uris": redirect_uris,
155 "grant_types": ["authorization_code", "refresh_token"],
156 "response_types": ["code"],
157 "token_endpoint_auth_method": "none",
158 }))?
159 .with_status(201))
160}
161
162async fn token(request: &mut Request, services: &Services) -> Result<Response> {
163 let body = fields(request).await;
164 let text = |key: &str| {
165 body.get(key)
166 .and_then(Value::as_str)
167 .unwrap_or_default()
168 .to_owned()
169 };
170 let issued: Outcome<OAuthTokens> = match text("grant_type").as_str() {
171 "authorization_code" => {
172 if text("code").is_empty()
173 || text("code_verifier").is_empty()
174 || text("client_id").is_empty()
175 {
176 return oauth_error(
177 "invalid_request",
178 "code, code_verifier and client_id are required.",
179 );
180 }
181 g1t_kit::call(
182 &services.identity,
183 "oauth_exchange",
184 &OAuthExchangeArgs {
185 code: text("code"),
186 code_verifier: text("code_verifier"),
187 client_id: text("client_id"),
188 redirect_uri: text("redirect_uri"),
189 },
190 )
191 .await?
192 }
193 "refresh_token" => {
194 if text("refresh_token").is_empty() || text("client_id").is_empty() {
195 return oauth_error(
196 "invalid_request",
197 "refresh_token and client_id are required.",
198 );
199 }
200 g1t_kit::call(
201 &services.identity,
202 "oauth_refresh",
203 &OAuthRefreshArgs {
204 refresh_token: text("refresh_token"),
205 client_id: text("client_id"),
206 },
207 )
208 .await?
209 }
210 _ => {
211 return oauth_error(
212 "unsupported_grant_type",
213 "grant_type must be authorization_code or refresh_token.",
214 );
215 }
216 };
217 let tokens = match issued {
218 Outcome::Ok(tokens) => tokens,
219 Outcome::Fail(failure) => return oauth_error("invalid_grant", &failure.message),
220 };
221 let mut response = crate::reply(&json!({
222 "access_token": tokens.access_token,
223 "token_type": "Bearer",
224 "expires_in": tokens.expires_in,
225 "refresh_token": tokens.refresh_token,
226 "scope": tokens.scope,
227 }))?;
228 response.headers_mut().set("cache-control", "no-store")?;
229 Ok(response)
230}
231
232/// Answers the request if it is for an OAuth endpoint. These are served on
233/// both hosts: an MCP client looks for the metadata next to the MCP server.
234/// Shared by well-known discovery and the MCP endpoint's server card.
235pub(crate) fn protected_resource_metadata(addresses: &Addresses) -> Value {
236 json!({
237 "resource": addresses.mcp,
238 "authorization_servers": [addresses.api],
239 "bearer_methods_supported": ["header"],
240 "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/",
241 "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(),
242 })
243}
244
245pub async fn handle(
246 request: &mut Request,
247 services: &Services,
248 method: &str,
249 path: &str,
250) -> Result<Option<Response>> {
251 let response = match (method, path) {
252 ("GET", "/.well-known/oauth-authorization-server") => {
253 crate::reply(&server_metadata(&services.addresses))?
254 }
255 // Asked for with or without the MCP server's path appended.
256 ("GET", path) if path.starts_with("/.well-known/oauth-protected-resource") => {
257 crate::reply(&protected_resource_metadata(&services.addresses))?
258 }
259 ("POST", "/oauth/register") => register(request).await?,
260 ("POST", "/oauth/token") => token(request, services).await?,
261 _ => return Ok(None),
262 };
263 Ok(Some(response))
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn split_origin_authorization_advertises_issuer_bound_callbacks() {
272 for addresses in [
273 Addresses::from_settings(None, None, None),
274 Addresses::from_settings(Some("https://git.example".into()), Some("https://api.example".into()), None),
275 ] {
276 let metadata = server_metadata(&addresses);
277 assert_eq!(metadata["issuer"], addresses.api);
278 assert_eq!(metadata["authorization_endpoint"], format!("{}/oauth/authorize", addresses.site));
279 assert_eq!(metadata["authorization_response_iss_parameter_supported"], true);
280 }
281 }
282
283 fn uris(list: &[&str]) -> Vec<String> {
284 list.iter().map(|uri| (*uri).to_owned()).collect()
285 }
286
287 #[test]
288 fn a_client_id_matches_the_one_the_site_decodes() {
289 // Produced by `encodeOAuthClient` in packages/contracts/src/oauth.ts.
290 let (id, name) =
291 encode_client(" e2e MCP client ", &uris(&["http://localhost:1/callback"])).unwrap();
292 assert_eq!(
293 id,
294 "g1c_eyJuIjoiZTJlIE1DUCBjbGllbnQiLCJyIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MS9jYWxsYmFjayJdfQ"
295 );
296 assert_eq!(name, "e2e MCP client");
297 }
298
299 #[test]
300 fn redirects_are_https_loopback_or_an_application_scheme() {
301 for good in [
302 "https://example.com/cb",
303 "http://localhost:8123/cb",
304 "http://127.0.0.1/cb",
305 "cursor://anysphere.cursor-mcp/oauth/callback",
306 ] {
307 assert!(is_valid_redirect_uri(good), "{good}");
308 }
309 for bad in [
310 "http://evil.example/cb",
311 "javascript:alert(1)",
312 "data:text/html,x",
313 "https://example.com/cb#fragment",
314 "not a url",
315 ] {
316 assert!(!is_valid_redirect_uri(bad), "{bad}");
317 }
318 }
319
320 #[test]
321 fn a_client_needs_one_to_five_redirects() {
322 assert!(encode_client("x", &[]).is_none());
323 assert!(encode_client("x", &uris(&["https://a.example/cb"; 6])).is_none());
324 let (_, name) = encode_client("", &uris(&["https://a.example/cb"])).unwrap();
325 assert_eq!(name, "An application");
326 }
327}