AkurAI Build
Menu

Public repository / 3594ca5510bcac2b26e45d9235a28905856ebf61

AkurAI-Notes / crates/notes-api/src/auth/mod.rs

Latest change 3f3c3218239131fdc60fb9196283c9a0162898a9 - E04: notes-core entities + CRUD handlers + collections schema + seed data by Claude Code

pub mod session;

use crate::router_setup::{internal_error, json_response, NotesState};
use akurai_http::{Request, Response};
use akurai_json::Value;
use std::sync::{Arc, Mutex};

/// GET /auth/login — redirect to IDP /authorize
pub fn login_route(
    state: Arc<Mutex<NotesState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    let _ = state;
    Box::new(move |_req: &Request| {
        let client_id = std::env::var("AKURAI_NOTES_IDP_CLIENT_ID")
            .unwrap_or_else(|_| "akurai-notes".into());
        let redirect_uri = std::env::var("AKURAI_NOTES_IDP_REDIRECT")
            .unwrap_or_else(|_| "https://akurai-notes.olibuijr.com/auth/callback".into());
        let issuer = std::env::var("AKURAI_NOTES_IDP_ISSUER")
            .unwrap_or_else(|_| "https://auth.olibuijr.com".into());

        let state_token = format!(
            "notes_{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos()
        );

        let location = format!(
            "{issuer}/authorize\
             ?client_id={client_id}\
             &redirect_uri={redirect_uri}\
             &response_type=code\
             &scope=openid+profile+email\
             &state={state_token}"
        );

        let cookie =
            format!("notes_oauth_state={state_token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600");

        let mut resp = Response::ok();
        resp.status = 302;
        resp.reason = "Found".to_string();
        resp = resp.with_header("Location", &location);
        resp = resp.with_header("Set-Cookie", &cookie);
        resp
    })
}

/// GET /auth/callback — exchange code, create session, redirect to app
pub fn callback_route(
    state: Arc<Mutex<NotesState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    Box::new(move |req: &Request| {
        let query = req.query.as_deref().unwrap_or("");
        let mut code = String::new();
        let mut returned_state = String::new();

        for pair in query.split('&') {
            if let Some((key, value)) = pair.split_once('=') {
                match key {
                    "code" => code = value.to_string(),
                    "state" => returned_state = value.to_string(),
                    _ => {}
                }
            }
        }

        if code.is_empty() || returned_state.is_empty() {
            return auth_redirect_error();
        }

        let _issuer = std::env::var("AKURAI_NOTES_IDP_ISSUER")
            .unwrap_or_else(|_| "https://auth.olibuijr.com".into());
        let client_id = std::env::var("AKURAI_NOTES_IDP_CLIENT_ID")
            .unwrap_or_else(|_| "akurai-notes".into());
        let client_secret = std::env::var("AKURAI_NOTES_IDP_CLIENT_SECRET")
            .unwrap_or_default();
        let redirect_uri = std::env::var("AKURAI_NOTES_IDP_REDIRECT")
            .unwrap_or_else(|_| "https://akurai-notes.olibuijr.com/auth/callback".into());

        let token_body = format!(
            "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&client_secret={}",
            urlencode(&code),
            urlencode(&redirect_uri),
            urlencode(&client_id),
            urlencode(&client_secret),
        );

        let token_response = match http_post_raw(
            &format!("{}/token", _issuer), &token_body
        ) {
            Ok(r) => r,
            Err(_) => return auth_redirect_error(),
        };

        let token_json = akurai_json::parse(&token_response)
            .map_err(|_| ())
            .and_then(|v| {
                v.get("access_token")
                    .and_then(|t| t.as_str())
                    .map(|s| s.to_string())
                    .ok_or(())
            });

        let access_token = match token_json {
            Ok(t) => t,
            Err(_) => return auth_redirect_error(),
        };

        let userinfo_response = match http_get_raw(
            &format!("{}/userinfo", _issuer), &access_token
        ) {
            Ok(r) => r,
            Err(_) => return auth_redirect_error(),
        };

        let info = match akurai_json::parse(&userinfo_response) {
            Ok(v) => v,
            Err(_) => return auth_redirect_error(),
        };

        let sub = info.get("sub").and_then(|v| v.as_str()).unwrap_or("");
        let email = info.get("email").and_then(|v| v.as_str()).unwrap_or("");

        if email.is_empty() {
            return auth_redirect_error();
        }

        let session_id = {
            let state_lock = match state.lock() {
                Ok(s) => s,
                Err(_) => return internal_error("lock poisoned"),
            };
            let mut db = match state_lock.db.lock() {
                Ok(db) => db,
                Err(_) => return internal_error("db lock poisoned"),
            };
            session::create(&mut db, sub, email)
        };

        let cookie =
            format!("notes_session={session_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400");

        let mut resp = Response::ok();
        resp.status = 302;
        resp.reason = "Found".to_string();
        resp = resp.with_header("Location", "/app");
        resp = resp.with_header("Set-Cookie", &cookie);
        resp
    })
}

/// GET /api/me — return current user info from session
pub fn me_route(
    state: Arc<Mutex<NotesState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    Box::new(move |req: &Request| {
        let session_id = session::extract_session_id(req);
        let user = match session_id {
            Some(ref sid) => {
                let state_lock = match state.lock() {
                    Ok(s) => s,
                    Err(_) => return internal_error("lock poisoned"),
                };
                let mut db = match state_lock.db.lock() {
                    Ok(db) => db,
                    Err(_) => return internal_error("db lock poisoned"),
                };
                session::get_user(&mut db, sid)
            }
            None => None,
        };

        match user {
            Some(u) => json_response(Value::Object(vec![
                ("authenticated".into(), Value::Bool(true)),
                ("sub".into(), Value::Str(u.sub)),
                ("email".into(), Value::Str(u.email)),
            ])),
            None => json_response(Value::Object(vec![
                ("authenticated".into(), Value::Bool(false)),
            ])),
        }
    })
}

/// GET /auth/logout — delete session and clear cookie
pub fn logout_route(
    state: Arc<Mutex<NotesState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    Box::new(move |req: &Request| {
        if let Some(sid) = session::extract_session_id(req) {
            if let Ok(state_lock) = state.lock() {
                if let Ok(mut db) = state_lock.db.lock() {
                    let key = format!("_session:{sid}");
                    let _ = db.delete(key.as_bytes());
                    let _ = db.commit();
                }
            }
        }

        let cookie = "notes_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0".to_string();

        let mut resp = Response::ok();
        resp.status = 302;
        resp.reason = "Found".to_string();
        resp = resp.with_header("Location", "/");
        resp = resp.with_header("Set-Cookie", &cookie);
        resp
    })
}

fn auth_redirect_error() -> Response {
    let mut resp = Response::ok();
    resp.status = 302;
    resp.reason = "Found".to_string();
    resp = resp.with_header("Location", "/?auth_error=1");
    resp
}

fn http_post_raw(url: &str, body: &str) -> Result<String, String> {
    let host_port = "127.0.0.1:3500";
    let path = url
        .strip_prefix("https://auth.olibuijr.com")
        .or_else(|| url.strip_prefix("http://127.0.0.1:3500"))
        .unwrap_or(url);

    let body_len = body.len();
    let req = format!(
        "POST {path} HTTP/1.1\r\nHost: {host_port}\r\nContent-Length: {body_len}\r\nContent-Type: application/x-www-form-urlencoded\r\nConnection: close\r\n\r\n{body}",
    );

    send_http(host_port, &req)
}

fn http_get_raw(url: &str, token: &str) -> Result<String, String> {
    let host_port = "127.0.0.1:3500";
    let path = url
        .strip_prefix("https://auth.olibuijr.com")
        .or_else(|| url.strip_prefix("http://127.0.0.1:3500"))
        .unwrap_or(url);

    let req = format!(
        "GET {path} HTTP/1.1\r\nHost: {host_port}\r\nAuthorization: Bearer {token}\r\nConnection: close\r\n\r\n",
    );

    send_http(host_port, &req)
}

fn send_http(addr: &str, request: &str) -> Result<String, String> {
    use std::io::{Read, Write};
    use std::net::TcpStream;
    use std::time::Duration;

    let stream = TcpStream::connect_timeout(
        &addr.parse().map_err(|e| format!("addr parse: {e}"))?,
        Duration::from_secs(5),
    )
    .map_err(|e| format!("connect: {e}"))?;

    stream
        .set_read_timeout(Some(Duration::from_secs(10)))
        .map_err(|e| format!("set timeout: {e}"))?;

    let mut wr = &stream;
    wr.write_all(request.as_bytes())
        .map_err(|e| format!("write: {e}"))?;

    let mut buf = Vec::new();
    let mut rd = &stream;
    rd.read_to_end(&mut buf)
        .map_err(|e| format!("read: {e}"))?;

    let response = String::from_utf8_lossy(&buf).to_string();

    let body = if let Some(pos) = response.find("\r\n\r\n") {
        response[pos + 4..].to_string()
    } else {
        String::new()
    };

    Ok(body)
}

fn urlencode(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    for &b in s.as_bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                result.push(b as char);
            }
            b' ' => result.push('+'),
            _ => result.push_str(&format!("%{:02X}", b)),
        }
    }
    result
}