OXYGENHUB

Coverage / crates/hub/src/api/device.rs 88.1%

104 of 118 lines run, 16 of 32 functions · run #534 on main · run missed

1
//! Device sign-in (oxygenhub#67), in the shape of RFC 8628: an app that
2
//! cannot take a password asks for a code, the user approves it on the web
3
//! (signed in, with their second factor), and the app's next poll gets a
4
//! token with the scopes the user allowed.
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
/// How long a device's token lasts after its last use (oxygenhub#70).
16
pub const TOKEN_LIFE_MS: i64 = 90 * 24 * 3600 * 1000;
17
/// How long a code lasts, and how often an app may poll.
18
const LIFETIME_MS: i64 = 15 * 60 * 1000;
19
const INTERVAL_S: i64 = 5;
20
/// User codes: consonants only, so no word is spelt and none is mistaken
21
/// for another letter or a digit.
22
const USER_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
23
24
#[derive(Deserialize)]
25
pub struct CodeRequest {
26
    /// What the app calls itself, shown when the user approves it.
27
    #[serde(default)]
28
    client: String,
29
    #[serde(default)]
30
    scopes: Vec<String>,
31
}
32
33
/// A user code, `XXXX-XXXX`.
343×
fn user_code() -> String {
35
    use rand::RngCore;
363×
    let mut b = [0u8; 8];
373×
    rand::rngs::OsRng.fill_bytes(&mut b);
3824×
    let c: String = b.iter().map(|x| USER_ALPHABET[*x as usize % USER_ALPHABET.len()] as char).collect();
393×
    format!("{}-{}", &c[..4], &c[4..])
403×
}
41
42
/// A user code as typed: upper case, spaces and dashes ignored.
436×
pub fn normalize_user_code(s: &str) -> String {
4453×
    let c: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect();
456×
    if c.len() == 8 {
466×
        format!("{}-{}", &c[..4], &c[4..])
47
    } else {
480×
        c
49
    }
506×
}
51
526×
fn oauth_error(status: StatusCode, error: &str, detail: &str) -> Response {
536×
    (status, Json(json!({ "error": error, "error_description": detail }))).into_response()
546×
}
55
56
/// `POST /api/v1/device/code`: a device code to poll with and a user code
57
/// to show. No token needed.
584×
pub async fn code(
594×
    State(app): State<Shared>,
604×
    headers: axum::http::HeaderMap,
614×
    extensions: axum::http::Extensions,
624×
    Json(req): Json<CodeRequest>,
634×
) -> Response {
644×
    let requester = super::client_addr(&app, &headers, &extensions);
6517×
    let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s);
664×
    let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect();
674×
    if scopes.len() != req.scopes.len() {
681×
        return oauth_error(StatusCode::BAD_REQUEST, "invalid_scope", "unknown scope");
693×
    }
703×
    if scopes.is_empty() {
712×
        scopes = vec!["repo:read", "user:read"];
722×
    }
7323×
    let client: String = req.client.trim().chars().filter(|c| !c.is_control()).take(80).collect();
743×
    let client = if client.is_empty() { "an app".to_string() } else { client };
753×
    let device = format!("oxydev_{}", crate::auth::random_hex(32));
763×
    let hash = crate::auth::token_hash(&device);
773×
    let expires = db::now_ms() + LIFETIME_MS;
78
    // A clash of user codes is unlikely; try a few.
793×
    for _ in 0..4 {
803×
        let user = user_code();
813×
        match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires, &requester).await {
82
            Ok(_) => {
833×
                let base = app.config.base_url().trim_end_matches('/').to_string();
843×
                return Json(json!({
853×
                    "device_code": device,
863×
                    "user_code": user,
873×
                    "verification_uri": format!("{base}/device"),
883×
                    "verification_uri_complete": format!("{base}/device?code={user}"),
893×
                    "expires_in": LIFETIME_MS / 1000,
903×
                    "interval": INTERVAL_S,
913×
                }))
923×
                .into_response();
93
            }
940×
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue,
950×
            Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
96
        }
97
    }
980×
    oauth_error(StatusCode::SERVICE_UNAVAILABLE, "server_error", "no free user code; try again")
994×
}
100
101
#[derive(Deserialize)]
102
pub struct TokenRequest {
103
    device_code: String,
104
}
105
106
/// `DELETE /api/v1/device/code` (#304): the app gave up (its user pressed
107
/// Cancel): the code is withdrawn at once, so nobody can approve it later,
108
/// rather than waiting out its fifteen minutes. Answers 204 whether or not
109
/// there was such a code; a code already approved and taken is a token
110
/// now, which the app revokes as any other.
1112×
pub async fn cancel(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response {
1122×
    let hash = crate::auth::token_hash(req.device_code.trim());
1132×
    match accounts::device_by_hash(&app.db, &hash).await {
1141×
        Ok(Some(d)) => match accounts::delete_device(&app.db, d.id).await {
1151×
            Ok(_) => StatusCode::NO_CONTENT.into_response(),
1160×
            Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
117
        },
1181×
        Ok(None) => StatusCode::NO_CONTENT.into_response(),
1190×
        Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
120
    }
1212×
}
122
123
/// `POST /api/v1/device/token`: the token once the user approved, or why
124
/// not yet (`authorization_pending`, `slow_down`, `access_denied`,
125
/// `expired_token`), as RFC 8628 has it.
1266×
pub async fn token(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response {
1276×
    let hash = crate::auth::token_hash(req.device_code.trim());
1286×
    let d = match accounts::device_by_hash(&app.db, &hash).await {
1294×
        Ok(Some(d)) => d,
1302×
        Ok(None) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "no such device code, or it was used"),
1310×
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
132
    };
1334×
    let now = db::now_ms();
1344×
    if d.expires_ms < now {
1350×
        let _ = accounts::delete_device(&app.db, d.id).await;
1360×
        return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code expired; ask for a new one");
1374×
    }
1384×
    if d.denied {
1391×
        let _ = accounts::delete_device(&app.db, d.id).await;
1401×
        return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the user denied the request");
1413×
    }
1423×
    let Some(account_id) = d.account_id else {
1432×
        let _ = accounts::device_polled(&app.db, d.id).await;
1442×
        if now - d.last_poll_ms < INTERVAL_S * 1000 {
1451×
            return oauth_error(StatusCode::BAD_REQUEST, "slow_down", "poll no more often than the interval");
1461×
        }
1471×
        return oauth_error(StatusCode::BAD_REQUEST, "authorization_pending", "not yet approved");
148
    };
149
    // One poll takes the approval: the token is made only by the poll
150
    // that removed the code.
1511×
    match accounts::delete_device(&app.db, d.id).await {
1521×
        Ok(true) => {}
1530×
        Ok(false) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code was used"),
1540×
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
155
    }
1561×
    let account = match accounts::account_by_id(&app.db, account_id).await {
1571×
        Ok(Some(a)) => a,
1580×
        _ => return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the approving account is gone"),
159
    };
1601×
    let (secret, token_hash) = crate::auth::new_token();
1611×
    let name = format!("device: {}", d.client);
162
    // A device's token lasts while it is used: each use renews it.
1631×
    let expires = now + TOKEN_LIFE_MS;
1641×
    let made = match accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", Some(expires)).await {
1651×
        Ok(t) => t,
1660×
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
167
    };
1681×
    if let Err(e) = accounts::set_token_renewal(&app.db, made.id, TOKEN_LIFE_MS).await {
1690×
        return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string());
1701×
    }
1711×
    accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok();
1721×
    Json(json!({
1731×
        "access_token": secret,
1741×
        "token_type": "bearer",
1751×
        "scope": d.approved_scopes.replace(',', " "),
1761×
        "username": account.username,
1771×
        "expires_in": TOKEN_LIFE_MS / 1000,
1781×
    }))
1791×
    .into_response()
1806×
}