Gunnar

nordisk/holger-egui

src/login_seam.rs

//! **`HolgerLoginSeam`** — holger-server-ui'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
//! operator Sign-in tab mounts. This is the SAME sign-in the constellation reuses
//! (korp/holger/dwarves) — holger never hand-rolls a device-code loop (LAW #5).
//!
//! ## Layering (important)
//! The pane is auth-library-agnostic + presentational: it only renders the states
//! this seam surfaces. holger owns the wiring here. The
//! [`facett_auth_ui::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
//! Sign-in robot test asserts no sentinel token appears in the observable state.
//!
//! holger-server-ui has NO vault, so the acquired token lives in the client's shipped
//! default 0600 [`FileTokenStore`] at `~/.holger/auth-token.json` — a same-host,
//! owner-only convenience store (the same shape as korp's `~/.korp/auth-token.json`).

use facett_auth_ui::{LoginSeam, LoginState};
use nornir_auth_client::{
    cached_token, device, FileTokenStore, LoginConfig, TokenBundle, TokenStore,
};

// ── the default (Keycloak-dev-shaped) OIDC endpoints ─────────────────────────
//
// PLACEHOLDERS only — a local Keycloak `nordisk` realm shape, never a hardcoded
// secret. A deployment overrides each via the documented `HOLGER_OIDC_*` env vars.

/// Default device-authorization endpoint (`$HOLGER_OIDC_DEVICE_AUTH_URL`).
pub const DEFAULT_DEVICE_AUTH_URL: &str =
    "http://localhost:8080/realms/nordisk/protocol/openid-connect/auth/device";
/// Default token endpoint (`$HOLGER_OIDC_TOKEN_URL`).
pub const DEFAULT_TOKEN_URL: &str =
    "http://localhost:8080/realms/nordisk/protocol/openid-connect/token";
/// Default public client id for the operator UI (`$HOLGER_OIDC_CLIENT_ID`). A PUBLIC
/// client id (not a secret) — the device-code grant carries no client secret.
///
/// ⚠ **Deliberately still `holger-ui`, though the package was renamed to
/// `holger-server-ui` on 2026-08-01.** This string is not our name to change: it is
/// the id the client is REGISTERED under at the IdP, and renaming it here would make
/// every existing Keycloak/Entra/Okta registration reject the device-code request
/// with `invalid_client`. Change it only together with the IdP registration (or per
/// deployment via `$HOLGER_OIDC_CLIENT_ID`).
pub const DEFAULT_CLIENT_ID: &str = "holger-ui";
/// Default requested scope (`$HOLGER_OIDC_SCOPE`). `offline_access` is what makes the
/// IdP return a refresh token so the session can be silently refreshed.
pub const DEFAULT_SCOPE: &str = "openid profile offline_access";

/// The per-app store name — holger's default 0600 file lives at
/// `~/.holger/auth-token.json`.
pub const APP: &str = "holger";

/// Build a [`LoginConfig`] from the `HOLGER_OIDC_*` env vars, falling back to the
/// Keycloak-dev-shaped placeholders above (mirrors korp's `KORP_OIDC_*`):
///
/// | env | meaning | default |
/// |-----|---------|---------|
/// | `HOLGER_OIDC_DEVICE_AUTH_URL` | RFC 8628 device-authorization endpoint | [`DEFAULT_DEVICE_AUTH_URL`] |
/// | `HOLGER_OIDC_TOKEN_URL`       | token endpoint (poll + refresh)        | [`DEFAULT_TOKEN_URL`] |
/// | `HOLGER_OIDC_CLIENT_ID`       | public client id                       | [`DEFAULT_CLIENT_ID`] |
/// | `HOLGER_OIDC_SCOPE`           | requested scope                        | [`DEFAULT_SCOPE`] |
pub fn login_config_from_env() -> LoginConfig {
    let var = |key: &str, default: &str| {
        std::env::var(key)
            .ok()
            .filter(|v| !v.trim().is_empty())
            .unwrap_or_else(|| default.to_string())
    };
    LoginConfig {
        device_auth_url: var("HOLGER_OIDC_DEVICE_AUTH_URL", DEFAULT_DEVICE_AUTH_URL),
        token_url: var("HOLGER_OIDC_TOKEN_URL", DEFAULT_TOKEN_URL),
        client_id: var("HOLGER_OIDC_CLIENT_ID", DEFAULT_CLIENT_ID),
        scope: var("HOLGER_OIDC_SCOPE", DEFAULT_SCOPE),
    }
}

/// Best-effort extract of a human identity from an access token for DISPLAY only.
/// Decodes the JWT payload (NO signature check — that is the SERVER's job on
/// presentation) and prefers `preferred_username` → `email` → `sub`. `None` for an
/// opaque (non-JWT) token or a payload without those claims. Mirrors korp's
/// `subject_of` (the shared client has no display decoder — this stays presentational).
pub fn subject_of(access_token: &str) -> Option<String> {
    use base64::Engine;
    let payload_b64 = access_token.split('.').nth(1)?;
    // JWT uses base64url; tolerate both the (canonical) no-pad and padded forms.
    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload_b64)
        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
        .ok()?;
    let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    for key in ["preferred_username", "email", "sub"] {
        if let Some(v) = claims.get(key).and_then(|v| v.as_str()) {
            if !v.is_empty() {
                return Some(v.to_string());
            }
        }
    }
    None
}

/// 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()
}

/// holger-server-ui's host [`LoginSeam`]: the RFC 8628 device-code grant over the shared
/// [`nornir-auth-client`], persisting the acquired token in the default 0600 file
/// store. Holds the in-flight [`DeviceCode`](device::DeviceCode) between
/// [`begin_device_code`](LoginSeam::begin_device_code) and [`poll`](LoginSeam::poll).
pub struct HolgerLoginSeam {
    cfg: LoginConfig,
    store: FileTokenStore,
    /// The in-flight device grant (set by `begin`, cleared on success/sign-out).
    device: Option<device::DeviceCode>,
}

impl HolgerLoginSeam {
    /// Build the seam from the `HOLGER_OIDC_*` env config + the default
    /// `~/.holger/auth-token.json` file store (the operator UI's live sign-in).
    pub fn from_env() -> Self {
        Self {
            cfg: login_config_from_env(),
            store: FileTokenStore::for_app(APP),
            device: None,
        }
    }

    /// Build over an explicit config + store — the seam a test points at a stub IdP
    /// + a temp-dir token file (no real network, no `~/.holger` write).
    pub fn with_config_and_store(cfg: LoginConfig, store: FileTokenStore) -> Self {
        Self { cfg, store, device: None }
    }

    /// The NAMES (never values) of the credentials held in the stored bundle:
    /// `access_token` always (a token is present), `refresh_token` only when the IdP
    /// issued one (`offline_access`). Empty when signed out. The presentational
    /// `credential_names` surface — the pane renders these under the identity.
    fn stored_credential_names(&self) -> Vec<String> {
        match self.store.load() {
            Ok(Some(bundle)) => {
                let mut names = vec!["access_token".to_string()];
                if bundle.refresh_token.is_some() {
                    names.push("refresh_token".to_string());
                }
                names
            }
            _ => Vec::new(),
        }
    }
}

impl LoginSeam for HolgerLoginSeam {
    fn source(&self) -> &str {
        "nornir oidc"
    }

    fn begin_device_code(&mut self) -> LoginState {
        let cfg = self.cfg.clone();
        let started = runtime().and_then(|rt| {
            rt.block_on(async {
                device::request_device_code(&cfg.device_auth_url, &cfg.client_id, &cfg.scope)
                    .await
                    .map_err(std::io::Error::other)
            })
        });
        match started {
            Ok(dc) => {
                let state = LoginState::AwaitingUser {
                    user_code: dc.user_code.clone(),
                    verification_uri: dc.verification_uri.clone(),
                };
                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 cfg = self.cfg.clone();
        let polled = runtime().and_then(|rt| {
            rt.block_on(async {
                device::poll_token_full(&cfg.token_url, &cfg.client_id, &dc.device_code)
                    .await
                    .map_err(std::io::Error::other)
            })
        });
        match polled {
            // Approved: build + persist the bundle, resolve the display identity.
            Ok(Some(tr)) => {
                let bundle = TokenBundle::from_response(tr, now_unix(), None);
                if let Err(e) = self.store.save(&bundle) {
                    return LoginState::Error(format!("could not save the session: {e}"));
                }
                self.device = None;
                let subject = bundle
                    .subject
                    .clone()
                    .or_else(|| subject_of(&bundle.access_token))
                    .unwrap_or_else(|| "<signed in — identity not in token>".to_string());
                LoginState::SignedIn { subject }
            }
            // authorization_pending / slow_down — keep showing the prompt.
            Ok(None) => LoginState::AwaitingUser {
                user_code: dc.user_code,
                verification_uri: dc.verification_uri,
            },
            Err(e) => LoginState::Error(format!("sign-in failed: {e}")),
        }
    }

    fn current_identity(&self) -> Option<String> {
        // No network: read a non-expired cached token, decode its subject.
        let token = cached_token(&self.store).ok().flatten()?;
        subject_of(&token)
    }

    fn sign_out(&mut self) -> LoginState {
        let _ = self.store.clear();
        self.device = None;
        LoginState::SignedOut
    }

    fn credential_names(&self) -> Vec<String> {
        self.stored_credential_names()
    }
}

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

    /// `subject_of` reads the display identity from a JWT (preferred_username →
    /// email → sub) and returns `None` for an opaque token — RED if the precedence
    /// drifts or a token value ever became the identity.
    #[test]
    fn subject_of_reads_jwt_identity() {
        use base64::Engine;
        let jwt = |payload: serde_json::Value| {
            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())
            )
        };
        let t = jwt(serde_json::json!({ "sub": "u1", "preferred_username": "rickard", "email": "r@x14.se" }));
        assert_eq!(subject_of(&t).as_deref(), Some("rickard"));
        let t = jwt(serde_json::json!({ "sub": "u2", "email": "r@x14.se" }));
        assert_eq!(subject_of(&t).as_deref(), Some("r@x14.se"));
        assert_eq!(subject_of("opaque-not-a-jwt"), None);
    }

    /// `login_config_from_env` honours the `HOLGER_OIDC_*` overrides and otherwise
    /// uses the documented Keycloak-dev placeholders (never a hardcoded secret).
    #[test]
    fn login_config_reads_env_then_placeholders() {
        // SAFETY: single-threaded test owns these process-global vars; set + removed here.
        unsafe {
            for k in [
                "HOLGER_OIDC_DEVICE_AUTH_URL",
                "HOLGER_OIDC_TOKEN_URL",
                "HOLGER_OIDC_CLIENT_ID",
                "HOLGER_OIDC_SCOPE",
            ] {
                std::env::remove_var(k);
            }
        }
        let cfg = login_config_from_env();
        assert_eq!(cfg.device_auth_url, DEFAULT_DEVICE_AUTH_URL);
        assert_eq!(cfg.client_id, DEFAULT_CLIENT_ID);

        unsafe { std::env::set_var("HOLGER_OIDC_CLIENT_ID", "holger-server-ui-override") };
        assert_eq!(login_config_from_env().client_id, "holger-server-ui-override");
        unsafe { std::env::remove_var("HOLGER_OIDC_CLIENT_ID") };
    }

    /// `sign_out` + `credential_names` are store-driven and expose NAMES only: a
    /// saved bundle yields `access_token` (+`refresh_token` when present); clearing
    /// removes it. RED if a value ever leaked into the names or a clear left a token.
    #[test]
    fn credential_names_are_store_driven_names_only() {
        let dir = tempfile::tempdir().unwrap();
        let store = FileTokenStore::at(dir.path().join("auth-token.json"));
        store
            .save(&TokenBundle {
                access_token: "tok_SECRET".into(),
                refresh_token: Some("rt".into()),
                expires_at_unix: Some(now_unix() + 3600),
                subject: None,
            })
            .unwrap();

        let mut seam = HolgerLoginSeam::with_config_and_store(login_config_from_env(), store);
        let names = seam.credential_names();
        assert_eq!(names, vec!["access_token".to_string(), "refresh_token".to_string()]);
        // NAMES only — the secret value is never a name.
        assert!(!names.iter().any(|n| n.contains("SECRET")));

        assert_eq!(seam.sign_out(), LoginState::SignedOut);
        assert!(seam.credential_names().is_empty(), "clear removes the credentials");
    }
}