eve_esi 0.4.9

Thread-safe, asynchronous client for EVE Online's ESI & OAuth2
Documentation
//! # EVE Online OAuth2 Tokens
//!
//! Methods for fetching, refreshing, & validating tokens retrieved from EVE Online's OAuth2 API.
//!
//! For an overview & usage examples of OAuth2 with the `eve_esi` crate, see the [module-level documentation](super)
//!
//! ## Methods
//! - [`OAuth2Endpoints::get_token`]: Retrieves a token from EVE Online's OAuth2 API
//! - [`OAuth2Endpoints::get_token_refresh`]: Retrieves a new token using a refresh token
//! - [`OAuth2Endpoints::validate_token`]: Validates token retrieved via the [`OAuth2Endpoints::get_token`] method
//!
//! ## ESI Documentation
//! - <https://developers.eveonline.com/docs/services/sso/>
//!
//! ## Usage Example
//!
//! This demonstrates an example of a callback API route implemented in the Axum web framework.
//! See [SSO example](https://github.com/hyziri/eve_esi/blob/main/examples/sso.rs) for a more complete demonstration.
//!
//! ```no_run
//! use axum::extract::{Query, Extension};
//! use oauth2::TokenResponse;
//! use serde::Deserialize;
//!
//! // URL parameters for the callback route
//! #[derive(Deserialize)]
//! struct CallbackParams {
//!    state: String,
//!    code: String,
//! }
//!
//! // A callback API route implemented in the Axum web framework
//! async fn callback_route(
//!     Extension(esi_client): Extension<eve_esi::Client>,
//!     params: Query<CallbackParams>,
//! ) -> Result<(), eve_esi::Error> {
//!     // Validate state here to prevent CSRF...
//!
//!     // Fetch the token
//!     let token = esi_client
//!         .oauth2()
//!         .get_token(&params.0.code)
//!         .await?;
//!
//!     let access_token = token.access_token();
//!     // Refresh token will be None if no scopes were requested during login
//!     let refresh_token = token.refresh_token().expect("Expected refresh token, found None");
//!
//!     // Validate the access token to access the claims
//!     let claims = esi_client
//!         .oauth2()
//!         .validate_token(access_token.secret().to_string())
//!         .await?;
//!
//!     // Use helper function to get character ID from the claims
//!     let character_id = claims.character_id()?;
//!
//!     // Refresh the token
//!     let new_token = esi_client
//!         .oauth2()
//!         .get_token_refresh(refresh_token.secret().to_string())
//!         .await?;
//!
//!     Ok(())
//! }
//! ```

use jsonwebtoken::{DecodingKey, Validation};
use oauth2::basic::BasicTokenType;
use oauth2::{AuthorizationCode, EmptyExtraTokenFields, RefreshToken, StandardTokenResponse};

use crate::error::{Error, OAuthError};
use crate::model::oauth2::{EveJwtClaims, EveJwtKey};
use crate::oauth2::client::OAuth2Client;
use crate::oauth2::OAuth2Endpoints;
use crate::Client;

impl<'a> OAuth2Endpoints<'a> {
    /// Retrieves a token from EVE Online's OAuth2 API
    ///
    /// This method uses the configured Client to retrieve a token from EVE Online's
    /// OAuth2 API using the provided authorization code. This will contain both your
    /// access token and refresh token. The access token contains the character ID which you
    /// can access after validation. See [Self::validate_token] for token validation.
    ///
    /// For an overview & usage, see the [module-level documentation](super)
    ///
    /// # Documentation
    /// See <https://developers.eveonline.com/docs/services/sso/#authorization-code>
    ///
    /// # Usage
    /// After successful usage of [Self::get_token], you can use these methods on the resulting token:
    ///
    /// - Access token: `token.access_token()`
    /// - Refresh token: `token.refresh_token()` (Refresh token will be None if no scopes were requested)
    ///
    /// The access token expires after 20 minutes, you can use [Self::get_token_refresh]
    /// to get a new token.
    ///
    /// # Arguments
    /// - `code` (&[`str`]): Authorization code returned in the callback API route query parameters
    ///   for your application.
    ///
    /// # Errors
    /// - [`Error`]: If OAuth2 is not configured for the ESI client or there is an issue fetching
    ///   the JWT token from EVE Online's OAuth2 API.
    pub async fn get_token(
        &self,
        code: &str,
    ) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, Error> {
        let oauth_client = get_oauth_client(self.client)?;

        // Attempt to fetch token
        let message = "Attempting to fetch JWT token using provided authorization code";
        debug!("{}", message);

        match oauth_client
            .exchange_code(AuthorizationCode::new(code.to_string()))
            .request_async(&self.client.inner.reqwest_client)
            .await
        {
            Ok(token) => {
                debug!("{}", "JWT Token fetched successfully");

                Ok(token)
            }
            Err(err) => {
                let message = format!("Error fetching token: {:#?}", err);
                error!("{}", message);

                Err(Error::OAuthError(OAuthError::RequestTokenError(err)))
            }
        }
    }

    /// Retrieves a new token using a refresh token
    ///
    /// Uses the configured client to fetch a fresh token using a provided refresh_token.
    /// This will allow for getting a new access token & refresh token which you should replace your old
    /// tokens with.
    ///
    /// This is similar to [`Self::get_token`], the only difference is you are requesting a token using a
    /// refresh token rather than an authorization code.
    ///
    /// For an overview & usage, see the [module-level documentation](super)
    ///
    /// # Documentation
    /// See <https://developers.eveonline.com/docs/services/sso/>
    ///
    /// # Arguments
    /// - `refresh_token` ([`String`]): A string representing a refresh token returned from the
    ///   [`Self::get_token`] method. You can get the refresh token from the token with the
    ///   `token.refresh_token()` method if you haven't yet converted it to a string for database
    ///   storage.
    ///
    /// # Errors
    /// - [`Error`]: If OAuth2 is not configured for the ESI client, the provided refresh_token
    ///   is invalid, or there is an issue fetching the JWT token from EVE Online's OAuth2 API.
    pub async fn get_token_refresh(
        &self,
        refresh_token: String,
    ) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, Error> {
        let oauth_client = get_oauth_client(self.client)?;

        // Convert refresh_token string to RefreshToken
        let refresh_token = RefreshToken::new(refresh_token);

        // Attempt to refresh token
        let message = "Attempting to refresh JWT token using provided refresh token";
        debug!("{}", message);

        match oauth_client
            .exchange_refresh_token(&refresh_token)
            .request_async(&self.client.inner.reqwest_client)
            .await
        {
            Ok(token) => {
                debug!("{}", "JWT Token refreshed successfully");

                Ok(token)
            }
            Err(err) => {
                let message = format!("Error refreshing JWT token token: {:#?}", err);
                error!("{}", message);

                Err(Error::OAuthError(OAuthError::RequestTokenError(err)))
            }
        }
    }

    /// Validates token retrieved via the [`Self::get_token`] method
    ///
    /// This will validate the token with an RS256 JWT key which will either be
    /// fetched EVE's OAuth2 API or retrieved from cache via the
    /// [`crate::oauth2::jwk::JwkApi::get_jwt_keys`] method.
    ///
    /// This function will make 2 attempts to validate a token, if the first attempt
    /// fails the JWT key cache will be cleared and a refresh attempt will be made.
    /// This is useful for when EVE Online rotates the JWT keys used to validate
    /// tokens and the keys need to be refetched.
    ///
    /// For a general overview on tokens & usage, see the [module-level documentation](super)
    ///
    /// # Documentation
    /// See <https://developers.eveonline.com/docs/services/sso/#validating-jwt-tokens>
    ///
    /// # Arguments
    /// - `token_secret` ([`String`]): The access token secret as a stribuilderng. You can use
    ///   `token.access_token().secret().to_string()` on the token returned from [`Self::get_token`].
    ///
    /// # Errors
    /// - [`Error`]: If the retry attempt fails and there is an issue retrieving JWT keys from ESI Client's
    ///   cache or there is an issue validating the token.
    pub async fn validate_token(&self, token_secret: String) -> Result<EveJwtClaims, Error> {
        debug!("Attempting JWT token validation");

        // First attempt
        match attempt_validation(self.client, &token_secret).await {
            Ok(claims) => Ok(claims),
            Err(err) => {
                // Clear the cache to trigger a JWT key refresh on next attempt
                let cache_cleared = self.client.inner.jwt_key_cache.clear_cache().await;

                // Second attempt (retry) if cache was successfully cleared
                if cache_cleared {
                    let message = format!(
                        "Making 2nd attempt to validate token due to previous error: {:#?}",
                        &err
                    );

                    debug!("{}", message);

                    attempt_validation(self.client, &token_secret).await
                } else {
                    let message = format!("Failed to validate JWT token due to error: {:#?}", &err);

                    debug!("{}", message);

                    Err(err)
                }
            }
        }
    }
}

/// Attempts to validate a token retrieved via the [`Self::get_token`] method
///
/// This is the internal utility method for token validation, see [`OAuth2Api::validate_token`]
/// for an overview.
///
/// # Arguments
/// - `client` (&[`Client`]): client used to make ESI & EVE OAuth2 requests and cache JWT keys
/// - `token_secret` ([`String`]): The access token secret as a string. You can use
///   `token.access_token().secret().to_string()` on the token returned from [`Self::get_token`].
///
/// # Errors
/// - [`Error`]: If there is an issue retrieving JWT keys from ESI Client's cache or there is an
///   issue validating the token.
async fn attempt_validation(client: &Client, token_secret: &str) -> Result<EveJwtClaims, Error> {
    // Get JWT keys to validate token
    trace!("Retrieving keys for validation from JWT key cache");

    let jwt_keys = client.oauth2().jwk().get_jwt_keys().await?;

    // Configure validation
    let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
    validation.set_audience(&[client.inner.jwt_audience.to_string()]);
    validation.set_issuer(&client.inner.jwt_issuers);

    // Try to find an RS256 key
    trace!("Checking JWT key cache for RS256 key");

    if let Some(EveJwtKey::RS256 { ref n, ref e, .. }) = &jwt_keys.get_first_rs256_key() {
        // RS256 key was found, extract n (modulus) and e (exponent) components for the decoding key
        trace!("Creating a decoding key from RS256 key");

        let decoding_key = match DecodingKey::from_rsa_components(n, e) {
            Ok(key) => {
                trace!("Created decoding key from RS256 key successfully");

                key
            }
            Err(err) => {
                error!("Failed to decode RS256 key for token validation: {}", &err);

                return Err(Error::OAuthError(OAuthError::ValidateTokenError(err)));
            }
        };

        // Validate the token
        debug!("Validating token using RS256 decoding key");

        match jsonwebtoken::decode::<EveJwtClaims>(token_secret, &decoding_key, &validation) {
            Ok(token_data) => {
                let character_id = token_data.claims.character_id()?;
                let message = format!(
                    "Successfully validated JWT token for character ID: {}",
                    character_id
                );

                info!("{}", message);

                Ok(token_data.claims)
            }
            Err(err) => {
                let message = format!("Failed to validate token with RS256 key: {}", &err);

                error!("{}", message);

                Err(Error::OAuthError(OAuthError::ValidateTokenError(err)))
            }
        }
    } else {
        // No RS256 key was found
        let message: &str =
            "Failed to find RS256 key in JWT key cache when attempting to validate a JWT token.";

        error!(message);

        Err(Error::OAuthError(OAuthError::NoValidKeyFound(
            message.to_string(),
        )))
    }
}

/// Utility function to retrieve OAuth2 client or return an error
fn get_oauth_client(client: &Client) -> Result<&OAuth2Client, Error> {
    // Attempt to retrieve OAuth2 client from ESI client
    trace!("Attempting to retrieve OAuth2 client from ESI client");

    match client.inner.oauth2_client {
        Some(ref client) => {
            trace!("{}", "Found OAuth2 client on ESI client");

            Ok(client)
        }
        None => {
            error!("{}", Error::OAuthError(OAuthError::OAuth2NotConfigured));

            // No OAuth2 client was found due to not being configured
            Err(Error::OAuthError(OAuthError::OAuth2NotConfigured))
        }
    }
}