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<S: Storage + Send + Sync + 'static>(
State(db): State<S>,
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::<crate::database::settings::SiteName>(&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<reqwest_middleware::ClientWithMiddleware>,
Form(form): Form<LoginForm>,
) -> 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::<Vec<axum_extra::headers::Link>>();
//
// 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<url::Url> = 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::<kittybox_indieauth::Metadata>().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<AuthorizationResponse>,
cookie_jar: SignedCookieJar,
State(http): State<reqwest_middleware::ClientWithMiddleware>,
State(session_store): State<crate::SessionStore>,
) -> 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::<kittybox_indieauth::GrantResponse>().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::<Error>().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<crate::SessionStore>
) -> (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<S: Storage + Send + Sync + 'static>(
Host(host): Host,
State(storage): State<S>,
// XXX: blocked on https://github.com/hyperium/headers/pull/162
//TypedHeader(accept): TypedHeader<axum_extra::headers::Accept>
cached: Option<TypedHeader<axum_extra::headers::IfNoneMatch>>,
) -> 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 = {
let mut etag = String::with_capacity(66);
etag.push_str("W/");
data_encoding::HEXLOWER.encode_append(&digest.finalize(), &mut etag);
etag
};
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::<crate::database::settings::SiteName>(&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<St, S>() -> axum::routing::Router<St>
where
St: Clone + Send + Sync + 'static,
cookie::Key: FromRef<St>,
reqwest_middleware::ClientWithMiddleware: FromRef<St>,
crate::SessionStore: FromRef<St>,
S: Storage + FromRef<St> + Send + Sync + 'static,
{
axum::routing::Router::new()
.route("/start", axum::routing::get(get::<S>).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::<S>))
}