Menu
akurai-tasks
publicLatest change 4c5c31f9a90fbb8370fad70f0178d5c1f401482b - fix: restart stale OIDC authorization flows 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 !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")));
}
}