OXYGENHUB
# dd0788b756f2 oxygenhub / crates / hub / src / api / device.rs
8.0 KiB · Rust · last changed The API for apps: own repositories, organisations, 401, device cancel, richer notifications (closes #302, closes #303, closes #304, closes #305) 11 days 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 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`.
34
fn user_code() -> String {
35
    use rand::RngCore;
36
    let mut b = [0u8; 8];
37
    rand::rngs::OsRng.fill_bytes(&mut b);
38
    let c: String = b.iter().map(|x| USER_ALPHABET[*x as usize % USER_ALPHABET.len()] as char).collect();
39
    format!("{}-{}", &c[..4], &c[4..])
40
}
41
42
/// A user code as typed: upper case, spaces and dashes ignored.
43
pub fn normalize_user_code(s: &str) -> String {
44
    let c: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect();
45
    if c.len() == 8 {
46
        format!("{}-{}", &c[..4], &c[4..])
47
    } else {
48
        c
49
    }
50
}
51
52
fn oauth_error(status: StatusCode, error: &str, detail: &str) -> Response {
53
    (status, Json(json!({ "error": error, "error_description": detail }))).into_response()
54
}
55
56
/// `POST /api/v1/device/code`: a device code to poll with and a user code
57
/// to show. No token needed.
58
pub async fn code(
59
    State(app): State<Shared>,
60
    headers: axum::http::HeaderMap,
61
    extensions: axum::http::Extensions,
62
    Json(req): Json<CodeRequest>,
63
) -> Response {
64
    let requester = super::client_addr(&app, &headers, &extensions);
65
    let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s);
66
    let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect();
67
    if scopes.len() != req.scopes.len() {
68
        return oauth_error(StatusCode::BAD_REQUEST, "invalid_scope", "unknown scope");
69
    }
70
    if scopes.is_empty() {
71
        scopes = vec!["repo:read", "user:read"];
72
    }
73
    let client: String = req.client.trim().chars().filter(|c| !c.is_control()).take(80).collect();
74
    let client = if client.is_empty() { "an app".to_string() } else { client };
75
    let device = format!("oxydev_{}", crate::auth::random_hex(32));
76
    let hash = crate::auth::token_hash(&device);
77
    let expires = db::now_ms() + LIFETIME_MS;
78
    // A clash of user codes is unlikely; try a few.
79
    for _ in 0..4 {
80
        let user = user_code();
81
        match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires, &requester).await {
82
            Ok(_) => {
83
                let base = app.config.base_url().trim_end_matches('/').to_string();
84
                return Json(json!({
85
                    "device_code": device,
86
                    "user_code": user,
87
                    "verification_uri": format!("{base}/device"),
88
                    "verification_uri_complete": format!("{base}/device?code={user}"),
89
                    "expires_in": LIFETIME_MS / 1000,
90
                    "interval": INTERVAL_S,
91
                }))
92
                .into_response();
93
            }
94
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue,
95
            Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
96
        }
97
    }
98
    oauth_error(StatusCode::SERVICE_UNAVAILABLE, "server_error", "no free user code; try again")
99
}
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.
111
pub async fn cancel(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response {
112
    let hash = crate::auth::token_hash(req.device_code.trim());
113
    match accounts::device_by_hash(&app.db, &hash).await {
114
        Ok(Some(d)) => match accounts::delete_device(&app.db, d.id).await {
115
            Ok(_) => StatusCode::NO_CONTENT.into_response(),
116
            Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
117
        },
118
        Ok(None) => StatusCode::NO_CONTENT.into_response(),
119
        Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
120
    }
121
}
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.
126
pub async fn token(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response {
127
    let hash = crate::auth::token_hash(req.device_code.trim());
128
    let d = match accounts::device_by_hash(&app.db, &hash).await {
129
        Ok(Some(d)) => d,
130
        Ok(None) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "no such device code, or it was used"),
131
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
132
    };
133
    let now = db::now_ms();
134
    if d.expires_ms < now {
135
        let _ = accounts::delete_device(&app.db, d.id).await;
136
        return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code expired; ask for a new one");
137
    }
138
    if d.denied {
139
        let _ = accounts::delete_device(&app.db, d.id).await;
140
        return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the user denied the request");
141
    }
142
    let Some(account_id) = d.account_id else {
143
        let _ = accounts::device_polled(&app.db, d.id).await;
144
        if now - d.last_poll_ms < INTERVAL_S * 1000 {
145
            return oauth_error(StatusCode::BAD_REQUEST, "slow_down", "poll no more often than the interval");
146
        }
147
        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.
151
    match accounts::delete_device(&app.db, d.id).await {
152
        Ok(true) => {}
153
        Ok(false) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code was used"),
154
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
155
    }
156
    let account = match accounts::account_by_id(&app.db, account_id).await {
157
        Ok(Some(a)) => a,
158
        _ => return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the approving account is gone"),
159
    };
160
    let (secret, token_hash) = crate::auth::new_token();
161
    let name = format!("device: {}", d.client);
162
    // A device's token lasts while it is used: each use renews it.
163
    let expires = now + TOKEN_LIFE_MS;
164
    let made = match accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", Some(expires)).await {
165
        Ok(t) => t,
166
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
167
    };
168
    if let Err(e) = accounts::set_token_renewal(&app.db, made.id, TOKEN_LIFE_MS).await {
169
        return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string());
170
    }
171
    accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok();
172
    Json(json!({
173
        "access_token": secret,
174
        "token_type": "bearer",
175
        "scope": d.approved_scopes.replace(',', " "),
176
        "username": account.username,
177
        "expires_in": TOKEN_LIFE_MS / 1000,
178
    }))
179
    .into_response()
180
}