OXYGENHUB
# ae138eea7a71 oxygenhub / crates / hub / src / web / device.rs
4.6 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
1
//! `/device`: approving an app's sign-in code (oxygenhub#67). The app shows
2
//! a code; the user, signed in here, enters it, sees what the app asked
3
//! for, and approves it with the scopes they choose, or denies it.
4
5
use super::settings::{csrf, require, SCOPES};
6
use super::{filters, Error, Page, Shell};
7
use crate::api::device::normalize_user_code;
8
use crate::auth::Viewer;
9
use crate::db::{self, accounts};
10
use crate::Shared;
11
use askama::Template;
12
use axum::extract::{Query, State};
13
use serde::Deserialize;
14
15
#[derive(Template)]
16
#[template(path = "device.html")]
17
pub struct DevicePage {
18
    shell: Shell,
19
    code: String,
20
    /// The pending request the code names, with each scope it asked for.
21
    pending: Option<(accounts::DeviceCode, Vec<(&'static str, &'static str)>)>,
22
    done: Option<String>,
23
    error: Option<String>,
24
}
25
26
/// A code still waiting for an answer.
27
async fn pending(app: &Shared, code: &str) -> Result<Option<accounts::DeviceCode>, sqlx::Error> {
28
    Ok(accounts::device_by_user_code(&app.db, code).await?.filter(|d| d.account_id.is_none() && !d.denied && d.expires_ms >= db::now_ms()))
29
}
30
31
fn page(shell: Shell, code: String, d: Option<accounts::DeviceCode>, done: Option<String>, error: Option<String>) -> Page<DevicePage> {
32
    let pending = d.map(|d| {
33
        let asked: Vec<(&'static str, &'static str)> = SCOPES.iter().filter(|(k, _)| d.scope_list().contains(k)).copied().collect();
34
        (d, asked)
35
    });
36
    Page(DevicePage { shell, code, pending, done, error })
37
}
38
39
#[derive(Deserialize)]
40
pub struct CodeQuery {
41
    #[serde(default)]
42
    code: String,
43
}
44
45
pub async fn form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<CodeQuery>) -> Result<Page<DevicePage>, Error> {
46
    let shell = Shell::new(&app, "/device", &viewer);
47
    require(&viewer, &shell)?;
48
    let code = normalize_user_code(&q.code);
49
    if code.is_empty() {
50
        return Ok(page(shell, code, None, None, None));
51
    }
52
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
53
    let error = d.is_none().then(|| "No app is waiting for that code: it may have expired. Ask the app for a new one.".to_string());
54
    Ok(page(shell, code, d, None, error))
55
}
56
57
#[derive(Deserialize)]
58
pub struct DeviceForm {
59
    #[serde(rename = "_csrf")]
60
    csrf_token: String,
61
    code: String,
62
    #[serde(default)]
63
    action: String,
64
    #[serde(default)]
65
    scopes: Vec<String>,
66
}
67
68
pub async fn answer(
69
    State(app): State<Shared>,
70
    viewer: Viewer,
71
    axum_extra::extract::Form(f): axum_extra::extract::Form<DeviceForm>,
72
) -> Result<Page<DevicePage>, Error> {
73
    let shell = Shell::new(&app, "/device", &viewer);
74
    let me = require(&viewer, &shell)?;
75
    csrf(&viewer, &f.csrf_token, &shell)?;
76
    let code = normalize_user_code(&f.code);
77
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
78
    let Some(d) = d else {
79
        let e = "No app is waiting for that code: it may have expired. Ask the app for a new one.";
80
        return Ok(page(shell, code, None, None, Some(e.into())));
81
    };
82
    match f.action.as_str() {
83
        "approve" => {
84
            // Only scopes the app asked for, and at least one.
85
            let asked = d.scope_list();
86
            let chosen: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| asked.contains(s)).collect();
87
            if chosen.is_empty() {
88
                return Ok(page(shell, code, Some(d), None, Some("Allow at least one of what it asks for, or deny it.".into())));
89
            }
90
            let ok = accounts::approve_device(&app.db, d.id, me.account.id, &chosen.join(","))
91
                .await
92
                .map_err(|e| Error::from_db(shell.clone(), e))?;
93
            if !ok {
94
                return Ok(page(shell, code, None, None, Some("That code was answered or expired meanwhile.".into())));
95
            }
96
            accounts::audit(&app.db, Some(&me.account), "device.approved", &d.client, &chosen.join(",")).await.ok();
97
            let done = format!(
98
                "Approved: {} is signed in as {} and will get its token in a moment. It is listed under Settings → Tokens, where you can revoke it.",
99
                d.client, me.account.username
100
            );
101
            Ok(page(shell, String::new(), None, Some(done), None))
102
        }
103
        "deny" => {
104
            accounts::deny_device(&app.db, d.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
105
            accounts::audit(&app.db, Some(&me.account), "device.denied", &d.client, "").await.ok();
106
            Ok(page(shell, String::new(), None, Some(format!("Denied: {} was not signed in.", d.client)), None))
107
        }
108
        // Looking a typed code up.
109
        _ => Ok(page(shell, code, Some(d), None, None)),
110
    }
111
}