AkurAI Build
Menu

Public repository / fe9611075d08d4b881b528d06a3e3ad5045ec657

AkurAI-PassVault / crates/passvault-mcp/src/oauth.rs

Latest change fe9611075d08d4b881b528d06a3e3ad5045ec657 - oauth: fall back to AKURAI_PUBLIC_URL like the deployed build by Ólafur Búi Ólafsson

//! OAuth 2.0 protected-resource support for the MCP endpoint.
//!
//! PassVault's `/mcp` accepts EdDSA access tokens from the AkurAI IdP when
//! their `aud` names PassVault (RFC 9728 metadata + RFC 6750 challenges).
//! Signatures are verified locally against the IdP JWKS; the vault owner
//! allowlist is then enforced on the userinfo email for the same token.

use crate::auth::{request_idp, IdpConfig};
use akurai_json::Value;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use parking_lot::Mutex;
use std::env;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// Audience the IdP puts on tokens minted for PassVault.
pub const AUDIENCE: &str = "akurai-passvault";
/// Public PKCE client the IdP registers for agents talking to PassVault.
pub const AGENT_CLIENT: &str = "akurai-agent-passvault";
const RESOURCE_NAME: &str = "AkurAI PassVault MCP";
const SCOPES: [&str; 5] = ["openid", "profile", "email", "groups", "offline_access"];
/// Scope needed to learn the caller's email for the owner allowlist.
const REQUIRED_SCOPE: &str = "email";
const JWKS_TTL: Duration = Duration::from_secs(600);

// ---------------------------------------------------------------------------
// Protected resource metadata and challenges
// ---------------------------------------------------------------------------

fn base(base_url: &str) -> &str {
    base_url.trim_end_matches('/')
}

pub fn resource_url(base_url: &str) -> String {
    format!("{}/mcp", base(base_url))
}

pub fn resource_metadata_url(base_url: &str) -> String {
    format!(
        "{}/.well-known/oauth-protected-resource/mcp",
        base(base_url)
    )
}

fn strings(items: &[&str]) -> Value {
    Value::Array(items.iter().map(|s| Value::Str((*s).into())).collect())
}

pub fn protected_resource_metadata(base_url: &str, issuer: &str) -> String {
    Value::Object(vec![
        ("resource".into(), Value::Str(resource_url(base_url))),
        (
            "authorization_servers".into(),
            strings(&[issuer.trim_end_matches('/')]),
        ),
        ("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 www_authenticate(base_url: &str, invalid: bool) -> String {
    let url = resource_metadata_url(base_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(origin: Option<&str>, base_url: &str) -> bool {
    origin.is_none_or(|o| o == base(base_url))
}

// ---------------------------------------------------------------------------
// Access-token validation
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq)]
pub struct TokenGrant {
    pub sub: String,
    pub scopes: Vec<String>,
}

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

fn decode_json(segment: &str) -> Option<Value> {
    let bytes = URL_SAFE_NO_PAD.decode(segment).ok()?;
    akurai_json::parse(std::str::from_utf8(&bytes).ok()?).ok()
}

fn find_key(jwks: &Value, kid: &str) -> Option<VerifyingKey> {
    let Some(Value::Array(keys)) = jwks.get("keys") else {
        return None;
    };
    let jwk = keys
        .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").and_then(Value::as_str)?)
        .ok()?;
    VerifyingKey::from_bytes(&x.try_into().ok()?).ok()
}

pub fn key_id(token: &str) -> Option<String> {
    let header = decode_json(token.split('.').next()?)?;
    header.get("kid").and_then(Value::as_str).map(String::from)
}

pub fn validate_access_token(
    token: &str,
    jwks: &Value,
    issuer: &str,
    accepted_audiences: &[&str],
    now: u64,
) -> Result<TokenGrant, &'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_json(header).ok_or("invalid JWT header")?;
    if header.get("alg").and_then(Value::as_str) != Some("EdDSA") {
        return Err("unexpected JWT algorithm");
    }
    let kid = header
        .get("kid")
        .and_then(Value::as_str)
        .ok_or("missing JWT key id")?;
    let key = find_key(jwks, kid).ok_or("unknown JWT key id")?;
    let signature = URL_SAFE_NO_PAD
        .decode(signature)
        .ok()
        .and_then(|s| Signature::from_slice(&s).ok())
        .ok_or("invalid JWT signature")?;
    let signed = &token[..token.rfind('.').unwrap_or_default()];
    key.verify_strict(signed.as_bytes(), &signature)
        .map_err(|_| "invalid JWT signature")?;

    let claims = decode_json(payload).ok_or("invalid JWT claims")?;
    if claims.get("iss").and_then(Value::as_str) != Some(issuer.trim_end_matches('/')) {
        return Err("access token issuer mismatch");
    }
    let exp = claims.get("exp").and_then(Value::as_i64).unwrap_or(0);
    if exp <= now as i64 {
        return Err("access token expired");
    }
    if claims.get("nbf").and_then(Value::as_i64).unwrap_or(0) > now as i64 {
        return Err("access token not yet valid");
    }
    if !accepted_audiences
        .iter()
        .any(|a| audience_contains(claims.get("aud"), a))
    {
        return Err("access token audience mismatch");
    }
    let sub = claims.get("sub").and_then(Value::as_str).unwrap_or("");
    // client_credentials tokens name a client as subject; they have no owner.
    if claims.get("client_id").is_some() || sub.is_empty() || accepted_audiences.contains(&sub) {
        return Err("invalid user access token");
    }
    let scopes: Vec<String> = claims
        .get("scope")
        .and_then(Value::as_str)
        .unwrap_or("")
        .split_whitespace()
        .map(String::from)
        .collect();
    if !scopes.iter().any(|s| s == REQUIRED_SCOPE) {
        return Err("access token lacks the email scope");
    }
    Ok(TokenGrant {
        sub: sub.into(),
        scopes,
    })
}

/// Owner email from the IdP userinfo response for the same token.
pub fn email_from_userinfo(userinfo: &Value, sub: &str) -> Result<String, &'static str> {
    if userinfo.get("sub").and_then(Value::as_str) != Some(sub) {
        return Err("userinfo identity mismatch");
    }
    if userinfo.get("email_verified").and_then(Value::as_bool) == Some(false) {
        return Err("verified email is required");
    }
    userinfo
        .get("email")
        .and_then(Value::as_str)
        .map(|e| e.trim().to_lowercase())
        .filter(|e| e.contains('@') && !e.chars().any(char::is_control))
        .ok_or("userinfo omitted email")
}

// ---------------------------------------------------------------------------
// Runtime verifier (JWKS cache + userinfo over the loopback IdP link)
// ---------------------------------------------------------------------------

pub struct OAuthVerifier {
    idp: IdpConfig,
    base_url: String,
    jwks: Mutex<Option<(Instant, Value)>>,
}

impl OAuthVerifier {
    pub fn new(idp: IdpConfig) -> Self {
        let base_url = env::var("AKURAI_PASSVAULT_PUBLIC_URL")
            .or_else(|_| env::var("AKURAI_PUBLIC_URL"))
            .unwrap_or_else(|_| "https://akurai-passvault.olibuijr.com".into());
        Self {
            idp,
            base_url,
            jwks: Mutex::new(None),
        }
    }

    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    pub fn metadata(&self) -> String {
        protected_resource_metadata(&self.base_url, &self.idp.issuer)
    }

    fn fetch_jwks(&self, force: bool) -> Result<Value, &'static str> {
        let mut cache = self.jwks.lock();
        if let Some((at, jwks)) = cache.as_ref() {
            if !force && at.elapsed() < JWKS_TTL {
                return Ok(jwks.clone());
            }
        }
        let body = request_idp(&self.idp, "GET", "/jwks", None).map_err(|_| "JWKS unavailable")?;
        let jwks = akurai_json::parse(&body).map_err(|_| "JWKS unavailable")?;
        *cache = Some((Instant::now(), jwks.clone()));
        Ok(jwks)
    }

    /// Validates a bearer token and returns the allowlisted owner email.
    pub fn authenticate(&self, token: &str) -> Result<String, &'static str> {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or_default();
        let accepted = [AUDIENCE, AGENT_CLIENT];
        let issuer = &self.idp.issuer;
        let mut jwks = self.fetch_jwks(false)?;
        if key_id(token).is_some_and(|kid| find_key(&jwks, &kid).is_none()) {
            jwks = self.fetch_jwks(true)?; // key rotation
        }
        let grant = validate_access_token(token, &jwks, issuer, &accepted, now)?;
        let userinfo = request_idp(&self.idp, "GET", "/userinfo", Some(token))
            .ok()
            .and_then(|body| akurai_json::parse(&body).ok())
            .ok_or("userinfo unavailable")?;
        let email = email_from_userinfo(&userinfo, &grant.sub)?;
        if !self.idp.allows(&email) {
            return Err("IDP user is not allowed to access PassVault");
        }
        Ok(email)
    }
}

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

    const ISS: &str = "https://auth.example";
    const BASE: &str = "https://vault.example";
    const NOW: u64 = 1_800_000_000;

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

    fn jwks() -> Value {
        let x = URL_SAFE_NO_PAD.encode(signing_key().verifying_key().to_bytes());
        akurai_json::parse(&format!(
            r#"{{"keys":[{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"k1","x":"{x}"}}]}}"#
        ))
        .unwrap()
    }

    fn sign_with(header: &str, claims: &str) -> String {
        let signed = format!(
            "{}.{}",
            URL_SAFE_NO_PAD.encode(header),
            URL_SAFE_NO_PAD.encode(claims)
        );
        let sig = signing_key().sign(signed.as_bytes());
        format!("{signed}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes()))
    }

    fn sign(claims: &str) -> String {
        sign_with(r#"{"alg":"EdDSA","kid":"k1","typ":"JWT"}"#, claims)
    }

    fn claims(aud: &str, extra: &str) -> String {
        format!(
            r#"{{"iss":"{ISS}","sub":"user-1","aud":{aud},"exp":{},"scope":"openid profile email groups","tenant_id":"t1"{extra}}}"#,
            NOW + 300
        )
    }

    fn idp_aud() -> String {
        format!(r#"["{AGENT_CLIENT}","{AUDIENCE}"]"#)
    }

    fn validate(token: &str) -> Result<TokenGrant, &'static str> {
        validate_access_token(token, &jwks(), ISS, &[AUDIENCE, AGENT_CLIENT], NOW)
    }

    #[test]
    fn metadata_names_resource_and_issuer() {
        let meta = akurai_json::parse(&protected_resource_metadata(BASE, "https://auth.example/"))
            .unwrap();
        assert_eq!(
            meta.get("resource").and_then(Value::as_str),
            Some("https://vault.example/mcp")
        );
        assert_eq!(
            meta.get("authorization_servers").unwrap().to_json(),
            r#"["https://auth.example"]"#
        );
        assert!(meta
            .get("scopes_supported")
            .unwrap()
            .to_json()
            .contains("\"email\""));
        assert_eq!(
            meta.get("bearer_methods_supported").unwrap().to_json(),
            r#"["header"]"#
        );
    }

    #[test]
    fn challenges_point_at_resource_metadata() {
        let url = "https://vault.example/.well-known/oauth-protected-resource/mcp";
        assert_eq!(
            www_authenticate(BASE, false),
            format!("Bearer resource_metadata=\"{url}\"")
        );
        assert_eq!(
            www_authenticate(BASE, true),
            format!("Bearer error=\"invalid_token\", resource_metadata=\"{url}\"")
        );
    }

    #[test]
    fn origin_must_be_absent_or_same_origin() {
        assert!(origin_allowed(None, BASE));
        assert!(origin_allowed(
            Some("https://vault.example"),
            "https://vault.example/"
        ));
        assert!(!origin_allowed(Some("https://evil.example"), BASE));
    }

    #[test]
    fn accepts_idp_token_whose_audience_array_names_passvault() {
        let grant = validate(&sign(&claims(&idp_aud(), ""))).unwrap();
        assert_eq!(grant.sub, "user-1");
        assert!(grant.scopes.iter().any(|s| s == "email"));
    }

    #[test]
    fn accepts_string_audience() {
        assert!(validate(&sign(&claims(&format!("\"{AUDIENCE}\""), ""))).is_ok());
    }

    #[test]
    fn rejects_other_audiences_issuers_and_expired_tokens() {
        let other = sign(&claims(r#"["akurai-agent-mail","akurai-mail"]"#, ""));
        assert_eq!(validate(&other), Err("access token audience mismatch"));
        let token = sign(&claims(&idp_aud(), "").replace(ISS, "https://evil.example"));
        assert_eq!(validate(&token), Err("access token issuer mismatch"));
        let expired =
            sign(&claims(&idp_aud(), "").replace(&(NOW + 300).to_string(), &NOW.to_string()));
        assert_eq!(validate(&expired), Err("access token expired"));
        let early = sign(&claims(&idp_aud(), &format!(r#","nbf":{}"#, NOW + 60)));
        assert_eq!(validate(&early), Err("access token not yet valid"));
    }

    #[test]
    fn rejects_client_credentials_and_missing_email_scope() {
        let client = sign(&claims(&idp_aud(), r#","client_id":"svc""#));
        assert_eq!(validate(&client), Err("invalid user access token"));
        let no_email =
            sign(&claims(&idp_aud(), "").replace("openid profile email groups", "openid"));
        assert_eq!(
            validate(&no_email),
            Err("access token lacks the email scope")
        );
    }

    #[test]
    fn rejects_bad_signatures_keys_and_algorithms() {
        let token = sign(&claims(&idp_aud(), ""));
        let tampered = format!("{}x", &token[..token.len() - 1]);
        assert!(validate(&tampered).is_err());
        let forged_payload = {
            let mut parts: Vec<&str> = token.split('.').collect();
            let evil = URL_SAFE_NO_PAD.encode(claims(&idp_aud(), "").replace("user-1", "admin"));
            parts[1] = &evil;
            parts.join(".")
        };
        assert_eq!(validate(&forged_payload), Err("invalid JWT signature"));
        let unknown = sign_with(r#"{"alg":"EdDSA","kid":"k9"}"#, &claims(&idp_aud(), ""));
        assert_eq!(validate(&unknown), Err("unknown JWT key id"));
        let none = sign_with(r#"{"alg":"none","kid":"k1"}"#, &claims(&idp_aud(), ""));
        assert_eq!(validate(&none), Err("unexpected JWT algorithm"));
        assert_eq!(validate("static-token"), Err("access token is not a JWT"));
    }

    #[test]
    fn key_id_reads_header() {
        assert_eq!(
            key_id(&sign(&claims(&idp_aud(), ""))).as_deref(),
            Some("k1")
        );
        assert_eq!(key_id("opaque"), None);
    }

    #[test]
    fn userinfo_must_match_subject_and_carry_email() {
        let ok = akurai_json::parse(
            r#"{"sub":"user-1","email":" Owner@Example.com ","email_verified":true}"#,
        )
        .unwrap();
        assert_eq!(
            email_from_userinfo(&ok, "user-1").unwrap(),
            "owner@example.com"
        );
        assert_eq!(
            email_from_userinfo(&ok, "user-2"),
            Err("userinfo identity mismatch")
        );
        let unverified =
            akurai_json::parse(r#"{"sub":"user-1","email":"a@b.c","email_verified":false}"#)
                .unwrap();
        assert_eq!(
            email_from_userinfo(&unverified, "user-1"),
            Err("verified email is required")
        );
        let missing = akurai_json::parse(r#"{"sub":"user-1"}"#).unwrap();
        assert_eq!(
            email_from_userinfo(&missing, "user-1"),
            Err("userinfo omitted email")
        );
    }
}