1use std::fmt;
8use std::sync::Arc;
9
10use sha2::{Digest, Sha256};
11use subtle::ConstantTimeEq;
12
13use crate::config::{AuthConfig, Role};
14
15pub const MIN_TOKEN_LEN: usize = 16;
17pub const RECOMMENDED_TOKEN_LEN: usize = 32;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Principal {
23 pub role: Role,
25 pub pools: Option<Arc<[Arc<str>]>>,
27 pub labels: Option<cronoq::Labels>,
30 pub tenant: Option<Arc<str>>,
32 pub bearer: Option<Bearer>,
35 pub forwarded: bool,
37}
38
39#[derive(Clone, PartialEq, Eq)]
41pub struct Bearer(Arc<str>);
42
43impl Bearer {
44 #[cfg(test)]
45 pub(crate) fn for_tests(header: &str) -> Bearer {
46 Bearer(header.into())
47 }
48
49 pub fn header(&self) -> &str {
51 &self.0
52 }
53}
54
55impl std::fmt::Debug for Bearer {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.write_str("Bearer(<redacted>)")
58 }
59}
60
61pub const FORWARDED_HEADER: &str = "cronoq-forwarded";
63
64impl Principal {
65 pub fn anonymous_admin() -> Principal {
67 Principal {
68 role: Role::Admin,
69 pools: None,
70 labels: None,
71 tenant: None,
72 bearer: None,
73 forwarded: false,
74 }
75 }
76
77 pub fn has_role(&self, required: Role) -> bool {
79 self.role == Role::Admin || self.role == required
80 }
81
82 pub fn may_claim_labels(&self, labels: &[(Arc<str>, Arc<str>)]) -> bool {
84 self.labels
85 .as_ref()
86 .is_none_or(|allowed| labels.iter().all(|l| allowed.contains(l)))
87 }
88
89 pub fn may_use_pool(&self, pool: &str) -> bool {
91 self.pools
92 .as_ref()
93 .is_none_or(|ps| ps.iter().any(|p| &**p == pool))
94 }
95}
96
97struct TokenEntry {
98 digest: [u8; 32],
99 principal: Principal,
100}
101
102struct CertEntry {
103 dns_name: String,
104 principal: Principal,
105}
106
107#[derive(Clone, Default)]
110pub struct Auth {
111 tokens: Arc<Vec<TokenEntry>>,
112 certs: Arc<Vec<CertEntry>>,
113}
114
115impl fmt::Debug for Auth {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 f.debug_struct("Auth")
118 .field("tokens", &self.tokens.len())
119 .field("cert_mappings", &self.certs.len())
120 .finish()
121 }
122}
123
124fn labels(v: &Option<Vec<String>>) -> anyhow::Result<Option<cronoq::Labels>> {
126 let Some(v) = v else { return Ok(None) };
127 v.iter()
128 .map(|l| match l.split_once('=') {
129 Some((k, val)) if !k.is_empty() => Ok((Arc::from(k), Arc::from(val))),
130 _ => Err(anyhow::anyhow!(
131 "auth label '{l}' must have the form name=value"
132 )),
133 })
134 .collect::<anyhow::Result<Vec<_>>>()
135 .map(|v| Some(v.into()))
136}
137
138fn digest(token: &str) -> [u8; 32] {
139 Sha256::digest(token.as_bytes()).into()
140}
141
142fn pools(list: &Option<Vec<String>>) -> Option<Arc<[Arc<str>]>> {
143 list.as_ref()
144 .map(|ps| ps.iter().map(|p| Arc::from(p.as_str())).collect())
145}
146
147impl Auth {
148 pub fn from_config(cfg: &AuthConfig) -> anyhow::Result<Auth> {
150 let mut tokens: Vec<TokenEntry> = Vec::new();
151 for t in &cfg.tokens {
152 let value = match (&t.token, &t.token_env) {
153 (Some(v), _) => v.clone(),
154 (None, Some(env)) => std::env::var(env).map_err(|_| {
155 anyhow::anyhow!("environment variable {env} (auth token) is not set")
156 })?,
157 (None, None) => anyhow::bail!("auth token needs `token` or `token_env`"),
158 };
159 let value = value.trim().to_string();
160 anyhow::ensure!(
161 value.len() >= MIN_TOKEN_LEN,
162 "auth tokens must be at least {MIN_TOKEN_LEN} characters ({} role token is shorter)",
163 t.role.name()
164 );
165 if value.len() < RECOMMENDED_TOKEN_LEN {
166 tracing::warn!(
167 role = t.role.name(),
168 "auth token is shorter than {RECOMMENDED_TOKEN_LEN} characters; use at least 32 random characters"
169 );
170 }
171 let d = digest(&value);
172 anyhow::ensure!(
173 !tokens.iter().any(|e| bool::from(e.digest.ct_eq(&d))),
174 "duplicate auth token (each token must be unique)"
175 );
176 tokens.push(TokenEntry {
177 digest: d,
178 principal: Principal {
179 role: t.role,
180 pools: pools(&t.pools),
181 labels: labels(&t.labels)?,
182 tenant: t.tenant.as_deref().map(Arc::from),
183 bearer: None,
184 forwarded: false,
185 },
186 });
187 }
188 let certs = cfg
189 .certs
190 .iter()
191 .map(|c| {
192 Ok(CertEntry {
193 dns_name: c.dns_name.to_ascii_lowercase(),
194 principal: Principal {
195 role: c.role,
196 pools: pools(&c.pools),
197 labels: labels(&c.labels)?,
198 tenant: c.tenant.as_deref().map(Arc::from),
199 bearer: None,
200 forwarded: false,
201 },
202 })
203 })
204 .collect::<anyhow::Result<_>>()?;
205 Ok(Auth {
206 tokens: Arc::new(tokens),
207 certs: Arc::new(certs),
208 })
209 }
210
211 pub fn disabled() -> Auth {
213 Auth::default()
214 }
215
216 pub fn enabled(&self) -> bool {
218 !self.tokens.is_empty() || !self.certs.is_empty()
219 }
220
221 fn token(&self, token: &str) -> Option<&Principal> {
222 let d = digest(token);
223 let mut found = None;
225 for e in self.tokens.iter() {
226 if bool::from(e.digest.ct_eq(&d)) {
227 found = Some(&e.principal);
228 }
229 }
230 found
231 }
232
233 pub fn principal_for_cert(&self, der: &[u8]) -> Option<Principal> {
235 if self.certs.is_empty() {
236 return None;
237 }
238 let cert = rustls_pki_types::CertificateDer::from(der);
239 let ee = webpki::EndEntityCert::try_from(&cert).ok()?;
240 let names: Vec<String> = ee
241 .valid_dns_names()
242 .map(|n| n.to_ascii_lowercase())
243 .collect();
244 self.certs
245 .iter()
246 .find(|c| names.contains(&c.dns_name))
247 .map(|c| c.principal.clone())
248 }
249
250 pub fn authorize(
253 &self,
254 header: Option<&str>,
255 cert: Option<&Principal>,
256 required: Role,
257 ) -> Result<Principal, AuthError> {
258 if !self.enabled() {
259 return Ok(Principal::anonymous_admin());
260 }
261 let p = match header {
262 Some(h) => {
263 let token = h
264 .strip_prefix("Bearer ")
265 .or_else(|| h.strip_prefix("bearer "))
266 .ok_or(AuthError::Missing)?
267 .trim();
268 let mut p = self.token(token).cloned().ok_or(AuthError::Invalid)?;
269 p.bearer = Some(Bearer(format!("Bearer {token}").into()));
270 p
271 }
272 None => cert.cloned().ok_or(AuthError::Missing)?,
273 };
274 if p.has_role(required) {
275 Ok(p)
276 } else {
277 Err(AuthError::Forbidden)
278 }
279 }
280
281 pub fn check(&self, header: Option<&str>, required: Role) -> Result<Principal, AuthError> {
283 self.authorize(header, None, required)
284 }
285}
286
287#[derive(Debug, PartialEq, Eq)]
289pub enum AuthError {
290 Missing,
292 Invalid,
294 Forbidden,
296}
297
298impl AuthError {
299 pub fn to_status(&self) -> tonic::Status {
301 match self {
302 AuthError::Missing => tonic::Status::unauthenticated(
303 "missing credentials (bearer token or client certificate)",
304 ),
305 AuthError::Invalid => tonic::Status::unauthenticated("invalid token"),
306 AuthError::Forbidden => {
307 tonic::Status::permission_denied("credentials lack the required role")
308 }
309 }
310 }
311}
312
313pub fn check_grpc<T>(
315 auth: &Auth,
316 req: &tonic::Request<T>,
317 role: Role,
318) -> Result<Principal, tonic::Status> {
319 let header = req
320 .metadata()
321 .get("authorization")
322 .and_then(|v| v.to_str().ok());
323 let cert = if auth.certs.is_empty() {
324 None
325 } else {
326 req.peer_certs().and_then(|certs| {
327 certs
328 .first()
329 .and_then(|c| auth.principal_for_cert(c.as_ref()))
330 })
331 };
332 let mut p = auth
333 .authorize(header, cert.as_ref(), role)
334 .map_err(|e| e.to_status())?;
335 p.forwarded = req.metadata().contains_key(FORWARDED_HEADER);
336 Ok(p)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::config::TokenConfig;
343
344 const C: &str = "client-token-0123456789abcdef0123";
345 const A: &str = "admin-token-0123456789abcdef01234";
346
347 fn tok(t: &str, role: Role, pools: Option<Vec<&str>>) -> TokenConfig {
348 TokenConfig {
349 token: Some(t.into()),
350 token_env: None,
351 role,
352 pools: pools.map(|p| p.into_iter().map(String::from).collect()),
353 labels: None,
354 tenant: None,
355 }
356 }
357
358 #[test]
359 fn roles_and_pools() {
360 let auth = Auth::from_config(&AuthConfig {
361 tokens: vec![
362 tok(C, Role::Client, Some(vec!["a"])),
363 tok(A, Role::Admin, None),
364 ],
365 certs: vec![],
366 })
367 .unwrap();
368 let c = auth
369 .check(Some(&format!("Bearer {C}")), Role::Client)
370 .unwrap();
371 assert!(c.may_use_pool("a") && !c.may_use_pool("b"));
372 assert_eq!(
373 auth.check(Some(&format!("Bearer {C}")), Role::Worker),
374 Err(AuthError::Forbidden)
375 );
376 assert_eq!(
377 auth.check(Some(&format!("Bearer {C}")), Role::Metrics),
378 Err(AuthError::Forbidden)
379 );
380 assert!(
381 auth.check(Some(&format!("Bearer {A}")), Role::Worker)
382 .is_ok()
383 );
384 assert_eq!(
385 auth.check(Some("Bearer xxxxxxxxxxxxxxxxxxxx"), Role::Client),
386 Err(AuthError::Invalid)
387 );
388 assert_eq!(auth.check(None, Role::Client), Err(AuthError::Missing));
389 assert_eq!(
390 Auth::disabled().check(None, Role::Admin),
391 Ok(Principal::anonymous_admin())
392 );
393 }
394
395 #[test]
396 fn rejects_short_and_duplicate_tokens() {
397 let short = Auth::from_config(&AuthConfig {
398 tokens: vec![tok("short", Role::Client, None)],
399 certs: vec![],
400 });
401 assert!(short.is_err());
402 let dup = Auth::from_config(&AuthConfig {
403 tokens: vec![tok(A, Role::Admin, None), tok(A, Role::Client, None)],
404 certs: vec![],
405 });
406 assert!(
407 dup.is_err(),
408 "a duplicate must not silently downgrade the admin token"
409 );
410 }
411}