Skip to main content

clio_auth/
lib.rs

1//! OAuth 2.0 helper for CLI and desktop applications.
2//!
3//! This package facilitates the [OAuth 2.0 Authorization Code with PKCE][1] flow for command line
4//! and desktop GUI applications. It works hand-in-hand with the [oauth2][2] crate by providing the
5//! "missing pieces" for the flow: a web server to handle the authorization callback, and opening
6//! the browser with the authorization link.
7//!
8//! # Usage
9//!
10//! General usage is as follows:
11//!
12//! 1. Configure a [`CliOAuthBuilder`] and build a [`CliOAuth`] helper
13//! 1. Configure an [`oauth2::Client`]
14//! 1. Start the [authorization flow](CliOAuth::authorize)
15//! 1. [Validate and obtain](CliOAuth::validate) the authorization code
16//! 1. [Exchange the code](oauth2::Client::exchange_code) for a token
17//!
18//! # Example
19//!
20//! This example is adapted directly from the [`oauth2`] package documentation ("Asynchronous API"),
21//! and demonstrates how `CliOAuth` fills in the gaps.
22//!
23//! ```no_run
24//! use clio_auth::{AuthContext, CliOAuth};
25//! use log::{info, warn};
26//! use oauth2::basic::BasicClient;
27//! use oauth2::{AuthUrl, ClientId, ClientSecret, TokenUrl};
28//!
29//! # async fn err_wrapper() -> Result<(), Box<dyn std::error::Error>> {
30//! // CliOAuth: Build helper with default options
31//! let mut auth = CliOAuth::builder().build().unwrap();                  // (1)
32//! // Create an OAuth2 client by specifying the client ID, client secret, authorization URL and
33//! // token URL.
34//! let client = BasicClient::new(ClientId::new("client_id".to_string()))
35//!     .set_client_secret(ClientSecret::new("client_secret".to_string()))
36//!     .set_auth_uri(AuthUrl::new("http://authorize".to_string())?)
37//!     .set_token_uri(TokenUrl::new("http://token".to_string())?)
38//! // CliOAuth: Use the local redirect URL
39//!     .set_redirect_uri(auth.redirect_url());                          // (2)
40//!
41//! // CliOAuth: The PKCE challenge is handled internally. Just authorize... (3)
42//! match auth.authorize(&client).await {
43//!     Ok(auth_url) => info!("authorized successfully (url: {})", auth_url),
44//!     Err(e) => warn!("uh oh! {:?}", e),
45//! };
46//! // CliOAuth: The browser is opened to the authorization URL              (3)
47//!
48//! // Once the user has been redirected to the redirect URL, you'll have access to the
49//! // authorization code. For security reasons, your code should verify that the `state`
50//! // parameter returned by the server matches `csrf_state`.
51//! // CliOAuth: Validation must be performed to acquire the authorization code. CliOAuth handles
52//! // the CSRF verification.
53//! match auth.validate() {                                              // (4)
54//!     Ok(AuthContext {
55//!         auth_code,
56//!         pkce_verifier,
57//!         state: _,
58//!     }) => {
59//!         // Now you can trade it for an access token.
60//!         let http_client = oauth2::reqwest::Client::new();
61//!         let _token_result = client
62//!             .exchange_code(auth_code)                                // (5)
63//!             // Set the PKCE code verifier.
64//!             .set_pkce_verifier(pkce_verifier)
65//!             .request_async(&http_client)
66//!             .await?;
67//!         // Unwrapping token_result will either produce a Token or a RequestTokenError.
68//!     }
69//!     Err(e) => warn!("uh oh! {:?}", e),
70//! }
71//!
72//! # Ok(())
73//! # }
74//! ```
75//!
76//! _Breaking it down..._
77//!
78//! 1. `CliOAuth` construction starts with a [builder](CliOAuthBuilder), which allows you to
79//!    customize the way the authorization helper is configured. See the builder doc for more details
80//!    about configuration.
81//! 2. `CliOAuth` constructs the authorization URL based on the address & port it is running on. The
82//!    URL is provided to the [`oauth2::Client`] during construction.
83//! 3. Invoking the [`CliOAuth::authorize`] method will do the following things:
84//!    - Launch a local web server
85//!    - Generate the CSRF protection token (`state` parameter)
86//!    - Open the user's browser with the URL to initiate the authorization flow
87//!    - Receive the redirect from the IdP that contains the incoming authorization code
88//!    - Shutdown the local web server
89//! 4. Invoking the [`CliOAuth::validate`] method will verify that an auth code was received and
90//!    that the `state` parameter matches the expected value. If validation succeeds, the auth code
91//!    and PKCE verifier will be returned to the caller.
92//! 5. The auth code and PKCE verifier are provided to the
93//!    [exchange code](oauth2::Client::exchange_code) flow.
94//!
95//! [1]: https://www.rfc-editor.org/rfc/rfc7636
96//! [2]: https://crates.io/crates/oauth2
97
98use std::fmt::{Debug, Formatter};
99use std::net::{IpAddr, SocketAddr, TcpListener};
100use std::ops::Range;
101use std::sync::{Arc, Mutex};
102use std::time::Duration;
103
104use log::debug;
105use oauth2::{
106    AuthorizationCode, CsrfToken, EndpointSet, EndpointState, ErrorResponse, PkceCodeChallenge,
107    PkceCodeVerifier, RedirectUrl, RevocableToken, Scope, TokenIntrospectionResponse,
108    TokenResponse,
109};
110use tokio::runtime::Handle;
111use url::Url;
112
113pub use crate::builder::CliOAuthBuilder;
114pub use crate::error::{AuthError, ConfigError, ServerError};
115use crate::server::launch;
116use crate::ConfigError::CannotBindAddress;
117
118mod builder;
119mod error;
120mod server;
121
122pub(crate) type PortRange = Range<u16>;
123/// A shortcut [`Result`] using an error of [`ConfigError`].
124pub type ConfigResult<T> = Result<T, ConfigError>;
125type AuthorizationResultHolder = Arc<Mutex<Option<AuthorizationResult>>>;
126
127/// The CLI OAuth helper.
128#[derive(Debug)]
129pub struct CliOAuth {
130    address: SocketAddr,
131    timeout: u64,
132    scopes: Vec<Scope>,
133    open_browser: bool,
134    auth_context: Option<AuthContext>,
135    auth_result: Option<AuthorizationResult>,
136}
137
138impl CliOAuth {
139    /// Constructs a new builder struct for configuration.
140    pub fn builder() -> CliOAuthBuilder {
141        CliOAuthBuilder::new()
142    }
143
144    /// Generates the redirect URL that will sent in the authorization URL to the identity
145    /// provider.
146    ///
147    /// Pass the result of this method to [`oauth2::Client::set_redirect_uri`] while building the
148    /// client.
149    pub fn redirect_url(&self) -> RedirectUrl {
150        let url = format!("http://{}", self.address);
151        RedirectUrl::from_url(Url::parse(&url).unwrap())
152    }
153
154    /// Initiates the Authorization Code flow.
155    ///
156    /// The PKCE challenge and verifier are generated. The challenge is used in the authorization
157    /// URL, and the verifier is saved for the validation step.
158    ///
159    /// The authorization URL is returned. If [`CliOAuthBuilder::open_browser`] is `true` (the
160    /// default), the user's browser is also opened to that URL automatically.
161    ///
162    /// The authorization code (`code`) and CSRF token (`state`) are extracted from the redirect
163    /// request and recorded. These values are used in the validation step, and then returned to
164    /// the caller for the token exchange.
165    #[cfg(not(tarpaulin_include))]
166    pub async fn authorize<
167        TE,
168        TR,
169        TIR,
170        RT,
171        TRE,
172        HasDeviceAuthUrl,
173        HasIntrospectionUrl,
174        HasRevocationUrl,
175        HasTokenUrl,
176    >(
177        &mut self,
178        oauth_client: &oauth2::Client<
179            TE,
180            TR,
181            TIR,
182            RT,
183            TRE,
184            EndpointSet,
185            HasDeviceAuthUrl,
186            HasIntrospectionUrl,
187            HasRevocationUrl,
188            HasTokenUrl,
189        >,
190    ) -> Result<Url, ServerError>
191    where
192        TE: ErrorResponse + 'static,
193        TR: TokenResponse,
194        TIR: TokenIntrospectionResponse,
195        RT: RevocableToken,
196        TRE: ErrorResponse + 'static,
197        HasDeviceAuthUrl: EndpointState,
198        HasIntrospectionUrl: EndpointState,
199        HasRevocationUrl: EndpointState,
200        HasTokenUrl: EndpointState,
201    {
202        let scopes: Vec<Scope> = self.scopes.to_vec();
203        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
204        let (auth_url, state) = oauth_client
205            .authorize_url(CsrfToken::new_random)
206            .add_scopes(scopes)
207            .set_pkce_challenge(pkce_challenge)
208            .url();
209
210        // Acquire handle to Tokio runtime
211        let handle = Handle::try_current()?;
212        let server = handle.spawn(launch(self.address, Duration::from_secs(self.timeout)));
213
214        debug!("🔑 authorization URL: {}", auth_url);
215        if self.open_browser {
216            open::that(auth_url.as_str())?;
217        }
218
219        let result = server.await?;
220
221        match result {
222            Ok(auth_result) => {
223                self.auth_result = Some(auth_result.clone());
224                let auth_ctx = AuthContext {
225                    auth_code: AuthorizationCode::new(auth_result.auth_code.clone()),
226                    state,
227                    pkce_verifier,
228                };
229                self.auth_context = Some(auth_ctx);
230                Ok(auth_url)
231            }
232            Err(e) => Err(e),
233        }
234    }
235
236    /// Validates the authorization code and CSRF token (`state`).
237    ///
238    /// If validation is successful, then the code and PKCE verifier are returned to the caller in
239    /// order to build the [exchange code](oauth2::Client::exchange_code) request.
240    ///
241    /// This method *must* be called after [`CliOAuth::authorize`] completes successfully.
242    pub fn validate(&mut self) -> Result<AuthContext, AuthError> {
243        let expected_state = self
244            .auth_result
245            .take()
246            .ok_or(AuthError::InvalidAuthState)?
247            .state;
248        match self.auth_context.take() {
249            Some(auth_ctx) if auth_ctx.state.secret() == &expected_state => Ok(auth_ctx),
250            Some(_) => Err(AuthError::CsrfMismatch),
251            None => Err(AuthError::InvalidAuthState),
252        }
253    }
254}
255
256/// Holds intermediate values needed to complete the authorization flow.
257///
258/// These values are generated during the [authorize](CliOAuth::authorize) step, and
259/// provided to the caller after [validation](CliOAuth::validate). They can then be used for the
260/// [code exchange](oauth2::Client::exchange_code).
261#[derive(Debug)]
262pub struct AuthContext {
263    /// The authorization code obtained from the Authorize step.
264    pub auth_code: AuthorizationCode,
265    pub state: CsrfToken,
266    /// The PKCE verifier that will be supplied to the Exchange Code step.
267    pub pkce_verifier: PkceCodeVerifier,
268}
269
270#[derive(Clone)]
271struct AuthorizationResult {
272    pub auth_code: String,
273    pub state: String,
274}
275
276impl Debug for AuthorizationResult {
277    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
278        f.write_fmt(format_args!(
279            "auth code={}*****, state={}*****",
280            self.auth_code.chars().take(3).collect::<String>(),
281            self.state.chars().take(3).collect::<String>(),
282        ))
283    }
284}
285
286const PORT_MIN: u16 = 1024;
287const DEFAULT_PORT_MIN: u16 = 3456;
288const DEFAULT_PORT_MAX: u16 = DEFAULT_PORT_MIN + 10;
289const DEFAULT_TIMEOUT: u64 = 60;
290
291/// Finds an available port within the give range.
292///
293/// Each port will be tried in ascending order. The first port that can successfully bind will be
294/// used, and the resulting socket address will be returned. An error will be returned if no ports
295/// in the range are available.
296///
297/// Note that this function **cannot guarantee** that the address/port combination will be usable by
298/// the server, since any other process on the system could bind to it before this process does.
299fn find_available_port(ip_addr: IpAddr, port_range: PortRange) -> ConfigResult<SocketAddr> {
300    for port in port_range.clone() {
301        let socket_addr = SocketAddr::new(ip_addr, port);
302        if is_address_available(socket_addr) {
303            return Ok(socket_addr);
304        }
305    }
306    Err(CannotBindAddress {
307        addr: ip_addr,
308        port_range,
309    })
310}
311
312/// Checks whether the given socket address is available for this process to use.
313fn is_address_available(socket_addr: SocketAddr) -> bool {
314    TcpListener::bind(socket_addr).is_ok()
315}
316
317// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
318// NOTE! The tests below all use different ports/port ranges, because the order of the tests
319// cannot be guaranteed. If the ports overlap, then tests will fail randomly. Make sure that any
320// future tests use their own unique port values. The best way to do that is with the `next_ports`
321// function to acquire a range of ports for the test.
322// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
323
324#[cfg(test)]
325mod tests {
326    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
327    use std::sync::atomic::AtomicU16;
328    use std::sync::atomic::Ordering::AcqRel;
329
330    use rstest::{fixture, rstest};
331
332    use crate::{find_available_port, is_address_available, PortRange};
333
334    pub(crate) static LOCALHOST: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
335    pub(crate) static PORT_GENERATOR: AtomicU16 = AtomicU16::new(8000);
336
337    // Acquires a range of port numbers for a test.
338    //
339    // Any test that needs to perform testing with network ports should call this method at the
340    // beginning to get the next start and end ports for the test:
341    //
342    // ```
343    // let (port_start, port_end) = next_ports(5);
344    // ```
345    //
346    // The function is backed by an atomic integer, so each test is guaranteed to get a unique
347    // range.
348    pub(crate) fn next_ports(count: u16) -> (u16, u16) {
349        let start = PORT_GENERATOR.fetch_add(count, AcqRel);
350        let end = start + count - 1;
351        (start, end)
352    }
353
354    /// Acquires a range of port numbers for a test.
355    ///
356    /// This is an alternative to [`next_ports`].
357    pub(crate) fn port_range(count: u16) -> PortRange {
358        let (start, end) = next_ports(count);
359        start..end
360    }
361
362    #[fixture]
363    fn one_port() -> PortRange {
364        port_range(1)
365    }
366
367    #[fixture]
368    fn two_ports() -> PortRange {
369        port_range(2)
370    }
371
372    #[fixture]
373    fn three_ports() -> PortRange {
374        port_range(3)
375    }
376
377    #[rstest]
378    fn find_available_port_with_open_port(three_ports: PortRange) {
379        let res = find_available_port(LOCALHOST, three_ports.clone());
380        match res {
381            Ok(addr) => assert!(three_ports.contains(&addr.port())),
382            Err(e) => panic!("error finding available port: {:?}", e),
383        }
384    }
385
386    #[rstest]
387    fn find_available_port_with_no_open_port(two_ports: PortRange) {
388        // Acquire sockets on both ports we need
389        let _s1 = TcpListener::bind(SocketAddr::new(LOCALHOST, two_ports.start)).unwrap();
390        let _s2 = TcpListener::bind(SocketAddr::new(LOCALHOST, two_ports.end)).unwrap();
391        let res = find_available_port(LOCALHOST, two_ports);
392        res.expect_err("ports should not be available");
393    }
394
395    #[rstest]
396    fn check_address_is_available_when_port_is_open(two_ports: PortRange) {
397        let _sock = TcpListener::bind(SocketAddr::new(LOCALHOST, two_ports.end))
398            .expect("control port {open_port} is already open");
399        let address = SocketAddr::new(LOCALHOST, two_ports.start);
400        assert!(is_address_available(address));
401    }
402
403    #[rstest]
404    fn check_address_is_not_available_when_port_is_used(one_port: PortRange) {
405        let _socket = TcpListener::bind(SocketAddr::new(LOCALHOST, one_port.end)).expect(
406            "port is already \
407            open",
408        );
409        let address = SocketAddr::new(LOCALHOST, one_port.start);
410        assert!(!is_address_available(address));
411    }
412
413    mod cli_oauth {
414        use crate::{AuthContext, AuthError, AuthorizationResult, CliOAuth};
415        use oauth2::{AuthorizationCode, CsrfToken, PkceCodeVerifier};
416        use rstest::{fixture, rstest};
417
418        #[fixture]
419        fn auth() -> CliOAuth {
420            CliOAuth {
421                address: ([127, 0, 0, 1], 8080).into(),
422                timeout: 30,
423                scopes: vec![],
424                open_browser: true,
425                auth_context: None,
426                auth_result: None,
427            }
428        }
429
430        #[fixture]
431        fn auth_context() -> AuthContext {
432            AuthContext {
433                state: CsrfToken::new(String::from("state")),
434                auth_code: AuthorizationCode::new(String::from("code")),
435                pkce_verifier: PkceCodeVerifier::new(String::from("pkce")),
436            }
437        }
438
439        #[fixture]
440        fn auth_result() -> AuthorizationResult {
441            AuthorizationResult {
442                auth_code: String::from("code"),
443                state: String::from("state"),
444            }
445        }
446
447        #[rstest]
448        fn redirect_url_valid(auth: CliOAuth) {
449            let url = auth.redirect_url();
450            assert_eq!("http://127.0.0.1:8080/", url.as_str());
451        }
452
453        #[rstest]
454        fn validate_with_no_context(mut auth: CliOAuth, auth_result: AuthorizationResult) {
455            auth.auth_result = Some(auth_result);
456            assert!(auth.validate().is_err());
457        }
458
459        #[rstest]
460        fn validate_with_no_result(mut auth: CliOAuth, auth_context: AuthContext) {
461            auth.auth_context = Some(auth_context);
462            assert!(auth.validate().is_err());
463        }
464
465        #[rstest]
466        fn validate_state_mismatch(
467            mut auth: CliOAuth,
468            mut auth_result: AuthorizationResult,
469            auth_context: AuthContext,
470        ) {
471            auth_result.state = String::from("other_state");
472            auth.auth_result = Some(auth_result);
473            auth.auth_context = Some(auth_context);
474            match auth.validate() {
475                Err(AuthError::CsrfMismatch) => (),
476                Err(e) => panic!("CsrfMismatch error should be raised, but was {:?}", e),
477                Ok(_) => panic!("Validation should fail"),
478            };
479        }
480    }
481}