Skip to main content

chisel_auth/
lib.rs

1//! Chisel platform authentication: JWT sessions with a closed role
2//! vocabulary, and the OIDC desktop-loopback seam over the kit estate.
3//!
4//! Two kit surfaces composed here, both kept behind thin Chisel vocabulary:
5//!
6//! - [`tokenkit`](https://crates.io/crates/tokenkit) — JWT issue/validate
7//!   with rotation-capable configuration ([`SessionService`]).
8//! - [`oauth_toolkit`](https://crates.io/crates/oauth-toolkit) — the OIDC
9//!   desktop-loopback flow with PKCE and CSRF state ([`LoginFlow`]).
10//!
11//! The v0.1 rule: everything here is testable offline. The one network step
12//! in OIDC (the authorization-code exchange at the provider's token
13//! endpoint) is behind the [`TokenExchanger`] trait — the HTTP
14//! implementation arrives with the service slice, and tests inject a stub.
15
16use chrono::{Duration, Utc};
17use serde::{Deserialize, Serialize};
18
19/// Authentication failures.
20#[derive(Debug, thiserror::Error)]
21pub enum Error {
22    /// The presented token is invalid, expired, or wrongly scoped.
23    #[error("invalid session token: {0}")]
24    Token(String),
25    /// The actor's roles do not permit the operation.
26    #[error("role {actor_role:?} does not permit {needed:?}")]
27    Forbidden {
28        /// The role the session carried.
29        actor_role: String,
30        /// The capability that was requested.
31        needed: String,
32    },
33    /// The underlying kit rejected an operation.
34    #[error("kit error: {0}")]
35    Kit(String),
36    /// A configuration error (empty secret, malformed issuer, …).
37    #[error("configuration error: {0}")]
38    Config(String),
39}
40
41/// Convenient alias for auth operations.
42pub type Result<T> = std::result::Result<T, Error>;
43
44/// The closed role vocabulary for the platform, declared weakest-first so
45/// the derived ordering matches strength (`Auditor < Reviewer < Editor <
46/// Owner`); capabilities accumulate upward.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48pub enum Role {
49    /// Read-only, plus the full audit trail.
50    Auditor,
51    /// Read and comment; no mutations.
52    Reviewer,
53    /// Edit documents and manage redactions.
54    Editor,
55    /// Full control, including membership and deletion.
56    Owner,
57}
58
59impl Role {
60    /// The role's name as serialized into tokens.
61    #[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    /// Parses a role name from a token claim.
72    #[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    /// Whether this role permits the given capability. Capabilities are a
84    /// closed set; new capabilities must name every role that holds them.
85    #[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/// The closed capability set. A capability is a verb-like permission the UI
100/// and the service both check before acting.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum Capability {
103    /// Read document content.
104    Read,
105    /// Read the audit trail (every role can).
106    ReadAudit,
107    /// Mutate document content.
108    Edit,
109    /// Apply redactions.
110    Redact,
111    /// Apply digital signatures.
112    Sign,
113    /// Manage membership.
114    ManageMembers,
115    /// Delete documents.
116    Delete,
117}
118
119/// The claims carried by a Chisel session token: `tokenkit`'s standard
120/// claims (whose `role` field carries the session's role name) plus the
121/// session id.
122///
123/// A session carries exactly one role — the Acrobat actor model is one
124/// identity, one job — so the vocabulary stays a flat enum, not a set.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ChiselClaims {
127    /// The standard registered claims; `role` holds the role name.
128    #[serde(flatten)]
129    pub standard: tokenkit::claims::StandardClaims,
130    /// The session this token belongs to (refresh pairs share it).
131    pub sid: String,
132}
133
134impl ChiselClaims {
135    /// The session's parsed role, if the name is in the current vocabulary.
136    #[must_use]
137    pub fn role(&self) -> Option<Role> {
138        self.standard.role.as_deref().and_then(Role::parse)
139    }
140
141    /// Whether the session's role permits a capability.
142    #[must_use]
143    pub fn permits(&self, capability: Capability) -> bool {
144        self.role().is_some_and(|r| r.permits(capability))
145    }
146
147    /// Enforces a capability, mapping failure to [`Error::Forbidden`].
148    ///
149    /// # Errors
150    ///
151    /// When the session's role does not permit the capability.
152    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/// Issues and validates Chisel session tokens over `tokenkit`'s JWT service
165/// with rotation-capable secrets.
166#[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/// The token pair handed out at login and refresh.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SessionTokens {
188    /// Short-lived access token (carries the roles).
189    pub access: String,
190    /// Long-lived refresh token (same `sid`, longer expiry).
191    pub refresh: String,
192    /// The session id both tokens share.
193    pub sid: String,
194}
195
196impl SessionService {
197    /// Creates a service.
198    ///
199    /// # Errors
200    ///
201    /// [`Error::Config`] when the signing secret is empty or the issuer or
202    /// audience is empty — a service started unconfigured must fail loudly
203    /// now, not at the first login.
204    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        // v0.1 pins a single HS256 secret. tokenkit's secret-rotation
219        // builder is behind its `rotation` feature; enable it here when the
220        // service slice lands and the secrets move to the settings store.
221        // The config carries the issuer and audience so VALIDATION enforces
222        // that tokens presented to this service are its own — not just
223        // correctly signed by it.
224        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    /// Issues an access + refresh pair for a user with the given role.
240    ///
241    /// # Errors
242    ///
243    /// Propagates encoding failures from the kit.
244    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    /// Validates an access token into its claims.
261    ///
262    /// # Errors
263    ///
264    /// [`Error::Token`] when the kit rejects the token (signature, expiry,
265    /// audience); propagation otherwise.
266    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    /// Validates a refresh token and issues a fresh pair for the same
273    /// session, preserving the role recorded at login.
274    ///
275    /// # Errors
276    ///
277    /// Same as [`validate`](Self::validate).
278    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    /// Issues a fresh pair from already-validated claims. Split from
284    /// [`refresh`](Self::refresh) so the no-role branch is reachable in
285    /// tests with a hand-built claims value.
286    ///
287    /// # Errors
288    ///
289    /// When the claims carry no role from the current vocabulary, or the
290    /// subject/session ids are malformed.
291    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/// The OIDC provider facts the desktop login needs. The app supplies them
336/// (from its settings or the provider's discovery document).
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct OidcProviderConfig {
339    /// The provider's issuer URL (e.g. `https://id.example.com`).
340    pub issuer: String,
341    /// The authorization endpoint.
342    pub authorization_url: String,
343    /// The token endpoint (used by the service-side exchanger).
344    pub token_url: String,
345    /// The OIDC client id registered for the desktop app.
346    pub client_id: String,
347    /// Scopes to request; `openid email profile` is the floor.
348    pub scopes: Vec<String>,
349}
350
351/// The outcome of starting a desktop login: open `url` in a browser, then
352/// `wait()` for the loopback capture.
353pub struct LoginStart {
354    flow: oauth_toolkit::loopback::LoopbackFlow,
355}
356
357impl LoginStart {
358    /// The URL to open in the system browser.
359    #[must_use]
360    pub fn url(&self) -> &str {
361        self.flow.authorization_url()
362    }
363
364    /// Blocks for up to the flow's timeout waiting for the provider to call
365    /// back on the loopback listener.
366    ///
367    /// # Errors
368    ///
369    /// Propagates the kit's capture errors (timeout, state mismatch, deny).
370    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
377/// Exchanges an authorization code for OIDC tokens at the provider's token
378/// endpoint. The HTTP implementation belongs to the service slice; tests
379/// inject closures.
380pub trait TokenExchanger: Send + Sync {
381    /// The token response as a raw JSON value (the caller validates it via
382    /// the kit's OIDC validator).
383    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
407/// Starts a desktop OIDC login against `provider`: builds the PKCE pair and
408/// CSRF state via the kit, binds the loopback listener, and returns the URL
409/// to open. Complete the flow by waiting and exchanging the code.
410///
411/// # Errors
412///
413/// Propagates the kit's flow-start errors (bind failure, URL build).
414pub 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}