AkurAI Build
Menu

akurai-tasks

public

Latest change 6b04003dac7c1d10982cbed209dbfaca19a4a72b - fix: probe browser session without unauthorized response by Ólafur Búi Ólafsson

#![forbid(unsafe_code)]
mod oidc;

pub use oidc::OidcConfig;

use akurai_http::{Method, Request, Response};
use akurai_json::{parse, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use tasks_core::{field, object, string, CreateItem, Error, Permission, Query, Store};

#[derive(Clone)]
pub struct Api {
    store: Store,
    fallback_token: Option<String>,
    fallback_principal: String,
    allowed_origin: Option<String>,
    oidc: Option<OidcConfig>,
    pending_oidc: Arc<Mutex<BTreeMap<String, oidc::Pending>>>,
}

impl Api {
    pub fn new(
        store: Store,
        token: String,
        allowed_origin: Option<String>,
    ) -> Result<Self, String> {
        if token.trim().is_empty() {
            return Err("AKURAI_TASKS_TOKEN is required".into());
        }
        Ok(Self {
            store,
            fallback_token: Some(token),
            fallback_principal: "legacy-service".into(),
            allowed_origin,
            oidc: None,
            pending_oidc: Arc::new(Mutex::new(BTreeMap::new())),
        })
    }

    pub fn production(
        store: Store,
        allowed_origin: Option<String>,
        oidc: OidcConfig,
    ) -> Result<Self, String> {
        if oidc.client_id.trim().is_empty() || oidc.client_secret.trim().is_empty() {
            return Err("OIDC client id and secret are required".into());
        }
        Ok(Self {
            store,
            fallback_token: None,
            fallback_principal: "service".into(),
            allowed_origin,
            oidc: Some(oidc),
            pending_oidc: Arc::new(Mutex::new(BTreeMap::new())),
        })
    }

    pub fn handle(&self, request: &Request) -> Response {
        if request.path == "/health"
            || request.path == "/api/health"
            || request.path == "/api/v1/health"
        {
            return json(200, object(vec![("status", string("ok"))]));
        }
        if request.path == "/ready"
            || request.path == "/api/ready"
            || request.path == "/api/v1/ready"
        {
            return match self.store.doctor() {
                Ok(_) => json(200, object(vec![("ready", Value::Bool(true))])),
                Err(_) => json(503, object(vec![("ready", Value::Bool(false))])),
            };
        }
        if request.path == "/auth/login" {
            return self.oidc_login();
        }
        if request.path == "/auth/callback" {
            return self.oidc_callback(request);
        }
        if request.path == "/auth/logout" {
            return self.logout(request);
        }
        if request.path == "/api" {
            return json(
                200,
                object(vec![
                    ("name", string("AkurAI-Tasks")),
                    ("version", string(env!("CARGO_PKG_VERSION"))),
                ]),
            );
        }
        if request.path == "/api/v1/session" && matches!(request.method, Method::Get) {
            let principal = self.authenticate(request, false);
            return json(
                200,
                object(vec![
                    ("authenticated", Value::Bool(principal.is_some())),
                    (
                        "principal",
                        principal.map(string).unwrap_or(Value::Null),
                    ),
                ]),
            );
        }
        if !origin_allowed(request, self.allowed_origin.as_deref()) {
            return error(403, "origin is not allowed");
        }
        let mutation = matches!(request.method, Method::Post | Method::Put | Method::Delete);
        let Some(actor) = self.authenticate(request, mutation) else {
            return error(401, "authentication required");
        };
        match self.route(request, &actor) {
            Ok(value) => json(
                if matches!(request.method, Method::Post) {
                    201
                } else {
                    200
                },
                value,
            ),
            Err(error_value) => domain_error(error_value),
        }
    }

    fn oidc_login(&self) -> Response {
        let Some(config) = &self.oidc else {
            return error(404, "OIDC is not configured");
        };
        let mut pending = match self.pending_oidc.lock() {
            Ok(value) => value,
            Err(_) => return error(500, "OIDC state lock failed"),
        };
        match oidc::begin(config, &mut pending) {
            Ok(location) => redirect(&location, &[]),
            Err(message) => error(500, &message),
        }
    }

    fn oidc_callback(&self, request: &Request) -> Response {
        let Some(config) = &self.oidc else {
            return error(404, "OIDC is not configured");
        };
        let identity = {
            let mut pending = match self.pending_oidc.lock() {
                Ok(value) => value,
                Err(_) => return error(500, "OIDC state lock failed"),
            };
            match oidc::callback(config, request.query.as_deref(), &mut pending) {
                Ok(value) => value,
                Err(message) if message.contains("state is invalid") || message.contains("state expired") => {
                    return redirect("/auth/login", &[]);
                }
                Err(message) => return error(401, &message),
            }
        };
        let identity_key = short_hash(&format!("{}:{}", identity.sub, identity.email));
        let setup = if self.store.bootstrap_complete().unwrap_or(false) {
            self.store
                .upsert_principal(
                    &identity.sub,
                    "human",
                    &identity.email,
                    true,
                    "oidc",
                    &format!("oidc-principal-{identity_key}"),
                )
                .and_then(|_| {
                    self.store.grant_role(
                        &identity.sub,
                        "*",
                        "administrator",
                        "oidc",
                        &format!("oidc-role-{identity_key}"),
                    )
                })
        } else {
            self.store.bootstrap(
                &identity.sub,
                &identity.email,
                "oidc",
                &format!("oidc-bootstrap-{identity_key}"),
            )
        };
        if let Err(error_value) = setup {
            return domain_error(error_value);
        }
        let session = match self.store.create_session(
            &identity.sub,
            86_400,
            "oidc",
            &format!("oidc-session-{}-{}", identity_key, now_seconds()),
        ) {
            Ok(value) => value,
            Err(error_value) => return domain_error(error_value),
        };
        let token = field(&session, "token").unwrap_or("");
        let csrf = field(&session, "csrf").unwrap_or("");
        redirect(
            "/",
            &[
                format!(
                    "tasks_session={token}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400"
                ),
                format!("tasks_csrf={csrf}; Path=/; Secure; SameSite=Lax; Max-Age=86400"),
            ],
        )
    }

    fn logout(&self, request: &Request) -> Response {
        if let Some(token) = cookie(request, "tasks_session") {
            if let Ok(Some(principal)) = self.store.authenticate_session(&token, None, false) {
                let _ = self.store.revoke_session(
                    &token,
                    &principal,
                    &format!("logout-{}", short_hash(&token)),
                );
            }
        }
        redirect(
            "/",
            &[
                "tasks_session=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0".into(),
                "tasks_csrf=; Path=/; Secure; SameSite=Lax; Max-Age=0".into(),
            ],
        )
    }

    fn authenticate(&self, request: &Request, mutation: bool) -> Option<String> {
        if let Some(token) = request
            .header("Authorization")
            .and_then(|value| value.strip_prefix("Bearer "))
        {
            if let Ok(Some(principal)) = self.store.authenticate_bearer(token) {
                return Some(principal);
            }
            if self
                .fallback_token
                .as_deref()
                .is_some_and(|expected| constant_token(expected, token))
            {
                return Some(self.fallback_principal.clone());
            }
        }
        let session = cookie(request, "tasks_session")?;
        self.store
            .authenticate_session(&session, request.header("X-CSRF-Token"), mutation)
            .ok()
            .flatten()
    }

    fn ensure(
        &self,
        actor: &str,
        project: Option<&str>,
        permission: Permission,
    ) -> Result<(), Error> {
        if self.fallback_token.is_some() && actor == self.fallback_principal {
            return Ok(());
        }
        if self.store.authorize(actor, project, permission)? {
            Ok(())
        } else {
            Err(Error::Forbidden(
                "principal lacks the required scoped role".into(),
            ))
        }
    }

    fn route(&self, request: &Request, actor: &str) -> Result<Value, Error> {
        let raw: Vec<&str> = request.path.trim_matches('/').split('/').collect();
        let parts: Vec<&str> = if raw.starts_with(&["api", "v1"]) {
            std::iter::once("api")
                .chain(raw[2..].iter().copied())
                .collect()
        } else {
            raw
        };
        let idempotency = request.header("Idempotency-Key").unwrap_or("");
        match (&request.method, parts.as_slice()) {
            (Method::Post, ["api", "projects"]) => self.ensure(actor, None, Permission::Admin)?,
            (Method::Get, ["api", "projects"]) | (Method::Get, ["api", "events"]) => {
                self.ensure(actor, None, Permission::Read)?
            }
            (Method::Post, ["api", "query"]) | (Method::Get, ["api", "me"]) => {
                self.ensure(actor, None, Permission::Read)?
            }
            (Method::Get, ["api", "projects", project, ..]) => {
                self.ensure(actor, Some(project), Permission::Read)?
            }
            (Method::Post, ["api", "items"]) => {
                let body = body(request)?;
                self.ensure(actor, Some(required(&body, "project")?), Permission::Plan)?
            }
            (method, ["api", "items", id, ..]) => {
                let item = self.store.get_item(id)?;
                let project = field(&item, "project");
                let permission = if matches!(method, Method::Get) {
                    Permission::Read
                } else if parts.last() == Some(&"dependency") {
                    Permission::Plan
                } else {
                    Permission::Execute
                };
                self.ensure(actor, project, permission)?;
                let action = parts.last().copied().unwrap_or("");
                if matches!(*method, Method::Post)
                    && item.get("owner").and_then(Value::as_str).is_some()
                    && !matches!(action, "claim" | "accept-handoff" | "dependency")
                {
                    let token = request
                        .header("X-Lease-Token")
                        .ok_or_else(|| Error::Forbidden("lease token required".into()))?;
                    let generation = request
                        .header("X-Lease-Generation")
                        .and_then(|value| value.parse::<i64>().ok())
                        .ok_or_else(|| Error::Forbidden("lease generation required".into()))?;
                    if !self.store.verify_lease(id, actor, token, generation)? {
                        return Err(Error::Forbidden(
                            "current lease token and generation required".into(),
                        ));
                    }
                }
            }
            _ => {}
        }
        match (&request.method, parts.as_slice()) {
            (Method::Get, ["api", "projects"]) => self.store.list_projects(),
            (Method::Post, ["api", "projects"]) => {
                let body = body(request)?;
                let repos = string_array(&body, "repos")?;
                self.store.create_project(
                    required(&body, "key")?,
                    required(&body, "name")?,
                    repos,
                    actor,
                    idempotency,
                )
            }
            (Method::Get, ["api", "projects", project]) => self.store.get_project(project),
            (Method::Get, ["api", "me"]) => Ok(object(vec![("principal", string(actor))])),
            (Method::Post, ["api", "query"]) => {
                let body = body(request)?;
                self.store.query_items(&query_from_value(&body)?)
            }
            (Method::Get, ["api", "projects", project, "items"]) => self.store.list_items(project),
            (Method::Get, ["api", "projects", project, "board"]) => self.store.board(project),
            (Method::Get, ["api", "projects", project, "frontier"]) => {
                self.store.ready_frontier(project)
            }
            (Method::Get, ["api", "projects", project, "events"]) => {
                self.store.events(Some(project))
            }
            (Method::Post, ["api", "items"]) => {
                let body = body(request)?;
                self.store.create_item(CreateItem {
                    project: required(&body, "project")?,
                    title: required(&body, "title")?,
                    description: field(&body, "description").unwrap_or(""),
                    repo: field(&body, "repo"),
                    priority: field(&body, "priority").unwrap_or("normal"),
                    actor,
                    idempotency_key: idempotency,
                })
            }
            (Method::Get, ["api", "items", id]) => self.store.get_item(id),
            (Method::Post, ["api", "items", id, "transition"]) => {
                let body = body(request)?;
                self.store.transition(
                    id,
                    required(&body, "state")?,
                    integer(&body, "expectedRevision")?,
                    actor,
                    idempotency,
                )
            }
            (Method::Post, ["api", "items", id, "claim"]) => {
                let body = body(request)?;
                self.store.claim(
                    id,
                    actor,
                    integer(&body, "expectedRevision")?,
                    actor,
                    idempotency,
                )
            }
            (Method::Post, ["api", "items", id, "renew"]) => {
                let body = body(request)?;
                self.store.renew_lease(
                    id,
                    request
                        .header("X-Lease-Token")
                        .ok_or_else(|| Error::Forbidden("lease token required".into()))?,
                    request
                        .header("X-Lease-Generation")
                        .and_then(|value| value.parse().ok())
                        .ok_or_else(|| Error::Forbidden("lease generation required".into()))?,
                    integer(&body, "expectedRevision")?,
                    actor,
                    idempotency,
                )
            }
            (Method::Post, ["api", "items", id, "release"]) => {
                let body = body(request)?;
                self.store.release_lease(
                    id,
                    request
                        .header("X-Lease-Token")
                        .ok_or_else(|| Error::Forbidden("lease token required".into()))?,
                    request
                        .header("X-Lease-Generation")
                        .and_then(|value| value.parse().ok())
                        .ok_or_else(|| Error::Forbidden("lease generation required".into()))?,
                    integer(&body, "expectedRevision")?,
                    actor,
                    idempotency,
                )
            }
            (Method::Post, ["api", "items", id, "dependency"]) => {
                let body = body(request)?;
                self.store
                    .add_dependency(id, required(&body, "dependsOn")?, actor, idempotency)
            }
            (Method::Post, ["api", "items", id, kind @ ("comment" | "evidence")]) => {
                let body = body(request)?;
                self.store
                    .add_record(kind, id, required(&body, "body")?, actor, idempotency)
            }
            (Method::Post, ["api", "items", id, "handoff"]) => {
                let body = body(request)?;
                self.store.handoff(
                    id,
                    required(&body, "to")?,
                    required(&body, "summary")?,
                    actor,
                    idempotency,
                )
            }
            (Method::Post, ["api", "items", id, "accept-handoff"]) => {
                self.store.accept_handoff(id, actor, idempotency)
            }
            (Method::Post, ["api", "items", id, "block"]) => {
                let body = body(request)?;
                self.store.set_blocked(
                    id,
                    field(&body, "reason"),
                    integer(&body, "expectedRevision")?,
                    actor,
                    idempotency,
                )
            }
            (Method::Get, ["api", "events"]) => self.store.events(None),
            _ => Err(Error::NotFound(request.path.clone())),
        }
    }
}

pub fn origin_allowed(request: &Request, configured: Option<&str>) -> bool {
    let Some(origin) = request.header("Origin") else {
        return true;
    };
    if configured == Some(origin) {
        return true;
    }
    let Some(host) = request.header("Host") else {
        return false;
    };
    origin
        .strip_prefix("http://")
        .or_else(|| origin.strip_prefix("https://"))
        == Some(host)
}

fn body(request: &Request) -> Result<Value, Error> {
    if request.body.len() > 1_048_576 {
        return Err(Error::Invalid("request body exceeds 1 MiB".into()));
    }
    parse(&request.body_str()).map_err(|error| Error::Invalid(format!("invalid JSON: {error}")))
}

fn required<'a>(body: &'a Value, key: &str) -> Result<&'a str, Error> {
    field(body, key).ok_or_else(|| Error::Invalid(format!("{key} is required")))
}

fn integer(body: &Value, key: &str) -> Result<i64, Error> {
    body.get(key)
        .and_then(Value::as_i64)
        .ok_or_else(|| Error::Invalid(format!("{key} must be an integer")))
}

fn string_array(body: &Value, key: &str) -> Result<Vec<String>, Error> {
    match body.get(key) {
        None | Some(Value::Null) => Ok(Vec::new()),
        Some(Value::Array(values)) => values
            .iter()
            .map(|value| {
                value
                    .as_str()
                    .map(str::to_string)
                    .ok_or_else(|| Error::Invalid(format!("{key} must contain strings")))
            })
            .collect(),
        _ => Err(Error::Invalid(format!("{key} must be an array"))),
    }
}

fn query_from_value(value: &Value) -> Result<Query, Error> {
    Ok(Query {
        projects: string_array(value, "projects")?,
        repos: string_array(value, "repos")?,
        states: string_array(value, "states")?,
        priorities: string_array(value, "priorities")?,
        owners: string_array(value, "owners")?,
        types: string_array(value, "types")?,
        labels: string_array(value, "labels")?,
        parent: field(value, "parent").map(str::to_string),
        cycle: field(value, "cycle").map(str::to_string),
        text: field(value, "text").map(str::to_string),
        blocked: value.get("blocked").and_then(Value::as_bool),
        created_after: value.get("createdAfter").and_then(Value::as_i64),
        created_before: value.get("createdBefore").and_then(Value::as_i64),
        updated_after: value.get("updatedAfter").and_then(Value::as_i64),
        updated_before: value.get("updatedBefore").and_then(Value::as_i64),
        sort: field(value, "sort").unwrap_or("id").to_string(),
        descending: value
            .get("descending")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        limit: value
            .get("limit")
            .and_then(Value::as_i64)
            .unwrap_or(100)
            .try_into()
            .map_err(|_| Error::Invalid("limit must be positive".into()))?,
        byte_limit: value
            .get("byteLimit")
            .and_then(Value::as_i64)
            .unwrap_or(1_048_576)
            .try_into()
            .map_err(|_| Error::Invalid("byteLimit must be positive".into()))?,
        cursor: field(value, "cursor").map(str::to_string),
    })
}

fn cookie(request: &Request, name: &str) -> Option<String> {
    request.header("Cookie")?.split(';').find_map(|part| {
        let (key, value) = part.trim().split_once('=')?;
        (key == name && !value.is_empty()).then(|| value.to_string())
    })
}

fn redirect(location: &str, cookies: &[String]) -> Response {
    let mut response = Response::ok()
        .with_header("Location", location)
        .with_header("Cache-Control", "no-store")
        .with_header("X-Content-Type-Options", "nosniff");
    for cookie in cookies {
        response = response.with_header("Set-Cookie", cookie);
    }
    response.status = 302;
    response.reason = "Found".into();
    response
}

fn short_hash(value: &str) -> String {
    Sha256::digest(value.as_bytes())
        .iter()
        .take(16)
        .map(|byte| format!("{byte:02x}"))
        .collect()
}

fn constant_token(expected: &str, supplied: &str) -> bool {
    let left = Sha256::digest(expected.as_bytes());
    let right = Sha256::digest(supplied.as_bytes());
    left.iter()
        .zip(right.iter())
        .fold(0u8, |diff, (a, b)| diff | (a ^ b))
        == 0
}

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

fn domain_error(value: Error) -> Response {
    match value {
        Error::Forbidden(message) => error(403, &message),
        Error::Invalid(message) => error(400, &message),
        Error::NotFound(message) => error(404, &message),
        Error::Conflict(message) => error(409, &message),
        Error::Storage(message) => error(500, &message),
    }
}

fn error(status: u16, message: &str) -> Response {
    json(status, object(vec![("error", string(message))]))
}

fn json(status: u16, value: Value) -> Response {
    let mut response = Response::ok()
        .with_header("Content-Type", "application/json")
        .with_header("Cache-Control", "no-store")
        .with_header("X-Content-Type-Options", "nosniff")
        .with_body("application/json", value.to_json().into_bytes());
    response.status = status;
    response.reason = match status {
        200 => "OK",
        201 => "Created",
        302 => "Found",
        503 => "Service Unavailable",
        400 => "Bad Request",
        401 => "Unauthorized",
        403 => "Forbidden",
        404 => "Not Found",
        409 => "Conflict",
        _ => "Internal Server Error",
    }
    .into();
    response
}

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

    fn request(method: Method, path: &str, token: Option<&str>, body_value: Value) -> Request {
        let body = body_value.to_json().into_bytes();
        let mut headers = vec![("Content-Length".into(), body.len().to_string())];
        if let Some(token) = token {
            headers.push(("Authorization".into(), format!("Bearer {token}")));
            headers.push(("Idempotency-Key".into(), format!("test-{path}")));
            headers.push(("X-AkurAI-Actor".into(), "tester".into()));
        }
        Request {
            method,
            path: path.into(),
            query: None,
            version: "HTTP/1.1".into(),
            headers,
            body,
        }
    }

    #[test]
    fn authentication_and_multi_project_routes_work() {
        let path = std::env::temp_dir().join(format!("tasks-api-{}.db", std::process::id()));
        let _ = std::fs::remove_file(&path);
        let api = Api::new(Store::open(&path).unwrap(), "secret".into(), None).unwrap();
        assert_eq!(
            api.handle(&request(Method::Get, "/api/projects", None, Value::Null))
                .status,
            401
        );
        let response = api.handle(&request(
            Method::Post,
            "/api/projects",
            Some("secret"),
            object(vec![
                ("key", string("APP")),
                ("name", string("App")),
                ("repos", Value::Array(vec![string("owner/app")])),
            ]),
        ));
        assert_eq!(response.status, 201);
        assert_eq!(
            api.handle(&request(
                Method::Get,
                "/api/projects",
                Some("secret"),
                Value::Null
            ))
            .status,
            200
        );
        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn same_origin_browser_mutations_are_allowed_without_cors_configuration() {
        let mut request = request(Method::Post, "/api/items", Some("secret"), Value::Null);
        request
            .headers
            .push(("Host".into(), "127.0.0.1:8090".into()));
        request
            .headers
            .push(("Origin".into(), "http://127.0.0.1:8090".into()));
        assert!(origin_allowed(&request, None));
        request.headers.last_mut().unwrap().1 = "https://attacker.example".into();
        assert!(!origin_allowed(&request, None));
        assert!(origin_allowed(&request, Some("https://attacker.example")));
    }
}