diff --git a/Cargo.toml b/Cargo.toml index de86ce0..c52473d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/localthought/auth-proxy" axum = { version = "0.7", features = ["macros"] } axum-extra = { version = "0.9", features = ["cookie-private"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } -tower-http = { version = "0.5", features = ["trace"] } +tower-http = { version = "0.5", features = ["trace", "cors"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } oauth2 = "4" diff --git a/README.md b/README.md index 096a761..3027f7e 100644 --- a/README.md +++ b/README.md @@ -157,3 +157,12 @@ controls in [SECURITY.md](SECURITY.md) are in place. - The pending `/connect` redirect (used to return to `/connect` after a login detour) is held in a short-lived encrypted cookie (`connect_redirect`), the same pattern as `oauth_state`. + +## Browser clients + +CORS permits explicit bearer-token requests from browser frontends and answers +OPTIONS preflights. Responses expose `X-Connection-Code`, `Link`, `Retry-After`, +`ETag`, `X-Total-Count` and `X-Next-Page`. Clients must persist a rotated code +before continuing pagination and must never replay a consumed code after an +uncertain response. Cookie credentials are not enabled for CORS; provider +login and consent remain top-level browser navigations. diff --git a/src/main.rs b/src/main.rs index 70eb8bc..cee7da1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,6 +121,32 @@ async fn main() { axum::serve(listener, app).await.expect("server error"); } +// Bearer credentials are explicitly supplied by the browser. Never enable +// cookie credentials: login/consent remain top-level navigations. +fn browser_cors() -> tower_http::cors::CorsLayer { + use axum::http::{header, HeaderName, Method}; + use tower_http::cors::{Any, CorsLayer}; + CorsLayer::new() + .allow_origin(Any) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) + .expose_headers([ + HeaderName::from_static("x-connection-code"), + header::LINK, + header::RETRY_AFTER, + header::ETAG, + HeaderName::from_static("x-total-count"), + HeaderName::from_static("x-next-page"), + ]) +} + fn router(state: AppState) -> Router { Router::new() .route("/", get(home)) @@ -138,6 +164,7 @@ fn router(state: AppState) -> Router { .route("/oauth/:provider/callback", get(oauth::callback)) .route("/catalog", get(catalog::list)) .route("/catalog/:file", get(catalog::document)) + .layer(browser_cors()) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -152,3 +179,69 @@ async fn home(State(state): State, jar: PrivateCookieJar) -> Html; rel=next"), + ], + "[]", + ) + }), + ) + .layer(browser_cors()); + let response = app + .clone() + .oneshot( + Request::builder() + .method("OPTIONS") + .uri("/proxy/pets") + .header("origin", "https://atomic.example") + .header("access-control-request-method", "GET") + .header("access-control-request-headers", "authorization") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert!(response.headers()["access-control-allow-headers"] + .to_str() + .unwrap() + .contains("authorization")); + assert!(!response + .headers() + .contains_key("access-control-allow-credentials")); + let response = app + .oneshot( + Request::builder() + .uri("/proxy/pets") + .header("origin", "https://atomic.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let exposed = response.headers()["access-control-expose-headers"] + .to_str() + .unwrap(); + assert!(exposed.contains("x-connection-code")); + assert!(exposed.contains("link")); + } +}