ring-client 0.1.3

A Rust client for interfacing with Ring home security devices.
Documentation
mod error;

pub use error::AuthenticationError;

use crate::helper::url::Url;
use crate::{Client, helper};
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use serde::Serialize;
use serde_json::json;
use std::fmt::Debug;
use std::ops::Add;
use std::sync::Arc;

use crate::helper::OperatingSystem;

#[derive(Debug, Serialize)]
pub(crate) struct Tokens {
    pub(crate) access_token: String,
    pub(crate) expires_at: DateTime<Utc>,
    pub(crate) refresh_token: String,
}

impl Tokens {
    #[must_use]
    pub const fn new(
        access_token: String,
        expires_at: DateTime<Utc>,
        refresh_token: String,
    ) -> Self {
        Self {
            access_token,
            expires_at,
            refresh_token,
        }
    }
}

impl<'de> serde::Deserialize<'de> for Tokens {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = serde_json::Value::deserialize(deserializer)?;

        let access_token = value["access_token"]
            .as_str()
            .ok_or_else(|| serde::de::Error::custom("Invalid access token"))?
            .to_string();
        let refresh_token = value["refresh_token"]
            .as_str()
            .ok_or_else(|| serde::de::Error::custom("Invalid refresh token"))?
            .to_string();
        let expires_at = Utc::now().add(chrono::Duration::seconds(
            value["expires_in"]
                .as_i64()
                .ok_or_else(|| serde::de::Error::custom("Invalid expires_in value"))?,
        ));

        Ok(Self::new(access_token, expires_at, refresh_token))
    }
}

#[derive(Debug)]
pub(crate) struct RingAuth {
    client: reqwest::Client,
    operating_system: OperatingSystem,
}

/// A set of credentials used to authenticate with the Ring API.
#[derive(Debug)]
pub enum Credentials {
    /// A username and password.
    ///
    /// This method is subject to two-factor authentication (2FA) and may require a
    /// verification code to be sent to the user's associated mobile number.
    #[allow(missing_docs)]
    User { username: String, password: String },

    /// An existing refresh token for a user.
    ///
    /// This can be generated by logging in with a username and password, and then
    /// using [`get_refresh_token`](Client::get_refresh_token) to retrieve it for later
    /// use.
    RefreshToken(String),
}

impl RingAuth {
    #[must_use]
    pub fn new(operating_system: OperatingSystem) -> Self {
        Self {
            client: reqwest::Client::new(),
            operating_system,
        }
    }

    pub(crate) async fn login(
        &self,
        username: &str,
        password: &str,
        system_id: &str,
    ) -> Result<Tokens, AuthenticationError> {
        let response = self
            .client
            .post(helper::url::get_base_url(&Url::Oauth))
            .header("User-Agent", self.operating_system.get_user_agent())
            .header("2fa-support", "true")
            .header(
                "hardware_id",
                crate::helper::hardware::generate_hardware_id(system_id),
            )
            .json(&json!({
                "client_id": self.operating_system.get_client_id(),
                "scope": "client",
                "grant_type": "password",
                "password": password,
                "username": username,
            }))
            .send()
            .await?;

        if response.status() == StatusCode::PRECONDITION_FAILED {
            return Err(AuthenticationError::MfaCodeRequired);
        }

        if response.status() != StatusCode::OK {
            log::error!("Failed to login with status code: {}", response.status());
            return Err(AuthenticationError::InvalidCredentials);
        }

        Ok(response.json::<Tokens>().await?)
    }

    pub(crate) async fn respond_to_challenge(
        &self,
        username: &str,
        password: &str,
        system_id: &str,
        code: &str,
    ) -> Result<Tokens, AuthenticationError> {
        Ok(self
            .client
            .post(helper::url::get_base_url(&Url::Oauth))
            .header("User-Agent", self.operating_system.get_user_agent())
            .header("2fa-support", "true")
            .header("2fa-code", code)
            .header(
                "hardware_id",
                crate::helper::hardware::generate_hardware_id(system_id),
            )
            .json(&json!({
                "client_id": self.operating_system.get_client_id(),
                "scope": "client",
                "grant_type": "password",
                "password": &password,
                "username": &username,
            }))
            .send()
            .await?
            .json::<Tokens>()
            .await?)
    }

    pub(crate) async fn refresh_tokens(
        &self,
        tokens: Arc<Tokens>,
    ) -> Result<Tokens, AuthenticationError> {
        Ok(self
            .client
            .post(helper::url::get_base_url(&Url::Oauth))
            .header("User-Agent", self.operating_system.get_user_agent())
            .header("2fa-support", "true")
            .json(&json!({
                "client_id": "ring_official_ios",
                "grant_type": "refresh_token",
                "scope": "client",
                "refresh_token": tokens.refresh_token,
            }))
            .send()
            .await?
            .json::<Tokens>()
            .await?)
    }
}

impl Client {
    pub(crate) async fn refresh_tokens_if_needed(
        &self,
    ) -> Result<Arc<Tokens>, AuthenticationError> {
        let mut token_to_refresh = self
            .tokens
            .write()
            .await
            .take_if(|current_tokens| current_tokens.expires_at < Utc::now());

        if let Some(current_tokens) = &token_to_refresh {
            let replacement_tokens =
                Arc::new(self.auth.refresh_tokens(Arc::clone(current_tokens)).await?);

            token_to_refresh.replace(Arc::clone(&replacement_tokens));

            log::info!(
                "Tokens have been replaced successfully. New expiration time: {}",
                replacement_tokens.expires_at,
            );

            return Ok(Arc::clone(&replacement_tokens));
        }

        self.tokens.read().await.as_ref().map_or_else(
            || Err(AuthenticationError::InvalidCredentials),
            |current_tokens| Ok(Arc::clone(current_tokens)),
        )
    }
}