use std::time::Duration;
use kynos::{
Router,
http::{Method, Request, Response, StatusCode, header},
middleware::{cookies::SetCookies, csrf::Csrf},
openapi::RefOr,
prelude::*,
response::cookie::{Cookie, SameSite},
router::service::Service,
};
#[derive(Schema, serde::Serialize)]
struct Profile {
name: String,
}
const SESSION: &str = "kynos_session";
const CONSOLE: &str = "https://admin.example.com";
const OWN_AUTHORITY: &str = "api.example.com";
#[kynos::get("/profile")]
async fn profile() -> Json<Profile> {
Json(Profile {
name: "Ada Lovelace".to_owned(),
})
}
#[kynos::post("/profile")]
async fn rename() -> NoContent {
NoContent
}
#[kynos::delete("/session")]
async fn logout() -> NoContent {
NoContent
}
fn session_cookies(request: &kynos::http::Request) -> Vec<Cookie> {
if request.method() == Method::DELETE && request.uri().path() == "/session" {
return vec![Cookie::removal(SESSION).path("/")];
}
if carries_a_session(request.headers()) {
return Vec::new();
}
vec![
Cookie::new(SESSION, "opaque-identifier")
.path("/")
.max_age(Duration::from_secs(86_400))
.http_only()
.secure()
.same_site(SameSite::Lax),
]
}
fn carries_a_session(headers: &kynos::http::HeaderMap) -> bool {
headers
.get(header::COOKIE)
.and_then(|value| value.to_str().ok())
.is_some_and(|jar| {
jar.split(';')
.any(|pair| pair.trim().starts_with(&format!("{SESSION}=")))
})
}
#[tokio::main]
async fn main() -> kynos::Result<()> {
let router = Router::<()>::new()
.mount(kynos::routes![profile, rename, logout])
.intercept(Csrf::new().trusting_origin(CONSOLE))
.intercept(SetCookies::new(
|request: &kynos::http::Request, (): &()| session_cookies(request),
));
what_the_description_says(&router.openapi()?);
let service = router.build(())?;
a_safe_method_is_never_refused(&service).await;
a_same_origin_write_is_served(&service).await;
a_cross_site_write_is_refused(&service).await;
a_trusted_origin_is_admitted(&service).await;
an_origin_equal_to_the_host_is_admitted(&service).await;
a_client_that_is_not_a_browser_is_not_subject(&service).await;
logging_out_removes_the_cookie(&service).await;
Ok(())
}
fn what_the_description_says(document: &kynos::openapi::Document) {
let write = document.paths.items["/profile"]
.post
.as_ref()
.expect("the rename is declared");
println!(
"POST /profile declares {:?}",
write.responses.responses.keys().collect::<Vec<_>>()
);
assert!(write.responses.responses.contains_key("403"));
let read = document.paths.items["/profile"]
.get
.as_ref()
.expect("the profile is declared");
let RefOr::Item(ok) = &read.responses.responses["200"] else {
panic!("the success is inline rather than a reference")
};
println!(
"GET /profile declares {:?}",
ok.headers.keys().collect::<Vec<_>>()
);
assert!(ok.headers.contains_key("Set-Cookie"));
}
async fn a_safe_method_is_never_refused(service: &Service<()>) {
let response = send(
service,
Method::GET,
"/profile",
&[("sec-fetch-site", "cross-site")],
)
.await;
println!("\nGET from another site -> {}", response.status());
assert_eq!(response.status(), StatusCode::OK);
show(&response, &header::SET_COOKIE);
}
async fn a_same_origin_write_is_served(service: &Service<()>) {
let response = send(
service,
Method::POST,
"/profile",
&[("sec-fetch-site", "same-origin")],
)
.await;
println!("\nPOST, same-origin -> {}", response.status());
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
async fn a_cross_site_write_is_refused(service: &Service<()>) {
let response = send(
service,
Method::POST,
"/profile",
&[("sec-fetch-site", "cross-site")],
)
.await;
println!("\nPOST, cross-site -> {}", response.status());
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(response.headers().get(header::SET_COOKIE).is_none());
println!(" set-cookie: <absent>");
}
async fn a_trusted_origin_is_admitted(service: &Service<()>) {
let response = send(service, Method::POST, "/profile", &[("origin", CONSOLE)]).await;
println!("\nPOST from the trusted console -> {}", response.status());
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
async fn an_origin_equal_to_the_host_is_admitted(service: &Service<()>) {
let response = send(
service,
Method::POST,
"/profile",
&[
("origin", &format!("https://{OWN_AUTHORITY}")),
("host", OWN_AUTHORITY),
],
)
.await;
println!("\nPOST from our own origin -> {}", response.status());
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
async fn a_client_that_is_not_a_browser_is_not_subject(service: &Service<()>) {
let response = send(service, Method::POST, "/profile", &[]).await;
println!(
"\nPOST from something that is not a browser -> {}",
response.status()
);
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
async fn logging_out_removes_the_cookie(service: &Service<()>) {
let response = send(
service,
Method::DELETE,
"/session",
&[
("sec-fetch-site", "same-origin"),
("cookie", &format!("{SESSION}=opaque-identifier")),
],
)
.await;
println!("\nDELETE /session -> {}", response.status());
show(&response, &header::SET_COOKIE);
assert!(
response
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
async fn send(
service: &Service<()>,
method: Method,
path: &str,
fields: &[(&str, &str)],
) -> Response {
let mut request = Request::new(kynos::http::body::Body::empty());
*request.method_mut() = method;
*request.uri_mut() = path.parse().expect("a usable path");
for (name, value) in fields {
request.headers_mut().insert(
header::HeaderName::from_bytes(name.as_bytes()).expect("a usable field name"),
kynos::http::HeaderValue::from_str(value).expect("a usable field value"),
);
}
service.call(request).await
}
fn show(response: &Response, name: &header::HeaderName) {
if let Some(value) = response.headers().get(name) {
println!(" {name}: {}", value.to_str().unwrap_or("<unprintable>"));
}
}