aauth 0.0.2

Rust implementation of the AAuth authorization protocol
Documentation
use http::HeaderMap;

use crate::error::{AAuthError, Result};
use crate::headers::parse_aauth_requirement;
use crate::types::{RequirementLevel, TokenResponseBody};

use super::types::DeferRequirement;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedDeferred {
    pub location: String,
    pub requirement: DeferRequirement,
}

pub fn resolve_deferred_location(base_url: &str, location: &str) -> String {
    if location.starts_with("http://") || location.starts_with("https://") {
        location.to_string()
    } else {
        url::Url::parse(base_url)
            .and_then(|b| b.join(location.trim_start_matches('/')))
            .map(|u| u.to_string())
            .unwrap_or_else(|_| location.to_string())
    }
}

pub fn parse_deferred_response(
    status: u16,
    headers: &HeaderMap,
    body: &[u8],
    base_url: &str,
) -> Result<ParsedDeferred> {
    if status != 202 {
        return Err(AAuthError::Message(format!(
            "expected 202 Accepted, got {status}"
        )));
    }

    let location = header_value(headers, "location")
        .ok_or_else(|| AAuthError::Message("202 response missing Location header".into()))?;
    let location = resolve_deferred_location(base_url, location);

    let requirement_header = header_value(headers, "aauth-requirement").ok_or_else(|| {
        AAuthError::Message("202 response missing AAuth-Requirement header".into())
    })?;
    let challenge = parse_aauth_requirement(requirement_header)?;

    let json_body: Option<serde_json::Value> = if body.is_empty() {
        None
    } else {
        serde_json::from_slice(body).ok()
    };

    let requirement = map_challenge_to_defer(&challenge, json_body.as_ref())?;

    Ok(ParsedDeferred {
        location,
        requirement,
    })
}

pub fn parse_auth_token_response(status: u16, body: &[u8]) -> Result<TokenResponseBody> {
    if status != 200 {
        return Err(AAuthError::Message(format!(
            "expected 200 OK for auth token, got {status}"
        )));
    }
    serde_json::from_slice(body).map_err(|e| AAuthError::Message(e.to_string()))
}

fn map_challenge_to_defer(
    challenge: &crate::types::AAuthChallenge,
    body: Option<&serde_json::Value>,
) -> Result<DeferRequirement> {
    match challenge.requirement {
        RequirementLevel::Clarification => {
            let question = body
                .and_then(|v| v.get("clarification"))
                .and_then(|v| v.as_str())
                .unwrap_or("Please clarify your request")
                .to_string();
            let timeout = body
                .and_then(|v| v.get("timeout"))
                .and_then(|v| v.as_u64());
            Ok(DeferRequirement::Clarification { question, timeout })
        }
        RequirementLevel::Claims => {
            let required_claims = body
                .and_then(|v| v.get("required_claims"))
                .and_then(|v| v.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(str::to_string))
                        .collect()
                })
                .unwrap_or_default();
            Ok(DeferRequirement::Claims { required_claims })
        }
        RequirementLevel::Interaction => {
            let url = challenge
                .url
                .clone()
                .ok_or_else(|| AAuthError::Message("interaction defer missing url".into()))?;
            let code = challenge
                .code
                .clone()
                .ok_or_else(|| AAuthError::Message("interaction defer missing code".into()))?;
            Ok(DeferRequirement::Interaction { url, code })
        }
        RequirementLevel::Approval => Ok(DeferRequirement::Approval),
        RequirementLevel::AgentToken | RequirementLevel::AuthToken => Err(AAuthError::Message(
            "agent-token/auth-token requirements are not defer requirements".into(),
        )),
    }
}

fn header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
    headers.get(name).and_then(|v| v.to_str().ok()).or_else(|| {
        headers.iter().find_map(|(k, v)| {
            if k.as_str().eq_ignore_ascii_case(name) {
                v.to_str().ok()
            } else {
                None
            }
        })
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::server::deferred::build_accepted;

    #[test]
    fn round_trip_clarification_defer() {
        let requirement = DeferRequirement::Clarification {
            question: "Why?".into(),
            timeout: Some(60),
        };
        let accepted = build_accepted("https://as.example/pending/abc", &requirement).unwrap();
        let body = accepted.body.to_string();
        let parsed = parse_deferred_response(
            202,
            &accepted.headers,
            body.as_bytes(),
            "https://as.example",
        )
        .unwrap();
        assert_eq!(parsed.location, "https://as.example/pending/abc");
        assert_eq!(parsed.requirement, requirement);
    }

    #[test]
    fn round_trip_interaction_defer() {
        let requirement = DeferRequirement::Interaction {
            url: "https://as.example/interact".into(),
            code: "AB-CD".into(),
        };
        let accepted = build_accepted("https://as.example/pending/x", &requirement).unwrap();
        let parsed = parse_deferred_response(
            202,
            &accepted.headers,
            accepted.body.to_string().as_bytes(),
            "https://as.example",
        )
        .unwrap();
        assert_eq!(parsed.requirement, requirement);
    }
}