Skip to main content

cheers_core/
error.rs

1//! Workspace-wide typed errors.
2//!
3//! Per-module errors ([`CodecError`], [`StoreError`]) stay local to the modules
4//! that raise them — they describe failures a single subsystem can own. [`Error`]
5//! is the umbrella type for functions that touch more than one subsystem (e.g. a
6//! sign-in handler that loads a user *and* mints a token); it carries the
7//! original typed cause via `#[from]` so callers can downcast when they need to.
8//!
9//! All the error *types* live here in `cheers-core`, even when the machinery that
10//! raises them lives in a higher crate: [`RefreshError`] is produced by
11//! `cheers-server`'s refresh rotator, but the type is keyless, so keeping it in
12//! the shared contract crate lets the [`Error`] umbrella stay whole (both the
13//! `cheers-verify` `EdgeVerifier` and the `cheers-server` `SessionAuthority`
14//! return this one `Error`).
15//!
16//! ```
17//! use cheers_core::{CodecError, Error};
18//!
19//! fn outer() -> Result<(), Error> {
20//!     // CodecError -> Error via the From impl.
21//!     Err(CodecError::Malformed)?
22//! }
23//!
24//! assert!(matches!(outer(), Err(Error::Codec(CodecError::Malformed))));
25//! ```
26
27use crate::codec::CodecError;
28use crate::store::StoreError;
29
30/// Errors raised by refresh-token rotation (`cheers-server`'s `RefreshRotator`).
31///
32/// Keyless, so it lives in the shared contract crate. `#[non_exhaustive]` so
33/// future variants don't break callers.
34#[derive(Debug, thiserror::Error)]
35#[non_exhaustive]
36pub enum RefreshError {
37    /// The presented token isn't in the store.
38    #[error("unknown refresh token")]
39    Unknown,
40    /// `expires_at <= now` for this token.
41    #[error("refresh token expired")]
42    Expired,
43    /// The presented token has already been rotated. The chain is now revoked as
44    /// a side effect — every record sharing the chain id has `revoked = true`
45    /// after this error returns.
46    #[error("replay detected; chain revoked")]
47    Replay,
48    /// The chain was previously revoked (logout, device revoke, prior replay).
49    #[error("chain revoked")]
50    ChainRevoked,
51    /// Underlying `RefreshStore` failure.
52    #[error(transparent)]
53    Store(#[from] StoreError),
54}
55
56/// Top-level cheers error. `#[non_exhaustive]` so new variants are non-breaking.
57#[derive(Debug, thiserror::Error)]
58#[non_exhaustive]
59pub enum Error {
60    /// Failure inside the [`Codec`](crate::codec::Codec) layer.
61    #[error(transparent)]
62    Codec(#[from] CodecError),
63
64    /// Failure inside a [`UserStore`]/[`CredentialStore`](crate::store::CredentialStore)/`RefreshStore`
65    /// impl.
66    ///
67    /// [`UserStore`]: crate::store
68    #[error(transparent)]
69    Store(#[from] StoreError),
70
71    /// Failure inside refresh-token rotation — surfaced by `cheers-server`'s
72    /// `SessionAuthority::rotate`.
73    #[error(transparent)]
74    Refresh(#[from] RefreshError),
75
76    /// A token verified cryptographically but its `jti` is in the revocation
77    /// set — surfaced by `cheers-verify`'s `EdgeVerifier`.
78    #[error("session revoked")]
79    Revoked,
80
81    /// A token verified cryptographically but is not bound to the peer public
82    /// key the caller presented — either it carries no
83    /// [`peer_key`](crate::Claims::peer_key) at all, or it names a different
84    /// one. Surfaced by `cheers-verify`'s `EdgeVerifier::verify_bound_at`
85    /// (R515): the token holder is not the connecting peer.
86    #[error("token is not bound to the presented peer key")]
87    PeerKeyMismatch,
88
89    /// Caller passed invalid input that no specific subsystem owns
90    /// (e.g. an empty subject string, a timestamp outside i64 range).
91    #[error("invalid input: {0}")]
92    InvalidInput(String),
93}
94
95/// Crate-local `Result` alias. Re-exported at the crate root.
96pub type Result<T> = std::result::Result<T, Error>;
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn codec_error_converts_via_from() {
104        let e: Error = CodecError::Malformed.into();
105        assert!(matches!(e, Error::Codec(CodecError::Malformed)));
106    }
107
108    #[test]
109    fn store_error_converts_via_from() {
110        let e: Error = StoreError::NotFound.into();
111        assert!(matches!(e, Error::Store(StoreError::NotFound)));
112    }
113
114    #[test]
115    fn refresh_error_converts_via_from() {
116        let e: Error = RefreshError::Replay.into();
117        assert!(matches!(e, Error::Refresh(RefreshError::Replay)));
118        // RefreshError itself absorbs a StoreError.
119        let e: Error = RefreshError::from(StoreError::Conflict).into();
120        assert!(matches!(e, Error::Refresh(RefreshError::Store(StoreError::Conflict))));
121    }
122
123    #[test]
124    fn question_mark_propagates() {
125        fn inner() -> Result<()> {
126            Err(StoreError::Conflict)?
127        }
128        assert!(matches!(inner(), Err(Error::Store(StoreError::Conflict))));
129    }
130
131    #[test]
132    fn invalid_input_displays_message() {
133        let e = Error::InvalidInput("subject empty".into());
134        assert_eq!(format!("{e}"), "invalid input: subject empty");
135    }
136
137    #[test]
138    fn error_is_send_sync_static() {
139        fn _check<T: Send + Sync + 'static>() {}
140        _check::<Error>();
141    }
142}