AkurAI Build
Menu

Public repository / main

AkurAI-CRM / crates/crm-api/src/auth/id_token.rs

Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson

//! OIDC ID token claim checks. The token arrives directly from the IdP's
//! token endpoint over the back channel, so claims are validated
//! (OIDC Core 3.1.3.7) and the access token is confirmed via `/introspect`.

use akurai_json::Value;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;

pub struct Expected<'a> {
    pub issuer: &'a str,
    pub client_id: &'a str,
    pub nonce: &'a str,
    pub now: i64,
}

/// Validated identity from the ID token.
#[derive(Debug, PartialEq)]
pub struct Identity {
    pub sub: String,
    /// Organization membership role (`owner`/`admin`/`member`); may be empty.
    pub role: String,
}

pub fn validate(id_token: &str, expected: &Expected<'_>) -> Result<Identity, String> {
    let parts: Vec<&str> = id_token.split('.').collect();
    if parts.len() != 3 {
        return Err("id_token is not a JWT".into());
    }
    let bytes = URL_SAFE_NO_PAD
        .decode(parts[1])
        .map_err(|_| "id_token payload is not base64url".to_string())?;
    let text = String::from_utf8(bytes).map_err(|_| "id_token payload is not utf-8".to_string())?;
    let claims = akurai_json::parse(&text).map_err(|e| format!("id_token payload: {e}"))?;

    let text_claim = |name: &str| claims.get(name).and_then(Value::as_str).unwrap_or("");
    if text_claim("iss").trim_end_matches('/') != expected.issuer.trim_end_matches('/') {
        return Err("id_token issuer mismatch".into());
    }
    let audience_ok = match claims.get("aud") {
        Some(Value::Str(aud)) => aud == expected.client_id,
        Some(Value::Array(items)) => items.iter().any(|v| v.as_str() == Some(expected.client_id)),
        _ => false,
    };
    if !audience_ok {
        return Err("id_token audience mismatch".into());
    }
    match claims.get("exp").and_then(Value::as_i64) {
        Some(exp) if exp > expected.now => {}
        _ => return Err("id_token has expired".into()),
    }
    if text_claim("nonce") != expected.nonce {
        return Err("id_token nonce mismatch".into());
    }
    let sub = text_claim("sub");
    if sub.is_empty() {
        return Err("id_token has no sub".into());
    }
    Ok(Identity {
        sub: sub.to_string(),
        role: text_claim("role").to_string(),
    })
}

#[cfg(test)]
pub fn encode_for_test(claims: &str) -> String {
    format!(
        "{}.{}.sig",
        URL_SAFE_NO_PAD.encode(r#"{"alg":"EdDSA"}"#),
        URL_SAFE_NO_PAD.encode(claims)
    )
}

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

    const EXPECTED: Expected<'static> = Expected {
        issuer: "https://auth.test",
        client_id: "akurai-crm",
        nonce: "n1",
        now: 1_000,
    };

    fn token(overrides: &str) -> String {
        let base =
            r#""iss":"https://auth.test","aud":"akurai-crm","exp":2000,"nonce":"n1","sub":"u1""#;
        // Overrides come first; the JSON parser keeps the first duplicate key.
        let overrides = overrides.trim_start_matches(',');
        let sep = if overrides.is_empty() { "" } else { "," };
        encode_for_test(&format!("{{{overrides}{sep}{base}}}"))
    }

    #[test]
    fn accepts_valid_token() {
        assert_eq!(validate(&token(""), &EXPECTED).unwrap().sub, "u1");
    }

    #[test]
    fn reads_membership_role() {
        let identity = validate(&token(r#","role":"admin""#), &EXPECTED).unwrap();
        assert_eq!(identity.role, "admin");
    }

    #[test]
    fn accepts_array_audience() {
        let jwt = token(r#","aud":["other","akurai-crm"]"#);
        assert_eq!(validate(&jwt, &EXPECTED).unwrap().sub, "u1");
    }

    #[test]
    fn rejects_each_bad_claim() {
        for (override_, error) in [
            (r#","iss":"https://evil""#, "id_token issuer mismatch"),
            (r#","aud":"other""#, "id_token audience mismatch"),
            (r#","exp":999"#, "id_token has expired"),
            (r#","nonce":"n2""#, "id_token nonce mismatch"),
            (r#","sub":"""#, "id_token has no sub"),
        ] {
            assert_eq!(validate(&token(override_), &EXPECTED).unwrap_err(), error);
        }
    }

    #[test]
    fn rejects_non_jwt() {
        assert_eq!(
            validate("abc", &EXPECTED).unwrap_err(),
            "id_token is not a JWT"
        );
    }
}