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

nordisk/facett

facett-auth-ui/src/lib.rs

//! # facett-auth-ui — the shared **device-code sign-in** pane
//!
//! The ONE robot-testable egui [`Facet`](facett_core::Facet) that renders an
//! **RFC 8628 device-code** login, so every suite app's *Sign in* surface (dwarves'
//! Profile · korp · holger-ui) is **one pane, not a per-app twin** (LAW #5). It
//! generalises `dwarves-demo/src/profile.rs`'s interim `LoginSeam` (whose
//! `NornirLogin::sign_in` just left a *"(see terminal for the device code)"* note)
//! into a real in-GUI device-code panel: the user-code + verification URL paint
//! **inside** the app (clickable + copyable), and the pane polls to completion.
//!
//! ## Layering — the pane is AUTH-LIBRARY-AGNOSTIC (important)
//! facett carries **no** `nornir-auth-client` / `edda` auth dependency. The pane is
//! **presentational + host-driven**: the HOST implements the [`LoginSeam`] trait
//! (wiring the real `nornir-auth-client` device-code grant + token store), and the
//! [`LoginPane`] only ever **renders the states the seam surfaces** —
//! [`LoginState::SignedOut`] / [`AwaitingUser`](LoginState::AwaitingUser) /
//! [`SignedIn`](LoginState::SignedIn) / [`Error`](LoginState::Error). This mirrors
//! how the other facett panes take host data in (the Release tab takes a
//! `ReleaseConvergence`; the About card takes an `Identity`).
//!
//! ## What it renders
//! * **Signed out** — a *Sign in* button → [`LoginSeam::begin_device_code`].
//! * **Awaiting user** — the device-code **prompt**: the `user_code` (monospace) +
//!   the `verification_uri` as a **clickable** hyperlink, each with a **Copy**
//!   button (reusing [`facett_core::clipboard::put`], never a twinned OS call). The
//!   pane polls [`LoginSeam::poll`] while it waits (see [`LoginPane::auto_poll`]).
//! * **Signed in** — the resolved **subject** (identity) + the credential **NAMES**
//!   ([`LoginSeam::credential_names`] — *never* values), and a *Sign out* button.
//! * **Error** — the human message + a *Try again* button.
//!
//! ## Security invariant (RED-when-broken)
//! The seam exposes **no token/secret getter** — only a subject (identity) string
//! and credential *names*. So a secret **value** can never reach the pane or its
//! [`state_json`](Facet::state_json); the robot test asserts no secret sentinel
//! appears anywhere in the state. (The QR-of-URL is a deferred nicety — no encoder
//! dep is pulled in for it here.)

pub use facett_core::Facet;

/// The RFC 8628 device-code sign-in **state** the HOST reports and this pane
/// RENDERS. The pane never computes these — it only paints whichever one the
/// [`LoginSeam`] returns.
#[derive(Clone, Debug, PartialEq, Default)]
pub enum LoginState {
    /// Nobody is signed in — the pane shows the *Sign in* button.
    #[default]
    SignedOut,
    /// A device-code grant is in flight: show the `user_code` + `verification_uri`
    /// and wait for the user to approve it in their browser. The pane polls.
    AwaitingUser {
        /// The short code the user types at the verification page (e.g. `WDJB-MJHT`).
        user_code: String,
        /// The URL the user opens to approve (clickable + copyable in the pane).
        verification_uri: String,
    },
    /// Signed in as `subject` — the identity read from the acquired token. The token
    /// itself is **never** surfaced; only this subject string is.
    SignedIn {
        /// The resolved identity (e.g. `dev@nordisk.dev`).
        subject: String,
    },
    /// The last attempt failed with a human-readable message.
    Error(String),
}

/// The device-code login **seam** the [`LoginPane`] drives — implemented by the
/// **HOST** (dwarves/korp/holger-ui), which wires the real `nornir-auth-client`
/// grant + token store behind it. Generalises dwarves-demo's interim `LoginSeam`
/// (single blocking `sign_in`) into the begin/poll shape a real in-GUI device-code
/// flow needs. **No method exposes a token/secret value** — by construction a
/// credential value can never reach the pane.
pub trait LoginSeam {
    /// An honest banner for *where* sign-in is backed, e.g. `"nornir oidc"` or
    /// `"stub device-code (demo)"`. Defaulted to a neutral label.
    fn source(&self) -> &str {
        "device-code"
    }

    /// **Begin** a device-code grant: the host contacts the IdP's device endpoint.
    /// Returns the initial state — normally
    /// [`AwaitingUser`](LoginState::AwaitingUser) carrying the `user_code` +
    /// `verification_uri`, or [`Error`](LoginState::Error) if it couldn't start
    /// (or [`SignedIn`](LoginState::SignedIn) if a cached token already covers it).
    fn begin_device_code(&mut self) -> LoginState;

    /// **Poll** the token endpoint once. While the user hasn't approved yet the host
    /// returns [`AwaitingUser`](LoginState::AwaitingUser) (unchanged); on approval
    /// [`SignedIn`](LoginState::SignedIn); on failure [`Error`](LoginState::Error).
    fn poll(&mut self) -> LoginState;

    /// The identity from an already-cached token, if any (**no network**). Lets the
    /// pane open straight into the signed-in surface. Defaulted to `None`.
    fn current_identity(&self) -> Option<String> {
        None
    }

    /// Forget the current credential (sign out). Returns the resulting state
    /// (normally [`SignedOut`](LoginState::SignedOut)). Defaulted to `SignedOut`.
    fn sign_out(&mut self) -> LoginState {
        LoginState::SignedOut
    }

    /// The **NAMES** of the credentials held for the signed-in identity — e.g.
    /// `id_token`, `refresh_token`. **Never values.** Empty by default.
    fn credential_names(&self) -> Vec<String> {
        Vec::new()
    }
}

/// The shared device-code **sign-in pane** — a [`Facet`](facett_core::Facet) the
/// host mounts, driving its host-supplied [`LoginSeam`]. Presentational: it holds
/// the current [`LoginState`] and paints it; every transition comes from the seam.
pub struct LoginPane {
    seam: Box<dyn LoginSeam>,
    state: LoginState,
    title: String,
    /// While [`AwaitingUser`](LoginState::AwaitingUser), call [`LoginSeam::poll`]
    /// each frame (and request a repaint) so the pane completes on its own. On by
    /// default; the robot test turns it OFF to drive transitions deterministically.
    auto_poll: bool,
}

impl LoginPane {
    /// Build the pane over a host-supplied [`LoginSeam`]. If the seam already has a
    /// cached identity ([`LoginSeam::current_identity`]) the pane opens straight
    /// into the signed-in surface; otherwise it opens signed-out.
    pub fn new(seam: Box<dyn LoginSeam>) -> Self {
        let state = match seam.current_identity() {
            Some(subject) => LoginState::SignedIn { subject },
            None => LoginState::SignedOut,
        };
        Self { seam, state, title: "Sign in".to_string(), auto_poll: true }
    }

    /// Override the pane heading (default `"Sign in"`).
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    /// Turn frame-by-frame auto-polling off (the robot test drives `poll` itself).
    pub fn with_auto_poll(mut self, auto_poll: bool) -> Self {
        self.auto_poll = auto_poll;
        self
    }

    /// The current sign-in state (read-only) — the host/robot can branch on it.
    pub fn state(&self) -> &LoginState {
        &self.state
    }

    /// `true` iff the pane is currently showing a signed-in subject.
    pub fn is_signed_in(&self) -> bool {
        matches!(self.state, LoginState::SignedIn { .. })
    }

    /// Kick off a device-code grant (the *Sign in* / *Try again* action).
    pub fn begin(&mut self) {
        self.state = self.seam.begin_device_code();
    }

    /// Poll once — only meaningful while awaiting the user; a no-op otherwise.
    pub fn poll(&mut self) {
        if matches!(self.state, LoginState::AwaitingUser { .. }) {
            self.state = self.seam.poll();
        }
    }

    /// Sign out (forget the credential).
    pub fn sign_out(&mut self) {
        self.state = self.seam.sign_out();
    }
}

impl Facet for LoginPane {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.heading(&self.title);
        ui.weak(format!("via {}", self.seam.source()));
        ui.separator();

        // Clone the state to paint from, so the button handlers below can mutate
        // `self` (begin/poll/sign_out) without a borrow conflict.
        match self.state.clone() {
            LoginState::SignedOut => {
                if ui.button("Sign in").clicked() {
                    self.begin();
                }
            }
            LoginState::AwaitingUser { user_code, verification_uri } => {
                ui.label("To sign in, open this URL in your browser:");
                // Clickable verification URL (a11y + robot-observable by its text).
                ui.hyperlink_to(verification_uri.clone(), verification_uri.clone());
                ui.horizontal(|ui| {
                    ui.label("and enter the code:");
                    ui.monospace(&user_code);
                });
                ui.horizontal(|ui| {
                    if ui.button("Copy URL").clicked() {
                        facett_core::clipboard::put(ui.ctx(), verification_uri.clone());
                    }
                    if ui.button("Copy code").clicked() {
                        facett_core::clipboard::put(ui.ctx(), user_code.clone());
                    }
                });
                ui.weak("Waiting for approval…");
                // Poll to completion on our own (real UX); the robot test disables
                // this and drives `poll` deterministically between frames.
                if self.auto_poll {
                    self.poll();
                    ui.ctx().request_repaint();
                }
            }
            LoginState::SignedIn { subject } => {
                ui.horizontal(|ui| {
                    ui.label("Signed in as");
                    ui.monospace(&subject);
                });
                let names = self.seam.credential_names();
                if !names.is_empty() {
                    ui.add_space(4.0);
                    ui.weak("Credentials (names only):");
                    for n in &names {
                        // NAME only — the seam exposes no value to render.
                        ui.monospace(n);
                    }
                }
                ui.add_space(6.0);
                if ui.button("Sign out").clicked() {
                    self.sign_out();
                }
            }
            LoginState::Error(msg) => {
                ui.colored_label(egui::Color32::from_rgb(0xd9, 0x53, 0x4f), &msg);
                if ui.button("Try again").clicked() {
                    self.begin();
                }
            }
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let mut o = serde_json::Map::new();
        o.insert("kind".into(), serde_json::json!("login"));
        o.insert("source".into(), serde_json::json!(self.seam.source()));
        match &self.state {
            LoginState::SignedOut => {
                o.insert("state".into(), serde_json::json!("signed_out"));
            }
            LoginState::AwaitingUser { user_code, verification_uri } => {
                o.insert("state".into(), serde_json::json!("awaiting_user"));
                o.insert("user_code".into(), serde_json::json!(user_code));
                o.insert("verification_uri".into(), serde_json::json!(verification_uri));
            }
            LoginState::SignedIn { subject } => {
                o.insert("state".into(), serde_json::json!("signed_in"));
                o.insert("subject".into(), serde_json::json!(subject));
                // NAMES only — there is deliberately no "value"/"token"/"secret"
                // key anywhere in this object (the RED-when-broken invariant).
                o.insert(
                    "credential_names".into(),
                    serde_json::json!(self.seam.credential_names()),
                );
            }
            LoginState::Error(msg) => {
                o.insert("state".into(), serde_json::json!("error"));
                o.insert("error".into(), serde_json::json!(msg));
            }
        }
        serde_json::Value::Object(o)
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            if v.get("begin").and_then(|t| t.as_bool()) == Some(true) {
                self.begin();
            }
            if v.get("poll").and_then(|t| t.as_bool()) == Some(true) {
                self.poll();
            }
            if v.get("sign_out").and_then(|t| t.as_bool()) == Some(true) {
                self.sign_out();
            }
        }
    }

    fn kind(&self) -> &'static str {
        "auth-login"
    }
}

/// A network-free **stub** [`LoginSeam`] for the shipped demo + the robot test: it
/// SIMULATES the device-code grant (honest `"stub device-code (demo)"` source) so
/// the *Sign in* button and the whole drive are exercised without a real IdP. It
/// approves after [`approve_after`](StubLogin::with_approve_after) polls (default 1)
/// and holds a **secret sentinel** it never surfaces — the invariant the robot test
/// guards.
pub struct StubLogin {
    subject: String,
    user_code: String,
    verification_uri: String,
    cached: Option<String>,
    approve_after: u32,
    polls: u32,
    signed_in: bool,
    /// A stand-in for a real token — held to PROVE it never leaks into the pane or
    /// its `state_json` (the seam exposes no value getter, so it can't).
    _secret_token: String,
}

impl StubLogin {
    /// A stub that will sign in as `subject`, showing the sample device-code prompt.
    pub fn new(subject: impl Into<String>) -> Self {
        Self {
            subject: subject.into(),
            user_code: "WDJB-MJHT".to_string(),
            verification_uri: "https://sso.nordisk.dev/device".to_string(),
            cached: None,
            approve_after: 1,
            polls: 0,
            signed_in: false,
            _secret_token: "tok_TOPSECRET-NEVER-LEAKS".to_string(),
        }
    }

    /// Approve only after `n` polls (default 1) — lets a test observe the
    /// awaiting-user surface for several frames before it flips to signed-in.
    pub fn with_approve_after(mut self, n: u32) -> Self {
        self.approve_after = n.max(1);
        self
    }

    /// Mark this stub as already signed-in (a cached token) as `subject`, so the
    /// pane opens straight into the signed-in surface.
    pub fn already_signed_in(mut self, subject: impl Into<String>) -> Self {
        self.cached = Some(subject.into());
        self.signed_in = true;
        self
    }
}

impl LoginSeam for StubLogin {
    fn source(&self) -> &str {
        "stub device-code (demo)"
    }

    fn begin_device_code(&mut self) -> LoginState {
        self.polls = 0;
        self.signed_in = false;
        LoginState::AwaitingUser {
            user_code: self.user_code.clone(),
            verification_uri: self.verification_uri.clone(),
        }
    }

    fn poll(&mut self) -> LoginState {
        self.polls += 1;
        if self.polls >= self.approve_after {
            self.signed_in = true;
            LoginState::SignedIn { subject: self.subject.clone() }
        } else {
            LoginState::AwaitingUser {
                user_code: self.user_code.clone(),
                verification_uri: self.verification_uri.clone(),
            }
        }
    }

    fn current_identity(&self) -> Option<String> {
        self.cached.clone()
    }

    fn sign_out(&mut self) -> LoginState {
        self.signed_in = false;
        self.cached = None;
        self.polls = 0;
        LoginState::SignedOut
    }

    fn credential_names(&self) -> Vec<String> {
        if self.signed_in {
            vec!["id_token".to_string(), "refresh_token".to_string()]
        } else {
            Vec::new()
        }
    }
}

// ── testmatrix emit shim (real row emitter with `--features testmatrix`, a
//    compiled-out no-op otherwise — the facett-about / facett-confirm idiom) ─────
/// Emit one functional-status row (no-op unless built `--features testmatrix`).
#[cfg(feature = "testmatrix")]
pub fn emit(component: &str, check: &str, ok: bool, detail: &str) {
    nornir_testmatrix::functional_status(component, check, ok, detail);
}
/// No-op shape compiled when `testmatrix` is OFF (the release path).
#[cfg(not(feature = "testmatrix"))]
#[inline(always)]
pub fn emit(_component: &str, _check: &str, _ok: bool, _detail: &str) {}

#[cfg(test)]
mod tests {
    use super::*;

    /// The pane opens signed-out over a fresh stub, and drives the full device-code
    /// flow headlessly over `update_json`/`state_json` — SignedOut → AwaitingUser
    /// (user-code + URL present) → SignedIn (subject present) — with NO secret value
    /// ever appearing in the state (the seam has no value getter).
    #[test]
    fn drives_device_code_flow_and_never_leaks_a_value() {
        let mut pane = LoginPane::new(Box::new(StubLogin::new("dev@nordisk.dev")))
            .with_auto_poll(false);

        // SignedOut.
        assert_eq!(pane.state_json()["state"], "signed_out");

        // Begin → AwaitingUser: the prompt (user-code + verification URL) is surfaced.
        pane.update_json(r#"{"begin":true}"#);
        let s = pane.state_json();
        assert_eq!(s["state"], "awaiting_user");
        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");
        assert_eq!(s["subject"], "dev@nordisk.dev");
        let names = s["credential_names"].as_array().unwrap();
        assert!(names.iter().any(|n| n == "id_token"));

        // No secret value ANYWHERE — not the sentinel token, not a value/secret key.
        let whole = serde_json::to_string(&s).unwrap();
        assert!(!whole.contains("TOPSECRET"), "the sentinel token never reaches state: {whole}");
        assert!(!whole.contains("access_token") && !whole.contains("value_b64"));
        assert!(s.as_object().unwrap().get("value").is_none());
        assert!(s.as_object().unwrap().get("token").is_none());

        // Sign out → back to signed-out.
        pane.update_json(r#"{"sign_out":true}"#);
        assert_eq!(pane.state_json()["state"], "signed_out");
    }

    /// A cached token opens the pane straight into the signed-in surface (the
    /// `current_identity` seam read) — no begin/poll needed.
    #[test]
    fn cached_identity_opens_signed_in() {
        let pane = LoginPane::new(Box::new(
            StubLogin::new("ignored").already_signed_in("cached@vetra.se"),
        ));
        let s = pane.state_json();
        assert_eq!(s["state"], "signed_in");
        assert_eq!(s["subject"], "cached@vetra.se");
    }

    /// An error state from the seam renders as an error the pane can surface + retry.
    #[test]
    fn error_state_surfaces() {
        struct Failing;
        impl LoginSeam for Failing {
            fn begin_device_code(&mut self) -> LoginState {
                LoginState::Error("device endpoint unreachable".to_string())
            }
            fn poll(&mut self) -> LoginState {
                LoginState::Error("device endpoint unreachable".to_string())
            }
        }
        let mut pane = LoginPane::new(Box::new(Failing));
        pane.update_json(r#"{"begin":true}"#);
        let s = pane.state_json();
        assert_eq!(s["state"], "error");
        assert_eq!(s["error"], "device endpoint unreachable");
    }
}