OXYGENHUB
# 44a3d2ebaddd oxygenhub / crates / hub / src / api / device.rs
7.1 KiB · Rust · last changed Device tokens expire and renew on use; the approval page shows the requester (closes #70) 1 month ago source blame history raw
df71c789 Andy McCall ↶ 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
60f11b71 Andy McCall ↶ 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;
df71c789 Andy McCall ↶ 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.
60f11b71 Andy McCall ↶ 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);
df71c789 Andy McCall ↶ 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();
60f11b71 Andy McCall ↶ 81
        match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires, &requester).await {
df71c789 Andy McCall ↶ 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
/// `POST /api/v1/device/token`: the token once the user approved, or why
107
/// not yet (`authorization_pending`, `slow_down`, `access_denied`,
108
/// `expired_token`), as RFC 8628 has it.
109
pub async fn token(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response {
110
    let hash = crate::auth::token_hash(req.device_code.trim());
111
    let d = match accounts::device_by_hash(&app.db, &hash).await {
112
        Ok(Some(d)) => d,
113
        Ok(None) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "no such device code, or it was used"),
114
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
115
    };
116
    let now = db::now_ms();
117
    if d.expires_ms < now {
118
        let _ = accounts::delete_device(&app.db, d.id).await;
119
        return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code expired; ask for a new one");
120
    }
121
    if d.denied {
122
        let _ = accounts::delete_device(&app.db, d.id).await;
123
        return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the user denied the request");
124
    }
125
    let Some(account_id) = d.account_id else {
126
        let _ = accounts::device_polled(&app.db, d.id).await;
127
        if now - d.last_poll_ms < INTERVAL_S * 1000 {
128
            return oauth_error(StatusCode::BAD_REQUEST, "slow_down", "poll no more often than the interval");
129
        }
130
        return oauth_error(StatusCode::BAD_REQUEST, "authorization_pending", "not yet approved");
131
    };
132
    // One poll takes the approval: the token is made only by the poll
133
    // that removed the code.
134
    match accounts::delete_device(&app.db, d.id).await {
135
        Ok(true) => {}
136
        Ok(false) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code was used"),
137
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
138
    }
139
    let account = match accounts::account_by_id(&app.db, account_id).await {
140
        Ok(Some(a)) => a,
141
        _ => return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the approving account is gone"),
142
    };
143
    let (secret, token_hash) = crate::auth::new_token();
144
    let name = format!("device: {}", d.client);
60f11b71 Andy McCall ↶ 145
    // A device's token lasts while it is used: each use renews it.
146
    let expires = now + TOKEN_LIFE_MS;
147
    let made = match accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", Some(expires)).await {
148
        Ok(t) => t,
149
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
150
    };
151
    if let Err(e) = accounts::set_token_renewal(&app.db, made.id, TOKEN_LIFE_MS).await {
df71c789 Andy McCall ↶ 152
        return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string());
153
    }
154
    accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok();
155
    Json(json!({
156
        "access_token": secret,
157
        "token_type": "bearer",
158
        "scope": d.approved_scopes.replace(',', " "),
159
        "username": account.username,
60f11b71 Andy McCall ↶ 160
        "expires_in": TOKEN_LIFE_MS / 1000,
df71c789 Andy McCall ↶ 161
    }))
162
    .into_response()
163
}