1use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31
32use chrono::{DateTime, Duration, Utc};
33use rand::RngCore;
34use serde::Serialize;
35use serde_json::{Value, json};
36use sha2::{Digest, Sha256};
37
38use crate::chain::ChainManager;
39
40pub const SOURCE: &str = "auth.token";
42pub const KIND_ISSUED: &str = "auth.token.issued";
44pub const KIND_REVOKED: &str = "auth.token.revoked";
46pub const DEFAULT_TTL: Duration = Duration::minutes(15);
48pub const MAX_TTL: Duration = Duration::hours(24);
50pub const MAX_LABEL_LEN: usize = 64;
52pub const SECRET_PREFIX: &str = "wft_";
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum TokenScope {
59 Owner,
61 Project,
66 Read,
73}
74
75impl TokenScope {
76 pub fn capability_scopes(self) -> &'static [&'static str] {
79 match self {
80 TokenScope::Owner => &["admin"],
81 TokenScope::Project => &["write"],
82 TokenScope::Read => &["read"],
83 }
84 }
85
86 fn parse(s: Option<&str>) -> Option<Self> {
89 match s {
90 None | Some("owner") => Some(TokenScope::Owner),
91 Some("project") => Some(TokenScope::Project),
92 Some("read") => Some(TokenScope::Read),
93 Some(_) => None,
94 }
95 }
96
97 fn as_str(self) -> &'static str {
98 match self {
99 TokenScope::Owner => "owner",
100 TokenScope::Project => "project",
101 TokenScope::Read => "read",
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108pub struct TokenInfo {
109 pub id: String,
110 pub label: String,
111 pub issued_at: DateTime<Utc>,
112 pub expires_at: DateTime<Utc>,
113 pub scope: TokenScope,
114 pub project: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum TokenError {
121 #[error("ttl must be positive")]
122 TtlNotPositive,
123 #[error("ttl exceeds the 24 h maximum")]
124 TtlTooLong,
125 #[error("label must be 1-{MAX_LABEL_LEN} printable characters")]
126 BadLabel,
127 #[error("could not persist the token record: {0}")]
128 Persist(String),
129}
130
131#[derive(Debug, Clone, Default)]
133pub struct Issuer {
134 pub uid: Option<u32>,
135}
136
137struct Entry {
138 hash: [u8; 32],
139 info: TokenInfo,
140}
141
142pub struct TokenAuthority {
144 chain: Arc<ChainManager>,
145 node_id: String,
146 journal: Option<std::path::PathBuf>,
147 journal_lock: Mutex<()>,
149 table: Mutex<HashMap<String, Entry>>,
150}
151
152fn hash_secret(secret: &str) -> [u8; 32] {
153 Sha256::digest(secret.as_bytes()).into()
154}
155
156fn id_of(hash: &[u8; 32]) -> String {
157 hex(hash)[..16].to_owned()
158}
159
160fn hex(bytes: &[u8]) -> String {
161 bytes.iter().map(|b| format!("{b:02x}")).collect()
162}
163
164fn unhex32(s: &str) -> Option<[u8; 32]> {
165 if s.len() != 64 || !s.is_ascii() {
166 return None;
167 }
168 let mut out = [0u8; 32];
169 for (i, o) in out.iter_mut().enumerate() {
170 *o = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
171 }
172 Some(out)
173}
174
175fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
177 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
178}
179
180fn parse_time(v: &Value, key: &str) -> Option<DateTime<Utc>> {
181 DateTime::parse_from_rfc3339(v.get(key)?.as_str()?)
182 .ok()
183 .map(|t| t.with_timezone(&Utc))
184}
185
186fn warn_if_dir_writable(path: &std::path::Path) {
188 #[cfg(unix)]
189 if let Some(dir) = path.parent()
190 && let Ok(m) = std::fs::metadata(dir)
191 {
192 use std::os::unix::fs::PermissionsExt;
193 if m.permissions().mode() & 0o022 != 0 {
194 tracing::warn!(dir = %dir.display(), "token journal directory is group/world-writable");
195 }
196 }
197 #[cfg(not(unix))]
198 let _ = path;
199}
200
201struct Record<'a> {
203 kind: &'a str,
204 payload: &'a Value,
205}
206
207fn parse_issued(p: &Value) -> Option<Entry> {
210 let id = p.get("id").and_then(Value::as_str)?;
211 let hash = p.get("sha256").and_then(Value::as_str).and_then(unhex32)?;
212 let issued_at = parse_time(p, "issued_at")?;
213 let expires_at = parse_time(p, "expires_at")?;
214 let life = expires_at - issued_at;
215 if id_of(&hash) != id || life <= Duration::zero() || life > MAX_TTL {
216 return None;
217 }
218 Some(Entry {
219 hash,
220 info: TokenInfo {
221 id: id.to_owned(),
222 label: p
223 .get("label")
224 .and_then(Value::as_str)
225 .unwrap_or_default()
226 .to_owned(),
227 issued_at,
228 expires_at,
229 scope: TokenScope::parse(p.get("scope").and_then(Value::as_str))?,
230 project: p.get("project").and_then(Value::as_str).map(str::to_owned),
231 },
232 })
233}
234
235fn fold(records: &[Record<'_>], now: DateTime<Utc>) -> HashMap<String, Entry> {
239 let mut revoked = std::collections::HashSet::new();
240 let mut table = HashMap::new();
241 for r in records {
242 match r.kind {
243 KIND_ISSUED => {
244 if let Some(e) = parse_issued(r.payload) {
245 table.insert(e.info.id.clone(), e);
246 }
247 }
248 KIND_REVOKED => {
249 if let Some(id) = r.payload.get("id").and_then(Value::as_str) {
250 revoked.insert(id.to_owned());
251 }
252 }
253 _ => {}
254 }
255 }
256 table.retain(|id, e| !revoked.contains(id) && e.info.expires_at > now);
257 table
258}
259
260impl TokenAuthority {
261 pub fn new(chain: Arc<ChainManager>, node_id: impl Into<String>) -> Self {
263 Self::with_journal(chain, node_id, None)
264 }
265
266 pub fn with_journal(
269 chain: Arc<ChainManager>,
270 node_id: impl Into<String>,
271 journal: Option<std::path::PathBuf>,
272 ) -> Self {
273 match &journal {
274 None => tracing::warn!(
275 "token authority has no journal path: revocations are not durable across a crash"
276 ),
277 Some(path) => warn_if_dir_writable(path),
278 }
279 let a = Self {
280 chain,
281 node_id: node_id.into(),
282 journal,
283 journal_lock: Mutex::new(()),
284 table: Mutex::new(HashMap::new()),
285 };
286 a.rebuild_at(Utc::now());
287 a
288 }
289
290 pub fn chain_refs(&self) -> usize {
292 Arc::strong_count(&self.chain)
293 }
294
295 fn read_journal(&self) -> Vec<Value> {
299 let Some(path) = &self.journal else {
300 return Vec::new();
301 };
302 let text = match std::fs::read_to_string(path) {
303 Ok(t) => t,
304 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
305 Err(e) => {
306 tracing::error!(path = %path.display(), error = %e, "token journal unreadable: revocations may be lost");
307 return Vec::new();
308 }
309 };
310 let mut out = Vec::new();
311 for (n, line) in text.lines().enumerate() {
312 if line.trim().is_empty() {
313 continue;
314 }
315 let Ok(v) = serde_json::from_str::<Value>(line) else {
316 tracing::warn!(path = %path.display(), line = n + 1, "token journal: unparseable line skipped; a revocation may be lost");
317 continue;
318 };
319 let kind = v.get("kind").and_then(Value::as_str);
320 let payload = v.get("payload");
321 match (kind, payload) {
322 (Some(KIND_REVOKED), Some(p)) if p.get("id").and_then(Value::as_str).is_some() => {
323 out.push(p.clone());
324 }
325 (Some(KIND_ISSUED), _) => {
326 tracing::warn!(path = %path.display(), line = n + 1, "token journal: ignoring an `issued` entry (the journal is revocations only)");
327 }
328 _ => {
329 tracing::warn!(path = %path.display(), line = n + 1, "token journal: malformed entry skipped");
330 }
331 }
332 }
333 out
334 }
335
336 fn journal_revoke(&self, payload: &Value) -> Result<(), TokenError> {
340 use std::io::{Read, Seek, SeekFrom, Write};
341 let Some(path) = &self.journal else {
342 return Ok(());
343 };
344 let mut line =
345 json!({ "source": SOURCE, "kind": KIND_REVOKED, "payload": payload }).to_string();
346 line.push('\n');
347 let _guard = self.journal_lock.lock().unwrap_or_else(|e| e.into_inner());
348 let mut write = || -> std::io::Result<()> {
349 let dir = path.parent();
350 if let Some(dir) = dir {
351 std::fs::create_dir_all(dir)?;
352 }
353 let created = !path.exists();
354 let mut opts = std::fs::OpenOptions::new();
355 opts.create(true).append(true).read(true);
356 #[cfg(unix)]
357 {
358 use std::os::unix::fs::OpenOptionsExt;
359 opts.mode(0o600);
360 }
361 let mut f = opts.open(path)?;
362 let len = f.metadata()?.len();
363 if len > 0 {
364 f.seek(SeekFrom::Start(len - 1))?;
365 let mut last = [0u8; 1];
366 f.read_exact(&mut last)?;
367 if last[0] != b'\n' {
368 line.insert(0, '\n');
369 }
370 }
371 f.write_all(line.as_bytes())?;
372 f.sync_all()?;
373 if created && let Some(dir) = dir {
374 std::fs::File::open(dir)?.sync_all()?;
375 }
376 Ok(())
377 };
378 write().map_err(|e| TokenError::Persist(e.to_string()))
379 }
380
381 pub fn rebuild_at(&self, now: DateTime<Utc>) {
384 let _guard = self.journal_lock.lock().unwrap_or_else(|e| e.into_inner());
387 let events = self.chain.tail_from(0);
388 let revoked_in_journal = self.read_journal();
389 let mut records: Vec<Record<'_>> = Vec::new();
390 for ev in &events {
391 if ev.source != SOURCE {
392 continue;
393 }
394 if let Some(payload) = ev.payload.as_ref() {
395 records.push(Record {
396 kind: ev.kind.as_str(),
397 payload,
398 });
399 }
400 }
401 for payload in &revoked_in_journal {
402 records.push(Record {
403 kind: KIND_REVOKED,
404 payload,
405 });
406 }
407 *self.table.lock().unwrap_or_else(|e| e.into_inner()) = fold(&records, now);
408 self.prune_journal(&events, &revoked_in_journal, now);
409 }
410
411 fn prune_journal(
425 &self,
426 events: &[crate::chain::ChainEvent],
427 revoked: &[Value],
428 now: DateTime<Utc>,
429 ) {
430 use std::io::Write;
431 let Some(path) = &self.journal else { return };
432 let Some(newest) = events.iter().map(|e| e.timestamp).max() else {
433 return;
434 };
435 let horizon = now.min(newest);
436 let mut issued_at: HashMap<&str, DateTime<Utc>> = HashMap::new();
437 for ev in events {
438 if ev.source != SOURCE || ev.kind != KIND_ISSUED {
439 continue;
440 }
441 if let Some(p) = ev.payload.as_ref()
442 && let (Some(id), Some(t)) = (
443 p.get("id").and_then(Value::as_str),
444 parse_time(p, "issued_at"),
445 )
446 {
447 issued_at.insert(id, t);
448 }
449 }
450 let keep: Vec<&Value> = revoked
451 .iter()
452 .filter(|p| {
453 let id = p.get("id").and_then(Value::as_str).unwrap_or_default();
454 issued_at.get(id).is_none_or(|t| *t + MAX_TTL > horizon)
455 })
456 .collect();
457 if keep.len() == revoked.len() {
458 return;
459 }
460 let write = || -> std::io::Result<()> {
461 let tmp = path.with_extension("jsonl.tmp");
462 let mut opts = std::fs::OpenOptions::new();
463 opts.create(true).write(true).truncate(true);
464 #[cfg(unix)]
465 {
466 use std::os::unix::fs::OpenOptionsExt;
467 opts.mode(0o600);
468 }
469 let mut f = opts.open(&tmp)?;
470 for p in &keep {
471 let mut line =
472 json!({ "source": SOURCE, "kind": KIND_REVOKED, "payload": p }).to_string();
473 line.push('\n');
474 f.write_all(line.as_bytes())?;
475 }
476 f.sync_all()?;
477 std::fs::rename(&tmp, path)?;
478 if let Some(dir) = path.parent() {
479 std::fs::File::open(dir)?.sync_all()?;
480 }
481 Ok(())
482 };
483 if let Err(e) = write() {
484 tracing::warn!(path = %path.display(), error = %e, "token journal prune failed; keeping the old file");
485 }
486 }
487
488 pub fn issue(
490 &self,
491 label: &str,
492 ttl: Option<Duration>,
493 project: Option<String>,
494 issuer: &Issuer,
495 ) -> Result<(String, TokenInfo), TokenError> {
496 self.issue_at(Utc::now(), label, ttl, project, issuer)
497 }
498
499 pub fn issue_at(
501 &self,
502 now: DateTime<Utc>,
503 label: &str,
504 ttl: Option<Duration>,
505 project: Option<String>,
506 issuer: &Issuer,
507 ) -> Result<(String, TokenInfo), TokenError> {
508 self.issue_scoped_at(now, label, ttl, project, TokenScope::Owner, issuer)
509 }
510
511 pub fn issue_project(
514 &self,
515 project_id: &str,
516 ttl: Duration,
517 issuer: &Issuer,
518 ) -> Result<(String, TokenInfo), TokenError> {
519 self.issue_scoped_at(
520 Utc::now(),
521 &format!("project:{project_id}"),
522 Some(ttl),
523 Some(project_id.to_owned()),
524 TokenScope::Project,
525 issuer,
526 )
527 }
528
529 pub fn issue_scoped_at(
531 &self,
532 now: DateTime<Utc>,
533 label: &str,
534 ttl: Option<Duration>,
535 project: Option<String>,
536 scope: TokenScope,
537 issuer: &Issuer,
538 ) -> Result<(String, TokenInfo), TokenError> {
539 let ttl = ttl.unwrap_or(DEFAULT_TTL);
540 if ttl <= Duration::zero() {
541 return Err(TokenError::TtlNotPositive);
542 }
543 if ttl > MAX_TTL {
544 return Err(TokenError::TtlTooLong);
545 }
546 if label.is_empty()
547 || label.chars().count() > MAX_LABEL_LEN
548 || label.chars().any(char::is_control)
549 {
550 return Err(TokenError::BadLabel);
551 }
552 let mut raw = [0u8; 32];
553 rand::rngs::OsRng.fill_bytes(&mut raw);
554 let secret = format!("{SECRET_PREFIX}{}", hex(&raw));
555 let hash = hash_secret(&secret);
556 let info = TokenInfo {
557 id: id_of(&hash),
558 label: label.to_owned(),
559 issued_at: now,
560 expires_at: now + ttl,
561 scope,
562 project,
563 };
564 let mut payload = json!({
565 "id": info.id,
566 "sha256": hex(&hash),
567 "label": info.label,
568 "issued_at": info.issued_at.to_rfc3339(),
569 "expires_at": info.expires_at.to_rfc3339(),
570 "issuer": { "node_id": self.node_id, "uid": issuer.uid, "uid_verified": false },
571 });
572 if let Some(p) = &info.project {
573 payload["project"] = json!(p);
574 }
575 if scope != TokenScope::Owner {
576 payload["scope"] = json!(scope.as_str());
577 }
578 self.chain.append(SOURCE, KIND_ISSUED, Some(payload));
579 self.lock().insert(
580 info.id.clone(),
581 Entry {
582 hash,
583 info: info.clone(),
584 },
585 );
586 Ok((secret, info))
587 }
588
589 pub fn revoke(&self, id: &str) -> Result<bool, TokenError> {
594 if self.lock().remove(id).is_none() {
595 return Ok(false);
596 }
597 let payload = json!({ "id": id, "revoked_at": Utc::now().to_rfc3339() });
598 self.chain
599 .append(SOURCE, KIND_REVOKED, Some(payload.clone()));
600 self.journal_revoke(&payload)?;
601 Ok(true)
602 }
603
604 pub fn validate(&self, secret: &str) -> Option<TokenInfo> {
606 self.validate_at(Utc::now(), secret)
607 }
608
609 pub fn validate_at(&self, now: DateTime<Utc>, secret: &str) -> Option<TokenInfo> {
611 let hash = hash_secret(secret);
612 let table = self.lock();
613 let e = table.get(&id_of(&hash))?;
614 (ct_eq(&e.hash, &hash) && e.info.expires_at > now).then(|| e.info.clone())
615 }
616
617 pub fn list(&self) -> Vec<TokenInfo> {
619 self.list_at(Utc::now())
620 }
621
622 pub fn list_at(&self, now: DateTime<Utc>) -> Vec<TokenInfo> {
624 let mut v: Vec<_> = self
625 .lock()
626 .values()
627 .filter(|e| e.info.expires_at > now)
628 .map(|e| e.info.clone())
629 .collect();
630 v.sort_by(|a, b| a.issued_at.cmp(&b.issued_at).then_with(|| a.id.cmp(&b.id)));
631 v
632 }
633
634 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
635 self.table.lock().unwrap_or_else(|e| e.into_inner())
636 }
637}
638
639#[cfg(test)]
640mod tests;