diff options
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | indieauth/src/lib.rs | 2 | ||||
-rw-r--r-- | src/database/mod.rs | 2 | ||||
-rw-r--r-- | src/frontend/mod.rs | 20 | ||||
-rw-r--r-- | src/indieauth/mod.rs | 96 | ||||
-rw-r--r-- | src/lib.rs | 79 | ||||
-rw-r--r-- | src/login.rs | 355 | ||||
-rw-r--r-- | src/main.rs | 4 | ||||
-rw-r--r-- | templates/src/indieauth.rs | 31 | ||||
-rw-r--r-- | templates/src/lib.rs | 2 | ||||
-rw-r--r-- | templates/src/login.rs | 14 | ||||
-rw-r--r-- | templates/src/templates.rs | 17 |
12 files changed, 530 insertions, 94 deletions
diff --git a/Cargo.toml b/Cargo.toml index 9062cfe..ada6c98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,7 +96,7 @@ serde_urlencoded = "^0.7.0" # `x-www-form-urlencoded` meets Serde serde_variant = "^0.1.1" # Retrieve serde provided variant names for enum objects relative-path = "^1.5.0" # Portable relative paths for Rust sha2 = "^0.10.7" # SHA-2 series of algorithms for Rust -uuid = "^1.3.3" +uuid = { version = "^1.3.3", features = [ "v4" ] } tracing = { version = "0.1.34", features = [] } tracing-tree = "0.4.0" tracing-log = "0.2.0" diff --git a/indieauth/src/lib.rs b/indieauth/src/lib.rs index 96ddea4..40bba5a 100644 --- a/indieauth/src/lib.rs +++ b/indieauth/src/lib.rs @@ -989,7 +989,7 @@ impl std::error::Error for self::Error {} impl std::fmt::Display for self::Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "IndieAuth error ({})", self.kind)?; + write!(f, "indieauth error: {}", self.kind)?; if let Some(msg) = self.msg.as_deref() { write!(f, ": {}", msg)?; } 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<D: Storage>( Host(host): Host, Query(query): Query<QueryParams>, State(db): State<D>, + session: Option<crate::Session> ) -> 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<D: Storage>( 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<D: Storage>( 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<D: Storage>( State(db): State<D>, Host(host): Host, Query(query): Query<QueryParams>, + session: Option<crate::Session>, uri: Uri, ) -> impl IntoResponse { - let user: Option<url::Url> = 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::<crate::database::settings::SiteName>(&host) @@ -363,7 +365,7 @@ pub async fn catchall<D: Storage>( 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<D: Storage>( 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 <A: AuthBackend + FromRef<St>, 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<A: AuthBackend, D: Storage + 'static>( State(http): State<reqwest::Client>, State(auth): State<A> ) -> 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::<ContentType>() == 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<A: AuthBackend, D: Storage + 'static>( .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<A: AuthBackend, D: Storage + 'static>( }) ) .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::<ClientMetadata>().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<A: AuthBackend>( auth: &A, website: &url::Url, @@ -272,6 +309,7 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( 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<A, S, M, Q> where @@ -36,7 +38,63 @@ Q: JobQueue<webmentions::Webmention> + Sized pub job_queue: Q, pub http: reqwest::Client, pub background_jobs: Arc<Mutex<JoinSet<()>>>, - pub cookie_key: Key + pub cookie_key: Key, + pub session_store: SessionStore +} + +pub type SessionStore = Arc<RwLock<std::collections::HashMap<uuid::Uuid, Session>>>; + +#[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<S> FromRequestParts<S> for Session +where + SessionStore: FromRef<S>, + Key: FromRef<S>, + S: Send + Sync, +{ + type Rejection = NoSessionError; + + async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result<Self, Self::Rejection> { + let jar = SignedCookieJar::<Key>::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<webmentions::Webmention> + Sized // have to repeat this magic invocation. impl<S, M, Q> FromRef<AppState<Self, S, M, Q>> for FileAuthBackend -// where S: Storage, M: MediaStore where S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> { fn from_ref(input: &AppState<Self, S, M, Q>) -> Self { @@ -67,7 +124,6 @@ where S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> } impl<A, M, Q> FromRef<AppState<A, Self, M, Q>> for PostgresStorage -// where A: AuthBackend, M: MediaStore where A: AuthBackend, M: MediaStore, Q: JobQueue<webmentions::Webmention> { fn from_ref(input: &AppState<A, Self, M, Q>) -> Self { @@ -125,6 +181,14 @@ where A: AuthBackend, S: Storage, M: MediaStore } } +impl<A, S, M, Q> FromRef<AppState<A, S, M, Q>> for SessionStore +where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue<webmentions::Webmention> +{ + fn from_ref(input: &AppState<A, S, M, Q>) -> Self { + input.session_store.clone() + } +} + pub mod companion { use std::{collections::HashMap, sync::Arc}; use axum::{ @@ -229,6 +293,8 @@ M: MediaStore + 'static + FromRef<St>, Q: kittybox_util::queue::JobQueue<crate::webmentions::Webmention> + FromRef<St>, reqwest::Client: FromRef<St>, Arc<Mutex<JoinSet<()>>>: FromRef<St>, +crate::SessionStore: FromRef<St>, +axum_extra::extract::cookie::Key: FromRef<St>, St: Clone + Send + Sync + 'static { use axum::routing::get; @@ -241,6 +307,7 @@ St: Clone + Send + Sync + 'static .merge(crate::indieauth::router::<St, A, S>()) .merge(crate::webmentions::router::<St, Q>()) .route("/.kittybox/health", get(health_check::<S>)) + .nest("/.kittybox/login", crate::login::router::<St, S>()) .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<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>)) } 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<Mutex<JoinSet<()>>> = 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<AuthBackend, Storage, MediaStore, JobQueue>; diff --git a/templates/src/indieauth.rs b/templates/src/indieauth.rs index 155b580..5f92196 100644 --- a/templates/src/indieauth.rs +++ b/templates/src/indieauth.rs @@ -1,11 +1,13 @@ -use kittybox_indieauth::{AuthorizationRequest, Scope}; +use kittybox_indieauth::{AuthorizationRequest, ClientMetadata, Scope}; use kittybox_util::auth::EnrolledCredential; +const INDIEAUTH_SPEC: &str = "https://indieauth.spec.indieweb.org/20240711/#authorization-request"; + markup::define! { AuthorizationRequestPage( request: AuthorizationRequest, credentials: Vec<EnrolledCredential>, - app: Option<serde_json::Value>, + app: ClientMetadata, user: serde_json::Value ) { script[type="module"] { @@ -37,27 +39,12 @@ document.getElementById("indieauth_page").addEventListener("submit", submit_hand } p."mini-h-card" { - @if let Some(icon) = app - .as_ref() - .and_then(|app| app["properties"]["logo"][0].as_str()) - { - img.app_icon[src=icon]; - } else if let Some(icon) = app - .as_ref() - .and_then(|app| app["properties"]["logo"][0].as_object()) - { - img.app_icon[src=icon["src"].as_str().unwrap(), alt=icon["alt"].as_str().unwrap()]; + @if let Some(icon) = app.logo_uri.as_ref() { + img.app_icon[src=icon.as_str()]; } span { - a[href=app - .as_ref() - .and_then(|app| app["properties"]["url"][0].as_str()) - .unwrap_or_else(|| request.client_id.as_str()) - ] { - @app - .as_ref() - .and_then(|app| app["properties"]["name"][0].as_str()) - .unwrap_or_else(|| request.client_id.as_str()) + a[href=app.client_uri.as_str()] { + @app.client_name.as_deref().unwrap_or_else(|| request.client_id.as_str()) } " wants to confirm your identity." } @@ -152,7 +139,7 @@ document.getElementById("indieauth_page").addEventListener("submit", submit_hand p { "More info about meanings of these fields can be found in " - a[href="https://indieauth.spec.indieweb.org/20220212/#authorization-request"] { + a[href=INDIEAUTH_SPEC] { "the IndieAuth specification" } ", which this webpage uses." } diff --git a/templates/src/lib.rs b/templates/src/lib.rs index b7b9a24..81b1a60 100644 --- a/templates/src/lib.rs +++ b/templates/src/lib.rs @@ -5,7 +5,7 @@ pub use onboarding::OnboardingPage; mod indieauth; pub use indieauth::AuthorizationRequestPage; mod login; -pub use login::LoginPage; +pub use login::{LoginPage, LogoutPage}; mod mf2; pub use mf2::{Entry, VCard, Feed, Food, POSTS_PER_PAGE}; diff --git a/templates/src/login.rs b/templates/src/login.rs index 042c308..ed5dc4e 100644 --- a/templates/src/login.rs +++ b/templates/src/login.rs @@ -14,4 +14,18 @@ markup::define! { } } } + + LogoutPage { + script[type="module"] { + @markup::raw(r#"const form = document.getElementById("logout"); +form.submit(); +"#) + } + form[id="logout", method="POST"] { + p { "You will be logged out automatically if you have JavaScript on." } + noscript { p { "However, you don't seem to have it running. No worries, just press the button to be logged out." } } + + input[type="submit", value="Sign out"]; + } + } } diff --git a/templates/src/templates.rs b/templates/src/templates.rs index 4f7970c..3d22eac 100644 --- a/templates/src/templates.rs +++ b/templates/src/templates.rs @@ -1,10 +1,9 @@ use http::StatusCode; use kittybox_util::MicropubChannel; - use crate::{Feed, VCard}; markup::define! { - Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<MicropubChannel>, user: Option<String>, content: String) { + Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<MicropubChannel>, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) { @markup::doctype() html { head { @@ -41,12 +40,18 @@ markup::define! { li { a[href=&feed.uid] { @feed.name } } } li.shiftright { - @if user.is_none() { - a #login[href="/login/start"] { "Sign in" } - } else { + @if let Some(user) = &user { span { - @user.as_ref().unwrap() " - " a #logout[href="/logout"] { "Sign out" } + @if let Some(kittybox_indieauth::Profile { name: Some(name), photo, .. }) = &user.profile { + a[href=user.me.as_str()] { @name } + } else { + @user.me.as_str() + } + " - " + a #logout[href="/.kittybox/login/logout"] { "Sign out" } } + } else { + a #login[href="/.kittybox/login/start"] { "Sign in" } } } } |