AkurAI Build
Menu

Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb

AkurAI-CRM / crates/crm-mcp/src/oauth.rs

Latest change 46f8d3437352b9043c8406756a07d2c52c892d19 - Gate MCP merge tool on IdP role; send app.js 401s through login by Ólafur Búi Ólafsson

//! The MCP endpoint as an OAuth 2.0 protected resource of the AkurAI IdP.
//!
//! - RFC 9728 metadata at `/.well-known/oauth-protected-resource[/mcp]`.
//! - Bearer access tokens are EdDSA (Ed25519) JWTs verified locally against
//!   the IdP JWKS. `aud` (string or array) must contain the app audience
//!   (`akurai-crm`); agent tokens carry `["akurai-agent-crm","akurai-crm"]`.
//! - Only user tokens with a tenant are accepted; `client_credentials`
//!   tokens have no user and are refused.

use akurai_json::Value;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use crm_api::session::Role;
use ring::signature::{UnparsedPublicKey, ED25519};
use std::sync::Mutex;
use std::time::{Duration, Instant};

pub const DEFAULT_PUBLIC_URL: &str = "https://akurai-crm.olibuijr.com";
pub const DEFAULT_ISSUER: &str = "https://auth.olibuijr.com";
pub const AUDIENCE: &str = "akurai-crm";
const RESOURCE_NAME: &str = "AkurAI-CRM MCP";
const SCOPES: [&str; 5] = ["openid", "profile", "email", "groups", "offline_access"];
const JWKS_TTL: Duration = Duration::from_secs(600);
const JWKS_MIN_REFRESH: Duration = Duration::from_secs(30);

#[derive(Debug, Clone)]
pub struct ResourceConfig {
    pub public_url: String,
    pub issuer: String,
    pub audience: String,
}

impl ResourceConfig {
    pub fn from_env() -> Self {
        let env = |name: &str, default: &str| {
            std::env::var(name)
                .ok()
                .filter(|v| !v.trim().is_empty())
                .unwrap_or_else(|| default.to_string())
        };
        Self {
            public_url: env("AKURAI_PUBLIC_URL", DEFAULT_PUBLIC_URL),
            issuer: env("AKURAI_IDP_ISSUER", DEFAULT_ISSUER),
            audience: AUDIENCE.to_string(),
        }
    }

    fn base(&self) -> &str {
        self.public_url.trim_end_matches('/')
    }

    fn issuer(&self) -> &str {
        self.issuer.trim_end_matches('/')
    }

    pub fn resource_url(&self) -> String {
        format!("{}/mcp", self.base())
    }

    pub fn metadata_url(&self) -> String {
        format!("{}/.well-known/oauth-protected-resource/mcp", self.base())
    }

    pub fn metadata_json(&self) -> String {
        let strings = |items: &[&str]| {
            Value::Array(items.iter().map(|s| Value::Str(s.to_string())).collect())
        };
        Value::Object(vec![
            ("resource".into(), Value::Str(self.resource_url())),
            ("authorization_servers".into(), strings(&[self.issuer()])),
            ("scopes_supported".into(), strings(&SCOPES)),
            ("bearer_methods_supported".into(), strings(&["header"])),
            ("resource_name".into(), Value::Str(RESOURCE_NAME.into())),
        ])
        .to_json()
    }

    /// `WWW-Authenticate` value; `invalid` marks a presented-but-rejected token.
    pub fn challenge(&self, invalid: bool) -> String {
        let url = self.metadata_url();
        if invalid {
            format!("Bearer error=\"invalid_token\", resource_metadata=\"{url}\"")
        } else {
            format!("Bearer resource_metadata=\"{url}\"")
        }
    }

    /// Browsers must be same-origin; non-browser clients send no Origin.
    pub fn origin_allowed(&self, origin: Option<&str>) -> bool {
        origin.is_none_or(|o| o.trim_end_matches('/') == self.base())
    }
}

/// Ed25519 verification keys from a JWKS document, by `kid`.
#[derive(Debug, Clone, Default)]
pub struct Jwks {
    keys: Vec<(String, Vec<u8>)>,
}

impl Jwks {
    pub fn parse(json: &str) -> Result<Self, &'static str> {
        let doc = akurai_json::parse(json).map_err(|_| "invalid JWKS")?;
        let Some(Value::Array(entries)) = doc.get("keys") else {
            return Err("JWKS has no keys");
        };
        let keys = entries
            .iter()
            .filter(|k| {
                str_field(k, "kty") == Some("OKP") && str_field(k, "crv") == Some("Ed25519")
            })
            .filter_map(|k| {
                let kid = str_field(k, "kid")?.to_string();
                let x = URL_SAFE_NO_PAD.decode(str_field(k, "x")?).ok()?;
                Some((kid, x))
            })
            .collect();
        Ok(Self { keys })
    }

    fn find(&self, kid: &str) -> Option<&[u8]> {
        self.keys
            .iter()
            .find(|(k, _)| k == kid)
            .map(|(_, x)| x.as_slice())
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Grant {
    pub sub: String,
    pub tenant: String,
    pub scopes: Vec<String>,
    /// Organization role from the `role` claim; missing/unknown is member.
    pub role: Role,
}

fn str_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
    value.get(key).and_then(Value::as_str)
}

fn decode_segment(segment: &str) -> Result<Value, &'static str> {
    let bytes = URL_SAFE_NO_PAD
        .decode(segment)
        .map_err(|_| "JWT segment is not base64url")?;
    let text = std::str::from_utf8(&bytes).map_err(|_| "JWT segment is not UTF-8")?;
    akurai_json::parse(text).map_err(|_| "JWT segment is not JSON")
}

fn audience_contains(aud: Option<&Value>, audience: &str) -> bool {
    match aud {
        Some(Value::Str(s)) => s == audience,
        Some(Value::Array(items)) => items.iter().any(|v| v.as_str() == Some(audience)),
        _ => false,
    }
}

pub fn validate_access_token(
    token: &str,
    jwks: &Jwks,
    cfg: &ResourceConfig,
    now: i64,
) -> Result<Grant, &'static str> {
    let mut parts = token.split('.');
    let (Some(header), Some(payload), Some(signature), None) =
        (parts.next(), parts.next(), parts.next(), parts.next())
    else {
        return Err("access token is not a JWT");
    };
    let header = decode_segment(header)?;
    if str_field(&header, "alg") != Some("EdDSA") {
        return Err("unexpected JWT algorithm");
    }
    let kid = str_field(&header, "kid").ok_or("missing JWT key id")?;
    let key = jwks.find(kid).ok_or("unknown JWT key id")?;
    let signature = URL_SAFE_NO_PAD
        .decode(signature)
        .map_err(|_| "JWT signature is not base64url")?;
    let (signing_input, _) = token.rsplit_once('.').ok_or("access token is not a JWT")?;
    UnparsedPublicKey::new(&ED25519, key)
        .verify(signing_input.as_bytes(), &signature)
        .map_err(|_| "JWT signature validation failed")?;

    let claims = decode_segment(payload)?;
    if str_field(&claims, "iss").map(|i| i.trim_end_matches('/')) != Some(cfg.issuer()) {
        return Err("access token issuer mismatch");
    }
    match claims.get("exp").and_then(Value::as_i64) {
        Some(exp) if exp > now => {}
        _ => return Err("access token has expired"),
    }
    if claims
        .get("nbf")
        .and_then(Value::as_i64)
        .is_some_and(|nbf| nbf > now)
    {
        return Err("access token is not yet valid");
    }
    if !audience_contains(claims.get("aud"), &cfg.audience) {
        return Err("access token audience mismatch");
    }
    let sub = str_field(&claims, "sub").unwrap_or("");
    // client_credentials tokens name the client as subject; they have no user.
    if sub.is_empty()
        || claims.get("client_id").is_some()
        || str_field(&claims, "token_type") == Some("client_credentials")
        || audience_contains(claims.get("aud"), sub)
    {
        return Err("invalid user access token");
    }
    let tenant = str_field(&claims, "tenant_id")
        .or_else(|| str_field(&claims, "organization_id"))
        .filter(|t| !t.is_empty())
        .ok_or("access token has no tenant")?;
    let scopes = str_field(&claims, "scope")
        .unwrap_or("")
        .split_whitespace()
        .map(str::to_string)
        .collect();
    Ok(Grant {
        sub: sub.to_string(),
        tenant: tenant.to_string(),
        scopes,
        role: Role::parse(str_field(&claims, "role").unwrap_or("")),
    })
}

pub type JwksSource = Box<dyn Fn() -> Result<Jwks, &'static str> + Send + Sync>;

/// Looks up a user's IdP groups for an access token. IdP access tokens carry
/// no `groups` claim, so production asks the IdP's `/userinfo`.
pub type GroupSource = Box<dyn Fn(&str) -> Result<Vec<String>, &'static str> + Send + Sync>;

pub const GROUP_DENIED: &str = crm_api::auth::idp_client::GROUP_DENIED;

struct GroupPolicy {
    allowed: Vec<String>,
    source: GroupSource,
}

/// Validates access tokens with a cached JWKS, refetching on expiry or on an
/// unknown key id (key rotation), rate-limited.
pub struct TokenVerifier {
    cfg: ResourceConfig,
    source: JwksSource,
    cache: Mutex<Option<(Instant, Jwks)>>,
    groups: Option<GroupPolicy>,
}

impl TokenVerifier {
    pub fn new(cfg: ResourceConfig, source: JwksSource) -> Self {
        Self {
            cfg,
            source,
            cache: Mutex::new(None),
            groups: None,
        }
    }

    /// Require membership in one of `allowed` (no-op when empty).
    pub fn with_allowed_groups(mut self, allowed: Vec<String>, source: GroupSource) -> Self {
        self.groups = (!allowed.is_empty()).then_some(GroupPolicy { allowed, source });
        self
    }

    /// Verifier that fetches `<issuer>/jwks` over HTTPS and, when
    /// `AKURAI_IDP_ALLOWED_GROUPS` is set, checks groups via the IdP's
    /// internal `/userinfo`.
    pub fn from_env() -> Result<Self, String> {
        use crm_api::auth::idp_client;
        let cfg = ResourceConfig::from_env();
        let url = format!("{}/jwks", cfg.issuer());
        let verifier = Self::new(cfg, Box::new(move || fetch_jwks(&url)));
        let allowed = idp_client::allowed_groups_from_env();
        if allowed.is_empty() {
            return Ok(verifier);
        }
        let internal = idp_client::internal_url_from_env()?;
        Ok(verifier.with_allowed_groups(
            allowed,
            Box::new(move |token| {
                idp_client::fetch_userinfo(&internal, token)
                    .map(|info| info.groups)
                    .map_err(|_| "userinfo lookup failed")
            }),
        ))
    }

    pub fn config(&self) -> &ResourceConfig {
        &self.cfg
    }

    pub fn verify(&self, token: &str) -> Result<Grant, &'static str> {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as i64;
        let jwks = self.jwks(false)?;
        let grant = match validate_access_token(token, &jwks, &self.cfg, now) {
            Err("unknown JWT key id") => {
                validate_access_token(token, &self.jwks(true)?, &self.cfg, now)
            }
            result => result,
        }?;
        if let Some(policy) = &self.groups {
            let groups = (policy.source)(token)?;
            if !crm_api::auth::idp_client::groups_allowed(&policy.allowed, &groups) {
                return Err(GROUP_DENIED);
            }
        }
        Ok(grant)
    }

    fn jwks(&self, refresh: bool) -> Result<Jwks, &'static str> {
        let mut cache = self.cache.lock().map_err(|_| "JWKS cache poisoned")?;
        if let Some((at, jwks)) = cache.as_ref() {
            let fresh = at.elapsed() < JWKS_TTL;
            if (fresh && !refresh) || at.elapsed() < JWKS_MIN_REFRESH {
                return Ok(jwks.clone());
            }
        }
        let jwks = (self.source)()?;
        *cache = Some((Instant::now(), jwks.clone()));
        Ok(jwks)
    }
}

fn fetch_jwks(url: &str) -> Result<Jwks, &'static str> {
    let body = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .map_err(|_| "JWKS client configuration failed")?
        .get(url)
        .send()
        .and_then(|r| r.error_for_status())
        .and_then(|r| r.text())
        .map_err(|_| "JWKS request failed")?;
    Jwks::parse(&body)
}