AkurAI Build
Menu

Public repository / e20fdc20ba73ed38f462643569dcb30aa8cb2187

AkurAI-Drive / backend/src/idp.rs

Latest change 2a1ed73acfbc6091a9fcdba5a28f55de9e2d75a4 - feat: native email+password sign-in (no browser) by olibuijr

use base64::Engine;

use crate::config;

/// The subset of OIDC userinfo the Drive needs.
pub struct UserInfo {
    pub sub: String,
    pub email: String,
    pub groups: Vec<String>,
    pub tenant_id: Option<String>,
}

/// Back-channel: exchange the authorization `code` for an access token, then
/// fetch userinfo. Blocking (uses `ureq`) — call from `spawn_blocking`.
///
/// All traffic here is plain HTTP over localhost (`IDP_INTERNAL_URL`), so no TLS
/// is involved. The token endpoint uses HTTP Basic auth with the OIDC client
/// credentials; userinfo uses the resulting Bearer token.
pub fn exchange_code_for_userinfo(code: &str) -> Result<UserInfo, String> {
    let cfg = config::get();

    // 1. Token exchange (HTTP Basic client auth, form body).
    let basic = base64::engine::general_purpose::STANDARD
        .encode(format!("{}:{}", cfg.oidc_client_id, cfg.oidc_client_secret));
    let token_url = format!("{}/token", cfg.idp_internal_url);

    let token_body = ureq::post(&token_url)
        .set("Authorization", &format!("Basic {basic}"))
        .send_form(&[
            ("grant_type", "authorization_code"),
            ("code", code),
            ("redirect_uri", &cfg.oidc_redirect_uri),
        ])
        .map_err(|e| format!("token request failed: {e}"))?
        .into_string()
        .map_err(|e| format!("token body read failed: {e}"))?;

    let token_json: serde_json::Value =
        serde_json::from_str(&token_body).map_err(|e| format!("token json parse failed: {e}"))?;

    let access_token = token_json
        .get("access_token")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .ok_or_else(|| "no access_token in token response".to_string())?
        .to_string();

    // 2. Userinfo (Bearer auth).
    let userinfo_url = format!("{}/userinfo", cfg.idp_internal_url);
    let userinfo_body = ureq::get(&userinfo_url)
        .set("Authorization", &format!("Bearer {access_token}"))
        .call()
        .map_err(|e| format!("userinfo request failed: {e}"))?
        .into_string()
        .map_err(|e| format!("userinfo body read failed: {e}"))?;

    let info: serde_json::Value = serde_json::from_str(&userinfo_body)
        .map_err(|e| format!("userinfo json parse failed: {e}"))?;

    let sub = info
        .get("sub")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .ok_or_else(|| "no sub in userinfo".to_string())?
        .to_string();

    let email = info
        .get("email")
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .to_string();

    let groups = info
        .get("groups")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|g| g.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    let tenant_id = info
        .get("tenant_id")
        .and_then(|v| v.as_str())
        .map(String::from);

    Ok(UserInfo {
        sub,
        email,
        groups,
        tenant_id,
    })
}

/// Outcome of a headless email+password login against the IDP.
pub enum PasswordLogin {
    /// Credentials accepted and the OIDC code was exchanged into userinfo.
    Ok(UserInfo),
    /// The IDP rejected the email/password pair.
    InvalidCredentials,
    /// The account has MFA enabled — the browser flow must be used instead.
    MfaRequired,
}

/// Headless email+password login. The IDP exposes no password grant, so we drive
/// the same browser login flow it serves, but server-side over localhost:
///
/// 1. `GET /login` — read the `_csrf` form token and the `_csrf` cookie.
/// 2. `POST /login` (form-encoded, `Cookie: _csrf=…`) — success is signalled by a
///    `Set-Cookie: idp_session=…`; a redirect to `/mfa` means MFA is required;
///    anything else (the login page re-rendered with an error) is bad credentials.
/// 3. `GET /authorize` (`Cookie: idp_session=…`, redirects NOT followed) — read the
///    `code` out of the `Location` redirect to our `redirect_uri`.
/// 4. Exchange the code for userinfo via [`exchange_code_for_userinfo`].
///
/// Blocking (uses `ureq`) — call from `spawn_blocking`. All traffic is plain HTTP
/// over `IDP_INTERNAL_URL` (localhost), so there is no TLS. `ureq` keeps no cookie
/// jar, so cookies are set/read by hand. The password is never logged.
pub fn password_login(email: &str, password: &str) -> Result<PasswordLogin, String> {
    let cfg = config::get();
    // A dedicated agent that does NOT follow redirects: every step needs to read
    // the raw `Set-Cookie`/`Location` of the 3xx itself, not chase it.
    let agent = ureq::builder().redirects(0).build();
    let login_url = format!("{}/login", cfg.idp_internal_url);

    // 1. GET /login → grab the CSRF token (form field + cookie).
    let login_page = agent
        .get(&login_url)
        .call()
        .map_err(|e| format!("GET /login failed: {e}"))?;
    let login_cookies: Vec<String> = login_page
        .all("set-cookie")
        .into_iter()
        .map(String::from)
        .collect();
    let csrf_cookie = set_cookie_value(&login_cookies, "_csrf");
    let html = login_page
        .into_string()
        .map_err(|e| format!("read /login body failed: {e}"))?;
    let csrf_form = parse_csrf_input(&html);

    // The IDP renders the cookie token into the form, so the two are equal; fall
    // back to whichever we managed to read. The CSRF check is a double-submit —
    // all that matters is that the cookie and the form field we send back match.
    let csrf_for_cookie = csrf_cookie
        .clone()
        .or_else(|| csrf_form.clone())
        .ok_or_else(|| "no _csrf token on /login".to_string())?;
    let csrf_for_form = csrf_form
        .or(csrf_cookie)
        .ok_or_else(|| "no _csrf token on /login".to_string())?;

    // 2. POST /login (form-encoded) with the CSRF cookie + field.
    let submit = agent
        .post(&login_url)
        .set("Cookie", &format!("_csrf={csrf_for_cookie}"))
        .send_form(&[
            ("email", email),
            ("password", password),
            ("_csrf", &csrf_for_form),
        ]);
    let submit = match submit {
        Ok(r) => r,
        // >=400 (e.g. a 403 CSRF rejection) surfaces here — treat as a hard error.
        Err(ureq::Error::Status(code, _)) => return Err(format!("POST /login returned {code}")),
        Err(e) => return Err(format!("POST /login failed: {e}")),
    };
    let submit_cookies: Vec<String> = submit
        .all("set-cookie")
        .into_iter()
        .map(String::from)
        .collect();
    let submit_location = submit.header("location").map(String::from);

    let idp_session = match set_cookie_value(&submit_cookies, "idp_session") {
        Some(s) => s,
        None => {
            // No session cookie: either MFA (redirect to /mfa) or bad credentials
            // (the login page re-rendered, redirect back to /login).
            if submit_location.as_deref().is_some_and(is_mfa_redirect) {
                return Ok(PasswordLogin::MfaRequired);
            }
            return Ok(PasswordLogin::InvalidCredentials);
        }
    };

    // 3. GET /authorize with the session cookie; read the code from the redirect.
    let state = crate::util::token();
    let query = form_urlencoded::Serializer::new(String::new())
        .append_pair("client_id", &cfg.oidc_client_id)
        .append_pair("redirect_uri", &cfg.oidc_redirect_uri)
        .append_pair("response_type", "code")
        .append_pair("scope", "openid profile email groups")
        .append_pair("state", &state)
        .finish()
        .replace('+', "%20");
    let authorize_url = format!("{}/authorize?{query}", cfg.idp_internal_url);

    let authorized = agent
        .get(&authorize_url)
        .set("Cookie", &format!("idp_session={idp_session}"))
        .call();
    let authorized = match authorized {
        Ok(r) => r,
        Err(ureq::Error::Status(code, _)) => {
            return Err(format!("GET /authorize returned {code}"));
        }
        Err(e) => return Err(format!("GET /authorize failed: {e}")),
    };
    let location = authorized
        .header("location")
        .map(String::from)
        .ok_or_else(|| "no Location on /authorize response".to_string())?;
    let code =
        code_from_redirect(&location).ok_or_else(|| "no code in /authorize redirect".to_string())?;

    // 4. Same token + userinfo exchange the browser callback uses.
    let info = exchange_code_for_userinfo(&code)?;
    Ok(PasswordLogin::Ok(info))
}

/// Pull the `value` of the first `<input ... name="_csrf" ... value="…">` in the
/// IDP login page. The IDP renders `name` before `value` in the same tag.
fn parse_csrf_input(html: &str) -> Option<String> {
    let name_at = html.find("name=\"_csrf\"")?;
    let after = &html[name_at..];
    let val_at = after.find("value=\"")?;
    let rest = &after[val_at + "value=\"".len()..];
    let end = rest.find('"')?;
    let value = &rest[..end];
    (!value.is_empty()).then(|| value.to_string())
}

/// Read a cookie's value from a list of `Set-Cookie` header lines. Each line is
/// `name=value; Attr; Attr` — we match the leading `name=` and take everything up
/// to the first `;`. Empty values (cookie-clearing) are treated as absent.
fn set_cookie_value(set_cookies: &[String], name: &str) -> Option<String> {
    let prefix = format!("{name}=");
    for line in set_cookies {
        let pair = line.split(';').next().unwrap_or("").trim();
        if let Some(value) = pair.strip_prefix(&prefix)
            && !value.is_empty()
        {
            return Some(value.to_string());
        }
    }
    None
}

/// True if a `Location` points at the IDP's `/mfa` page (path component is `/mfa`),
/// whether it is a bare path (`/mfa?…`) or an absolute URL.
fn is_mfa_redirect(location: &str) -> bool {
    let path = location.split(['?', '#']).next().unwrap_or("");
    path == "/mfa" || path.ends_with("/mfa")
}

/// Extract the `code` query parameter from an OAuth redirect `Location`.
fn code_from_redirect(location: &str) -> Option<String> {
    let query = location.split_once('?').map(|(_, q)| q)?;
    form_urlencoded::parse(query.as_bytes())
        .find(|(k, _)| k == "code")
        .map(|(_, v)| v.into_owned())
        .filter(|c| !c.is_empty())
}

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

    #[test]
    fn parses_csrf_from_login_form() {
        // Mirrors the IDP's rendered hidden input.
        let html = r#"<form method="post" action="/login">
  <input type="hidden" name="_csrf" value="abc123DEF">
  <input type="hidden" name="return_to" value="">
  <button type="submit">Sign in</button>
</form>"#;
        assert_eq!(parse_csrf_input(html), Some("abc123DEF".to_string()));
    }

    #[test]
    fn missing_or_empty_csrf_is_none() {
        assert_eq!(parse_csrf_input("<form></form>"), None);
        assert_eq!(
            parse_csrf_input(r#"<input name="_csrf" value="">"#),
            None
        );
    }

    #[test]
    fn reads_named_cookie_value() {
        let cookies = vec![
            "_csrf=tok-xyz; Path=/; HttpOnly; SameSite=Strict; Secure".to_string(),
            "idp_session=sess-789; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=86400"
                .to_string(),
        ];
        assert_eq!(set_cookie_value(&cookies, "_csrf"), Some("tok-xyz".to_string()));
        assert_eq!(
            set_cookie_value(&cookies, "idp_session"),
            Some("sess-789".to_string())
        );
        assert_eq!(set_cookie_value(&cookies, "missing"), None);
    }

    #[test]
    fn cleared_cookie_reads_as_absent() {
        let cookies = vec!["idp_session=; Path=/; Max-Age=0".to_string()];
        assert_eq!(set_cookie_value(&cookies, "idp_session"), None);
    }

    #[test]
    fn detects_mfa_redirect() {
        assert!(is_mfa_redirect("/mfa?return_to=%2Fauthorize"));
        assert!(is_mfa_redirect("https://auth.olibuijr.com/mfa"));
        assert!(!is_mfa_redirect("/login?error=Invalid%20email%20or%20password"));
        assert!(!is_mfa_redirect(
            "https://akurai-drive.olibuijr.com/auth/callback?code=x&state=y"
        ));
    }

    #[test]
    fn extracts_code_from_redirect() {
        assert_eq!(
            code_from_redirect("https://akurai-drive.olibuijr.com/auth/callback?code=AUTHCODE&state=st"),
            Some("AUTHCODE".to_string())
        );
        assert_eq!(
            code_from_redirect("/login?error=Invalid%20email%20or%20password"),
            None
        );
        assert_eq!(code_from_redirect("https://x/cb?code=&state=y"), None);
    }
}