//! `/device`: approving an app's sign-in code (oxygenhub#67). The app shows //! a code; the user, signed in here, enters it, sees what the app asked //! for, and approves it with the scopes they choose, or denies it. use super::settings::{csrf, require, SCOPES}; use super::{filters, Error, Page, Shell}; use crate::api::device::normalize_user_code; use crate::auth::Viewer; use crate::db::{self, accounts}; use crate::Shared; use askama::Template; use axum::extract::{Query, State}; use serde::Deserialize; #[derive(Template)] #[template(path = "device.html")] pub struct DevicePage { shell: Shell, code: String, /// The pending request the code names, with each scope it asked for. pending: Option<(accounts::DeviceCode, Vec<(&'static str, &'static str)>)>, /// The answer just given: the page then says so and shows no code /// form, so nobody thinks the code is needed again (oxygenhub#83). done: Option, error: Option, } pub struct Done { pub approved: bool, /// The app, as it named itself ("oxy on saturn"). pub client: String, pub username: String, } /// A code still waiting for an answer. async fn pending(app: &Shared, code: &str) -> Result, sqlx::Error> { 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())) } fn page(shell: Shell, code: String, d: Option, done: Option, error: Option) -> Page { let pending = d.map(|d| { let asked: Vec<(&'static str, &'static str)> = SCOPES.iter().filter(|(k, _)| d.scope_list().contains(k)).copied().collect(); (d, asked) }); Page(DevicePage { shell, code, pending, done, error }) } #[derive(Deserialize)] pub struct CodeQuery { #[serde(default)] code: String, } pub async fn form(State(app): State, viewer: Viewer, Query(q): Query) -> Result, Error> { let shell = Shell::new(&app, "/device", &viewer); require(&viewer, &shell)?; let code = normalize_user_code(&q.code); if code.is_empty() { return Ok(page(shell, code, None, None, None)); } let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?; 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()); Ok(page(shell, code, d, None, error)) } #[derive(Deserialize)] pub struct DeviceForm { #[serde(rename = "_csrf")] csrf_token: String, code: String, #[serde(default)] action: String, #[serde(default)] scopes: Vec, } pub async fn answer( State(app): State, viewer: Viewer, axum_extra::extract::Form(f): axum_extra::extract::Form, ) -> Result, Error> { let shell = Shell::new(&app, "/device", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let code = normalize_user_code(&f.code); let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?; let Some(d) = d else { let e = "No app is waiting for that code: it may have expired. Ask the app for a new one."; return Ok(page(shell, code, None, None, Some(e.into()))); }; match f.action.as_str() { "approve" => { // Only scopes the app asked for, and at least one. let asked = d.scope_list(); let chosen: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| asked.contains(s)).collect(); if chosen.is_empty() { return Ok(page(shell, code, Some(d), None, Some("Allow at least one of what it asks for, or deny it.".into()))); } let ok = accounts::approve_device(&app.db, d.id, me.account.id, &chosen.join(",")) .await .map_err(|e| Error::from_db(shell.clone(), e))?; if !ok { return Ok(page(shell, code, None, None, Some("That code was answered or expired meanwhile.".into()))); } accounts::audit(&app.db, Some(&me.account), "device.approved", &d.client, &chosen.join(",")).await.ok(); let done = Done { approved: true, client: d.client.clone(), username: me.account.username.clone() }; Ok(page(shell, String::new(), None, Some(done), None)) } "deny" => { accounts::deny_device(&app.db, d.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "device.denied", &d.client, "").await.ok(); Ok(page( shell, String::new(), None, Some(Done { approved: false, client: d.client.clone(), username: me.account.username.clone() }), None, )) } // Looking a typed code up. _ => Ok(page(shell, code, Some(d), None, None)), } }