solo-api 0.11.0

Solo: MCP and HTTP transports
Documentation
// SPDX-License-Identifier: Apache-2.0

//! Bearer-token validation. Forward port of v0.7.x's
//! `ValidateRequestHeaderLayer::custom(BearerToken::new(token))` flow,
//! re-shaped to emit an [`AuthenticatedPrincipal`] so downstream layers
//! (audit log, tenant extractor) can treat bearer + OIDC identically.

use super::{AuthError, AuthenticatedPrincipal};
use solo_core::TenantId;

/// Stateless validator. Compare the `Authorization: Bearer <token>`
/// header against the expected token; on match, return the daemon's
/// default tenant as the principal's `tenant_claim`.
///
/// Constant-time comparison is intentional but the implementation
/// relies on Rust's `&[u8] == &[u8]` (which short-circuits on length
/// mismatch). For Solo's threat model — local-machine + LAN bearer —
/// the timing-attack surface is negligible (no network adversary
/// timing thousands of requests). A future hardening pass could pull
/// in the `subtle` crate; not blocking v0.8.0.
#[derive(Debug, Clone)]
pub struct BearerValidator {
    expected_token: String,
    default_tenant: TenantId,
}

impl BearerValidator {
    pub fn new(expected_token: String, default_tenant: TenantId) -> Self {
        Self {
            expected_token,
            default_tenant,
        }
    }

    /// Validate the value of an Authorization header. `None` is treated
    /// as a missing header (same as malformed).
    pub fn validate(&self, header: Option<&str>) -> Result<AuthenticatedPrincipal, AuthError> {
        let header = header.ok_or(AuthError::MissingAuthHeader)?;
        let token = header
            .strip_prefix("Bearer ")
            .ok_or(AuthError::MalformedAuthHeader)?;
        if token != self.expected_token {
            return Err(AuthError::InvalidBearer);
        }
        Ok(AuthenticatedPrincipal::bearer(self.default_tenant.clone()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn validator() -> BearerValidator {
        BearerValidator::new("s3cr3t".to_string(), TenantId::default_tenant())
    }

    #[test]
    fn accepts_correct_token() {
        let v = validator();
        let p = v.validate(Some("Bearer s3cr3t")).expect("accept");
        assert_eq!(p.subject, "bearer");
        assert_eq!(p.tenant_claim, Some(TenantId::default_tenant()));
        assert!(p.scopes.is_empty());
        assert!(p.claims.is_null());
    }

    #[test]
    fn rejects_missing_header() {
        let v = validator();
        let err = v.validate(None).unwrap_err();
        assert!(matches!(err, AuthError::MissingAuthHeader), "got {err:?}");
    }

    #[test]
    fn rejects_malformed_header_no_bearer_prefix() {
        let v = validator();
        // No "Bearer " prefix — looks like a Basic auth header.
        let err = v.validate(Some("Basic s3cr3t")).unwrap_err();
        assert!(matches!(err, AuthError::MalformedAuthHeader), "got {err:?}");
        // No prefix at all.
        let err = v.validate(Some("s3cr3t")).unwrap_err();
        assert!(matches!(err, AuthError::MalformedAuthHeader), "got {err:?}");
    }

    #[test]
    fn rejects_wrong_token() {
        let v = validator();
        let err = v.validate(Some("Bearer wrong-token")).unwrap_err();
        assert!(matches!(err, AuthError::InvalidBearer), "got {err:?}");
    }
}