src/login.rs
//! **`nornir login`** — interactive OIDC single-sign-on for the nornir CLI, built on the
//! SHARED [`nornir-auth-client`](nornir_auth_client) (edda) so login works identically
//! in `nornir`, `korp`, `holger`, and `dwarves` instead of each hand-rolling a device-code
//! loop (L5 — reuse, never twin). This is the CLIENT side of holger-style auth: a nornir
//! client can obtain an OIDC access token via the RFC 8628 device-authorization grant
//! instead of only a static `~/.nornir/token`.
//!
//! Everything substantial is *consumed* — the device-code flow, silent refresh, and the
//! `TokenStore`/`TokenBundle`/`LoginConfig` types are the shared crate's; this module never
//! re-implements them. nornir plugs the client's shipped default [`FileTokenStore`] in
//! behind the object-safe [`TokenStore`](nornir_auth_client::TokenStore) seam: the acquired
//! token lives in a 0600 `~/.nornir/auth-token.json` file (a same-host, owner-only
//! convenience store, alongside — never replacing — the legacy `~/.nornir/token`).
//!
//! What lives here: the CLI glue ([`cli_login`] / [`cli_whoami`] / [`cli_logout`] + their
//! blocking wrappers), config from `NORNIR_OIDC_*` env ([`login_config_from_env`]), a
//! display-only [`subject_of`] JWT reader for `whoami`, and [`cached_oidc_token`] — the
//! no-network read the client bearer-resolution prefers over the static token file.
use anyhow::{Context, Result};
// `TokenStore` is in scope for `cli_logout`'s `store.clear()` (the trait method).
use nornir_auth_client::{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 `NORNIR_OIDC_*` env vars.
/// Default device-authorization endpoint (`$NORNIR_OIDC_DEVICE_AUTH_URL`).
pub const DEFAULT_DEVICE_AUTH_URL: &str =
"http://localhost:8080/realms/nordisk/protocol/openid-connect/auth/device";
/// Default token endpoint (`$NORNIR_OIDC_TOKEN_URL`).
pub const DEFAULT_TOKEN_URL: &str =
"http://localhost:8080/realms/nordisk/protocol/openid-connect/token";
/// Default public client id for the CLI (`$NORNIR_OIDC_CLIENT_ID`). A PUBLIC client id
/// (not a secret) — the device-code grant uses no client secret.
pub const DEFAULT_CLIENT_ID: &str = "nornir-cli";
/// Default requested scope (`$NORNIR_OIDC_SCOPE`). `offline_access` is what makes the
/// IdP return a refresh token so `whoami` can silently refresh.
pub const DEFAULT_SCOPE: &str = "openid profile offline_access";
/// The per-app store name — nornir's default 0600 file lives at `~/.nornir/auth-token.json`.
pub const APP: &str = "nornir";
/// Build a [`LoginConfig`] from the `NORNIR_OIDC_*` env vars, falling back to the
/// Keycloak-dev-shaped placeholders above. Documented knobs:
///
/// | env | meaning | default |
/// |-----|---------|---------|
/// | `NORNIR_OIDC_DEVICE_AUTH_URL` | RFC 8628 device-authorization endpoint | [`DEFAULT_DEVICE_AUTH_URL`] |
/// | `NORNIR_OIDC_TOKEN_URL` | token endpoint (poll + refresh) | [`DEFAULT_TOKEN_URL`] |
/// | `NORNIR_OIDC_CLIENT_ID` | public CLI client id | [`DEFAULT_CLIENT_ID`] |
/// | `NORNIR_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("NORNIR_OIDC_DEVICE_AUTH_URL", DEFAULT_DEVICE_AUTH_URL),
token_url: var("NORNIR_OIDC_TOKEN_URL", DEFAULT_TOKEN_URL),
client_id: var("NORNIR_OIDC_CLIENT_ID", DEFAULT_CLIENT_ID),
scope: var("NORNIR_OIDC_SCOPE", DEFAULT_SCOPE),
}
}
/// nornir's default login store: the client's shipped 0600 [`FileTokenStore`] at
/// `~/.nornir/auth-token.json`. Separate from — and preferred over — the legacy static
/// `~/.nornir/token` (same home + owner-only convention).
pub fn default_store() -> FileTokenStore {
FileTokenStore::for_app(APP)
}
// ── a display-only subject reader ─────────────────────────────────────────────
/// Best-effort extract of a human identity from an access token for DISPLAY only
/// (`whoami`). 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.
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
}
/// The identity to show for a `bundle`: its recorded `subject`, else the subject decoded
/// from the access token, else a non-empty placeholder (a token is present but carries no
/// readable identity — never claim "signed out" when a token exists).
pub fn identity_of(bundle: &TokenBundle) -> String {
bundle
.subject
.clone()
.or_else(|| subject_of(&bundle.access_token))
.unwrap_or_else(|| "<signed in — identity not in token>".to_string())
}
// ── the cached-token read the client bearer-resolution prefers ───────────────
/// The cached OIDC access token from `nornir login`, read WITHOUT any network via
/// `nornir_auth_client::cached_token` over the default 0600 file store. `None` when
/// nothing is signed in or the cached token has expired. This is what the CLI / saga /
/// MCP client bearer resolution PREFERS over the static `~/.nornir/token` (see
/// [`crate::cached_oidc_bearer`], which forwards here and is compiled even without the
/// `login` feature).
pub fn cached_oidc_token() -> Option<String> {
nornir_auth_client::cached_token(&default_store())
.ok()
.flatten()
}
// ── CLI glue (the default file store) ────────────────────────────────────────
/// Run the device-code login and persist the token in the default file store. Prints the
/// verification URL + user code (the RFC 8628 `on_prompt`) to stderr. Returns the acquired
/// [`TokenBundle`].
pub async fn cli_login(cfg: &LoginConfig) -> Result<TokenBundle> {
let store = default_store();
nornir_auth_client::login(cfg, &store, |dc| {
eprintln!("To sign in, open: {}", dc.verification_uri);
eprintln!("and enter code: {}", dc.user_code);
if let Some(complete) = &dc.verification_uri_complete {
eprintln!("(or open the pre-filled URL: {complete})");
}
eprintln!("Waiting for approval…");
})
.await
.context("device-code login failed")
}
/// The current signed-in identity, silently refreshing an expired token if a refresh token
/// is held. `Ok(None)` when nothing is stored / the session cannot be renewed.
pub async fn cli_whoami(cfg: &LoginConfig) -> Result<Option<String>> {
let store = default_store();
match nornir_auth_client::current_token(cfg, &store).await? {
Some(token) => Ok(Some(
subject_of(&token).unwrap_or_else(|| "<token present>".to_string()),
)),
None => Ok(None),
}
}
/// Remove the stored login token (`nornir logout`). Idempotent.
pub fn cli_logout() -> Result<()> {
default_store().clear()
}
/// Blocking [`cli_login`] for the synchronous CLI handler: builds a current-thread tokio
/// runtime and runs the async device-code flow to completion.
pub fn blocking_login(cfg: &LoginConfig) -> Result<TokenBundle> {
runtime()?.block_on(cli_login(cfg))
}
/// Blocking [`cli_whoami`] for the synchronous CLI handler.
pub fn blocking_whoami(cfg: &LoginConfig) -> Result<Option<String>> {
runtime()?.block_on(cli_whoami(cfg))
}
/// A current-thread tokio runtime for the blocking wrappers.
fn runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("start the async runtime for the login flow")
}
#[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 claim precedence drifts.
#[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!(
"{}.{}.{}",
b64(b"{}"),
b64(payload.to_string().as_bytes()),
b64(b"sig")
)
};
let t = jwt(
serde_json::json!({ "sub": "uid-1", "preferred_username": "alice", "email": "a@example.com" }),
);
assert_eq!(subject_of(&t).as_deref(), Some("alice"));
let t = jwt(serde_json::json!({ "sub": "uid-2", "email": "a@example.com" }));
assert_eq!(subject_of(&t).as_deref(), Some("a@example.com"));
let t = jwt(serde_json::json!({ "sub": "uid-3" }));
assert_eq!(subject_of(&t).as_deref(), Some("uid-3"));
assert_eq!(subject_of("opaque-not-a-jwt"), None);
}
/// `login_config_from_env` honours the `NORNIR_OIDC_*` overrides and otherwise uses the
/// documented Keycloak-dev-shaped placeholders (never a hardcoded secret — the
/// device-code grant carries no client 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 [
"NORNIR_OIDC_DEVICE_AUTH_URL",
"NORNIR_OIDC_TOKEN_URL",
"NORNIR_OIDC_CLIENT_ID",
"NORNIR_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.token_url, DEFAULT_TOKEN_URL);
assert_eq!(cfg.client_id, DEFAULT_CLIENT_ID);
assert_eq!(cfg.scope, DEFAULT_SCOPE);
unsafe { std::env::set_var("NORNIR_OIDC_CLIENT_ID", "nornir-cli-override") };
assert_eq!(login_config_from_env().client_id, "nornir-cli-override");
unsafe { std::env::remove_var("NORNIR_OIDC_CLIENT_ID") };
}
}