Skip to main content

seamless_auth/
options.rs

1use std::fmt;
2use std::future::Future;
3use std::net::IpAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use axum::http::HeaderMap;
8
9use crate::adapter::Adapter;
10use crate::client_ip::TrustedProxies;
11use crate::delivery::{Delivery, DeliveryError, DeliveryFuture};
12
13const MIN_SECRET_LENGTH: usize = 32;
14
15/// The `SameSite` attribute of the adapter's cookies.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum SameSite {
18    Strict,
19    Lax,
20    None,
21}
22
23impl SameSite {
24    pub(crate) fn as_str(self) -> &'static str {
25        match self {
26            SameSite::Strict => "Strict",
27            SameSite::Lax => "Lax",
28            SameSite::None => "None",
29        }
30    }
31}
32
33pub(crate) type DeliverFn = Arc<dyn Fn(Delivery) -> DeliveryFuture + Send + Sync>;
34pub(crate) type ClientIpFn =
35    Arc<dyn Fn(&HeaderMap, Option<IpAddr>) -> Option<IpAddr> + Send + Sync>;
36
37/// Why [`Builder::build`] refused its configuration.
38#[derive(Debug, Clone, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum BuildError {
41    /// The auth server URL is empty or not an http(s) URL.
42    InvalidAuthServerUrl,
43    /// The cookie secret is shorter than 32 bytes.
44    WeakCookieSecret,
45    /// The service secret is shorter than 32 bytes.
46    WeakServiceSecret,
47    /// A cookie name or domain holds characters a `Set-Cookie` header cannot carry.
48    InvalidCookieAttribute(String),
49    /// The HTTP client could not be built.
50    HttpClient(String),
51}
52
53impl fmt::Display for BuildError {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            BuildError::InvalidAuthServerUrl => {
57                f.write_str("seamless-auth: the auth server URL must be an http or https URL")
58            }
59            BuildError::WeakCookieSecret => write!(
60                f,
61                "seamless-auth: the cookie secret must be at least {MIN_SECRET_LENGTH} bytes"
62            ),
63            BuildError::WeakServiceSecret => write!(
64                f,
65                "seamless-auth: the service secret must be at least {MIN_SECRET_LENGTH} bytes"
66            ),
67            BuildError::InvalidCookieAttribute(what) => {
68                write!(f, "seamless-auth: invalid cookie attribute {what}")
69            }
70            BuildError::HttpClient(err) => {
71                write!(f, "seamless-auth: could not build the HTTP client: {err}")
72            }
73        }
74    }
75}
76
77impl std::error::Error for BuildError {}
78
79/// Configures an [`Adapter`]. Start one with [`Adapter::builder`].
80pub struct Builder {
81    auth_server_url: String,
82    auth_server_issuer: Option<String>,
83    audience: Option<String>,
84    cookie_secret: String,
85    service_secret: String,
86    jwks_kid: Option<String>,
87    cookie_domain: Option<String>,
88    insecure_cookies: bool,
89    same_site: Option<SameSite>,
90    allowed_origins: Vec<String>,
91    access_cookie_name: String,
92    registration_cookie_name: String,
93    refresh_cookie_name: String,
94    pre_auth_cookie_name: String,
95    deliver: Option<DeliverFn>,
96    client_ip: Option<ClientIpFn>,
97    disable_manifest_fetch: bool,
98    http_client: Option<reqwest::Client>,
99}
100
101impl fmt::Debug for Builder {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.debug_struct("Builder")
104            .field("auth_server_url", &self.auth_server_url)
105            .field("auth_server_issuer", &self.auth_server_issuer)
106            .finish_non_exhaustive()
107    }
108}
109
110impl Builder {
111    pub(crate) fn new(auth_server_url: impl Into<String>) -> Self {
112        Builder {
113            auth_server_url: auth_server_url.into(),
114            auth_server_issuer: None,
115            audience: None,
116            cookie_secret: String::new(),
117            service_secret: String::new(),
118            jwks_kid: None,
119            cookie_domain: None,
120            insecure_cookies: false,
121            same_site: None,
122            allowed_origins: Vec::new(),
123            access_cookie_name: "seamless-access".into(),
124            registration_cookie_name: "seamless-ephemeral".into(),
125            refresh_cookie_name: "seamless-refresh".into(),
126            // Shares the registration cookie on purpose: registration and sign-in
127            // never hold an ephemeral cookie at the same time.
128            pre_auth_cookie_name: "seamless-ephemeral".into(),
129            deliver: None,
130            client_ip: None,
131            disable_manifest_fetch: false,
132            http_client: None,
133        }
134    }
135
136    /// The expected `iss` of the auth API's tokens. Defaults to the auth server URL.
137    /// Set it when this server reaches the API at another URL than the issuer it
138    /// advertises.
139    pub fn auth_server_issuer(mut self, issuer: impl Into<String>) -> Self {
140        self.auth_server_issuer = Some(issuer.into());
141        self
142    }
143
144    /// The expected `aud` of the auth API's tokens. The auth API sets it to its
145    /// issuer, so it defaults to the issuer.
146    pub fn audience(mut self, audience: impl Into<String>) -> Self {
147        self.audience = Some(audience.into());
148        self
149    }
150
151    /// Signs the session cookies. At least 32 bytes. Required.
152    pub fn cookie_secret(mut self, secret: impl Into<String>) -> Self {
153        self.cookie_secret = secret.into();
154        self
155    }
156
157    /// Signs the service tokens the auth API trusts this adapter by. It holds the
158    /// same value as the API's `API_SERVICE_TOKEN`; apps conventionally read it
159    /// from `SEAMLESS_SERVICE_TOKEN`. At least 32 bytes. Required.
160    pub fn service_secret(mut self, secret: impl Into<String>) -> Self {
161        self.service_secret = secret.into();
162        self
163    }
164
165    /// The `kid` header on service tokens. Defaults to `dev-main`, a placeholder.
166    pub fn jwks_kid(mut self, kid: impl Into<String>) -> Self {
167        self.jwks_kid = Some(kid.into());
168        self
169    }
170
171    /// The cookie `Domain` attribute. None by default.
172    pub fn cookie_domain(mut self, domain: impl Into<String>) -> Self {
173        self.cookie_domain = Some(domain.into());
174        self
175    }
176
177    /// Drops the `Secure` attribute, for local development over plain HTTP.
178    pub fn insecure_cookies(mut self, insecure: bool) -> Self {
179        self.insecure_cookies = insecure;
180        self
181    }
182
183    /// The cookie `SameSite` attribute. Defaults to `None`, or `Lax` with
184    /// [`insecure_cookies`](Self::insecure_cookies).
185    pub fn same_site(mut self, same_site: SameSite) -> Self {
186        self.same_site = Some(same_site);
187        self
188    }
189
190    /// The only cross-origin callers allowed to change state while cookies are
191    /// `SameSite=None`.
192    pub fn allowed_origins<I, S>(mut self, origins: I) -> Self
193    where
194        I: IntoIterator<Item = S>,
195        S: Into<String>,
196    {
197        self.allowed_origins = origins.into_iter().map(Into::into).collect();
198        self
199    }
200
201    /// Session cookie names. Default `seamless-access` and `seamless-refresh`.
202    pub fn session_cookie_names(
203        mut self,
204        access: impl Into<String>,
205        refresh: impl Into<String>,
206    ) -> Self {
207        self.access_cookie_name = access.into();
208        self.refresh_cookie_name = refresh.into();
209        self
210    }
211
212    /// Sign-in flow cookie names. Both default to `seamless-ephemeral`.
213    pub fn flow_cookie_names(
214        mut self,
215        registration: impl Into<String>,
216        pre_auth: impl Into<String>,
217    ) -> Self {
218        self.registration_cookie_name = registration.into();
219        self.pre_auth_cookie_name = pre_auth.into();
220        self
221    }
222
223    /// Sends OTP codes and magic links through the application's own transports.
224    /// With it, the adapter asks the auth API for the message instead of having
225    /// the API send it. A delivery error answers the request with 502
226    /// `delivery_failed`.
227    pub fn deliver<F, Fut, E>(mut self, deliver: F) -> Self
228    where
229        F: Fn(Delivery) -> Fut + Send + Sync + 'static,
230        Fut: Future<Output = Result<(), E>> + Send + 'static,
231        E: Into<DeliveryError>,
232    {
233        self.deliver = Some(Arc::new(move |delivery| {
234            let fut = deliver(delivery);
235            Box::pin(async move { fut.await.map_err(Into::into) })
236        }));
237        self
238    }
239
240    /// Resolves the end user's address from the request headers and the
241    /// connecting peer. Defaults to the peer. Behind a proxy, use
242    /// [`trusted_proxies`](Self::trusted_proxies).
243    pub fn resolve_client_ip<F>(mut self, resolve: F) -> Self
244    where
245        F: Fn(&HeaderMap, Option<IpAddr>) -> Option<IpAddr> + Send + Sync + 'static,
246    {
247        self.client_ip = Some(Arc::new(resolve));
248        self
249    }
250
251    /// Resolves the end user's address through these proxies.
252    pub fn trusted_proxies(self, proxies: TrustedProxies) -> Self {
253        self.resolve_client_ip(move |headers, peer| proxies.resolve(headers, peer))
254    }
255
256    /// Uses only the manifest bundled with this version instead of fetching the
257    /// auth API's on the first request.
258    pub fn disable_manifest_fetch(mut self, disable: bool) -> Self {
259        self.disable_manifest_fetch = disable;
260        self
261    }
262
263    /// The client for calls to the auth API. The default has a 15 second timeout
264    /// and follows no redirects. A client that follows redirects would carry the
265    /// service token to wherever the auth API pointed it.
266    pub fn http_client(mut self, client: reqwest::Client) -> Self {
267        self.http_client = Some(client);
268        self
269    }
270
271    /// Validates the configuration and returns the adapter.
272    pub fn build(self) -> Result<Adapter, BuildError> {
273        let auth_server_url = self
274            .auth_server_url
275            .trim()
276            .trim_end_matches('/')
277            .to_string();
278        if !(auth_server_url.starts_with("http://") || auth_server_url.starts_with("https://"))
279            || auth_server_url.len() <= "https://".len()
280        {
281            return Err(BuildError::InvalidAuthServerUrl);
282        }
283        if self.cookie_secret.len() < MIN_SECRET_LENGTH {
284            return Err(BuildError::WeakCookieSecret);
285        }
286        if self.service_secret.len() < MIN_SECRET_LENGTH {
287            return Err(BuildError::WeakServiceSecret);
288        }
289
290        for name in [
291            &self.access_cookie_name,
292            &self.registration_cookie_name,
293            &self.refresh_cookie_name,
294            &self.pre_auth_cookie_name,
295        ] {
296            if name.is_empty() || !name.bytes().all(is_token_byte) {
297                return Err(BuildError::InvalidCookieAttribute(format!("name {name:?}")));
298            }
299        }
300        if let Some(domain) = &self.cookie_domain
301            && (domain.is_empty()
302                || !domain
303                    .bytes()
304                    .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-'))
305        {
306            return Err(BuildError::InvalidCookieAttribute(format!(
307                "domain {domain:?}"
308            )));
309        }
310
311        let auth_server_issuer = self
312            .auth_server_issuer
313            .unwrap_or_else(|| auth_server_url.clone());
314        let audience = self.audience.unwrap_or_else(|| auth_server_issuer.clone());
315        let jwks_kid = self.jwks_kid.filter(|k| !k.is_empty()).unwrap_or_else(|| {
316            tracing::warn!(
317                "[seamless-auth] jwks_kid is not set and defaults to \"dev-main\", a placeholder. Set it to name the service-token key explicitly."
318            );
319            "dev-main".to_string()
320        });
321        let same_site = self.same_site.unwrap_or(if self.insecure_cookies {
322            SameSite::Lax
323        } else {
324            SameSite::None
325        });
326
327        let http_client = match self.http_client {
328            Some(client) => client,
329            None => reqwest::Client::builder()
330                .timeout(Duration::from_secs(15))
331                .redirect(reqwest::redirect::Policy::none())
332                .build()
333                .map_err(|e| BuildError::HttpClient(e.to_string()))?,
334        };
335
336        Ok(Adapter::from_config(Config {
337            auth_server_url,
338            auth_server_issuer,
339            audience,
340            cookie_secret: self.cookie_secret,
341            service_secret: self.service_secret,
342            jwks_kid,
343            cookie_domain: self.cookie_domain,
344            secure_cookies: !self.insecure_cookies,
345            same_site,
346            allowed_origins: self.allowed_origins,
347            access_cookie_name: self.access_cookie_name,
348            registration_cookie_name: self.registration_cookie_name,
349            refresh_cookie_name: self.refresh_cookie_name,
350            pre_auth_cookie_name: self.pre_auth_cookie_name,
351            deliver: self.deliver,
352            client_ip: self.client_ip,
353            fetch_manifest: !self.disable_manifest_fetch,
354            http_client,
355        }))
356    }
357}
358
359/// RFC 7230 token characters, which is what a cookie name may hold.
360fn is_token_byte(b: u8) -> bool {
361    b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
362}
363
364pub(crate) struct Config {
365    pub auth_server_url: String,
366    pub auth_server_issuer: String,
367    pub audience: String,
368    pub cookie_secret: String,
369    pub service_secret: String,
370    pub jwks_kid: String,
371    pub cookie_domain: Option<String>,
372    pub secure_cookies: bool,
373    pub same_site: SameSite,
374    pub allowed_origins: Vec<String>,
375    pub access_cookie_name: String,
376    pub registration_cookie_name: String,
377    pub refresh_cookie_name: String,
378    pub pre_auth_cookie_name: String,
379    pub deliver: Option<DeliverFn>,
380    pub client_ip: Option<ClientIpFn>,
381    pub fetch_manifest: bool,
382    pub http_client: reqwest::Client,
383}
384
385impl Config {
386    /// The cookie a held credential lives in.
387    pub fn cookie_name(&self, held: &str) -> &str {
388        match held {
389            "access" => &self.access_cookie_name,
390            "registration" => &self.registration_cookie_name,
391            "refresh" => &self.refresh_cookie_name,
392            _ => &self.pre_auth_cookie_name,
393        }
394    }
395}