about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2024-08-18 00:30:15 +0300
committerVika <vika@fireburn.ru>2024-08-18 00:30:15 +0300
commite43313210269b8e48fe35b17ac416c9ba88ae4f3 (patch)
tree51941c5149351bb32260fb8cbd4293eed80563e0 /src
parentcd8029a930b966225d0a57afb1ee29808fe2a409 (diff)
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
Diffstat (limited to 'src')
-rw-r--r--src/database/mod.rs2
-rw-r--r--src/frontend/mod.rs20
-rw-r--r--src/indieauth/mod.rs96
-rw-r--r--src/lib.rs79
-rw-r--r--src/login.rs355
-rw-r--r--src/main.rs4
6 files changed, 493 insertions, 63 deletions
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>;