Skip to main content

remem/api/
auth.rs

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
19/// Ensure the REST API bearer token exists and return its path.
20///
21/// Call this before using [`crate::api::build_router`] directly. The
22/// `remem api` command calls it automatically.
23pub 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
48/// Load the REST API bearer token from the remem data directory.
49pub 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}