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");
}
}