about summary refs log tree commit diff
path: root/kittybox-rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-09-28 03:55:48 +0300
committerVika <vika@fireburn.ru>2022-09-28 03:55:48 +0300
commit6e20a3c51756c2e84290da6ec53b89a5fc58c0fc (patch)
treea360c40dce27b7804001038babd4631476232001 /kittybox-rs
parent2f02bf76a40c971b9404aa0913bc8baa7dfde24c (diff)
downloadkittybox-6e20a3c51756c2e84290da6ec53b89a5fc58c0fc.tar.zst
Use tokens from the auth backend to authenticate for Micropub
Diffstat (limited to 'kittybox-rs')
-rw-r--r--kittybox-rs/indieauth/src/lib.rs5
-rw-r--r--kittybox-rs/src/frontend/onboarding.rs10
-rw-r--r--kittybox-rs/src/indieauth/mod.rs72
-rw-r--r--kittybox-rs/src/lib.rs1
-rw-r--r--kittybox-rs/src/main.rs15
-rw-r--r--kittybox-rs/src/media/mod.rs18
-rw-r--r--kittybox-rs/src/micropub/mod.rs148
-rw-r--r--kittybox-rs/src/micropub/util.rs52
8 files changed, 201 insertions, 120 deletions
diff --git a/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs
index 22dcdbd..a60cc42 100644
--- a/kittybox-rs/indieauth/src/lib.rs
+++ b/kittybox-rs/indieauth/src/lib.rs
@@ -538,6 +538,11 @@ impl TokenData {
             std::time::UNIX_EPOCH + std::time::Duration::from_secs(time)
         })
     }
+
+    /// Check if a certain scope is allowed for this token.
+    pub fn check_scope(&self, scope: &Scope) -> bool {
+        self.scope.has(scope)
+    }
 }
 
 // I don't like this type, because it could've been represented
diff --git a/kittybox-rs/src/frontend/onboarding.rs b/kittybox-rs/src/frontend/onboarding.rs
index b498aed..b460e6a 100644
--- a/kittybox-rs/src/frontend/onboarding.rs
+++ b/kittybox-rs/src/frontend/onboarding.rs
@@ -54,8 +54,12 @@ async fn onboard<D: Storage + 'static>(
 ) -> Result<(), FrontendError> {
     // Create a user to pass to the backend
     // At this point the site belongs to nobody, so it is safe to do
-    let user =
-        crate::tokenauth::User::new(user_uid.as_str(), "https://kittybox.fireburn.ru/", "create");
+    let user = kittybox_indieauth::TokenData {
+        me: user_uid.clone(),
+        client_id: "https://kittybox.fireburn.ru/".parse().unwrap(),
+        scope: kittybox_indieauth::Scopes::new(vec![kittybox_indieauth::Scope::Create]),
+        iat: None, exp: None
+    };
 
     if data.user["type"][0] != "h-card" || data.first_post["type"][0] != "h-entry" {
         return Err(FrontendError::with_code(
@@ -96,7 +100,7 @@ async fn onboard<D: Storage + 'static>(
             .map_err(FrontendError::from)?;
     }
     let (uid, post) = crate::micropub::normalize_mf2(data.first_post, &user);
-    crate::micropub::_post(user, uid, post, db, http)
+    crate::micropub::_post(&user, uid, post, db, http)
         .await
         .map_err(|e| FrontendError {
             msg: "Error while posting the first post".to_string(),
diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs
index 36f207c..6dc9ec6 100644
--- a/kittybox-rs/src/indieauth/mod.rs
+++ b/kittybox-rs/src/indieauth/mod.rs
@@ -1,3 +1,5 @@
+use std::marker::PhantomData;
+
 use tracing::error;
 use serde::Deserialize;
 use axum::{
@@ -27,6 +29,76 @@ const REFRESH_TOKEN_VALIDITY: u64 = ACCESS_TOKEN_VALIDITY / 7 * 60; // 60 days
 /// Internal scope for accessing the token introspection endpoint.
 const KITTYBOX_TOKEN_STATUS: &str = "kittybox:token_status";
 
+pub(crate) struct User<A: AuthBackend>(pub(crate) TokenData, pub(crate) PhantomData<A>);
+impl<A: AuthBackend> std::fmt::Debug for User<A> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_tuple("User").field(&self.0).finish()
+    }
+}
+impl<A: AuthBackend> std::ops::Deref for User<A> {
+    type Target = TokenData;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+pub enum IndieAuthResourceError {
+    InvalidRequest,
+    Unauthorized,
+    InvalidToken
+}
+impl axum::response::IntoResponse for IndieAuthResourceError {
+    fn into_response(self) -> axum::response::Response {
+        use IndieAuthResourceError::*;
+
+        match self {
+            Unauthorized => (
+                StatusCode::UNAUTHORIZED,
+                [("WWW-Authenticate", "Bearer")]
+            ).into_response(),
+            InvalidRequest => (
+                StatusCode::BAD_REQUEST,
+                Json(&serde_json::json!({"error": "invalid_request"}))
+            ).into_response(),
+            InvalidToken => (
+                StatusCode::UNAUTHORIZED,
+                [("WWW-Authenticate", "Bearer, error=\"invalid_token\"")],
+                Json(&serde_json::json!({"error": "unauthorized"}))
+            ).into_response()
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl <B: Send, A: AuthBackend> axum::extract::FromRequest<B> for User<A> {
+    type Rejection = IndieAuthResourceError;
+
+    async fn from_request(req: &mut axum::extract::RequestParts<B>) -> Result<Self, Self::Rejection> {
+        let TypedHeader(Authorization(token)) =
+            TypedHeader::<Authorization<Bearer>>::from_request(req)
+            .await
+            .map_err(|_| IndieAuthResourceError::Unauthorized)?;
+
+        let axum::Extension(auth) = axum::Extension::<A>::from_request(req)
+            .await
+            .unwrap();
+
+        let Host(host) = Host::from_request(req)
+            .await
+            .map_err(|_| IndieAuthResourceError::InvalidRequest)?;
+        
+        auth.get_token(
+            &format!("https://{host}/").parse().unwrap(),
+            token.token()
+        )
+            .await
+            .unwrap()
+            .ok_or(IndieAuthResourceError::InvalidToken)
+            .map(|t| User(t, PhantomData))
+    }
+}
+
 pub async fn metadata(
     Host(host): Host
 ) -> Metadata {
diff --git a/kittybox-rs/src/lib.rs b/kittybox-rs/src/lib.rs
index a75c3ea..7084601 100644
--- a/kittybox-rs/src/lib.rs
+++ b/kittybox-rs/src/lib.rs
@@ -4,7 +4,6 @@
 /// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database.
 pub mod database;
 pub mod frontend;
-pub mod tokenauth;
 pub mod media;
 pub mod micropub;
 pub mod indieauth;
diff --git a/kittybox-rs/src/main.rs b/kittybox-rs/src/main.rs
index 796903b..ad76042 100644
--- a/kittybox-rs/src/main.rs
+++ b/kittybox-rs/src/main.rs
@@ -123,11 +123,15 @@ async fn main() {
             ));
 
         let micropub = axum::Router::new()
-            .route("/.kittybox/micropub", kittybox::micropub::router(database.clone(), http.clone()))
+            .route("/.kittybox/micropub", kittybox::micropub::router(
+                database.clone(),
+                http.clone(),
+                auth_backend.clone()
+            ))
             .nest("/.kittybox/micropub/client", kittybox::companion::router());
 
         let media = axum::Router::new()
-            .nest("/.kittybox/media", kittybox::media::router(blobstore).layer(axum::Extension(http)));
+            .nest("/.kittybox/media", kittybox::media::router(blobstore, auth_backend.clone()));
 
         let indieauth = kittybox::indieauth::router(auth_backend, database.clone());
 
@@ -165,13 +169,6 @@ async fn main() {
             .merge(media)
             .merge(indieauth)
             .merge(technical)
-            .layer(
-                axum::Extension(
-                    kittybox::tokenauth::TokenEndpoint(
-                        "https://tokens.indieauth.com/token".parse().unwrap()
-                    )
-                )
-            )
             .layer(tower::ServiceBuilder::new()
                    .layer(tower_http::trace::TraceLayer::new_for_http())
                    .into_inner())
diff --git a/kittybox-rs/src/media/mod.rs b/kittybox-rs/src/media/mod.rs
index a8ae6f9..0ce2ec9 100644
--- a/kittybox-rs/src/media/mod.rs
+++ b/kittybox-rs/src/media/mod.rs
@@ -1,9 +1,10 @@
 use axum::{
-    extract::{Extension, Host, multipart::{Multipart, MultipartError}, Path},
+    extract::{Extension, Host, multipart::Multipart, Path},
     response::{IntoResponse, Response}, headers::HeaderValue,
 };
 use kittybox_util::error::{MicropubError, ErrorType};
-use crate::tokenauth::User;
+use kittybox_indieauth::Scope;
+use crate::indieauth::{User, backend::AuthBackend};
 
 pub mod storage;
 use storage::{MediaStore, MediaStoreError, Metadata, ErrorKind};
@@ -19,12 +20,12 @@ impl From<MediaStoreError> for MicropubError {
 }
 
 #[tracing::instrument(skip(blobstore))]
-pub async fn upload<S: MediaStore>(
+pub(crate) async fn upload<S: MediaStore, A: AuthBackend>(
     mut upload: Multipart,
     Extension(blobstore): Extension<S>,
-    user: User
+    user: User<A>
 ) -> Response {
-    if !user.check_scope("media") {
+    if !user.check_scope(&Scope::Media) {
         return MicropubError {
             error: ErrorType::NotAuthorized,
             error_description: "Interacting with the media storage requires the \"media\" scope.".to_owned()
@@ -61,7 +62,7 @@ pub async fn upload<S: MediaStore>(
 }
 
 #[tracing::instrument(skip(blobstore))]
-pub async fn serve<S: MediaStore>(
+pub(crate) async fn serve<S: MediaStore>(
     Host(host): Host,
     Path(path): Path<String>,
     Extension(blobstore): Extension<S>
@@ -103,9 +104,10 @@ pub async fn serve<S: MediaStore>(
     }
 }
 
-pub fn router<S: MediaStore>(blobstore: S) -> axum::Router {
+pub fn router<S: MediaStore, A: AuthBackend>(blobstore: S, auth: A) -> axum::Router {
     axum::Router::new()
-        .route("/", axum::routing::post(upload::<S>))
+        .route("/", axum::routing::post(upload::<S, A>))
         .route("/uploads/*file", axum::routing::get(serve::<S>))
         .layer(axum::Extension(blobstore))
+        .layer(axum::Extension(auth))
 }
diff --git a/kittybox-rs/src/micropub/mod.rs b/kittybox-rs/src/micropub/mod.rs
index d0aeae0..0f7441e 100644
--- a/kittybox-rs/src/micropub/mod.rs
+++ b/kittybox-rs/src/micropub/mod.rs
@@ -1,16 +1,16 @@
 use crate::database::{MicropubChannel, Storage, StorageError};
-use crate::tokenauth::User;
+use crate::indieauth::backend::AuthBackend;
+use crate::indieauth::User;
 use crate::micropub::util::form_to_mf2_json;
-use axum::extract::{BodyStream, Query};
+use axum::extract::{BodyStream, Query, Host};
 use axum::headers::ContentType;
 use axum::response::{IntoResponse, Response};
 use axum::TypedHeader;
 use axum::{http::StatusCode, Extension};
 use serde::{Deserialize, Serialize};
 use serde_json::json;
-use std::fmt::Display;
 use tracing::{debug, error, info, warn};
-
+use kittybox_indieauth::{Scope, TokenData};
 use kittybox_util::{MicropubError, ErrorType};
 
 #[derive(Serialize, Deserialize, Debug, PartialEq)]
@@ -65,8 +65,7 @@ fn populate_reply_context(
                 .iter()
                 .find(|ctx| Some(ctx.url.as_str()) == i.as_str())
                 .and_then(|ctx| ctx.mf2["items"].get(0))
-                .or(Some(i))
-                .unwrap())
+                .unwrap_or(i))
             .collect::<Vec<&serde_json::Value>>())
     })
 }
@@ -218,7 +217,7 @@ async fn background_processing<D: 'static + Storage>(
 
 // TODO actually save the post to the database and schedule post-processing
 pub(crate) async fn _post<D: 'static + Storage>(
-    user: User,
+    user: &TokenData,
     uid: String,
     mf2: serde_json::Value,
     db: D,
@@ -234,7 +233,7 @@ pub(crate) async fn _post<D: 'static + Storage>(
     //   - The MF2-JSON document's author is set
 
     // Security check! Do we have an OAuth2 scope to proceed?
-    if !user.check_scope("create") {
+    if !user.check_scope(&Scope::Create) {
         return Err(MicropubError {
             error: ErrorType::InvalidScope,
             error_description: "Not enough privileges - try acquiring the \"create\" scope."
@@ -292,7 +291,7 @@ pub(crate) async fn _post<D: 'static + Storage>(
             db.update_post(chan, json!({"add": {"children": [uid]}}))
                 .await?;
         } else if default_channels.iter().any(|i| chan == i) {
-            util::create_feed(&db, &uid, chan, &user).await?;
+            util::create_feed(&db, &uid, chan, user).await?;
         } else {
             warn!("Ignoring non-existent channel: {}", chan);
         }
@@ -344,10 +343,10 @@ impl From<MicropubFormAction> for MicropubAction {
 }
 
 #[tracing::instrument(skip(db))]
-async fn post_action<D: Storage>(
+async fn post_action<D: Storage, A: AuthBackend>(
     action: MicropubAction,
     db: D,
-    user: User,
+    user: User<A>,
 ) -> Result<(), MicropubError> {
     let uri = if let Ok(uri) = action.url.parse::<hyper::Uri>() {
         uri
@@ -375,7 +374,7 @@ async fn post_action<D: Storage>(
 
     match action.action {
         ActionType::Delete => {
-            if !user.check_scope("delete") {
+            if !user.check_scope(&Scope::Delete) {
                 return Err(MicropubError {
                     error: ErrorType::InvalidScope,
                     error_description: "You need a \"delete\" scope for this.".to_owned(),
@@ -385,7 +384,7 @@ async fn post_action<D: Storage>(
             db.delete_post(&action.url).await?
         }
         ActionType::Update => {
-            if !user.check_scope("update") {
+            if !user.check_scope(&Scope::Update) {
                 return Err(MicropubError {
                     error: ErrorType::InvalidScope,
                     error_description: "You need an \"update\" scope for this.".to_owned(),
@@ -468,10 +467,10 @@ async fn dispatch_body(
 }
 
 #[tracing::instrument(skip(db, http))]
-pub async fn post<D: Storage + 'static>(
+pub(crate) async fn post<D: Storage + 'static, A: AuthBackend>(
     Extension(db): Extension<D>,
     Extension(http): Extension<reqwest::Client>,
-    user: User,
+    user: User<A>,
     body: BodyStream,
     TypedHeader(content_type): TypedHeader<ContentType>,
 ) -> axum::response::Response {
@@ -482,7 +481,7 @@ pub async fn post<D: Storage + 'static>(
         },
         Ok(PostBody::MF2(mf2)) => {
             let (uid, mf2) = normalize_mf2(mf2, &user);
-            match _post(user, uid, mf2, db, http).await {
+            match _post(&user, uid, mf2, db, http).await {
                 Ok(response) => response,
                 Err(err) => err.into_response(),
             }
@@ -492,30 +491,40 @@ pub async fn post<D: Storage + 'static>(
 }
 
 #[tracing::instrument(skip(db))]
-pub async fn query<D: Storage>(
+pub(crate) async fn query<D: Storage, A: AuthBackend>(
     Extension(db): Extension<D>,
     query: Option<Query<MicropubQuery>>,
-    user: User,
+    Host(host): Host,
+    user: User<A>,
 ) -> axum::response::Response {
     // We handle the invalid query case manually to return a
     // MicropubError instead of HTTP 422
-    if query.is_none() {
+    let query = if let Some(Query(query)) = query {
+        query
+    } else {
         return MicropubError::new(
             ErrorType::InvalidRequest,
             "Invalid query provided. Try ?q=config to see what you can do."
         ).into_response();
-    }
-    let query: MicropubQuery = query.unwrap().0;
+    };
 
-    let host = axum::http::Uri::try_from(user.me.as_str())
+    if axum::http::Uri::try_from(user.me.as_str())
         .unwrap()
         .authority()
         .unwrap()
-        .clone();
+        != &host
+    {
+        return MicropubError::new(
+            ErrorType::NotAuthorized,
+            "This website doesn't belong to you.",
+        )
+            .into_response();
+    }
 
+    
     match query.q {
         QueryType::Config => {
-            let channels: Vec<MicropubChannel> = match db.get_channels(host.as_str()).await {
+            let channels: Vec<MicropubChannel> = match db.get_channels(user.me.as_str()).await {
                 Ok(chans) => chans,
                 Err(err) => {
                     return MicropubError::new(
@@ -534,26 +543,15 @@ pub async fn query<D: Storage>(
                     QueryType::SyndicateTo
                 ],
                 "channels": channels,
-                "_kittybox_authority": host.as_str(),
-                "syndicate-to": []
+                "_kittybox_authority": user.me.as_str(),
+                "syndicate-to": [],
+                "media_endpoint": user.me.join("/.kittybox/media").unwrap().as_str()
             }))
             .into_response()
         }
         QueryType::Source => {
             match query.url {
                 Some(url) => {
-                    if axum::http::Uri::try_from(&url)
-                        .unwrap()
-                        .authority()
-                        .unwrap()
-                        != &host
-                    {
-                        return MicropubError::new(
-                            ErrorType::NotAuthorized,
-                            "You are requesting a post from a website that doesn't belong to you.",
-                        )
-                        .into_response();
-                    }
                     match db.get_post(&url).await {
                         Ok(some) => match some {
                             Some(post) => axum::response::Json(&post).into_response(),
@@ -582,7 +580,7 @@ pub async fn query<D: Storage>(
                 }
             }
         }
-        QueryType::Channel => match db.get_channels(host.as_str()).await {
+        QueryType::Channel => match db.get_channels(user.me.as_str()).await {
             Ok(chans) => axum::response::Json(json!({ "channels": chans })).into_response(),
             Err(err) => MicropubError::new(
                 ErrorType::InternalServerError,
@@ -596,9 +594,17 @@ pub async fn query<D: Storage>(
     }
 }
 
-pub fn router<S: Storage + 'static>(storage: S, http: reqwest::Client) -> axum::routing::MethodRouter {
-    axum::routing::get(query::<S>)
-        .post(post::<S>)
+pub fn router<S, A>(
+    storage: S,
+    http: reqwest::Client,
+    auth: A
+) -> axum::routing::MethodRouter
+where
+    S: Storage + 'static,
+    A: AuthBackend
+{
+    axum::routing::get(query::<S, A>)
+        .post(post::<S, A>)
         .layer(tower_http::cors::CorsLayer::new()
                .allow_methods([
                    axum::http::Method::GET,
@@ -607,6 +613,7 @@ pub fn router<S: Storage + 'static>(storage: S, http: reqwest::Client) -> axum::
                .allow_origin(tower_http::cors::Any))
         .layer(axum::Extension(storage))
         .layer(axum::Extension(http))
+        .layer(axum::Extension(auth))
 }
 
 #[cfg(test)]
@@ -629,11 +636,13 @@ impl MicropubQuery {
 
 #[cfg(test)]
 mod tests {
-    use crate::{database::Storage, micropub::MicropubError, tokenauth::User};
+    use crate::{database::Storage, micropub::MicropubError};
     use hyper::body::HttpBody;
     use serde_json::json;
 
     use super::FetchedPostContext;
+    use kittybox_indieauth::{Scopes, Scope, TokenData};
+    use axum::extract::Host;
 
     #[test]
     fn test_populate_reply_context() {
@@ -682,14 +691,15 @@ mod tests {
                 "content": ["Hello world!"]
             }
         });
-        let user = User::new(
-            "https://localhost:8080/",
-            "https://kittybox.fireburn.ru/",
-            "profile",
-        );
+        let user = TokenData {
+            me: "https://localhost:8080/".parse().unwrap(),
+            client_id: "https://kittybox.fireburn.ru/".parse().unwrap(),
+            scope: Scopes::new(vec![Scope::Profile]),
+            iat: None, exp: None
+        };
         let (uid, mf2) = super::normalize_mf2(post, &user);
 
-        let err = super::_post(user, uid, mf2, db.clone(), reqwest::Client::new())
+        let err = super::_post(&user, uid, mf2, db.clone(), reqwest::Client::new())
             .await
             .unwrap_err();
 
@@ -711,14 +721,15 @@ mod tests {
                 "url": ["https://fireburn.ru/posts/hello"]
             }
         });
-        let user = User::new(
-            "https://aaronparecki.com/",
-            "https://kittybox.fireburn.ru/",
-            "create update media",
-        );
+        let user = TokenData {
+            me: "https://aaronparecki.com/".parse().unwrap(),
+            client_id: "https://kittybox.fireburn.ru/".parse().unwrap(),
+            scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]),
+            iat: None, exp: None
+        };
         let (uid, mf2) = super::normalize_mf2(post, &user);
 
-        let err = super::_post(user, uid, mf2, db.clone(), reqwest::Client::new())
+        let err = super::_post(&user, uid, mf2, db.clone(), reqwest::Client::new())
             .await
             .unwrap_err();
 
@@ -738,14 +749,15 @@ mod tests {
                 "content": ["Hello world!"]
             }
         });
-        let user = User::new(
-            "https://localhost:8080/",
-            "https://kittybox.fireburn.ru/",
-            "create",
-        );
+        let user = TokenData {
+            me: "https://localhost:8080/".parse().unwrap(),
+            client_id: "https://kittybox.fireburn.ru/".parse().unwrap(),
+            scope: Scopes::new(vec![Scope::Profile, Scope::Create]),
+            iat: None, exp: None
+        };
         let (uid, mf2) = super::normalize_mf2(post, &user);
 
-        let res = super::_post(user, uid, mf2, db.clone(), reqwest::Client::new())
+        let res = super::_post(&user, uid, mf2, db.clone(), reqwest::Client::new())
             .await
             .unwrap();
 
@@ -765,11 +777,15 @@ mod tests {
             Some(axum::extract::Query(super::MicropubQuery::source(
                 "https://aaronparecki.com/feeds/main",
             ))),
-            User::new(
-                "https://fireburn.ru/",
-                "https://quill.p3k.io/",
-                "create update media",
-            ),
+            Host("aaronparecki.com".to_owned()),
+            crate::indieauth::User::<crate::indieauth::backend::fs::FileBackend>(
+                TokenData {
+                    me: "https://fireburn.ru/".parse().unwrap(),
+                    client_id: "https://kittybox.fireburn.ru/".parse().unwrap(),
+                    scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]),
+                    iat: None, exp: None
+                }, std::marker::PhantomData
+            )
         )
         .await;
 
diff --git a/kittybox-rs/src/micropub/util.rs b/kittybox-rs/src/micropub/util.rs
index 7c6a0b1..5097878 100644
--- a/kittybox-rs/src/micropub/util.rs
+++ b/kittybox-rs/src/micropub/util.rs
@@ -1,5 +1,5 @@
 use crate::database::Storage;
-use crate::tokenauth::User;
+use kittybox_indieauth::TokenData;
 use chrono::prelude::*;
 use core::iter::Iterator;
 use newbase60::num_to_sxg;
@@ -33,7 +33,7 @@ fn reset_dt(post: &mut serde_json::Value) -> DateTime<FixedOffset> {
     chrono::DateTime::from(curtime)
 }
 
-pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde_json::Value) {
+pub fn normalize_mf2(mut body: serde_json::Value, user: &TokenData) -> (String, serde_json::Value) {
     // Normalize the MF2 object here.
     let me = &user.me;
     let folder = get_folder_from_type(body["type"][0].as_str().unwrap());
@@ -190,7 +190,7 @@ pub(crate) async fn create_feed(
     storage: &impl Storage,
     uid: &str,
     channel: &str,
-    user: &User,
+    user: &TokenData,
 ) -> crate::database::Result<()> {
     let path = url::Url::parse(channel).unwrap().path().to_string();
 
@@ -220,6 +220,16 @@ mod tests {
     use super::*;
     use serde_json::json;
 
+    fn token_data() -> TokenData {
+        TokenData {
+            me: "https://fireburn.ru/".parse().unwrap(),
+            client_id: "https://quill.p3k.io/".parse().unwrap(),
+            scope: kittybox_indieauth::Scopes::new(vec![kittybox_indieauth::Scope::Create]),
+            exp: Some(u64::MAX),
+            iat: Some(0)
+        }
+    }
+
     #[test]
     fn test_form_to_mf2() {
         assert_eq!(
@@ -248,11 +258,7 @@ mod tests {
 
         let (uid, normalized) = normalize_mf2(
             mf2.clone(),
-            &User::new(
-                "https://fireburn.ru/",
-                "https://quill.p3k.io/",
-                "create update media",
-            ),
+            &token_data(),
         );
         assert_eq!(
             normalized["properties"]["uid"][0], mf2["properties"]["uid"][0],
@@ -277,11 +283,7 @@ mod tests {
 
         let (_, normalized) = normalize_mf2(
             mf2.clone(),
-            &User::new(
-                "https://fireburn.ru/",
-                "https://quill.p3k.io/",
-                "create update media",
-            ),
+            &token_data(),
         );
 
         assert_eq!(
@@ -303,11 +305,7 @@ mod tests {
 
         let (_, normalized) = normalize_mf2(
             mf2.clone(),
-            &User::new(
-                "https://fireburn.ru/",
-                "https://quill.p3k.io/",
-                "create update media",
-            ),
+            &token_data(),
         );
 
         assert_eq!(
@@ -327,11 +325,7 @@ mod tests {
 
         let (uid, post) = normalize_mf2(
             mf2,
-            &User::new(
-                "https://fireburn.ru/",
-                "https://quill.p3k.io/",
-                "create update media",
-            ),
+            &token_data(),
         );
         assert_eq!(
             post["properties"]["published"]
@@ -396,11 +390,7 @@ mod tests {
 
         let (_, post) = normalize_mf2(
             mf2,
-            &User::new(
-                "https://fireburn.ru/",
-                "https://quill.p3k.io/",
-                "create update media",
-            ),
+            &token_data(),
         );
         assert!(
             post["properties"]["url"]
@@ -429,11 +419,7 @@ mod tests {
 
         let (uid, post) = normalize_mf2(
             mf2,
-            &User::new(
-                "https://fireburn.ru/",
-                "https://quill.p3k.io/",
-                "create update media",
-            ),
+            &token_data(),
         );
         assert_eq!(
             post["properties"]["uid"][0], uid,