AkurAI Build
Menu

Public repository / master

AkurAI-IDP / src/lib/clients.rs

Latest change 6a72ff276d26a76936569c968c80f8b033efafa6 - clients: drop built-in Tasks agent client (tasks live in Paperclip) by Ólafur Búi Ólafsson

//! OAuth client policy: public (PKCE-only) clients, token audiences and the
//! built-in MCP clients that let AkurAI agents reach AkurAI app MCP servers.

use rusqlite::{Connection, OptionalExtension};

use crate::lib::crypto::{constant_time_equal, sha256};

pub const AUTH_METHOD_NONE: &str = "none";

/// Loopback redirect URIs used by agent MCP clients. Agents run on Titan and
/// complete the OAuth code flow on a fixed local port.
const AGENT_REDIRECT_URIS: &str =
    r#"["http://localhost:47840/callback","http://127.0.0.1:47840/callback"]"#;
const AGENT_GRANT_TYPES: &str = r#"["authorization_code","refresh_token"]"#;
const APP_SCOPES: &str = r#"["openid","profile","email","groups","offline_access"]"#;
const MAIL_SCOPES: &str =
    r#"["openid","email","offline_access","mail.read","mail.draft","mail.send"]"#;

/// (client id, display name, scopes, audience) for every AkurAI app MCP server.
const AGENT_MCP_CLIENTS: &[(&str, &str, &str, &str)] = &[
    (
        "akurai-agent-mail",
        "AkurAI Agent Mail",
        MAIL_SCOPES,
        "https://mail.olibuijr.com/mcp",
    ),
    (
        "akurai-agent-notes",
        "AkurAI Agent Notes",
        APP_SCOPES,
        "akurai-notes",
    ),
    (
        "akurai-agent-passvault",
        "AkurAI Agent PassVault",
        APP_SCOPES,
        "akurai-passvault",
    ),
    (
        "akurai-agent-crm",
        "AkurAI Agent CRM",
        APP_SCOPES,
        "akurai-crm",
    ),
    (
        "akurai-agent-build",
        "AkurAI Agent Build",
        APP_SCOPES,
        "akurai-build",
    ),
    (
        "akurai-agent-drive",
        "AkurAI Agent Drive",
        APP_SCOPES,
        "akurai-drive",
    ),
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientPolicy {
    pub tenant_id: String,
    pub auth_method: String,
    pub platform_wide: bool,
    pub audience: String,
}

impl ClientPolicy {
    pub fn is_public(&self) -> bool {
        self.auth_method == AUTH_METHOD_NONE
    }

    /// Platform-wide clients serve every organization; others only their own.
    pub fn allows_organization(&self, organization_id: &str) -> bool {
        self.platform_wide || self.tenant_id == organization_id
    }
}

pub fn load_policy(conn: &Connection, client_id: &str) -> Option<ClientPolicy> {
    conn.query_row(
        "SELECT tenant_id, token_endpoint_auth_method, platform_wide, audience
         FROM clients WHERE id = ?1",
        [client_id],
        |row| {
            Ok(ClientPolicy {
                tenant_id: row.get(0)?,
                auth_method: row.get(1)?,
                platform_wide: row.get::<_, i64>(2)? != 0,
                audience: row.get(3)?,
            })
        },
    )
    .optional()
    .ok()
    .flatten()
}

/// Authenticate a client at the token endpoint. Public clients carry no
/// secret; their proof of possession is the PKCE verifier checked by the caller.
pub fn authenticate(conn: &Connection, client_id: &str, secret: Option<&str>) -> bool {
    let Some(policy) = load_policy(conn, client_id) else {
        return false;
    };
    if policy.is_public() {
        return true;
    }
    let Some(secret) = secret.filter(|s| !s.is_empty()) else {
        return false;
    };
    conn.query_row(
        "SELECT client_secret_hash FROM clients WHERE id = ?1",
        [client_id],
        |row| row.get::<_, String>(0),
    )
    .map(|hash| !hash.is_empty() && constant_time_equal(&sha256(secret), &hash))
    .unwrap_or(false)
}

/// Access-token audience for a client; falls back to the client id.
pub fn audience(conn: &Connection, client_id: &str) -> String {
    load_policy(conn, client_id)
        .map(|p| p.audience)
        .filter(|a| !a.trim().is_empty())
        .unwrap_or_else(|| client_id.to_string())
}

/// `aud` claim for an access token: the client id, plus the resource
/// audience when it differs, so resource servers may check either.
pub fn access_token_audience(conn: &Connection, client_id: &str) -> serde_json::Value {
    let resource = audience(conn, client_id);
    if resource == client_id {
        serde_json::json!(client_id)
    } else {
        serde_json::json!([client_id, resource])
    }
}

/// True when a JWT `aud` claim (string or array) names `expected`.
pub fn audience_contains(aud: Option<&serde_json::Value>, expected: &str) -> bool {
    match aud {
        Some(serde_json::Value::String(value)) => value == expected,
        Some(serde_json::Value::Array(values)) => values.iter().any(|v| v == expected),
        _ => false,
    }
}

/// Register the built-in agent MCP clients under the oldest organization.
/// Existing rows are never overwritten.
pub fn register_builtin_clients(conn: &Connection, now: i64) -> rusqlite::Result<()> {
    let Some(organization_id) = conn
        .query_row(
            "SELECT id FROM organizations ORDER BY created_at, id LIMIT 1",
            [],
            |row| row.get::<_, String>(0),
        )
        .optional()?
    else {
        return Ok(());
    };
    for (id, name, scopes, audience) in AGENT_MCP_CLIENTS {
        conn.execute(
            "INSERT OR IGNORE INTO clients
             (id, tenant_id, name, client_secret_hash, redirect_uris, grant_types,
              scopes, first_party, token_endpoint_auth_method, platform_wide, audience, created_at)
             VALUES (?1, ?2, ?3, '', ?4, ?5, ?6, 1, 'none', 0, ?7, ?8)",
            rusqlite::params![
                id,
                organization_id,
                name,
                AGENT_REDIRECT_URIS,
                AGENT_GRANT_TYPES,
                scopes,
                audience,
                now
            ],
        )?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::schema::create_tables;

    fn fixture() -> Connection {
        let conn = Connection::open_in_memory().expect("in-memory db");
        create_tables(&conn);
        conn.execute(
            "INSERT INTO tenants (id, name, slug, created_at) VALUES ('org-a', 'A', 'a', 1)",
            [],
        )
        .expect("tenant");
        create_tables(&conn);
        conn.execute(
            "INSERT INTO clients (id, tenant_id, name, client_secret_hash, redirect_uris,
             grant_types, scopes, created_at)
             VALUES ('conf', 'org-a', 'Confidential', ?1, '[]', '[]', '[]', 1)",
            [sha256("s3cret")],
        )
        .expect("confidential client");
        conn
    }

    #[test]
    fn confidential_client_requires_matching_secret() {
        let conn = fixture();
        assert!(authenticate(&conn, "conf", Some("s3cret")));
        assert!(!authenticate(&conn, "conf", Some("wrong")));
        assert!(!authenticate(&conn, "conf", None));
    }

    #[test]
    fn legacy_clients_default_to_secret_auth_and_own_audience() {
        let conn = fixture();
        let policy = load_policy(&conn, "conf").expect("policy");
        assert_eq!(policy.auth_method, "client_secret_basic");
        assert!(!policy.is_public());
        assert_eq!(audience(&conn, "conf"), "conf");
    }

    #[test]
    fn builtin_agent_clients_are_public_and_bound_to_app_audiences() {
        let conn = fixture();
        register_builtin_clients(&conn, 5).expect("register");
        let mail = load_policy(&conn, "akurai-agent-mail").expect("mail client");
        assert!(mail.is_public());
        assert_eq!(mail.tenant_id, "org-a");
        assert_eq!(
            audience(&conn, "akurai-agent-mail"),
            "https://mail.olibuijr.com/mcp"
        );
        assert_eq!(audience(&conn, "akurai-agent-notes"), "akurai-notes");
        assert!(authenticate(&conn, "akurai-agent-notes", None));
    }

    #[test]
    fn only_akurai_agent_clients_are_registered() {
        let conn = fixture();
        register_builtin_clients(&conn, 5).expect("register");
        let foreign: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM clients WHERE token_endpoint_auth_method = 'none'
                 AND id NOT LIKE 'akurai-agent-%'",
                [],
                |row| row.get(0),
            )
            .expect("count");
        assert_eq!(foreign, 0);
    }

    #[test]
    fn registration_never_overwrites_existing_rows() {
        let conn = fixture();
        register_builtin_clients(&conn, 5).expect("register");
        conn.execute(
            "UPDATE clients SET name = 'Custom' WHERE id = 'akurai-agent-mail'",
            [],
        )
        .expect("rename");
        register_builtin_clients(&conn, 9).expect("re-register");
        let name: String = conn
            .query_row(
                "SELECT name FROM clients WHERE id = 'akurai-agent-mail'",
                [],
                |r| r.get(0),
            )
            .expect("name");
        assert_eq!(name, "Custom");
    }

    #[test]
    fn platform_wide_clients_allow_any_organization() {
        let own = ClientPolicy {
            tenant_id: "org-a".into(),
            auth_method: "none".into(),
            platform_wide: false,
            audience: "x".into(),
        };
        assert!(own.allows_organization("org-a"));
        assert!(!own.allows_organization("org-b"));
        let wide = ClientPolicy {
            platform_wide: true,
            ..own
        };
        assert!(wide.allows_organization("org-b"));
    }

    #[test]
    fn access_audience_lists_client_and_resource_when_they_differ() {
        let conn = fixture();
        register_builtin_clients(&conn, 5).expect("register");
        assert_eq!(
            access_token_audience(&conn, "conf"),
            serde_json::json!("conf")
        );
        let mail = access_token_audience(&conn, "akurai-agent-mail");
        assert_eq!(
            mail,
            serde_json::json!(["akurai-agent-mail", "https://mail.olibuijr.com/mcp"])
        );
        assert!(audience_contains(
            Some(&mail),
            "https://mail.olibuijr.com/mcp"
        ));
        assert!(audience_contains(Some(&mail), "akurai-agent-mail"));
        assert!(!audience_contains(Some(&mail), "other"));
        assert!(!audience_contains(None, "conf"));
    }

    #[test]
    fn unknown_client_fails_authentication() {
        let conn = fixture();
        assert!(!authenticate(&conn, "missing", None));
    }
}