Skip to main content

axum_security_oauth2/
builder.rs

1use std::{error::Error as StdError, fmt};
2
3use url::Url;
4
5use crate::{
6    client::{AuthType, OAuth2Client},
7    http::HttpClient,
8};
9
10/// Builds an [`OAuth2Client`]. Created with
11/// [`OAuth2Client::builder()`](OAuth2Client::builder) or a provider
12/// shortcut ([`OAuth2Client::github()`](OAuth2Client::github),
13/// [`google()`](OAuth2Client::google), ...) that presets the endpoints.
14///
15/// `client_id`, `auth_url` and `token_url` are required; the rest is
16/// optional. [`try_build`](Self::try_build) validates everything up
17/// front, so the client's methods never fail on configuration.
18pub struct OAuth2ClientBuilder {
19    client_id: Option<String>,
20    client_secret: Option<String>,
21    auth_url: Option<String>,
22    token_url: Option<String>,
23    redirect_url: Option<String>,
24    scopes: Vec<String>,
25    auth_type: AuthType,
26    http: Option<HttpClient>,
27}
28
29impl OAuth2ClientBuilder {
30    pub(crate) fn new() -> Self {
31        Self {
32            client_id: None,
33            client_secret: None,
34            auth_url: None,
35            token_url: None,
36            redirect_url: None,
37            scopes: Vec::new(),
38            auth_type: AuthType::default(),
39            http: None,
40        }
41    }
42
43    /// The OAuth2 client id. Required.
44    pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
45        self.client_id = Some(client_id.into());
46        self
47    }
48
49    /// The OAuth2 client secret. When set, token requests authenticate via
50    /// HTTP Basic (RFC 6749 §2.3.1); without it the `client_id` is sent in
51    /// the request body.
52    pub fn client_secret(mut self, client_secret: impl Into<String>) -> Self {
53        self.client_secret = Some(client_secret.into());
54        self
55    }
56
57    /// The authorization endpoint, parsed in [`build`](Self::build).
58    /// Required.
59    pub fn auth_url(mut self, auth_url: impl Into<String>) -> Self {
60        self.auth_url = Some(auth_url.into());
61        self
62    }
63
64    /// The token endpoint, parsed in [`build`](Self::build). Required.
65    pub fn token_url(mut self, token_url: impl Into<String>) -> Self {
66        self.token_url = Some(token_url.into());
67        self
68    }
69
70    /// The redirect URL sent on both legs of the flow, parsed in
71    /// [`build`](Self::build).
72    pub fn redirect_url(mut self, redirect_url: impl Into<String>) -> Self {
73        self.redirect_url = Some(redirect_url.into());
74        self
75    }
76
77    /// The scopes requested on every login. Replaces any previously set
78    /// scopes; the default is none.
79    pub fn scopes(mut self, scopes: &[&str]) -> Self {
80        self.scopes = scopes.iter().map(|scope| scope.to_string()).collect();
81        self
82    }
83
84    /// Authenticate to the token endpoint with HTTP Basic (RFC 6749
85    /// §2.3.1). This is the default; call this only to undo a prior
86    /// [`request_body`](Self::request_body).
87    pub fn basic_auth(mut self) -> Self {
88        self.auth_type = AuthType::BasicAuth;
89        self
90    }
91
92    /// Send `client_id` and `client_secret` in the request body instead of
93    /// an HTTP Basic header, for providers that don't support Basic.
94    pub fn request_body(mut self) -> Self {
95        self.auth_type = AuthType::RequestBody;
96        self
97    }
98
99    /// The HTTP backend for token requests. Defaults to a reqwest client
100    /// that never follows redirects and times out after 10 seconds (when
101    /// the `reqwest` feature is enabled); without any backend feature,
102    /// [`try_build`](Self::try_build) fails with
103    /// [`ConfigError::NoHttpClient`].
104    // Without a backend feature `HttpClient` is uninhabited and this
105    // method cannot be reached.
106    #[cfg_attr(not(feature = "reqwest"), allow(unreachable_code, unused_mut))]
107    pub fn http_client(mut self, http_client: impl Into<HttpClient>) -> Self {
108        self.http = Some(http_client.into());
109        self
110    }
111
112    /// Sets the client id in place. See [`client_id`](Self::client_id).
113    pub fn set_client_id(&mut self, client_id: impl Into<String>) {
114        self.client_id = Some(client_id.into());
115    }
116
117    /// Sets the client secret in place. See
118    /// [`client_secret`](Self::client_secret).
119    pub fn set_client_secret(&mut self, client_secret: impl Into<String>) {
120        self.client_secret = Some(client_secret.into());
121    }
122
123    /// Sets the authorization endpoint in place. See
124    /// [`auth_url`](Self::auth_url).
125    pub fn set_auth_url(&mut self, auth_url: impl Into<String>) {
126        self.auth_url = Some(auth_url.into());
127    }
128
129    /// Sets the token endpoint in place. See [`token_url`](Self::token_url).
130    pub fn set_token_url(&mut self, token_url: impl Into<String>) {
131        self.token_url = Some(token_url.into());
132    }
133
134    /// Sets the redirect URL in place. See
135    /// [`redirect_url`](Self::redirect_url).
136    pub fn set_redirect_url(&mut self, redirect_url: impl Into<String>) {
137        self.redirect_url = Some(redirect_url.into());
138    }
139
140    /// Sets the requested scopes in place. See [`scopes`](Self::scopes).
141    pub fn set_scopes(&mut self, scopes: &[&str]) {
142        self.scopes = scopes.iter().map(|scope| scope.to_string()).collect();
143    }
144
145    /// Sets the HTTP backend in place. See
146    /// [`http_client`](Self::http_client).
147    #[cfg_attr(not(feature = "reqwest"), allow(unreachable_code))]
148    pub fn set_http_client(&mut self, http_client: impl Into<HttpClient>) {
149        self.http = Some(http_client.into());
150    }
151
152    /// Validates the configuration and builds the client.
153    ///
154    /// Panics on invalid configuration; use [`try_build`](Self::try_build)
155    /// to handle the error instead.
156    pub fn build(self) -> OAuth2Client {
157        self.try_build().unwrap()
158    }
159
160    /// Validates the configuration and builds the client.
161    pub fn try_build(self) -> Result<OAuth2Client, ConfigError> {
162        let client_id = self.client_id.ok_or(ConfigError::MissingClientId)?;
163
164        let auth_url = Url::parse(&self.auth_url.ok_or(ConfigError::MissingAuthUrl)?)
165            .map_err(ConfigError::InvalidAuthUrl)?;
166
167        let token_url = Url::parse(&self.token_url.ok_or(ConfigError::MissingTokenUrl)?)
168            .map_err(ConfigError::InvalidTokenUrl)?;
169
170        let redirect_url = self
171            .redirect_url
172            .map(|url| Url::parse(&url))
173            .transpose()
174            .map_err(ConfigError::InvalidRedirectUrl)?;
175
176        let http = self.http;
177        #[cfg(feature = "reqwest")]
178        let http = http.or_else(|| {
179            Some(HttpClient::Reqwest(
180                crate::http::dep_reqwest::default_client(),
181            ))
182        });
183        let http = http.ok_or(ConfigError::NoHttpClient)?;
184
185        Ok(OAuth2Client {
186            client_id,
187            client_secret: self.client_secret,
188            auth_url,
189            token_url,
190            redirect_url,
191            scopes: self.scopes,
192            auth_type: self.auth_type,
193            http,
194        })
195    }
196}
197
198/// Errors from [`OAuth2ClientBuilder::try_build`].
199#[derive(Debug)]
200#[non_exhaustive]
201pub enum ConfigError {
202    MissingClientId,
203    MissingAuthUrl,
204    MissingTokenUrl,
205    InvalidAuthUrl(url::ParseError),
206    InvalidTokenUrl(url::ParseError),
207    InvalidRedirectUrl(url::ParseError),
208    /// No backend feature (such as `reqwest`) is enabled and no client was
209    /// set with [`http_client`](OAuth2ClientBuilder::http_client).
210    NoHttpClient,
211}
212
213impl fmt::Display for ConfigError {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match self {
216            ConfigError::MissingClientId => f.write_str("client id is missing"),
217            ConfigError::MissingAuthUrl => f.write_str("authorization url is missing"),
218            ConfigError::MissingTokenUrl => f.write_str("token url is missing"),
219            ConfigError::InvalidAuthUrl(parse_error) => {
220                write!(f, "could not parse authorization url: {parse_error}")
221            }
222            ConfigError::InvalidTokenUrl(parse_error) => {
223                write!(f, "could not parse token url: {parse_error}")
224            }
225            ConfigError::InvalidRedirectUrl(parse_error) => {
226                write!(f, "could not parse redirect url: {parse_error}")
227            }
228            ConfigError::NoHttpClient => f.write_str(
229                "no HTTP client available (enable a backend feature such as `reqwest`, or set one with `http_client`)",
230            ),
231        }
232    }
233}
234
235impl StdError for ConfigError {}
236
237// Without a backend feature there is no default HTTP client and no way to
238// provide one, so building must fail.
239#[cfg(all(test, not(feature = "reqwest")))]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn no_backend_is_a_config_error() {
245        let result = OAuth2ClientBuilder::new()
246            .client_id("id")
247            .auth_url("https://provider.example/authorize")
248            .token_url("https://provider.example/token")
249            .try_build();
250        assert!(matches!(result, Err(ConfigError::NoHttpClient)));
251    }
252}