AkurAI Build
Menu

akurai-tasks

public

Latest change 765ec448ced1bf9f878cb3179ca9575d0f46d0a9 - feat(mcp): advertise OAuth via RFC 9728 discovery by Ólafur Búi Ólafsson

use akurai_json::{parse, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[derive(Clone)]
pub struct OidcConfig {
    pub issuer: String,
    pub internal_url: String,
    pub client_id: String,
    pub client_secret: String,
    pub redirect_uri: String,
    pub allowed_emails: Vec<String>,
}

/// The `aud` claim AkurAI IDP mints for tokens scoped to this service.
///
/// Must stay in sync with the `akurai-tasks` audience registered for the
/// `opencode-tasks` client in AkurAI-IDP. The `/mcp` route already validates
/// this audience; the constant names it in one place so discovery and
/// validation cannot drift apart.
pub const AUDIENCE: &str = "akurai-tasks";

/// RFC 9728 OAuth 2.0 Protected Resource Metadata.
///
/// MCP clients (the official TypeScript SDK, and therefore opencode) begin the
/// OAuth flow by probing `/.well-known/oauth-protected-resource` on the MCP
/// server to learn which authorization server to talk to. Without it the client
/// treats AkurAI-Tasks itself as the authorization server, probes well-known
/// endpoints that do not exist here, receives the static-file 404, and the flow
/// dies before it ever reaches AkurAI IDP.
#[derive(Clone, Debug)]
pub struct ResourceMetadata {
    /// Canonical MCP resource identifier, e.g. `https://host/mcp`.
    pub resource: String,
    /// Authorization server issuer, e.g. `https://auth.olibuijr.com`.
    pub issuer: String,
}

impl ResourceMetadata {
    /// Build metadata for a public base URL. An empty `public_url` falls back to
    /// `AKURAI_TASKS_PUBLIC_URL`, then `AKURAI_PUBLIC_URL`, then the production
    /// hostname.
    pub fn new(public_url: &str, issuer: &str) -> Self {
        let base = if public_url.is_empty() {
            std::env::var("AKURAI_TASKS_PUBLIC_URL")
                .or_else(|_| std::env::var("AKURAI_PUBLIC_URL"))
                .unwrap_or_else(|_| "https://akurai-tasks.olibuijr.com".into())
        } else {
            public_url.to_string()
        };
        Self {
            resource: format!("{}/mcp", base.trim_end_matches('/')),
            issuer: issuer.trim_end_matches('/').to_string(),
        }
    }

    /// True when `path` is one of the metadata paths clients probe.
    ///
    /// The SDK tries the path-scoped form first (`.../oauth-protected-resource/mcp`,
    /// RFC 9728 §3.1) and falls back to the root form, so both must answer.
    pub fn is_metadata_path(path: &str) -> bool {
        matches!(
            path.trim_end_matches('/'),
            "/.well-known/oauth-protected-resource" | "/.well-known/oauth-protected-resource/mcp"
        )
    }

    /// Absolute URL of the metadata document, advertised in `WWW-Authenticate`.
    pub fn metadata_url(&self) -> String {
        let base = self
            .resource
            .strip_suffix("/mcp")
            .unwrap_or(&self.resource)
            .trim_end_matches('/');
        format!("{base}/.well-known/oauth-protected-resource")
    }

    /// `WWW-Authenticate` challenge value for an unauthenticated `/mcp` request.
    ///
    /// The SDK reads `resource_metadata` from this header to skip straight to
    /// the right document instead of guessing well-known paths.
    pub fn challenge(&self) -> String {
        format!("Bearer resource_metadata=\"{}\"", self.metadata_url())
    }

    /// The metadata document itself.
    ///
    /// `resource` must exactly match the URL the client requested: the SDK's
    /// `checkResourceAllowed` compares origins and requires the configured path
    /// to be a prefix of the requested one, otherwise it aborts the flow.
    pub fn document(&self) -> String {
        format!(
            concat!(
                "{{",
                r#""resource":"{resource}","#,
                r#""authorization_servers":["{issuer}"],"#,
                r#""scopes_supported":["openid","profile","email","groups"],"#,
                r#""bearer_methods_supported":["header"]"#,
                "}}"
            ),
            resource = self.resource,
            issuer = self.issuer,
        )
    }
}

#[derive(Clone)]
pub(crate) struct Pending {
    pub verifier: String,
    pub nonce: String,
    pub expires_at: i64,
    pub completed: Option<Identity>,
}

#[derive(Clone, Debug)]
pub(crate) struct Identity {
    pub sub: String,
    pub email: String,
}

pub(crate) fn verify_access_token(
    token: &str,
    config: &OidcConfig,
    audience: &str,
) -> Result<Identity, String> {
    let payload = token
        .split('.')
        .nth(1)
        .ok_or_else(|| "access token is not a JWT".to_string())
        .and_then(base64url_decode)?;
    let claims =
        parse(&String::from_utf8(payload).map_err(|_| "access token payload is not UTF-8")?)
            .map_err(|error| format!("access token payload: {error}"))?;
    if claims.get("iss").and_then(Value::as_str) != Some(config.issuer.trim_end_matches('/')) {
        return Err("access token issuer mismatch".into());
    }
    if claims.get("aud").and_then(Value::as_str) != Some(audience) {
        return Err("access token audience mismatch".into());
    }
    let response = request(
        &config.internal_url,
        "GET",
        "/userinfo",
        &[("Authorization", &format!("Bearer {token}"))],
        None,
    )?;
    let user = parse(&response).map_err(|error| format!("userinfo response: {error}"))?;
    let identity = Identity {
        sub: user
            .get("sub")
            .and_then(Value::as_str)
            .ok_or("userinfo omitted subject")?
            .to_string(),
        email: user
            .get("email")
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_string(),
    };
    if !config.allowed_emails.is_empty()
        && !config
            .allowed_emails
            .iter()
            .any(|allowed| allowed.eq_ignore_ascii_case(&identity.email))
    {
        return Err("IDP identity is not allowed".into());
    }
    Ok(identity)
}

pub(crate) fn begin(
    config: &OidcConfig,
    pending: &mut BTreeMap<String, Pending>,
) -> Result<String, String> {
    if config.client_id.is_empty() || config.client_secret.is_empty() {
        return Err("OIDC client is not configured".into());
    }
    let state = random_token(24)?;
    let verifier = random_token(32)?;
    let nonce = random_token(24)?;
    let challenge = base64url(&Sha256::digest(verifier.as_bytes()));
    pending.retain(|_, value| value.expires_at > now());
    pending.insert(
        state.clone(),
        Pending {
            verifier,
            nonce: nonce.clone(),
            expires_at: now() + 300,
            completed: None,
        },
    );
    Ok(format!(
        "{}/authorize?client_id={}&redirect_uri={}&response_type=code&scope=openid%20profile%20email&state={}&nonce={}&code_challenge={}&code_challenge_method=S256",
        config.issuer.trim_end_matches('/'), encode(&config.client_id), encode(&config.redirect_uri), encode(&state), encode(&nonce), encode(&challenge)
    ))
}

pub(crate) fn callback(
    config: &OidcConfig,
    query: Option<&str>,
    pending: &mut BTreeMap<String, Pending>,
) -> Result<Identity, String> {
    let params = form(query.unwrap_or(""));
    let state = params.get("state").ok_or("OIDC state missing")?;
    let code = params
        .get("code")
        .ok_or("OIDC authorization code missing")?;
    let flow = pending.get_mut(state).ok_or("OIDC state is invalid")?;
    if flow.expires_at <= now() {
        return Err("OIDC state expired".into());
    }
    if let Some(identity) = &flow.completed {
        return Ok(identity.clone());
    }
    let token_body = format!("grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&client_secret={}&code_verifier={}", encode(code), encode(&config.redirect_uri), encode(&config.client_id), encode(&config.client_secret), encode(&flow.verifier));
    let token_response = request(
        &config.internal_url,
        "POST",
        "/token",
        &[("Content-Type", "application/x-www-form-urlencoded")],
        Some(&token_body),
    )?;
    let token = parse(&token_response).map_err(|error| format!("OIDC token response: {error}"))?;
    let access = token
        .get("access_token")
        .and_then(Value::as_str)
        .ok_or("OIDC access token missing")?;
    let id_token = token
        .get("id_token")
        .and_then(Value::as_str)
        .ok_or("OIDC ID token missing")?;
    validate_nonce(id_token, &flow.nonce)?;
    let userinfo = request(
        &config.internal_url,
        "GET",
        "/userinfo",
        &[("Authorization", &format!("Bearer {access}"))],
        None,
    )?;
    let user = parse(&userinfo).map_err(|error| format!("OIDC userinfo response: {error}"))?;
    let sub = user
        .get("sub")
        .and_then(Value::as_str)
        .ok_or("OIDC subject missing")?
        .to_string();
    let email = user
        .get("email")
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string();
    if !config.allowed_emails.is_empty()
        && !config
            .allowed_emails
            .iter()
            .any(|allowed| allowed.eq_ignore_ascii_case(&email))
    {
        return Err("OIDC identity is not allowed".into());
    }
    let identity = Identity { sub, email };
    flow.completed = Some(identity.clone());
    Ok(identity)
}

fn validate_nonce(token: &str, expected: &str) -> Result<(), String> {
    let payload = token
        .split('.')
        .nth(1)
        .ok_or("OIDC ID token is malformed")?;
    let bytes = base64url_decode(payload)?;
    let json = parse(&String::from_utf8(bytes).map_err(|_| "OIDC ID token payload is not UTF-8")?)
        .map_err(|error| format!("OIDC ID token payload: {error}"))?;
    if json.get("nonce").and_then(Value::as_str) != Some(expected) {
        return Err("OIDC nonce mismatch".into());
    }
    Ok(())
}

fn request(
    base: &str,
    method: &str,
    path: &str,
    headers: &[(&str, &str)],
    body: Option<&str>,
) -> Result<String, String> {
    let without_scheme = base
        .strip_prefix("http://")
        .ok_or("OIDC internal URL must use loopback HTTP")?;
    let (host, port) = without_scheme
        .split_once(':')
        .unwrap_or((without_scheme, "80"));
    if !matches!(host, "127.0.0.1" | "localhost") {
        return Err("OIDC internal URL must be loopback".into());
    }
    let port: u16 = port.parse().map_err(|_| "OIDC internal port is invalid")?;
    let mut stream = TcpStream::connect_timeout(
        &format!("{host}:{port}")
            .parse()
            .map_err(|_| "OIDC internal address invalid")?,
        Duration::from_secs(5),
    )
    .map_err(|error| format!("OIDC connect: {error}"))?;
    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .map_err(|error| format!("OIDC timeout: {error}"))?;
    let body = body.unwrap_or("");
    let mut wire = format!("{method} {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\nContent-Length: {}\r\n", body.len());
    for (name, value) in headers {
        wire.push_str(&format!("{name}: {value}\r\n"));
    }
    wire.push_str("\r\n");
    wire.push_str(body);
    stream
        .write_all(wire.as_bytes())
        .map_err(|error| format!("OIDC write: {error}"))?;
    let mut bytes = Vec::new();
    stream
        .read_to_end(&mut bytes)
        .map_err(|error| format!("OIDC read: {error}"))?;
    let response = String::from_utf8_lossy(&bytes);
    let status = response
        .lines()
        .next()
        .and_then(|line| line.split_whitespace().nth(1))
        .and_then(|value| value.parse::<u16>().ok())
        .unwrap_or(0);
    let content = response
        .split_once("\r\n\r\n")
        .map(|(_, body)| body)
        .unwrap_or("");
    if !(200..300).contains(&status) {
        return Err(format!("OIDC endpoint returned HTTP {status}"));
    }
    Ok(content.to_string())
}

fn form(query: &str) -> BTreeMap<String, String> {
    query
        .split('&')
        .filter_map(|pair| pair.split_once('='))
        .map(|(key, value)| (decode(key), decode(value)))
        .collect()
}

fn encode(value: &str) -> String {
    let mut out = String::new();
    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
            out.push(byte as char);
        } else {
            out.push_str(&format!("%{byte:02X}"));
        }
    }
    out
}

fn decode(value: &str) -> String {
    let bytes = value.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%' && index + 2 < bytes.len() {
            if let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16) {
                out.push(byte);
                index += 3;
                continue;
            }
        }
        out.push(if bytes[index] == b'+' {
            b' '
        } else {
            bytes[index]
        });
        index += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

fn random_token(bytes: usize) -> Result<String, String> {
    let mut data = vec![0u8; bytes];
    File::open("/dev/urandom")
        .and_then(|mut file| file.read_exact(&mut data))
        .map_err(|error| format!("secure random: {error}"))?;
    Ok(base64url(&data))
}

fn base64url(bytes: &[u8]) -> String {
    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    let mut out = String::new();
    for chunk in bytes.chunks(3) {
        let n = ((chunk[0] as u32) << 16)
            | ((chunk.get(1).copied().unwrap_or(0) as u32) << 8)
            | chunk.get(2).copied().unwrap_or(0) as u32;
        out.push(TABLE[((n >> 18) & 63) as usize] as char);
        out.push(TABLE[((n >> 12) & 63) as usize] as char);
        if chunk.len() > 1 {
            out.push(TABLE[((n >> 6) & 63) as usize] as char);
        }
        if chunk.len() > 2 {
            out.push(TABLE[(n & 63) as usize] as char);
        }
    }
    out
}

fn base64url_decode(value: &str) -> Result<Vec<u8>, String> {
    fn digit(byte: u8) -> Option<u8> {
        match byte {
            b'A'..=b'Z' => Some(byte - b'A'),
            b'a'..=b'z' => Some(byte - b'a' + 26),
            b'0'..=b'9' => Some(byte - b'0' + 52),
            b'-' => Some(62),
            b'_' => Some(63),
            _ => None,
        }
    }
    let mut out = Vec::new();
    for chunk in value.as_bytes().chunks(4) {
        if chunk.len() < 2 {
            return Err("invalid base64url".into());
        }
        let a = digit(chunk[0]).ok_or("invalid base64url")? as u32;
        let b = digit(chunk[1]).ok_or("invalid base64url")? as u32;
        let c = chunk.get(2).and_then(|value| digit(*value)).unwrap_or(0) as u32;
        let d = chunk.get(3).and_then(|value| digit(*value)).unwrap_or(0) as u32;
        let n = (a << 18) | (b << 12) | (c << 6) | d;
        out.push((n >> 16) as u8);
        if chunk.len() > 2 {
            out.push((n >> 8) as u8);
        }
        if chunk.len() > 3 {
            out.push(n as u8);
        }
    }
    Ok(out)
}

fn now() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64
}

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

    #[test]
    fn metadata_trims_the_issuer_and_derives_the_mcp_resource() {
        let metadata = ResourceMetadata::new("https://tasks.invalid/", "https://issuer.invalid/");
        assert_eq!(metadata.resource, "https://tasks.invalid/mcp");
        assert_eq!(metadata.issuer, "https://issuer.invalid");
    }

    #[test]
    fn the_metadata_url_is_root_scoped_not_path_scoped() {
        // RFC 9728 locates the document at the origin root even when the
        // protected resource itself lives at /mcp.
        let metadata = ResourceMetadata::new("https://tasks.invalid", "https://issuer.invalid");
        assert_eq!(
            metadata.metadata_url(),
            "https://tasks.invalid/.well-known/oauth-protected-resource"
        );
    }

    #[test]
    fn both_probed_well_known_paths_are_recognised() {
        assert!(ResourceMetadata::is_metadata_path(
            "/.well-known/oauth-protected-resource"
        ));
        assert!(ResourceMetadata::is_metadata_path(
            "/.well-known/oauth-protected-resource/mcp"
        ));
        assert!(ResourceMetadata::is_metadata_path(
            "/.well-known/oauth-protected-resource/"
        ));
        assert!(!ResourceMetadata::is_metadata_path(
            "/.well-known/oauth-authorization-server"
        ));
        assert!(!ResourceMetadata::is_metadata_path("/mcp"));
    }

    #[test]
    fn the_challenge_exposes_the_resource_metadata_parameter() {
        let metadata = ResourceMetadata::new("https://tasks.invalid", "https://issuer.invalid");
        assert_eq!(
            metadata.challenge(),
            "Bearer resource_metadata=\"https://tasks.invalid/.well-known/oauth-protected-resource\""
        );
    }

    #[test]
    fn the_document_is_valid_json_naming_the_resource_and_authorization_server() {
        let metadata = ResourceMetadata::new("https://tasks.invalid", "https://issuer.invalid");
        let document = parse(&metadata.document()).expect("metadata must be valid JSON");
        assert_eq!(
            document.get("resource").and_then(Value::as_str),
            Some("https://tasks.invalid/mcp")
        );
        let Some(Value::Array(servers)) = document.get("authorization_servers") else {
            panic!("authorization_servers must be an array");
        };
        assert_eq!(servers.len(), 1);
        assert_eq!(servers[0].as_str(), Some("https://issuer.invalid"));
    }

    #[test]
    fn an_access_token_is_rejected_offline_on_issuer_or_audience_mismatch() {
        // Neither case may reach /userinfo: internal_url points at a closed
        // port, so a network attempt would surface as a connection error.
        let config = OidcConfig {
            issuer: "https://issuer.invalid".into(),
            internal_url: "http://127.0.0.1:1".into(),
            client_id: "client".into(),
            client_secret: "secret".into(),
            redirect_uri: "https://tasks.invalid/auth/callback".into(),
            allowed_emails: Vec::new(),
        };
        let token = |claims: &str| format!("header.{}.signature", base64url(claims.as_bytes()));

        let wrong_issuer = token(r#"{"iss":"https://evil.invalid","aud":"akurai-tasks"}"#);
        assert_eq!(
            verify_access_token(&wrong_issuer, &config, AUDIENCE).unwrap_err(),
            "access token issuer mismatch"
        );

        let wrong_audience = token(r#"{"iss":"https://issuer.invalid","aud":"akurai-notes"}"#);
        assert_eq!(
            verify_access_token(&wrong_audience, &config, AUDIENCE).unwrap_err(),
            "access token audience mismatch"
        );
    }

    #[test]
    fn an_access_token_must_be_a_jwt() {
        let config = OidcConfig {
            issuer: "https://issuer.invalid".into(),
            internal_url: "http://127.0.0.1:1".into(),
            client_id: "client".into(),
            client_secret: "secret".into(),
            redirect_uri: "https://tasks.invalid/auth/callback".into(),
            allowed_emails: Vec::new(),
        };
        assert!(verify_access_token("not-a-jwt", &config, AUDIENCE).is_err());
    }

    #[test]
    fn completed_callback_replays_without_reusing_authorization_code() {
        let config = OidcConfig {
            issuer: "https://issuer.invalid".into(),
            internal_url: "http://127.0.0.1:1".into(),
            client_id: "client".into(),
            client_secret: "secret".into(),
            redirect_uri: "https://tasks.invalid/auth/callback".into(),
            allowed_emails: Vec::new(),
        };
        let mut pending = BTreeMap::from([(
            "state".into(),
            Pending {
                verifier: "verifier".into(),
                nonce: "nonce".into(),
                expires_at: now() + 300,
                completed: Some(Identity {
                    sub: "subject".into(),
                    email: "agent@example.com".into(),
                }),
            },
        )]);
        let replay = callback(&config, Some("state=state&code=already-used"), &mut pending)
            .expect("completed callback should replay");
        assert_eq!(replay.sub, "subject");
        assert_eq!(replay.email, "agent@example.com");
    }
}