about summary refs log tree commit diff
path: root/src/login.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/login.rs')
-rw-r--r--src/login.rs355
1 files changed, 338 insertions, 17 deletions
diff --git a/src/login.rs b/src/login.rs
index 7f0314f..fd8fe05 100644
--- a/src/login.rs
+++ b/src/login.rs
@@ -1,19 +1,281 @@
-use axum_extra::extract::cookie;
+use std::borrow::Cow;
+
+use futures_util::FutureExt;
+use axum::{extract::{FromRef, Host, OriginalUri, Query, State}, http::HeaderValue, response::IntoResponse, Form};
+use axum_extra::{extract::{cookie::{self, Cookie}, CookieJar, SignedCookieJar}, headers::Header, 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 crate::database::Storage;
 
 /// Show a login page.
-async fn get() {
-    todo!()
+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.
-async fn post() {
-    todo!()
+#[tracing::instrument]
+async fn post(
+    Host(host): Host,
+    mut cookies: SignedCookieJar,
+    State(http): State<reqwest::Client>,
+    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
+    // 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() {
-    todo!()
+async fn callback(
+    Host(host): Host,
+    Query(result): Query<AuthorizationResponse>,
+    cookie_jar: SignedCookieJar,
+    State(http): State<reqwest::Client>,
+    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,
@@ -23,23 +285,82 @@ async fn callback() {
 /// 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() {
-    todo!()
+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() {
-    todo!()
+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>
+) -> axum::response::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"));
+
+    response
+}
+
+
 /// Produce a router for all of the above.
-fn router(key: cookie::Key) -> axum::routing::Router<cookie::Key> {
+pub fn router<St, S>() -> axum::routing::Router<St>
+where
+    St: Clone + Send + Sync + 'static,
+    cookie::Key: FromRef<St>,
+    reqwest::Client: FromRef<St>,
+    crate::SessionStore: FromRef<St>,
+    S: Storage + FromRef<St> + Send + Sync + 'static,
+{
     axum::routing::Router::new()
-        .route("/start", axum::routing::get(get).post(post))
+        .route("/start", axum::routing::get(get::<S>).post(post))
         .route("/finish", axum::routing::get(callback))
         .route("/logout", axum::routing::get(logout_page).post(logout))
-        // I'll need some kind of session store here too. It should be
-        // a key from UUIDs (128 bits is enough for a session token)
-        // to at least a URL, if not something more.
-        .with_state(key)
+        .route("/client_metadata", axum::routing::get(client_metadata::<S>))
 }