AkurAI Build
Menu

Public repository / master

AkurAI-Drive / backend/src/oauth.rs

Latest change f891f45338fe5e5d14370df77cc8001dceb06426 - Drive MCP: recover /mcp tools and make it an IdP OAuth protected resource by AkurAI Agent

//! IdP access-token validation for the MCP endpoint.
//!
//! The AkurAI IdP issues EdDSA (Ed25519) JWT access tokens to the public PKCE
//! client `akurai-agent-akurai-drive`, with `aud` = [client id, "akurai-drive"].
//! They are verified locally against the IdP JWKS (fetched over the same-box
//! back channel and cached); no token is ever stored.

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde_json::Value;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};

use crate::config;

const JWKS_TTL: Duration = Duration::from_secs(600);

/// The user a valid IdP access token was issued to.
#[derive(Debug, Clone, PartialEq)]
pub struct TokenGrant {
    pub sub: String,
    pub tenant_id: Option<String>,
    pub scopes: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TokenError {
    Malformed,
    UnknownKey,
    BadSignature,
    WrongIssuer,
    Expired,
    WrongAudience,
    NotAUserToken,
}

/// Does the `aud` claim (string or array) contain `audience`?
pub fn audience_contains(aud: Option<&Value>, audience: &str) -> bool {
    match aud {
        Some(Value::String(s)) => s == audience,
        Some(Value::Array(items)) => items.iter().any(|v| v.as_str() == Some(audience)),
        _ => false,
    }
}

fn decode_json(segment: &str) -> Result<Value, TokenError> {
    let bytes = URL_SAFE_NO_PAD
        .decode(segment)
        .map_err(|_| TokenError::Malformed)?;
    serde_json::from_slice(&bytes).map_err(|_| TokenError::Malformed)
}

/// The Ed25519 public key for `kid` in a JWKS document, if present and usable.
fn find_key(jwks: &Value, kid: &str) -> Option<VerifyingKey> {
    let jwk = jwks
        .get("keys")?
        .as_array()?
        .iter()
        .find(|k| k.get("kid").and_then(Value::as_str) == Some(kid))?;
    if jwk.get("kty").and_then(Value::as_str) != Some("OKP")
        || jwk.get("crv").and_then(Value::as_str) != Some("Ed25519")
    {
        return None;
    }
    let x = URL_SAFE_NO_PAD.decode(jwk.get("x")?.as_str()?).ok()?;
    VerifyingKey::from_bytes(&x.try_into().ok()?).ok()
}

/// Verify a compact JWS access token and its claims. `now` is Unix seconds.
pub fn validate_access_token(
    token: &str,
    jwks: &Value,
    issuer: &str,
    accepted_audiences: &[&str],
    now: i64,
) -> Result<TokenGrant, TokenError> {
    let mut parts = token.split('.');
    let (Some(h), Some(p), Some(s), None) =
        (parts.next(), parts.next(), parts.next(), parts.next())
    else {
        return Err(TokenError::Malformed);
    };
    let header = decode_json(h)?;
    if header.get("alg").and_then(Value::as_str) != Some("EdDSA") {
        return Err(TokenError::Malformed);
    }
    let kid = header
        .get("kid")
        .and_then(Value::as_str)
        .ok_or(TokenError::Malformed)?;
    let key = find_key(jwks, kid).ok_or(TokenError::UnknownKey)?;
    let sig_bytes = URL_SAFE_NO_PAD
        .decode(s)
        .map_err(|_| TokenError::Malformed)?;
    let signature = Signature::from_slice(&sig_bytes).map_err(|_| TokenError::Malformed)?;
    key.verify(format!("{h}.{p}").as_bytes(), &signature)
        .map_err(|_| TokenError::BadSignature)?;

    let claims = decode_json(p)?;
    if claims.get("iss").and_then(Value::as_str) != Some(issuer.trim_end_matches('/')) {
        return Err(TokenError::WrongIssuer);
    }
    let exp = claims.get("exp").and_then(Value::as_i64).unwrap_or(0);
    let nbf = claims
        .get("nbf")
        .and_then(Value::as_i64)
        .unwrap_or(i64::MIN);
    if exp <= now || nbf > now {
        return Err(TokenError::Expired);
    }
    if !accepted_audiences
        .iter()
        .any(|a| audience_contains(claims.get("aud"), a))
    {
        return Err(TokenError::WrongAudience);
    }
    // client_credentials tokens carry `client_id` and have no Drive owner.
    let sub = claims.get("sub").and_then(Value::as_str).unwrap_or("");
    if sub.is_empty() || claims.get("client_id").is_some() || accepted_audiences.contains(&sub) {
        return Err(TokenError::NotAUserToken);
    }
    let tenant_id = ["tenant_id", "organization_id"]
        .iter()
        .find_map(|c| claims.get(*c).and_then(Value::as_str))
        .filter(|t| !t.is_empty())
        .map(str::to_string);
    let scopes = claims
        .get("scope")
        .and_then(Value::as_str)
        .unwrap_or("")
        .split_whitespace()
        .map(str::to_string)
        .collect();
    Ok(TokenGrant {
        sub: sub.to_string(),
        tenant_id,
        scopes,
    })
}

static JWKS_CACHE: LazyLock<Mutex<Option<(Instant, Value)>>> = LazyLock::new(|| Mutex::new(None));

/// IdP JWKS over the plain-HTTP same-box back channel, cached for `JWKS_TTL`.
fn fetch_jwks(force: bool) -> Result<Value, String> {
    if !force
        && let Some((at, jwks)) = JWKS_CACHE.lock().unwrap().as_ref()
        && at.elapsed() < JWKS_TTL
    {
        return Ok(jwks.clone());
    }
    let url = format!("{}/jwks", config::get().idp_internal_url);
    let body = ureq::get(&url)
        .timeout(Duration::from_secs(10))
        .call()
        .map_err(|e| format!("JWKS request failed: {e}"))?
        .into_string()
        .map_err(|e| format!("JWKS read failed: {e}"))?;
    let jwks: Value =
        serde_json::from_str(&body).map_err(|e| format!("invalid JWKS response: {e}"))?;
    *JWKS_CACHE.lock().unwrap() = Some((Instant::now(), jwks.clone()));
    Ok(jwks)
}

/// Validate an IdP access token for this app (blocking: may fetch the JWKS).
/// Refetches the JWKS once on an unknown `kid` to follow key rotation.
pub fn authenticate(token: &str) -> Result<TokenGrant, TokenError> {
    let cfg = config::get();
    let resource = crate::mcp::resource_url(&cfg.drive_public_url);
    let accepted = [cfg.mcp_audience.as_str(), resource.as_str()];
    let now = chrono::Utc::now().timestamp();
    let check = |jwks: &Value| validate_access_token(token, jwks, &cfg.idp_issuer, &accepted, now);
    let jwks = fetch_jwks(false).map_err(|e| {
        tracing::warn!("{e}");
        TokenError::UnknownKey
    })?;
    match check(&jwks) {
        Err(TokenError::UnknownKey) => {
            let jwks = fetch_jwks(true).map_err(|e| {
                tracing::warn!("{e}");
                TokenError::UnknownKey
            })?;
            check(&jwks)
        }
        other => other,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::{Signer, SigningKey};
    use serde_json::json;

    const ISS: &str = "https://auth.olibuijr.com";
    const NOW: i64 = 1_800_000_000;
    const AUD: [&str; 1] = ["akurai-drive"];

    fn key() -> SigningKey {
        SigningKey::from_bytes(&[7u8; 32])
    }

    fn jwks() -> Value {
        json!({"keys": [{
            "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "kid": "k1",
            "x": URL_SAFE_NO_PAD.encode(key().verifying_key().to_bytes()),
        }]})
    }

    fn sign(header: Value, claims: Value, signer: &SigningKey) -> String {
        let h = URL_SAFE_NO_PAD.encode(header.to_string());
        let p = URL_SAFE_NO_PAD.encode(claims.to_string());
        let sig = signer.sign(format!("{h}.{p}").as_bytes());
        format!("{h}.{p}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes()))
    }

    fn claims() -> Value {
        json!({
            "iss": ISS, "sub": "user-1", "exp": NOW + 60, "iat": NOW,
            "aud": ["akurai-agent-akurai-drive", "akurai-drive"],
            "tenant_id": "t1", "scope": "openid profile email",
        })
    }

    fn token(claims: Value) -> String {
        sign(
            json!({"alg": "EdDSA", "kid": "k1", "typ": "JWT"}),
            claims,
            &key(),
        )
    }

    fn check(token: &str) -> Result<TokenGrant, TokenError> {
        validate_access_token(token, &jwks(), ISS, &AUD, NOW)
    }

    #[test]
    fn accepts_idp_user_token_with_array_audience() {
        let grant = check(&token(claims())).unwrap();
        assert_eq!(grant.sub, "user-1");
        assert_eq!(grant.tenant_id.as_deref(), Some("t1"));
        assert_eq!(grant.scopes, ["openid", "profile", "email"]);
    }

    #[test]
    fn accepts_string_audience() {
        let mut c = claims();
        c["aud"] = json!("akurai-drive");
        assert!(check(&token(c)).is_ok());
    }

    #[test]
    fn rejects_other_audience() {
        let mut c = claims();
        c["aud"] = json!(["akurai-agent-akurai-mail", "akurai-mail"]);
        assert_eq!(check(&token(c)), Err(TokenError::WrongAudience));
    }

    #[test]
    fn rejects_expired_and_not_yet_valid() {
        let mut c = claims();
        c["exp"] = json!(NOW);
        assert_eq!(check(&token(c)), Err(TokenError::Expired));
        let mut c = claims();
        c["nbf"] = json!(NOW + 5);
        assert_eq!(check(&token(c)), Err(TokenError::Expired));
    }

    #[test]
    fn rejects_wrong_issuer() {
        let mut c = claims();
        c["iss"] = json!("https://evil.example");
        assert_eq!(check(&token(c)), Err(TokenError::WrongIssuer));
    }

    #[test]
    fn rejects_forged_signature_and_unknown_kid() {
        let forged = sign(
            json!({"alg": "EdDSA", "kid": "k1"}),
            claims(),
            &SigningKey::from_bytes(&[9u8; 32]),
        );
        assert_eq!(check(&forged), Err(TokenError::BadSignature));
        let other_kid = sign(json!({"alg": "EdDSA", "kid": "k2"}), claims(), &key());
        assert_eq!(check(&other_kid), Err(TokenError::UnknownKey));
    }

    #[test]
    fn rejects_non_eddsa_and_garbage() {
        let none = sign(json!({"alg": "none", "kid": "k1"}), claims(), &key());
        assert_eq!(check(&none), Err(TokenError::Malformed));
        assert_eq!(check("not-a-jwt"), Err(TokenError::Malformed));
        assert_eq!(check("a.b.c.d"), Err(TokenError::Malformed));
    }

    #[test]
    fn rejects_client_credentials_tokens() {
        let mut c = claims();
        c["client_id"] = json!("some-service");
        assert_eq!(check(&token(c)), Err(TokenError::NotAUserToken));
        let mut c = claims();
        c["sub"] = json!("akurai-drive");
        assert_eq!(check(&token(c)), Err(TokenError::NotAUserToken));
    }

    #[test]
    fn audience_contains_handles_shapes() {
        assert!(audience_contains(Some(&json!("a")), "a"));
        assert!(audience_contains(Some(&json!(["x", "a"])), "a"));
        assert!(!audience_contains(Some(&json!(["x"])), "a"));
        assert!(!audience_contains(Some(&json!(1)), "a"));
        assert!(!audience_contains(None, "a"));
    }
}