dioxus_clerk/reverification.rs
1//! Step-up reverification: wrapping a gated action so that a clerk-reported
2//! "needs reverification" outcome triggers a re-auth prompt and, on success,
3//! resumes the action.
4//!
5//! The wrap → prompt → retry control flow lives in [`run_with_reverification`]
6//! as a pure combinator over two async closures (the gated action and the
7//! prompt) so `cargo test` covers it on the host without a browser. The public
8//! [`use_reverification`] hook wires the prompt to clerk-js's reverification UI.
9
10use crate::context::{ClerkContext, use_clerk_context};
11use crate::core::{ClerkError, ReverificationLevel};
12use dioxus::prelude::*;
13use std::future::Future;
14
15/// The result of a step-up reverification prompt handed back to
16/// [`run_with_reverification`].
17///
18/// Only the browser client (`handle.rs`) constructs these; off-client the
19/// prompt short-circuits to [`ClerkError::UnsupportedTarget`], so allow the
20/// variants to read as dead there.
21#[cfg_attr(not(clerk_client), allow(dead_code))]
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub(crate) enum ReverificationOutcome {
24 /// The user completed reverification; the gated action may retry.
25 Completed,
26 /// The user dismissed the prompt without completing it.
27 Cancelled,
28}
29
30/// Run a gated action, and if it reports that step-up reverification is needed,
31/// prompt for it and resume the action on success.
32///
33/// `fetcher` is the gated action; it may run twice (once, then again after a
34/// successful reverification), so it takes no arguments and is called fresh
35/// each time. `prompt` drives the reverification UI for a required level and is
36/// invoked at most once, only when the first `fetcher` call reports
37/// [`ClerkError::NeedsReverification`].
38///
39/// - action succeeds → its value, prompt untouched
40/// - action needs reverification, prompt completes → the action's retry result
41/// - action needs reverification, prompt cancels →
42/// [`ClerkError::ReverificationCancelled`]
43/// - any other action error, or a prompt error → propagated unchanged
44pub(crate) async fn run_with_reverification<T, Fetch, FetchFut, Prompt, PromptFut>(
45 fetcher: Fetch,
46 prompt: Prompt,
47) -> Result<T, ClerkError>
48where
49 Fetch: Fn() -> FetchFut,
50 FetchFut: Future<Output = Result<T, ClerkError>>,
51 Prompt: FnOnce(Option<ReverificationLevel>) -> PromptFut,
52 PromptFut: Future<Output = Result<ReverificationOutcome, ClerkError>>,
53{
54 match fetcher().await {
55 Err(ClerkError::NeedsReverification { level }) => match prompt(level).await? {
56 ReverificationOutcome::Completed => fetcher().await,
57 ReverificationOutcome::Cancelled => Err(ClerkError::ReverificationCancelled),
58 },
59 other => other,
60 }
61}
62
63/// Drive clerk-js's reverification UI for a required level, after Clerk
64/// lifecycle loadedness, and report whether the user completed or cancelled it.
65///
66/// Off the browser client clerk-js is unreachable, so a gated action that needs
67/// reverification fails with [`ClerkError::UnsupportedTarget`] rather than
68/// silently resolving.
69#[cfg(clerk_client)]
70async fn prompt_reverification(
71 ctx: ClerkContext,
72 level: Option<ReverificationLevel>,
73) -> Result<ReverificationOutcome, ClerkError> {
74 crate::lifecycle::run_async_bridge_action_after_loaded(ctx, move |bridge| async move {
75 bridge.open_reverification(level).await
76 })
77 .await
78}
79
80#[cfg(not(clerk_client))]
81async fn prompt_reverification(
82 _ctx: ClerkContext,
83 _level: Option<ReverificationLevel>,
84) -> Result<ReverificationOutcome, ClerkError> {
85 Err(ClerkError::UnsupportedTarget)
86}
87
88/// Reactive handle returned by [`use_reverification`], mirroring Clerk React's
89/// `useReverification`.
90///
91/// The handle is `Copy`, so it can be captured into event handlers. Wrap a
92/// sensitive action with [`UseReverification::guard`]; if the action reports
93/// that step-up reverification is needed, clerk-js's reverification UI is shown
94/// and the action resumes on success.
95#[derive(Clone, Copy)]
96pub struct UseReverification {
97 ctx: ClerkContext,
98}
99
100impl std::fmt::Debug for UseReverification {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("UseReverification").finish_non_exhaustive()
103 }
104}
105
106impl UseReverification {
107 /// Run a sensitive `action`, guarding it with step-up reverification.
108 ///
109 /// `action` is the gated call. Typically it is a `#[server]` function that
110 /// maps a clerk reverification hint onto [`ClerkError::NeedsReverification`]
111 /// via [`ClerkError::from_reverification_hint`]; an `action` calling clerk-js
112 /// directly instead constructs that variant from its own caught throw. When
113 /// `action` reports that reverification is needed, clerk-js
114 /// prompts the user for a
115 /// fresh factor and `action` is retried on success. `action` may therefore
116 /// run twice, so it takes no arguments and is invoked fresh each time.
117 ///
118 /// - action succeeds → its value
119 /// - action needs reverification, user completes it → the retry's result
120 /// - action needs reverification, user cancels →
121 /// [`ClerkError::ReverificationCancelled`]
122 /// - any other action error → propagated unchanged
123 ///
124 /// # Example
125 ///
126 /// ```no_run
127 /// use dioxus::prelude::*;
128 /// use dioxus_clerk::*;
129 ///
130 /// # async fn delete_account() -> Result<(), ClerkError> { Ok(()) }
131 /// #[component]
132 /// fn DangerZone() -> Element {
133 /// let reverify = use_reverification();
134 ///
135 /// rsx! {
136 /// button {
137 /// onclick: move |_| async move {
138 /// let _ = reverify.guard(|| delete_account()).await;
139 /// },
140 /// "Delete account"
141 /// }
142 /// }
143 /// }
144 /// ```
145 pub async fn guard<T, F, Fut>(&self, action: F) -> Result<T, ClerkError>
146 where
147 F: Fn() -> Fut,
148 Fut: Future<Output = Result<T, ClerkError>>,
149 {
150 let ctx = self.ctx;
151 run_with_reverification(action, move |level| prompt_reverification(ctx, level)).await
152 }
153}
154
155/// Guard sensitive actions behind Clerk step-up reverification.
156///
157/// Returns a [`UseReverification`] handle whose [`guard`](UseReverification::guard)
158/// method wraps an action so a clerk-reported "needs reverification" outcome
159/// prompts the user for a fresh authentication factor and resumes the action on
160/// success. Mirrors Clerk React's `useReverification`.
161pub fn use_reverification() -> UseReverification {
162 UseReverification {
163 ctx: use_clerk_context(),
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::core::ClerkError;
171 use futures_util::FutureExt;
172 use std::cell::Cell;
173
174 #[test]
175 fn success_returns_value_and_never_prompts() {
176 let prompted = Cell::new(false);
177
178 let result = run_with_reverification(
179 || async { Ok::<_, ClerkError>(7) },
180 |_level| async {
181 prompted.set(true);
182 Ok(ReverificationOutcome::Completed)
183 },
184 )
185 .now_or_never()
186 .expect("all futures resolve immediately");
187
188 assert_eq!(result, Ok(7));
189 assert!(!prompted.get(), "a successful action must not prompt");
190 }
191
192 // A fetcher that needs reverification on its first call and succeeds on the
193 // retry: the prompt runs once (seeing the level), the action runs twice,
194 // and the caller gets the retry's value.
195 #[test]
196 fn completed_prompt_retries_the_action() {
197 let calls = Cell::new(0u32);
198 let seen_level = Cell::new(None);
199
200 let result = run_with_reverification(
201 || async {
202 let n = calls.get();
203 calls.set(n + 1);
204 if n == 0 {
205 Err(ClerkError::NeedsReverification {
206 level: Some(ReverificationLevel::SecondFactor),
207 })
208 } else {
209 Ok(42)
210 }
211 },
212 |level| async {
213 seen_level.set(Some(level));
214 Ok(ReverificationOutcome::Completed)
215 },
216 )
217 .now_or_never()
218 .expect("all futures resolve immediately");
219
220 assert_eq!(result, Ok(42));
221 assert_eq!(calls.get(), 2, "the action retries after reverification");
222 assert_eq!(
223 seen_level.take(),
224 Some(Some(ReverificationLevel::SecondFactor)),
225 "the prompt receives the required level"
226 );
227 }
228
229 // A cancelled prompt maps to `ReverificationCancelled` and does not retry.
230 #[test]
231 fn cancelled_prompt_yields_cancelled_and_does_not_retry() {
232 let calls = Cell::new(0u32);
233
234 let result = run_with_reverification(
235 || async {
236 calls.set(calls.get() + 1);
237 Err::<i32, _>(ClerkError::NeedsReverification { level: None })
238 },
239 |_level| async { Ok(ReverificationOutcome::Cancelled) },
240 )
241 .now_or_never()
242 .expect("all futures resolve immediately");
243
244 assert_eq!(result, Err(ClerkError::ReverificationCancelled));
245 assert_eq!(calls.get(), 1, "a cancelled prompt must not retry");
246 }
247
248 // Any non-reverification error passes straight through, untouched, and the
249 // prompt is never shown.
250 #[test]
251 fn other_errors_pass_through_without_prompting() {
252 let prompted = Cell::new(false);
253
254 let result = run_with_reverification(
255 || async { Err::<i32, _>(ClerkError::Unauthenticated) },
256 |_level| async {
257 prompted.set(true);
258 Ok(ReverificationOutcome::Completed)
259 },
260 )
261 .now_or_never()
262 .expect("all futures resolve immediately");
263
264 assert_eq!(result, Err(ClerkError::Unauthenticated));
265 assert!(
266 !prompted.get(),
267 "a non-reverification error must not prompt"
268 );
269 }
270
271 // A prompt that fails to run (e.g. clerk-js not loaded) propagates its own
272 // error rather than retrying or masking it as cancelled.
273 #[test]
274 fn prompt_error_propagates_without_retry() {
275 let calls = Cell::new(0u32);
276
277 let result = run_with_reverification(
278 || async {
279 calls.set(calls.get() + 1);
280 Err::<i32, _>(ClerkError::NeedsReverification { level: None })
281 },
282 |_level| async { Err(ClerkError::NotLoaded) },
283 )
284 .now_or_never()
285 .expect("all futures resolve immediately");
286
287 assert_eq!(result, Err(ClerkError::NotLoaded));
288 assert_eq!(calls.get(), 1, "a failed prompt must not retry the action");
289 }
290}