From e43313210269b8e48fe35b17ac416c9ba88ae4f3 Mon Sep 17 00:00:00 2001 From: Vika Date: Sun, 18 Aug 2024 00:30:15 +0300 Subject: feat: logins!! yes you can finally sign in this is also supposed to show private posts intended for you! maybe i can also reveal my email to those who sign in! :3 --- src/database/mod.rs | 2 +- src/frontend/mod.rs | 20 +-- src/indieauth/mod.rs | 96 +++++++++----- src/lib.rs | 79 +++++++++++- src/login.rs | 355 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 4 +- 6 files changed, 493 insertions(+), 63 deletions(-) (limited to 'src') diff --git a/src/database/mod.rs b/src/database/mod.rs index c256867..ac8b43c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -66,7 +66,7 @@ pub mod settings { /// A website's title, shown in the header. #[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)] - pub struct SiteName(String); + pub struct SiteName(pub(crate) String); impl Default for SiteName { fn default() -> Self { Self("Kittybox".to_string()) diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 42e8754..c4a86b4 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -239,15 +239,16 @@ pub async fn homepage( Host(host): Host, Query(query): Query, State(db): State, + session: Option ) -> impl IntoResponse { - let user = None; // TODO authentication // This is stupid, but there is no other way. let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap(); let feed_path = format!("https://{}/feeds/main", host); + let user = session.as_ref().map(|s| &s.0.me); match tokio::try_join!( - get_post_from_database(&db, &hcard_url.as_str(), None, user.as_ref()), - get_post_from_database(&db, &feed_path, query.after, user.as_ref()) + get_post_from_database(&db, &hcard_url.as_str(), None, user), + get_post_from_database(&db, &feed_path, query.after, user) ) { Ok(((hcard, _), (hfeed, cursor))) => { // Here, we know those operations can't really fail @@ -275,7 +276,7 @@ pub async fn homepage( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, - user: user.as_ref().map(url::Url::to_string), + user: session.as_deref(), content: MainPage { feed: &hfeed, card: &hcard, @@ -316,7 +317,7 @@ pub async fn homepage( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, - user: user.as_ref().map(url::Url::to_string), + user: session.as_deref(), content: ErrorPage { code: err.code(), msg: Some(err.msg().to_string()), @@ -335,16 +336,17 @@ pub async fn catchall( State(db): State, Host(host): Host, Query(query): Query, + session: Option, uri: Uri, ) -> impl IntoResponse { - let user: Option = None; // TODO authentication + let user: Option<&url::Url> = session.as_deref().map(|p| &p.me); // TODO authentication let host = url::Url::parse(&format!("https://{}/", host)).unwrap(); let path = host .clone() .join(uri.path()) .unwrap(); - match get_post_from_database(&db, path.as_str(), query.after, user.as_ref()).await { + match get_post_from_database(&db, path.as_str(), query.after, user).await { Ok((post, cursor)) => { let (blogname, channels) = tokio::join!( db.get_setting::(&host) @@ -363,7 +365,7 @@ pub async fn catchall( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, - user: user.as_ref().map(url::Url::to_string), + user: session.as_deref(), content: match post.pointer("/type/0").and_then(|i| i.as_str()) { Some("h-entry") => Entry { post: &post }.to_string(), Some("h-feed") => Feed { feed: &post, cursor: cursor.as_deref() }.to_string(), @@ -393,7 +395,7 @@ pub async fn catchall( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, - user: user.as_ref().map(url::Url::to_string), + user: session.as_deref(), content: ErrorPage { code: err.code(), msg: Some(err.msg().to_owned()), diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs index 06ec1f7..e466d98 100644 --- a/src/indieauth/mod.rs +++ b/src/indieauth/mod.rs @@ -7,14 +7,10 @@ use axum::{ }; #[cfg_attr(not(feature = "webauthn"), allow(unused_imports))] use axum_extra::extract::cookie::{CookieJar, Cookie}; -use axum_extra::{TypedHeader, headers::{authorization::Bearer, Authorization}}; +use axum_extra::{headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, TypedHeader}; use crate::database::Storage; use kittybox_indieauth::{ - Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod, - Scope, Scopes, PKCEMethod, Error, ErrorKind, ResponseType, - AuthorizationRequest, AuthorizationResponse, - GrantType, GrantRequest, GrantResponse, Profile, - TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, TokenData + AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData, TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest }; use std::str::FromStr; @@ -99,15 +95,7 @@ impl , St: Clone + Send + Sync + 'static> axum::ext pub async fn metadata( Host(host): Host ) -> Metadata { - let issuer: url::Url = format!( - "{}://{}/", - if cfg!(debug_assertions) { - "http" - } else { - "https" - }, - host - ).parse().unwrap(); + let issuer: url::Url = format!("https://{}/", host).parse().unwrap(); let indieauth: url::Url = issuer.join("/.kittybox/indieauth/").unwrap(); Metadata { @@ -146,11 +134,22 @@ async fn authorization_endpoint_get( State(http): State, State(auth): State ) -> Response { - let me = format!("https://{host}/").parse().unwrap(); - let h_app = { + let me: url::Url = format!("https://{host}/").parse().unwrap(); + // XXX: attempt fetching OAuth application metadata + let h_app: ClientMetadata = if request.client_id.domain().unwrap() == "localhost" && me.domain().unwrap() != "localhost" { + // If client is localhost, but we aren't localhost, generate synthetic metadata. + tracing::warn!("Client is localhost, not fetching metadata"); + let mut metadata = ClientMetadata::new(request.client_id.clone(), request.client_id.clone()).unwrap(); + + metadata.client_name = Some("Your locally hosted app".to_string()); + + metadata + } else { tracing::debug!("Sending request to {} to fetch metadata", request.client_id); - match http.get(request.client_id.clone()).send().await { - Ok(response) => { + let metadata_request = http.get(request.client_id.clone()) + .header("Accept", "application/json, text/html"); + match metadata_request.send().await.and_then(|res| res.error_for_status()) { + Ok(response) if response.headers().typed_get::() == Some(ContentType::html()) => { let url = response.url().clone(); let text = response.text().await.unwrap(); tracing::debug!("Received {} bytes in response", text.len()); @@ -172,7 +171,7 @@ async fn authorization_endpoint_get( .into_response() } - mf2.items + if let Some(app) = mf2.items .iter() .find(|&i| i.r#type.iter() .any(|i| { @@ -181,23 +180,60 @@ async fn authorization_endpoint_get( }) ) .cloned() - .map(|i| { - serde_json::to_value(&i).unwrap() - }) + { + // Create a synthetic metadata document. Be forgiving. + let mut metadata = ClientMetadata::new( + request.client_id.clone(), + app.properties.get("url") + .and_then(|v| v.first()) + .and_then(|i| match i { + microformats::types::PropertyValue::Url(url) => Some(url.clone()), + _ => None + }) + .unwrap_or_else(|| request.client_id.clone()) + ).unwrap(); + + metadata.client_name = app.properties.get("name") + .and_then(|v| v.first()) + .and_then(|i| match i { + microformats::types::PropertyValue::Plain(name) => Some(name.to_owned()), + _ => None + }); + + metadata + } else { + return (StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], "No h-app or JSON application metadata found.").into_response() + } }, Err(err) => { tracing::error!("Error parsing application metadata: {}", err); - return (StatusCode::BAD_REQUEST, - [("Content-Type", "text/plain")], - "Parsing application metadata failed.").into_response() + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "Parsing h-app metadata failed.").into_response() } } }, + Ok(response) => match response.json::().await { + Ok(client_metadata) => { + client_metadata + }, + Err(err) => { + tracing::error!("Error parsing JSON application metadata: {}", err); + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + format!("Parsing OAuth2 JSON app metadata failed: {}", err) + ).into_response() + } + }, Err(err) => { tracing::error!("Error fetching application metadata: {}", err); - return (StatusCode::INTERNAL_SERVER_ERROR, - [("Content-Type", "text/plain")], - "Fetching application metadata failed.").into_response() + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + format!("Fetching app metadata failed: {}", err) + ).into_response() } } }; @@ -233,6 +269,7 @@ struct AuthorizationConfirmation { request: AuthorizationRequest } +#[tracing::instrument(skip(auth, credential))] async fn verify_credential( auth: &A, website: &url::Url, @@ -272,6 +309,7 @@ async fn authorization_endpoint_confirm( authorization_method: credential, request: mut auth } = confirmation; + match verify_credential(&backend, &website, credential, challenge_id).await { Ok(verified) => if !verified { error!("User failed verification, bailing out."); diff --git a/src/lib.rs b/src/lib.rs index 495591d..f1a563e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,13 +3,13 @@ use std::sync::Arc; -use axum::extract::FromRef; -use axum_extra::extract::cookie::Key; +use axum::{extract::{FromRef, FromRequestParts}, response::IntoResponse}; +use axum_extra::extract::{cookie::Key, SignedCookieJar}; use database::{FileStorage, PostgresStorage, Storage}; use indieauth::backend::{AuthBackend, FileBackend as FileAuthBackend}; use kittybox_util::queue::JobQueue; use media::storage::{MediaStore, file::FileStore as FileMediaStore}; -use tokio::{sync::Mutex, task::JoinSet}; +use tokio::{sync::{Mutex, RwLock}, task::JoinSet}; use webmentions::queue::PostgresJobQueue; /// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database. @@ -22,6 +22,8 @@ pub mod webmentions; pub mod login; //pub mod admin; +const OAUTH2_SOFTWARE_ID: &str = "6f2eee84-c22c-4c9e-b900-10d4e97273c8"; + #[derive(Clone)] pub struct AppState where @@ -36,7 +38,63 @@ Q: JobQueue + Sized pub job_queue: Q, pub http: reqwest::Client, pub background_jobs: Arc>>, - pub cookie_key: Key + pub cookie_key: Key, + pub session_store: SessionStore +} + +pub type SessionStore = Arc>>; + +#[derive(Debug, Clone)] +#[repr(transparent)] +pub struct Session(kittybox_indieauth::ProfileUrl); + +impl std::ops::Deref for Session { + type Target = kittybox_indieauth::ProfileUrl; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub struct NoSessionError; +impl axum::response::IntoResponse for NoSessionError { + fn into_response(self) -> axum::response::Response { + // TODO: prettier error message + (axum::http::StatusCode::UNAUTHORIZED, "You are not logged in, but this page requires a session.").into_response() + } +} + +#[async_trait::async_trait] +impl FromRequestParts for Session +where + SessionStore: FromRef, + Key: FromRef, + S: Send + Sync, +{ + type Rejection = NoSessionError; + + async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result { + let jar = SignedCookieJar::::from_request_parts(parts, state).await.unwrap(); + let session_store = SessionStore::from_ref(state).read_owned().await; + + tracing::debug!("Cookie jar: {:#?}", jar); + let cookie = match jar.get("session_id") { + Some(cookie) => { + tracing::debug!("Session ID cookie: {}", cookie); + cookie + }, + None => { return Err(NoSessionError) } + }; + + session_store.get( + &dbg!(cookie.value_trimmed()) + .parse() + .map_err(|err| { + tracing::error!("Error parsing cookie: {}", err); + NoSessionError + })? + ).cloned().ok_or(NoSessionError) + } } // This is really regrettable, but I can't write: @@ -58,7 +116,6 @@ Q: JobQueue + Sized // have to repeat this magic invocation. impl FromRef> for FileAuthBackend -// where S: Storage, M: MediaStore where S: Storage, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { @@ -67,7 +124,6 @@ where S: Storage, M: MediaStore, Q: JobQueue } impl FromRef> for PostgresStorage -// where A: AuthBackend, M: MediaStore where A: AuthBackend, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { @@ -125,6 +181,14 @@ where A: AuthBackend, S: Storage, M: MediaStore } } +impl FromRef> for SessionStore +where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue +{ + fn from_ref(input: &AppState) -> Self { + input.session_store.clone() + } +} + pub mod companion { use std::{collections::HashMap, sync::Arc}; use axum::{ @@ -229,6 +293,8 @@ M: MediaStore + 'static + FromRef, Q: kittybox_util::queue::JobQueue + FromRef, reqwest::Client: FromRef, Arc>>: FromRef, +crate::SessionStore: FromRef, +axum_extra::extract::cookie::Key: FromRef, St: Clone + Send + Sync + 'static { use axum::routing::get; @@ -241,6 +307,7 @@ St: Clone + Send + Sync + 'static .merge(crate::indieauth::router::()) .merge(crate::webmentions::router::()) .route("/.kittybox/health", get(health_check::)) + .nest("/.kittybox/login", crate::login::router::()) .route( "/.kittybox/static/:path", axum::routing::get(crate::frontend::statics) 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( + 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. -async fn post() { - todo!() +#[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 + // 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() { - todo!() +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, @@ -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 +) -> (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 +) -> 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::(&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 { +pub fn router() -> axum::routing::Router +where + St: Clone + Send + Sync + 'static, + cookie::Key: FromRef, + reqwest::Client: FromRef, + crate::SessionStore: FromRef, + S: Storage + FromRef + Send + Sync + 'static, +{ axum::routing::Router::new() - .route("/start", axum::routing::get(get).post(post)) + .route("/start", axum::routing::get(get::).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::)) } diff --git a/src/main.rs b/src/main.rs index 34c25c0..f272a63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,6 +90,7 @@ async fn main() { let cancellation_token = tokio_util::sync::CancellationToken::new(); let jobset: Arc>> = Default::default(); + let session_store: kittybox::SessionStore = Default::default(); let http: reqwest::Client = { #[allow(unused_mut)] @@ -182,7 +183,8 @@ async fn main() { }, http, background_jobs: jobset.clone(), - cookie_key + cookie_key, + session_store, }; type St = kittybox::AppState; -- cgit 1.4.1