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
}
}
}