openauth-plugins 0.0.4

Official OpenAuth plugin modules.
Documentation
use openauth_oauth::oauth2::{OAuth2Tokens, OAuth2UserInfo, OAuthError};
use serde_json::Value;

use super::super::user_info;

pub async fn gumroad(tokens: OAuth2Tokens) -> Result<Option<OAuth2UserInfo>, OAuthError> {
    let Some(profile) = bearer_json(
        "https://api.gumroad.com/v2/user",
        tokens.access_token.as_deref(),
    )
    .await?
    else {
        return Ok(None);
    };
    Ok(map_gumroad_profile(&profile))
}

pub async fn hubspot(tokens: OAuth2Tokens) -> Result<Option<OAuth2UserInfo>, OAuthError> {
    let Some(access_token) = tokens.access_token.as_deref() else {
        return Ok(None);
    };
    let profile = reqwest::Client::new()
        .get(format!(
            "https://api.hubapi.com/oauth/v1/access-tokens/{access_token}"
        ))
        .header("content-type", "application/json")
        .send()
        .await?
        .error_for_status()?
        .json::<Value>()
        .await?;
    Ok(map_hubspot_profile(&profile))
}

pub async fn line(tokens: OAuth2Tokens) -> Result<Option<OAuth2UserInfo>, OAuthError> {
    if let Some(id_token) = tokens.id_token.as_deref() {
        if let Some(user) = user_info::decode_id_token_claims(id_token)
            .as_ref()
            .and_then(map_line_profile)
        {
            return Ok(Some(user));
        }
    }
    let profile = bearer_json(
        "https://api.line.me/oauth2/v2.1/userinfo",
        tokens.access_token.as_deref(),
    )
    .await?;
    Ok(profile.as_ref().and_then(map_line_profile))
}

pub async fn microsoft_entra_id(
    tokens: OAuth2Tokens,
) -> Result<Option<OAuth2UserInfo>, OAuthError> {
    let profile = bearer_json(
        "https://graph.microsoft.com/oidc/userinfo",
        tokens.access_token.as_deref(),
    )
    .await?;
    Ok(profile.as_ref().and_then(map_microsoft_entra_profile))
}

pub async fn patreon(tokens: OAuth2Tokens) -> Result<Option<OAuth2UserInfo>, OAuthError> {
    let profile = bearer_json(
        "https://www.patreon.com/api/oauth2/v2/identity?fields[user]=email,full_name,image_url,is_email_verified",
        tokens.access_token.as_deref(),
    )
    .await?;
    Ok(profile.as_ref().and_then(map_patreon_profile))
}

pub async fn slack(tokens: OAuth2Tokens) -> Result<Option<OAuth2UserInfo>, OAuthError> {
    let profile = bearer_json(
        "https://slack.com/api/openid.connect.userInfo",
        tokens.access_token.as_deref(),
    )
    .await?;
    Ok(profile.as_ref().and_then(map_slack_profile))
}

fn map_gumroad_profile(profile: &Value) -> Option<OAuth2UserInfo> {
    let user = profile.get("user")?;
    if profile.get("success").and_then(Value::as_bool) != Some(true) {
        return None;
    }
    Some(OAuth2UserInfo {
        id: string_value(user, "user_id")?,
        name: string_value(user, "name"),
        email: string_value(user, "email"),
        image: string_value(user, "profile_url"),
        email_verified: false,
    })
}

fn map_hubspot_profile(profile: &Value) -> Option<OAuth2UserInfo> {
    let id = string_value(profile, "user_id").or_else(|| {
        profile
            .get("signed_access_token")
            .and_then(|value| string_value(value, "userId"))
    })?;
    Some(OAuth2UserInfo {
        id,
        name: string_value(profile, "user"),
        email: string_value(profile, "user"),
        image: None,
        email_verified: false,
    })
}

fn map_line_profile(profile: &Value) -> Option<OAuth2UserInfo> {
    Some(OAuth2UserInfo {
        id: string_value(profile, "sub")?,
        name: string_value(profile, "name"),
        email: string_value(profile, "email"),
        image: string_value(profile, "picture"),
        email_verified: false,
    })
}

fn map_microsoft_entra_profile(profile: &Value) -> Option<OAuth2UserInfo> {
    Some(OAuth2UserInfo {
        id: string_value(profile, "sub")?,
        name: string_value(profile, "name").or_else(|| full_name(profile)),
        email: string_value(profile, "email")
            .or_else(|| string_value(profile, "preferred_username")),
        image: string_value(profile, "picture"),
        email_verified: profile
            .get("email_verified")
            .and_then(Value::as_bool)
            .unwrap_or(false),
    })
}

fn map_patreon_profile(profile: &Value) -> Option<OAuth2UserInfo> {
    let data = profile.get("data")?;
    let attributes = data.get("attributes")?;
    Some(OAuth2UserInfo {
        id: string_value(data, "id")?,
        name: string_value(attributes, "full_name"),
        email: string_value(attributes, "email"),
        image: string_value(attributes, "image_url"),
        email_verified: attributes
            .get("is_email_verified")
            .and_then(Value::as_bool)
            .unwrap_or(false),
    })
}

fn map_slack_profile(profile: &Value) -> Option<OAuth2UserInfo> {
    Some(OAuth2UserInfo {
        id: string_value(profile, "https://slack.com/user_id")
            .or_else(|| string_value(profile, "sub"))?,
        name: string_value(profile, "name"),
        email: string_value(profile, "email"),
        image: string_value(profile, "picture")
            .or_else(|| string_value(profile, "https://slack.com/user_image_512")),
        email_verified: profile
            .get("email_verified")
            .and_then(Value::as_bool)
            .unwrap_or(false),
    })
}

async fn bearer_json(url: &str, access_token: Option<&str>) -> Result<Option<Value>, OAuthError> {
    let Some(access_token) = access_token else {
        return Ok(None);
    };
    let profile = reqwest::Client::new()
        .get(url)
        .bearer_auth(access_token)
        .send()
        .await?
        .error_for_status()?
        .json::<Value>()
        .await?;
    Ok(Some(profile))
}

fn string_value(profile: &Value, key: &str) -> Option<String> {
    match profile.get(key)? {
        Value::String(value) => Some(value.clone()),
        Value::Number(value) => Some(value.to_string()),
        _ => None,
    }
}

fn full_name(profile: &Value) -> Option<String> {
    let given = string_value(profile, "given_name").unwrap_or_default();
    let family = string_value(profile, "family_name").unwrap_or_default();
    let name = format!("{given} {family}").trim().to_owned();
    (!name.is_empty()).then_some(name)
}

#[cfg(test)]
mod tests {
    #![allow(
        clippy::panic,
        reason = "fixture tests should fail immediately when a fixture no longer maps"
    )]

    use serde_json::json;

    use super::{
        map_gumroad_profile, map_hubspot_profile, map_line_profile, map_microsoft_entra_profile,
        map_patreon_profile, map_slack_profile,
    };

    #[test]
    fn maps_gumroad_profile() {
        let Some(user) = map_gumroad_profile(&json!({
            "success": true,
            "user": {
                "user_id": "gum-1",
                "name": "Ada",
                "email": "ada@example.com",
                "profile_url": "https://img.example.com/ada.png"
            }
        })) else {
            panic!("expected gumroad fixture to map");
        };

        assert_eq!(user.id, "gum-1");
        assert_eq!(user.email.as_deref(), Some("ada@example.com"));
        assert!(!user.email_verified);
    }

    #[test]
    fn maps_hubspot_profile() {
        let Some(user) = map_hubspot_profile(&json!({
            "user": "ada@example.com",
            "signed_access_token": { "userId": 42 }
        })) else {
            panic!("expected hubspot fixture to map");
        };

        assert_eq!(user.id, "42");
        assert_eq!(user.name.as_deref(), Some("ada@example.com"));
    }

    #[test]
    fn maps_line_profile() {
        let Some(user) = map_line_profile(&json!({
            "sub": "line-1",
            "name": "Ada",
            "email": "ada@example.com",
            "picture": "https://img.example.com/line.png"
        })) else {
            panic!("expected line fixture to map");
        };

        assert_eq!(user.id, "line-1");
        assert_eq!(
            user.image.as_deref(),
            Some("https://img.example.com/line.png")
        );
    }

    #[test]
    fn maps_microsoft_entra_profile() {
        let Some(user) = map_microsoft_entra_profile(&json!({
            "sub": "ms-1",
            "given_name": "Ada",
            "family_name": "Lovelace",
            "preferred_username": "ada@example.com"
        })) else {
            panic!("expected microsoft entra fixture to map");
        };

        assert_eq!(user.name.as_deref(), Some("Ada Lovelace"));
        assert_eq!(user.email.as_deref(), Some("ada@example.com"));
        assert!(!user.email_verified);
    }

    #[test]
    fn maps_patreon_profile() {
        let Some(user) = map_patreon_profile(&json!({
            "data": {
                "id": "pat-1",
                "attributes": {
                    "full_name": "Ada",
                    "email": "ada@example.com",
                    "image_url": "https://img.example.com/patreon.png",
                    "is_email_verified": true
                }
            }
        })) else {
            panic!("expected patreon fixture to map");
        };

        assert_eq!(user.id, "pat-1");
        assert!(user.email_verified);
    }

    #[test]
    fn maps_slack_profile() {
        let Some(user) = map_slack_profile(&json!({
            "sub": "slack-sub",
            "https://slack.com/user_id": "slack-1",
            "email": "ada@example.com",
            "email_verified": true,
            "name": "Ada",
            "https://slack.com/user_image_512": "https://img.example.com/slack.png"
        })) else {
            panic!("expected slack fixture to map");
        };

        assert_eq!(user.id, "slack-1");
        assert_eq!(
            user.image.as_deref(),
            Some("https://img.example.com/slack.png")
        );
    }
}