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.

g1t.sh
349 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.

Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1//! 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
9use base64::Engine;
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11use g1t_contracts::identity::*;
12use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
13use g1t_contracts::scopes::FULL_ACCESS;
14use g1t_contracts::{FailureCode, Outcome, User, new_id};
15use worker::wasm_bindgen::JsValue;
16use g1t_kit::now_ms;
17use serde::Deserialize;
18use sha2::{Digest, Sha256};
19use worker::Result;
20
21use crate::tokens::{Grant, stored_scopes};
22use crate::{Identity, crypto};
23
24const CODE_TTL_SECONDS: u64 = 5 * 60;
25const ACCESS_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
26const REFRESH_TTL_SECONDS: u64 = 180 * 24 * 60 * 60;
27const REFRESH_PREFIX: &str = "g1r_";
28
29#[derive(Deserialize)]
30struct CodeRow {
31 user_id: String,
32 client_id: String,
33 client_name: String,
34 redirect_uri: String,
35 code_challenge: String,
36 scopes: Option<String>,
37}
38
39#[derive(Deserialize)]
40struct GrantRow {
41 id: String,
42 user_id: String,
43 client_id: String,
44 client_name: String,
45 access_token_id: Option<String>,
46 scopes: Option<String>,
47}
48
49#[derive(Deserialize)]
50struct GrantListRow {
51 id: String,
52 client_name: String,
53 created_at: String,
54 last_used_at: String,
55 scopes: Option<String>,
56}
57
58fn text(value: Option<&str>) -> JsValue {
59 value.map_or(JsValue::NULL, JsValue::from)
60}
61
62/// Whether `verifier` is the secret behind an S256 `challenge` (RFC 7636).
63fn pkce_matches(verifier: &str, challenge: &str) -> bool {
64 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) == challenge
65}
66
67fn invalid_grant<T>(message: &str) -> Outcome<T> {
68 Outcome::fail(FailureCode::Invalid, message)
69}
70
71fn grant(row: GrantListRow) -> OAuthGrant {
72 let (scopes, legacy) = stored_scopes(row.scopes.as_deref());
73 OAuthGrant {
74 id: row.id,
75 client_name: row.client_name,
76 created_at: row.created_at,
77 last_used_at: row.last_used_at,
78 scopes,
79 legacy,
80 }
81}
82
83impl Identity {
84 /// Records that `user` approved the client and returns the one-time
85 /// code the client exchanges for tokens.
86 pub async fn oauth_authorize(&self, a: OAuthAuthorizeArgs) -> Result<OAuthCode> {
87 let code = crypto::random_hex(32);
88 // What the person granted, carried through the code to the grant.
89 let grant = Grant::asked(&a.scopes);
90 self.db
91 .prepare(format!(
92 "INSERT INTO oauth_codes
93 (id, user_id, client_id, client_name, redirect_uri, code_challenge, expires_at,
94 scopes)
95 VALUES (?, ?, ?, ?, ?, ?, {}, ?)",
96 sql_after(CODE_TTL_SECONDS)
97 ))
98 .bind(&[
99 crypto::sha256_hex(&code).into(),
100 a.user.id.into(),
101 a.client_id.into(),
102 a.client_name.into(),
103 a.redirect_uri.into(),
104 a.code_challenge.into(),
105 grant.scopes_column().into(),
106 ])?
107 .run()
108 .await?;
109 Ok(OAuthCode { code })
110 }
111
112 /// Redeems an authorization code. A code works once, only for the client
113 /// and redirect it was issued to, and only with the PKCE verifier.
114 pub async fn oauth_exchange(&self, a: OAuthExchangeArgs) -> Result<Outcome<OAuthTokens>> {
115 let id = crypto::sha256_hex(&a.code);
116 let row = self
117 .db
118 .prepare(format!(
119 "DELETE FROM oauth_codes WHERE id = ? AND expires_at > {SQL_NOW}
120 RETURNING user_id, client_id, client_name, redirect_uri, code_challenge, scopes"
121 ))
122 .bind(&[id.into()])?
123 .first::<CodeRow>(None)
124 .await?;
125 let Some(row) = row else {
126 return Ok(invalid_grant(
127 "That code is not valid, has expired, or was already used.",
128 ));
129 };
130 if row.client_id != a.client_id || row.redirect_uri != a.redirect_uri {
131 return Ok(invalid_grant("That code was issued to a different client."));
132 }
133 if !pkce_matches(&a.code_verifier, &row.code_challenge) {
134 return Ok(invalid_grant("The code verifier does not match."));
135 }
136 let now = now_ms();
137 let grant_id = new_id("oag", now);
138 self.db
139 .prepare(
140 "INSERT INTO oauth_grants
141 (id, user_id, client_id, client_name, created_at, last_used_at, scopes)
142 VALUES (?, ?, ?, ?, ?, ?, ?)",
143 )
144 .bind(&[
145 grant_id.as_str().into(),
146 row.user_id.as_str().into(),
147 row.client_id.into(),
148 row.client_name.as_str().into(),
149 rfc3339(now).into(),
150 rfc3339(now).into(),
151 text(row.scopes.as_deref()),
152 ])?
153 .run()
154 .await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens155 self.log_security(&row.user_id, "oauth_grant_created", Some(&row.client_name), None).await;
156 if let Some(person) = self
157 .find_public_user("SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?", &row.user_id)
158 .await?
159 {
160 self.audit_account(&person, "oauth_grant.created", &format!("Authorized the application {}", row.client_name)).await;
161 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running162 Ok(Outcome::Ok(
163 self.issue_oauth_tokens(
164 &grant_id,
165 &row.user_id,
166 &row.client_name,
167 row.scopes.as_deref(),
168 )
169 .await?,
170 ))
171 }
172
173 /// Trades a refresh token for new tokens. The refresh token and the
174 /// access token issued with it stop working.
175 pub async fn oauth_refresh(&self, a: OAuthRefreshArgs) -> Result<Outcome<OAuthTokens>> {
176 let row = self
177 .db
178 .prepare(format!(
179 "SELECT id, user_id, client_id, client_name, access_token_id, scopes
180 FROM oauth_grants
181 WHERE refresh_hash = ? AND expires_at > {SQL_NOW}"
182 ))
183 .bind(&[crypto::sha256_hex(&a.refresh_token).into()])?
184 .first::<GrantRow>(None)
185 .await?;
186 let Some(row) = row.filter(|row| row.client_id == a.client_id) else {
187 return Ok(invalid_grant(
188 "That refresh token is not valid or has expired. Sign in again.",
189 ));
190 };
191 if let Some(previous) = &row.access_token_id {
192 self.db
193 .prepare("DELETE FROM access_tokens WHERE id = ?")
194 .bind(&[previous.as_str().into()])?
195 .run()
196 .await?;
197 }
198 // A refreshed token keeps what the grant allows now.
199 Ok(Outcome::Ok(
200 self.issue_oauth_tokens(
201 &row.id,
202 &row.user_id,
203 &row.client_name,
204 row.scopes.as_deref(),
205 )
206 .await?,
207 ))
208 }
209
210 /// A new access token and refresh token for a grant.
211 /// `scopes` is the grant's column: a grant made before scopes (null)
212 /// keeps full access.
213 async fn issue_oauth_tokens(
214 &self,
215 grant_id: &str,
216 user_id: &str,
217 client_name: &str,
218 scopes: Option<&str>,
219 ) -> Result<OAuthTokens> {
220 let (scopes, _) = stored_scopes(scopes);
221 let access = self
222 .create_access_token(CreateAccessTokenArgs {
223 user: User {
224 id: user_id.to_owned(),
225 ..User::default()
226 },
227 name: client_name.to_owned(),
228 ttl_seconds: Some(ACCESS_TTL_SECONDS),
229 scopes: scopes.clone(),
230 listed: false,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.231 repo: None,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running232 })
233 .await?;
234 let refresh_token = format!("{REFRESH_PREFIX}{}", crypto::random_hex(32));
235 self.db
236 .prepare(format!(
237 "UPDATE oauth_grants
238 SET refresh_hash = ?, access_token_id = ?, last_used_at = {SQL_NOW},
239 expires_at = {}
240 WHERE id = ?",
241 sql_after(REFRESH_TTL_SECONDS)
242 ))
243 .bind(&[
244 crypto::sha256_hex(&refresh_token).into(),
245 access.info.id.into(),
246 grant_id.into(),
247 ])?
248 .run()
249 .await?;
250 Ok(OAuthTokens {
251 access_token: access.token,
252 refresh_token,
253 expires_in: ACCESS_TTL_SECONDS,
254 scope: Some(scopes.map_or_else(|| FULL_ACCESS.to_owned(), |scopes| scopes.join(" "))),
255 })
256 }
257
258 /// Applications the user has signed in to, most recently used first.
259 pub async fn list_oauth_grants(&self, a: UserArgs) -> Result<Vec<OAuthGrant>> {
260 let rows = self
261 .db
262 .prepare(format!(
263 "SELECT id, client_name, created_at, last_used_at, scopes FROM oauth_grants
264 WHERE user_id = ? AND expires_at > {SQL_NOW} ORDER BY last_used_at DESC"
265 ))
266 .bind(&[a.user.id.into()])?
267 .all()
268 .await?
269 .results::<GrantListRow>()?;
270 Ok(rows.into_iter().map(grant).collect())
271 }
272
273 /// Changes what an application may do: its current access token at
274 /// once, and every token it is given from now on.
275 pub async fn update_oauth_grant(&self, a: UpdateOAuthGrantArgs) -> Result<Outcome<OAuthGrant>> {
276 let scopes = Grant::asked(&a.scopes).scopes_column();
277 let row = self
278 .db
279 .prepare(format!(
280 "UPDATE oauth_grants SET scopes = ?
281 WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}
282 RETURNING id, client_name, created_at, last_used_at, scopes"
283 ))
284 .bind(&[
285 scopes.as_str().into(),
286 a.id.as_str().into(),
287 a.user.id.as_str().into(),
288 ])?
289 .first::<GrantListRow>(None)
290 .await?;
291 let Some(row) = row else {
292 return Ok(Outcome::fail(FailureCode::NotFound, "No such application."));
293 };
294 self.db
295 .prepare(
296 "UPDATE access_tokens SET scopes = ?
297 WHERE id = (SELECT access_token_id FROM oauth_grants WHERE id = ?)",
298 )
299 .bind(&[scopes.as_str().into(), a.id.as_str().into()])?
300 .run()
301 .await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens302 self.log_security(&a.user.id, "oauth_grant_rescoped", Some(&row.client_name), None).await;
303 self.audit_account(&a.user, "oauth_grant.rescoped", &format!("Changed what the application {} may do", row.client_name)).await;
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running304 Ok(Outcome::Ok(grant(row)))
305 }
306
307 /// Signs an application out: its refresh token and access token stop
308 /// working.
309 pub async fn revoke_oauth_grant(&self, a: RemoveArgs) -> Result<()> {
Merge main (membership, two-factor, GitHub repo roles) into tokens310 let client: Option<String> = self
311 .db
312 .prepare("SELECT client_name FROM oauth_grants WHERE id = ? AND user_id = ?")
313 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?
314 .first(Some("client_name"))
315 .await?;
316 if let Some(client) = &client {
317 self.log_security(&a.user.id, "oauth_grant_revoked", Some(client), None).await;
318 self.audit_account(&a.user, "oauth_grant.revoked", &format!("Revoked the application {client}")).await;
319 }
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running320 self.db
321 .batch(vec![
322 self.db
323 .prepare(
324 "DELETE FROM access_tokens WHERE id =
325 (SELECT access_token_id FROM oauth_grants WHERE id = ? AND user_id = ?)",
326 )
327 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
328 self.db
329 .prepare("DELETE FROM oauth_grants WHERE id = ? AND user_id = ?")
330 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
331 ])
332 .await?;
333 Ok(())
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::pkce_matches;
340
341 #[test]
342 fn pkce_verifier_matches_its_challenge() {
343 // The example from RFC 7636, appendix B.
344 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
345 let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
346 assert!(pkce_matches(verifier, challenge));
347 assert!(!pkce_matches("something else", challenge));
348 }
349}

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