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