Files
ElyOffice/crates/ely-mail-engine/tests/oauth.rs
T
ZacharyZhang-NYandClaude bd68382921 C12.4: mail credentials and OAuth sign-in
Passwords and refresh tokens go only to the system keychain; a refused
keychain keeps them in memory until exit and says why. Gmail and
Microsoft sign in by authorization code with S256 PKCE on a loopback
redirect; refresh tokens are kept, and revoked grants ask to sign in
again. Client IDs come from the build (env or oauth.env). ureq trace
records never reach the log.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 18:59:56 -04:00

144 lines
5.8 KiB
Rust

mod oauth_fake;
use std::{
thread,
time::{Duration, SystemTime},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use ely_mail_engine::{
Error,
oauth::{SignIn, refresh},
};
use oauth_fake::{browse, endpoint, id_token, provider, query, signed_in};
use sha2::{Digest, Sha256};
#[tokio::test]
async fn sign_in_exchanges_the_code_with_pkce() {
let body = format!(
r#"{{"access_token":"at-1","expires_in":3600,"refresh_token":"rt-1","id_token":"{}"}}"#,
id_token(r#"{"email":"ana@example.com"}"#)
);
let (token, forms) = endpoint(200, body);
let sign_in = SignIn::start(provider(token, Some("shh")), "ana@example.com")
.await
.unwrap();
let asked = query(sign_in.url());
assert!(sign_in.url().starts_with("https://auth.example/authorize?"));
assert_eq!(asked["client_id"], "client-1");
assert_eq!(asked["response_type"], "code");
assert_eq!(asked["code_challenge_method"], "S256");
assert_eq!(asked["login_hint"], "ana@example.com");
assert_eq!(asked["scope"], "mail openid email");
assert_eq!(asked["prompt"], "consent");
let redirect = asked["redirect_uri"].clone();
assert!(redirect.starts_with("http://127.0.0.1:"));
let state = asked["state"].clone();
let browser = thread::spawn(move || {
let ignored = browse(&redirect, "/favicon.ico");
let done = browse(&redirect, &format!("/?code=c%2B1&state={state}"));
(ignored, done, redirect)
});
let before = SystemTime::now();
let tokens = sign_in.finish().await.unwrap();
let (ignored, done, redirect) = browser.join().unwrap();
assert_eq!(ignored, "HTTP/1.1 404 Not Found");
assert_eq!(done, "HTTP/1.1 200 OK");
assert_eq!(tokens.access, "at-1");
assert_eq!(tokens.refresh.as_deref(), Some("rt-1"));
assert_eq!(tokens.email.as_deref(), Some("ana@example.com"));
let left = tokens.expires_at.duration_since(before).unwrap();
assert!(left >= Duration::from_secs(3600) && left < Duration::from_secs(3610));
let form = forms.recv().unwrap();
assert_eq!(form["grant_type"], "authorization_code");
assert_eq!(form["code"], "c+1");
assert_eq!(form["client_secret"], "shh");
assert_eq!(form["redirect_uri"], redirect);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(form["code_verifier"].as_bytes()));
assert_eq!(challenge, asked["code_challenge"]);
}
#[tokio::test]
async fn a_wrong_state_is_refused() {
let sign_in = SignIn::start(provider("http://127.0.0.1:9/token".into(), None), "")
.await
.unwrap();
let asked = query(sign_in.url());
assert!(!asked.contains_key("login_hint"));
let redirect = asked["redirect_uri"].clone();
let browser = thread::spawn(move || browse(&redirect, "/?code=x&state=forged"));
let err = sign_in.finish().await.err().unwrap();
browser.join().unwrap();
assert!(matches!(err, Error::SignIn(text) if text.contains("state")));
}
#[tokio::test]
async fn a_declined_consent_reports_the_reason() {
let sign_in = SignIn::start(provider("http://127.0.0.1:9/token".into(), None), "")
.await
.unwrap();
let asked = query(sign_in.url());
let (redirect, state) = (asked["redirect_uri"].clone(), asked["state"].clone());
let path = format!("/?error=access_denied&error_description=user+said+no&state={state}");
let browser = thread::spawn(move || browse(&redirect, &path));
let err = sign_in.finish().await.err().unwrap();
browser.join().unwrap();
assert!(matches!(err, Error::SignIn(text) if text == "access_denied: user said no"));
}
#[tokio::test]
async fn a_sign_in_without_a_refresh_token_fails() {
let body = r#"{"access_token":"at-1","expires_in":3600}"#.to_owned();
let err = signed_in(body).await.err().unwrap();
assert!(matches!(err, Error::SignIn(text) if text.contains("refresh token")));
}
#[tokio::test]
async fn the_username_stands_in_for_a_missing_email() {
let body = format!(
r#"{{"access_token":"at-1","expires_in":3600,"refresh_token":"rt-1","id_token":"{}"}}"#,
id_token(r#"{"preferred_username":"bo@outlook.com"}"#)
);
let tokens = signed_in(body).await.unwrap();
assert_eq!(tokens.email.as_deref(), Some("bo@outlook.com"));
}
#[tokio::test]
async fn refresh_rotates_the_token() {
let body = r#"{"access_token":"at-2","expires_in":60,"refresh_token":"rt-2"}"#.to_owned();
let (token, forms) = endpoint(200, body);
let tokens = refresh(&provider(token, None), "rt-1").await.unwrap();
assert_eq!(
(tokens.access.as_str(), tokens.refresh.as_deref()),
("at-2", Some("rt-2"))
);
assert_eq!(tokens.email, None);
let form = forms.recv().unwrap();
assert_eq!(form["grant_type"], "refresh_token");
assert_eq!(form["refresh_token"], "rt-1");
assert!(!form.contains_key("client_secret"));
}
#[tokio::test]
async fn a_revoked_refresh_token_needs_sign_in_again() {
for error in ["invalid_grant", "interaction_required"] {
let body = format!(r#"{{"error":"{error}","error_description":"revoked"}}"#);
let (token, _) = endpoint(400, body);
let err = refresh(&provider(token, None), "rt-1").await.err().unwrap();
assert!(
matches!(&err, Error::SignInAgain(text) if text == &format!("{error}: revoked")),
"{err}"
);
}
}
#[tokio::test]
async fn other_refusals_stay_sign_in_errors() {
let (token, _) = endpoint(401, r#"{"error":"invalid_client"}"#.to_owned());
let err = refresh(&provider(token, None), "rt-1").await.err().unwrap();
assert!(matches!(err, Error::SignIn(text) if text == "invalid_client"));
let (token, _) = endpoint(502, "<html>bad gateway</html>".to_owned());
let err = refresh(&provider(token, None), "rt-1").await.err().unwrap();
assert!(matches!(err, Error::SignIn(text) if text.contains("502")));
}