Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/src/login_seam.rs

//! **`DwarvesLoginSeam`** — dwarves-gui's host implementation of the shared
//! [`facett_auth_ui::LoginSeam`], wiring the SHARED
//! [`nornir-auth-client`](nornir_auth_client) (edda) RFC 8628 device-authorization
//! grant behind the presentational [`LoginPane`](facett_auth_ui::LoginPane) the
//! Profile tab mounts. This is the SAME sign-in the constellation reuses
//! (korp/holger/dwarves) — dwarves never hand-rolls a device-code loop (LAW #5) —
//! and it mirrors korp's `KorpLoginSeam` / holger's `HolgerLoginSeam` shape.
//!
//! ## The dwarves difference — the token lands ENCRYPTED in the vault
//! korp/holger have no vault, so their acquired token lives in the client's shipped
//! 0600 `FileTokenStore`. dwarves DOES have the encrypted vault, so this seam persists
//! the [`TokenBundle`] through dwarves' [`VaultTokenStore`](dwarves::login::VaultTokenStore)
//! — one named secret sealed under Argon2id + AES-256-GCM with the rest of the user's
//! credentials, NOT a plaintext file. Because the store can only seal into an UNLOCKED
//! vault, sign-in is gated on the vault [`Session`](dwarves::vault::Session) being
//! unlocked (the SAME Settings → Profile gate the credential list uses): a locked vault
//! ⇒ [`begin_device_code`](LoginSeam::begin_device_code) refuses with a clear message.
//!
//! ## Layering (important)
//! The pane is auth-library-agnostic + presentational: it only renders the states this
//! seam surfaces. dwarves-gui owns the wiring here. The [`LoginSeam`] methods are SYNC,
//! but the device primitives are async — so each call bridges through a private
//! current-thread `tokio` runtime (`block_on`), the same idiom korp's `src/login.rs`
//! uses. That is IO, not CPU parallelism: no `rayon`/`thread::spawn`/`tokio::spawn` of
//! compute (ROOT LAW #0).
//!
//! ## Security invariant (RED-when-broken)
//! No method exposes a token/secret VALUE — [`current_identity`](LoginSeam::current_identity)
//! and [`poll`](LoginSeam::poll) surface only the subject (identity) string, and
//! [`credential_names`](LoginSeam::credential_names) only the NAMES of the held
//! credentials. So a secret value can never reach the pane or its `state_json`; the
//! robot test asserts no sentinel token appears in the observable state, and that the
//! acquired token DID land sealed (unreadable) in the vault file.
#![cfg(feature = "login")]

use std::path::PathBuf;
use std::time::{Duration, Instant};

use dwarves::login::{identity_of, login_config_from_env, VaultTokenStore};
use dwarves::vault::{Session, ZnippyVault};
use facett_auth_ui::{LoginSeam, LoginState};
use nornir_auth_client::{cached_token, device, LoginConfig, TokenBundle, TokenStore};

/// Current unix time in seconds (the stamp [`TokenBundle::from_response`] needs).
fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

/// A private current-thread `tokio` runtime for the sync↔async bridge (the device grant
/// is async; the [`LoginSeam`] is sync). One per call — cheap, and the pane only drives
/// the seam on user action / poll ticks, never in a hot loop.
fn runtime() -> std::io::Result<tokio::runtime::Runtime> {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
}

/// dwarves-gui's host [`LoginSeam`]: the RFC 8628 device-code grant over the shared
/// [`nornir-auth-client`], persisting the acquired token ENCRYPTED in dwarves' vault via
/// the [`VaultTokenStore`]. Holds the in-flight [`DeviceCode`](device::DeviceCode)
/// between [`begin_device_code`](LoginSeam::begin_device_code) and [`poll`](LoginSeam::poll),
/// and throttles polling to the IdP-advertised `interval` (the pane auto-polls per frame).
/// Sign-in is gated on the vault [`Session`] being unlocked (the store seals only into an
/// unlocked vault).
pub struct DwarvesLoginSeam {
    cfg: LoginConfig,
    store: VaultTokenStore<ZnippyVault>,
    /// The vault file the [`Session`] gate is checked against (the store's vault path).
    vault_path: PathBuf,
    /// The device code in flight (between `begin_device_code` and the approving `poll`).
    device: Option<device::DeviceCode>,
    /// When we last polled the token endpoint — honours `interval` while the pane polls
    /// each frame (so we don't hammer the IdP).
    last_poll: Option<Instant>,
    /// The IdP-advertised min seconds between polls (from the device-code response).
    interval: Duration,
}

impl DwarvesLoginSeam {
    /// Build over dwarves' `DWARVES_OIDC_*` config + the default encrypted vault
    /// (`~/.dwarves/vault.bin`) — the SAME config + store the `dwarves login` CLI uses.
    pub fn from_env() -> Self {
        Self::new(login_config_from_env(), ZnippyVault::default_path())
    }

    /// Build over an explicit config + a vault at `vault_path` (the robot test points
    /// this at a local stub IdP + a temp-dir vault + an isolated `Session`).
    pub fn new(cfg: LoginConfig, vault_path: PathBuf) -> Self {
        let store = VaultTokenStore::with_default_name(ZnippyVault::at(&vault_path));
        Self {
            cfg,
            store,
            vault_path,
            device: None,
            last_poll: None,
            interval: Duration::from_secs(1),
        }
    }

    /// The vault is unlocked iff a live [`Session`] key exists for it — the SAME gate the
    /// Profile credential list + Settings' Unlock/Lock drive. A locked vault ⇒ the store
    /// cannot seal a token, so sign-in is refused.
    fn vault_unlocked(&self) -> bool {
        Session::key_for_path(&self.vault_path).is_some()
    }

    /// The NAMES of the credentials a bundle holds — `access_token`, plus `refresh_token`
    /// when one was issued. **Never values.**
    fn names_of(bundle: &TokenBundle) -> Vec<String> {
        let mut names = vec!["access_token".to_string()];
        if bundle.refresh_token.is_some() {
            names.push("refresh_token".to_string());
        }
        names
    }
}

impl LoginSeam for DwarvesLoginSeam {
    fn source(&self) -> &str {
        "dwarves oidc (vault)"
    }

    fn begin_device_code(&mut self) -> LoginState {
        // Gate: the acquired token can only be sealed into an UNLOCKED vault — refuse
        // early with a clear message rather than start a grant we can't persist.
        if !self.vault_unlocked() {
            return LoginState::Error(
                "vault is locked — unlock it in Settings before signing in".to_string(),
            );
        }
        let cfg = self.cfg.clone();
        let requested = runtime().and_then(|rt| {
            rt.block_on(async move {
                device::request_device_code(&cfg.device_auth_url, &cfg.client_id, &cfg.scope)
                    .await
                    .map_err(std::io::Error::other)
            })
        });
        match requested {
            Ok(dc) => {
                let state = LoginState::AwaitingUser {
                    user_code: dc.user_code.clone(),
                    verification_uri: dc.verification_uri.clone(),
                };
                self.interval = Duration::from_secs(dc.interval.max(1));
                self.last_poll = None;
                self.device = Some(dc);
                state
            }
            Err(e) => LoginState::Error(format!("could not start sign-in: {e}")),
        }
    }

    fn poll(&mut self) -> LoginState {
        let Some(dc) = self.device.clone() else {
            // Nothing in flight — nothing to poll.
            return LoginState::SignedOut;
        };
        let awaiting = || LoginState::AwaitingUser {
            user_code: dc.user_code.clone(),
            verification_uri: dc.verification_uri.clone(),
        };
        // Honour the IdP poll interval — the pane calls us every frame.
        if let Some(last) = self.last_poll {
            if last.elapsed() < self.interval {
                return awaiting();
            }
        }
        self.last_poll = Some(Instant::now());

        let cfg = self.cfg.clone();
        let device_code = dc.device_code.clone();
        let polled = runtime().and_then(|rt| {
            rt.block_on(async move {
                device::poll_token_full(&cfg.token_url, &cfg.client_id, &device_code)
                    .await
                    .map_err(std::io::Error::other)
            })
        });
        match polled {
            // Approved: build the bundle, SEAL it in the encrypted vault, resolve subject.
            Ok(Some(tr)) => {
                let bundle = TokenBundle::from_response(tr, now_unix(), None);
                let subject = identity_of(&bundle);
                if let Err(e) = self.store.save(&bundle) {
                    return LoginState::Error(format!(
                        "signed in, but could not seal the token in the vault: {e:#}"
                    ));
                }
                self.device = None;
                self.last_poll = None;
                LoginState::SignedIn { subject }
            }
            // authorization_pending / slow_down — keep showing the prompt.
            Ok(None) => awaiting(),
            Err(e) => LoginState::Error(format!("sign-in failed: {e}")),
        }
    }

    fn current_identity(&self) -> Option<String> {
        // No network: read a non-expired cached token from the vault (needs the vault
        // unlocked to decrypt — a locked vault ⇒ `Err` ⇒ `None`, fail closed), decode
        // its subject. NEVER the token value.
        let token = cached_token(&self.store).ok().flatten()?;
        dwarves::login::subject_of(&token)
    }

    fn sign_out(&mut self) -> LoginState {
        // Remove the sealed token (needs the vault unlocked to re-seal; best-effort).
        let _ = self.store.clear();
        self.device = None;
        self.last_poll = None;
        LoginState::SignedOut
    }

    fn credential_names(&self) -> Vec<String> {
        // NAMES only — the sealed bundle's token names, never values. A locked vault ⇒
        // load errors ⇒ empty (nothing to show).
        match self.store.load() {
            Ok(Some(bundle)) => Self::names_of(&bundle),
            _ => Vec::new(),
        }
    }
}

/// Build the shared sign-in [`LoginPane`](facett_auth_ui::LoginPane) over dwarves'
/// [`DwarvesLoginSeam`] (`DWARVES_OIDC_*` config + the default encrypted vault). If a
/// cached token already covers dwarves, the pane opens straight into the signed-in
/// surface.
pub fn login_pane() -> facett_auth_ui::LoginPane {
    facett_auth_ui::LoginPane::new(Box::new(DwarvesLoginSeam::from_env())).with_title("Sign in")
}

#[cfg(test)]
mod tests {
    use super::*;
    use dwarves::vault::Vault;
    use facett_auth_ui::{Facet, LoginPane};

    /// **UNIT — `names_of` lists the credential NAMES, gated on the refresh token.**
    /// (autonom gap.) The Profile/login surface must reveal only the NAMES a bundle holds,
    /// never the secret values — and `refresh_token` appears only when the IdP issued one
    /// (device-code with `offline_access`), so the branch is asserted both ways. A bundle
    /// always carries an access token; the names must never leak the token strings.
    #[test]
    fn names_of_lists_credential_names_and_only_adds_refresh_when_present() {
        let bundle = |refresh: Option<&str>| TokenBundle {
            access_token: "at-secret-value".into(),
            refresh_token: refresh.map(str::to_string),
            expires_at_unix: None,
            subject: None,
        };

        // No refresh token ⇒ exactly the access-token name.
        assert_eq!(DwarvesLoginSeam::names_of(&bundle(None)), ["access_token"]);

        // Refresh present ⇒ both names, refresh second.
        let both = DwarvesLoginSeam::names_of(&bundle(Some("rt-secret")));
        assert_eq!(both, ["access_token", "refresh_token"]);

        // The NAMES never leak the secret VALUES — the whole point of the seam.
        assert!(
            !both.iter().any(|n| n.contains("secret")),
            "credential names must not expose token values: {both:?}"
        );
    }

    /// An unsigned display-only JWT `header.payload.sig` (the shape the IdP would mint;
    /// the server verifies the signature on presentation — here it is display-only).
    fn jwt(payload: serde_json::Value) -> String {
        use base64::Engine;
        let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
        format!(
            "{}.{}.sig",
            b64(br#"{"alg":"none"}"#),
            b64(payload.to_string().as_bytes())
        )
    }

    /// **ROBOT (the GATE): the REAL `DwarvesLoginSeam` drives the shared
    /// `facett_auth_ui::LoginPane` SignedOut → AwaitingUser → SignedIn over a LOCAL stub
    /// IdP (no real network), UNLOCK-GATED on the vault `Session`, and NO secret VALUE
    /// ever leaks** into the pane's observable `state_json`. Proves:
    /// 1. a LOCKED vault refuses sign-in (the Settings → Profile gate) — `begin` surfaces
    ///    the "locked" error, the pane shows no prompt;
    /// 2. once the `Session` is UNLOCKED, `begin` → the device prompt, `poll` → signed in
    ///    (subject decoded from the token), credential NAMES only;
    /// 3. the acquired token is SEALED (unreadable) in the vault file, yet never appears
    ///    in the pane state (no token/refresh value, no `value`/`token` key).
    #[test]
    fn dwarves_seam_drives_login_pane_gated_on_unlock_no_leak() {
        let _g = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

        let dir = tempfile::tempdir().unwrap();
        let sess = dir.path().join("vault.session");
        let vpath = dir.path().join("vault.bin");
        // SAFETY: single-threaded under ENV; isolate the session to this dir.
        unsafe { std::env::set_var("DWARVES_VAULT_SESSION", &sess) };

        // The IdP mints THIS access token — a JWT sentinel we then prove never leaks.
        let access_token = jwt(serde_json::json!({ "preferred_username": "robot@vetra.se" }));

        // A PERSISTENT stub IdP: a multi-thread runtime kept alive for the whole drive
        // (the seam builds its OWN current-thread runtime per begin/poll call, so the
        // server must live independently). IO stub only — no CPU fan-out (ROOT LAW #0).
        let server_rt = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(1)
            .enable_all()
            .build()
            .unwrap();
        let addr = server_rt.block_on(stub_idp::spawn(access_token.clone()));

        let cfg = LoginConfig {
            device_auth_url: format!("http://{addr}/device"),
            token_url: format!("http://{addr}/token"),
            client_id: "dwarves-cli".into(),
            scope: "openid offline_access".into(),
        };
        let seam = DwarvesLoginSeam::new(cfg, vpath.clone());
        let mut pane = LoginPane::new(Box::new(seam)).with_auto_poll(false);

        // (1) LOCKED vault ⇒ begin REFUSES (the Settings gate). The pane stays without a
        //     device prompt and surfaces the "locked" error.
        assert_eq!(
            pane.state_json()["state"],
            "signed_out",
            "fresh + locked ⇒ signed out"
        );
        pane.update_json(r#"{"begin":true}"#);
        let s = pane.state_json();
        assert_eq!(
            s["state"], "error",
            "a locked vault refuses to start sign-in"
        );
        assert!(
            s["error"].as_str().unwrap_or_default().contains("locked"),
            "the locked gate is surfaced: {s}"
        );

        // (2) UNLOCK the vault (the Settings unlock) — creates the `Session` key the store
        //     needs to seal the token.
        let v = ZnippyVault::at(&vpath);
        Session::unlock(&v, b"master-pw", 3600).unwrap();

        // begin → AwaitingUser: the device prompt (user-code + verification URL) shows.
        pane.update_json(r#"{"begin":true}"#);
        let s = pane.state_json();
        assert_eq!(
            s["state"], "awaiting_user",
            "unlocked ⇒ the device-code prompt: {s}"
        );
        assert_eq!(s["user_code"], "WDJB-MJHT");
        assert!(s["verification_uri"].as_str().unwrap().contains("/device"));

        // poll → SignedIn: the subject (identity) shows; credential NAMES only.
        pane.update_json(r#"{"poll":true}"#);
        let s = pane.state_json();
        assert_eq!(
            s["state"], "signed_in",
            "the stub approved and the seam signed in: {s}"
        );
        assert_eq!(
            s["subject"], "robot@vetra.se",
            "subject decoded from the token, no value"
        );
        let names = s["credential_names"].as_array().unwrap();
        assert!(
            names.iter().any(|n| n == "access_token"),
            "access_token NAME present"
        );
        assert!(
            names.iter().any(|n| n == "refresh_token"),
            "refresh_token NAME present"
        );

        // (3) NO secret VALUE ANYWHERE in the state — not the access token, not the
        //     refresh token, no value/token key (the RED-when-broken security invariant).
        let whole = serde_json::to_string(&s).unwrap();
        assert!(
            !whole.contains(&access_token),
            "the access token never reaches state: {whole}"
        );
        assert!(
            !whole.contains("rt-456"),
            "the refresh token never reaches state: {whole}"
        );
        assert!(s.as_object().unwrap().get("value").is_none());
        assert!(s.as_object().unwrap().get("token").is_none());

        // …yet the token WAS sealed in the vault (encrypted-at-rest): the vault file exists
        // and does NOT contain the token plaintext.
        assert!(
            vpath.exists(),
            "the acquired token is persisted to dwarves' encrypted vault"
        );
        let on_disk = std::fs::read(&vpath).unwrap();
        let jwt_head = &access_token.as_bytes()[..access_token.len().min(16)];
        assert!(
            !on_disk.windows(jwt_head.len()).any(|w| w == jwt_head),
            "the access token leaked into the sealed vault file (must be encrypted)"
        );

        // Sign out → back to SignedOut, and the sealed token is cleared.
        pane.update_json(r#"{"sign_out":true}"#);
        assert_eq!(pane.state_json()["state"], "signed_out");
        assert!(
            v.list()
                .unwrap()
                .iter()
                .all(|r| r.name != "dwarves-login-token"),
            "sign out clears the sealed token from the vault",
        );

        Session::lock().unwrap();
        // SAFETY: single-threaded under ENV.
        unsafe { std::env::remove_var("DWARVES_VAULT_SESSION") };

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "dwarves_seam_drives_shared_login_pane_gated_on_unlock",
            true,
            "the REAL DwarvesLoginSeam (device-code grant over nornir-auth-client, token sealed in dwarves' encrypted vault via VaultTokenStore) drives the SHARED facett_auth_ui::LoginPane SignedOut→AwaitingUser→SignedIn over a local stub IdP: a locked vault refuses (the Session gate), an unlocked vault signs in and renders the subject + credential NAMES; the token lands sealed (unreadable) in the vault and no secret value leaks into the pane state — no real network",
        );
    }

    /// A minimal localhost stub of the RFC 8628 IdP endpoints — a raw `tokio` TCP
    /// HTTP/1.1 server answering `/device` (device-authorization) and `/token` (immediate
    /// approval). No real network; bound to `127.0.0.1:0`. Mirrors korp/holger's stub (the
    /// shared reuse pattern). The `tokio::spawn`s run IO (a mock HTTP server), NOT CPU work
    /// — ROOT LAW #0 targets compute parallelism, not IO servers.
    mod stub_idp {
        use tokio::io::{AsyncReadExt, AsyncWriteExt};
        use tokio::net::TcpListener;

        /// Bind on an ephemeral loopback port, serve the two endpoints on a background
        /// task, and return the bound `addr`.
        pub async fn spawn(access_token: String) -> std::net::SocketAddr {
            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
            let addr = listener.local_addr().unwrap();
            let device_body = format!(
                r#"{{"device_code":"dc-123","user_code":"WDJB-MJHT","verification_uri":"http://{addr}/device","interval":1,"expires_in":600}}"#
            );
            let token_body = format!(
                r#"{{"access_token":"{access_token}","refresh_token":"rt-456","expires_in":3600,"token_type":"Bearer"}}"#
            );
            tokio::spawn(async move {
                loop {
                    let Ok((mut sock, _)) = listener.accept().await else {
                        break;
                    };
                    let device_body = device_body.clone();
                    let token_body = token_body.clone();
                    tokio::spawn(async move {
                        let mut buf = vec![0u8; 4096];
                        let n = sock.read(&mut buf).await.unwrap_or(0);
                        let req = String::from_utf8_lossy(&buf[..n]);
                        let path = req.split_whitespace().nth(1).unwrap_or("/");
                        let body = if path.starts_with("/token") {
                            &token_body
                        } else {
                            &device_body
                        };
                        let resp = format!(
                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                            body.len(),
                            body
                        );
                        let _ = sock.write_all(resp.as_bytes()).await;
                        let _ = sock.shutdown().await;
                    });
                }
            });
            addr
        }
    }
}