AkurAI Build
Menu

Public repository / master

AkurAI-Drive / backend/src/oidc.rs

Latest change 87af7df38e96b7f796055497f19e265e0dff2cdb - Drive logout via IdP /logout; pin akurai-log 3c855de by AkurAI Agent

//! OIDC relying party for the AkurAI IdP (authorization code + PKCE).
//!
//! Browser login flow:
//! 1. `/auth/login` mints `state`, `nonce` and a PKCE verifier, stores them
//!    server-side (`oauth_states`, single use, 600 s) and redirects to
//!    `{issuer}/authorize` with an S256 `code_challenge`.
//! 2. `/auth/callback` redeems the code at `{internal}/token` with the verifier,
//!    checks the id_token claims (iss, aud, exp, nonce), confirms the access token
//!    via `{internal}/introspect`, reads `{internal}/userinfo`, and applies the
//!    `AKURAI_IDP_ALLOWED_GROUPS` gate.
//!
//! The id_token signature is not verified locally: it arrives directly from the
//! IdP over the back channel and the paired access token is confirmed live by
//! introspection. All back-channel calls are blocking (`ureq`) — run them from
//! `spawn_blocking`.

use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::config::{self, Config};

pub const SCOPE: &str = "openid profile email groups";

/// Who signed in, after every check has passed.
#[derive(Debug, Clone, PartialEq)]
pub struct Identity {
    pub sub: String,
    pub email: String,
    pub groups: Vec<String>,
    pub tenant_id: Option<String>,
    pub organization_id: Option<String>,
    /// `admin` (member of `DRIVE_ADMIN_GROUP`) or `member`.
    pub role: String,
    /// Stored with the session (deployed schema parity).
    pub id_token: String,
}

/// Why a callback could not produce a session.
#[derive(Debug, PartialEq)]
pub enum LoginError {
    /// The identity is valid but not in an allowed group.
    Denied,
    /// Anything else (IdP unreachable, bad token, claim mismatch...).
    Failed(String),
}

/// S256 code challenge for a PKCE verifier (RFC 7636 §4.2).
pub fn code_challenge(verifier: &str) -> String {
    URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}

/// Browser-facing authorize URL on the public issuer.
pub fn authorize_url(cfg: &Config, state: &str, nonce: &str, verifier: &str) -> String {
    let query = form_urlencoded::Serializer::new(String::new())
        .append_pair("response_type", "code")
        .append_pair("client_id", &cfg.oidc_client_id)
        .append_pair("redirect_uri", &cfg.oidc_redirect_uri)
        .append_pair("scope", SCOPE)
        .append_pair("state", state)
        .append_pair("nonce", nonce)
        .append_pair("code_challenge", &code_challenge(verifier))
        .append_pair("code_challenge_method", "S256")
        .finish()
        .replace('+', "%20");
    format!("{}/authorize?{query}", cfg.idp_issuer)
}

/// IdP logout URL. The IdP's `GET /logout` ends its session and takes no
/// post-logout redirect parameters.
pub fn logout_url(cfg: &Config) -> String {
    format!("{}/logout", cfg.idp_issuer)
}

/// Decode (without verifying) the JSON payload of a compact JWT.
pub fn decode_jwt_payload(jwt: &str) -> Result<Value, String> {
    let mut parts = jwt.split('.');
    let payload = match (parts.next(), parts.next(), parts.next(), parts.next()) {
        (Some(_), Some(p), Some(_), None) => p,
        _ => return Err("id_token is not a JWT".into()),
    };
    let bytes = URL_SAFE_NO_PAD
        .decode(payload.trim_end_matches('='))
        .map_err(|e| format!("id_token payload is not base64url: {e}"))?;
    serde_json::from_slice(&bytes).map_err(|e| format!("id_token payload is not JSON: {e}"))
}

fn str_claim<'a>(claims: &'a Value, key: &str) -> Option<&'a str> {
    claims
        .get(key)
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
}

fn aud_contains(aud: Option<&Value>, client_id: &str) -> bool {
    match aud {
        Some(Value::String(s)) => s == client_id,
        Some(Value::Array(items)) => items.iter().any(|v| v.as_str() == Some(client_id)),
        _ => false,
    }
}

/// Check the id_token claims and return its `sub`.
pub fn validate_id_token(
    claims: &Value,
    issuer: &str,
    client_id: &str,
    nonce: &str,
    now: i64,
) -> Result<String, String> {
    if str_claim(claims, "iss").map(|s| s.trim_end_matches('/')) != Some(issuer) {
        return Err("id_token issuer mismatch".into());
    }
    if !aud_contains(claims.get("aud"), client_id) {
        return Err("id_token audience mismatch".into());
    }
    match claims.get("exp").and_then(Value::as_i64) {
        Some(exp) if exp > now => {}
        _ => return Err("id_token is expired".into()),
    }
    if str_claim(claims, "nonce") != Some(nonce) {
        return Err("id_token nonce mismatch".into());
    }
    str_claim(claims, "sub")
        .map(String::from)
        .ok_or_else(|| "no sub in id_token".into())
}

/// Check an introspection response for the access token belonging to `sub`.
pub fn validate_introspection(resp: &Value, sub: &str) -> Result<(), String> {
    if resp.get("active").and_then(Value::as_bool) != Some(true) {
        return Err("introspection says the access token is not active".into());
    }
    match str_claim(resp, "sub") {
        None => Err("access token omitted sub".into()),
        Some(s) if s != sub => Err("introspection sub does not match the id_token sub".into()),
        Some(_) => Ok(()),
    }
}

/// True when `allowed` is empty (no gate) or shares a group with `groups`.
pub fn group_allowed(groups: &[String], allowed: &[String]) -> bool {
    allowed.is_empty() || groups.iter().any(|g| allowed.contains(g))
}

pub fn role_for(groups: &[String], admin_group: &str) -> &'static str {
    if groups.iter().any(|g| g == admin_group) {
        "admin"
    } else {
        "member"
    }
}

fn string_list(v: Option<&Value>) -> Vec<String> {
    v.and_then(Value::as_array)
        .map(|a| {
            a.iter()
                .filter_map(|g| g.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default()
}

/// Merge userinfo into an [`Identity`] for `sub`, enforcing the group gate.
pub fn build_identity(
    cfg: &Config,
    sub: &str,
    userinfo: &Value,
    id_token: String,
) -> Result<Identity, LoginError> {
    if str_claim(userinfo, "sub") != Some(sub) {
        return Err(LoginError::Failed("userinfo subject mismatch".into()));
    }
    let groups = string_list(userinfo.get("groups"));
    if !group_allowed(&groups, &cfg.allowed_groups) {
        return Err(LoginError::Denied);
    }
    Ok(Identity {
        sub: sub.to_string(),
        email: str_claim(userinfo, "email").unwrap_or_default().to_string(),
        role: role_for(&groups, &cfg.drive_admin_group).to_string(),
        tenant_id: str_claim(userinfo, "tenant_id").map(String::from),
        organization_id: str_claim(userinfo, "organization_id")
            .or_else(|| str_claim(userinfo, "tenant_id"))
            .map(String::from),
        groups,
        id_token,
    })
}

fn basic_auth(cfg: &Config) -> String {
    let raw = format!("{}:{}", cfg.oidc_client_id, cfg.oidc_client_secret);
    format!("Basic {}", STANDARD.encode(raw))
}

fn read_json(resp: Result<ureq::Response, ureq::Error>, what: &str) -> Result<Value, String> {
    let body = resp
        .map_err(|e| format!("{what} failed: {e}"))?
        .into_string()
        .map_err(|e| format!("{what} body read failed: {e}"))?;
    serde_json::from_str(&body).map_err(|e| format!("{what} json parse failed: {e}"))
}

/// Run the whole back channel for a callback: token → id_token checks →
/// introspection → userinfo → group gate.
pub fn complete_login(code: &str, verifier: &str, nonce: &str) -> Result<Identity, LoginError> {
    let cfg = config::get();
    let failed = LoginError::Failed;

    let tokens = read_json(
        ureq::post(&format!("{}/token", cfg.idp_internal_url))
            .set("Authorization", &basic_auth(cfg))
            .send_form(&[
                ("grant_type", "authorization_code"),
                ("code", code),
                ("redirect_uri", &cfg.oidc_redirect_uri),
                ("code_verifier", verifier),
            ]),
        "token request",
    )
    .map_err(failed)?;
    let access_token = str_claim(&tokens, "access_token")
        .ok_or_else(|| failed("no access_token in token response".into()))?;
    let id_token = str_claim(&tokens, "id_token")
        .ok_or_else(|| failed("no id_token in token response".into()))?;

    let claims = decode_jwt_payload(id_token).map_err(failed)?;
    let now = chrono::Utc::now().timestamp();
    let sub = validate_id_token(&claims, &cfg.idp_issuer, &cfg.oidc_client_id, nonce, now)
        .map_err(failed)?;

    let introspection = read_json(
        ureq::post(&format!("{}/introspect", cfg.idp_internal_url))
            .set("Authorization", &basic_auth(cfg))
            .send_form(&[("token", access_token), ("token_type_hint", "access_token")]),
        "introspection",
    )
    .map_err(failed)?;
    validate_introspection(&introspection, &sub).map_err(failed)?;

    let userinfo = read_json(
        ureq::get(&format!("{}/userinfo", cfg.idp_internal_url))
            .set("Authorization", &format!("Bearer {access_token}"))
            .call(),
        "userinfo request",
    )
    .map_err(failed)?;
    build_identity(cfg, &sub, &userinfo, id_token.to_string())
}

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

    fn cfg() -> Config {
        Config {
            port: 8096,
            listen_addr: "127.0.0.1:8096".into(),
            db_path: String::new(),
            idp_internal_url: "http://127.0.0.1:3500".into(),
            idp_issuer: "https://auth.example".into(),
            oidc_client_id: "akurai-drive".into(),
            oidc_client_secret: "s".into(),
            oidc_redirect_uri: "https://drive.example/auth/callback".into(),
            allowed_groups: vec![],
            drive_public_url: "https://drive.example".into(),
            drive_access_code: String::new(),
            drive_admin_group: "drive-admins".into(),
            drive_bucket_prefix: "akurai-drive".into(),
            aws_region: "us-east-1".into(),
            aws_access_key_id: String::new(),
            aws_secret_access_key: String::new(),
            mcp_audience: "akurai-drive".into(),
            drive_installation_id: "x".into(),
        }
    }

    #[test]
    fn code_challenge_matches_rfc7636_example() {
        assert_eq!(
            code_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
            "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
        );
    }

    #[test]
    fn authorize_url_carries_pkce_nonce_and_state() {
        let url = authorize_url(&cfg(), "st", "no", "verifier");
        assert!(url.starts_with("https://auth.example/authorize?response_type=code&"));
        assert!(url.contains("client_id=akurai-drive"));
        assert!(url.contains("scope=openid%20profile%20email%20groups"));
        assert!(url.contains("state=st&nonce=no"));
        assert!(url.contains(&format!("code_challenge={}", code_challenge("verifier"))));
        assert!(url.ends_with("code_challenge_method=S256"));
    }

    #[test]
    fn logout_goes_to_idp_logout() {
        assert_eq!(logout_url(&cfg()), "https://auth.example/logout");
    }

    fn jwt(payload: &Value) -> String {
        format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
    }

    #[test]
    fn decodes_jwt_payload_and_rejects_garbage() {
        let v = decode_jwt_payload(&jwt(&json!({"sub": "u1"}))).unwrap();
        assert_eq!(v["sub"], "u1");
        assert_eq!(
            decode_jwt_payload("nope").unwrap_err(),
            "id_token is not a JWT"
        );
        assert!(
            decode_jwt_payload("a.!!.c")
                .unwrap_err()
                .contains("base64url")
        );
        let not_json = format!("a.{}.c", URL_SAFE_NO_PAD.encode("x"));
        assert!(
            decode_jwt_payload(&not_json)
                .unwrap_err()
                .contains("not JSON")
        );
    }

    fn claims() -> Value {
        json!({"iss": "https://auth.example", "aud": ["akurai-drive"], "exp": 200,
               "nonce": "n1", "sub": "u1"})
    }

    #[test]
    fn id_token_claims_are_checked() {
        let ok =
            |c: &Value| validate_id_token(c, "https://auth.example", "akurai-drive", "n1", 100);
        assert_eq!(ok(&claims()).unwrap(), "u1");

        let mut c = claims();
        c["aud"] = json!("akurai-drive");
        assert!(ok(&c).is_ok());

        for (key, val, err) in [
            ("iss", json!("https://evil"), "id_token issuer mismatch"),
            ("aud", json!("other"), "id_token audience mismatch"),
            ("exp", json!(50), "id_token is expired"),
            ("nonce", json!("n2"), "id_token nonce mismatch"),
            ("sub", json!(""), "no sub in id_token"),
        ] {
            let mut c = claims();
            c[key] = val;
            assert_eq!(ok(&c).unwrap_err(), err, "{key}");
        }
    }

    #[test]
    fn introspection_must_be_active_and_same_subject() {
        assert!(validate_introspection(&json!({"active": true, "sub": "u1"}), "u1").is_ok());
        assert_eq!(
            validate_introspection(&json!({"active": false}), "u1").unwrap_err(),
            "introspection says the access token is not active"
        );
        assert_eq!(
            validate_introspection(&json!({"active": true}), "u1").unwrap_err(),
            "access token omitted sub"
        );
        assert_eq!(
            validate_introspection(&json!({"active": true, "sub": "u2"}), "u1").unwrap_err(),
            "introspection sub does not match the id_token sub"
        );
    }

    #[test]
    fn group_gate_and_role() {
        let g = vec!["staff".to_string(), "drive-admins".to_string()];
        assert!(group_allowed(&g, &[]));
        assert!(group_allowed(&g, &["staff".into()]));
        assert!(!group_allowed(&g, &["other".into()]));
        assert_eq!(role_for(&g, "drive-admins"), "admin");
        assert_eq!(role_for(&[], "drive-admins"), "member");
    }

    #[test]
    fn builds_identity_from_userinfo() {
        let info = json!({"sub": "u1", "email": "a@b.c", "groups": ["drive-admins"],
                          "tenant_id": "t1"});
        let id = build_identity(&cfg(), "u1", &info, "tok".into()).unwrap();
        assert_eq!(id.email, "a@b.c");
        assert_eq!(id.role, "admin");
        assert_eq!(id.tenant_id.as_deref(), Some("t1"));
        assert_eq!(id.organization_id.as_deref(), Some("t1"));
        assert_eq!(id.id_token, "tok");

        assert_eq!(
            build_identity(&cfg(), "u2", &info, String::new()).unwrap_err(),
            LoginError::Failed("userinfo subject mismatch".into())
        );
        let mut gated = cfg();
        gated.allowed_groups = vec!["staff".into()];
        assert_eq!(
            build_identity(&gated, "u1", &info, String::new()).unwrap_err(),
            LoginError::Denied
        );
    }
}