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}