//! **`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");
}
}