Skip to main content

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}