Skip to main content

reddb_server/server/
ui_auth.rs

1//! Credential handoff for `red ui` (issue #1048, PRD #1041; ADR 0051 auth
2//! model, ADR 0036 handshake auth).
3//!
4//! When the user supplies a token, `red` owns it and the UI never sees it:
5//!
6//!   * **Served-UI path** (`red ui <uri> --token <X>`): the token is held in
7//!     this process and presented in the RedWire handshake (ADR 0036 bearer)
8//!     by the loopback bridge — the UI runs in *injected-auth* mode and never
9//!     sees or persists the secret. The injection itself lives in
10//!     [`super::ui_bridge`]; this module owns the *mode decision* and the
11//!     credential-free config snippet served into the page.
12//!   * **Deep-link / desktop path**: the token crosses via a *local secret
13//!     channel* — a one-time loopback fetch keyed by a single-use nonce
14//!     ([`OneTimeSecret`] + [`spawn_handoff_server`]). The dispatched deep-link
15//!     URL carries only the nonce/handoff URL, never the secret, so nothing
16//!     lands in `ps`, shell history, or URL logs.
17//!
18//! The database's auth configuration is the source of truth for whether the
19//! UI prompts ([`UiAuthMode::resolve`]): an authenticated DB with no supplied
20//! token → the UI prompts via its own connect flow; an unauthenticated DB →
21//! no prompt.
22
23use std::net::SocketAddr;
24use std::sync::{Arc, Mutex};
25
26use axum::extract::{Path, State};
27use axum::http::{header, StatusCode};
28use axum::response::{IntoResponse, Response};
29use axum::routing::get;
30use tokio::sync::oneshot;
31
32/// What the served UI should do about authentication, decided by `red` from
33/// the database's auth configuration and whether a token was supplied. The
34/// mode is injected into the page (credential-free) so the UI knows whether
35/// to prompt without ever holding the secret itself.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum UiAuthMode {
38    /// A token was supplied: `red` holds it and presents it in the RedWire
39    /// handshake. The UI must **not** prompt and never sees the secret.
40    Injected,
41    /// No token, but the database requires auth: the UI prompts for
42    /// credentials through its own connect flow.
43    Prompt,
44    /// No token and the database is unauthenticated: the UI connects
45    /// anonymously with no prompt. The conservative default.
46    #[default]
47    Open,
48}
49
50impl UiAuthMode {
51    /// Resolve the mode from whether a credential was supplied and whether the
52    /// target database requires authentication. A supplied token always wins
53    /// (injected-auth); otherwise the DB's auth config decides prompt vs open.
54    pub fn resolve(token_supplied: bool, db_auth_required: bool) -> Self {
55        match (token_supplied, db_auth_required) {
56            (true, _) => UiAuthMode::Injected,
57            (false, true) => UiAuthMode::Prompt,
58            (false, false) => UiAuthMode::Open,
59        }
60    }
61
62    /// The stable wire string injected as `window.REDDB_AUTH_MODE`. Kept
63    /// lowercase and hyphen-free so it is a clean JS string literal.
64    pub fn as_str(self) -> &'static str {
65        match self {
66            UiAuthMode::Injected => "injected",
67            UiAuthMode::Prompt => "prompt",
68            UiAuthMode::Open => "open",
69        }
70    }
71}
72
73/// Build the `<script>` snippet that tells the served page which auth mode to
74/// run in. **Never carries the token** — only the mode hint. Injected before
75/// `</head>` by the bridge's static-file handler.
76pub fn auth_mode_config_snippet(mode: UiAuthMode) -> String {
77    format!(
78        "<script>window.REDDB_AUTH_MODE=\"{}\";</script>",
79        mode.as_str()
80    )
81}
82
83/// Insert [`auth_mode_config_snippet`] just before `</head>` in an HTML
84/// document. Returns the original bytes unchanged when `</head>` is absent.
85/// The mode string is a fixed enum rendering (no `"`/`\`), so plain
86/// interpolation is safe.
87pub fn inject_auth_mode_config(html: Vec<u8>, mode: UiAuthMode) -> Vec<u8> {
88    let snippet = auth_mode_config_snippet(mode);
89    let marker = b"</head>";
90    match html.windows(marker.len()).position(|w| w == marker) {
91        Some(pos) => {
92            let mut out = Vec::with_capacity(html.len() + snippet.len());
93            out.extend_from_slice(&html[..pos]);
94            out.extend_from_slice(snippet.as_bytes());
95            out.extend_from_slice(&html[pos..]);
96            out
97        }
98        None => html,
99    }
100}
101
102// ---------------------------------------------------------------------------
103// One-time secret channel for the deep-link / desktop path.
104// ---------------------------------------------------------------------------
105
106/// A single-use nonce, hex-encoded from 16 bytes of OS CSPRNG. Used to key the
107/// one-time loopback handoff so the deep-link URL can carry the nonce (a
108/// throwaway lookup key) instead of the secret.
109pub fn new_handoff_nonce() -> String {
110    let mut bytes = [0u8; 16];
111    // CSPRNG; on the astronomically unlikely fill error, fall back to a
112    // process/address-derived value — still single-use and never the secret.
113    if crate::crypto::os_random::fill_bytes(&mut bytes).is_err() {
114        let seed = (&bytes as *const _ as usize) as u64;
115        bytes[..8].copy_from_slice(&seed.to_le_bytes());
116    }
117    let mut out = String::with_capacity(32);
118    for b in bytes {
119        out.push(nibble_hex(b >> 4));
120        out.push(nibble_hex(b & 0x0f));
121    }
122    out
123}
124
125fn nibble_hex(n: u8) -> char {
126    match n {
127        0..=9 => (b'0' + n) as char,
128        _ => (b'a' + (n - 10)) as char,
129    }
130}
131
132/// A credential that can be fetched **exactly once**. The desktop app fetches
133/// it from the loopback handoff endpoint; a second fetch (a replay, or a
134/// stray request) gets nothing. Thread-safe so the handoff server's handler
135/// and the waiting CLI can share it.
136#[derive(Debug)]
137pub struct OneTimeSecret {
138    inner: Mutex<Option<String>>,
139}
140
141impl OneTimeSecret {
142    /// Wrap a secret for single-use retrieval.
143    pub fn new(secret: String) -> Self {
144        Self {
145            inner: Mutex::new(Some(secret)),
146        }
147    }
148
149    /// Take the secret, leaving the channel empty. Returns `None` if already
150    /// taken (replay) — the first caller wins and no one else sees it.
151    pub fn take(&self) -> Option<String> {
152        self.inner.lock().expect("one-time secret lock").take()
153    }
154
155    /// Whether the secret has already been consumed.
156    pub fn is_consumed(&self) -> bool {
157        self.inner.lock().expect("one-time secret lock").is_none()
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Loopback handoff server — serves the one-time secret to the desktop app.
163// ---------------------------------------------------------------------------
164
165/// Shared state for the handoff server's single route.
166#[derive(Clone)]
167struct HandoffState {
168    /// The single-use nonce the path must match (constant-time compared).
169    nonce: Arc<String>,
170    /// The credential, retrievable exactly once.
171    secret: Arc<OneTimeSecret>,
172}
173
174/// A running loopback server that hands the held credential to the desktop
175/// app exactly once, keyed by a single-use nonce. The deep-link URL carries
176/// only [`Self::handoff_url`] (host/port + nonce) — never the secret — so
177/// nothing sensitive lands in `ps`, shell history, or URL logs.
178pub struct HandoffServer {
179    local_addr: SocketAddr,
180    nonce: String,
181    secret: Arc<OneTimeSecret>,
182    shutdown_tx: Option<oneshot::Sender<()>>,
183    join: tokio::task::JoinHandle<()>,
184}
185
186impl HandoffServer {
187    /// The loopback URL the desktop app fetches the credential from. Carries
188    /// the nonce (a throwaway lookup key), never the secret.
189    pub fn handoff_url(&self) -> String {
190        format!("http://{}/handoff/{}", self.local_addr, self.nonce)
191    }
192
193    /// The bound loopback address.
194    pub fn local_addr(&self) -> SocketAddr {
195        self.local_addr
196    }
197
198    /// Whether the credential has been fetched (the handoff completed).
199    pub fn is_consumed(&self) -> bool {
200        self.secret.is_consumed()
201    }
202
203    /// Signal graceful shutdown and wait for the serve task to wind down.
204    pub async fn shutdown(mut self) {
205        if let Some(tx) = self.shutdown_tx.take() {
206            let _ = tx.send(());
207        }
208        let _ = self.join.await;
209    }
210}
211
212/// Bind a loopback handoff server holding `token`, retrievable once via the
213/// nonce-keyed path. Returns once the listener is bound. Must be called from
214/// within a tokio runtime.
215pub async fn spawn_handoff_server(token: String) -> std::io::Result<HandoffServer> {
216    let nonce = new_handoff_nonce();
217    let secret = Arc::new(OneTimeSecret::new(token));
218
219    let state = HandoffState {
220        nonce: Arc::new(nonce.clone()),
221        secret: Arc::clone(&secret),
222    };
223
224    let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?;
225    let local_addr = listener.local_addr()?;
226
227    let router = axum::Router::new()
228        .route("/handoff/{nonce}", get(serve_handoff))
229        .with_state(state);
230
231    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
232    let join = tokio::spawn(async move {
233        let _ = axum::serve(listener, router)
234            .with_graceful_shutdown(async move {
235                let _ = shutdown_rx.await;
236            })
237            .await;
238    });
239
240    Ok(HandoffServer {
241        local_addr,
242        nonce,
243        secret,
244        shutdown_tx: Some(shutdown_tx),
245        join,
246    })
247}
248
249/// `GET /handoff/{nonce}` — return the credential once when the nonce matches
250/// (constant-time), 404 otherwise or after it has been consumed. A
251/// `Cache-Control: no-store` header keeps the secret out of any intermediary
252/// (there are none on loopback, but defence in depth).
253async fn serve_handoff(State(state): State<HandoffState>, Path(nonce): Path<String>) -> Response {
254    if !crate::crypto::constant_time_eq(nonce.as_bytes(), state.nonce.as_bytes()) {
255        return not_found();
256    }
257    match state.secret.take() {
258        Some(token) => (
259            StatusCode::OK,
260            [
261                (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
262                (header::CACHE_CONTROL, "no-store"),
263            ],
264            token,
265        )
266            .into_response(),
267        None => not_found(),
268    }
269}
270
271fn not_found() -> Response {
272    (StatusCode::NOT_FOUND, "not found").into_response()
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn resolve_supplied_token_is_always_injected() {
281        assert_eq!(UiAuthMode::resolve(true, true), UiAuthMode::Injected);
282        assert_eq!(UiAuthMode::resolve(true, false), UiAuthMode::Injected);
283    }
284
285    #[test]
286    fn resolve_no_token_follows_db_auth_config() {
287        // Authenticated DB, no token → the UI prompts.
288        assert_eq!(UiAuthMode::resolve(false, true), UiAuthMode::Prompt);
289        // Unauthenticated DB, no token → no prompt.
290        assert_eq!(UiAuthMode::resolve(false, false), UiAuthMode::Open);
291    }
292
293    #[test]
294    fn auth_mode_strings_are_stable() {
295        assert_eq!(UiAuthMode::Injected.as_str(), "injected");
296        assert_eq!(UiAuthMode::Prompt.as_str(), "prompt");
297        assert_eq!(UiAuthMode::Open.as_str(), "open");
298    }
299
300    #[test]
301    fn config_snippet_never_carries_a_token() {
302        // The snippet is mode-only; no token argument exists to leak.
303        for mode in [UiAuthMode::Injected, UiAuthMode::Prompt, UiAuthMode::Open] {
304            let snippet = auth_mode_config_snippet(mode);
305            assert!(snippet.contains(mode.as_str()));
306            assert!(!snippet.to_ascii_lowercase().contains("token"));
307            assert!(!snippet.to_ascii_lowercase().contains("bearer"));
308        }
309    }
310
311    #[test]
312    fn inject_auth_mode_inserts_before_head_close() {
313        let html = b"<html><head></head><body></body></html>".to_vec();
314        let out = inject_auth_mode_config(html, UiAuthMode::Injected);
315        let s = String::from_utf8(out).unwrap();
316        assert!(
317            s.contains("<script>window.REDDB_AUTH_MODE=\"injected\";</script></head>"),
318            "snippet must appear before </head>: {s}"
319        );
320    }
321
322    #[test]
323    fn inject_auth_mode_noop_without_head_close() {
324        let html = b"<html><body>no head</body></html>".to_vec();
325        let orig = html.clone();
326        assert_eq!(inject_auth_mode_config(html, UiAuthMode::Prompt), orig);
327    }
328
329    #[test]
330    fn handoff_nonce_is_32_hex_chars_and_varies() {
331        let a = new_handoff_nonce();
332        let b = new_handoff_nonce();
333        assert_eq!(a.len(), 32, "nonce is 16 bytes hex-encoded");
334        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
335        // Two CSPRNG draws collide with probability 2^-128 — treat equality
336        // as a generator fault.
337        assert_ne!(a, b, "nonces must be unique per draw");
338    }
339
340    #[test]
341    fn one_time_secret_yields_once_then_empty() {
342        let secret = OneTimeSecret::new("rk_supersecret".to_string());
343        assert!(!secret.is_consumed());
344        assert_eq!(secret.take().as_deref(), Some("rk_supersecret"));
345        assert!(secret.is_consumed());
346        // A replay gets nothing — the channel is single-use.
347        assert_eq!(secret.take(), None);
348    }
349}