crates/nornir-service/src/client.rs
//! # Client-side bearer stamping — the ONE outbound `Authorization` builder.
//!
//! The server half of the bearer contract already lives in this crate
//! ([`auth_interceptor`](crate::auth_interceptor) + [`token_matches`](crate::token_matches)).
//! This module is its **client** half: the single place a nordisk client turns a
//! resolved token into the `authorization: Bearer <token>` [`MetadataValue`] it
//! stamps onto an outbound tonic request.
//!
//! ## Why it returns a `Result<Option<…>, BearerError>` and not a fixed error
//!
//! The auth-dedup audit found the same three-line stamp
//! (`format!("Bearer {token}").parse()…insert("authorization", …)`) copied across
//! the leaf embed crates (`nornir-warehouse-embed`, `nornir-funnel-embed`,
//! `dwarves-embed`) and nornir's own client paths — each building the value
//! **eagerly** but mapping the parse failure to a *different* error type:
//! `anyhow::Context`, `BuildErr::AuthFailed`, `WarehouseReadError::AuthFailed`.
//! A helper that returned one fixed error could not serve them all, so the seam
//! returns the **outcome** and lets each caller `.map_err`/`.context` it into its
//! own error:
//!
//! * `Ok(Some(value))` — a non-empty token parsed into a header value.
//! * `Ok(None)` — the token is empty ⇒ **open / no header** (the constellation
//! contract: absent token ⇒ no `Authorization`, matching the dwarves
//! `thin_client::attach_bearer` sibling and nornir's warehouse remote). The
//! caller stamps the header only on `Some`.
//! * `Err(BearerError)` — a non-empty token contained bytes not valid in an HTTP
//! header value. The caller maps this to its own error variant.
//!
//! The leaf crates cannot depend on `dwarves` (it depends on *them*), so this
//! shared home lives here in edda, the crate they already consume.
use std::fmt;
use tonic::metadata::{Ascii, MetadataValue};
/// A non-empty token could not be encoded as an `authorization` header value —
/// it held bytes not permitted in an HTTP header (control chars, non-visible /
/// non-ASCII bytes). Carries the underlying tonic message so a caller's
/// `.context(...)` / `format!("…: {e}")` reads exactly as the hand-rolled
/// `.parse().context("parse bearer token")?` twins it replaces did.
///
/// Implements [`std::error::Error`] + [`Display`](fmt::Display) so every caller's
/// mapping works unchanged: anyhow sites `?`/`.context` it, and the
/// `AuthFailed(String)` sites `format!("…: {e}")` it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BearerError(String);
impl BearerError {
/// The underlying tonic parse-error message.
pub fn message(&self) -> &str {
&self.0
}
}
impl fmt::Display for BearerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Faithful to the raw `InvalidMetadataValue` Display the twins surfaced,
// so wrapping messages are byte-identical to before the dedup.
write!(f, "{}", self.0)
}
}
impl std::error::Error for BearerError {}
/// Build the outbound `authorization: Bearer <token>` header value.
///
/// * empty `token` ⇒ `Ok(None)` — open / no auth header (dev / single-workspace).
/// * non-empty `token` ⇒ `Ok(Some(value))` where `value` is `Bearer <token>`.
/// * non-empty but unencodable `token` ⇒ `Err(BearerError)`.
///
/// The caller stamps the header only when `Some`, so an absent token leaves the
/// request open — the exact contract the server-side
/// [`auth_interceptor`](crate::auth_interceptor) pairs with (empty configured
/// token ⇒ open) and the same shape as the dwarves `thin_client::attach_bearer`
/// sibling.
pub fn bearer(token: &str) -> Result<Option<MetadataValue<Ascii>>, BearerError> {
if token.is_empty() {
return Ok(None);
}
format!("Bearer {token}")
.parse::<MetadataValue<Ascii>>()
.map(Some)
.map_err(|e| BearerError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
/// RED-WHEN-BROKEN: a present token yields a `Bearer <token>` header value;
/// an empty/absent token yields `None` (open — no header). If the seam stops
/// stamping, or starts emitting a `Bearer ` with an empty secret, one of
/// these flips.
#[test]
fn present_token_builds_header_absent_token_is_none() {
let v = bearer("tok-abc123").expect("valid token must parse");
let v = v.expect("a non-empty token must produce a header value");
assert_eq!(v.to_str().unwrap(), "Bearer tok-abc123");
// empty ⇒ open, never a `Bearer ` with an empty secret.
assert_eq!(bearer(""), Ok(None), "empty token ⇒ no header");
}
/// RED-WHEN-BROKEN: a non-empty token that cannot be encoded as an HTTP
/// header value surfaces as `Err(BearerError)` (not a silent drop), so each
/// caller can map it to its own error type. A newline is not a legal
/// metadata-value byte.
#[test]
fn unencodable_token_is_err() {
let e = bearer("bad\ntoken").expect_err("a newline token must not encode");
// Display is non-empty and usable in a caller's `format!("…: {e}")`.
assert!(!e.to_string().is_empty());
}
}