Skip to main content

mnm_core/
auth_file.rs

1//! `auth.toml` reader (Phase-2 stub, expanded in Phase 7 with the writer).
2//!
3//! The token file lives at `$XDG_CONFIG_HOME/midnight-manual/auth.toml` with
4//! `chmod 0600` on the private half (see D28). Phase-2 only needs the read path
5//! so the MCP server can resolve a read-uplift bearer at startup. Phase 7's
6//! `mnm login` and `mnm auth github` commands land the writer + rotation logic.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12use time::OffsetDateTime;
13
14/// Canonical schema version. The file MUST carry `schema_version = 1`; a
15/// mismatch fails the load with [`AuthFileError::SchemaVersionMismatch`].
16pub const SCHEMA_VERSION: u32 = 1;
17
18/// Full auth.toml shape.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AuthFile {
21    /// Schema version sentinel. Always `1` in v1.
22    pub schema_version: u32,
23    /// Admin section — set by `mnm login`. Hidden from MCP server.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub admin: Option<AdminSection>,
26    /// Read-uplift section — set by `mnm auth github`. Used by MCP + CLI reads.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub read_uplift: Option<ReadUpliftSection>,
29}
30
31/// `[admin]` — admin-mode JWT (1h TTL, D21).
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct AdminSection {
34    /// Logged-in user id (sub claim).
35    pub user_id: String,
36    /// HS256 JWT.
37    pub token: String,
38    /// When the token expires.
39    #[serde(with = "time::serde::rfc3339")]
40    pub expires_at: OffsetDateTime,
41}
42
43/// `[read_uplift]` — GitHub-OAuth-minted read-tier bearer (30d default).
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ReadUpliftSection {
46    /// GitHub login that authenticated.
47    pub github_login: String,
48    /// Opaque bearer token.
49    pub token: String,
50    /// When the token expires.
51    #[serde(with = "time::serde::rfc3339")]
52    pub expires_at: OffsetDateTime,
53}
54
55impl AuthFile {
56    /// Read and validate an auth.toml from `path`.
57    ///
58    /// Returns:
59    /// - `Ok(Some(AuthFile))` on a present, well-formed file with matching schema.
60    /// - `Ok(None)` when the file is absent (anonymous mode — EC-39, FR-070).
61    /// - `Err(...)` on a present but malformed or schema-mismatched file.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`AuthFileError::Io`] for I/O failures other than "not found",
66    /// [`AuthFileError::Parse`] for malformed TOML, and
67    /// [`AuthFileError::SchemaVersionMismatch`] if `schema_version` is not
68    /// [`SCHEMA_VERSION`].
69    pub fn read_optional(path: &Path) -> Result<Option<Self>, AuthFileError> {
70        match std::fs::metadata(path) {
71            Ok(md) => {
72                check_permissions(path, &md)?;
73                let body = std::fs::read_to_string(path).map_err(|e| AuthFileError::Io {
74                    path: path.to_path_buf(),
75                    message: e.to_string(),
76                })?;
77                let file: Self = toml::from_str(&body).map_err(|e| AuthFileError::Parse {
78                    path: path.to_path_buf(),
79                    message: e.to_string(),
80                })?;
81                if file.schema_version != SCHEMA_VERSION {
82                    return Err(AuthFileError::SchemaVersionMismatch {
83                        path: path.to_path_buf(),
84                        found: file.schema_version,
85                        expected: SCHEMA_VERSION,
86                    });
87                }
88                Ok(Some(file))
89            }
90            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
91            Err(e) => Err(AuthFileError::Io {
92                path: path.to_path_buf(),
93                message: e.to_string(),
94            }),
95        }
96    }
97
98    /// Returns the admin JWT only if present and not expired (`now < expires_at`).
99    #[must_use]
100    pub fn active_admin_token(&self, now: OffsetDateTime) -> Option<&str> {
101        self.admin
102            .as_ref()
103            .filter(|a| now < a.expires_at)
104            .map(|a| a.token.as_str())
105    }
106
107    /// Returns the read-uplift bearer only if present and not expired.
108    #[must_use]
109    pub fn active_read_uplift_token(&self, now: OffsetDateTime) -> Option<&str> {
110        self.read_uplift
111            .as_ref()
112            .filter(|r| now < r.expires_at)
113            .map(|r| r.token.as_str())
114    }
115
116    /// Serialize this auth file to a TOML body suitable for writing to disk.
117    /// Section order is `schema_version`, `[admin]`, `[read_uplift]` —
118    /// matching what `mnm login` / `mnm auth github` would have produced.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`AuthFileError::Serialize`] on the (effectively never)
123    /// serialiser failure.
124    pub fn to_toml(&self) -> Result<String, AuthFileError> {
125        toml::to_string(self).map_err(|e| AuthFileError::Serialize(e.to_string()))
126    }
127
128    /// Atomically write the auth file to `path`, creating the file with
129    /// mode `0o600` (Unix) and refusing to write if an existing file has
130    /// looser permissions.
131    ///
132    /// Atomicity comes from a `path.tmp` sibling that is `rename(2)`'d into
133    /// place; concurrent readers will see either the previous file or the
134    /// new file but never a partial mid-write.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`AuthFileError::Io`] on filesystem failure,
139    /// [`AuthFileError::Serialize`] on the (rare) TOML encode failure, or
140    /// [`AuthFileError::InsecurePermissions`] if an existing file at `path`
141    /// already has group- or world-readable bits set (we refuse to silently
142    /// re-narrow them).
143    pub fn write(&self, path: &Path) -> Result<(), AuthFileError> {
144        // Refuse to clobber a world-readable file — the operator likely
145        // didn't intend us to inherit those permissions, and our subsequent
146        // chmod could race with another reader.
147        if let Ok(md) = std::fs::metadata(path) {
148            check_permissions(path, &md)?;
149        }
150        let body = self.to_toml()?;
151        atomic_write(path, &body)?;
152        Ok(())
153    }
154
155    /// Read the existing file (or build a fresh empty one), update the
156    /// `[admin]` section with the supplied JWT + expiry, and persist back to
157    /// disk under the same permission discipline as [`AuthFile::write`].
158    ///
159    /// # Errors
160    ///
161    /// Returns any variant of [`AuthFileError`] that
162    /// [`AuthFile::read_optional`] or [`AuthFile::write`] can produce.
163    pub fn write_admin_token(
164        path: &Path,
165        user_id: impl Into<String>,
166        token: impl Into<String>,
167        expires_at: OffsetDateTime,
168    ) -> Result<(), AuthFileError> {
169        let mut file = Self::read_optional(path)?.unwrap_or_else(Self::empty);
170        file.admin = Some(AdminSection {
171            user_id: user_id.into(),
172            token: token.into(),
173            expires_at,
174        });
175        file.write(path)
176    }
177
178    /// Like [`AuthFile::write_admin_token`] but for the `[read_uplift]`
179    /// section, used by `mnm auth github`.
180    ///
181    /// # Errors
182    ///
183    /// See [`AuthFile::write_admin_token`].
184    pub fn write_read_uplift_token(
185        path: &Path,
186        github_login: impl Into<String>,
187        token: impl Into<String>,
188        expires_at: OffsetDateTime,
189    ) -> Result<(), AuthFileError> {
190        let mut file = Self::read_optional(path)?.unwrap_or_else(Self::empty);
191        file.read_uplift = Some(ReadUpliftSection {
192            github_login: github_login.into(),
193            token: token.into(),
194            expires_at,
195        });
196        file.write(path)
197    }
198
199    /// Fresh schema-versioned auth file with no sections populated.
200    #[must_use]
201    pub const fn empty() -> Self {
202        Self {
203            schema_version: SCHEMA_VERSION,
204            admin: None,
205            read_uplift: None,
206        }
207    }
208}
209
210/// Write `body` to `path` via a tmp-then-rename dance so the file is
211/// atomically replaced and never observed half-written. On Unix the file
212/// is created with mode `0o600`.
213fn atomic_write(path: &Path, body: &str) -> Result<(), AuthFileError> {
214    use std::io::Write as _;
215    if let Some(parent) = path.parent() {
216        if !parent.as_os_str().is_empty() {
217            std::fs::create_dir_all(parent).map_err(|e| AuthFileError::Io {
218                path: parent.to_path_buf(),
219                message: e.to_string(),
220            })?;
221        }
222    }
223    let tmp = path.with_extension("toml.tmp");
224    let file_handle = open_restricted(&tmp)?;
225    {
226        let mut bw = std::io::BufWriter::new(file_handle);
227        bw.write_all(body.as_bytes())
228            .map_err(|e| AuthFileError::Io {
229                path: tmp.clone(),
230                message: e.to_string(),
231            })?;
232        bw.flush().map_err(|e| AuthFileError::Io {
233            path: tmp.clone(),
234            message: e.to_string(),
235        })?;
236    }
237    std::fs::rename(&tmp, path).map_err(|e| AuthFileError::Io {
238        path: path.to_path_buf(),
239        message: format!("rename {} -> {}: {e}", tmp.display(), path.display()),
240    })?;
241    Ok(())
242}
243
244#[cfg(unix)]
245fn open_restricted(path: &Path) -> Result<std::fs::File, AuthFileError> {
246    use std::os::unix::fs::OpenOptionsExt as _;
247    std::fs::OpenOptions::new()
248        .create(true)
249        .truncate(true)
250        .write(true)
251        .mode(0o600)
252        .open(path)
253        .map_err(|e| AuthFileError::Io {
254            path: path.to_path_buf(),
255            message: e.to_string(),
256        })
257}
258
259#[cfg(not(unix))]
260fn open_restricted(path: &Path) -> Result<std::fs::File, AuthFileError> {
261    // Platform permission model is the user's NTFS ACL / equivalent.
262    std::fs::OpenOptions::new()
263        .create(true)
264        .truncate(true)
265        .write(true)
266        .open(path)
267        .map_err(|e| AuthFileError::Io {
268            path: path.to_path_buf(),
269            message: e.to_string(),
270        })
271}
272
273/// All the ways auth.toml loading can fail.
274#[derive(Debug, Error)]
275pub enum AuthFileError {
276    /// I/O failure (excluding `NotFound`, which is rendered as `Ok(None)`).
277    #[error("failed to read auth file `{}`: {message}", path.display())]
278    Io {
279        /// File path that failed to read.
280        path: PathBuf,
281        /// Underlying I/O error message.
282        message: String,
283    },
284    /// TOML parse failure on the resolved file.
285    #[error("failed to parse auth file `{}`: {message}", path.display())]
286    Parse {
287        /// File path that failed to parse.
288        path: PathBuf,
289        /// Underlying parser error message.
290        message: String,
291    },
292    /// `schema_version` does not match [`SCHEMA_VERSION`].
293    #[error(
294        "auth file `{}` has schema_version={found}; expected {expected}. Re-run `mnm login` to refresh.",
295        .path.display()
296    )]
297    SchemaVersionMismatch {
298        /// The file path that failed validation.
299        path: PathBuf,
300        /// The schema version we found.
301        found: u32,
302        /// The version we expected.
303        expected: u32,
304    },
305    /// File permissions are too permissive (group- or world-readable). Bearer
306    /// tokens MUST be `chmod 0600` per D28; we refuse to load any wider mode
307    /// so a leaked file (e.g. copied into a shared dir, baked into an image)
308    /// does not silently authenticate as the user.
309    #[error(
310        "auth file `{}` has insecure permissions ({mode:#o}); expected 0o600. Run `chmod 600 \"{}\"` and retry.",
311        path.display(), path.display()
312    )]
313    InsecurePermissions {
314        /// The file path that failed the permission check.
315        path: PathBuf,
316        /// The mode bits we observed.
317        mode: u32,
318    },
319    /// TOML serialization failure on a write.
320    #[error("failed to serialize auth file: {0}")]
321    Serialize(String),
322}
323
324#[cfg(unix)]
325fn check_permissions(path: &Path, md: &std::fs::Metadata) -> Result<(), AuthFileError> {
326    use std::os::unix::fs::PermissionsExt as _;
327    let mode = md.permissions().mode() & 0o777;
328    // Any group / world bits set is a refusal.
329    if mode & 0o077 != 0 {
330        return Err(AuthFileError::InsecurePermissions { path: path.to_path_buf(), mode });
331    }
332    Ok(())
333}
334
335#[cfg(not(unix))]
336fn check_permissions(_path: &Path, _md: &std::fs::Metadata) -> Result<(), AuthFileError> {
337    // Windows / WASI: rely on the user's NTFS ACLs or platform-equivalent.
338    Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn write_tempfile(body: &str) -> tempfile::NamedTempFile {
346        use std::io::Write as _;
347        let mut f = tempfile::Builder::new()
348            .suffix(".toml")
349            .tempfile()
350            .expect("create tempfile");
351        f.write_all(body.as_bytes()).expect("write tempfile");
352        f
353    }
354
355    #[test]
356    fn absent_file_returns_none() {
357        let path = std::path::PathBuf::from("/definitely/does/not/exist/auth.toml");
358        let r = AuthFile::read_optional(&path).expect("ok");
359        assert!(r.is_none());
360    }
361
362    #[test]
363    fn empty_sections_load_ok() {
364        let body = "schema_version = 1\n";
365        let f = write_tempfile(body);
366        let r = AuthFile::read_optional(f.path()).unwrap().unwrap();
367        assert_eq!(r.schema_version, 1);
368        assert!(r.admin.is_none());
369        assert!(r.read_uplift.is_none());
370    }
371
372    #[test]
373    fn schema_mismatch_fails() {
374        let body = "schema_version = 2\n";
375        let f = write_tempfile(body);
376        let err = AuthFile::read_optional(f.path()).unwrap_err();
377        assert!(matches!(
378            err,
379            AuthFileError::SchemaVersionMismatch { found: 2, expected: 1, .. },
380        ));
381    }
382
383    #[test]
384    fn admin_active_window_respected() {
385        let body = r#"
386schema_version = 1
387
388[admin]
389user_id = "aaron"
390token = "jwt-abc"
391expires_at = "2026-05-13T15:30:00Z"
392"#;
393        let f = write_tempfile(body);
394        let r = AuthFile::read_optional(f.path()).unwrap().unwrap();
395        let now_before = OffsetDateTime::parse(
396            "2026-05-13T15:29:00Z",
397            &time::format_description::well_known::Rfc3339,
398        )
399        .unwrap();
400        let now_after = OffsetDateTime::parse(
401            "2026-05-13T15:31:00Z",
402            &time::format_description::well_known::Rfc3339,
403        )
404        .unwrap();
405        assert_eq!(r.active_admin_token(now_before), Some("jwt-abc"));
406        assert_eq!(r.active_admin_token(now_after), None);
407    }
408
409    #[test]
410    fn malformed_toml_returns_parse() {
411        let f = write_tempfile("definitely := not = toml\n");
412        let err = AuthFile::read_optional(f.path()).unwrap_err();
413        assert!(matches!(err, AuthFileError::Parse { .. }));
414    }
415
416    #[cfg(unix)]
417    #[test]
418    fn rejects_group_readable_file() {
419        use std::os::unix::fs::PermissionsExt as _;
420        let f = write_tempfile("schema_version = 1\n");
421        // tempfile defaults to 0600 — widen to 0640 (group-readable) to
422        // simulate an exported / leaked file.
423        std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o640))
424            .expect("chmod tempfile");
425        let err = AuthFile::read_optional(f.path()).unwrap_err();
426        assert!(
427            matches!(err, AuthFileError::InsecurePermissions { mode: 0o640, .. }),
428            "expected InsecurePermissions, got {err:?}"
429        );
430    }
431
432    fn rfc(s: &str) -> OffsetDateTime {
433        OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).unwrap()
434    }
435
436    #[test]
437    fn writes_then_reads_admin_section() {
438        let dir = tempfile::tempdir().unwrap();
439        let path = dir.path().join("auth.toml");
440        let exp = rfc("2026-05-14T01:30:00Z");
441        AuthFile::write_admin_token(&path, "aaron", "jwt-xyz", exp).expect("write");
442
443        let loaded = AuthFile::read_optional(&path).unwrap().unwrap();
444        let admin = loaded.admin.unwrap();
445        assert_eq!(admin.user_id, "aaron");
446        assert_eq!(admin.token, "jwt-xyz");
447        assert_eq!(admin.expires_at, exp);
448        assert!(loaded.read_uplift.is_none(), "writer must not invent unrelated sections");
449    }
450
451    #[test]
452    fn writes_then_reads_read_uplift_section() {
453        let dir = tempfile::tempdir().unwrap();
454        let path = dir.path().join("auth.toml");
455        let exp = rfc("2026-06-14T00:00:00Z");
456        AuthFile::write_read_uplift_token(&path, "aaronbassett", "ru_abc", exp).expect("write");
457
458        let loaded = AuthFile::read_optional(&path).unwrap().unwrap();
459        let r = loaded.read_uplift.unwrap();
460        assert_eq!(r.github_login, "aaronbassett");
461        assert_eq!(r.token, "ru_abc");
462        assert_eq!(r.expires_at, exp);
463    }
464
465    #[test]
466    fn writing_admin_does_not_clobber_read_uplift() {
467        let dir = tempfile::tempdir().unwrap();
468        let path = dir.path().join("auth.toml");
469        let ru_exp = rfc("2026-06-14T00:00:00Z");
470        AuthFile::write_read_uplift_token(&path, "aaronbassett", "ru_abc", ru_exp).unwrap();
471        let admin_exp = rfc("2026-05-14T01:30:00Z");
472        AuthFile::write_admin_token(&path, "aaron", "jwt-xyz", admin_exp).unwrap();
473
474        let loaded = AuthFile::read_optional(&path).unwrap().unwrap();
475        assert_eq!(loaded.read_uplift.unwrap().token, "ru_abc");
476        assert_eq!(loaded.admin.unwrap().token, "jwt-xyz");
477    }
478
479    #[cfg(unix)]
480    #[test]
481    fn writer_creates_file_with_0600() {
482        use std::os::unix::fs::PermissionsExt as _;
483        let dir = tempfile::tempdir().unwrap();
484        let path = dir.path().join("auth.toml");
485        AuthFile::write_admin_token(&path, "aaron", "jwt", rfc("2026-05-14T01:30:00Z")).unwrap();
486        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
487        assert_eq!(mode, 0o600, "writer must create the file with 0o600");
488    }
489
490    #[cfg(unix)]
491    #[test]
492    fn writer_refuses_insecure_existing_file() {
493        use std::os::unix::fs::PermissionsExt as _;
494        let dir = tempfile::tempdir().unwrap();
495        let path = dir.path().join("auth.toml");
496        // Seed an existing world-readable file and confirm we refuse to
497        // overwrite it (we'd inherit / fight its perms otherwise).
498        std::fs::write(&path, "schema_version = 1\n").unwrap();
499        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
500        let err = AuthFile::write_admin_token(&path, "aaron", "jwt", rfc("2026-05-14T01:30:00Z"))
501            .unwrap_err();
502        assert!(
503            matches!(err, AuthFileError::InsecurePermissions { mode: 0o644, .. }),
504            "expected InsecurePermissions, got {err:?}"
505        );
506    }
507}