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/services/identity/src/oauth.rs

329 lines11,611 bytesCodeBlame
1//! 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?;
155 Ok(Outcome::Ok(
156 self.issue_oauth_tokens(
157 &grant_id,
158 &row.user_id,
159 &row.client_name,
160 row.scopes.as_deref(),
161 )
162 .await?,
163 ))
164 }
165
166 /// Trades a refresh token for new tokens. The refresh token and the
167 /// access token issued with it stop working.
168 pub async fn oauth_refresh(&self, a: OAuthRefreshArgs) -> Result<Outcome<OAuthTokens>> {
169 let row = self
170 .db
171 .prepare(format!(
172 "SELECT id, user_id, client_id, client_name, access_token_id, scopes
173 FROM oauth_grants
174 WHERE refresh_hash = ? AND expires_at > {SQL_NOW}"
175 ))
176 .bind(&[crypto::sha256_hex(&a.refresh_token).into()])?
177 .first::<GrantRow>(None)
178 .await?;
179 let Some(row) = row.filter(|row| row.client_id == a.client_id) else {
180 return Ok(invalid_grant(
181 "That refresh token is not valid or has expired. Sign in again.",
182 ));
183 };
184 if let Some(previous) = &row.access_token_id {
185 self.db
186 .prepare("DELETE FROM access_tokens WHERE id = ?")
187 .bind(&[previous.as_str().into()])?
188 .run()
189 .await?;
190 }
191 // A refreshed token keeps what the grant allows now.
192 Ok(Outcome::Ok(
193 self.issue_oauth_tokens(
194 &row.id,
195 &row.user_id,
196 &row.client_name,
197 row.scopes.as_deref(),
198 )
199 .await?,
200 ))
201 }
202
203 /// A new access token and refresh token for a grant.
204 /// `scopes` is the grant's column: a grant made before scopes (null)
205 /// keeps full access.
206 async fn issue_oauth_tokens(
207 &self,
208 grant_id: &str,
209 user_id: &str,
210 client_name: &str,
211 scopes: Option<&str>,
212 ) -> Result<OAuthTokens> {
213 let (scopes, _) = stored_scopes(scopes);
214 let access = self
215 .create_access_token(CreateAccessTokenArgs {
216 user: User {
217 id: user_id.to_owned(),
218 ..User::default()
219 },
220 name: client_name.to_owned(),
221 ttl_seconds: Some(ACCESS_TTL_SECONDS),
222 scopes: scopes.clone(),
223 listed: false,
224 })
225 .await?;
226 let refresh_token = format!("{REFRESH_PREFIX}{}", crypto::random_hex(32));
227 self.db
228 .prepare(format!(
229 "UPDATE oauth_grants
230 SET refresh_hash = ?, access_token_id = ?, last_used_at = {SQL_NOW},
231 expires_at = {}
232 WHERE id = ?",
233 sql_after(REFRESH_TTL_SECONDS)
234 ))
235 .bind(&[
236 crypto::sha256_hex(&refresh_token).into(),
237 access.info.id.into(),
238 grant_id.into(),
239 ])?
240 .run()
241 .await?;
242 Ok(OAuthTokens {
243 access_token: access.token,
244 refresh_token,
245 expires_in: ACCESS_TTL_SECONDS,
246 scope: Some(scopes.map_or_else(|| FULL_ACCESS.to_owned(), |scopes| scopes.join(" "))),
247 })
248 }
249
250 /// Applications the user has signed in to, most recently used first.
251 pub async fn list_oauth_grants(&self, a: UserArgs) -> Result<Vec<OAuthGrant>> {
252 let rows = self
253 .db
254 .prepare(format!(
255 "SELECT id, client_name, created_at, last_used_at, scopes FROM oauth_grants
256 WHERE user_id = ? AND expires_at > {SQL_NOW} ORDER BY last_used_at DESC"
257 ))
258 .bind(&[a.user.id.into()])?
259 .all()
260 .await?
261 .results::<GrantListRow>()?;
262 Ok(rows.into_iter().map(grant).collect())
263 }
264
265 /// Changes what an application may do: its current access token at
266 /// once, and every token it is given from now on.
267 pub async fn update_oauth_grant(&self, a: UpdateOAuthGrantArgs) -> Result<Outcome<OAuthGrant>> {
268 let scopes = Grant::asked(&a.scopes).scopes_column();
269 let row = self
270 .db
271 .prepare(format!(
272 "UPDATE oauth_grants SET scopes = ?
273 WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}
274 RETURNING id, client_name, created_at, last_used_at, scopes"
275 ))
276 .bind(&[
277 scopes.as_str().into(),
278 a.id.as_str().into(),
279 a.user.id.as_str().into(),
280 ])?
281 .first::<GrantListRow>(None)
282 .await?;
283 let Some(row) = row else {
284 return Ok(Outcome::fail(FailureCode::NotFound, "No such application."));
285 };
286 self.db
287 .prepare(
288 "UPDATE access_tokens SET scopes = ?
289 WHERE id = (SELECT access_token_id FROM oauth_grants WHERE id = ?)",
290 )
291 .bind(&[scopes.as_str().into(), a.id.as_str().into()])?
292 .run()
293 .await?;
294 Ok(Outcome::Ok(grant(row)))
295 }
296
297 /// Signs an application out: its refresh token and access token stop
298 /// working.
299 pub async fn revoke_oauth_grant(&self, a: RemoveArgs) -> Result<()> {
300 self.db
301 .batch(vec![
302 self.db
303 .prepare(
304 "DELETE FROM access_tokens WHERE id =
305 (SELECT access_token_id FROM oauth_grants WHERE id = ? AND user_id = ?)",
306 )
307 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
308 self.db
309 .prepare("DELETE FROM oauth_grants WHERE id = ? AND user_id = ?")
310 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
311 ])
312 .await?;
313 Ok(())
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::pkce_matches;
320
321 #[test]
322 fn pkce_verifier_matches_its_challenge() {
323 // The example from RFC 7636, appendix B.
324 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
325 let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
326 assert!(pkce_matches(verifier, challenge));
327 assert!(!pkce_matches("something else", challenge));
328 }
329}