tiny_google_oidc 0.6.0

Tiny library for Google's OpenID Connect
Documentation
//! Provides the process of requesting and decode IDToken.
//!
//! This module:
//! IDTokenRequest: A data structure for sending requests to the token endpoint.
//! IDTokenResponse: A data structure for parsing the response from the token endpoint.
//! IDToken: A data structure representing the decoded payload of an ID token.
//! AccessToken: A structure representing an access token used to call Google APIs.
//! IDTokenRaw: A structure representing an encoded ID token before decoding.

use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use tracing::error;

use crate::{
    code::Code,
    config::{ClientID, ClientSecret, Config, RedirectURI, TokenEndPoint},
    error::Error,
    nonce::Nonce,
    refresh_token::RefreshToken,
};
/// Represents a decoded ID token payload in OpenID Connect.  
/// An ID token contains user authentication and profile information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IDToken {
    /// Issuer (e.g., "<https://accounts.google.com>")
    pub iss: String,
    /// Client ID
    pub aud: String,
    /// User ID (Unique identifier for Google accounts)
    pub sub: String,
    /// Authorized party (Optional)
    pub azp: Option<String>,
    /// User's email address
    pub email: Option<String>,
    /// Whether the email is verified
    pub email_verified: Option<bool>,
    /// Given name
    pub given_name: Option<String>,
    /// Family name
    pub family_name: Option<String>,
    /// Full name
    pub name: Option<String>,
    /// Profile picture URL
    pub picture: Option<String>,
    /// Access token hash
    pub at_hash: Option<String>,
    /// Issued-at timestamp (UNIX time)
    pub iat: u32,
    /// Expiration timestamp (UNIX time)
    pub exp: u32,
    /// Nonce for security validation
    pub nonce: Option<Nonce>,
}

impl IDToken {
    /// Construct IDToken from [`IDTokenRaw`] .   
    /// Decodes an IDTokenRaw (encoded ID token) into an IDToken.
    pub fn from_id_token_raw(id_token: &IDTokenRaw) -> Result<Self, Error> {
        let split: Vec<_> = id_token.0.split(".").collect();
        if split.len() != 3 {
            return Err(Error::Decode);
        }

        let bytes = BASE64_URL_SAFE_NO_PAD.decode(split[1]).map_err(|e| {
            error!("Failed to decode IDToken: {}", e);
            Error::Decode
        })?;

        // Deserialize from bytes::Byte
        let id_token = serde_json::from_slice::<IDToken>(&bytes).map_err(|e| {
            error!("Failed to deserialize IDToken: {}", e);
            Error::Deserialize
        })?;
        Ok(id_token)
    }
}

/// A structure used to send an ID token request to Google's token endpoint.
#[derive(Debug, Clone)]
pub struct IDTokenRequest<'a> {
    token_endpoint: &'a TokenEndPoint,
    code: Code,
    client_id: &'a ClientID,
    client_secret: &'a ClientSecret,
    redirect_uri: &'a RedirectURI,
    grant_type: &'a str,
}

impl<'a> IDTokenRequest<'a> {
    /// Creates a new request using parameters from Config and Code.
    pub fn new(config: &'a Config, code: Code) -> Self {
        Self {
            token_endpoint: config.token_endpoint(),
            code,
            client_id: config.client_id(),
            client_secret: config.client_secret(),
            redirect_uri: config.redirect_uri(),
            grant_type: "authorization_code",
        }
    }

    pub fn token_endpoint(&self) -> &TokenEndPoint {
        self.token_endpoint
    }

    pub fn code(&self) -> &Code {
        &self.code
    }

    pub fn client_id(&self) -> &ClientID {
        self.client_id
    }

    pub fn client_secret(&self) -> &ClientSecret {
        self.client_secret
    }

    pub fn redirect_uri(&self) -> &RedirectURI {
        self.redirect_uri
    }

    pub fn grant_type(&self) -> &str {
        self.grant_type
    }
}

/// Represents the response from Google's token endpoint, which includes both an access token and an ID token.
#[derive(Debug, Clone, Deserialize)]
pub struct IDTokenResponse {
    access_token: AccessToken,
    expires_in: u32,
    id_token: IDTokenRaw,
    scope: String,
    token_type: String,
    refresh_token: Option<RefreshToken>,
}

impl IDTokenResponse {
    pub fn access_token(&self) -> &AccessToken {
        &self.access_token
    }

    pub fn expires_in(&self) -> u32 {
        self.expires_in
    }

    pub fn id_token(&self) -> &IDTokenRaw {
        &self.id_token
    }

    pub fn scope(&self) -> &str {
        &self.scope
    }

    pub fn token_type(&self) -> &str {
        &self.token_type
    }

    pub fn refresh_token(&self) -> &Option<RefreshToken> {
        &self.refresh_token
    }
}

/// Represents an OAuth 2.0 access token.  
/// This token is used to access Google APIs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AccessToken(pub(crate) String);

impl AccessToken {
    /// Retrieves the access token as a string.
    pub fn value(&self) -> String {
        self.0.clone()
    }
}

/// Represents an encoded ID token, which must be decoded before use.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct IDTokenRaw(String);

/// A function that sends an HTTP request to Google's authentication server to obtain an IDToken.  
///
/// It takes an `IDTokenRequest` struct as input and returns an `IDTokenResponse` on success.  
/// The implementation uses the [reqwest](https://docs.rs/reqwest/) crate internally for HTTP communication.
pub async fn send_id_token_req(req: &IDTokenRequest<'_>) -> Result<IDTokenResponse, Error> {
    use reqwest::Client;
    use std::collections::HashMap;
    use url::Url;

    let url = Url::parse(req.token_endpoint().value()).map_err(|e| {
        error!("Failed to parse url: {:?}", e);
        Error::ParseURL
    })?;
    let mut params = HashMap::new();
    params.insert("code", req.code().0.as_str());
    params.insert("client_id", req.client_id().value());
    params.insert("client_secret", req.client_secret().value());
    params.insert("redirect_uri", req.redirect_uri().value());
    params.insert("grant_type", req.grant_type());

    let client = Client::new();
    let res = client
        .post(url)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .form(&params)
        .send()
        .await
        .map_err(|e| {
            error!("Failed to send request: {:?}", e);
            Error::Send
        })?;

    if !res.status().is_success() {
        return Err(Error::SendStatus(res.status()));
    }

    let res_json = res.json::<IDTokenResponse>().await.map_err(|e| {
        error!("Failed to deserialize JSON: {:?}", e);
        Error::DeserializeJson
    })?;
    Ok(res_json)
}

#[cfg(test)]
mod tests {
    use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};

    use crate::{
        code::Code,
        config::ConfigBuilder,
        error::Error,
        id_token::{AccessToken, IDToken, IDTokenRaw, IDTokenRequest, IDTokenResponse},
        refresh_token::RefreshToken,
    };

    #[test]
    fn test_access_token_value() {
        let token = AccessToken("test_token".to_string());
        assert_eq!(token.value(), "test_token");
    }

    #[test]
    fn test_id_token_decode_success() {
        let id_token_json = r#"{
            "iss": "https://accounts.google.com",
            "aud": "my_aud",
            "sub": "my_sub",
            "azp": "my_azp",
            "email": "email@gmail.com",
            "email_verified": true,
            "given_name": "my_given_name",
            "family_name": "my_family_name",
            "name": "my_name",
            "picture": "https://picture.example.com",
            "at_hash": "my_at_hash",
            "iat": 1742189616,
            "exp": 1742193216,
            "nonce": "my_nonce"
        }"#;
        let encoded = BASE64_URL_SAFE_NO_PAD.encode(id_token_json);

        let mut token_raw = "header.".to_string();
        token_raw.push_str(&encoded);
        token_raw.push_str(".signature");
        let id_token_raw = IDTokenRaw(token_raw);

        let decoded = IDToken::from_id_token_raw(&id_token_raw);
        assert!(decoded.is_ok());
    }

    #[test]
    fn test_id_token_decode_invalid_base64() {
        let id_token_raw = IDTokenRaw("invalid_base64".to_string());

        let decoded = IDToken::from_id_token_raw(&id_token_raw);
        assert!(matches!(decoded, Err(Error::Decode)));
    }

    #[test]
    fn test_id_token_decode_invalid_json() {
        let invalid_json = BASE64_URL_SAFE_NO_PAD.encode("not a valid json");
        let id_token_raw = IDTokenRaw(invalid_json);

        let decoded = IDToken::from_id_token_raw(&id_token_raw);
        assert!(matches!(decoded, Err(Error::Decode)));
    }

    #[test]
    fn test_id_token_request_new() {
        let config = ConfigBuilder::new()
            .token_endpoint("https://token.example.com")
            .client_id("client_id")
            .client_secret("secret")
            .redirect_uri("https://redirect.example.com")
            .build();

        let code = Code("auth_code".to_string());
        let request = IDTokenRequest::new(&config, code.clone());

        assert_eq!(request.token_endpoint.0, "https://token.example.com");
        assert_eq!(request.client_id.0, "client_id");
        assert_eq!(request.client_secret.0, "secret");
        assert_eq!(request.redirect_uri.0, "https://redirect.example.com");
        assert_eq!(request.code, code);
    }

    #[test]
    fn test_id_token_response_getters() {
        let access_token = AccessToken("access_token_value".to_string());
        let id_token_raw = IDTokenRaw("id_token_value".to_string());
        let refresh_token = Some(RefreshToken("refresh_token_value".to_string()));

        let response = IDTokenResponse {
            access_token: access_token.clone(),
            expires_in: 3600,
            id_token: id_token_raw.clone(),
            scope: "openid email".to_string(),
            token_type: "Bearer".to_string(),
            refresh_token: refresh_token.clone(),
        };

        assert_eq!(response.access_token(), &access_token);
        assert_eq!(response.expires_in(), 3600);
        assert_eq!(response.id_token(), &id_token_raw);
        assert_eq!(response.scope(), "openid email");
        assert_eq!(response.token_type(), "Bearer");
        assert_eq!(response.refresh_token(), &refresh_token);
    }
}