From e88b656a7bd4e87d431249b37db75dec5ecc4e85 Mon Sep 17 00:00:00 2001 From: Vika Date: Wed, 27 Jul 2022 11:10:43 +0300 Subject: indieauth: replace numerous placeholders in the prototype Fetching profiles is now fully implemented. The only missing pieces are the frontend template and the persistent store for tokens and codes. --- kittybox-rs/src/indieauth/mod.rs | 175 +++++++++++++++++++++++++++++++++------ 1 file changed, 150 insertions(+), 25 deletions(-) (limited to 'kittybox-rs/src') diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs index 70b909a..b7d4597 100644 --- a/kittybox-rs/src/indieauth/mod.rs +++ b/kittybox-rs/src/indieauth/mod.rs @@ -4,6 +4,7 @@ use axum::{ http::StatusCode, TypedHeader, headers::{Authorization, authorization::Bearer}, Extension }; +use crate::database::Storage; use kittybox_indieauth::{ Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod, Scope, Scopes, PKCEMethod, Error, ErrorKind, @@ -18,6 +19,8 @@ use backend::AuthBackend; const ACCESS_TOKEN_VALIDITY: u64 = 7 * 24 * 60 * 60; // 7 days 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 async fn metadata( Host(host): Host @@ -71,16 +74,20 @@ async fn authorization_endpoint_get( }.to_string()) } -async fn authorization_endpoint_post( +async fn authorization_endpoint_post( Host(host): Host, Form(auth): Form, - Extension(backend): Extension + Extension(backend): Extension, + Extension(db): Extension ) -> Response { use RequestMaybeAuthorizationEndpoint::*; match auth { Authorization(auth) => { + // Cloning these two values, because we can't destructure + // the AuthorizationRequest - we need it for the code let state = auth.state.clone(); let redirect_uri = auth.redirect_uri.clone(); + let code = match backend.create_code(auth).await { Ok(code) => code, Err(err) => { @@ -89,7 +96,7 @@ async fn authorization_endpoint_post( } }; - let redirect_uri = { + let location = { let mut uri = redirect_uri; uri.set_query(Some(&serde_urlencoded::to_string( AuthorizationResponse { @@ -102,7 +109,7 @@ async fn authorization_endpoint_post( }; (StatusCode::FOUND, - [("Location", redirect_uri.as_str())] + [("Location", location.as_str())] ) .into_response() }, @@ -142,15 +149,28 @@ async fn authorization_endpoint_post( error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok() }.into_response() } - let profile = if request.scope + let me: url::Url = format!("https://{}/", host).parse().unwrap(); + let profile = if request.scope.as_ref() .map(|s| s.has(&Scope::Profile)) .unwrap_or_default() { - Some(todo!()) + match get_profile( + db, + me.as_str(), + request.scope.as_ref() + .map(|s| s.has(&Scope::Email)) + .unwrap_or_default() + ).await { + Ok(profile) => profile, + Err(err) => { + tracing::error!("Error retrieving profile from database: {}", err); + + return StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } } else { None }; - let me = format!("https://{}/", host).parse().unwrap(); GrantResponse::ProfileUrl { me, profile }.into_response() }, @@ -163,10 +183,11 @@ async fn authorization_endpoint_post( } } -async fn token_endpoint_post( +async fn token_endpoint_post( Host(host): Host, Form(grant): Form, - Extension(backend): Extension + Extension(backend): Extension, + Extension(db): Extension ) -> Response { #[inline] fn prepare_access_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData { @@ -252,7 +273,18 @@ async fn token_endpoint_post( } let profile = if scope.has(&Scope::Profile) { - Some(todo!()) + match get_profile( + db, + me.as_str(), + scope.has(&Scope::Email) + ).await { + Ok(profile) => profile, + Err(err) => { + tracing::error!("Error retrieving profile from database: {}", err); + + return StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } } else { None }; @@ -324,7 +356,18 @@ async fn token_endpoint_post( let profile = if scope.has(&Scope::Profile) { - Some(todo!()) + match get_profile( + db, + data.me.as_str(), + scope.has(&Scope::Email) + ).await { + Ok(profile) => profile, + Err(err) => { + tracing::error!("Error retrieving profile from database: {}", err); + + return StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } } else { None }; @@ -366,11 +409,26 @@ async fn token_endpoint_post( } async fn introspection_endpoint_post( - Host(host): Host, Form(token_request): Form, TypedHeader(Authorization(auth_token)): TypedHeader>, Extension(backend): Extension ) -> Response { + use serde_json::json; + // Check authentication first + match backend.get_token(auth_token.token()).await { + Ok(Some(token)) => if !token.scope.has(&Scope::custom(KITTYBOX_TOKEN_STATUS)) { + return (StatusCode::UNAUTHORIZED, Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope + }))).into_response(); + }, + Ok(None) => return (StatusCode::UNAUTHORIZED, Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InvalidToken + }))).into_response(), + Err(err) => { + tracing::error!("Error retrieving token data for introspection: {}", err); + return StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } let response: TokenIntrospectionResponse = match backend.get_token(&token_request.token).await { Ok(maybe_data) => maybe_data.into(), Err(err) => { @@ -383,7 +441,6 @@ async fn introspection_endpoint_post( } async fn revocation_endpoint_post( - Host(host): Host, Form(revocation): Form, Extension(backend): Extension ) -> impl IntoResponse { @@ -399,20 +456,84 @@ async fn revocation_endpoint_post( } } -async fn userinfo_endpoint_get( +async fn get_profile( + db: D, + url: &str, + email: bool +) -> crate::database::Result> { + Ok(db.get_post(url).await?.map(|mut mf2| { + // Ruthlessly manually destructure the MF2 document to save memory + let name = match mf2["properties"]["name"][0].take() { + serde_json::Value::String(s) => Some(s), + _ => None + }; + let url = match mf2["properties"]["uid"][0].take() { + serde_json::Value::String(s) => s.parse().ok(), + _ => None + }; + let photo = match mf2["properties"]["photo"][0].take() { + serde_json::Value::String(s) => s.parse().ok(), + _ => None + }; + let email = if email { + match mf2["properties"]["email"][0].take() { + serde_json::Value::String(s) => Some(s), + _ => None + } + } else { + None + }; + + Profile { name, url, photo, email } + })) +} + +async fn userinfo_endpoint_get( Host(host): Host, TypedHeader(Authorization(auth_token)): TypedHeader>, - Extension(backend): Extension + Extension(backend): Extension, + Extension(db): Extension ) -> Response { - Profile { - name: todo!(), - url: todo!(), - photo: todo!(), - email: Some(todo!()) - }.into_response() + use serde_json::json; + + match backend.get_token(auth_token.token()).await { + Ok(Some(token)) => { + if token.expired() { + return (StatusCode::UNAUTHORIZED, Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InvalidToken + }))).into_response(); + } + if !token.scope.has(&Scope::Profile) { + return (StatusCode::UNAUTHORIZED, Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope + }))).into_response(); + } + + match get_profile(db, &format!("https://{}/", host), token.scope.has(&Scope::Email)).await { + Ok(Some(profile)) => profile.into_response(), + Ok(None) => Json(json!({ + // We do this because ResourceErrorKind is IndieAuth errors only + "error": "invalid_request" + })).into_response(), + Err(err) => { + tracing::error!("Error retrieving profile from database: {}", err); + + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + }, + Ok(None) => Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InvalidToken + })).into_response(), + Err(err) => { + tracing::error!("Error reading token: {}", err); + + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } } -pub fn router(backend: A) -> axum::Router { +pub fn router(backend: A, db: D) -> axum::Router { use axum::routing::{Router, get, post}; Router::new() @@ -422,10 +543,10 @@ pub fn router(backend: A) -> axum::Router { .route( "/auth", get(authorization_endpoint_get) - .post(authorization_endpoint_post::)) + .post(authorization_endpoint_post::)) .route( "/token", - post(token_endpoint_post::)) + post(token_endpoint_post::)) .route( "/token_status", post(introspection_endpoint_post::)) @@ -434,7 +555,7 @@ pub fn router(backend: A) -> axum::Router { post(revocation_endpoint_post::)) .route( "/userinfo", - get(userinfo_endpoint_get::)) + get(userinfo_endpoint_get::)) .layer(tower_http::cors::CorsLayer::new() .allow_methods([ axum::http::Method::GET, @@ -442,6 +563,10 @@ pub fn router(backend: A) -> axum::Router { ]) .allow_origin(tower_http::cors::Any)) .layer(Extension(backend)) + // I don't really like the fact that I have to use the whole database + // If I could, I would've designed a separate trait for getting profiles + // And made databases implement it, for example + .layer(Extension(db)) ) .route( "/.well-known/oauth-authorization-server", -- cgit 1.4.1