AkurAI Build
Menu

Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb

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

Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson

//! Back-channel client for the AkurAI IdP (token, introspect, userinfo).
//!
//! Configuration reads `AKURAI_IDP_*`; the older `CRM_IDP_*` names are
//! fallbacks so existing environments keep working.

use akurai_json::Value;
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;

pub const DEFAULT_ISSUER: &str = "https://auth.olibuijr.com";
pub const DEFAULT_INTERNAL_URL: &str = "http://127.0.0.1:3500";
pub const DEFAULT_PUBLIC_URL: &str = "https://akurai-crm.olibuijr.com";
pub const SCOPE: &str = "openid profile email groups";

#[derive(Clone, Debug)]
pub struct Config {
    pub client_id: String,
    client_secret: String,
    pub issuer: String,
    pub redirect_uri: String,
    pub internal: InternalUrl,
    pub allowed_groups: Vec<String>,
    pub public_url: String,
}

/// Plain-HTTP base URL of the IdP on the private network.
#[derive(Clone, Debug, PartialEq)]
pub struct InternalUrl {
    pub host: String,
    pub port: u16,
    pub base_path: String,
}

impl InternalUrl {
    pub fn parse(url: &str) -> Result<Self, String> {
        let rest = url
            .strip_prefix("http://")
            .ok_or_else(|| "AKURAI_IDP_INTERNAL_URL must use http://".to_string())?;
        let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
        let (host, port) = match authority.rsplit_once(':') {
            Some((host, port)) => (
                host,
                port.parse::<u16>()
                    .ok()
                    .filter(|p| *p != 0)
                    .ok_or_else(|| "AKURAI_IDP_INTERNAL_URL has an invalid port".to_string())?,
            ),
            None => (authority, 80),
        };
        if host.is_empty() {
            return Err("AKURAI_IDP_INTERNAL_URL has no host".into());
        }
        let path = path.trim_end_matches('/');
        Ok(Self {
            host: host.to_string(),
            port,
            base_path: if path.is_empty() {
                String::new()
            } else {
                format!("/{path}")
            },
        })
    }
}

/// Raw values as read from the environment.
#[derive(Default)]
pub struct Settings {
    pub client_id: Option<String>,
    pub client_secret: Option<String>,
    pub issuer: Option<String>,
    pub redirect_uri: Option<String>,
    pub internal_url: Option<String>,
    pub allowed_groups: Option<String>,
    pub public_url: Option<String>,
}

impl Settings {
    pub fn from_env() -> Self {
        let read = |names: &[&str]| {
            names
                .iter()
                .find_map(|name| std::env::var(name).ok().filter(|v| !v.trim().is_empty()))
        };
        let legacy_internal = match (
            read(&["CRM_IDP_INTERNAL_HOST"]),
            read(&["CRM_IDP_INTERNAL_PORT"]),
        ) {
            (Some(host), Some(port)) => Some(format!("http://{host}:{port}")),
            _ => None,
        };
        Self {
            client_id: read(&["AKURAI_IDP_CLIENT_ID", "CRM_IDP_CLIENT_ID"]),
            client_secret: read(&["AKURAI_IDP_CLIENT_SECRET", "CRM_IDP_CLIENT_SECRET"]),
            issuer: read(&["AKURAI_IDP_ISSUER", "CRM_IDP_ISSUER"]),
            redirect_uri: read(&["AKURAI_IDP_REDIRECT_URI", "CRM_IDP_REDIRECT_URI"]),
            internal_url: read(&["AKURAI_IDP_INTERNAL_URL"]).or(legacy_internal),
            allowed_groups: read(&["AKURAI_IDP_ALLOWED_GROUPS", "CRM_IDP_ALLOWED_GROUPS"]),
            public_url: read(&["AKURAI_PUBLIC_URL"]),
        }
    }
}

impl Config {
    pub fn from_env() -> Result<Self, String> {
        Self::from_settings(Settings::from_env())
    }

    pub fn from_settings(settings: Settings) -> Result<Self, String> {
        let required = |value: Option<String>, name: &str| -> Result<String, String> {
            let value = value.ok_or_else(|| format!("{name} is not configured"))?;
            validate_value(name, &value)?;
            Ok(value)
        };
        let client_id = required(settings.client_id, "AKURAI_IDP_CLIENT_ID")?;
        let client_secret = required(settings.client_secret, "AKURAI_IDP_CLIENT_SECRET")?;
        let redirect_uri = required(settings.redirect_uri, "AKURAI_IDP_REDIRECT_URI")?;
        require_http_url("AKURAI_IDP_REDIRECT_URI", &redirect_uri)?;
        let issuer = settings
            .issuer
            .unwrap_or_else(|| DEFAULT_ISSUER.to_string())
            .trim_end_matches('/')
            .to_string();
        validate_value("AKURAI_IDP_ISSUER", &issuer)?;
        require_http_url("AKURAI_IDP_ISSUER", &issuer)?;
        let internal = InternalUrl::parse(
            settings
                .internal_url
                .as_deref()
                .unwrap_or(DEFAULT_INTERNAL_URL),
        )?;
        let public_url = settings
            .public_url
            .unwrap_or_else(|| DEFAULT_PUBLIC_URL.to_string())
            .trim_end_matches('/')
            .to_string();
        Ok(Self {
            client_id,
            client_secret,
            issuer,
            redirect_uri,
            internal,
            allowed_groups: parse_groups(settings.allowed_groups.as_deref()),
            public_url,
        })
    }

    pub fn authorization_url(&self, state: &str, nonce: &str, code_challenge: &str) -> String {
        format!(
            "{}/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}&code_challenge={}&code_challenge_method=S256",
            self.issuer,
            urlencode(&self.client_id),
            urlencode(&self.redirect_uri),
            urlencode(SCOPE),
            urlencode(state),
            urlencode(nonce),
            urlencode(code_challenge),
        )
    }

    /// The IdP's session logout endpoint (takes no parameters).
    pub fn logout_url(&self) -> String {
        format!("{}/logout", self.issuer)
    }

    fn basic_auth(&self) -> String {
        format!(
            "Basic {}",
            STANDARD.encode(format!("{}:{}", self.client_id, self.client_secret))
        )
    }

    fn path(&self, endpoint: &str) -> String {
        format!("{}{endpoint}", self.internal.base_path)
    }

    /// Exchange an authorization code (with its PKCE verifier) for tokens.
    pub fn exchange_code(&self, code: &str, code_verifier: &str) -> Result<Tokens, String> {
        let body = format!(
            "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}",
            urlencode(code),
            urlencode(&self.redirect_uri),
            urlencode(code_verifier),
        );
        let response = self
            .post_form("/token", &body)
            .map_err(|e| format!("code exchange failed: {e}"))?;
        let json = akurai_json::parse(&response).map_err(|e| format!("token parse: {e}"))?;
        let field = |name: &str| json.get(name).and_then(Value::as_str).map(str::to_string);
        Ok(Tokens {
            access_token: field("access_token")
                .ok_or_else(|| "token response has no access_token".to_string())?,
            id_token: field("id_token")
                .ok_or_else(|| "token response has no id_token".to_string())?,
        })
    }

    /// RFC 7662 introspection; returns the parsed response.
    pub fn introspect(&self, access_token: &str) -> Result<Value, String> {
        let body = format!(
            "token={}&token_type_hint=access_token",
            urlencode(access_token)
        );
        let response = self
            .post_form("/introspect", &body)
            .map_err(|e| format!("introspection failed: {e}"))?;
        akurai_json::parse(&response).map_err(|e| format!("introspect parse: {e}"))
    }

    pub fn userinfo(&self, access_token: &str) -> Result<UserInfo, String> {
        fetch_userinfo(&self.internal, access_token)
    }

    fn post_form(&self, endpoint: &str, body: &str) -> Result<String, String> {
        send_http(
            &self.internal,
            &format!(
                "POST {} HTTP/1.1\r\nHost: {}\r\nAuthorization: {}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                self.path(endpoint),
                self.internal.host,
                self.basic_auth(),
                body.len(),
            ),
        )
    }
}

pub struct Tokens {
    pub access_token: String,
    pub id_token: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct UserInfo {
    pub sub: String,
    pub email: String,
    pub tenant_id: String,
    pub groups: Vec<String>,
}

impl UserInfo {
    pub fn parse(body: &str) -> Result<Self, String> {
        let info = akurai_json::parse(body).map_err(|e| format!("userinfo parse: {e}"))?;
        let text = |name: &str| {
            info.get(name)
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|v| !v.is_empty())
                .map(str::to_string)
        };
        let groups = match info.get("groups") {
            Some(Value::Array(items)) => items
                .iter()
                .filter_map(Value::as_str)
                .map(str::to_string)
                .collect(),
            _ => Vec::new(),
        };
        Ok(Self {
            sub: text("sub").ok_or_else(|| "userinfo has no sub".to_string())?,
            email: text("email").unwrap_or_default(),
            tenant_id: text("organization_id")
                .or_else(|| text("tenant_id"))
                .or_else(|| text("tenantId"))
                .ok_or_else(|| "userinfo has no organization_id or tenant_id".to_string())?,
            groups,
        })
    }
}

/// `GET /userinfo` with a bearer access token (returns `groups`).
pub fn fetch_userinfo(internal: &InternalUrl, access_token: &str) -> Result<UserInfo, String> {
    let response = send_http(
        internal,
        &format!(
            "GET {}/userinfo HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {access_token}\r\nConnection: close\r\n\r\n",
            internal.base_path, internal.host,
        ),
    )
    .map_err(|e| format!("userinfo failed: {e}"))?;
    UserInfo::parse(&response)
}

/// Internal IdP URL from `AKURAI_IDP_INTERNAL_URL` (legacy host/port
/// fallback, then the default).
pub fn internal_url_from_env() -> Result<InternalUrl, String> {
    InternalUrl::parse(
        Settings::from_env()
            .internal_url
            .as_deref()
            .unwrap_or(DEFAULT_INTERNAL_URL),
    )
}

/// `AKURAI_PUBLIC_URL` without a trailing slash (defaults to production).
pub fn public_url_from_env() -> String {
    Settings::from_env()
        .public_url
        .unwrap_or_else(|| DEFAULT_PUBLIC_URL.to_string())
        .trim_end_matches('/')
        .to_string()
}

/// `AKURAI_IDP_ALLOWED_GROUPS` (legacy `CRM_IDP_ALLOWED_GROUPS`).
pub fn allowed_groups_from_env() -> Vec<String> {
    parse_groups(Settings::from_env().allowed_groups.as_deref())
}

/// Comma- or whitespace-separated group names.
pub fn parse_groups(value: Option<&str>) -> Vec<String> {
    value
        .unwrap_or("")
        .split(|c: char| c == ',' || c.is_whitespace())
        .filter(|g| !g.is_empty())
        .map(str::to_string)
        .collect()
}

/// An empty allow-list admits everyone; otherwise one group must match.
pub fn groups_allowed(allowed: &[String], groups: &[String]) -> bool {
    allowed.is_empty() || groups.iter().any(|g| allowed.contains(g))
}

pub const GROUP_DENIED: &str = "your account is not a member of a group allowed in AkurAI-CRM";

fn require_http_url(name: &str, value: &str) -> Result<(), String> {
    if value.starts_with("http://") || value.starts_with("https://") {
        Ok(())
    } else {
        Err(format!("{name} must use http:// or https://"))
    }
}

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(|c| c.is_control() || c.is_whitespace()) {
        return Err(format!("{name} contains invalid whitespace"));
    }
    Ok(())
}

fn send_http(target: &InternalUrl, request: &str) -> Result<String, String> {
    use std::io::{Read, Write};
    use std::net::{TcpStream, ToSocketAddrs};
    use std::time::Duration;

    let addr = (target.host.as_str(), target.port)
        .to_socket_addrs()
        .map_err(|e| format!("resolve {}: {e}", target.host))?
        .next()
        .ok_or_else(|| format!("resolve {}: no address", target.host))?;
    let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))
        .map_err(|e| format!("connect {addr}: {e}"))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .map_err(|e| format!("set timeout: {e}"))?;
    stream
        .write_all(request.as_bytes())
        .map_err(|e| format!("write: {e}"))?;
    let mut buf = Vec::new();
    stream
        .read_to_end(&mut buf)
        .map_err(|e| format!("read: {e}"))?;
    let response = String::from_utf8_lossy(&buf);
    let status = response
        .lines()
        .next()
        .and_then(|line| line.split(' ').nth(1))
        .and_then(|s| s.parse::<u16>().ok())
        .unwrap_or(0);
    let body = response
        .split_once("\r\n\r\n")
        .map(|(_, body)| body.to_string())
        .unwrap_or_default();
    if !(200..300).contains(&status) {
        return Err(format!(
            "IdP returned HTTP {status}: {}",
            body.chars().take(200).collect::<String>()
        ));
    }
    Ok(body)
}

pub 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)
            }
            _ => result.push_str(&format!("%{b:02X}")),
        }
    }
    result
}

pub fn urldecode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        match bytes[i] {
            b'+' => out.push(b' '),
            b'%' if i + 2 < bytes.len() => {
                let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
                match u8::from_str_radix(hex, 16) {
                    Ok(byte) => {
                        out.push(byte);
                        i += 2;
                    }
                    Err(_) => out.push(b'%'),
                }
            }
            byte => out.push(byte),
        }
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

#[cfg(test)]
pub fn test_settings(internal_url: &str) -> Settings {
    Settings {
        client_id: Some("akurai-crm".into()),
        client_secret: Some("test-secret".into()),
        issuer: Some("https://auth.test".into()),
        redirect_uri: Some("https://crm.test/auth/callback".into()),
        internal_url: Some(internal_url.into()),
        allowed_groups: None,
        public_url: Some("https://crm.test".into()),
    }
}

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

    #[test]
    fn config_rejects_missing_secret() {
        let settings = Settings {
            client_secret: None,
            ..test_settings("http://127.0.0.1:3500")
        };
        let error = Config::from_settings(settings).unwrap_err();
        assert_eq!(error, "AKURAI_IDP_CLIENT_SECRET is not configured");
    }

    #[test]
    fn config_defaults_issuer_and_internal_url() {
        let config = Config::from_settings(Settings {
            issuer: None,
            internal_url: None,
            ..test_settings("")
        })
        .unwrap();
        assert_eq!(config.issuer, DEFAULT_ISSUER);
        assert_eq!(config.internal.host, "127.0.0.1");
        assert_eq!(config.internal.port, 3500);
    }

    #[test]
    fn internal_url_parses_host_port_and_path() {
        let url = InternalUrl::parse("http://idp.lan:8080/base/").unwrap();
        assert_eq!(
            url,
            InternalUrl {
                host: "idp.lan".into(),
                port: 8080,
                base_path: "/base".into()
            }
        );
        assert!(InternalUrl::parse("https://idp.lan").is_err());
    }

    #[test]
    fn authorization_url_carries_pkce_nonce_and_state() {
        let config = Config::from_settings(test_settings("http://127.0.0.1:1")).unwrap();
        let url = config.authorization_url("st", "no", "ch");
        assert!(
            url.starts_with("https://auth.test/authorize?response_type=code&client_id=akurai-crm&")
        );
        assert!(url.contains("&scope=openid%20profile%20email%20groups&"));
        assert!(url.contains("&state=st&nonce=no&code_challenge=ch&code_challenge_method=S256"));
        assert!(!url.contains("test-secret"));
    }

    #[test]
    fn userinfo_prefers_organization_and_reads_groups() {
        let info = UserInfo::parse(
            r#"{"sub":"u1","email":"a@b","tenant_id":"t-old","organization_id":"org-1","groups":["crm","x"]}"#,
        )
        .unwrap();
        assert_eq!(info.tenant_id, "org-1");
        assert_eq!(info.groups, vec!["crm".to_string(), "x".to_string()]);
        assert_eq!(
            UserInfo::parse(r#"{"sub":"u1"}"#).unwrap_err(),
            "userinfo has no organization_id or tenant_id"
        );
    }

    #[test]
    fn group_policy() {
        let allowed = parse_groups(Some("crm-users, admins"));
        assert_eq!(allowed, vec!["crm-users".to_string(), "admins".to_string()]);
        assert!(groups_allowed(&allowed, &["admins".into()]));
        assert!(!groups_allowed(&allowed, &["other".into()]));
        assert!(groups_allowed(&[], &[]));
    }

    #[test]
    fn urldecode_roundtrips() {
        assert_eq!(urldecode(&urlencode("/a b?c=d&e")), "/a b?c=d&e");
        assert_eq!(urldecode("100%"), "100%");
    }
}