AkurAI Build
Menu

Public repository / 54570403655120661aa5aeb6120646216896cc76

AkurAI-CRM / crates/crm-api/src/auth/idp_client.rs

Latest change c7bf22ef78ad08f1f59b04d08e88d86e4ca91815 - feat: add tenant-safe account graph by Ólafur Búi Ólafsson

#[derive(Clone)]
pub struct Config {
    client_id: String,
    client_secret: String,
    redirect_uri: String,
    internal_host: String,
    internal_port: u16,
}

impl Config {
    pub fn from_env() -> Result<Self, String> {
        Self::from_values(
            required_env("CRM_IDP_CLIENT_ID")?,
            required_env("CRM_IDP_CLIENT_SECRET")?,
            required_env("CRM_IDP_REDIRECT_URI")?,
            required_env("CRM_IDP_INTERNAL_HOST")?,
            required_env("CRM_IDP_INTERNAL_PORT")?,
        )
    }

    fn from_values(
        client_id: String,
        client_secret: String,
        redirect_uri: String,
        internal_host: String,
        internal_port: String,
    ) -> Result<Self, String> {
        validate_value("CRM_IDP_CLIENT_ID", &client_id)?;
        validate_value("CRM_IDP_CLIENT_SECRET", &client_secret)?;
        validate_value("CRM_IDP_REDIRECT_URI", &redirect_uri)?;
        if !(redirect_uri.starts_with("http://") || redirect_uri.starts_with("https://")) {
            return Err("CRM_IDP_REDIRECT_URI must use http:// or https://".to_string());
        }
        validate_value("CRM_IDP_INTERNAL_HOST", &internal_host)?;
        let internal_port = internal_port
            .parse::<u16>()
            .map_err(|_| "CRM_IDP_INTERNAL_PORT must be a valid TCP port".to_string())?;
        if internal_port == 0 {
            return Err("CRM_IDP_INTERNAL_PORT must be a valid TCP port".to_string());
        }
        Ok(Self {
            client_id,
            client_secret,
            redirect_uri,
            internal_host,
            internal_port,
        })
    }

    pub fn authorization_url(&self, state: &str) -> String {
        format!(
            "https://auth.olibuijr.com/authorize?client_id={}&redirect_uri={}&response_type=code&scope=openid+profile+email+groups&state={}",
            urlencode(&self.client_id),
            urlencode(&self.redirect_uri),
            urlencode(state),
        )
    }
}

fn required_env(name: &str) -> Result<String, String> {
    std::env::var(name).map_err(|_| format!("{name} is not configured"))
}

fn validate_value(name: &str, value: &str) -> Result<(), String> {
    if value.trim().is_empty() {
        return Err(format!("{name} is not configured"));
    }
    if value
        .chars()
        .any(|character| character.is_control() || character.is_whitespace())
    {
        return Err(format!("{name} contains invalid whitespace"));
    }
    Ok(())
}

#[cfg(test)]
fn test_config() -> Config {
    Config::from_values(
        "client-id".to_string(),
        "test-secret".to_string(),
        "https://crm.test/auth/callback".to_string(),
        "127.0.0.1".to_string(),
        "3500".to_string(),
    )
    .unwrap()
}

pub struct UserInfo {
    pub sub: String,
    pub email: String,
    pub tenant_id: String,
}

pub fn exchange_code(code: &str, _state: &str) -> Result<UserInfo, String> {
    let config = Config::from_env()?;
    let basic = base64_encode(&format!("{}:{}", config.client_id, config.client_secret));
    let token_body = format!(
        "grant_type=authorization_code&code={}&redirect_uri={}",
        urlencode(code),
        urlencode(&config.redirect_uri),
    );

    let response = http_post(
        &config.internal_host,
        config.internal_port,
        "/token",
        &[
            ("Authorization", &format!("Basic {}", basic)),
            ("Content-Type", "application/x-www-form-urlencoded"),
        ],
        &token_body,
    )?;

    let token_json = akurai_json::parse(&response.body).map_err(|e| format!("token parse: {e}"))?;
    let access_token = token_json
        .get("access_token")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "no access_token in token response".to_string())?
        .to_string();

    let userinfo_response = http_get(
        &config.internal_host,
        config.internal_port,
        "/userinfo",
        &[("Authorization", &format!("Bearer {}", access_token))],
    )?;
    let info =
        akurai_json::parse(&userinfo_response.body).map_err(|e| format!("userinfo parse: {e}"))?;

    let sub = info
        .get("sub")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "no sub in userinfo".to_string())?
        .to_string();
    let email = info
        .get("email")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let tenant_id = info
        .get("tenantId")
        .or_else(|| info.get("tenant_id"))
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .ok_or_else(|| "no tenantId in userinfo".to_string())?
        .to_string();

    Ok(UserInfo {
        sub,
        email,
        tenant_id,
    })
}

#[allow(dead_code)]
struct HttpResponse {
    status: u16,
    body: String,
}

fn http_get(
    host: &str,
    port: u16,
    path: &str,
    headers: &[(&str, &str)],
) -> Result<HttpResponse, String> {
    let mut req = format!("GET {} HTTP/1.1\r\nHost: {}:{}\r\n", path, host, port);
    for (k, v) in headers {
        req.push_str(&format!("{}: {}\r\n", k, v));
    }
    req.push_str("Connection: close\r\n\r\n");
    send_http(host, port, &req)
}

fn http_post(
    host: &str,
    port: u16,
    path: &str,
    headers: &[(&str, &str)],
    body: &str,
) -> Result<HttpResponse, String> {
    let mut req = format!(
        "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Length: {}\r\n",
        path,
        host,
        port,
        body.len()
    );
    for (k, v) in headers {
        req.push_str(&format!("{}: {}\r\n", k, v));
    }
    req.push_str("Connection: close\r\n\r\n");
    req.push_str(body);
    send_http(host, port, &req)
}

fn send_http(host: &str, port: u16, request: &str) -> Result<HttpResponse, String> {
    use std::io::{Read, Write};
    use std::net::TcpStream;
    use std::time::Duration;

    let addr = format!("{}:{}", host, port);
    let stream = TcpStream::connect_timeout(
        &addr.parse().map_err(|e| format!("addr parse: {e}"))?,
        Duration::from_secs(5),
    )
    .map_err(|e| format!("connect: {e}"))?;

    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .map_err(|e| format!("set timeout: {e}"))?;

    let mut wr = &stream;
    wr.write_all(request.as_bytes())
        .map_err(|e| format!("write: {e}"))?;

    let mut buf = Vec::new();
    let mut rd = &stream;
    rd.read_to_end(&mut buf).map_err(|e| format!("read: {e}"))?;

    let response = String::from_utf8_lossy(&buf).to_string();

    let status = response
        .lines()
        .next()
        .and_then(|line| line.split(' ').nth(1))
        .and_then(|s| s.parse::<u16>().ok())
        .unwrap_or(0);

    let body = if let Some(pos) = response.find("\r\n\r\n") {
        response[pos + 4..].to_string()
    } else {
        String::new()
    };

    if !(200..300).contains(&status) {
        return Err(format!(
            "HTTP {}: {}",
            status,
            body.chars().take(200).collect::<String>()
        ));
    }

    Ok(HttpResponse { status, body })
}

fn base64_encode(input: &str) -> String {
    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let bytes = input.as_bytes();
    let mut result = String::new();

    for chunk in bytes.chunks(3) {
        let b0 = chunk[0] as u32;
        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
        let triple = (b0 << 16) | (b1 << 8) | b2;

        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);

        if chunk.len() > 1 {
            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
        } else {
            result.push('=');
        }

        if chunk.len() > 2 {
            result.push(CHARS[(triple & 0x3F) as usize] as char);
        } else {
            result.push('=');
        }
    }

    result
}

fn urlencode(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    for &b in s.as_bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                result.push(b as char);
            }
            b' ' => result.push('+'),
            _ => result.push_str(&format!("%{:02X}", b)),
        }
    }
    result
}

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

    #[test]
    fn config_rejects_missing_secret() {
        let error = match Config::from_values(
            "client-id".into(),
            "".into(),
            "https://crm.test/callback".into(),
            "127.0.0.1".into(),
            "3500".into(),
        ) {
            Ok(_) => panic!("missing secret must fail"),
            Err(error) => error,
        };
        assert!(error.contains("CRM_IDP_CLIENT_SECRET"));
    }

    #[test]
    fn config_encodes_authorization_parameters() {
        let config = test_config();
        let url = config.authorization_url("state value");
        assert!(url.contains("client-id"));
        assert!(url.contains("state+value"));
        assert!(!url.contains("test-secret"));
    }
}