Skip to main content
← dvs documentation Rust API reference

dvs/
auth.rs

1//! Auth for dvs-server
2
3use std::collections::HashMap;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::thread::sleep;
7use std::time::Duration;
8
9use anyhow::{Result, bail};
10use fs_err as fs;
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::backends::server::{
15    invalid_response, join_url, response_error, status_error, unreachable,
16};
17use crate::error::{Error, ErrorCode};
18use crate::utils::http_agent;
19
20type TokenStore = HashMap<String, String>;
21
22#[cfg(not(test))]
23fn get_token_store_path() -> Result<PathBuf> {
24    use etcetera::BaseStrategy;
25    let d = etcetera::choose_base_strategy()?.config_dir();
26    Ok(d.join("dvs").join("credentials.json"))
27}
28
29/// Unit tests never touch the real user's credentials.
30#[cfg(test)]
31fn get_token_store_path() -> Result<PathBuf> {
32    Ok(std::env::temp_dir()
33        .join(format!(
34            "dvs-unit-tests-no-credentials-{}",
35            std::process::id()
36        ))
37        .join("credentials.json"))
38}
39
40fn create_private_dir(dir: &Path) -> Result<()> {
41    #[cfg(unix)]
42    {
43        use std::os::unix::fs::DirBuilderExt;
44        std::fs::DirBuilder::new()
45            .recursive(true)
46            .mode(0o700)
47            .create(dir)?;
48    }
49    #[cfg(not(unix))]
50    fs::create_dir_all(dir)?;
51    Ok(())
52}
53
54fn write_store(path: &Path, contents: &str) -> Result<()> {
55    create_private_dir(path.parent().unwrap())?;
56    let mut opts = fs::OpenOptions::new();
57    opts.write(true).create(true).truncate(true);
58    #[cfg(unix)]
59    {
60        use fs_err::os::unix::fs::OpenOptionsExt;
61        opts.mode(0o600);
62    }
63    opts.open(path)?.write_all(contents.as_bytes())?;
64    Ok(())
65}
66
67/// Reads the token store from `path`; none yet is an empty store. An
68/// unreadable store is an error: only `dvs login` and `dvs logout` replace it.
69fn read_store(path: &Path) -> Result<TokenStore> {
70    let unreadable = |reason: String| {
71        Error::new(
72            ErrorCode::CredentialsUnreadable,
73            format!("{} {reason}; run `dvs login` to replace it", path.display()),
74        )
75    };
76    let file = match fs::File::open(path) {
77        Ok(file) => file,
78        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(TokenStore::new()),
79        Err(e) => return Err(unreadable(format!("could not be read ({e})")).into()),
80    };
81    serde_json::from_reader(file)
82        .map_err(|e| unreadable(format!("is not a valid credentials store ({e})")).into())
83}
84
85pub fn store_token(url: &Url, token: &str) -> Result<()> {
86    let path = get_token_store_path()?;
87    // Logging in is the user's way to replace an unreadable store
88    let mut store = read_store(&path).unwrap_or_default();
89    store.insert(url.to_string(), token.to_string());
90    let json = serde_json::to_string_pretty(&store)?;
91    write_store(&path, &json)?;
92
93    Ok(())
94}
95
96pub fn get_token(url: &Url) -> Result<Option<String>> {
97    let path = get_token_store_path().map_err(|e| {
98        Error::new(
99            ErrorCode::AuthRequired,
100            format!("Could not find the credentials store ({e}). Log in first"),
101        )
102    })?;
103    Ok(read_store(&path)?.get(url.as_str()).cloned())
104}
105
106pub fn delete_token(url: &Url) -> Result<bool> {
107    let path = get_token_store_path()?;
108    if path.exists() {
109        // Logging out resets an unreadable store
110        let mut store = read_store(&path).unwrap_or_default();
111        let old = store.remove(url.as_str());
112        let json = serde_json::to_string_pretty(&store)?;
113        fs::write(path, json)?;
114        Ok(old.is_some())
115    } else {
116        Ok(false)
117    }
118}
119
120#[derive(Serialize, Deserialize)]
121pub struct DeviceAuthorizationResponse {
122    pub device_code: String,
123    pub user_code: String,
124}
125
126pub fn get_client_approve_url(base_url: &Url, user_code: &str) -> Result<Url> {
127    let mut u = join_url(base_url, "device/approve")?;
128    u.set_query(Some(&format!("user_code={user_code}")));
129    Ok(u)
130}
131
132pub fn initiate_device_approval(base_url: &Url) -> Result<DeviceAuthorizationResponse> {
133    let url = join_url(base_url, "api/oauth/device/authorize")?;
134    let mut resp = http_agent()
135        .post(url.as_str())
136        .send_empty()
137        .map_err(|e| unreachable(base_url, e))?;
138    if !resp.status().is_success() {
139        return Err(response_error(base_url, &mut resp, "device login"));
140    }
141    let r = resp
142        .body_mut()
143        .read_json::<DeviceAuthorizationResponse>()
144        .map_err(|e| invalid_response(base_url, e))?;
145    Ok(r)
146}
147
148#[derive(Serialize, Deserialize)]
149pub struct TokenPayload {
150    pub device_code: String,
151}
152
153#[derive(Serialize, Deserialize)]
154pub struct TokenResponse {
155    pub access_token: String,
156}
157
158pub fn query_for_token(base_url: &Url, device_code: &str) -> Result<Option<TokenResponse>> {
159    let url = join_url(base_url, "api/oauth/token")?;
160    let mut resp = http_agent()
161        .post(url.as_str())
162        .send_json(TokenPayload {
163            device_code: device_code.to_string(),
164        })
165        .map_err(|e| unreachable(base_url, e))?;
166    match resp.status().as_u16() {
167        200 => {
168            let r = resp
169                .body_mut()
170                .read_json::<TokenResponse>()
171                .map_err(|e| invalid_response(base_url, e))?;
172            Ok(Some(r))
173        }
174        400 => bail!("User code expired"),
175        401 => Ok(None),
176        404 => bail!("Unknown device code"),
177        code @ (405 | 500..) => Err(status_error(base_url, code, "", "device login")),
178        _ => bail!("Unknown server error: {}", resp.status()),
179    }
180}
181
182pub fn poll_for_token(base_url: &Url, device_code: &str) -> Result<TokenResponse> {
183    let mut num_retries = 0;
184
185    loop {
186        if num_retries > 120 {
187            bail!("Token will have expired");
188        }
189
190        match query_for_token(base_url, device_code) {
191            Ok(None) => {
192                sleep(Duration::from_secs(5));
193                num_retries += 1;
194            }
195            Ok(Some(t)) => {
196                return Ok(t);
197            }
198            Err(e) => return Err(e),
199        }
200    }
201}
202
203/// Ask the server to revoke `token` so it stops working everywhere, not just on this machine.
204pub fn revoke_token(base_url: &Url, token: &str) -> Result<()> {
205    let url = join_url(base_url, "api/oauth/token")?;
206    let mut resp = http_agent()
207        .delete(url.as_str())
208        .header("authorization", format!("Bearer {token}"))
209        .call()
210        .map_err(|e| unreachable(base_url, e))?;
211    if !resp.status().is_success() {
212        return Err(response_error(base_url, &mut resp, "revoking the token"));
213    }
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn an_unparsable_store_is_an_error_and_is_kept() {
223        let dir = tempfile::tempdir().unwrap();
224        let path = dir.path().join("credentials.json");
225        fs::write(&path, "not json").unwrap();
226        let error = read_store(&path).unwrap_err();
227        assert_eq!(
228            crate::backend_error_code(&error),
229            Some(ErrorCode::CredentialsUnreadable)
230        );
231        assert!(path.exists());
232    }
233
234    #[test]
235    fn login_replaces_an_unparsable_store() {
236        let path = get_token_store_path().unwrap();
237        fs::create_dir_all(path.parent().unwrap()).unwrap();
238        fs::write(&path, "not json").unwrap();
239        let url = Url::parse("https://h/").unwrap();
240        store_token(&url, "t").unwrap();
241        assert_eq!(get_token(&url).unwrap().as_deref(), Some("t"));
242        fs::remove_dir_all(path.parent().unwrap()).unwrap();
243    }
244}