| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use crate::db::{self, accounts}; |
| 7 | | use crate::Shared; |
| 8 | | use axum::extract::State; |
| 9 | | use axum::http::StatusCode; |
| 10 | | use axum::response::{IntoResponse, Response}; |
| 11 | | use axum::Json; |
| 12 | | use serde::Deserialize; |
| 13 | | use serde_json::json; |
| 14 | |
|
| 15 | |
|
| 16 | | pub const TOKEN_LIFE_MS: i64 = 90 * 24 * 3600 * 1000; |
| 17 | |
|
| 18 | | const LIFETIME_MS: i64 = 15 * 60 * 1000; |
| 19 | | const INTERVAL_S: i64 = 5; |
| 20 | |
|
| 21 | |
|
| 22 | | const USER_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ"; |
| 23 | |
|
| 24 | | #[derive(Deserialize)] |
| 25 | | pub struct CodeRequest { |
| 26 | | |
| 27 | | #[serde(default)] |
| 28 | | client: String, |
| 29 | | #[serde(default)] |
| 30 | | scopes: Vec<String>, |
| 31 | | } |
| 32 | |
|
| 33 | |
|
| 34 | 3× | fn user_code() -> String { |
| 35 | | use rand::RngCore; |
| 36 | 3× | let mut b = [0u8; 8]; |
| 37 | 3× | rand::rngs::OsRng.fill_bytes(&mut b); |
| 38 | 24× | let c: String = b.iter().map(|x| USER_ALPHABET[*x as usize % USER_ALPHABET.len()] as char).collect(); |
| 39 | 3× | format!("{}-{}", &c[..4], &c[4..]) |
| 40 | 3× | } |
| 41 | |
|
| 42 | |
|
| 43 | 6× | pub fn normalize_user_code(s: &str) -> String { |
| 44 | 53× | let c: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect(); |
| 45 | 6× | if c.len() == 8 { |
| 46 | 6× | format!("{}-{}", &c[..4], &c[4..]) |
| 47 | | } else { |
| 48 | 0× | c |
| 49 | | } |
| 50 | 6× | } |
| 51 | |
|
| 52 | 6× | fn oauth_error(status: StatusCode, error: &str, detail: &str) -> Response { |
| 53 | 6× | (status, Json(json!({ "error": error, "error_description": detail }))).into_response() |
| 54 | 6× | } |
| 55 | |
|
| 56 | |
|
| 57 | |
|
| 58 | 4× | pub async fn code( |
| 59 | 4× | State(app): State<Shared>, |
| 60 | 4× | headers: axum::http::HeaderMap, |
| 61 | 4× | extensions: axum::http::Extensions, |
| 62 | 4× | Json(req): Json<CodeRequest>, |
| 63 | 4× | ) -> Response { |
| 64 | 4× | let requester = super::client_addr(&app, &headers, &extensions); |
| 65 | 17× | let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s); |
| 66 | 4× | let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect(); |
| 67 | 4× | if scopes.len() != req.scopes.len() { |
| 68 | 1× | return oauth_error(StatusCode::BAD_REQUEST, "invalid_scope", "unknown scope"); |
| 69 | 3× | } |
| 70 | 3× | if scopes.is_empty() { |
| 71 | 2× | scopes = vec!["repo:read", "user:read"]; |
| 72 | 2× | } |
| 73 | 23× | let client: String = req.client.trim().chars().filter(|c| !c.is_control()).take(80).collect(); |
| 74 | 3× | let client = if client.is_empty() { "an app".to_string() } else { client }; |
| 75 | 3× | let device = format!("oxydev_{}", crate::auth::random_hex(32)); |
| 76 | 3× | let hash = crate::auth::token_hash(&device); |
| 77 | 3× | let expires = db::now_ms() + LIFETIME_MS; |
| 78 | | |
| 79 | 3× | for _ in 0..4 { |
| 80 | 3× | let user = user_code(); |
| 81 | 3× | match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires, &requester).await { |
| 82 | | Ok(_) => { |
| 83 | 3× | let base = app.config.base_url().trim_end_matches('/').to_string(); |
| 84 | 3× | return Json(json!({ |
| 85 | 3× | "device_code": device, |
| 86 | 3× | "user_code": user, |
| 87 | 3× | "verification_uri": format!("{base}/device"), |
| 88 | 3× | "verification_uri_complete": format!("{base}/device?code={user}"), |
| 89 | 3× | "expires_in": LIFETIME_MS / 1000, |
| 90 | 3× | "interval": INTERVAL_S, |
| 91 | 3× | })) |
| 92 | 3× | .into_response(); |
| 93 | | } |
| 94 | 0× | Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue, |
| 95 | 0× | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
| 96 | | } |
| 97 | | } |
| 98 | 0× | oauth_error(StatusCode::SERVICE_UNAVAILABLE, "server_error", "no free user code; try again") |
| 99 | 4× | } |
| 100 | |
|
| 101 | | #[derive(Deserialize)] |
| 102 | | pub struct TokenRequest { |
| 103 | | device_code: String, |
| 104 | | } |
| 105 | |
|
| 106 | |
|
| 107 | |
|
| 108 | |
|
| 109 | |
|
| 110 | |
|
| 111 | 2× | pub async fn cancel(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response { |
| 112 | 2× | let hash = crate::auth::token_hash(req.device_code.trim()); |
| 113 | 2× | match accounts::device_by_hash(&app.db, &hash).await { |
| 114 | 1× | Ok(Some(d)) => match accounts::delete_device(&app.db, d.id).await { |
| 115 | 1× | Ok(_) => StatusCode::NO_CONTENT.into_response(), |
| 116 | 0× | Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
| 117 | | }, |
| 118 | 1× | Ok(None) => StatusCode::NO_CONTENT.into_response(), |
| 119 | 0× | Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
| 120 | | } |
| 121 | 2× | } |
| 122 | |
|
| 123 | |
|
| 124 | |
|
| 125 | |
|
| 126 | 6× | pub async fn token(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response { |
| 127 | 6× | let hash = crate::auth::token_hash(req.device_code.trim()); |
| 128 | 6× | let d = match accounts::device_by_hash(&app.db, &hash).await { |
| 129 | 4× | Ok(Some(d)) => d, |
| 130 | 2× | Ok(None) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "no such device code, or it was used"), |
| 131 | 0× | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
| 132 | | }; |
| 133 | 4× | let now = db::now_ms(); |
| 134 | 4× | if d.expires_ms < now { |
| 135 | 0× | let _ = accounts::delete_device(&app.db, d.id).await; |
| 136 | 0× | return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code expired; ask for a new one"); |
| 137 | 4× | } |
| 138 | 4× | if d.denied { |
| 139 | 1× | let _ = accounts::delete_device(&app.db, d.id).await; |
| 140 | 1× | return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the user denied the request"); |
| 141 | 3× | } |
| 142 | 3× | let Some(account_id) = d.account_id else { |
| 143 | 2× | let _ = accounts::device_polled(&app.db, d.id).await; |
| 144 | 2× | if now - d.last_poll_ms < INTERVAL_S * 1000 { |
| 145 | 1× | return oauth_error(StatusCode::BAD_REQUEST, "slow_down", "poll no more often than the interval"); |
| 146 | 1× | } |
| 147 | 1× | return oauth_error(StatusCode::BAD_REQUEST, "authorization_pending", "not yet approved"); |
| 148 | | }; |
| 149 | | |
| 150 | | |
| 151 | 1× | match accounts::delete_device(&app.db, d.id).await { |
| 152 | 1× | Ok(true) => {} |
| 153 | 0× | Ok(false) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code was used"), |
| 154 | 0× | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
| 155 | | } |
| 156 | 1× | let account = match accounts::account_by_id(&app.db, account_id).await { |
| 157 | 1× | Ok(Some(a)) => a, |
| 158 | 0× | _ => return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the approving account is gone"), |
| 159 | | }; |
| 160 | 1× | let (secret, token_hash) = crate::auth::new_token(); |
| 161 | 1× | let name = format!("device: {}", d.client); |
| 162 | | |
| 163 | 1× | let expires = now + TOKEN_LIFE_MS; |
| 164 | 1× | let made = match accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", Some(expires)).await { |
| 165 | 1× | Ok(t) => t, |
| 166 | 0× | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
| 167 | | }; |
| 168 | 1× | if let Err(e) = accounts::set_token_renewal(&app.db, made.id, TOKEN_LIFE_MS).await { |
| 169 | 0× | return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()); |
| 170 | 1× | } |
| 171 | 1× | accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok(); |
| 172 | 1× | Json(json!({ |
| 173 | 1× | "access_token": secret, |
| 174 | 1× | "token_type": "bearer", |
| 175 | 1× | "scope": d.approved_scopes.replace(',', " "), |
| 176 | 1× | "username": account.username, |
| 177 | 1× | "expires_in": TOKEN_LIFE_MS / 1000, |
| 178 | 1× | })) |
| 179 | 1× | .into_response() |
| 180 | 6× | } |