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}