1use anyhow::{anyhow, Context, Result};
2use axum::{
3 extract::Request,
4 http::{header, HeaderMap, StatusCode},
5 middleware::Next,
6 response::{IntoResponse, Response},
7};
8use std::{io::Write, path::PathBuf};
9
10use super::helpers::error_response;
11
12const API_TOKEN_FILE: &str = ".api-token";
13const API_TOKEN_BYTES: usize = 32;
14
15pub(crate) fn api_token_path() -> Result<PathBuf> {
16 Ok(crate::db::try_data_dir()?.join(API_TOKEN_FILE))
17}
18
19pub fn ensure_api_token() -> Result<PathBuf> {
24 let path = api_token_path()?;
25 if let Some(parent) = path.parent() {
26 std::fs::create_dir_all(parent)
27 .with_context(|| format!("create API token parent {}", parent.display()))?;
28 #[cfg(unix)]
29 {
30 use std::os::unix::fs::PermissionsExt;
31 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).with_context(
32 || format!("set API token parent permissions {}", parent.display()),
33 )?;
34 }
35 }
36
37 if path.exists() {
38 validate_existing_token(&path)?;
39 enforce_token_permissions(&path)?;
40 return Ok(path);
41 }
42
43 let token = generate_api_token()?;
44 write_new_token(&path, &token)?;
45 Ok(path)
46}
47
48pub fn load_api_token() -> Result<String> {
50 let path = api_token_path()?;
51 let token = std::fs::read_to_string(&path)
52 .with_context(|| format!("read API token from {}", path.display()))?;
53 let token = token.trim().to_string();
54 if token.is_empty() {
55 return Err(anyhow!("API token file is empty: {}", path.display()));
56 }
57 Ok(token)
58}
59
60pub(in crate::api) async fn require_api_token(req: Request, next: Next) -> Response {
61 let expected = match load_api_token() {
62 Ok(token) => token,
63 Err(err) => {
64 crate::log::error("api", &format!("API token unavailable: {err}"));
65 return error_response(
66 StatusCode::INTERNAL_SERVER_ERROR,
67 "api_token_unavailable",
68 "API token is not configured",
69 )
70 .into_response();
71 }
72 };
73
74 if request_has_token(req.headers(), &expected) {
75 next.run(req).await
76 } else {
77 error_response(
78 StatusCode::UNAUTHORIZED,
79 "unauthorized",
80 "Missing or invalid API token",
81 )
82 .into_response()
83 }
84}
85
86fn validate_existing_token(path: &std::path::Path) -> Result<()> {
87 let token = std::fs::read_to_string(path)
88 .with_context(|| format!("read existing API token {}", path.display()))?;
89 if token.trim().is_empty() {
90 return Err(anyhow!("existing API token is empty: {}", path.display()));
91 }
92 Ok(())
93}
94
95fn generate_api_token() -> Result<String> {
96 let mut bytes = [0u8; API_TOKEN_BYTES];
97 getrandom::fill(&mut bytes)
98 .map_err(|err| anyhow!("OS randomness unavailable while generating API token: {err}"))?;
99 Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
100}
101
102fn write_new_token(path: &std::path::Path, token: &str) -> Result<()> {
103 #[cfg(unix)]
104 let mut file = {
105 use std::os::unix::fs::OpenOptionsExt;
106 match std::fs::OpenOptions::new()
107 .mode(0o600)
108 .create_new(true)
109 .write(true)
110 .open(path)
111 {
112 Ok(file) => file,
113 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
114 validate_existing_token(path)?;
115 enforce_token_permissions(path)?;
116 return Ok(());
117 }
118 Err(err) => {
119 return Err(err)
120 .with_context(|| format!("create API token file {}", path.display()));
121 }
122 }
123 };
124
125 #[cfg(not(unix))]
126 let mut file = match std::fs::OpenOptions::new()
127 .create_new(true)
128 .write(true)
129 .open(path)
130 {
131 Ok(file) => file,
132 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
133 validate_existing_token(path)?;
134 enforce_token_permissions(path)?;
135 return Ok(());
136 }
137 Err(err) => {
138 return Err(err).with_context(|| format!("create API token file {}", path.display()));
139 }
140 };
141
142 if let Err(err) = file.write_all(token.as_bytes()) {
143 drop(file);
144 let _ = std::fs::remove_file(path);
145 return Err(anyhow!(
146 "write API token file {} failed: {}",
147 path.display(),
148 err
149 ));
150 }
151 enforce_token_permissions(path)?;
152 Ok(())
153}
154
155fn enforce_token_permissions(path: &std::path::Path) -> Result<()> {
156 #[cfg(unix)]
157 {
158 use std::os::unix::fs::PermissionsExt;
159 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
160 .with_context(|| format!("set API token permissions {}", path.display()))?;
161 }
162 Ok(())
163}
164
165fn request_has_token(headers: &HeaderMap, expected: &str) -> bool {
166 let Some(actual) = bearer_token(headers) else {
167 return false;
168 };
169 constant_time_eq(actual.as_bytes(), expected.as_bytes())
170}
171
172fn bearer_token(headers: &HeaderMap) -> Option<&str> {
173 let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
174 let token = value.strip_prefix("Bearer ")?;
175 let token = token.trim();
176 (!token.is_empty()).then_some(token)
177}
178
179fn constant_time_eq(actual: &[u8], expected: &[u8]) -> bool {
180 if actual.len() != expected.len() {
181 return false;
182 }
183 let mut diff = 0u8;
184 for (left, right) in actual.iter().zip(expected.iter()) {
185 diff |= left ^ right;
186 }
187 diff == 0
188}
189
190#[cfg(test)]
191mod tests {
192 use super::{constant_time_eq, ensure_api_token, load_api_token};
193 use crate::db::test_support::ScopedTestDataDir;
194
195 #[test]
196 fn ensure_api_token_creates_and_preserves_token() {
197 let data_dir = ScopedTestDataDir::new("api-token");
198
199 let path = ensure_api_token().expect("token should be created");
200 assert_eq!(path, data_dir.path.join(".api-token"));
201 let first = load_api_token().expect("token should load");
202 assert_eq!(first.len(), 64);
203
204 let second_path = ensure_api_token().expect("existing token should be reused");
205 let second = load_api_token().expect("token should load again");
206 assert_eq!(second_path, path);
207 assert_eq!(second, first);
208
209 #[cfg(unix)]
210 {
211 use std::os::unix::fs::PermissionsExt;
212 let mode = std::fs::metadata(&path)
213 .expect("token metadata")
214 .permissions()
215 .mode()
216 & 0o777;
217 assert_eq!(mode, 0o600);
218 }
219 }
220
221 #[test]
222 fn constant_time_eq_requires_exact_match() {
223 assert!(constant_time_eq(b"abc", b"abc"));
224 assert!(!constant_time_eq(b"abc", b"abd"));
225 assert!(!constant_time_eq(b"abc", b"abcd"));
226 }
227}