Skip to content

g1t/services/identity/src/oauth.rs

348 lines12,823 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 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 }
162 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,
231 })
232 .await?;
233 let refresh_token = format!("{REFRESH_PREFIX}{}", crypto::random_hex(32));
234 self.db
235 .prepare(format!(
236 "UPDATE oauth_grants
237 SET refresh_hash = ?, access_token_id = ?, last_used_at = {SQL_NOW},
238 expires_at = {}
239 WHERE id = ?",
240 sql_after(REFRESH_TTL_SECONDS)
241 ))
242 .bind(&[
243 crypto::sha256_hex(&refresh_token).into(),
244 access.info.id.into(),
245 grant_id.into(),
246 ])?
247 .run()
248 .await?;
249 Ok(OAuthTokens {
250 access_token: access.token,
251 refresh_token,
252 expires_in: ACCESS_TTL_SECONDS,
253 scope: Some(scopes.map_or_else(|| FULL_ACCESS.to_owned(), |scopes| scopes.join(" "))),
254 })
255 }
256
257 /// Applications the user has signed in to, most recently used first.
258 pub async fn list_oauth_grants(&self, a: UserArgs) -> Result<Vec<OAuthGrant>> {
259 let rows = self
260 .db
261 .prepare(format!(
262 "SELECT id, client_name, created_at, last_used_at, scopes FROM oauth_grants
263 WHERE user_id = ? AND expires_at > {SQL_NOW} ORDER BY last_used_at DESC"
264 ))
265 .bind(&[a.user.id.into()])?
266 .all()
267 .await?
268 .results::<GrantListRow>()?;
269 Ok(rows.into_iter().map(grant).collect())
270 }
271
272 /// Changes what an application may do: its current access token at
273 /// once, and every token it is given from now on.
274 pub async fn update_oauth_grant(&self, a: UpdateOAuthGrantArgs) -> Result<Outcome<OAuthGrant>> {
275 let scopes = Grant::asked(&a.scopes).scopes_column();
276 let row = self
277 .db
278 .prepare(format!(
279 "UPDATE oauth_grants SET scopes = ?
280 WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}
281 RETURNING id, client_name, created_at, last_used_at, scopes"
282 ))
283 .bind(&[
284 scopes.as_str().into(),
285 a.id.as_str().into(),
286 a.user.id.as_str().into(),
287 ])?
288 .first::<GrantListRow>(None)
289 .await?;
290 let Some(row) = row else {
291 return Ok(Outcome::fail(FailureCode::NotFound, "No such application."));
292 };
293 self.db
294 .prepare(
295 "UPDATE access_tokens SET scopes = ?
296 WHERE id = (SELECT access_token_id FROM oauth_grants WHERE id = ?)",
297 )
298 .bind(&[scopes.as_str().into(), a.id.as_str().into()])?
299 .run()
300 .await?;
301 self.log_security(&a.user.id, "oauth_grant_rescoped", Some(&row.client_name), None).await;
302 self.audit_account(&a.user, "oauth_grant.rescoped", &format!("Changed what the application {} may do", row.client_name)).await;
303 Ok(Outcome::Ok(grant(row)))
304 }
305
306 /// Signs an application out: its refresh token and access token stop
307 /// working.
308 pub async fn revoke_oauth_grant(&self, a: RemoveArgs) -> Result<()> {
309 let client: Option<String> = self
310 .db
311 .prepare("SELECT client_name FROM oauth_grants WHERE id = ? AND user_id = ?")
312 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?
313 .first(Some("client_name"))
314 .await?;
315 if let Some(client) = &client {
316 self.log_security(&a.user.id, "oauth_grant_revoked", Some(client), None).await;
317 self.audit_account(&a.user, "oauth_grant.revoked", &format!("Revoked the application {client}")).await;
318 }
319 self.db
320 .batch(vec![
321 self.db
322 .prepare(
323 "DELETE FROM access_tokens WHERE id =
324 (SELECT access_token_id FROM oauth_grants WHERE id = ? AND user_id = ?)",
325 )
326 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
327 self.db
328 .prepare("DELETE FROM oauth_grants WHERE id = ? AND user_id = ?")
329 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?,
330 ])
331 .await?;
332 Ok(())
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::pkce_matches;
339
340 #[test]
341 fn pkce_verifier_matches_its_challenge() {
342 // The example from RFC 7636, appendix B.
343 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
344 let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
345 assert!(pkce_matches(verifier, challenge));
346 assert!(!pkce_matches("something else", challenge));
347 }
348}