#![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: Some("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,
additional_issuers: Vec::new(),
};
let validator = OidcValidator::with_jwks_uri(config, "https://192.0.2.1/jwks")
.expect("an https jwks_uri is accepted");
OidcAuthState::new(
Arc::new(validator),
fraiseql_server::middleware::TenantClaim(Arc::from("tenant_id")),
)
}
fn optional_oidc_state() -> OidcAuthState {
let config = OidcConfig {
issuer: Some("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,
additional_issuers: Vec::new(),
};
let validator = OidcValidator::with_jwks_uri(config, "https://192.0.2.1/jwks")
.expect("an https jwks_uri is accepted");
OidcAuthState::new(
Arc::new(validator),
fraiseql_server::middleware::TenantClaim(Arc::from("tenant_id")),
)
}
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"
);
}
mod service_accounts {
use std::collections::HashMap;
use axum::body::to_bytes;
use fraiseql_core::security::{AuthConfig, AuthMiddleware};
use fraiseql_server::{
extractors::OptionalSecurityContext,
middleware::{Hs256AuthState, TenantClaim, hs256_auth_middleware},
service_account::{ServiceAccountAuthenticator, ServiceAccountConfig},
};
use super::*;
const SECRET: &str = "s3cr3t-reconciler";
fn accounts() -> Option<Arc<ServiceAccountAuthenticator>> {
let config = HashMap::from([(
"reconciler".to_string(),
ServiceAccountConfig {
secret_env: "SA_RECONCILER".to_string(),
roles: vec!["ledger:write".to_string()],
scopes: vec![],
tenant: None,
static_enriched: HashMap::new(),
},
)]);
ServiceAccountAuthenticator::from_config(&config, |_| Some(SECRET.to_string()))
}
async fn principal(OptionalSecurityContext(context): OptionalSecurityContext) -> String {
context.map_or_else(|| "anonymous".to_string(), |context| context.user_id.0)
}
fn oidc_router() -> Router {
Router::new()
.route("/data", get(principal))
.route_layer(middleware::from_fn_with_state(
required_oidc_state().with_service_accounts(accounts()),
oidc_auth_middleware,
))
}
fn hs256_router() -> Router {
let state = Hs256AuthState::new(
Arc::new(AuthMiddleware::from_config(AuthConfig::with_hs256(
"hs256-signing-key-of-at-least-32-bytes",
))),
"fraiseql".to_string(),
TenantClaim(Arc::from("tenant_id")),
)
.with_service_accounts(accounts());
Router::new()
.route("/data", get(principal))
.route_layer(middleware::from_fn_with_state(state, hs256_auth_middleware))
}
async fn call(router: Router, headers: &[(&str, &str)]) -> (StatusCode, String) {
let mut request = Request::builder().uri("/data");
for (name, value) in headers {
request = request.header(*name, *value);
}
let response = router.oneshot(request.body(Body::empty()).unwrap()).await.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, String::from_utf8_lossy(&body).into_owned())
}
#[tokio::test]
async fn a_service_account_key_is_admitted_under_required_oidc() {
let (status, body) = call(oidc_router(), &[("x-api-key", SECRET)]).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body, "service_account:reconciler");
}
#[tokio::test]
async fn a_bearer_plus_service_account_key_is_refused_as_ambiguous() {
let (status, body) =
call(oidc_router(), &[("authorization", "Bearer not-a-jwt"), ("x-api-key", SECRET)])
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(body.contains("Ambiguous credentials"), "{body}");
}
#[tokio::test]
async fn no_credentials_are_refused_under_required_oidc() {
let (status, _) = call(oidc_router(), &[]).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn an_unknown_service_account_key_is_refused_under_required_oidc() {
let (status, _) = call(oidc_router(), &[("x-api-key", "not-the-secret")]).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn a_service_account_key_is_served_as_itself_under_hs256() {
let (status, body) = call(hs256_router(), &[("x-api-key", SECRET)]).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body, "service_account:reconciler");
}
#[tokio::test]
async fn a_bearer_plus_service_account_key_is_refused_as_ambiguous_under_hs256() {
let (status, body) =
call(hs256_router(), &[("authorization", "Bearer not-a-jwt"), ("x-api-key", SECRET)])
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(body.contains("Ambiguous credentials"), "{body}");
}
}