#![allow(clippy::unwrap_used)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::doc_markdown)] #![allow(missing_docs)]
use std::{collections::HashMap, sync::Arc};
use axum::{
Router,
body::Body,
http::{Request, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
};
use chrono::{Duration, Utc};
use fraiseql_core::security::AuthenticatedUser;
use fraiseql_server::{
middleware::AuthUser,
routes::auth::{AuthMeState, auth_me},
};
use tower::ServiceExt;
fn make_me_state(expose_claims: &[&str]) -> Arc<AuthMeState> {
Arc::new(AuthMeState {
expose_claims: expose_claims.iter().map(|s| (*s).to_owned()).collect(),
})
}
fn make_auth_user(user_id: &str, extra_claims: HashMap<String, serde_json::Value>) -> AuthUser {
AuthUser(AuthenticatedUser {
user_id: fraiseql_core::types::UserId::new(user_id),
scopes: vec!["read".to_owned()],
expires_at: Utc::now() + Duration::hours(1),
email: None,
display_name: None,
extra_claims,
})
}
async fn inject_auth_user(request: Request<Body>, next: Next) -> Response {
next.run(request).await
}
async fn require_auth_user(request: Request<Body>, next: Next) -> Response {
if request.extensions().get::<AuthUser>().is_none() {
return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
}
next.run(request).await
}
fn auth_me_router_with_user(state: Arc<AuthMeState>, user: AuthUser) -> Router {
Router::new()
.route("/auth/me", get(auth_me))
.route_layer(middleware::from_fn(inject_auth_user))
.layer(axum::Extension(user))
.with_state(state)
}
fn auth_me_router_no_user(state: Arc<AuthMeState>) -> Router {
Router::new()
.route("/auth/me", get(auth_me))
.route_layer(middleware::from_fn(require_auth_user))
.with_state(state)
}
async fn get_response(router: &Router, uri: &str) -> (StatusCode, serde_json::Value) {
let response = router
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1024 * 64).await.unwrap();
if body.is_empty() {
return (status, serde_json::Value::Null);
}
let json: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
(status, json)
}
#[tokio::test]
async fn test_auth_me_returns_core_fields() {
let state = make_me_state(&[]);
let user = make_auth_user("user_123", HashMap::new());
let router = auth_me_router_with_user(state, user);
let (status, json) = get_response(&router, "/auth/me").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["sub"], "user_123");
assert_eq!(json["user_id"], "user_123");
assert!(json["expires_at"].is_string());
let expires_str = json["expires_at"].as_str().unwrap();
assert!(chrono::DateTime::parse_from_rfc3339(expires_str).is_ok());
}
#[tokio::test]
async fn test_auth_me_exposes_allowed_extra_claims() {
let state = make_me_state(&["email", "tenant_id"]);
let mut extra = HashMap::new();
extra.insert("email".to_owned(), serde_json::json!("alice@example.com"));
extra.insert("tenant_id".to_owned(), serde_json::json!("tenant_42"));
extra.insert("secret_internal".to_owned(), serde_json::json!("hidden"));
let user = make_auth_user("alice", extra);
let router = auth_me_router_with_user(state, user);
let (status, json) = get_response(&router, "/auth/me").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["email"], "alice@example.com");
assert_eq!(json["tenant_id"], "tenant_42");
assert!(json.get("secret_internal").is_none());
}
#[tokio::test]
async fn test_auth_me_omits_absent_claims() {
let state = make_me_state(&["email", "missing_claim"]);
let mut extra = HashMap::new();
extra.insert("email".to_owned(), serde_json::json!("bob@example.com"));
let user = make_auth_user("bob", extra);
let router = auth_me_router_with_user(state, user);
let (status, json) = get_response(&router, "/auth/me").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["email"], "bob@example.com");
assert!(json.get("missing_claim").is_none());
}
#[tokio::test]
async fn test_auth_me_returns_401_without_auth() {
let state = make_me_state(&[]);
let router = auth_me_router_no_user(state);
let (status, _) = get_response(&router, "/auth/me").await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_auth_me_empty_expose_claims() {
let state = make_me_state(&[]);
let mut extra = HashMap::new();
extra.insert("email".to_owned(), serde_json::json!("eve@example.com"));
extra.insert("role".to_owned(), serde_json::json!("admin"));
let user = make_auth_user("eve", extra);
let router = auth_me_router_with_user(state, user);
let (status, json) = get_response(&router, "/auth/me").await;
assert_eq!(status, StatusCode::OK);
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 3); assert!(obj.contains_key("sub"));
assert!(obj.contains_key("user_id"));
assert!(obj.contains_key("expires_at"));
}
#[tokio::test]
async fn test_auth_me_reads_host_cookie() {
use fraiseql_core::security::{OidcConfig, OidcValidator};
use fraiseql_server::middleware::{OidcAuthState, oidc_auth_middleware};
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());
let auth_state = OidcAuthState::new(Arc::new(validator));
let state = make_me_state(&[]);
let router = Router::new()
.route("/auth/me", get(auth_me))
.route_layer(middleware::from_fn_with_state(auth_state, oidc_auth_middleware))
.with_state(state);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/auth/me")
.header(header::COOKIE, "__Host-access_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2V5In0.eyJzdWIiOiJ0ZXN0In0.invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response_no_auth = router
.oneshot(Request::builder().uri("/auth/me").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response_no_auth.status(), StatusCode::UNAUTHORIZED);
}