use std::collections::{BTreeMap, VecDeque};
use std::fmt::Write as _;
use std::sync::Mutex;
use crate::token::TokenError;
pub const RECENT_CAPACITY: usize = 64;
pub mod reason {
pub const MISSING_CREDENTIAL: &str = "missing_credential";
pub const INVALID_PREFIX: &str = "invalid_prefix";
pub const MALFORMED: &str = "malformed";
pub const SIGNATURE_INVALID: &str = "signature_invalid";
pub const ISSUER_SECRET_UNSET: &str = "issuer_secret_unset";
pub const EXPIRED: &str = "expired";
pub const REVOKED: &str = "revoked";
pub const MISSING_RECORD: &str = "missing_record";
pub const BINDING_MISMATCH: &str = "binding_mismatch";
pub const REQUEST_BUDGET: &str = "request_budget";
pub const TOKEN_BUDGET: &str = "token_budget";
pub const RATE_LIMIT: &str = "rate_limit";
pub const MODEL_POLICY: &str = "model_policy";
pub const INSUFFICIENT_SCOPE: &str = "insufficient_scope";
pub const RUN_LEASE_UNRENEWABLE: &str = "run_lease_unrenewable";
pub const UNSUPPORTED_LEASE_ENDPOINT: &str = "unsupported_lease_endpoint";
pub const STORAGE: &str = "storage";
pub const NOT_FOUND: &str = "not_found";
}
impl TokenError {
#[must_use]
pub const fn reason_code(&self) -> &'static str {
match self {
Self::InvalidPrefix => reason::INVALID_PREFIX,
Self::Invalid(_) => reason::MALFORMED,
Self::SignatureInvalid => reason::SIGNATURE_INVALID,
Self::IssuerSecretUnset => reason::ISSUER_SECRET_UNSET,
Self::Expired(_) => reason::EXPIRED,
Self::Revoked => reason::REVOKED,
Self::MissingRecord => reason::MISSING_RECORD,
Self::BindingMismatch => reason::BINDING_MISMATCH,
Self::NotFound(_) => reason::NOT_FOUND,
Self::InsufficientScope => reason::INSUFFICIENT_SCOPE,
Self::LimitExceeded(_) => reason::REQUEST_BUDGET,
Self::TokenLimitExceeded(_) => reason::TOKEN_BUDGET,
Self::RateLimitExceeded => reason::RATE_LIMIT,
Self::Storage(_) => reason::STORAGE,
}
}
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct AuthFailure {
pub at: i64,
pub reason: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_id: Option<String>,
}
#[derive(Debug, Default)]
pub struct AuthDiagnostics {
counts: Mutex<BTreeMap<&'static str, u64>>,
recent: Mutex<VecDeque<AuthFailure>>,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct AuthDiagnosticsSnapshot {
pub failures_by_reason: BTreeMap<&'static str, u64>,
pub recent_failures: Vec<AuthFailure>,
}
impl AuthDiagnostics {
pub fn record(&self, reason: &'static str, token: Option<&str>) {
let fingerprint = token.map(crate::emergency_auth::token_fingerprint);
let token_id = token.and_then(unverified_subject);
tracing::debug!(
reason,
fingerprint = fingerprint.as_deref().unwrap_or("-"),
token_id = token_id.as_deref().unwrap_or("-"),
"Router token authentication failed"
);
if let Ok(mut counts) = self.counts.lock() {
*counts.entry(reason).or_default() += 1;
}
if let Ok(mut recent) = self.recent.lock() {
if recent.len() == RECENT_CAPACITY {
recent.pop_front();
}
recent.push_back(AuthFailure {
at: chrono::Utc::now().timestamp(),
reason,
fingerprint,
token_id,
});
}
}
pub fn record_error(&self, error: &TokenError, token: Option<&str>) {
self.record(error.reason_code(), token);
}
#[must_use]
pub fn snapshot(&self) -> AuthDiagnosticsSnapshot {
AuthDiagnosticsSnapshot {
failures_by_reason: self
.counts
.lock()
.map(|counts| counts.clone())
.unwrap_or_default(),
recent_failures: self
.recent
.lock()
.map(|recent| recent.iter().rev().cloned().collect())
.unwrap_or_default(),
}
}
#[must_use]
pub fn render_prometheus(&self) -> String {
let mut body = String::from(
"# HELP link_assistant_auth_failures_total Router token authentication failures by reason.\n\
# TYPE link_assistant_auth_failures_total counter\n",
);
let snapshot = self.snapshot();
if snapshot.failures_by_reason.is_empty() {
body.push_str("link_assistant_auth_failures_total 0\n");
}
for (reason, count) in &snapshot.failures_by_reason {
let _ = writeln!(
body,
"link_assistant_auth_failures_total{{reason=\"{reason}\"}} {count}"
);
}
body
}
}
tokio::task_local! {
static OUTCOME_COUNTED: std::cell::Cell<bool>;
}
pub async fn scope_request<F: std::future::Future>(request: F) -> F::Output {
OUTCOME_COUNTED
.scope(std::cell::Cell::new(false), request)
.await
}
#[must_use]
pub fn first_outcome_in_request() -> bool {
OUTCOME_COUNTED
.try_with(|counted| !counted.replace(true))
.unwrap_or(true)
}
fn unverified_subject(token: &str) -> Option<String> {
use base64::Engine;
let jwt = crate::token::token_jwt(token)?;
let payload = jwt.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let sub = value.get("sub")?.as_str()?;
uuid::Uuid::parse_str(sub).ok().map(|id| id.to_string())
}
#[cfg(test)]
#[path = "auth_diagnostics_tests.rs"]
mod tests;