embystream 0.0.27

Another Emby streaming application (frontend/backend separation) written in Rust.
Documentation
use std::{
    fs,
    future::Future,
    path::{Path, PathBuf},
    pin::Pin,
};

use anyhow::{Context, Result, anyhow};
use time::format_description::well_known::Rfc3339;
use yup_oauth2::{
    ApplicationSecret, InstalledFlowAuthenticator, InstalledFlowReturnMethod,
    authenticator_delegate::{
        DefaultInstalledFlowDelegate, InstalledFlowDelegate,
    },
};

const GOOGLE_AUTH_URI: &str = "https://accounts.google.com/o/oauth2/auth";
const GOOGLE_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
const GOOGLE_DRIVE_READONLY_SCOPE: &str =
    "https://www.googleapis.com/auth/drive.readonly";

#[derive(Clone, Debug)]
pub struct GoogleAuthArgs {
    pub client_id: String,
    pub client_secret: String,
    pub no_browser: bool,
}

#[derive(Clone, Debug, serde::Deserialize)]
struct StoredTokenInfo {
    access_token: Option<String>,
    refresh_token: Option<String>,
    expires_at: Option<time::OffsetDateTime>,
}

#[derive(Clone)]
struct CliInstalledFlowDelegate {
    open_browser: bool,
}

impl InstalledFlowDelegate for CliInstalledFlowDelegate {
    fn present_user_url<'a>(
        &'a self,
        url: &'a str,
        need_code: bool,
    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
        Box::pin(async move {
            println!(
                "Open this URL to authorize Google Drive readonly access:"
            );
            println!("{url}");

            if self.open_browser {
                match webbrowser::open(url) {
                    Ok(_) => {
                        println!(
                            "Browser launch requested. If nothing opens, \
                             use the URL above manually."
                        );
                    }
                    Err(err) => {
                        println!("Browser launch failed: {err}");
                        println!(
                            "Continue by copying the URL into a browser manually."
                        );
                    }
                }
            } else {
                println!(
                    "Browser auto-open disabled. Copy the URL into a browser \
                     manually."
                );
            }

            DefaultInstalledFlowDelegate
                .present_user_url(url, need_code)
                .await
        })
    }
}

fn build_secret(client_id: &str, client_secret: &str) -> ApplicationSecret {
    ApplicationSecret {
        client_id: client_id.to_string(),
        client_secret: client_secret.to_string(),
        auth_uri: GOOGLE_AUTH_URI.to_string(),
        token_uri: GOOGLE_TOKEN_URI.to_string(),
        redirect_uris: vec![
            "http://127.0.0.1".to_string(),
            "http://localhost".to_string(),
        ],
        ..Default::default()
    }
}

fn temp_token_cache_path() -> PathBuf {
    std::env::temp_dir().join(format!(
        "embystream_google_auth_{}.json",
        uuid::Uuid::new_v4()
    ))
}

fn read_token_info(path: &Path) -> Result<StoredTokenInfo> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("read token cache file {}", path.display()))?;
    serde_json::from_str(&content)
        .with_context(|| format!("parse token cache file {}", path.display()))
}

fn print_token_result(token: &StoredTokenInfo) -> Result<()> {
    let access_token = token
        .access_token
        .as_deref()
        .filter(|value| !value.is_empty())
        .ok_or_else(|| anyhow!("missing access_token after Google auth"))?;
    let refresh_token = token
        .refresh_token
        .as_deref()
        .filter(|value| !value.is_empty())
        .ok_or_else(|| anyhow!("missing refresh_token after Google auth"))?;

    println!("Google OAuth succeeded.");
    println!("access_token={access_token}");
    println!("refresh_token={refresh_token}");

    if let Some(expires_at) = token.expires_at {
        let formatted = expires_at
            .format(&Rfc3339)
            .context("format expires_at as RFC3339")?;
        println!("expires_at={formatted}");
    }

    Ok(())
}

pub async fn run_google_auth(args: &GoogleAuthArgs) -> Result<()> {
    let cache_path = temp_token_cache_path();
    let secret = build_secret(&args.client_id, &args.client_secret);
    let delegate = CliInstalledFlowDelegate {
        open_browser: !args.no_browser,
    };

    let auth = InstalledFlowAuthenticator::builder(
        secret,
        InstalledFlowReturnMethod::HTTPRedirect,
    )
    .persist_tokens_to_disk(&cache_path)
    .flow_delegate(Box::new(delegate))
    .build()
    .await
    .context("build Google installed-flow authenticator")?;

    let scopes = [GOOGLE_DRIVE_READONLY_SCOPE];
    auth.token(&scopes)
        .await
        .context("perform Google OAuth installed flow")?;

    let token_info = read_token_info(&cache_path)?;
    let print_result = print_token_result(&token_info);
    let _ = fs::remove_file(&cache_path);
    print_result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_secret_uses_google_endpoints_and_loopback_redirects() {
        let secret = build_secret("cid", "sec");
        assert_eq!(secret.client_id, "cid");
        assert_eq!(secret.client_secret, "sec");
        assert_eq!(secret.auth_uri, GOOGLE_AUTH_URI);
        assert_eq!(secret.token_uri, GOOGLE_TOKEN_URI);
        assert!(
            secret
                .redirect_uris
                .iter()
                .any(|uri| uri == "http://127.0.0.1")
        );
        assert!(
            secret
                .redirect_uris
                .iter()
                .any(|uri| uri == "http://localhost")
        );
    }
}