#![allow(clippy::unwrap_used)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_sign_loss)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_possible_wrap)] #![allow(clippy::cast_lossless)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)] #![allow(clippy::items_after_statements)] #![allow(clippy::used_underscore_binding)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::match_same_arms)] #![allow(clippy::branches_sharing_code)] #![allow(clippy::undocumented_unsafe_blocks)]
use std::sync::Arc;
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
middleware,
routing::get,
};
use fraiseql_core::security::{OidcConfig, OidcValidator};
use fraiseql_server::middleware::{OidcAuthState, oidc_auth_middleware};
use tower::ServiceExt;
fn required_oidc_state() -> OidcAuthState {
let config = OidcConfig {
issuer: "https://test.fraiseql.dev".to_string(),
audience: Some("https://api.test.fraiseql.dev".to_string()),
required: true, additional_audiences: vec![],
jwks_cache_ttl_secs: 3600,
allowed_algorithms: vec!["RS256".to_string()],
clock_skew_secs: 60,
jwks_uri: None,
scope_claim: "scope".to_string(),
require_jti: false,
me: None,
};
let validator = OidcValidator::with_jwks_uri(config, "https://192.0.2.1/jwks".to_string());
OidcAuthState::new(Arc::new(validator))
}
fn optional_oidc_state() -> OidcAuthState {
let config = OidcConfig {
issuer: "https://test.fraiseql.dev".to_string(),
audience: Some("https://api.test.fraiseql.dev".to_string()),
required: false, additional_audiences: vec![],
jwks_cache_ttl_secs: 3600,
allowed_algorithms: vec!["RS256".to_string()],
clock_skew_secs: 60,
jwks_uri: None,
scope_claim: "scope".to_string(),
require_jti: false,
me: None,
};
let validator = OidcValidator::with_jwks_uri(config, "https://192.0.2.1/jwks".to_string());
OidcAuthState::new(Arc::new(validator))
}
async fn dummy_graphql_handler() -> StatusCode {
StatusCode::OK
}
fn graphql_router_with_required_auth() -> Router {
let oidc_state = required_oidc_state();
Router::new()
.route("/graphql", get(dummy_graphql_handler))
.route_layer(middleware::from_fn_with_state(oidc_state, oidc_auth_middleware))
}
fn graphql_router_with_optional_auth() -> Router {
let oidc_state = optional_oidc_state();
Router::new()
.route("/graphql", get(dummy_graphql_handler))
.route_layer(middleware::from_fn_with_state(oidc_state, oidc_auth_middleware))
}
#[tokio::test]
async fn get_graphql_without_auth_returns_401_when_auth_required() {
let router = graphql_router_with_required_auth();
let response = router
.oneshot(Request::builder().uri("/graphql").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"E1 regression: GET /graphql must return 401 when auth is required and no token is provided"
);
assert!(
response.headers().contains_key("www-authenticate"),
"E1 regression: 401 response must include WWW-Authenticate header"
);
}
#[tokio::test]
async fn get_graphql_with_malformed_auth_header_returns_401() {
let router = graphql_router_with_required_auth();
let response = router
.oneshot(
Request::builder()
.uri("/graphql")
.header("Authorization", "Basic dXNlcjpwYXNz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"E1 regression: non-Bearer Authorization header must return 401"
);
}
#[tokio::test]
async fn get_graphql_without_auth_passes_when_auth_is_optional() {
let router = graphql_router_with_optional_auth();
let response = router
.oneshot(Request::builder().uri("/graphql").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"When auth is optional, unauthenticated GET /graphql must be allowed"
);
}