OXYGENHUB
# c4c87f481d05 oxygenhub / crates / hub / src / api / device.rs
6.4 KiB · Rust · last changed Device sign-in: approve an app's code on the web, the app gets a scoped token (closes #67) 1 month ago source blame history raw
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 code lasts, and how often an app may poll.
16
const LIFETIME_MS: i64 = 15 * 60 * 1000;
17
const INTERVAL_S: i64 = 5;
18
/// User codes: consonants only, so no word is spelt and none is mistaken
19
/// for another letter or a digit.
20
const USER_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
21
22
#[derive(Deserialize)]
23
pub struct CodeRequest {
24
    /// What the app calls itself, shown when the user approves it.
25
    #[serde(default)]
26
    client: String,
27
    #[serde(default)]
28
    scopes: Vec<String>,
29
}
30
31
/// A user code, `XXXX-XXXX`.
32
fn user_code() -> String {
33
    use rand::RngCore;
34
    let mut b = [0u8; 8];
35
    rand::rngs::OsRng.fill_bytes(&mut b);
36
    let c: String = b.iter().map(|x| USER_ALPHABET[*x as usize % USER_ALPHABET.len()] as char).collect();
37
    format!("{}-{}", &c[..4], &c[4..])
38
}
39
40
/// A user code as typed: upper case, spaces and dashes ignored.
41
pub fn normalize_user_code(s: &str) -> String {
42
    let c: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect();
43
    if c.len() == 8 {
44
        format!("{}-{}", &c[..4], &c[4..])
45
    } else {
46
        c
47
    }
48
}
49
50
fn oauth_error(status: StatusCode, error: &str, detail: &str) -> Response {
51
    (status, Json(json!({ "error": error, "error_description": detail }))).into_response()
52
}
53
54
/// `POST /api/v1/device/code`: a device code to poll with and a user code
55
/// to show. No token needed.
56
pub async fn code(State(app): State<Shared>, Json(req): Json<CodeRequest>) -> Response {
57
    let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s);
58
    let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect();
59
    if scopes.len() != req.scopes.len() {
60
        return oauth_error(StatusCode::BAD_REQUEST, "invalid_scope", "unknown scope");
61
    }
62
    if scopes.is_empty() {
63
        scopes = vec!["repo:read", "user:read"];
64
    }
65
    let client: String = req.client.trim().chars().filter(|c| !c.is_control()).take(80).collect();
66
    let client = if client.is_empty() { "an app".to_string() } else { client };
67
    let device = format!("oxydev_{}", crate::auth::random_hex(32));
68
    let hash = crate::auth::token_hash(&device);
69
    let expires = db::now_ms() + LIFETIME_MS;
70
    // A clash of user codes is unlikely; try a few.
71
    for _ in 0..4 {
72
        let user = user_code();
73
        match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires).await {
74
            Ok(_) => {
75
                let base = app.config.base_url().trim_end_matches('/').to_string();
76
                return Json(json!({
77
                    "device_code": device,
78
                    "user_code": user,
79
                    "verification_uri": format!("{base}/device"),
80
                    "verification_uri_complete": format!("{base}/device?code={user}"),
81
                    "expires_in": LIFETIME_MS / 1000,
82
                    "interval": INTERVAL_S,
83
                }))
84
                .into_response();
85
            }
86
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue,
87
            Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
88
        }
89
    }
90
    oauth_error(StatusCode::SERVICE_UNAVAILABLE, "server_error", "no free user code; try again")
91
}
92
93
#[derive(Deserialize)]
94
pub struct TokenRequest {
95
    device_code: String,
96
}
97
98
/// `POST /api/v1/device/token`: the token once the user approved, or why
99
/// not yet (`authorization_pending`, `slow_down`, `access_denied`,
100
/// `expired_token`), as RFC 8628 has it.
101
pub async fn token(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response {
102
    let hash = crate::auth::token_hash(req.device_code.trim());
103
    let d = match accounts::device_by_hash(&app.db, &hash).await {
104
        Ok(Some(d)) => d,
105
        Ok(None) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "no such device code, or it was used"),
106
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
107
    };
108
    let now = db::now_ms();
109
    if d.expires_ms < now {
110
        let _ = accounts::delete_device(&app.db, d.id).await;
111
        return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code expired; ask for a new one");
112
    }
113
    if d.denied {
114
        let _ = accounts::delete_device(&app.db, d.id).await;
115
        return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the user denied the request");
116
    }
117
    let Some(account_id) = d.account_id else {
118
        let _ = accounts::device_polled(&app.db, d.id).await;
119
        if now - d.last_poll_ms < INTERVAL_S * 1000 {
120
            return oauth_error(StatusCode::BAD_REQUEST, "slow_down", "poll no more often than the interval");
121
        }
122
        return oauth_error(StatusCode::BAD_REQUEST, "authorization_pending", "not yet approved");
123
    };
124
    // One poll takes the approval: the token is made only by the poll
125
    // that removed the code.
126
    match accounts::delete_device(&app.db, d.id).await {
127
        Ok(true) => {}
128
        Ok(false) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code was used"),
129
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
130
    }
131
    let account = match accounts::account_by_id(&app.db, account_id).await {
132
        Ok(Some(a)) => a,
133
        _ => return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the approving account is gone"),
134
    };
135
    let (secret, token_hash) = crate::auth::new_token();
136
    let name = format!("device: {}", d.client);
137
    if let Err(e) = accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", None).await {
138
        return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string());
139
    }
140
    accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok();
141
    Json(json!({
142
        "access_token": secret,
143
        "token_type": "bearer",
144
        "scope": d.approved_scopes.replace(',', " "),
145
        "username": account.username,
146
    }))
147
    .into_response()
148
}