Skip to main content

solo_api/auth/
bearer.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Bearer-token validation. Forward port of v0.7.x's
4//! `ValidateRequestHeaderLayer::custom(BearerToken::new(token))` flow,
5//! re-shaped to emit an [`AuthenticatedPrincipal`] so downstream layers
6//! (audit log, tenant extractor) can treat bearer + OIDC identically.
7
8use super::{AuthError, AuthenticatedPrincipal};
9use solo_core::TenantId;
10
11/// Stateless validator. Compare the `Authorization: Bearer <token>`
12/// header against the expected token; on match, return the daemon's
13/// default tenant as the principal's `tenant_claim`.
14///
15/// Dev-log 0152 M11: the token comparison is constant-time over the
16/// length of `expected_token`. Length differences ARE observable — but
17/// length-leak alone doesn't recover the token (32-byte recommended
18/// minimum entropy). The byte-comparison accumulator pattern below
19/// avoids adding a `subtle` crate dependency for this single use.
20#[derive(Debug, Clone)]
21pub struct BearerValidator {
22    expected_token: String,
23    default_tenant: TenantId,
24}
25
26impl BearerValidator {
27    pub fn new(expected_token: String, default_tenant: TenantId) -> Self {
28        Self {
29            expected_token,
30            default_tenant,
31        }
32    }
33
34    /// Validate the value of an Authorization header. `None` is treated
35    /// as a missing header (same as malformed).
36    pub fn validate(&self, header: Option<&str>) -> Result<AuthenticatedPrincipal, AuthError> {
37        let header = header.ok_or(AuthError::MissingAuthHeader)?;
38        let token = header
39            .strip_prefix("Bearer ")
40            .ok_or(AuthError::MalformedAuthHeader)?;
41        if !constant_time_eq(token.as_bytes(), self.expected_token.as_bytes()) {
42            return Err(AuthError::InvalidBearer);
43        }
44        Ok(AuthenticatedPrincipal::bearer(self.default_tenant.clone()))
45    }
46}
47
48/// Constant-time byte-slice equality (dev-log 0152 M11, dev-log 0154
49/// post-fix tightening). Runs in time proportional to `expected.len()`
50/// regardless of how many bytes match. Length mismatch returns false
51/// immediately — length-leak is acceptable for ≥32-byte tokens; the
52/// secret bytes are protected.
53///
54/// The `std::hint::black_box` calls prevent the optimiser from
55/// unrolling the loop into data-dependent branches or short-circuiting
56/// the accumulator. We don't pull in the `subtle` crate (the only other
57/// constant-time primitive Solo needs is this one) but black-boxing
58/// the inputs + the accumulator achieves the same effect at the IR
59/// level. A future hardening could swap in `subtle::ConstantTimeEq` if
60/// other comparison sites appear.
61fn constant_time_eq(actual: &[u8], expected: &[u8]) -> bool {
62    if actual.len() != expected.len() {
63        return false;
64    }
65    let mut diff: u8 = 0;
66    for (a, e) in actual.iter().zip(expected.iter()) {
67        // black_box on both operands prevents the optimiser from
68        // reordering or short-circuiting based on early matches.
69        let a = std::hint::black_box(*a);
70        let e = std::hint::black_box(*e);
71        diff |= a ^ e;
72    }
73    std::hint::black_box(diff) == 0
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn validator() -> BearerValidator {
81        BearerValidator::new("s3cr3t".to_string(), TenantId::default_tenant())
82    }
83
84    #[test]
85    fn accepts_correct_token() {
86        let v = validator();
87        let p = v.validate(Some("Bearer s3cr3t")).expect("accept");
88        assert_eq!(p.subject, "bearer");
89        assert_eq!(p.tenant_claim, Some(TenantId::default_tenant()));
90        assert!(p.scopes.is_empty());
91        assert!(p.claims.is_null());
92    }
93
94    #[test]
95    fn rejects_missing_header() {
96        let v = validator();
97        let err = v.validate(None).unwrap_err();
98        assert!(matches!(err, AuthError::MissingAuthHeader), "got {err:?}");
99    }
100
101    #[test]
102    fn rejects_malformed_header_no_bearer_prefix() {
103        let v = validator();
104        // No "Bearer " prefix — looks like a Basic auth header.
105        let err = v.validate(Some("Basic s3cr3t")).unwrap_err();
106        assert!(matches!(err, AuthError::MalformedAuthHeader), "got {err:?}");
107        // No prefix at all.
108        let err = v.validate(Some("s3cr3t")).unwrap_err();
109        assert!(matches!(err, AuthError::MalformedAuthHeader), "got {err:?}");
110    }
111
112    #[test]
113    fn rejects_wrong_token() {
114        let v = validator();
115        let err = v.validate(Some("Bearer wrong-token")).unwrap_err();
116        assert!(matches!(err, AuthError::InvalidBearer), "got {err:?}");
117    }
118}