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}