diff --git a/crates/tranquil-oauth-server/src/endpoints/par.rs b/crates/tranquil-oauth-server/src/endpoints/par.rs index 2764ade..d5f4347 100644 --- a/crates/tranquil-oauth-server/src/endpoints/par.rs +++ b/crates/tranquil-oauth-server/src/endpoints/par.rs @@ -52,7 +52,7 @@ pub async fn pushed_authorization_request( _rate_limit: OAuthRateLimited, headers: HeaderMap, body: Bytes, -) -> Result<(axum::http::StatusCode, Json), OAuthError> { +) -> Result { let content_type = headers .get("content-type") .and_then(|v| v.to_str().ok()) @@ -129,6 +129,7 @@ pub async fn pushed_authorization_request( }); Ok(( axum::http::StatusCode::CREATED, + [(axum::http::header::CACHE_CONTROL, "no-cache, no-store")], Json(ParResponse { request_uri: request_id.into_inner(), expires_in: u64::try_from(PAR_EXPIRY_SECONDS).unwrap_or(600), diff --git a/crates/tranquil-oauth-server/src/endpoints/token/mod.rs b/crates/tranquil-oauth-server/src/endpoints/token/mod.rs index 23ee9a2..bc8ec0e 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/mod.rs @@ -4,7 +4,11 @@ mod introspect; mod types; use axum::body::Bytes; -use axum::{Json, extract::State, http::HeaderMap}; +use axum::{ + extract::State, + http::{HeaderMap, HeaderName, header}, + response::IntoResponse, +}; use tranquil_pds::oauth::OAuthError; use tranquil_pds::rate_limit::{OAuthRateLimited, OAuthTokenLimit}; use tranquil_pds::state::AppState; @@ -18,12 +22,17 @@ pub use types::{ GrantType, RequestClientAuth, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest, }; +const NO_STORE: [(HeaderName, &str); 2] = [ + (header::CACHE_CONTROL, "no-store"), + (header::PRAGMA, "no-cache"), +]; + pub async fn token_endpoint( State(state): State, _rate_limit: OAuthRateLimited, headers: HeaderMap, body: Bytes, -) -> Result<(HeaderMap, Json), OAuthError> { +) -> Result { let content_type = headers .get("content-type") .and_then(|v| v.to_str().ok()) @@ -45,12 +54,13 @@ pub async fn token_endpoint( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let validated = request.validate()?; - match validated.grant { + let response = match validated.grant { TokenGrant::AuthorizationCode { .. } => { handle_authorization_code_grant(state, headers, validated, dpop_proof).await } TokenGrant::RefreshToken { .. } => { handle_refresh_token_grant(state, headers, validated, dpop_proof).await } - } + }?; + Ok((NO_STORE, response)) } diff --git a/crates/tranquil-pds/src/frontend.rs b/crates/tranquil-pds/src/frontend.rs new file mode 100644 index 0000000..0a68179 --- /dev/null +++ b/crates/tranquil-pds/src/frontend.rs @@ -0,0 +1,293 @@ +use std::sync::Arc; + +use axum::{ + Router, middleware, + response::{IntoResponse, Response}, + routing::{MethodRouter, get}, +}; +use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; +use sha2::{Digest, Sha256}; +use tower_http::services::ServeDir; + +pub(crate) fn mount(router: Router, frontend_dir: &str) -> Router { + let index_path = format!("{}/index.html", frontend_dir); + let homepage_path = format!("{}/homepage.html", frontend_dir); + + let homepage_exists = std::path::Path::new(&homepage_path).exists(); + let homepage_file = if homepage_exists { + homepage_path.clone() + } else { + index_path.clone() + }; + + let spa_router = Router::new().fallback_service(html_page(index_path.clone())); + + let assets_router = Router::new() + .fallback_service( + ServeDir::new(format!("{}/assets", frontend_dir)) + .fallback(not_found_page(index_path.clone())), + ) + .layer(middleware::map_response(mark_immutable)); + + let serve_dir = ServeDir::new(frontend_dir).fallback(not_found_page(index_path.clone())); + + let router = router + .route("/", html_page(homepage_file)) + .route("/index.html", html_page(index_path)) + .nest("/assets", assets_router) + .nest("/app", spa_router) + .fallback_service(serve_dir); + + if homepage_exists { + router.route("/homepage.html", html_page(homepage_path)) + } else { + router + } +} + +const HTML_CONTENT_TYPE: [(HeaderName, &str); 1] = + [(header::CONTENT_TYPE, "text/html; charset=utf-8")]; +const NO_CACHE: [(HeaderName, &str); 1] = [(header::CACHE_CONTROL, "no-cache")]; + +fn html_page(path: String) -> MethodRouter { + let path: Arc = path.into(); + get(move |headers: HeaderMap| serve_html(path.clone(), headers)) +} + +async fn serve_html(path: Arc, headers: HeaderMap) -> Result { + let body = read_html(&path).await?; + let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body))); + let unchanged = headers + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + .is_some_and(|tags| { + tags.split(',') + .map(str::trim) + .any(|tag| tag == "*" || tag.strip_prefix("W/").unwrap_or(tag) == etag) + }); + let tag = [(header::ETAG, etag)]; + Ok(if unchanged { + (StatusCode::NOT_MODIFIED, tag, NO_CACHE).into_response() + } else { + (tag, NO_CACHE, HTML_CONTENT_TYPE, body).into_response() + }) +} + +fn not_found_page(path: String) -> MethodRouter { + let path: Arc = path.into(); + get(move || serve_not_found(path.clone())) +} + +async fn serve_not_found(path: Arc) -> Result { + let body = read_html(&path).await?; + Ok((StatusCode::NOT_FOUND, NO_CACHE, HTML_CONTENT_TYPE, body).into_response()) +} + +async fn mark_immutable(mut response: Response) -> Response { + if response.status().is_success() || response.status() == StatusCode::NOT_MODIFIED { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + } + response +} + +async fn read_html(path: &str) -> Result, Response> { + tokio::fs::read(path).await.map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND.into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{Body, to_bytes}; + use tower::ServiceExt; + + async fn fetch(app: &Router, uri: &str, headers: &[(header::HeaderName, &str)]) -> Response { + let request = headers + .iter() + .fold(http::Request::get(uri), |req, (name, value)| { + req.header(name, *value) + }) + .body(Body::empty()) + .unwrap(); + app.clone().oneshot(request).await.unwrap() + } + + fn write_with_epoch_mtime(path: std::path::PathBuf, contents: &str) { + std::fs::write(&path, contents).unwrap(); + std::fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)) + .unwrap(); + } + + fn header_of(response: &Response, name: header::HeaderName) -> Option<&str> { + response.headers().get(name).and_then(|v| v.to_str().ok()) + } + + async fn body_of(response: Response) -> String { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + } + + #[tokio::test] + async fn test_frontend_caching() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("assets")).unwrap(); + write_with_epoch_mtime(root.join("index.html"), "index v1"); + write_with_epoch_mtime(root.join("homepage.html"), "homepage"); + std::fs::write(root.join("assets/index-v1.js"), "export {}").unwrap(); + let app = mount(Router::new(), root.to_str().unwrap()); + + let stale = fetch( + &app, + "/app/dashboard", + &[(header::IF_MODIFIED_SINCE, "Thu, 01 Jan 1970 00:00:01 GMT")], + ) + .await; + assert_eq!( + stale.status(), + StatusCode::OK, + "a page cached under a timestamp must be replaced" + ); + assert_eq!( + header_of(&stale, header::CACHE_CONTROL), + Some("no-cache"), + "pages must be revalidated before reuse" + ); + assert!( + stale.headers().get(header::LAST_MODIFIED).is_none(), + "pages must not offer a timestamp validator" + ); + let etag = stale.headers()[header::ETAG].to_str().unwrap().to_string(); + assert_eq!( + body_of(stale).await, + "index v1", + "app pages must serve index.html" + ); + + for path in ["/app/dashboard", "/index.html"] { + let unchanged = fetch(&app, path, &[(header::IF_NONE_MATCH, &etag)]).await; + assert_eq!( + unchanged.status(), + StatusCode::NOT_MODIFIED, + "{path}: an unchanged page must revalidate" + ); + assert_eq!( + header_of(&unchanged, header::ETAG), + Some(etag.as_str()), + "{path}: an unchanged page must keep its tag" + ); + } + + let weak_tag = format!("W/{etag}"); + let weakened = fetch( + &app, + "/app/dashboard", + &[(header::IF_NONE_MATCH, &weak_tag)], + ) + .await; + assert_eq!( + weakened.status(), + StatusCode::NOT_MODIFIED, + "a weakened tag must still revalidate" + ); + + write_with_epoch_mtime(root.join("index.html"), "index v2"); + let changed = fetch(&app, "/app/dashboard", &[(header::IF_NONE_MATCH, &etag)]).await; + assert_eq!( + changed.status(), + StatusCode::OK, + "a changed page must replace the cached one" + ); + let current_etag = changed.headers()[header::ETAG] + .to_str() + .unwrap() + .to_string(); + assert_ne!(current_etag, etag, "a changed page must get a new tag"); + assert_eq!( + body_of(changed).await, + "index v2", + "a changed page must serve the new index.html" + ); + + for path in ["/", "/homepage.html"] { + let home = fetch( + &app, + path, + &[ + (header::IF_NONE_MATCH, ¤t_etag), + (header::IF_MODIFIED_SINCE, "Thu, 01 Jan 1970 00:00:01 GMT"), + ], + ) + .await; + assert_eq!( + home.status(), + StatusCode::OK, + "{path}: the homepage must not match index.html's tag or a timestamp" + ); + assert_eq!( + body_of(home).await, + "homepage", + "{path}: the homepage must serve homepage.html" + ); + } + + let asset = fetch(&app, "/assets/index-v1.js", &[]).await; + assert_eq!( + asset.status(), + StatusCode::OK, + "assets must still be served" + ); + assert_eq!( + header_of(&asset, header::CACHE_CONTROL), + Some("public, max-age=31536000, immutable"), + "hashed assets must be cached for good" + ); + assert_eq!( + body_of(asset).await, + "export {}", + "assets must be served as themselves" + ); + + let missing = fetch(&app, "/assets/index-v0.js", &[]).await; + assert_eq!( + missing.status(), + StatusCode::NOT_FOUND, + "a removed asset must not be found" + ); + assert!( + missing.headers().get(header::ETAG).is_none(), + "the not-found page must not offer a validator" + ); + assert_eq!( + header_of(&missing, header::CACHE_CONTROL), + Some("no-cache"), + "the not-found page must be refetched before reuse" + ); + + let revalidated = fetch( + &app, + "/assets/index-v0.js", + &[(header::IF_NONE_MATCH, ¤t_etag)], + ) + .await; + assert_eq!( + revalidated.status(), + StatusCode::NOT_FOUND, + "a not-found page sent index.html's tag must stay a 404" + ); + assert_eq!( + body_of(revalidated).await, + "index v2", + "a not-found page sent index.html's tag must keep its body" + ); + } +} diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index f074b57..e3f8ce8 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod crawlers; pub mod delegation; pub mod did; +mod frontend; pub mod handle; pub mod image; pub mod metrics; @@ -34,10 +35,7 @@ use http::StatusCode; use serde_json::json; use state::AppState; use tower::ServiceBuilder; -use tower_http::{ - cors::{AllowHeaders, Any, CorsLayer}, - services::{ServeDir, ServeFile}, -}; +use tower_http::cors::{AllowHeaders, Any, CorsLayer}; pub use tranquil_db_traits::AccountStatus; pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; @@ -117,25 +115,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router { .with_state(state); if cfg!(feature = "frontend") && tranquil_config::get().frontend.enabled { - let frontend_dir = &tranquil_config::get().frontend.dir; - let index_path = format!("{}/index.html", frontend_dir); - let homepage_path = format!("{}/homepage.html", frontend_dir); - - let homepage_exists = std::path::Path::new(&homepage_path).exists(); - let homepage_file = if homepage_exists { - homepage_path - } else { - index_path.clone() - }; - - let spa_router = Router::new().fallback_service(ServeFile::new(&index_path)); - - let serve_dir = ServeDir::new(frontend_dir).not_found_service(ServeFile::new(&index_path)); - - return router - .route_service("/", ServeFile::new(&homepage_file)) - .nest("/app", spa_router) - .fallback_service(serve_dir); + return frontend::mount(router, &tranquil_config::get().frontend.dir); } router diff --git a/crates/tranquil-pds/tests/it/oauth_security.rs b/crates/tranquil-pds/tests/it/oauth_security.rs index 0ccc467..a487fac 100644 --- a/crates/tranquil-pds/tests/it/oauth_security.rs +++ b/crates/tranquil-pds/tests/it/oauth_security.rs @@ -272,7 +272,7 @@ async fn par_request_with( extra: &[(&str, &str)], ) -> (String, String) { let (code_verifier, code_challenge) = generate_pkce(); - let par_body: Value = client() + let par = client() .post(format!("{}/oauth/par", base_url().await)) .form( &[ @@ -289,10 +289,13 @@ async fn par_request_with( ) .send() .await - .unwrap() - .json() - .await .unwrap(); + assert_eq!( + header_of(&par, "cache-control"), + Some("no-cache, no-store"), + "PAR responses must not be stored" + ); + let par_body: Value = par.json().await.unwrap(); ( par_body["request_uri"].as_str().unwrap().to_string(), code_verifier, @@ -1729,6 +1732,10 @@ async fn approve_consent(request_uri: &str, remember: bool) -> String { body["redirect_uri"].as_str().unwrap().to_string() } +fn header_of<'a>(res: &'a reqwest::Response, name: &str) -> Option<&'a str> { + res.headers().get(name).and_then(|v| v.to_str().ok()) +} + async fn exchange_code_for( client_id: &str, redirect_uri: &str, @@ -1748,6 +1755,16 @@ async fn exchange_code_for( .await .unwrap(); assert_eq!(res.status(), StatusCode::OK, "token exchange must succeed"); + assert_eq!( + header_of(&res, "cache-control"), + Some("no-store"), + "token responses must not be stored" + ); + assert_eq!( + header_of(&res, "pragma"), + Some("no-cache"), + "token responses must not be cached" + ); res.json().await.unwrap() } @@ -1966,7 +1983,7 @@ async fn test_password_login_flow() { ); let access_token = token["access_token"].as_str().unwrap(); let refresh_token = token["refresh_token"].as_str().unwrap(); - let refreshed: Value = http + let refresh = http .post(format!("{}/oauth/token", base_url().await)) .form(&[ ("grant_type", "refresh_token"), @@ -1975,10 +1992,18 @@ async fn test_password_login_flow() { ]) .send() .await - .unwrap() - .json() - .await .unwrap(); + assert_eq!( + header_of(&refresh, "cache-control"), + Some("no-store"), + "refreshed tokens must not be stored" + ); + assert_eq!( + header_of(&refresh, "pragma"), + Some("no-cache"), + "refreshed tokens must not be cached" + ); + let refreshed: Value = refresh.json().await.unwrap(); assert_ne!( refreshed["access_token"].as_str().unwrap(), access_token,