Skip to main content

cheers_axum/
error.rs

1//! Route-level errors. Each handler converts a typed `RouteError` into a
2//! JSON response with a stable code so a frontend can switch on it without
3//! string-matching messages.
4
5use axum::Json;
6use axum::http::StatusCode;
7use axum::response::{IntoResponse, Response};
8use cheers_core::Scope;
9use cheers_server::{
10    AuditValidationError, CampAuthorityError, OwnershipValidationError, ServicePrincipalError,
11};
12use serde::Serialize;
13
14/// What can go wrong inside a cheers-axum route handler.
15///
16/// Each variant maps to a stable JSON body + HTTP status — see
17/// [`RouteError::status_and_code`].
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum RouteError {
21    /// No CSRF cookie on the callback request.
22    #[error("missing csrf cookie")]
23    MissingCsrfCookie,
24
25    /// CSRF cookie present but its value doesn't match the `?state=` param.
26    /// Either a CSRF attempt or a cross-tab race.
27    #[error("csrf cookie / state mismatch")]
28    CsrfStateMismatch,
29
30    /// The OIDC flow keyed on `?state=` was never stashed (or already
31    /// consumed). Most likely a replay of an old callback URL.
32    #[error("unknown or already-consumed flow")]
33    UnknownFlow,
34
35    /// The OIDC flow stored at `begin()` exceeded its TTL before the
36    /// callback arrived.
37    #[error("flow expired")]
38    FlowExpired,
39
40    /// The IdP signaled an error in the callback (`error=access_denied`, …).
41    #[error("provider error: {0}")]
42    Provider(String),
43
44    /// `id_token` verification failed (signature, audience, nonce, expiry).
45    #[error("id_token verification failed: {0}")]
46    IdToken(String),
47
48    /// Network / HTTP failure talking to the IdP's token endpoint.
49    #[error("upstream http: {0}")]
50    Upstream(String),
51
52    /// Backend store failure — UserStore, RefreshStore, or RevocationWriter
53    /// returned an error.
54    #[error("store: {0}")]
55    Store(String),
56
57    /// Catch-all for misconfiguration the handler can't continue past
58    /// (missing token endpoint, wrong client_secret shape, …).
59    #[error("config: {0}")]
60    Config(String),
61
62    /// Apple's form-post body was missing a required field.
63    #[error("malformed callback body: {0}")]
64    MalformedCallback(String),
65
66    /// A passkey registration/authentication ceremony failed — bad challenge,
67    /// signature, origin, or excluded credential. The client may retry from a
68    /// fresh `start_*`.
69    #[error("ceremony failed: {0}")]
70    Ceremony(String),
71
72    /// No stored passkey matched the asserted credential id — either the user
73    /// has no registered passkeys or the assertion came from a credential they
74    /// do not own. The login must be rejected.
75    #[error("unknown credential")]
76    UnknownCredential,
77
78    /// Magic-link token replayed after consumption.
79    #[error("token already used")]
80    AlreadyUsed,
81
82    /// Magic-link request received a malformed email address.
83    #[error("invalid email")]
84    InvalidEmail,
85
86    /// Magic-link token decoded but its `purpose` claim was unexpected — a
87    /// session token presented to a magic-link verify slot, or vice versa.
88    #[error("wrong token purpose: {0}")]
89    WrongPurpose(String),
90
91    /// Magic-link token failed PASETO decryption / signature / expiry checks.
92    #[error("magic-link token invalid: {0}")]
93    MagicLinkToken(String),
94
95    /// Mailer rejected the message (bad address shape, body encoding, …) —
96    /// typically a deploy-time bug.
97    #[error("mailer build: {0}")]
98    MailerBuild(String),
99
100    /// Mailer transport failed (SMTP error, network error, auth refused) —
101    /// may be transient; retry policy is the caller's choice.
102    #[error("mailer transport: {0}")]
103    MailerTransport(String),
104
105    /// `Authorization` header absent on a route that needs a bearer token.
106    #[error("missing bearer token")]
107    MissingBearer,
108
109    /// `Authorization` header present but not in `Bearer <token>` form (wrong
110    /// scheme, non-ASCII bytes, empty token).
111    #[error("malformed bearer token")]
112    MalformedBearer,
113
114    /// Bearer token failed verification at the edge — bad signature, expired,
115    /// or revoked. Collapsed into one variant on purpose: leaking which of the
116    /// three a token tripped is a small but real oracle.
117    #[error("unauthorized")]
118    Unauthorized,
119
120    /// `DELETE /me/sessions/{device_id}` targeted a device the user does not
121    /// own. 404 so a probe can't enumerate other users' device ids.
122    #[error("unknown device")]
123    UnknownDevice,
124
125    /// `DELETE /me/tokens/{id}` targeted a token id the user does not own, or
126    /// that does not exist. 404, and the two are deliberately the same answer
127    /// — same reason [`UnknownDevice`](Self::UnknownDevice) is: a probe must
128    /// not be able to enumerate other users' token ids.
129    #[error("unknown token")]
130    UnknownToken,
131
132    /// `POST /me/tokens` asked for scopes the caller does not currently hold
133    /// for the requested audience. 400, naming every unheld scope — a token
134    /// silently narrower than the one requested is a debugging trap that
135    /// surfaces days later as an unexplained 403.
136    #[error("unentitled_scopes: {0}")]
137    UnentitledScopes(String),
138
139    /// `POST /me/tokens` named an audience the caller holds no grant for at
140    /// all (composition rule (5)). 403 — the caller is authenticated, they
141    /// simply have no entitlement to attenuate from, so there is no token
142    /// that could be minted. Distinct from
143    /// [`UnentitledScopes`](Self::UnentitledScopes), which is "you hold
144    /// *something* here, just not that".
145    #[error("aud_not_entitled: {0}")]
146    NotEntitledForAud(String),
147
148    /// `POST /me/tokens` body parsed as JSON but was invalid: empty `name`, a
149    /// scope string outside the closed vocabulary, or an `expires_in_secs`
150    /// outside the policy range. 400 — distinct from 401/403 so a caller can
151    /// tell a bad request from an auth failure.
152    #[error("invalid_token_request: {0}")]
153    InvalidTokenRequest(String),
154
155    /// An MCP-call token authenticated but lacks a scope the handler requires
156    /// (e.g. `camp:read` on a camp route). 403 — the principal is
157    /// known, the request is just not authorized.
158    #[error("insufficient scope: required '{required}'")]
159    InsufficientScope { required: Scope },
160
161    /// `POST /ownership` body parsed as JSON but violated a NewOwnership
162    /// invariant (e.g. `on_behalf_of` named a non-user principal). 400 —
163    /// distinct from 401/403 so a caller can tell auth failure from a
164    /// well-formed-but-invalid row.
165    #[error("ownership_invalid: {0}")]
166    OwnershipInvalid(#[from] OwnershipValidationError),
167
168    /// `GET /ownership` named neither a resource nor a subject, only part of
169    /// one, or both forms at once. 400.
170    #[error("invalid ownership query: {0}")]
171    InvalidOwnershipQuery(String),
172
173    /// `DELETE /ownership/{id}` targeted an id no [`OwnershipStore`] row
174    /// matches. 404 — the absence is reported, not the cause.
175    ///
176    /// [`OwnershipStore`]: cheers_server::OwnershipStore
177    #[error("unknown ownership")]
178    UnknownOwnership,
179
180    /// The caller holds no relation on the resource whose grants include the
181    /// relation it tried to write, revoke, or (for a list) any relation at
182    /// all — the D4 grant door's refusal. 403: the caller is known.
183    #[error("grant forbidden")]
184    GrantForbidden,
185
186    /// `POST /audit/ingest` body parsed as JSON but a record violated an
187    /// [`AuditRecord`] invariant (e.g. empty `aud`, non-positive `at`). 400
188    /// — distinct from 401/403 so kamaji can tell auth failure from a
189    /// well-formed-but-invalid batch (do not retry as-is).
190    ///
191    /// [`AuditRecord`]: cheers_server::AuditRecord
192    #[error("audit_invalid: {0}")]
193    AuditInvalid(#[from] AuditValidationError),
194
195    /// `GET /audit/by-on-behalf-of/{user}` was addressed to a principal the
196    /// caller may not read: a user asking for a different user's history, or
197    /// a principal kind with no defined audit subject (camp). 403 — the
198    /// caller authenticated and holds `audit:read`, it just may not read
199    /// *this* subject. Distinct from
200    /// [`InsufficientScope`](Self::InsufficientScope), which is about the
201    /// token lacking the scope at all.
202    #[error("not permitted to read this audit subject")]
203    AuditSubjectForbidden,
204
205    /// `GET /audit/by-on-behalf-of/{user}` carried a malformed path
206    /// principal, a non-user principal, or a cursor that didn't decode.
207    /// 400 — well-formed-but-invalid input the caller can fix without
208    /// re-authenticating (a stale cursor is fixed by dropping it and
209    /// restarting the page walk).
210    #[error("invalid audit query: {0}")]
211    InvalidAuditQuery(String),
212
213    /// `POST /admin/service-principals` collided with an existing principal
214    /// id. 409 — same shape as a database unique-key conflict.
215    #[error("already exists: {0}")]
216    AlreadyExists(String),
217
218    /// `POST /admin/service-principals/{id}/rotate` (or similar) named a
219    /// principal cheers doesn't know. 404.
220    #[error("unknown principal: {0}")]
221    UnknownPrincipal(String),
222
223    /// An authenticated session bearer reached an admin endpoint, but the
224    /// authenticated user is not on the product's operator list. 403 — the
225    /// caller proved who they are, they're just not authorized for this
226    /// surface. Distinct from [`InsufficientScope`](Self::InsufficientScope)
227    /// (which is the MCP/scope equivalent) so admin endpoints can stay
228    /// scope-free without conflating the two.
229    #[error("not an operator")]
230    NotOperator,
231
232    /// `POST /admin/camps/bootstrap` rejected a user-signed delegation for
233    /// reasons distinct from auth failure: the delegation's `bound_to` /
234    /// `camp_id` did not match the provision request, it had already
235    /// expired (`expires_at <= now`), or it failed an invariant. 400 —
236    /// well-formed-but-invalid input, callable to fix without re-auth.
237    #[error("invalid delegation: {0}")]
238    InvalidDelegation(String),
239}
240
241impl RouteError {
242    /// HTTP status + stable error code for this variant.
243    pub fn status_and_code(&self) -> (StatusCode, &'static str) {
244        match self {
245            RouteError::MissingCsrfCookie => (StatusCode::BAD_REQUEST, "missing_csrf_cookie"),
246            RouteError::CsrfStateMismatch => (StatusCode::BAD_REQUEST, "csrf_state_mismatch"),
247            RouteError::UnknownFlow => (StatusCode::BAD_REQUEST, "unknown_flow"),
248            RouteError::FlowExpired => (StatusCode::BAD_REQUEST, "flow_expired"),
249            RouteError::Provider(_) => (StatusCode::BAD_REQUEST, "provider_error"),
250            RouteError::IdToken(_) => (StatusCode::UNAUTHORIZED, "id_token_invalid"),
251            RouteError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
252            RouteError::Store(_) => (StatusCode::INTERNAL_SERVER_ERROR, "store_error"),
253            RouteError::Config(_) => (StatusCode::INTERNAL_SERVER_ERROR, "config_error"),
254            RouteError::MalformedCallback(_) => {
255                (StatusCode::BAD_REQUEST, "malformed_callback")
256            }
257            RouteError::Ceremony(_) => (StatusCode::BAD_REQUEST, "ceremony_failed"),
258            RouteError::UnknownCredential => (StatusCode::UNAUTHORIZED, "unknown_credential"),
259            RouteError::AlreadyUsed => (StatusCode::BAD_REQUEST, "already_used"),
260            RouteError::InvalidEmail => (StatusCode::BAD_REQUEST, "invalid_email"),
261            RouteError::WrongPurpose(_) => (StatusCode::BAD_REQUEST, "wrong_purpose"),
262            RouteError::MagicLinkToken(_) => (StatusCode::BAD_REQUEST, "magic_link_token"),
263            RouteError::MailerBuild(_) => (StatusCode::INTERNAL_SERVER_ERROR, "mailer_build"),
264            RouteError::MailerTransport(_) => (StatusCode::BAD_GATEWAY, "mailer_transport"),
265            RouteError::MissingBearer => (StatusCode::UNAUTHORIZED, "missing_bearer"),
266            RouteError::MalformedBearer => (StatusCode::UNAUTHORIZED, "malformed_bearer"),
267            RouteError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
268            RouteError::UnknownDevice => (StatusCode::NOT_FOUND, "unknown_device"),
269            RouteError::UnknownToken => (StatusCode::NOT_FOUND, "unknown_token"),
270            RouteError::UnentitledScopes(_) => (StatusCode::BAD_REQUEST, "unentitled_scopes"),
271            RouteError::NotEntitledForAud(_) => (StatusCode::FORBIDDEN, "aud_not_entitled"),
272            RouteError::InvalidTokenRequest(_) => {
273                (StatusCode::BAD_REQUEST, "invalid_token_request")
274            }
275            RouteError::InsufficientScope { .. } => {
276                (StatusCode::FORBIDDEN, "insufficient_scope")
277            }
278            RouteError::OwnershipInvalid(_) => (StatusCode::BAD_REQUEST, "ownership_invalid"),
279            RouteError::InvalidOwnershipQuery(_) => {
280                (StatusCode::BAD_REQUEST, "invalid_ownership_query")
281            }
282            RouteError::UnknownOwnership => (StatusCode::NOT_FOUND, "unknown_ownership"),
283            RouteError::GrantForbidden => (StatusCode::FORBIDDEN, "grant_forbidden"),
284            RouteError::AuditInvalid(_) => (StatusCode::BAD_REQUEST, "audit_invalid"),
285            RouteError::AuditSubjectForbidden => {
286                (StatusCode::FORBIDDEN, "audit_subject_forbidden")
287            }
288            RouteError::InvalidAuditQuery(_) => (StatusCode::BAD_REQUEST, "invalid_audit_query"),
289            RouteError::AlreadyExists(_) => (StatusCode::CONFLICT, "already_exists"),
290            RouteError::UnknownPrincipal(_) => (StatusCode::NOT_FOUND, "unknown_principal"),
291            RouteError::NotOperator => (StatusCode::FORBIDDEN, "not_operator"),
292            RouteError::InvalidDelegation(_) => (StatusCode::BAD_REQUEST, "invalid_delegation"),
293        }
294    }
295}
296
297#[derive(Serialize)]
298struct RouteErrorBody<'a> {
299    error: &'a str,
300    message: String,
301}
302
303impl IntoResponse for RouteError {
304    fn into_response(self) -> Response {
305        let (status, code) = self.status_and_code();
306        // The Display impl is already redaction-safe (Provider/IdToken/Upstream
307        // text comes from upstreams we trust to not echo secrets). We DO NOT
308        // include the source chain — that can leak internal hostnames.
309        let body = RouteErrorBody {
310            error: code,
311            message: self.to_string(),
312        };
313        tracing::warn!(error = code, message = %self, "cheers-axum route error");
314        (status, Json(body)).into_response()
315    }
316}
317
318impl From<cheers_core::StoreError> for RouteError {
319    fn from(value: cheers_core::StoreError) -> Self {
320        RouteError::Store(value.to_string())
321    }
322}
323
324impl From<cheers_core::Error> for RouteError {
325    fn from(value: cheers_core::Error) -> Self {
326        // cheers_core::Error covers codec / store / input — any of those
327        // surfacing in a handler is an integration bug, not a user problem.
328        RouteError::Store(value.to_string())
329    }
330}
331
332impl From<CampAuthorityError> for RouteError {
333    fn from(value: CampAuthorityError) -> Self {
334        // Three client-distinguishable outcomes:
335        // - AlreadyExists (the camp id is taken)            → 409
336        // - InvalidDelegation/Mismatch/Expired              → 400
337        // - UntrustedSigningKey/BadSignature                → 401
338        // Everything else (WrongPrincipalKind, Principal, Store) is a
339        // programmer / integration bug at this surface and collapses to 500
340        // through `Store(...)` — same shape as ServicePrincipalError below.
341        match value {
342            CampAuthorityError::AlreadyExists(id) => RouteError::AlreadyExists(id.to_string()),
343            CampAuthorityError::InvalidDelegation(e) => RouteError::InvalidDelegation(e.to_string()),
344            CampAuthorityError::DelegationMismatch(msg) => {
345                RouteError::InvalidDelegation(msg.to_owned())
346            }
347            CampAuthorityError::DelegationExpired => {
348                RouteError::InvalidDelegation("delegation expired".into())
349            }
350            CampAuthorityError::UntrustedSigningKey(_)
351            | CampAuthorityError::BadSignature => RouteError::Unauthorized,
352            CampAuthorityError::Store(e) => RouteError::Store(e.to_string()),
353            other => RouteError::Store(other.to_string()),
354        }
355    }
356}
357
358impl From<ServicePrincipalError> for RouteError {
359    fn from(value: ServicePrincipalError) -> Self {
360        // AlreadyExists / UnknownPrincipal are the only client-distinguishable
361        // outcomes — everything else (wrong-kind, no-active-key, codec failure,
362        // unexpected store error) is a programmer/integration bug at this
363        // surface and collapses to 500.
364        match value {
365            ServicePrincipalError::AlreadyExists(id) => RouteError::AlreadyExists(id.to_string()),
366            ServicePrincipalError::UnknownPrincipal(id) => {
367                RouteError::UnknownPrincipal(id.to_string())
368            }
369            ServicePrincipalError::Store(e) => RouteError::Store(e.to_string()),
370            other => RouteError::Store(other.to_string()),
371        }
372    }
373}
374
375#[cfg(any(feature = "google", feature = "apple"))]
376impl From<cheers::providers::oidc_generic::OidcError> for RouteError {
377    fn from(value: cheers::providers::oidc_generic::OidcError) -> Self {
378        use cheers::providers::oidc_generic::OidcError;
379        match value {
380            OidcError::UnknownFlow => RouteError::UnknownFlow,
381            OidcError::FlowExpired => RouteError::FlowExpired,
382            OidcError::StateMismatch => RouteError::CsrfStateMismatch,
383            OidcError::Http(msg) => RouteError::Upstream(msg),
384            OidcError::IdToken(msg) => RouteError::IdToken(msg),
385            OidcError::MissingIdToken => RouteError::IdToken("missing id_token".into()),
386            OidcError::Discovery(msg) | OidcError::Config(msg) => RouteError::Config(msg),
387            OidcError::Store(msg) => RouteError::Store(msg),
388            // OidcError is #[non_exhaustive]; new variants land in this catchall
389            // as Config until the bridge gets a dedicated mapping.
390            other => RouteError::Config(other.to_string()),
391        }
392    }
393}
394
395#[cfg(feature = "passkey")]
396impl From<cheers::passkey::PasskeyError> for RouteError {
397    fn from(value: cheers::passkey::PasskeyError) -> Self {
398        use cheers::passkey::PasskeyError;
399        match value {
400            PasskeyError::Config(e) => RouteError::Config(e.to_string()),
401            PasskeyError::Ceremony(e) => RouteError::Ceremony(e.to_string()),
402            // Serialize/Deserialize/WrongBinding surface only when stored
403            // credentials disagree with the live code path — a store-side bug,
404            // not a client-side one.
405            PasskeyError::Serialize(e) => RouteError::Store(format!("passkey serialize: {e}")),
406            PasskeyError::Deserialize(e) => {
407                RouteError::Store(format!("passkey deserialize: {e}"))
408            }
409            PasskeyError::WrongBinding { found } => {
410                RouteError::Store(format!("stored credential binding {found:?} is not passkey"))
411            }
412            other => RouteError::Config(other.to_string()),
413        }
414    }
415}
416
417#[cfg(feature = "email")]
418impl From<cheers::email::magic_link::MagicLinkError> for RouteError {
419    fn from(value: cheers::email::magic_link::MagicLinkError) -> Self {
420        use cheers::email::magic_link::MagicLinkError;
421        match value {
422            MagicLinkError::Codec(e) => RouteError::MagicLinkToken(e.to_string()),
423            MagicLinkError::WrongPurpose { got } => RouteError::WrongPurpose(got),
424            MagicLinkError::AlreadyUsed => RouteError::AlreadyUsed,
425            MagicLinkError::InvalidEmail => RouteError::InvalidEmail,
426            MagicLinkError::Store(msg) => RouteError::Store(msg),
427            other => RouteError::Config(other.to_string()),
428        }
429    }
430}
431
432#[cfg(feature = "email")]
433impl From<cheers::email::MailerError> for RouteError {
434    fn from(value: cheers::email::MailerError) -> Self {
435        use cheers::email::MailerError;
436        match value {
437            MailerError::Build(msg) => RouteError::MailerBuild(msg),
438            MailerError::Transport(msg) => RouteError::MailerTransport(msg),
439            other => RouteError::MailerTransport(other.to_string()),
440        }
441    }
442}
443
444#[cfg(feature = "apple")]
445impl From<cheers::providers::apple::AppleRedirectError> for RouteError {
446    fn from(value: cheers::providers::apple::AppleRedirectError) -> Self {
447        use cheers::providers::apple::AppleRedirectError;
448        match value {
449            AppleRedirectError::Oidc(e) => e.into(),
450            AppleRedirectError::MissingFormField(f) => {
451                RouteError::MalformedCallback(format!("missing field `{f}`"))
452            }
453            AppleRedirectError::Provider(msg) => RouteError::Provider(msg),
454            AppleRedirectError::ClientSecret(e) => RouteError::Config(e.to_string()),
455            // Same reason as the OidcError catchall: AppleRedirectError is
456            // #[non_exhaustive], so we tolerate new variants surfacing as
457            // Config until a dedicated mapping lands.
458            other => RouteError::Config(other.to_string()),
459        }
460    }
461}