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 is the
158    /// API's `API_SERVICE_TOKEN`. At least 32 bytes. Required.
159    pub fn service_secret(mut self, secret: impl Into<String>) -> Self {
160        self.service_secret = secret.into();
161        self
162    }
163
164    /// The `kid` header on service tokens. Defaults to `dev-main`, a placeholder.
165    pub fn jwks_kid(mut self, kid: impl Into<String>) -> Self {
166        self.jwks_kid = Some(kid.into());
167        self
168    }
169
170    /// The cookie `Domain` attribute. None by default.
171    pub fn cookie_domain(mut self, domain: impl Into<String>) -> Self {
172        self.cookie_domain = Some(domain.into());
173        self
174    }
175
176    /// Drops the `Secure` attribute, for local development over plain HTTP.
177    pub fn insecure_cookies(mut self, insecure: bool) -> Self {
178        self.insecure_cookies = insecure;
179        self
180    }
181
182    /// The cookie `SameSite` attribute. Defaults to `None`, or `Lax` with
183    /// [`insecure_cookies`](Self::insecure_cookies).
184    pub fn same_site(mut self, same_site: SameSite) -> Self {
185        self.same_site = Some(same_site);
186        self
187    }
188
189    /// The only cross-origin callers allowed to change state while cookies are
190    /// `SameSite=None`.
191    pub fn allowed_origins<I, S>(mut self, origins: I) -> Self
192    where
193        I: IntoIterator<Item = S>,
194        S: Into<String>,
195    {
196        self.allowed_origins = origins.into_iter().map(Into::into).collect();
197        self
198    }
199
200    /// Session cookie names. Default `seamless-access` and `seamless-refresh`.
201    pub fn session_cookie_names(
202        mut self,
203        access: impl Into<String>,
204        refresh: impl Into<String>,
205    ) -> Self {
206        self.access_cookie_name = access.into();
207        self.refresh_cookie_name = refresh.into();
208        self
209    }
210
211    /// Sign-in flow cookie names. Both default to `seamless-ephemeral`.
212    pub fn flow_cookie_names(
213        mut self,
214        registration: impl Into<String>,
215        pre_auth: impl Into<String>,
216    ) -> Self {
217        self.registration_cookie_name = registration.into();
218        self.pre_auth_cookie_name = pre_auth.into();
219        self
220    }
221
222    /// Sends OTP codes and magic links through the application's own transports.
223    /// With it, the adapter asks the auth API for the message instead of having
224    /// the API send it. A delivery error answers the request with 502
225    /// `delivery_failed`.
226    pub fn deliver<F, Fut, E>(mut self, deliver: F) -> Self
227    where
228        F: Fn(Delivery) -> Fut + Send + Sync + 'static,
229        Fut: Future<Output = Result<(), E>> + Send + 'static,
230        E: Into<DeliveryError>,
231    {
232        self.deliver = Some(Arc::new(move |delivery| {
233            let fut = deliver(delivery);
234            Box::pin(async move { fut.await.map_err(Into::into) })
235        }));
236        self
237    }
238
239    /// Resolves the end user's address from the request headers and the
240    /// connecting peer. Defaults to the peer. Behind a proxy, use
241    /// [`trusted_proxies`](Self::trusted_proxies).
242    pub fn resolve_client_ip<F>(mut self, resolve: F) -> Self
243    where
244        F: Fn(&HeaderMap, Option<IpAddr>) -> Option<IpAddr> + Send + Sync + 'static,
245    {
246        self.client_ip = Some(Arc::new(resolve));
247        self
248    }
249
250    /// Resolves the end user's address through these proxies.
251    pub fn trusted_proxies(self, proxies: TrustedProxies) -> Self {
252        self.resolve_client_ip(move |headers, peer| proxies.resolve(headers, peer))
253    }
254
255    /// Uses only the manifest bundled with this version instead of fetching the
256    /// auth API's on the first request.
257    pub fn disable_manifest_fetch(mut self, disable: bool) -> Self {
258        self.disable_manifest_fetch = disable;
259        self
260    }
261
262    /// The client for calls to the auth API. The default has a 15 second timeout
263    /// and follows no redirects. A client that follows redirects would carry the
264    /// service token to wherever the auth API pointed it.
265    pub fn http_client(mut self, client: reqwest::Client) -> Self {
266        self.http_client = Some(client);
267        self
268    }
269
270    /// Validates the configuration and returns the adapter.
271    pub fn build(self) -> Result<Adapter, BuildError> {
272        let auth_server_url = self
273            .auth_server_url
274            .trim()
275            .trim_end_matches('/')
276            .to_string();
277        if !(auth_server_url.starts_with("http://") || auth_server_url.starts_with("https://"))
278            || auth_server_url.len() <= "https://".len()
279        {
280            return Err(BuildError::InvalidAuthServerUrl);
281        }
282        if self.cookie_secret.len() < MIN_SECRET_LENGTH {
283            return Err(BuildError::WeakCookieSecret);
284        }
285        if self.service_secret.len() < MIN_SECRET_LENGTH {
286            return Err(BuildError::WeakServiceSecret);
287        }
288
289        for name in [
290            &self.access_cookie_name,
291            &self.registration_cookie_name,
292            &self.refresh_cookie_name,
293            &self.pre_auth_cookie_name,
294        ] {
295            if name.is_empty() || !name.bytes().all(is_token_byte) {
296                return Err(BuildError::InvalidCookieAttribute(format!("name {name:?}")));
297            }
298        }
299        if let Some(domain) = &self.cookie_domain
300            && (domain.is_empty()
301                || !domain
302                    .bytes()
303                    .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-'))
304        {
305            return Err(BuildError::InvalidCookieAttribute(format!(
306                "domain {domain:?}"
307            )));
308        }
309
310        let auth_server_issuer = self
311            .auth_server_issuer
312            .unwrap_or_else(|| auth_server_url.clone());
313        let audience = self.audience.unwrap_or_else(|| auth_server_issuer.clone());
314        let jwks_kid = self.jwks_kid.filter(|k| !k.is_empty()).unwrap_or_else(|| {
315            tracing::warn!(
316                "[seamless-auth] jwks_kid is not set and defaults to \"dev-main\", a placeholder. Set it to name the service-token key explicitly."
317            );
318            "dev-main".to_string()
319        });
320        let same_site = self.same_site.unwrap_or(if self.insecure_cookies {
321            SameSite::Lax
322        } else {
323            SameSite::None
324        });
325
326        let http_client = match self.http_client {
327            Some(client) => client,
328            None => reqwest::Client::builder()
329                .timeout(Duration::from_secs(15))
330                .redirect(reqwest::redirect::Policy::none())
331                .build()
332                .map_err(|e| BuildError::HttpClient(e.to_string()))?,
333        };
334
335        Ok(Adapter::from_config(Config {
336            auth_server_url,
337            auth_server_issuer,
338            audience,
339            cookie_secret: self.cookie_secret,
340            service_secret: self.service_secret,
341            jwks_kid,
342            cookie_domain: self.cookie_domain,
343            secure_cookies: !self.insecure_cookies,
344            same_site,
345            allowed_origins: self.allowed_origins,
346            access_cookie_name: self.access_cookie_name,
347            registration_cookie_name: self.registration_cookie_name,
348            refresh_cookie_name: self.refresh_cookie_name,
349            pre_auth_cookie_name: self.pre_auth_cookie_name,
350            deliver: self.deliver,
351            client_ip: self.client_ip,
352            fetch_manifest: !self.disable_manifest_fetch,
353            http_client,
354        }))
355    }
356}
357
358/// RFC 7230 token characters, which is what a cookie name may hold.
359fn is_token_byte(b: u8) -> bool {
360    b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
361}
362
363pub(crate) struct Config {
364    pub auth_server_url: String,
365    pub auth_server_issuer: String,
366    pub audience: String,
367    pub cookie_secret: String,
368    pub service_secret: String,
369    pub jwks_kid: String,
370    pub cookie_domain: Option<String>,
371    pub secure_cookies: bool,
372    pub same_site: SameSite,
373    pub allowed_origins: Vec<String>,
374    pub access_cookie_name: String,
375    pub registration_cookie_name: String,
376    pub refresh_cookie_name: String,
377    pub pre_auth_cookie_name: String,
378    pub deliver: Option<DeliverFn>,
379    pub client_ip: Option<ClientIpFn>,
380    pub fetch_manifest: bool,
381    pub http_client: reqwest::Client,
382}
383
384impl Config {
385    /// The cookie a held credential lives in.
386    pub fn cookie_name(&self, held: &str) -> &str {
387        match held {
388            "access" => &self.access_cookie_name,
389            "registration" => &self.registration_cookie_name,
390            "refresh" => &self.refresh_cookie_name,
391            _ => &self.pre_auth_cookie_name,
392        }
393    }
394}