flagon-io/g1t

public

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

g1t/apps/api/src/oauth.rs

303 lines10,620 bytesCodeBlame
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
17const ISSUER: &str = "https://api.g1t.sh";
18const MCP_RESOURCE: &str = "https://mcp.g1t.sh";
19/// What an MCP client is told when it must sign in first (RFC 9728).
20pub const MCP_CHALLENGE: &str =
21 "Bearer resource_metadata=\"https://mcp.g1t.sh/.well-known/oauth-protected-resource\"";
22
23const CLIENT_PREFIX: &str = "g1c_";
24const MAX_NAME_CHARS: usize = 80;
25const MAX_REDIRECTS: usize = 5;
26const MAX_URI_CHARS: usize = 500;
27/// Schemes that run or expose content instead of opening an application.
28const FORBIDDEN_SCHEMES: [&str; 6] = ["javascript", "data", "file", "blob", "vbscript", "about"];
29const LOOPBACK_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
30
31/// Whether an application may ask to be redirected here: an https address,
32/// http on this machine only, or an application's own scheme.
33fn is_valid_redirect_uri(uri: &str) -> bool {
34 let Ok(url) = Url::parse(uri) else {
35 return false;
36 };
37 if uri.len() > MAX_URI_CHARS || url.fragment().is_some() {
38 return false;
39 }
40 match url.scheme() {
41 "https" => true,
42 "http" => url
43 .host_str()
44 .is_some_and(|host| LOOPBACK_HOSTS.contains(&host)),
45 scheme => !FORBIDDEN_SCHEMES.contains(&scheme),
46 }
47}
48
49/// A client as its id carries it. The site decodes the same shape; see
50/// `packages/contracts/src/oauth.ts`.
51#[derive(Serialize)]
52struct Client<'a> {
53 n: &'a str,
54 r: &'a [String],
55}
56
57/// The client id for a client, or `None` if what it asks for is not
58/// allowed. Nothing is stored: the id is the registration itself, encoded,
59/// so this open endpoint cannot be used to fill a database.
60fn encode_client(name: &str, redirect_uris: &[String]) -> Option<(String, String)> {
61 let name: String = name.trim().chars().take(MAX_NAME_CHARS).collect();
62 let name = if name.is_empty() {
63 "An application".to_owned()
64 } else {
65 name
66 };
67 let allowed = !redirect_uris.is_empty()
68 && redirect_uris.len() <= MAX_REDIRECTS
69 && redirect_uris.iter().all(|uri| is_valid_redirect_uri(uri));
70 if !allowed {
71 return None;
72 }
73 let encoded = serde_json::to_string(&Client {
74 n: &name,
75 r: redirect_uris,
76 })
77 .ok()?;
78 Some((
79 format!("{CLIENT_PREFIX}{}", URL_SAFE_NO_PAD.encode(encoded)),
80 name,
81 ))
82}
83
84fn oauth_error(error: &str, description: &str) -> Result<Response> {
85 let mut response =
86 crate::reply(&json!({ "error": error, "error_description": description }))?
87 .with_status(400);
88 response.headers_mut().set("cache-control", "no-store")?;
89 Ok(response)
90}
91
92/// The request body as fields, whether sent as a form or as JSON.
93async fn fields(request: &mut Request) -> Map<String, Value> {
94 let json = request
95 .headers()
96 .get("content-type")
97 .ok()
98 .flatten()
99 .is_some_and(|kind| kind.contains("json"));
100 let body = request.text().await.unwrap_or_default();
101 if json {
102 return match serde_json::from_str(&body) {
103 Ok(Value::Object(fields)) => fields,
104 _ => Map::new(),
105 };
106 }
107 form_urlencoded::parse(body.as_bytes())
108 .map(|(name, value)| (name.into_owned(), Value::String(value.into_owned())))
109 .collect()
110}
111
112fn server_metadata() -> Value {
113 json!({
114 "issuer": ISSUER,
115 "authorization_endpoint": "https://g1t.sh/oauth/authorize",
116 "token_endpoint": format!("{ISSUER}/oauth/token"),
117 "registration_endpoint": format!("{ISSUER}/oauth/register"),
118 "response_types_supported": ["code"],
119 "grant_types_supported": ["authorization_code", "refresh_token"],
120 "code_challenge_methods_supported": ["S256"],
121 "token_endpoint_auth_methods_supported": ["none"],
122 "service_documentation": "https://docs.g1t.sh/guides/authentication/",
123 })
124}
125
126async fn register(request: &mut Request) -> Result<Response> {
127 let body = fields(request).await;
128 let redirect_uris: Vec<String> = body
129 .get("redirect_uris")
130 .and_then(Value::as_array)
131 .map(|uris| {
132 uris.iter()
133 .filter_map(|uri| uri.as_str().map(str::to_owned))
134 .collect()
135 })
136 .unwrap_or_default();
137 let name = body
138 .get("client_name")
139 .and_then(Value::as_str)
140 .unwrap_or_default();
141 let Some((client_id, client_name)) = encode_client(name, &redirect_uris) else {
142 return oauth_error(
143 "invalid_redirect_uri",
144 "Give one to five redirect_uris: https addresses, http on localhost, or the application's own scheme.",
145 );
146 };
147 Ok(crate::reply(&json!({
148 "client_id": client_id,
149 "client_name": client_name,
150 "redirect_uris": redirect_uris,
151 "grant_types": ["authorization_code", "refresh_token"],
152 "response_types": ["code"],
153 "token_endpoint_auth_method": "none",
154 }))?
155 .with_status(201))
156}
157
158async fn token(request: &mut Request, services: &Services) -> Result<Response> {
159 let body = fields(request).await;
160 let text = |key: &str| {
161 body.get(key)
162 .and_then(Value::as_str)
163 .unwrap_or_default()
164 .to_owned()
165 };
166 let issued: Outcome<OAuthTokens> = match text("grant_type").as_str() {
167 "authorization_code" => {
168 if text("code").is_empty()
169 || text("code_verifier").is_empty()
170 || text("client_id").is_empty()
171 {
172 return oauth_error(
173 "invalid_request",
174 "code, code_verifier and client_id are required.",
175 );
176 }
177 g1t_kit::call(
178 &services.identity,
179 "oauth_exchange",
180 &OAuthExchangeArgs {
181 code: text("code"),
182 code_verifier: text("code_verifier"),
183 client_id: text("client_id"),
184 redirect_uri: text("redirect_uri"),
185 },
186 )
187 .await?
188 }
189 "refresh_token" => {
190 if text("refresh_token").is_empty() || text("client_id").is_empty() {
191 return oauth_error(
192 "invalid_request",
193 "refresh_token and client_id are required.",
194 );
195 }
196 g1t_kit::call(
197 &services.identity,
198 "oauth_refresh",
199 &OAuthRefreshArgs {
200 refresh_token: text("refresh_token"),
201 client_id: text("client_id"),
202 },
203 )
204 .await?
205 }
206 _ => {
207 return oauth_error(
208 "unsupported_grant_type",
209 "grant_type must be authorization_code or refresh_token.",
210 );
211 }
212 };
213 let tokens = match issued {
214 Outcome::Ok(tokens) => tokens,
215 Outcome::Fail(failure) => return oauth_error("invalid_grant", &failure.message),
216 };
217 let mut response = crate::reply(&json!({
218 "access_token": tokens.access_token,
219 "token_type": "Bearer",
220 "expires_in": tokens.expires_in,
221 "refresh_token": tokens.refresh_token,
222 }))?;
223 response.headers_mut().set("cache-control", "no-store")?;
224 Ok(response)
225}
226
227/// Answers the request if it is for an OAuth endpoint. These are served on
228/// both hosts: an MCP client looks for the metadata next to the MCP server.
229pub async fn handle(
230 request: &mut Request,
231 services: &Services,
232 method: &str,
233 path: &str,
234) -> Result<Option<Response>> {
235 let response = match (method, path) {
236 ("GET", "/.well-known/oauth-authorization-server") => {
237 crate::reply(&server_metadata())?
238 }
239 // Asked for with or without the MCP server's path appended.
240 ("GET", path) if path.starts_with("/.well-known/oauth-protected-resource") => {
241 crate::reply(&json!({
242 "resource": MCP_RESOURCE,
243 "authorization_servers": [ISSUER],
244 "bearer_methods_supported": ["header"],
245 "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/",
246 }))?
247 }
248 ("POST", "/oauth/register") => register(request).await?,
249 ("POST", "/oauth/token") => token(request, services).await?,
250 _ => return Ok(None),
251 };
252 Ok(Some(response))
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn uris(list: &[&str]) -> Vec<String> {
260 list.iter().map(|uri| (*uri).to_owned()).collect()
261 }
262
263 #[test]
264 fn a_client_id_matches_the_one_the_site_decodes() {
265 // Produced by `encodeOAuthClient` in packages/contracts/src/oauth.ts.
266 let (id, name) =
267 encode_client(" e2e MCP client ", &uris(&["http://localhost:1/callback"])).unwrap();
268 assert_eq!(
269 id,
270 "g1c_eyJuIjoiZTJlIE1DUCBjbGllbnQiLCJyIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MS9jYWxsYmFjayJdfQ"
271 );
272 assert_eq!(name, "e2e MCP client");
273 }
274
275 #[test]
276 fn redirects_are_https_loopback_or_an_application_scheme() {
277 for good in [
278 "https://example.com/cb",
279 "http://localhost:8123/cb",
280 "http://127.0.0.1/cb",
281 "cursor://anysphere.cursor-mcp/oauth/callback",
282 ] {
283 assert!(is_valid_redirect_uri(good), "{good}");
284 }
285 for bad in [
286 "http://evil.example/cb",
287 "javascript:alert(1)",
288 "data:text/html,x",
289 "https://example.com/cb#fragment",
290 "not a url",
291 ] {
292 assert!(!is_valid_redirect_uri(bad), "{bad}");
293 }
294 }
295
296 #[test]
297 fn a_client_needs_one_to_five_redirects() {
298 assert!(encode_client("x", &[]).is_none());
299 assert!(encode_client("x", &uris(&["https://a.example/cb"; 6])).is_none());
300 let (_, name) = encode_client("", &uris(&["https://a.example/cb"])).unwrap();
301 assert_eq!(name, "An application");
302 }
303}