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//! # Where this parts company with the model
27//!
28//! The Smithy model spells `RedeemMagicLink`'s body as `{"token": …}` and says nothing of
29//! the cookie; upstream Fizzy reads `code` and requires the cookie. The flow follows
30//! upstream, since that is what answers, and sends both spellings of the code so it also
31//! works against a server built from the model as written. The generated
32//! [`crate::services::sessions::SessionsService::redeem_magic_link`] is the model's shape and
33//! cannot complete a login on its own until the model catches up.
34
35use std::sync::Mutex;
36
37use serde::Serialize;
38
39use crate::auth::NoAuth;
40use crate::client::{Client, ClientBuilder};
41use crate::config::Config;
42use crate::error::Error;
43use crate::generated::routes;
44use crate::generated::types::{
45    CreateSessionRequestContent, PendingAuthentication, SessionAuthorization,
46};
47use crate::http::header::COOKIE;
48use crate::types::SensitiveString;
49
50/// The cookie a pending sign-in is carried in between creating the session and redeeming
51/// the code.
52pub const PENDING_COOKIE: &str = "pending_authentication_token";
53
54/// A sign-in in progress.
55pub struct MagicLinkFlow {
56    client: Client,
57    pending: Mutex<Option<SensitiveString>>,
58}
59
60#[derive(Serialize)]
61struct Redeem<'a> {
62    code: &'a str,
63    token: &'a str,
64}
65
66impl MagicLinkFlow {
67    /// A flow against the configured origin, sending no credentials: there are none yet.
68    pub fn new(config: Config) -> Result<MagicLinkFlow, Error> {
69        MagicLinkFlow::from_builder(Client::builder(config))
70    }
71
72    /// A flow over a builder of the caller's own — a custom HTTP client, hooks — whose
73    /// credentials are replaced with none.
74    pub fn from_builder(builder: ClientBuilder) -> Result<MagicLinkFlow, Error> {
75        Ok(MagicLinkFlow {
76            client: builder.auth_strategy(NoAuth).build()?,
77            pending: Mutex::new(None),
78        })
79    }
80
81    /// A flow that picks up where another left off, with the pending token that one
82    /// handed out through [`MagicLinkFlow::pending_token`].
83    pub fn resume(
84        config: Config,
85        pending_token: impl Into<SensitiveString>,
86    ) -> Result<MagicLinkFlow, Error> {
87        let flow = MagicLinkFlow::new(config)?;
88        *flow
89            .pending
90            .lock()
91            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pending_token.into());
92        Ok(flow)
93    }
94
95    /// The client the flow sends through.
96    pub fn client(&self) -> &Client {
97        &self.client
98    }
99
100    /// Asks Fizzy to email a sign-in code to the address, and keeps the pending token the
101    /// answer carries for [`MagicLinkFlow::redeem`].
102    pub async fn create_session(
103        &self,
104        email_address: &str,
105    ) -> Result<PendingAuthentication, Error> {
106        let mut operation = self.client.operation(&routes::CREATE_SESSION, &[])?;
107        operation.operation_name("MagicLinkCreateSession");
108        operation.json(&CreateSessionRequestContent {
109            email_address: SensitiveString::new(email_address),
110        })?;
111        let pending: PendingAuthentication = self.client.send(operation).await?;
112        *self
113            .pending
114            .lock()
115            .unwrap_or_else(std::sync::PoisonError::into_inner) =
116            Some(pending.pending_authentication_token.clone());
117        Ok(pending)
118    }
119
120    /// The token a pending sign-in is waiting on, for a caller that finishes the flow in
121    /// another process.
122    pub fn pending_token(&self) -> Option<SensitiveString> {
123        self.pending
124            .lock()
125            .unwrap_or_else(std::sync::PoisonError::into_inner)
126            .clone()
127    }
128
129    /// Sends the code back with the pending token, and answers the session Fizzy signed
130    /// in. Without a pending token — no [`MagicLinkFlow::create_session`] before it, and
131    /// nothing given to [`MagicLinkFlow::resume`] — there is nothing to redeem against.
132    pub async fn redeem(&self, code: &str) -> Result<SessionAuthorization, Error> {
133        let pending = self.pending_token().ok_or_else(|| {
134            Error::usage_with_hint(
135                "no sign-in is pending",
136                "call create_session first, or resume with the pending token",
137            )
138        })?;
139        let cookie = crate::auth::cookie_header(PENDING_COOKIE, pending.expose())?;
140        let mut operation = self.client.operation(&routes::REDEEM_MAGIC_LINK, &[])?;
141        operation.operation_name("MagicLinkRedeem");
142        operation.header(COOKIE, cookie);
143        operation.json(&Redeem { code, token: code })?;
144        let session: SessionAuthorization = self.client.send(operation).await?;
145        *self
146            .pending
147            .lock()
148            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
149        Ok(session)
150    }
151}