#![allow(clippy::unwrap_used)] #![allow(clippy::wildcard_imports)]
mod github_tests {
use super::super::github::*;
#[test]
fn github_response_cap_constant_is_reasonable() {
const { assert!(MAX_GITHUB_RESPONSE_BYTES >= 1024 * 1024) }
const { assert!(MAX_GITHUB_RESPONSE_BYTES <= 100 * 1024 * 1024) }
}
#[test]
fn github_request_timeout_is_set() {
let secs = GITHUB_REQUEST_TIMEOUT.as_secs();
assert!(secs > 0 && secs <= 120, "GitHub timeout should be 1–120 s, got {secs}");
}
#[test]
fn github_constructs_offline_against_wellknown_endpoints() {
let p = GitHubOAuth::new(
"the-client-id".to_string(),
"the-secret".to_string(),
"https://app.example.com/auth/v1/callback".to_string(),
)
.expect("offline construction");
let url = crate::provider::OAuthProvider::authorization_url(&p, "STATE123");
assert!(
url.starts_with("https://github.com/login/oauth/authorize?"),
"authorize URL must target the well-known GitHub endpoint: {url}"
);
assert!(url.contains("client_id=the-client-id"), "{url}");
assert!(url.contains("state=STATE123"), "{url}");
assert!(
url.contains("scope=read%3Auser%20user%3Aemail"),
"the scope must request read:user + user:email so the /user/emails \
second hop is authorized: {url}"
);
}
#[test]
fn github_endpoint_overrides_are_ssrf_guarded() {
temp_env::with_vars([("FRAISEQL_OIDC_ALLOW_INSECURE", None::<&str>)], || {
let err = GitHubOAuth::with_endpoints(
"id".to_string(),
"secret".to_string(),
"https://app.example.com/cb".to_string(),
"http://169.254.169.254".to_string(),
"https://api.github.com".to_string(),
)
.expect_err("a link-local base_url must be refused");
let msg = err.to_string();
assert!(msg.contains("SSRF") || msg.contains("private"), "{msg}");
});
}
#[test]
fn github_primary_verified_email_is_linkable() {
let emails = vec![
GitHubEmail {
email: "old@example.com".to_string(),
primary: false,
verified: true,
},
GitHubEmail {
email: "me@example.com".to_string(),
primary: true,
verified: true,
},
];
assert_eq!(
select_linkable_email(&emails),
Some(("me@example.com".to_string(), true)),
"the primary verified email is the linkable identity"
);
}
#[test]
fn github_primary_unverified_email_is_never_verified() {
let emails = vec![GitHubEmail {
email: "me@example.com".to_string(),
primary: true,
verified: false,
}];
assert_eq!(
select_linkable_email(&emails),
Some(("me@example.com".to_string(), false)),
"an unverified primary email must carry verified = false"
);
}
#[test]
fn github_no_primary_email_selects_none() {
let emails = vec![GitHubEmail {
email: "side@example.com".to_string(),
primary: false,
verified: true,
}];
assert_eq!(
select_linkable_email(&emails),
None,
"without a primary entry there is no canonical identity email"
);
}
}
mod apple_tests {
use base64::Engine as _;
use crate::{
provider::{OAuthProvider as _, TokenResponse},
providers::apple::{AppleFirstAuthUser, AppleOAuth, is_private_relay_email},
};
const TEST_P8: &str = "-----BEGIN PRIVATE KEY-----\n\
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6mOJdB87DG8anytc\n\
jGCaH3eI4OkrTGRc6sZGu1DqyiGhRANCAARKwlK3b7SIes76KDfwwP1Dxf3OgSsa\n\
dTtT/3rfS3QYTqqyzOH6LW51mUpy3vxAi/IKx1oEdLAJzOCm1Z1p5wFw\n\
-----END PRIVATE KEY-----\n";
const CLIENT_ID: &str = "com.example.service";
const ISSUER: &str = "https://appleid.apple.com";
fn provider() -> AppleOAuth {
AppleOAuth::new(
CLIENT_ID.to_string(),
"TEAM123456".to_string(),
"KEY7890AB".to_string(),
TEST_P8.to_string(),
"https://app.example.com/auth/v1/callback".to_string(),
)
.expect("a valid .p8 key constructs")
}
fn decode_part(part: &str) -> serde_json::Value {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(part)
.expect("JWT part is base64url");
serde_json::from_slice(&bytes).expect("JWT part is JSON")
}
fn id_token(claims: &serde_json::Value) -> String {
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256"}"#);
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(claims).unwrap());
format!("{header}.{payload}.sig")
}
fn far_future() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600
}
#[test]
fn client_secret_is_an_es256_assertion_over_the_apple_triple() {
let assertion = provider().client_secret().expect("assertion mints");
let parts: Vec<&str> = assertion.split('.').collect();
assert_eq!(parts.len(), 3, "the client secret is a signed JWT, not a static string");
let header = decode_part(parts[0]);
assert_eq!(header["alg"], "ES256", "Apple accepts only ES256");
assert_eq!(header["kid"], "KEY7890AB", "the header must name the .p8 key");
let claims = decode_part(parts[1]);
assert_eq!(claims["iss"], "TEAM123456", "iss is the developer team ID");
assert_eq!(claims["sub"], CLIENT_ID, "sub is the services ID");
assert_eq!(
claims["aud"], "https://appleid.apple.com",
"aud names Apple itself, not the endpoint host"
);
let iat = claims["iat"].as_u64().unwrap();
let exp = claims["exp"].as_u64().unwrap();
assert!(exp > iat, "the assertion must expire after it was issued");
assert!(
exp - iat <= 15_777_000,
"Apple refuses an assertion living longer than six months"
);
}
#[test]
fn client_secret_is_reused_until_it_nears_expiry() {
let provider = provider();
let first = provider.client_secret().unwrap();
let second = provider.client_secret().unwrap();
assert_eq!(first, second, "a still-valid assertion is reused, not re-signed per request");
}
#[test]
fn a_key_that_cannot_sign_is_refused_at_construction() {
let err = AppleOAuth::new(
CLIENT_ID.to_string(),
"TEAM123456".to_string(),
"KEY7890AB".to_string(),
"-----BEGIN PRIVATE KEY-----\nnot-a-key\n-----END PRIVATE KEY-----\n".to_string(),
"https://app.example.com/auth/v1/callback".to_string(),
)
.expect_err("an unusable .p8 must not construct a provider");
assert!(
err.to_string().contains("ES256"),
"the error must name what is wrong with the key: {err}"
);
}
#[test]
fn authorization_url_requests_form_post_and_the_name_email_scopes() {
let url = provider().authorization_url("state-abc");
assert!(url.starts_with("https://appleid.apple.com/auth/authorize?"), "{url}");
assert!(
url.contains("response_mode=form_post"),
"the name/email scopes make Apple POST the callback: {url}"
);
assert!(url.contains("scope=name%20email"), "{url}");
assert!(url.contains("response_type=code"), "{url}");
assert!(url.contains("state=state-abc"), "{url}");
}
#[test]
fn id_token_yields_the_subject_and_verified_email() {
let info = provider()
.user_info_from_id_token(&id_token(&serde_json::json!({
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "001234.abcdef",
"exp": far_future(),
"email": "user@example.com",
"email_verified": true,
})))
.expect("a well-formed id_token identifies the user");
assert_eq!(info.id, "001234.abcdef");
assert_eq!(info.email.as_deref(), Some("user@example.com"));
assert!(info.email_verified);
}
#[test]
fn email_verified_is_honoured_in_apples_string_spelling() {
let info = provider()
.user_info_from_id_token(&id_token(&serde_json::json!({
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "001234.abcdef",
"exp": far_future(),
"email": "user@example.com",
"email_verified": "true",
"is_private_email": "false",
})))
.expect("the string spelling parses");
assert!(info.email_verified, "email_verified: \"true\" must be honoured");
assert_eq!(info.raw_claims["is_private_email"], serde_json::json!(false));
}
#[test]
fn an_email_with_no_verified_claim_is_unverified() {
let info = provider()
.user_info_from_id_token(&id_token(&serde_json::json!({
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "001234.abcdef",
"exp": far_future(),
"email": "user@example.com",
})))
.expect("the token parses");
assert!(
!info.email_verified,
"an address with no verification flag must not enter the email-keyed linking space"
);
}
#[test]
fn an_id_token_for_another_audience_is_refused() {
let err = provider()
.user_info_from_id_token(&id_token(&serde_json::json!({
"iss": ISSUER,
"aud": "com.someone.else",
"sub": "001234.abcdef",
"exp": far_future(),
})))
.expect_err("a token minted for a different client must not identify our user");
assert!(err.to_string().contains("audience"), "{err}");
}
#[test]
fn an_id_token_from_another_issuer_is_refused() {
let err = provider()
.user_info_from_id_token(&id_token(&serde_json::json!({
"iss": "https://evil.example.com",
"aud": CLIENT_ID,
"sub": "001234.abcdef",
"exp": far_future(),
})))
.expect_err("a token from another issuer must be refused");
assert!(err.to_string().contains("issuer"), "{err}");
}
#[test]
fn an_expired_id_token_is_refused() {
let err = provider()
.user_info_from_id_token(&id_token(&serde_json::json!({
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "001234.abcdef",
"exp": 1_000_000_u64,
})))
.expect_err("an expired token must be refused");
assert!(err.to_string().contains("expired"), "{err}");
}
#[tokio::test]
async fn user_info_by_access_token_refuses_loudly() {
let err = provider()
.user_info("any-access-token")
.await
.expect_err("there is no userinfo endpoint to call");
assert!(err.to_string().contains("user_info_from_tokens"), "{err}");
}
#[tokio::test]
async fn a_token_response_with_no_id_token_identifies_nobody() {
let err = provider()
.user_info_from_tokens(&TokenResponse {
access_token: "at".to_string(),
refresh_token: None,
expires_in: 3600,
token_type: "Bearer".to_string(),
id_token: None,
})
.await
.expect_err("without an id_token there is no identity");
assert!(err.to_string().contains("id_token"), "{err}");
}
#[test]
fn private_relay_addresses_are_recognised() {
assert!(is_private_relay_email("abc123@privaterelay.appleid.com"));
assert!(is_private_relay_email(" ABC123@PrivateRelay.AppleID.com "));
assert!(!is_private_relay_email("user@example.com"));
assert!(
!is_private_relay_email("user@privaterelay.appleid.com.evil.example"),
"the domain must match at the end, not anywhere"
);
}
#[test]
fn the_first_auth_payload_yields_a_display_name() {
let user = AppleFirstAuthUser::parse(
r#"{"name":{"firstName":"Ada","lastName":"Lovelace"},"email":"ada@example.com"}"#,
)
.expect("Apple's documented shape parses");
assert_eq!(user.display_name().as_deref(), Some("Ada Lovelace"));
}
#[test]
fn the_first_auth_payloads_email_is_not_even_modelled() {
let user = AppleFirstAuthUser::parse(r#"{"email":"victim@example.com"}"#)
.expect("an email-only payload still parses");
assert!(user.name.is_none());
assert_eq!(user.display_name(), None, "there is no path from the payload to an address");
assert!(
!format!("{user:?}").contains("victim@example.com"),
"the parsed payload must not retain the browser-supplied address anywhere: {user:?}"
);
}
#[test]
fn a_malformed_first_auth_payload_is_ignored_not_fatal() {
assert!(AppleFirstAuthUser::parse("not json").is_none());
let partial = AppleFirstAuthUser::parse(r#"{"name":{"firstName":" "}}"#).unwrap();
assert_eq!(partial.display_name(), None, "a blank name is no name");
}
}
mod discord_tests {
use crate::{
provider::OAuthProvider as _,
providers::discord::{DiscordOAuth, DiscordUser, select_linkable_email},
};
fn provider() -> DiscordOAuth {
DiscordOAuth::new(
"discord-client".to_string(),
"discord-secret".to_string(),
"https://app.example.com/auth/v1/callback".to_string(),
)
.expect("construction is network-free")
}
fn user(email: Option<&str>, verified: Option<bool>) -> DiscordUser {
DiscordUser {
id: "776655443322110099".to_string(),
username: "carol".to_string(),
global_name: Some("Carol".to_string()),
email: email.map(str::to_string),
verified,
avatar: None,
}
}
#[test]
fn authorization_url_requests_identify_and_email() {
let url = provider().authorization_url("state-abc");
assert!(url.starts_with("https://discord.com/oauth2/authorize?"), "{url}");
assert!(url.contains("scope=identify%20email"), "{url}");
assert!(url.contains("response_type=code"), "{url}");
assert!(url.contains("state=state-abc"), "{url}");
}
#[test]
fn a_verified_email_is_linkable() {
let (email, verified) = select_linkable_email(&user(Some("carol@example.com"), Some(true)));
assert_eq!(email.as_deref(), Some("carol@example.com"));
assert!(verified);
}
#[test]
fn an_unverified_email_is_reported_unverified() {
let (email, verified) =
select_linkable_email(&user(Some("carol@example.com"), Some(false)));
assert_eq!(email.as_deref(), Some("carol@example.com"));
assert!(!verified);
}
#[test]
fn an_absent_verified_flag_is_not_verified() {
let (_, verified) = select_linkable_email(&user(Some("carol@example.com"), None));
assert!(!verified, "a missing flag must fail closed, not default to trusted");
}
#[test]
fn an_absent_or_blank_email_is_no_email() {
assert_eq!(select_linkable_email(&user(None, Some(true))), (None, false));
assert_eq!(select_linkable_email(&user(Some(" "), Some(true))), (None, false));
}
#[test]
fn discord_is_trusted_for_email_verification_by_default() {
assert!(crate::TrustedEmailProviders::default().is_trusted("discord"));
}
#[test]
fn a_private_base_url_is_refused_by_the_ssrf_guard() {
temp_env::with_vars([("FRAISEQL_OIDC_ALLOW_INSECURE", None::<&str>)], || {
let err = DiscordOAuth::with_base_url(
"c".to_string(),
"s".to_string(),
"https://app.example.com/cb".to_string(),
"http://169.254.169.254".to_string(),
)
.expect_err("a link-local override must be refused");
assert!(err.to_string().contains("SSRF") || err.to_string().contains("https"), "{err}");
});
}
#[tokio::test]
async fn user_info_reports_discords_verified_flag_verbatim() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
for (reported, expected) in [(Some(true), true), (Some(false), false), (None, false)] {
let server = MockServer::start().await;
let mut body = serde_json::json!({
"id": "776655443322110099",
"username": "carol",
"email": "carol@example.com",
});
if let Some(v) = reported {
body["verified"] = serde_json::json!(v);
}
Mock::given(method("GET"))
.and(path("/api/users/@me"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let provider = temp_env::with_vars(
[
("FRAISEQL_OIDC_ALLOW_INSECURE", Some("1")),
("FRAISEQL_ENV", Some("development")),
("FRAISEQL_PROFILE", None),
("KUBERNETES_SERVICE_HOST", None),
],
|| {
DiscordOAuth::with_base_url(
"c".to_string(),
"s".to_string(),
"https://app.example.com/cb".to_string(),
server.uri(),
)
.expect("stub provider constructs")
},
);
let info = provider.user_info("discord-at").await.expect("the user object parses");
assert_eq!(info.id, "776655443322110099");
assert_eq!(info.email.as_deref(), Some("carol@example.com"));
assert_eq!(
info.email_verified, expected,
"verified: {reported:?} must surface as email_verified = {expected}"
);
assert_eq!(info.raw_claims["email_verified"], serde_json::json!(expected));
}
}
}
mod facebook_tests {
use crate::{
provider::OAuthProvider as _,
providers::facebook::{DEFAULT_API_VERSION, FacebookOAuth},
};
fn provider() -> FacebookOAuth {
FacebookOAuth::new(
"facebook-client".to_string(),
"facebook-secret".to_string(),
"https://app.example.com/auth/v1/callback".to_string(),
)
.expect("construction is network-free")
}
#[test]
fn the_api_version_is_in_the_path_and_configurable() {
let url = provider().authorization_url("state-abc");
assert!(
url.starts_with(&format!(
"https://www.facebook.com/{DEFAULT_API_VERSION}/dialog/oauth?"
)),
"{url}"
);
let pinned = FacebookOAuth::with_endpoints(
"facebook-client".to_string(),
"facebook-secret".to_string(),
"https://app.example.com/auth/v1/callback".to_string(),
"https://www.facebook.com".to_string(),
"https://graph.facebook.com".to_string(),
"v23.0".to_string(),
)
.expect("a later version constructs");
assert!(
pinned.authorization_url("s").contains("/v23.0/dialog/oauth"),
"the configured version must reach the request path"
);
}
#[test]
fn an_api_version_that_would_repoint_the_request_is_refused() {
for bad in [
"",
" ",
"v21.0/../../evil",
"v21.0?x=1",
"v21.0#f",
"v21.0\\x",
] {
FacebookOAuth::with_endpoints(
"c".to_string(),
"s".to_string(),
"https://app.example.com/cb".to_string(),
"https://www.facebook.com".to_string(),
"https://graph.facebook.com".to_string(),
bad.to_string(),
)
.expect_err(&format!("api_version {bad:?} lands in a URL path and must be refused"));
}
}
#[test]
fn facebook_is_never_trusted_for_email_verification() {
assert!(!crate::TrustedEmailProviders::default().is_trusted("facebook"));
}
#[test]
fn a_private_graph_url_is_refused_by_the_ssrf_guard() {
temp_env::with_vars([("FRAISEQL_OIDC_ALLOW_INSECURE", None::<&str>)], || {
let err = FacebookOAuth::with_endpoints(
"c".to_string(),
"s".to_string(),
"https://app.example.com/cb".to_string(),
"https://www.facebook.com".to_string(),
"http://127.0.0.1:8080".to_string(),
DEFAULT_API_VERSION.to_string(),
)
.expect_err("a loopback graph override must be refused");
assert!(err.to_string().contains("SSRF") || err.to_string().contains("https"), "{err}");
});
}
#[tokio::test]
async fn user_info_never_claims_a_facebook_email_is_verified() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/{DEFAULT_API_VERSION}/me")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "10223344556677889",
"name": "Dave",
"email": "dave@example.com",
})))
.mount(&server)
.await;
let provider = temp_env::with_vars(
[
("FRAISEQL_OIDC_ALLOW_INSECURE", Some("1")),
("FRAISEQL_ENV", Some("development")),
("FRAISEQL_PROFILE", None),
("KUBERNETES_SERVICE_HOST", None),
],
|| {
FacebookOAuth::with_endpoints(
"c".to_string(),
"s".to_string(),
"https://app.example.com/cb".to_string(),
"https://www.facebook.com".to_string(),
server.uri(),
DEFAULT_API_VERSION.to_string(),
)
.expect("stub provider constructs")
},
);
let info = provider.user_info("facebook-at").await.expect("the profile parses");
assert_eq!(info.id, "10223344556677889");
assert_eq!(info.email.as_deref(), Some("dave@example.com"));
assert!(
!info.email_verified,
"there is no signal to base a verified claim on, so it must be false"
);
assert_eq!(info.raw_claims["email_verified"], serde_json::json!(false));
}
}