watchword/core/service/cloudflare.rs
1//! Cloudflare Access sign-in on [`Auth`] (feature `cloudflare`).
2//!
3//! Access vouches for an email, which finds an existing user; users are never
4//! created. Signing in mints an ordinary session, so Access is consulted only
5//! when signing in. A pending user who signs in becomes active, and a revoked
6//! user is refused.
7
8#[cfg(test)]
9use std::string::String;
10#[cfg(test)]
11use std::vec::Vec;
12
13use http::HeaderMap;
14
15use super::{Actor, AuditEvent, Auth, AuthBuilder, AuthEvent, Result, require_admin};
16use crate::store::{Session, User};
17use crate::types::{Access, AuthError, CloudflareConfig};
18
19impl AuthBuilder {
20 /// The Cloudflare Access settings to run with while the store has none,
21 /// such as settings from the app's environment or its earlier storage.
22 /// `None` and settings that are not `enabled` both turn Access sign-in
23 /// off. The default is `None`.
24 ///
25 /// Settings saved with [`Auth::set_cloudflare`] are kept in the store
26 /// and win over these from then on, across restarts.
27 #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
28 pub fn cloudflare(mut self, config: Option<CloudflareConfig>) -> Self {
29 self.cloudflare = config;
30 self
31 }
32}
33
34impl Auth {
35 /// The Cloudflare Access settings: those kept in the store, or those
36 /// given to [`AuthBuilder::cloudflare`] while the store has none, or
37 /// `None` when neither has any. They are what an admin's settings page
38 /// shows, enabled or not.
39 ///
40 /// The store is read once, the first time the settings are needed, which
41 /// is also when they are logged. To have them logged at startup, call
42 /// this there.
43 ///
44 /// Errors only when the store fails.
45 #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
46 pub async fn cloudflare(&self) -> Result<Option<CloudflareConfig>> {
47 let provider = &self.inner.cloudflare;
48 provider.load(self.store()).await?;
49 Ok(provider.settings())
50 }
51
52 /// Stores the Cloudflare Access settings, under the store's `cloudflare`
53 /// setting, and applies them, such as when an admin saves them on the
54 /// settings page. Returns them as stored, with the team domain and the
55 /// audience trimmed.
56 ///
57 /// Settings that are not `enabled` turn Access sign-in off. Settings
58 /// that differ from the current ones are logged, with a warning when
59 /// they are unsafe or let no one sign in; the same settings again change
60 /// nothing.
61 ///
62 /// Sends [`AuditEvent::CloudflareChanged`] when the settings differ from
63 /// the current ones, naming an admin impersonating `actor` through
64 /// `session`.
65 ///
66 /// Admins only: [`AuthError::Forbidden`] unless `actor` is an active
67 /// admin. An app moving settings it kept itself onto watchword passes
68 /// them to [`AuthBuilder::cloudflare`], where they apply until an admin
69 /// saves settings here.
70 #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
71 pub async fn set_cloudflare(
72 &self,
73 actor: &User,
74 session: &Session,
75 config: CloudflareConfig,
76 ) -> Result<CloudflareConfig> {
77 require_admin(actor)?;
78 let (before, config) = self.inner.cloudflare.save(self.store(), config).await?;
79 tracing::info!(user = %actor.id, "Cloudflare Access settings saved");
80
81 if before.as_ref() != Some(&config) {
82 self.audit(AuditEvent::CloudflareChanged {
83 actor: Actor::of(actor, session),
84 before,
85 after: config.clone(),
86 });
87 }
88
89 Ok(config)
90 }
91
92 /// Whether the request carries Cloudflare Access credentials that sign in
93 /// an existing, not revoked user, as
94 /// [`sign_in_cloudflare`](Auth::sign_in_cloudflare) would. This is
95 /// [`AuthConfig::cloudflare`](crate::AuthConfig::cloudflare).
96 ///
97 /// When the request carries Access credentials that do not, the reason
98 /// is logged as a warning, at most once a minute.
99 #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
100 pub async fn cloudflare_available(&self, headers: &HeaderMap) -> Result<bool> {
101 Ok(self.cloudflare_user(headers).await?.is_some())
102 }
103
104 /// Signs in the user whose email the request's Cloudflare Access
105 /// credentials vouch for, whatever their [`Method`](crate::Method).
106 ///
107 /// A pending user becomes active, because Access has vouched for their
108 /// email. Anything else that is not an active user, including a revoked
109 /// user, an unknown email and Access being disabled, is
110 /// [`AuthError::Unauthorized`], with the reason logged as for
111 /// [`cloudflare_available`](Auth::cloudflare_available).
112 #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
113 pub async fn sign_in_cloudflare(&self, headers: &HeaderMap) -> Result<(User, Session)> {
114 let mut user = self
115 .cloudflare_user(headers)
116 .await?
117 .ok_or(AuthError::Unauthorized)?;
118
119 if user.access == Access::Pending {
120 // Only a user who is still pending is activated, so a revocation
121 // since the read above is not undone.
122 let activated = self.store().activate(user.id).await?;
123
124 user = self
125 .store()
126 .user(user.id)
127 .await?
128 .ok_or(AuthError::Unauthorized)?;
129
130 if activated {
131 tracing::info!(user = %user.id, "Cloudflare Access activated a pending user");
132 self.emit(AuthEvent::UserChanged(user.info()));
133
134 self.audit(AuditEvent::Activated {
135 actor: Actor::System,
136 user: user.info(),
137 });
138 }
139 }
140
141 if user.access != Access::Active {
142 return Err(AuthError::Unauthorized.into());
143 }
144
145 let session = self.create_session(user.id).await?;
146 Ok((user, session))
147 }
148
149 /// The user the request's Access credentials sign in: an existing user
150 /// with the vouched-for email who is not revoked.
151 async fn cloudflare_user(&self, headers: &HeaderMap) -> Result<Option<User>> {
152 let provider = &self.inner.cloudflare;
153 provider.load(self.store()).await?;
154
155 let Some(email) = provider.email(headers).await else {
156 return Ok(None);
157 };
158
159 let Some(user) = self.store().user_by_email(&email).await? else {
160 provider.warn(format!(
161 "Cloudflare Access sign-in failed: no user has the email {email}"
162 ));
163 return Ok(None);
164 };
165
166 if user.access == Access::Revoked {
167 provider.warn(format!(
168 "Cloudflare Access sign-in failed: the user with the email {email} has had their access revoked"
169 ));
170 return Ok(None);
171 }
172
173 Ok(Some(user))
174 }
175
176 /// The Cloudflare Access warnings logged within the last minute.
177 #[cfg(test)]
178 pub(crate) fn cloudflare_warnings(&self) -> Vec<String> {
179 self.inner.cloudflare.warnings()
180 }
181}
182
183#[cfg(test)]
184mod tests;