use std::{borrow::Cow, str::FromStr}; use futures_util::FutureExt; use axum::{extract::{FromRef, Host, Query, State}, http::HeaderValue, response::IntoResponse, Form}; use axum_extra::{extract::{cookie::{self, Cookie}, SignedCookieJar}, headers::HeaderMapExt, TypedHeader}; use hyper::{header::{CACHE_CONTROL, LOCATION}, StatusCode}; use kittybox_frontend_renderer::{Template, LoginPage, LogoutPage}; use kittybox_indieauth::{AuthorizationResponse, Error, GrantType, PKCEVerifier, Scope, Scopes}; use sha2::Digest; use crate::database::Storage; /// Show a login page. async fn get( State(db): State, Host(host): Host ) -> impl axum::response::IntoResponse { let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap(); let (blogname, channels) = tokio::join!( db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); ( StatusCode::OK, [( axum::http::header::CONTENT_TYPE, HeaderValue::from_static(r#"text/html; charset="utf-8""#), )], Template { title: "Sign in with your website", blog_name: blogname.as_ref(), feeds: channels, user: None, content: LoginPage {}.to_string() }.to_string() ) } #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] struct LoginForm { url: url::Url } /// Accept login and start the IndieAuth dance. #[tracing::instrument] async fn post( Host(host): Host, mut cookies: SignedCookieJar, State(http): State, Form(form): Form, ) -> axum::response::Response { let code_verifier = kittybox_indieauth::PKCEVerifier::new(); cookies = cookies.add( Cookie::build(("code_verifier", code_verifier.to_string())) .path("/.kittybox/login") .expires(None) .secure(true) .http_only(true) .build() ); let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host).parse().unwrap(); let redirect_uri = { let mut uri = client_id.clone(); uri.set_path("/.kittybox/login/finish"); uri }; let indieauth_state = kittybox_indieauth::AuthorizationRequest { response_type: kittybox_indieauth::ResponseType::Code, client_id, redirect_uri, state: kittybox_indieauth::State::new(), code_challenge: kittybox_indieauth::PKCEChallenge::new(&code_verifier, kittybox_indieauth::PKCEMethod::S256), scope: Some(Scopes::new(vec![Scope::Profile])), me: Some(form.url.clone()) }; // Fetch the user's homepage, determine their authorization endpoint // and either start the IndieAuth dance with the data above or bail out. // TODO: move IndieAuth endpoint discovery into kittybox-util or kittybox-indieauth tracing::debug!("Fetching {}", &form.url); let response = match http.get(form.url.clone()).send().await { Ok(response) => response, Err(err) => { tracing::error!("Error fetching homepage: {:?}", err); return ( StatusCode::BAD_REQUEST, format!("couldn't fetch your homepage: {}", err) ).into_response() } }; // XXX: Blocked on https://github.com/hyperium/headers/pull/113 // use axum_extra::{headers::Header, TypedHeader}; // let links = response // .headers() // .iter() // .filter(|(k, v)| **k == reqwest::header::LINK) // .map(|(k, v)| axum_extra::headers::Link::decode(v)) // .map(|res| res.ok()) // .map(|res| res.unwrap()) // .collect::>(); // // todo!("parse Link: headers") let body = match response.text().await { Ok(body) => match microformats::from_html(&body, form.url) { Ok(mf2) => mf2, Err(err) => return ( StatusCode::BAD_REQUEST, format!("error while parsing your homepage with mf2: {}", err) ).into_response() }, Err(err) => return ( StatusCode::INTERNAL_SERVER_ERROR, format!("error while fetching your homepage: {}", err) ).into_response() }; let mut iss: Option = None; let mut authorization_endpoint = match body .rels .by_rels() .get("indieauth-metadata") .map(|v| v.as_slice()) .unwrap_or_default() .first() .cloned() { // TODO: cache indieauth-metadata using http_cache_reqwest crate // this will also allow caching all the other things! Some(metadata_endpoint) => match http.get(metadata_endpoint).send().await { Ok(res) => match res.json::().await { Ok(metadata) => { iss = Some(metadata.issuer); metadata.authorization_endpoint }, Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't parse your oauth2 metadata: {}", err)).into_response() }, Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't fetch your oauth2 metadata: {}", err)).into_response() }, None => match body .rels .by_rels() .get("authorization_endpoint") .map(|v| v.as_slice()) .unwrap_or_default() .first() .cloned() { Some(authorization_endpoint) => authorization_endpoint, None => return ( StatusCode::BAD_REQUEST, "no authorization endpoint was found on your homepage." ).into_response() } }; cookies = cookies.add( Cookie::build(("authorization_endpoint", authorization_endpoint.to_string())) .path("/.kittybox/login") .expires(None) .secure(true) .http_only(true) .build() ); if let Some(iss) = iss { cookies = cookies.add( Cookie::build(("iss", iss.to_string())) .path("/.kittybox/login") .expires(None) .secure(true) .http_only(true) .build() ); } cookies = cookies.add( Cookie::build(("me", indieauth_state.me.as_ref().unwrap().to_string())) .path("/.kittybox/login") .expires(None) .secure(true) .http_only(true) .build() ); authorization_endpoint .query_pairs_mut() .extend_pairs(indieauth_state.as_query_pairs().iter()); tracing::debug!("Forwarding user to {}", authorization_endpoint); (StatusCode::FOUND, [ ("Location", authorization_endpoint.to_string()), ], cookies).into_response() } /// Accept the return of the IndieAuth dance. Set a cookie for the /// required session. async fn callback( Host(host): Host, Query(result): Query, cookie_jar: SignedCookieJar, State(http): State, State(session_store): State, ) -> axum::response::Response { let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host).parse().unwrap(); let redirect_uri = { let mut uri = client_id.clone(); uri.set_path("/.kittybox/login/finish"); uri }; let response = result; let me: url::Url = cookie_jar.get("me").unwrap().value().parse().unwrap(); let code_verifier: PKCEVerifier = cookie_jar.get("code_verifier").unwrap().value().into(); let authorization_endpoint: url::Url = cookie_jar.get("authorization_endpoint") .and_then(|v| v.value().parse().ok()) .unwrap(); match cookie_jar.get("iss").and_then(|c| c.value().parse().ok()) { Some(iss) if response.iss != iss => { return (StatusCode::FORBIDDEN, [(CACHE_CONTROL, "no-store")], format!("indieauth error: issuer {} doesn't match your declared issuer {}, ceremony aborted for security reasons", response.iss, iss)).into_response() }, _ => {}, } let grant_request = kittybox_indieauth::GrantRequest::AuthorizationCode { code: response.code, client_id, redirect_uri, code_verifier, }; tracing::debug!("POSTing {:?} to authorization endpoint {}", grant_request, authorization_endpoint); let res = match http.post(authorization_endpoint) .form(&grant_request) .header(reqwest::header::ACCEPT, "application/json") .send() .await { Ok(res) if res.status().is_success() => match res.json::().await { Ok(grant) => grant, Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error parsing authorization endpoint response: {}", err)).into_response() }, Ok(res) => match res.json::().await { Ok(err) => return (StatusCode::BAD_REQUEST, [(CACHE_CONTROL, "no-store")], err.to_string()).into_response(), Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error parsing indieauth error: {}", err)).into_response() } Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error redeeming authorization code: {}", err)).into_response() }; let profile = match res { kittybox_indieauth::GrantResponse::ProfileUrl(profile) => profile, // We can't be granted an access token if we aren't touching the token endpoint. kittybox_indieauth::GrantResponse::AccessToken { .. } => unreachable!(), }; if me != profile.me { todo!("verify the authorization endpoint is authoritative for the value of me"); } let session = crate::Session(profile); let uuid = uuid::Uuid::new_v4(); session_store.write().await.insert(uuid, session); let cookies = cookie_jar .add(Cookie::build(("session_id", uuid.to_string())) .expires(None) .secure(true) .http_only(true) .path("/") .build() ) .remove("authorization_endpoint") .remove("me") .remove("iss") .remove("code_verifier"); (StatusCode::FOUND, [(LOCATION, HeaderValue::from_static("/")), (CACHE_CONTROL, HeaderValue::from_static("no-store"))], dbg!(cookies)).into_response() } /// Show the form necessary for logout. If JS is enabled, /// automatically POST the form. /// /// This is essentially protection from CSRF and also from some kind /// of crawlers working with a user's cookies (wget?). If a crawler is /// stupid enough to execute JS and send a POST request though, that's /// on the crawler. async fn logout_page() -> impl axum::response::IntoResponse { (StatusCode::OK, [("Content-Type", "text/html")], Template { title: "Signing out...", blog_name: "Kittybox", feeds: vec![], user: None, content: LogoutPage {}.to_string() }.to_string()) } /// Erase the necessary cookies for login and invalidate the session. async fn logout( mut cookies: SignedCookieJar, State(session_store): State ) -> (StatusCode, [(&'static str, &'static str); 1], SignedCookieJar) { if let Some(id) = cookies.get("session_id") .and_then(|c| uuid::Uuid::parse_str(c.value_trimmed()).ok()) { session_store.write().await.remove(&id); } cookies = cookies.remove("me") .remove("iss") .remove("authorization_endpoint") .remove("code_verifier") .remove("session_id"); (StatusCode::FOUND, [("Location", "/")], cookies) } async fn client_metadata( Host(host): Host, State(storage): State, // XXX: blocked on https://github.com/hyperium/headers/pull/162 //TypedHeader(accept): TypedHeader cached: Option>, ) -> axum::response::Response { let etag = { let mut digest = sha2::Sha256::new(); digest.update(env!("CARGO_PKG_NAME").as_bytes()); digest.update(b" "); digest.update(env!("CARGO_PKG_VERSION").as_bytes()); digest.update(b" "); digest.update(crate::OAUTH2_SOFTWARE_ID.as_bytes()); let etag = String::from("W/") + &hex::encode(digest.finalize()); axum_extra::headers::ETag::from_str(&etag).unwrap() }; if let Some(cached) = cached { if cached.precondition_passes(&etag) { return StatusCode::NOT_MODIFIED.into_response() } } let client_uri: url::Url = format!("https://{}/", host).parse().unwrap(); let client_id: url::Url = { let mut url = client_uri.clone(); url.set_path("/.kittybox/login/client_metadata"); url }; let mut metadata = kittybox_indieauth::ClientMetadata::new(client_id, client_uri).unwrap(); metadata.client_name = Some(storage.get_setting::(&metadata.client_uri).await.unwrap_or_default().0); metadata.grant_types = Some(vec![GrantType::AuthorizationCode]); // We don't request anything more than the profile scope. metadata.scope = Some(Scopes::new(vec![Scope::Profile])); metadata.software_id = Some(Cow::Borrowed(crate::OAUTH2_SOFTWARE_ID)); metadata.software_version = Some(Cow::Borrowed(env!("CARGO_PKG_VERSION"))); // XXX: consider matching on Accept: header to detect whether // we're expected to serve mf2+html for compatibility with older // identity providers, or json to match newest spec let mut response = metadata.into_response(); // Indicate to upstream caches this endpoint does different things depending on the Accept: header. response.headers_mut().append("Vary", HeaderValue::from_static("Accept")); // Cache this metadata for an hour. response.headers_mut().append("Cache-Control", HeaderValue::from_static("max-age=600")); response.headers_mut().typed_insert(etag); response } /// Produce a router for all of the above. pub fn router() -> axum::routing::Router where St: Clone + Send + Sync + 'static, cookie::Key: FromRef, reqwest_middleware::ClientWithMiddleware: FromRef, crate::SessionStore: FromRef, S: Storage + FromRef + Send + Sync + 'static, { axum::routing::Router::new() .route("/start", axum::routing::get(get::).post(post)) .route("/finish", axum::routing::get(callback)) .route("/logout", axum::routing::get(logout_page).post(logout)) .route("/client_metadata", axum::routing::get(client_metadata::)) }