Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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