1use 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#[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
67fn 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 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 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
203pub 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}