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}