//! Device sign-in (oxygenhub#67), in the shape of RFC 8628: an app that //! cannot take a password asks for a code, the user approves it on the web //! (signed in, with their second factor), and the app's next poll gets a //! token with the scopes the user allowed. use crate::db::{self, accounts}; use crate::Shared; use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; use serde_json::json; /// How long a code lasts, and how often an app may poll. const LIFETIME_MS: i64 = 15 * 60 * 1000; const INTERVAL_S: i64 = 5; /// User codes: consonants only, so no word is spelt and none is mistaken /// for another letter or a digit. const USER_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ"; #[derive(Deserialize)] pub struct CodeRequest { /// What the app calls itself, shown when the user approves it. #[serde(default)] client: String, #[serde(default)] scopes: Vec, } /// A user code, `XXXX-XXXX`. fn user_code() -> String { use rand::RngCore; let mut b = [0u8; 8]; rand::rngs::OsRng.fill_bytes(&mut b); let c: String = b.iter().map(|x| USER_ALPHABET[*x as usize % USER_ALPHABET.len()] as char).collect(); format!("{}-{}", &c[..4], &c[4..]) } /// A user code as typed: upper case, spaces and dashes ignored. pub fn normalize_user_code(s: &str) -> String { let c: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect(); if c.len() == 8 { format!("{}-{}", &c[..4], &c[4..]) } else { c } } fn oauth_error(status: StatusCode, error: &str, detail: &str) -> Response { (status, Json(json!({ "error": error, "error_description": detail }))).into_response() } /// `POST /api/v1/device/code`: a device code to poll with and a user code /// to show. No token needed. pub async fn code(State(app): State, Json(req): Json) -> Response { let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s); let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect(); if scopes.len() != req.scopes.len() { return oauth_error(StatusCode::BAD_REQUEST, "invalid_scope", "unknown scope"); } if scopes.is_empty() { scopes = vec!["repo:read", "user:read"]; } let client: String = req.client.trim().chars().filter(|c| !c.is_control()).take(80).collect(); let client = if client.is_empty() { "an app".to_string() } else { client }; let device = format!("oxydev_{}", crate::auth::random_hex(32)); let hash = crate::auth::token_hash(&device); let expires = db::now_ms() + LIFETIME_MS; // A clash of user codes is unlikely; try a few. for _ in 0..4 { let user = user_code(); match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires).await { Ok(_) => { let base = app.config.base_url().trim_end_matches('/').to_string(); return Json(json!({ "device_code": device, "user_code": user, "verification_uri": format!("{base}/device"), "verification_uri_complete": format!("{base}/device?code={user}"), "expires_in": LIFETIME_MS / 1000, "interval": INTERVAL_S, })) .into_response(); } Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue, Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), } } oauth_error(StatusCode::SERVICE_UNAVAILABLE, "server_error", "no free user code; try again") } #[derive(Deserialize)] pub struct TokenRequest { device_code: String, } /// `POST /api/v1/device/token`: the token once the user approved, or why /// not yet (`authorization_pending`, `slow_down`, `access_denied`, /// `expired_token`), as RFC 8628 has it. pub async fn token(State(app): State, Json(req): Json) -> Response { let hash = crate::auth::token_hash(req.device_code.trim()); let d = match accounts::device_by_hash(&app.db, &hash).await { Ok(Some(d)) => d, Ok(None) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "no such device code, or it was used"), Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), }; let now = db::now_ms(); if d.expires_ms < now { let _ = accounts::delete_device(&app.db, d.id).await; return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code expired; ask for a new one"); } if d.denied { let _ = accounts::delete_device(&app.db, d.id).await; return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the user denied the request"); } let Some(account_id) = d.account_id else { let _ = accounts::device_polled(&app.db, d.id).await; if now - d.last_poll_ms < INTERVAL_S * 1000 { return oauth_error(StatusCode::BAD_REQUEST, "slow_down", "poll no more often than the interval"); } return oauth_error(StatusCode::BAD_REQUEST, "authorization_pending", "not yet approved"); }; // One poll takes the approval: the token is made only by the poll // that removed the code. match accounts::delete_device(&app.db, d.id).await { Ok(true) => {} Ok(false) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code was used"), Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), } let account = match accounts::account_by_id(&app.db, account_id).await { Ok(Some(a)) => a, _ => return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the approving account is gone"), }; let (secret, token_hash) = crate::auth::new_token(); let name = format!("device: {}", d.client); if let Err(e) = accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", None).await { return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()); } accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok(); Json(json!({ "access_token": secret, "token_type": "bearer", "scope": d.approved_scopes.replace(',', " "), "username": account.username, })) .into_response() }