Skip to main content

dioxus_clerk/core/
error.rs

1//! Unified error type for the dioxus-clerk family of crates.
2
3use super::reverification::ReverificationLevel;
4use super::verification::InvalidTokenReason;
5use serde_json::Value;
6use thiserror::Error;
7
8/// Errors produced anywhere in the dioxus-clerk stack.
9///
10/// Variants store only owned strings (no `JsValue`, no transport-specific
11/// error sources) so this crate stays target-neutral, and errors stay
12/// `Clone + PartialEq + Eq` for signal storage and test assertions. This is a
13/// deliberate design: causes are flattened into the message at the boundary
14/// where they occur, and `Error::source()` is always `None`. Conversion impls
15/// live in the consumer modules.
16#[derive(Debug, Clone, Error, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum ClerkError {
19    /// Clerk-js has not finished loading yet.
20    #[error("clerk has not finished loading")]
21    NotLoaded,
22
23    /// The browser Clerk lifecycle could not make progress before a deadline:
24    /// a hung `Clerk.load()`, or a lifecycle that never started. Distinct from
25    /// [`ClerkError::NotLoaded`], which is the transient still-loading state a
26    /// caller can wait out.
27    #[error("clerk lifecycle timed out: {0}")]
28    Timeout(String),
29
30    /// A browser-only Clerk action was awaited on a target where clerk-js can
31    /// never load (server or native builds).
32    #[error("clerk-js is not available on this build target")]
33    UnsupportedTarget,
34
35    /// The clerk-js script failed to load or did not become ready in time.
36    #[error("clerk-js failed to load: {0}")]
37    ScriptLoad(String),
38
39    /// The browser is offline, so clerk-js could not fetch a fresh session
40    /// token.
41    ///
42    /// clerk-js 6 throws `ClerkOfflineError` from `session.getToken()` in this
43    /// case, where 5.x returned `null`. Surfaced as a distinct, transient
44    /// variant so callers can retry or fall back to a cached token instead of
45    /// treating it as a hard failure or a signed-out state.
46    #[error("clerk is offline")]
47    Offline,
48
49    /// Request has no session/credentials.
50    #[error("unauthenticated")]
51    Unauthenticated,
52
53    /// Session JWT is past its `exp` claim.
54    #[error("session token expired")]
55    TokenExpired,
56
57    /// Server could not fetch or refresh JWKS from Clerk's Backend API.
58    ///
59    /// The message is intentionally coarse: this error's `Display` can reach
60    /// HTTP responses. The verification layer logs the underlying cause at
61    /// `warn` level via `tracing`.
62    #[error("clerk jwks unavailable: {0}")]
63    JwksUnavailable(String),
64
65    /// A server context reader was called outside a server function or SSR scope.
66    #[error(
67        "no server context available; Clerk auth server context methods must be called inside a server function or SSR scope"
68    )]
69    NoServerContext,
70
71    /// Configuration was invalid (missing key, malformed env, etc.).
72    #[error("invalid clerk configuration: {0}")]
73    InvalidConfig(String),
74
75    /// A gated action needs step-up reverification: the user must re-assert a
76    /// fresh authentication factor before it can proceed. Carries the required
77    /// [`ReverificationLevel`] when clerk reported one.
78    ///
79    /// Consumed by the reverification hook to trigger a re-auth prompt and
80    /// resume the action. Produced from either clerk reverification signal:
81    ///
82    /// - the server-side path: a gated `#[server]` action surfaces a 403
83    ///   reverification *hint* (JSON), which
84    ///   [`ClerkError::from_reverification_hint`] maps, recovering the level;
85    /// - the client-side path: a direct clerk-js call *throws* a
86    ///   `ClerkAPIResponseError` carrying the `session_reverification_required`
87    ///   code, which a caller maps into this variant. The throw does not carry
88    ///   the level, so that path yields `level: None`, matching
89    ///   clerk-react's `useReverification`.
90    #[error("reverification required")]
91    NeedsReverification {
92        /// The authentication-factor level the reverification requires, when
93        /// clerk reported one.
94        level: Option<ReverificationLevel>,
95    },
96
97    /// The user dismissed the step-up reverification prompt without completing
98    /// it, so the gated action did not run. Mirrors clerk-js's
99    /// `reverification_cancelled` runtime error.
100    #[error("reverification cancelled")]
101    ReverificationCancelled,
102
103    /// JS interop failure (wasm-bindgen / clerk-js threw).
104    #[error("clerk js error: {0}")]
105    Js(String),
106}
107
108impl ClerkError {
109    /// Recognize a clerk step-up reverification hint and map it to
110    /// [`ClerkError::NeedsReverification`], carrying the required level.
111    ///
112    /// A gated `#[server]` action (or any caller reading a clerk API response as
113    /// JSON) hits a 403 reverification hint of the shape emitted by clerk's
114    /// `clerk_render_reverification` and recognized by `@clerk/shared`'s
115    /// `isReverificationHint`:
116    ///
117    /// ```json
118    /// { "clerk_error": {
119    ///     "type": "forbidden",
120    ///     "reason": "reverification-error",
121    ///     "metadata": { "reverification": { "level": "second_factor" } } } }
122    /// ```
123    ///
124    /// Returns `None` for any value that is not such a hint, so a caller can map
125    /// only the reverification case and pass every other error through
126    /// unchanged.
127    pub fn from_reverification_hint(value: &Value) -> Option<Self> {
128        let clerk_error = value.get("clerk_error")?;
129        if clerk_error.get("type").and_then(Value::as_str) != Some("forbidden")
130            || clerk_error.get("reason").and_then(Value::as_str) != Some("reverification-error")
131        {
132            return None;
133        }
134
135        let level = clerk_error
136            .pointer("/metadata/reverification/level")
137            .and_then(Value::as_str)
138            .map(ReverificationLevel::from);
139
140        Some(Self::NeedsReverification { level })
141    }
142}
143
144/// Maps a token-verification failure reason to the error callers act on.
145///
146/// Only [`InvalidTokenReason::Expired`] maps to [`ClerkError::TokenExpired`];
147/// other reasons (including `NotYetValid`) collapse into
148/// [`ClerkError::Unauthenticated`], because expiry is the one case callers can
149/// meaningfully act on (prompt a re-authentication).
150impl From<InvalidTokenReason> for ClerkError {
151    fn from(reason: InvalidTokenReason) -> Self {
152        match reason {
153            InvalidTokenReason::Expired => Self::TokenExpired,
154            _ => Self::Unauthenticated,
155        }
156    }
157}