embystream 0.0.36

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 serde_json::Value;
use time::format_description::well_known::Rfc3339;
use time::{Date, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
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<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()))?;
    parse_token_info(&content)
        .with_context(|| format!("parse token cache file {}", path.display()))
}

fn parse_token_info(content: &str) -> Result<StoredTokenInfo> {
    let value: Value = serde_json::from_str(content)?;
    parse_token_info_value(&value)
}

fn parse_token_info_value(value: &Value) -> Result<StoredTokenInfo> {
    if let Some(object) = value.as_object() {
        return parse_token_info_object(object);
    }

    if let Some(entries) = value.as_array() {
        let Some(token_value) =
            entries.iter().find_map(|entry| entry.get("token"))
        else {
            return Err(anyhow!("missing token entry in array token cache"));
        };
        return parse_token_info_value(token_value);
    }

    Err(anyhow!("unsupported token cache JSON shape"))
}

fn parse_token_info_object(
    object: &serde_json::Map<String, Value>,
) -> Result<StoredTokenInfo> {
    let access_token = string_field(object, "access_token");
    let refresh_token = string_field(object, "refresh_token");
    let expires_at = object
        .get("expires_at")
        .map(parse_expires_at_value)
        .transpose()?;

    Ok(StoredTokenInfo {
        access_token,
        refresh_token,
        expires_at,
    })
}

fn string_field(
    object: &serde_json::Map<String, Value>,
    field_name: &str,
) -> Option<String> {
    object
        .get(field_name)
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
}

fn parse_expires_at_value(value: &Value) -> Result<OffsetDateTime> {
    if let Some(raw) = value.as_str() {
        return OffsetDateTime::parse(raw, &Rfc3339)
            .context("parse expires_at RFC3339 string");
    }

    let parts = value
        .as_array()
        .ok_or_else(|| anyhow!("expires_at must be a string or array"))?;
    if parts.len() != 9 {
        return Err(anyhow!(
            "expires_at array must contain 9 elements, got {}",
            parts.len()
        ));
    }

    let year = integer_part(parts, 0, "year")? as i32;
    let ordinal = integer_part(parts, 1, "ordinal")? as u16;
    let hour = integer_part(parts, 2, "hour")? as u8;
    let minute = integer_part(parts, 3, "minute")? as u8;
    let second = integer_part(parts, 4, "second")? as u8;
    let nanosecond = integer_part(parts, 5, "nanosecond")? as u32;
    let offset_hour = integer_part(parts, 6, "offset_hour")? as i8;
    let offset_minute = integer_part(parts, 7, "offset_minute")? as i8;
    let offset_second = integer_part(parts, 8, "offset_second")? as i8;

    let date = Date::from_ordinal_date(year, ordinal)
        .context("build expires_at date")?;
    let time = Time::from_hms_nano(hour, minute, second, nanosecond)
        .context("build expires_at time")?;
    let offset = UtcOffset::from_hms(offset_hour, offset_minute, offset_second)
        .context("build expires_at offset")?;

    Ok(PrimitiveDateTime::new(date, time).assume_offset(offset))
}

fn integer_part(
    parts: &[Value],
    index: usize,
    field_name: &str,
) -> Result<i64> {
    parts.get(index).and_then(Value::as_i64).ok_or_else(|| {
        anyhow!("expires_at[{index}] ({field_name}) must be an integer")
    })
}

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::*;
    use time::macros::datetime;

    #[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")
        );
    }

    #[test]
    fn parse_token_info_supports_flat_object_payload() {
        let content = r#"{
            "access_token": "access-flat",
            "refresh_token": "refresh-flat",
            "expires_at": "2026-04-03T07:42:38Z"
        }"#;

        let token =
            parse_token_info(content).expect("parse flat token payload");

        assert_eq!(token.access_token.as_deref(), Some("access-flat"));
        assert_eq!(token.refresh_token.as_deref(), Some("refresh-flat"));
        assert_eq!(token.expires_at, Some(datetime!(2026-04-03 07:42:38 UTC)));
    }

    #[test]
    fn parse_token_info_supports_yup_oauth2_cache_payload() {
        let content = r#"[
            {
                "scopes": [
                    "https://www.googleapis.com/auth/drive.readonly"
                ],
                "token": {
                    "access_token": "access-nested",
                    "refresh_token": "refresh-nested",
                    "expires_at": [2026,93,7,42,38,184613000,0,0,0],
                    "id_token": null
                }
            }
        ]"#;

        let token = parse_token_info(content)
            .expect("parse nested token cache payload");

        assert_eq!(token.access_token.as_deref(), Some("access-nested"));
        assert_eq!(token.refresh_token.as_deref(), Some("refresh-nested"));
        assert_eq!(
            token.expires_at,
            Some(datetime!(2026-04-03 07:42:38.184613 UTC))
        );
    }
}