Menu
Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb
AkurAI-CRM / crates/crm-api/src/auth/tests.rs
Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
/// Minimal IdP double: answers by request path, records request heads.
struct FakeIdp {
url: String,
requests: Arc<Mutex<Vec<String>>>,
}
fn fake_idp(routes: Vec<(&'static str, u16, String)>) -> FakeIdp {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let requests = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&requests);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let mut buf = [0_u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let path = request.split(' ').nth(1).unwrap_or("").to_string();
seen.lock().unwrap().push(request);
let (status, body) = routes
.iter()
.find(|(p, _, _)| *p == path)
.map(|(_, s, b)| (*s, b.clone()))
.unwrap_or((404, String::new()));
let _ = write!(
stream,
"HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
}
});
FakeIdp { url, requests }
}
const NOW: i64 = 1_000;
fn id_token(nonce: &str) -> String {
id_token::encode_for_test(&format!(
r#"{{"iss":"https://auth.test","aud":"akurai-crm","exp":2000,"nonce":"{nonce}","sub":"u1","role":"admin"}}"#
))
}
fn happy_routes(nonce: &str, groups: &str) -> Vec<(&'static str, u16, String)> {
vec![
(
"/token",
200,
format!(
r#"{{"access_token":"at-1","id_token":"{}","token_type":"Bearer"}}"#,
id_token(nonce)
),
),
("/introspect", 200, r#"{"active":true,"sub":"u1"}"#.into()),
(
"/userinfo",
200,
format!(r#"{{"sub":"u1","email":"u@x","tenant_id":"org-1","groups":{groups}}}"#),
),
]
}
fn config(url: &str, allowed: Option<&str>) -> Config {
Config::from_settings(idp_client::Settings {
allowed_groups: allowed.map(str::to_string),
..idp_client::test_settings(url)
})
.unwrap()
}
fn request(target: &str, cookie: &str) -> Request {
Request::parse_head(format!("GET {target} HTTP/1.1\r\nCookie: {cookie}\r\n\r\n").as_bytes())
.unwrap()
}
fn header<'a>(response: &'a Response, name: &str) -> Vec<&'a str> {
response
.headers
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
.collect()
}
fn param<'a>(url: &'a str, name: &str) -> &'a str {
url.split(['?', '&'])
.find_map(|pair| pair.strip_prefix(&format!("{name}=")))
.unwrap()
}
/// Start a login and return (state, nonce).
fn start(config: &Config, db: &mut BTree, return_to: Option<&str>) -> (String, String) {
let response = begin_login(config, db, return_to, NOW).unwrap();
let location = header(&response, "Location")[0].to_string();
let state = param(&location, "state").to_string();
let nonce = param(&location, "nonce").to_string();
(state, nonce)
}
#[test]
fn login_redirects_with_pkce_and_sets_state_cookie() {
let config = config("http://127.0.0.1:1", None);
let mut db = test_db("auth-begin");
let response = begin_login(&config, &mut db, Some("/people.html"), NOW).unwrap();
assert_eq!(response.status, 302);
let location = header(&response, "Location")[0];
assert!(location.starts_with("https://auth.test/authorize?"));
assert!(location.ends_with("&code_challenge_method=S256"));
let state = param(location, "state");
let stored = login_state::take(&mut db, state, NOW).unwrap();
assert_eq!(stored.return_to, "/people.html");
assert_eq!(
param(location, "code_challenge"),
pkce::s256_challenge(&stored.code_verifier)
);
assert_eq!(
header(&response, "Set-Cookie"),
vec![format!(
"akurai_oauth_state={state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600"
)]
);
}
#[test]
fn callback_creates_session_and_returns_to_saved_path() {
let mut db = test_db("auth-happy");
let placeholder = config("http://127.0.0.1:1", None);
let (state, nonce) = start(&placeholder, &mut db, Some("/people.html"));
let idp = fake_idp(happy_routes(&nonce, r#"["crm"]"#));
let config = config(&idp.url, Some("crm,admins"));
let req = request(
&format!("/auth/callback?code=c1&state={state}"),
&format!("akurai_oauth_state={state}"),
);
let logged_in = complete_login(&config, &mut db, &req, NOW).unwrap();
assert_eq!(logged_in.return_to, "/people.html");
let user = session::get_user(&mut db, &logged_in.session_id).unwrap();
assert_eq!(
(user.sub.as_str(), user.tenant_id.as_str()),
("u1", "org-1")
);
let requests = idp.requests.lock().unwrap();
let token = requests
.iter()
.find(|r| r.starts_with("POST /token"))
.unwrap();
assert!(token.contains("Authorization: Basic "));
assert!(token.contains("&code_verifier="));
let introspect = requests
.iter()
.find(|r| r.starts_with("POST /introspect"))
.unwrap();
assert!(introspect.ends_with("token=at-1&token_type_hint=access_token"));
assert!(requests
.iter()
.any(|r| r.starts_with("GET /userinfo") && r.contains("Bearer at-1")));
}
#[test]
fn callback_rejects_users_outside_allowed_groups() {
let mut db = test_db("auth-groups");
let (state, nonce) = start(&config("http://127.0.0.1:1", None), &mut db, None);
let idp = fake_idp(happy_routes(&nonce, r#"["other"]"#));
let req = request(
&format!("/auth/callback?code=c1&state={state}"),
&format!("akurai_oauth_state={state}"),
);
let error = complete_login(&config(&idp.url, Some("crm")), &mut db, &req, NOW).unwrap_err();
assert_eq!(error, LoginError::new(403, idp_client::GROUP_DENIED));
}
#[test]
fn callback_rejects_inactive_access_token() {
let mut db = test_db("auth-inactive");
let (state, nonce) = start(&config("http://127.0.0.1:1", None), &mut db, None);
let mut routes = happy_routes(&nonce, "[]");
routes[1].2 = r#"{"active":false}"#.into();
let idp = fake_idp(routes);
let req = request(
&format!("/auth/callback?code=c1&state={state}"),
&format!("akurai_oauth_state={state}"),
);
let error = complete_login(&config(&idp.url, None), &mut db, &req, NOW).unwrap_err();
assert_eq!(error.message, "access token is not active for this subject");
}
#[test]
fn callback_state_checks() {
let config = config("http://127.0.0.1:1", None);
let mut db = test_db("auth-state");
let (state, _) = start(&config, &mut db, None);
let cases = [
("/auth/callback?code=c", "", "invalid_login_response"),
(
&*format!("/auth/callback?code=c&state={state}"),
"x=1",
"state cookie is missing",
),
(
&*format!("/auth/callback?code=c&state={state}"),
"akurai_oauth_state=other",
"state does not match the state cookie",
),
(
"/auth/callback?code=c&state=unknown",
"akurai_oauth_state=unknown",
"state is unknown, expired, or already used",
),
(
&*format!("/auth/callback?state={state}"),
&*format!("akurai_oauth_state={state}"),
"code is missing",
),
];
for (target, cookie, message) in cases {
let error = complete_login(&config, &mut db, &request(target, cookie), NOW).unwrap_err();
assert_eq!(
(error.status, error.message.as_str()),
(400, message),
"{target}"
);
}
}
#[test]
fn logout_deletes_session_clears_cookie_and_goes_to_idp_logout() {
let config = config("http://127.0.0.1:1", None);
let mut db = test_db("auth-logout");
let sid = session::create(&mut db, "u1", "e", "t").unwrap();
let response = logout(
Some(&config),
&mut db,
&request("/auth/logout", &format!("akurai_session={sid}")),
);
assert_eq!(response.status, 302);
assert_eq!(
header(&response, "Location"),
vec!["https://auth.test/logout"]
);
assert_eq!(
header(&response, "Set-Cookie"),
vec![session::clear_cookie()]
);
assert!(session::get_user(&mut db, &sid).is_none());
}
#[test]
fn logout_without_sso_config_goes_home() {
let mut db = test_db("auth-logout-nosso");
let response = logout(None, &mut db, &request("/auth/logout", "x=1"));
assert_eq!(header(&response, "Location"), vec!["/"]);
}