Skip to main content

fizzy_sdk/
magic_link.rs

1//! Signing in without a password. Fizzy sends a code to an email address and, once the
2//! person types it back, hands out a session token for [`crate::CookieAuth`].
3//!
4//! The two steps are one exchange as far as Fizzy is concerned: creating the session sets
5//! a `pending_authentication_token` cookie naming the address the code went to, and
6//! redeeming the code has to send that cookie back or the code is refused. The flow keeps
7//! the token between the calls, and hands it out for a caller that has to finish in
8//! another process — a CLI that prompts for the code on its next run.
9//!
10//! ```no_run
11//! use fizzy_sdk::{Client, Config, MagicLinkFlow};
12//!
13//! # async fn run(code: &str) -> Result<(), fizzy_sdk::Error> {
14//! let flow = MagicLinkFlow::new(Config::default())?;
15//! flow.create_session("jane@example.com").await?;
16//! // ... the person reads the code out of their email ...
17//! let session = flow.redeem(code).await?;
18//! let client = Client::builder(Config::default())
19//!     .session_token(session.session_token.clone())
20//!     .build()?;
21//! # let _ = client;
22//! # Ok(())
23//! # }
24//! ```
25//!
26//! # What the flow adds to the generated operations
27//!
28//! The model documents the cookie but cannot send it: the generated
29//! [`crate::services::sessions::SessionsService::redeem_magic_link`] sends the code alone, so
30//! it completes a sign-in only on a client that already carries the pending cookie. The flow
31//! sends the generated `RedeemMagicLinkRequestContent` with the cookie set from the token it
32//! kept.
33
34use std::sync::Mutex;
35
36use crate::auth::NoAuth;
37use crate::client::{Client, ClientBuilder};
38use crate::config::Config;
39use crate::error::Error;
40use crate::generated::routes;
41use crate::generated::types::{
42    CreateSessionRequestContent, PendingAuthentication, RedeemMagicLinkRequestContent,
43    SessionAuthorization,
44};
45use crate::http::header::COOKIE;
46use crate::types::SensitiveString;
47
48/// The cookie a pending sign-in is carried in between creating the session and redeeming
49/// the code.
50pub const PENDING_COOKIE: &str = "pending_authentication_token";
51
52/// A sign-in in progress.
53pub struct MagicLinkFlow {
54    client: Client,
55    pending: Mutex<Option<SensitiveString>>,
56}
57
58impl MagicLinkFlow {
59    /// A flow against the configured origin, sending no credentials: there are none yet.
60    pub fn new(config: Config) -> Result<MagicLinkFlow, Error> {
61        MagicLinkFlow::from_builder(Client::builder(config))
62    }
63
64    /// A flow over a builder of the caller's own — a custom HTTP client, hooks — whose
65    /// credentials are replaced with none.
66    pub fn from_builder(builder: ClientBuilder) -> Result<MagicLinkFlow, Error> {
67        Ok(MagicLinkFlow {
68            client: builder.auth_strategy(NoAuth).build()?,
69            pending: Mutex::new(None),
70        })
71    }
72
73    /// A flow that picks up where another left off, with the pending token that one
74    /// handed out through [`MagicLinkFlow::pending_token`].
75    pub fn resume(
76        config: Config,
77        pending_token: impl Into<SensitiveString>,
78    ) -> Result<MagicLinkFlow, Error> {
79        let flow = MagicLinkFlow::new(config)?;
80        *flow
81            .pending
82            .lock()
83            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pending_token.into());
84        Ok(flow)
85    }
86
87    /// The client the flow sends through.
88    pub fn client(&self) -> &Client {
89        &self.client
90    }
91
92    /// Asks Fizzy to email a sign-in code to the address, and keeps the pending token the
93    /// answer carries for [`MagicLinkFlow::redeem`].
94    pub async fn create_session(
95        &self,
96        email_address: &str,
97    ) -> Result<PendingAuthentication, Error> {
98        let mut operation = self.client.operation(&routes::CREATE_SESSION, &[])?;
99        operation.operation_name("MagicLinkCreateSession");
100        operation.json(&CreateSessionRequestContent {
101            email_address: SensitiveString::new(email_address),
102        })?;
103        let pending: PendingAuthentication = self.client.send(operation).await?;
104        *self
105            .pending
106            .lock()
107            .unwrap_or_else(std::sync::PoisonError::into_inner) =
108            Some(pending.pending_authentication_token.clone());
109        Ok(pending)
110    }
111
112    /// The token a pending sign-in is waiting on, for a caller that finishes the flow in
113    /// another process.
114    pub fn pending_token(&self) -> Option<SensitiveString> {
115        self.pending
116            .lock()
117            .unwrap_or_else(std::sync::PoisonError::into_inner)
118            .clone()
119    }
120
121    /// Sends the code back with the pending token, and answers the session Fizzy signed
122    /// in. Without a pending token — no [`MagicLinkFlow::create_session`] before it, and
123    /// nothing given to [`MagicLinkFlow::resume`] — there is nothing to redeem against.
124    pub async fn redeem(&self, code: &str) -> Result<SessionAuthorization, Error> {
125        let pending = self.pending_token().ok_or_else(|| {
126            Error::usage_with_hint(
127                "no sign-in is pending",
128                "call create_session first, or resume with the pending token",
129            )
130        })?;
131        let cookie = crate::auth::cookie_header(PENDING_COOKIE, pending.expose())?;
132        let mut operation = self.client.operation(&routes::REDEEM_MAGIC_LINK, &[])?;
133        operation.operation_name("MagicLinkRedeem");
134        operation.header(COOKIE, cookie);
135        operation.json(&RedeemMagicLinkRequestContent {
136            code: SensitiveString::from(code),
137        })?;
138        let session: SessionAuthorization = self.client.send(operation).await?;
139        // The sign-in redeemed is consumed; one a create_session started meanwhile is not.
140        let mut current = self
141            .pending
142            .lock()
143            .unwrap_or_else(std::sync::PoisonError::into_inner);
144        if current.as_ref().map(SensitiveString::expose) == Some(pending.expose()) {
145            *current = None;
146        }
147        Ok(session)
148    }
149}