1use chrono::{Duration, Utc};
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, thiserror::Error)]
21pub enum Error {
22 #[error("invalid session token: {0}")]
24 Token(String),
25 #[error("role {actor_role:?} does not permit {needed:?}")]
27 Forbidden {
28 actor_role: String,
30 needed: String,
32 },
33 #[error("kit error: {0}")]
35 Kit(String),
36 #[error("configuration error: {0}")]
38 Config(String),
39}
40
41pub type Result<T> = std::result::Result<T, Error>;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48pub enum Role {
49 Auditor,
51 Reviewer,
53 Editor,
55 Owner,
57}
58
59impl Role {
60 #[must_use]
62 pub fn as_str(self) -> &'static str {
63 match self {
64 Role::Owner => "owner",
65 Role::Editor => "editor",
66 Role::Reviewer => "reviewer",
67 Role::Auditor => "auditor",
68 }
69 }
70
71 #[must_use]
73 pub fn parse(name: &str) -> Option<Self> {
74 match name {
75 "owner" => Some(Role::Owner),
76 "editor" => Some(Role::Editor),
77 "reviewer" => Some(Role::Reviewer),
78 "auditor" => Some(Role::Auditor),
79 _ => None,
80 }
81 }
82
83 #[must_use]
86 pub fn permits(self, capability: Capability) -> bool {
87 match capability {
88 Capability::Read => true,
89 Capability::ReadAudit => true,
90 Capability::Edit => matches!(self, Role::Owner | Role::Editor),
91 Capability::Redact => matches!(self, Role::Owner | Role::Editor),
92 Capability::Sign => matches!(self, Role::Owner | Role::Editor),
93 Capability::ManageMembers => matches!(self, Role::Owner),
94 Capability::Delete => matches!(self, Role::Owner),
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum Capability {
103 Read,
105 ReadAudit,
107 Edit,
109 Redact,
111 Sign,
113 ManageMembers,
115 Delete,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ChiselClaims {
127 #[serde(flatten)]
129 pub standard: tokenkit::claims::StandardClaims,
130 pub sid: String,
132}
133
134impl ChiselClaims {
135 #[must_use]
137 pub fn role(&self) -> Option<Role> {
138 self.standard.role.as_deref().and_then(Role::parse)
139 }
140
141 #[must_use]
143 pub fn permits(&self, capability: Capability) -> bool {
144 self.role().is_some_and(|r| r.permits(capability))
145 }
146
147 pub fn require(&self, capability: Capability) -> Result<()> {
153 if self.permits(capability) {
154 Ok(())
155 } else {
156 Err(Error::Forbidden {
157 actor_role: self.role().map_or("none".into(), |r| r.as_str().to_owned()),
158 needed: format!("{capability:?}"),
159 })
160 }
161 }
162}
163
164#[derive(Clone)]
167pub struct SessionService {
168 inner: std::sync::Arc<tokenkit::service::JwtService>,
169 issuer: String,
170 audience: String,
171 session_ttl: Duration,
172 refresh_ttl: Duration,
173}
174
175impl std::fmt::Debug for SessionService {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 f.debug_struct("SessionService")
178 .field("issuer", &self.issuer)
179 .field("audience", &self.audience)
180 .field("session_ttl", &self.session_ttl)
181 .finish_non_exhaustive()
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SessionTokens {
188 pub access: String,
190 pub refresh: String,
192 pub sid: String,
194}
195
196impl SessionService {
197 pub fn new(
205 signing_secret: impl Into<String>,
206 issuer: impl Into<String>,
207 audience: impl Into<String>,
208 ) -> Result<Self> {
209 let secret = signing_secret.into();
210 let issuer = issuer.into();
211 let audience = audience.into();
212 if secret.is_empty() {
213 return Err(Error::Config("signing secret cannot be empty".into()));
214 }
215 if issuer.is_empty() || audience.is_empty() {
216 return Err(Error::Config("issuer and audience cannot be empty".into()));
217 }
218 let config = tokenkit::service::JwtConfig {
225 secret,
226 issuer: Some(issuer.clone()),
227 audience: Some(audience.clone()),
228 ..tokenkit::service::JwtConfig::default()
229 };
230 Ok(Self {
231 inner: std::sync::Arc::new(tokenkit::service::JwtService::new(config)),
232 issuer,
233 audience,
234 session_ttl: Duration::hours(12),
235 refresh_ttl: Duration::days(30),
236 })
237 }
238
239 pub fn issue(&self, user: &chisel_kernel::UserId, role: Role) -> Result<SessionTokens> {
245 let sid = chisel_kernel::SessionId::try_from(format!(
246 "{}-{}",
247 user.as_str(),
248 Utc::now().timestamp_millis()
249 ))
250 .map_err(Error::Config)?;
251 let access = self.encode_session(user, role, &sid, self.session_ttl)?;
252 let refresh = self.encode_session(user, role, &sid, self.refresh_ttl)?;
253 Ok(SessionTokens {
254 access,
255 refresh,
256 sid: sid.to_string(),
257 })
258 }
259
260 pub fn validate(&self, token: &str) -> Result<ChiselClaims> {
267 self.inner
268 .decode::<ChiselClaims>(token)
269 .map_err(|e| Error::Token(e.to_string()))
270 }
271
272 pub fn refresh(&self, refresh_token: &str) -> Result<SessionTokens> {
279 let claims = self.validate(refresh_token)?;
280 self.refresh_from_claims(&claims)
281 }
282
283 pub fn refresh_from_claims(&self, claims: &ChiselClaims) -> Result<SessionTokens> {
292 let role = claims.role().ok_or_else(|| {
293 Error::Token("refresh token carries no role from the current vocabulary".into())
294 })?;
295 let user = chisel_kernel::UserId::try_from(claims.standard.sub.clone().unwrap_or_default())
296 .map_err(Error::Token)?;
297 let sid = chisel_kernel::SessionId::try_from(claims.sid.clone()).map_err(Error::Token)?;
298 let access = self.encode_session(&user, role, &sid, self.session_ttl)?;
299 let refresh = self.encode_session(&user, role, &sid, self.refresh_ttl)?;
300 Ok(SessionTokens {
301 access,
302 refresh,
303 sid: sid.to_string(),
304 })
305 }
306
307 fn encode_session(
308 &self,
309 user: &chisel_kernel::UserId,
310 role: Role,
311 sid: &chisel_kernel::SessionId,
312 ttl: Duration,
313 ) -> Result<String> {
314 let now = Utc::now();
315 let claims = ChiselClaims {
316 standard: tokenkit::claims::StandardClaims {
317 sub: Some(user.as_str().to_owned()),
318 iss: Some(self.issuer.clone()),
319 aud: Some(self.audience.clone()),
320 exp: Some(now + ttl),
321 iat: Some(now),
322 jti: None,
323 role: Some(role.as_str().to_owned()),
324 permissions: Vec::new(),
325 extra: std::collections::HashMap::new(),
326 },
327 sid: sid.to_string(),
328 };
329 self.inner
330 .encode(&claims)
331 .map_err(|e| Error::Kit(e.to_string()))
332 }
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct OidcProviderConfig {
339 pub issuer: String,
341 pub authorization_url: String,
343 pub token_url: String,
345 pub client_id: String,
347 pub scopes: Vec<String>,
349}
350
351pub struct LoginStart {
354 flow: oauth_toolkit::loopback::LoopbackFlow,
355}
356
357impl LoginStart {
358 #[must_use]
360 pub fn url(&self) -> &str {
361 self.flow.authorization_url()
362 }
363
364 pub fn wait(self) -> Result<oauth_toolkit::loopback::CapturedCode> {
371 self.flow
372 .wait_for_code()
373 .map_err(|e| Error::Kit(e.to_string()))
374 }
375}
376
377pub trait TokenExchanger: Send + Sync {
381 fn exchange(
384 &self,
385 token_url: &str,
386 code: &str,
387 code_verifier: &str,
388 client_id: &str,
389 ) -> Result<serde_json::Value>;
390}
391
392impl<F> TokenExchanger for F
393where
394 F: Fn(&str, &str, &str, &str) -> Result<serde_json::Value> + Send + Sync,
395{
396 fn exchange(
397 &self,
398 token_url: &str,
399 code: &str,
400 code_verifier: &str,
401 client_id: &str,
402 ) -> Result<serde_json::Value> {
403 self(token_url, code, code_verifier, client_id)
404 }
405}
406
407pub fn start_login(
415 provider: &OidcProviderConfig,
416 scopes: &[String],
417 timeout: std::time::Duration,
418) -> Result<LoginStart> {
419 let flow = oauth_toolkit::loopback::LoopbackFlow::start(
420 &provider.authorization_url,
421 &provider.client_id,
422 scopes,
423 None,
424 timeout,
425 )
426 .map_err(|e| Error::Kit(e.to_string()))?;
427 Ok(LoginStart { flow })
428}
429
430#[cfg(test)]
431mod tests {
432 #![allow(clippy::expect_used, clippy::unwrap_used)]
433 use super::*;
434 use chisel_kernel::UserId;
435
436 const SECRET: &str = "test-signing-secret-do-not-use";
437 const ISSUER: &str = "https://auth.chisel.test";
438 const AUDIENCE: &str = "chisel-desktop";
439
440 fn service() -> SessionService {
441 SessionService::new(SECRET, ISSUER, AUDIENCE).expect("valid config")
442 }
443
444 #[test]
445 fn config_failures_are_loud_and_early() {
446 assert!(SessionService::new("", ISSUER, AUDIENCE).is_err());
447 assert!(SessionService::new(SECRET, "", AUDIENCE).is_err());
448 assert!(SessionService::new(SECRET, ISSUER, "").is_err());
449 }
450
451 #[test]
452 fn issue_then_validate_round_trips_role_and_identity() -> Result<()> {
453 let svc = service();
454 let user = UserId::try_from("alice").map_err(Error::Token)?;
455 let tokens = svc.issue(&user, Role::Editor)?;
456
457 let claims = svc.validate(&tokens.access)?;
458 assert_eq!(claims.standard.sub.as_deref(), Some("alice"));
459 assert_eq!(claims.standard.role.as_deref(), Some("editor"));
460 assert_eq!(claims.sid, tokens.sid);
461 assert!(claims.permits(Capability::Edit));
462 assert!(!claims.permits(Capability::ManageMembers));
463 Ok(())
464 }
465
466 #[test]
467 fn refresh_preserves_the_session_and_role() -> Result<()> {
468 let svc = service();
469 let user = UserId::try_from("bob").map_err(Error::Token)?;
470 let tokens = svc.issue(&user, Role::Auditor)?;
471
472 let fresh = svc.refresh(&tokens.refresh)?;
473 assert_eq!(fresh.sid, tokens.sid);
474 let claims = svc.validate(&fresh.access)?;
475 assert_eq!(claims.standard.role.as_deref(), Some("auditor"));
476 assert!(claims.permits(Capability::ReadAudit));
477 assert!(!claims.permits(Capability::Edit));
478 Ok(())
479 }
480
481 #[test]
482 fn a_foreign_issuer_is_rejected() -> Result<()> {
483 let svc = service();
484 let user = UserId::try_from("alice").map_err(Error::Token)?;
485 let tokens = svc.issue(&user, Role::Reviewer)?;
486
487 let other = SessionService::new(SECRET, "https://elsewhere.test", AUDIENCE)?;
488 assert!(matches!(
489 other.validate(&tokens.access),
490 Err(Error::Token(_))
491 ));
492 Ok(())
493 }
494
495 #[test]
496 fn capability_checks_name_the_missing_role() -> Result<()> {
497 let svc = service();
498 let user = UserId::try_from("dan").map_err(Error::Token)?;
499 let tokens = svc.issue(&user, Role::Reviewer)?;
500 let claims = svc.validate(&tokens.access)?;
501 let err = claims.require(Capability::Redact).unwrap_err();
502 assert!(err.to_string().contains("reviewer"), "{err}");
503 assert!(err.to_string().contains("Redact"), "{err}");
504 Ok(())
505 }
506
507 #[test]
508 fn the_role_vocabulary_is_total_and_ordered() {
509 for (name, role) in [
510 ("owner", Role::Owner),
511 ("editor", Role::Editor),
512 ("reviewer", Role::Reviewer),
513 ("auditor", Role::Auditor),
514 ] {
515 assert_eq!(Role::parse(name), Some(role));
516 assert_eq!(role.as_str(), name);
517 }
518 assert!(Role::Auditor < Role::Reviewer);
519 assert!(Role::Reviewer < Role::Editor);
520 assert!(Role::Editor < Role::Owner);
521 for r in [Role::Owner, Role::Editor, Role::Reviewer, Role::Auditor] {
522 assert!(r.permits(Capability::Read));
523 assert!(!r.permits(Capability::Delete) || r == Role::Owner);
524 }
525 }
526
527 #[test]
528 fn start_login_builds_a_loopback_flow_against_the_provider() -> Result<()> {
529 let provider = OidcProviderConfig {
530 issuer: "https://id.example.test".into(),
531 authorization_url: "https://id.example.test/authorize".into(),
532 token_url: "https://id.example.test/token".into(),
533 client_id: "chisel-desktop".into(),
534 scopes: vec!["openid".into(), "email".into(), "profile".into()],
535 };
536 let start = start_login(
537 &provider,
538 &provider.scopes,
539 std::time::Duration::from_secs(30),
540 )?;
541 assert!(
542 start.url().starts_with("https://id.example.test/authorize"),
543 "{}",
544 start.url()
545 );
546 assert!(
547 start.url().contains("code_challenge="),
548 "PKCE challenge present"
549 );
550 assert!(start.url().contains("state="), "CSRF state present");
551 Ok(())
552 }
553
554 #[test]
555 fn sign_capability_splits_editors_from_auditors() {
556 assert!(Role::Owner.permits(Capability::Sign));
557 assert!(Role::Editor.permits(Capability::Sign));
558 assert!(!Role::Reviewer.permits(Capability::Sign));
559 assert!(!Role::Auditor.permits(Capability::Sign));
560 }
561
562 #[test]
563 fn require_returns_ok_when_permitted() -> Result<()> {
564 let svc = service();
565 let user = chisel_kernel::UserId::try_from("ed").map_err(Error::Token)?;
566 let tokens = svc.issue(&user, Role::Editor)?;
567 let claims = svc.validate(&tokens.access)?;
568 claims.require(Capability::Edit)?;
569 claims.require(Capability::Read)?;
570 Ok(())
571 }
572
573 #[test]
574 fn debug_never_prints_the_signing_secret() {
575 let dbg = format!("{svc:?}", svc = service());
576 assert!(dbg.contains("SessionService"), "{dbg}");
577 assert!(!dbg.contains(SECRET), "the secret must not appear in Debug");
578 }
579
580 #[test]
581 fn login_start_wait_times_out_loudly() -> Result<()> {
582 let provider = OidcProviderConfig {
583 issuer: "https://id.example.test".into(),
584 authorization_url: "https://id.example.test/authorize".into(),
585 token_url: "https://id.example.test/token".into(),
586 client_id: "chisel-desktop".into(),
587 scopes: vec!["openid".into()],
588 };
589 let start = start_login(
590 &provider,
591 &provider.scopes,
592 std::time::Duration::from_millis(1),
593 )?;
594 let outcome = start.wait();
595 assert!(matches!(outcome, Err(Error::Kit(_))), "{outcome:?}");
596 Ok(())
597 }
598
599 #[test]
600 fn the_token_exchanger_trait_runs_closures() -> Result<()> {
601 let exchange = |token_url: &str, _code: &str, _verifier: &str, _client: &str| {
602 Ok(serde_json::json!({ "token_url": token_url }))
603 };
604 let v = exchange.exchange("https://token", "code", "verifier", "client")?;
605 assert_eq!(
606 v.get("token_url"),
607 Some(&serde_json::json!("https://token"))
608 );
609 Ok(())
610 }
611}
612
613pub mod passkey;