Menu
Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb
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"
);
}
}