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

259 lines9,076 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::{FailureCode, Outcome, User, new_id};
14use g1t_kit::now_ms;
15use serde::Deserialize;
16use sha2::{Digest, Sha256};
17use worker::Result;
18
19use crate::{Identity, crypto};
20
21const CODE_TTL_SECONDS: u64 = 5 * 60;
22const ACCESS_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
23const REFRESH_TTL_SECONDS: u64 = 180 * 24 * 60 * 60;
24const REFRESH_PREFIX: &str = "g1r_";
25
26#[derive(Deserialize)]
27struct CodeRow {
28 user_id: String,
29 client_id: String,
30 client_name: String,
31 redirect_uri: String,
32 code_challenge: String,
33}
34
35#[derive(Deserialize)]
36struct GrantRow {
37 id: String,
38 user_id: String,
39 client_id: String,
40 client_name: String,
41 access_token_id: Option<String>,
42}
43
44#[derive(Deserialize)]
45struct GrantListRow {
46 id: String,
47 client_name: String,
48 created_at: String,
49 last_used_at: String,
50}
51
52/// Whether `verifier` is the secret behind an S256 `challenge` (RFC 7636).
53fn pkce_matches(verifier: &str, challenge: &str) -> bool {
54 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) == challenge
55}
56
57fn invalid_grant<T>(message: &str) -> Outcome<T> {
58 Outcome::fail(FailureCode::Invalid, message)
59}
60
61impl Identity {
62 /// Records that `user` approved the client and returns the one-time
63 /// code the client exchanges for tokens.
64 pub async fn oauth_authorize(&self, a: OAuthAuthorizeArgs) -> Result<OAuthCode> {
65 let code = crypto::random_hex(32);
66 self.db
67 .prepare(format!(
68 "INSERT INTO oauth_codes
69 (id, user_id, client_id, client_name, redirect_uri, code_challenge, expires_at)
70 VALUES (?, ?, ?, ?, ?, ?, {})",
71 sql_after(CODE_TTL_SECONDS)
72 ))
73 .bind(&[
74 crypto::sha256_hex(&code).into(),
75 a.user.id.into(),
76 a.client_id.into(),
77 a.client_name.into(),
78 a.redirect_uri.into(),
79 a.code_challenge.into(),
80 ])?
81 .run()
82 .await?;
83 Ok(OAuthCode { code })
84 }
85
86 /// Redeems an authorization code. A code works once, only for the client
87 /// and redirect it was issued to, and only with the PKCE verifier.
88 pub async fn oauth_exchange(&self, a: OAuthExchangeArgs) -> Result<Outcome<OAuthTokens>> {
89 let id = crypto::sha256_hex(&a.code);
90 let row = self
91 .db
92 .prepare(format!(
93 "DELETE FROM oauth_codes WHERE id = ? AND expires_at > {SQL_NOW}
94 RETURNING user_id, client_id, client_name, redirect_uri, code_challenge"
95 ))
96 .bind(&[id.into()])?
97 .first::<CodeRow>(None)
98 .await?;
99 let Some(row) = row else {
100 return Ok(invalid_grant(
101 "That code is not valid, has expired, or was already used.",
102 ));
103 };
104 if row.client_id != a.client_id || row.redirect_uri != a.redirect_uri {
105 return Ok(invalid_grant("That code was issued to a different client."));
106 }
107 if !pkce_matches(&a.code_verifier, &row.code_challenge) {
108 return Ok(invalid_grant("The code verifier does not match."));
109 }
110 let now = now_ms();
111 let grant_id = new_id("oag", now);
112 self.db
113 .prepare(
114 "INSERT INTO oauth_grants
115 (id, user_id, client_id, client_name, created_at, last_used_at)
116 VALUES (?, ?, ?, ?, ?, ?)",
117 )
118 .bind(&[
119 grant_id.as_str().into(),
120 row.user_id.as_str().into(),
121 row.client_id.into(),
122 row.client_name.as_str().into(),
123 rfc3339(now).into(),
124 rfc3339(now).into(),
125 ])?
126 .run()
127 .await?;
128 Ok(Outcome::Ok(
129 self.issue_oauth_tokens(&grant_id, &row.user_id, &row.client_name)
130 .await?,
131 ))
132 }
133
134 /// Trades a refresh token for new tokens. The refresh token and the
135 /// access token issued with it stop working.
136 pub async fn oauth_refresh(&self, a: OAuthRefreshArgs) -> Result<Outcome<OAuthTokens>> {
137 let row = self
138 .db
139 .prepare(format!(
140 "SELECT id, user_id, client_id, client_name, access_token_id FROM oauth_grants
141 WHERE refresh_hash = ? AND expires_at > {SQL_NOW}"
142 ))
143 .bind(&[crypto::sha256_hex(&a.refresh_token).into()])?
144 .first::<GrantRow>(None)
145 .await?;
146 let Some(row) = row.filter(|row| row.client_id == a.client_id) else {
147 return Ok(invalid_grant(
148 "That refresh token is not valid or has expired. Sign in again.",
149 ));
150 };
151 if let Some(previous) = &row.access_token_id {
152 self.db
153 .prepare("DELETE FROM access_tokens WHERE id = ?")
154 .bind(&[previous.as_str().into()])?
155 .run()
156 .await?;
157 }
158 Ok(Outcome::Ok(
159 self.issue_oauth_tokens(&row.id, &row.user_id, &row.client_name)
160 .await?,
161 ))
162 }
163
164 /// A new access token and refresh token for a grant.
165 async fn issue_oauth_tokens(
166 &self,
167 grant_id: &str,
168 user_id: &str,
169 client_name: &str,
170 ) -> Result<OAuthTokens> {
171 let access = self
172 .create_access_token(CreateAccessTokenArgs {
173 user: User {
174 id: user_id.to_owned(),
175 ..User::default()
176 },
177 name: client_name.to_owned(),
178 ttl_seconds: Some(ACCESS_TTL_SECONDS),
179 })
180 .await?;
181 let refresh_token = format!("{REFRESH_PREFIX}{}", crypto::random_hex(32));
182 self.db
183 .prepare(format!(
184 "UPDATE oauth_grants
185 SET refresh_hash = ?, access_token_id = ?, last_used_at = {SQL_NOW},
186 expires_at = {}
187 WHERE id = ?",
188 sql_after(REFRESH_TTL_SECONDS)
189 ))
190 .bind(&[
191 crypto::sha256_hex(&refresh_token).into(),
192 access.info.id.into(),
193 grant_id.into(),
194 ])?
195 .run()
196 .await?;
197 Ok(OAuthTokens {
198 access_token: access.token,
199 refresh_token,
200 expires_in: ACCESS_TTL_SECONDS,
201 })
202 }
203
204 /// Applications the user has signed in to, most recently used first.
205 pub async fn list_oauth_grants(&self, a: UserArgs) -> Result<Vec<OAuthGrant>> {
206 let rows = self
207 .db
208 .prepare(format!(
209 "SELECT id, client_name, created_at, last_used_at FROM oauth_grants
210 WHERE user_id = ? AND expires_at > {SQL_NOW} ORDER BY last_used_at DESC"
211 ))
212 .bind(&[a.user.id.into()])?
213 .all()
214 .await?
215 .results::<GrantListRow>()?;
216 Ok(rows
217 .into_iter()
218 .map(|row| OAuthGrant {
219 id: row.id,
220 client_name: row.client_name,
221 created_at: row.created_at,
222 last_used_at: row.last_used_at,
223 })
224 .collect())
225 }
226
227 /// Signs an application out: its refresh token and access token stop
228 /// working.
229 pub async fn revoke_oauth_grant(&self, a: RemoveArgs) -> Result<()> {
230 self.db
231 .batch(vec![
232 self.db
233 .prepare(
234 "DELETE FROM access_tokens WHERE id =
235 (SELECT access_token_id FROM oauth_grants WHERE id = ? AND user_id = ?)",
236 )
237 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
238 self.db
239 .prepare("DELETE FROM oauth_grants WHERE id = ? AND user_id = ?")
240 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
241 ])
242 .await?;
243 Ok(())
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::pkce_matches;
250
251 #[test]
252 fn pkce_verifier_matches_its_challenge() {
253 // The example from RFC 7636, appendix B.
254 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
255 let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
256 assert!(pkce_matches(verifier, challenge));
257 assert!(!pkce_matches("something else", challenge));
258 }
259}