fraiseql-server 2.3.0

HTTP server for FraiseQL v2 GraphQL engine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Pipeline 6 — composed HTTP integration test: PKCE `auth_start` → `auth_callback`.
//!
//! Drives the complete PKCE flow at the HTTP level against a real Axum router
//! built from the production `auth_start` and `auth_callback` handlers.  No
//! real OIDC `IdP` is involved — the test verifies the middleware layers
//! (state creation, state consumption, replay prevention) that are NOT covered
//! by the `PkceStateStore` unit tests alone.
//!
//! # What is NOT tested here
//! - Real OIDC token exchange (requires a live `IdP`)
//! - Encrypted PKCE state (requires `state_encryption` feature config)
//! - Redis-backed PKCE store (see the `#[ignore]` variant in the redis integration variant)
//!
//! # Why this test exists
//! Each stage of Pipeline 6 has unit tests, but no single test wires
//! `auth_start` → encrypted outbound token → `auth_callback` → state consumed
//! through the real HTTP router.  This composed test catches integration bugs
//! that per-stage unit tests cannot see.
//!
//! **Execution engine:** none
//! **Infrastructure:** none
//! **Parallelism:** safe
#![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
#![allow(clippy::cast_precision_loss)] // Reason: test metrics use usize/u64→f64 for reporting
#![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
#![allow(clippy::cast_possible_truncation)] // Reason: test data values are small and bounded
#![allow(clippy::cast_possible_wrap)] // Reason: test data values are small and bounded
#![allow(clippy::cast_lossless)] // Reason: test code readability
#![allow(clippy::missing_panics_doc)] // Reason: test helper functions, panics are expected
#![allow(clippy::missing_errors_doc)] // Reason: test helper functions
#![allow(missing_docs)] // Reason: test code does not require documentation
#![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
#![allow(clippy::used_underscore_binding)] // Reason: test variables prefixed with _ by convention
#![allow(clippy::needless_pass_by_value)] // Reason: test helper signatures follow test patterns

use std::sync::Arc;

use axum::{Router, body::Body, routing::get};
use fraiseql_auth::OidcServerClient;
use fraiseql_server::{
    auth::PkceStateStore,
    routes::{AuthPkceState, auth_callback, auth_start},
};
use http::{Request, StatusCode};
use tower::ServiceExt;
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{method, path},
};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Build a minimal `OidcServerClient` whose `authorization_url()` redirects to
/// `https://auth.example.com/authorize`.  The token endpoint is set to a
/// non-routable address so that the code exchange step will fail with a 502
/// rather than a 400 (state error) or 500 (internal error).
fn test_oidc_client() -> OidcServerClient {
    OidcServerClient::new(
        "test-client",
        "test-secret",
        "http://localhost/auth/callback",
        "https://auth.example.com/authorize",
        "https://192.0.2.1/token", // non-routable: exchange will fail with 502
    )
}

/// Build an Axum router that mounts only the PKCE auth routes.
///
/// Uses an in-memory `PkceStateStore` with a 300-second TTL and no encryption,
/// which is valid for single-process tests.
fn auth_router() -> Router {
    let pkce_store = PkceStateStore::new(300, None);
    let oidc_client = test_oidc_client();

    let state = Arc::new(AuthPkceState {
        pkce_store:              Arc::new(pkce_store),
        oidc_client:             Arc::new(oidc_client),
        http_client:             Arc::new(reqwest::Client::new()),
        post_login_redirect_uri: None,
    });

    Router::new()
        .route("/auth/start", get(auth_start))
        .route("/auth/callback", get(auth_callback))
        .with_state(state)
}

/// Send a GET request to the given router and return the status + location
/// header (if any).
async fn get_request(router: &Router, uri: &str) -> (StatusCode, Option<String>) {
    let response = router
        .clone()
        .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
        .await
        .unwrap();

    let status = response.status();
    let location = response
        .headers()
        .get("location")
        .and_then(|v| v.to_str().ok())
        .map(str::to_owned);

    (status, location)
}

/// Extract the `state=` query parameter from a URL string.
///
/// Returns the raw (URL-encoded) value so the test can pass it back in
/// the callback URL without double-encoding.
fn extract_state_param(url: &str) -> &str {
    // Find "state=" and take everything up to the next "&" or end.
    let start = url
        .find("state=")
        .map(|pos| pos + "state=".len())
        .expect("redirect URL must contain state= parameter");

    let end = url[start..].find('&').map_or(url.len(), |rel| start + rel);

    &url[start..end]
}

// ---------------------------------------------------------------------------
// Composed auth_start → auth_callback HTTP tests
// ---------------------------------------------------------------------------

/// Pipeline 6, Stage A: `GET /auth/start` must redirect to the configured `IdP`.
///
/// Verifies:
/// - Response status is 303 (Axum `Redirect::to()` always uses See Other).
/// - `Location` header points to the configured `authorization_endpoint`.
/// - `code_challenge` parameter is present (PKCE S256).
/// - `state` parameter is present (encrypted or plain opaque token).
#[tokio::test]
async fn auth_start_redirects_to_idp() {
    let router = auth_router();

    let (status, location) =
        get_request(&router, "/auth/start?redirect_uri=https://app.example.com/after-login").await;

    // Axum's Redirect::to() returns 303 See Other.
    assert_eq!(status, StatusCode::SEE_OTHER, "auth_start must redirect (303)");

    let loc = location.expect("auth_start must set Location header");
    assert!(
        loc.contains("auth.example.com"),
        "redirect must point to the configured IdP: {loc}"
    );
    assert!(
        loc.contains("code_challenge"),
        "redirect must include PKCE code_challenge: {loc}"
    );
    assert!(loc.contains("state="), "redirect must include opaque state token: {loc}");
}

/// Pipeline 6, Stages A+B+C: full flow `auth_start` → `auth_callback`.
///
/// Step 1 — `auth_start` creates state and redirects.
/// Step 2 — `auth_callback` with the correct state token is able to *consume*
///           that state (state lookup succeeds), then fails at the token
///           exchange step (502 from the non-routable token endpoint).
///
/// The key assertion is that the callback returns something OTHER than 400
/// (which would indicate state-not-found or state-expired), proving that the
/// state was created in Step 1 and consumed in Step 2.
///
/// Step 3 — A second `auth_callback` with the SAME state token must return 400,
///           proving that the state was consumed atomically (replay prevention).
#[tokio::test]
async fn auth_start_then_callback_completes_pkce_flow() {
    let router = auth_router();

    // ── Step 1: auth_start ────────────────────────────────────────────────
    let (status, location) =
        get_request(&router, "/auth/start?redirect_uri=https://app.example.com/after-login").await;

    assert_eq!(status, StatusCode::SEE_OTHER, "auth_start must redirect (303)");
    let loc = location.expect("auth_start must provide Location header");

    let state_token = extract_state_param(&loc);
    assert!(!state_token.is_empty(), "state token must not be empty");

    // ── Step 2: auth_callback — state consumed, exchange fails ────────────
    // The state token is valid; the token exchange fails because the endpoint
    // is non-routable (192.0.2.1).  That should produce 502, not 400.
    let callback_uri = format!("/auth/callback?code=fake_code&state={state_token}");
    let (callback_status, _) = get_request(&router, &callback_uri).await;

    // The token exchange fails (non-routable IP) → 502.
    // What matters: it must NOT be 400, which would indicate state was not found.
    assert_ne!(
        callback_status,
        StatusCode::BAD_REQUEST,
        "state token must be valid — failure must come from IdP exchange \
         (502), not state lookup (400). Got: {callback_status}"
    );

    // ── Step 3: replay — state already consumed ───────────────────────────
    let (replay_status, _) = get_request(&router, &callback_uri).await;

    assert_eq!(
        replay_status,
        StatusCode::BAD_REQUEST,
        "second use of the same state token must be rejected (state consumed)"
    );
}

/// Pipeline 6 error path: `auth_start` without `redirect_uri` must return 400.
#[tokio::test]
async fn auth_start_missing_redirect_uri_returns_400() {
    let router = auth_router();
    let (status, _) = get_request(&router, "/auth/start").await;
    assert_eq!(status, StatusCode::BAD_REQUEST, "missing redirect_uri must return 400");
}

/// Pipeline 6 error path: `auth_callback` with unknown state token must return 400.
#[tokio::test]
async fn auth_callback_unknown_state_returns_400() {
    let router = auth_router();
    let (status, _) =
        get_request(&router, "/auth/callback?code=any_code&state=unknown-state-token").await;
    assert_eq!(status, StatusCode::BAD_REQUEST, "unknown state token must return 400");
}

/// Pipeline 6 error path: `auth_callback` with a provider error parameter
/// must return 400.
#[tokio::test]
async fn auth_callback_provider_error_returns_400() {
    let router = auth_router();
    let (status, _) = get_request(&router, "/auth/callback?error=access_denied").await;
    assert_eq!(status, StatusCode::BAD_REQUEST, "provider error must return 400");
}

/// Pipeline 6 error path: `auth_callback` with a missing code and missing state
/// must return 400.
#[tokio::test]
async fn auth_callback_missing_code_and_state_returns_400() {
    let router = auth_router();
    let (status, _) = get_request(&router, "/auth/callback").await;
    assert_eq!(status, StatusCode::BAD_REQUEST, "callback with no params must return 400");
}

// ---------------------------------------------------------------------------
// C15: Session cookie mode (__Host-access_token Set-Cookie)
// ---------------------------------------------------------------------------

/// Build a router where:
/// - The OIDC token endpoint is a `wiremock` mock returning a valid token.
/// - `post_login_redirect_uri` is set, so the callback uses cookie mode.
async fn session_cookie_router(mock_server: &MockServer) -> Router {
    // Mount a mock that replies to POST /token with a valid token response.
    Mock::given(method("POST"))
        .and(path("/token"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "access_token": "test-access-token-xyz",
            "id_token": "test-id-token",
            "expires_in": 3600,
            "token_type": "Bearer"
        })))
        .mount(mock_server)
        .await;

    let oidc_client = OidcServerClient::new(
        "test-client",
        "test-secret",
        "http://localhost/auth/callback",
        "https://auth.example.com/authorize",
        format!("{}/token", mock_server.uri()),
    );

    let pkce_store = PkceStateStore::new(300, None);

    let state = Arc::new(AuthPkceState {
        pkce_store:              Arc::new(pkce_store),
        oidc_client:             Arc::new(oidc_client),
        http_client:             Arc::new(reqwest::Client::new()),
        post_login_redirect_uri: Some("https://app.example.com/dashboard".to_string()),
    });

    Router::new()
        .route("/auth/start", get(auth_start))
        .route("/auth/callback", get(auth_callback))
        .with_state(state)
}

/// C15: When `post_login_redirect_uri` is configured the callback must:
/// 1. Return a 302/303 redirect to the configured URI (NOT the caller's `redirect_uri`).
/// 2. Set a `__Host-access_token` cookie with `HttpOnly`, `Secure`, `SameSite=Strict`.
/// 3. Include the access token in the cookie value.
#[tokio::test]
async fn auth_callback_session_cookie_mode() {
    let mock_server = MockServer::start().await;
    let router = session_cookie_router(&mock_server).await;

    // ── Step 1: auth_start → redirect to IdP ─────────────────────────────
    let (status, location) =
        get_request(&router, "/auth/start?redirect_uri=https://app.example.com/after-login").await;
    assert_eq!(status, StatusCode::SEE_OTHER, "auth_start must redirect (303)");
    let loc = location.expect("auth_start must set Location header");
    let state_token = extract_state_param(&loc);
    assert!(!state_token.is_empty(), "state token must not be empty");

    // ── Step 2: auth_callback → cookie redirect ──────────────────────────
    let callback_uri = format!("/auth/callback?code=valid_code&state={state_token}");
    let response = router
        .clone()
        .oneshot(Request::builder().uri(&callback_uri).body(Body::empty()).unwrap())
        .await
        .unwrap();

    let status = response.status();
    // Axum's Redirect::to() returns 303 See Other.
    assert!(
        status == StatusCode::SEE_OTHER || status == StatusCode::FOUND,
        "session cookie mode must redirect, got: {status}"
    );

    // ── Verify redirect target ───────────────────────────────────────────
    let redirect_location = response
        .headers()
        .get("location")
        .and_then(|v| v.to_str().ok())
        .expect("redirect response must have Location header");
    assert_eq!(
        redirect_location, "https://app.example.com/dashboard",
        "redirect must point to post_login_redirect_uri, not the caller's redirect_uri"
    );

    // ── Verify Set-Cookie header ─────────────────────────────────────────
    let set_cookie = response
        .headers()
        .get("set-cookie")
        .and_then(|v| v.to_str().ok())
        .expect("session cookie mode must set a Set-Cookie header");

    // Cookie name must use the __Host- prefix.
    assert!(
        set_cookie.starts_with("__Host-access_token="),
        "cookie must use __Host-access_token prefix, got: {set_cookie}"
    );

    // Cookie must contain the access token.
    assert!(
        set_cookie.contains("test-access-token-xyz"),
        "cookie must contain the access token, got: {set_cookie}"
    );

    // HttpOnly attribute prevents JavaScript access.
    assert!(
        set_cookie.contains("HttpOnly"),
        "cookie must have HttpOnly attribute, got: {set_cookie}"
    );

    // Secure attribute (required by __Host- prefix).
    assert!(
        set_cookie.contains("Secure"),
        "cookie must have Secure attribute, got: {set_cookie}"
    );

    // SameSite=Strict prevents CSRF via cross-origin requests.
    assert!(
        set_cookie.contains("SameSite=Strict"),
        "cookie must have SameSite=Strict, got: {set_cookie}"
    );

    // Path=/ is required by the __Host- cookie prefix.
    assert!(set_cookie.contains("Path=/"), "cookie must have Path=/, got: {set_cookie}");

    // Max-Age must match the token's expires_in (3600).
    assert!(
        set_cookie.contains("Max-Age=3600"),
        "cookie Max-Age must match token expires_in (3600), got: {set_cookie}"
    );
}

// ---------------------------------------------------------------------------
// Redis PkceStateStore variant (requires REDIS_TEST_URL)
// ---------------------------------------------------------------------------

/// Same flow as the in-memory tests but using the Redis-backed PKCE state store.
///
/// Skipped unless `REDIS_TEST_URL` is set in the environment.
#[cfg(feature = "redis-pkce")]
#[tokio::test]
#[ignore = "requires REDIS_TEST_URL"]
async fn auth_pkce_flow_with_redis_store() {
    let redis_url =
        std::env::var("REDIS_TEST_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());

    let pkce_store = PkceStateStore::new_redis(&redis_url, 300, None)
        .await
        .expect("Redis PKCE store must connect");

    let oidc_client = test_oidc_client();
    let state = Arc::new(AuthPkceState {
        pkce_store:              Arc::new(pkce_store),
        oidc_client:             Arc::new(oidc_client),
        http_client:             Arc::new(reqwest::Client::new()),
        post_login_redirect_uri: None,
    });
    let router = Router::new()
        .route("/auth/start", get(auth_start))
        .route("/auth/callback", get(auth_callback))
        .with_state(state);

    // Step 1
    let (status, location) =
        get_request(&router, "/auth/start?redirect_uri=https://app.example.com/after-login").await;
    assert_eq!(status, StatusCode::SEE_OTHER);
    let loc = location.unwrap();
    let state_token = extract_state_param(&loc);

    // Step 2 — exchange fails (non-routable) but state is consumed
    let callback_uri = format!("/auth/callback?code=fake_code&state={state_token}");
    let (callback_status, _) = get_request(&router, &callback_uri).await;
    assert_ne!(callback_status, StatusCode::BAD_REQUEST);

    // Step 3 — replay rejected
    let (replay_status, _) = get_request(&router, &callback_uri).await;
    assert_eq!(replay_status, StatusCode::BAD_REQUEST);
}