OXYGENHUB
◆ run-one-job oxygenhub / crates / hub / src / web / device.rs
4.9 KiB · Rust · last changed Device sign-in: a clear done page after approving or denying 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
    /// The answer just given: the page then says so and shows no code
23
    /// form, so nobody thinks the code is needed again (oxygenhub#83).
24
    done: Option<Done>,
25
    error: Option<String>,
26
}
27
28
pub struct Done {
29
    pub approved: bool,
30
    /// The app, as it named itself ("oxy on saturn").
31
    pub client: String,
32
    pub username: String,
33
}
34
35
/// A code still waiting for an answer.
36
async fn pending(app: &Shared, code: &str) -> Result<Option<accounts::DeviceCode>, sqlx::Error> {
37
    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()))
38
}
39
40
fn page(shell: Shell, code: String, d: Option<accounts::DeviceCode>, done: Option<Done>, error: Option<String>) -> Page<DevicePage> {
41
    let pending = d.map(|d| {
42
        let asked: Vec<(&'static str, &'static str)> = SCOPES.iter().filter(|(k, _)| d.scope_list().contains(k)).copied().collect();
43
        (d, asked)
44
    });
45
    Page(DevicePage { shell, code, pending, done, error })
46
}
47
48
#[derive(Deserialize)]
49
pub struct CodeQuery {
50
    #[serde(default)]
51
    code: String,
52
}
53
54
pub async fn form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<CodeQuery>) -> Result<Page<DevicePage>, Error> {
55
    let shell = Shell::new(&app, "/device", &viewer);
56
    require(&viewer, &shell)?;
57
    let code = normalize_user_code(&q.code);
58
    if code.is_empty() {
59
        return Ok(page(shell, code, None, None, None));
60
    }
61
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
62
    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());
63
    Ok(page(shell, code, d, None, error))
64
}
65
66
#[derive(Deserialize)]
67
pub struct DeviceForm {
68
    #[serde(rename = "_csrf")]
69
    csrf_token: String,
70
    code: String,
71
    #[serde(default)]
72
    action: String,
73
    #[serde(default)]
74
    scopes: Vec<String>,
75
}
76
77
pub async fn answer(
78
    State(app): State<Shared>,
79
    viewer: Viewer,
80
    axum_extra::extract::Form(f): axum_extra::extract::Form<DeviceForm>,
81
) -> Result<Page<DevicePage>, Error> {
82
    let shell = Shell::new(&app, "/device", &viewer);
83
    let me = require(&viewer, &shell)?;
84
    csrf(&viewer, &f.csrf_token, &shell)?;
85
    let code = normalize_user_code(&f.code);
86
    let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?;
87
    let Some(d) = d else {
88
        let e = "No app is waiting for that code: it may have expired. Ask the app for a new one.";
89
        return Ok(page(shell, code, None, None, Some(e.into())));
90
    };
91
    match f.action.as_str() {
92
        "approve" => {
93
            // Only scopes the app asked for, and at least one.
94
            let asked = d.scope_list();
95
            let chosen: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| asked.contains(s)).collect();
96
            if chosen.is_empty() {
97
                return Ok(page(shell, code, Some(d), None, Some("Allow at least one of what it asks for, or deny it.".into())));
98
            }
99
            let ok = accounts::approve_device(&app.db, d.id, me.account.id, &chosen.join(","))
100
                .await
101
                .map_err(|e| Error::from_db(shell.clone(), e))?;
102
            if !ok {
103
                return Ok(page(shell, code, None, None, Some("That code was answered or expired meanwhile.".into())));
104
            }
105
            accounts::audit(&app.db, Some(&me.account), "device.approved", &d.client, &chosen.join(",")).await.ok();
106
            let done = Done { approved: true, client: d.client.clone(), username: me.account.username.clone() };
107
            Ok(page(shell, String::new(), None, Some(done), None))
108
        }
109
        "deny" => {
110
            accounts::deny_device(&app.db, d.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
111
            accounts::audit(&app.db, Some(&me.account), "device.denied", &d.client, "").await.ok();
112
            Ok(page(
113
                shell,
114
                String::new(),
115
                None,
116
                Some(Done { approved: false, client: d.client.clone(), username: me.account.username.clone() }),
117
                None,
118
            ))
119
        }
120
        // Looking a typed code up.
121
        _ => Ok(page(shell, code, Some(d), None, None)),
122
    }
123
}