diff options
author | Vika <vika@fireburn.ru> | 2022-07-27 11:10:43 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2022-07-27 11:10:43 +0300 |
commit | e88b656a7bd4e87d431249b37db75dec5ecc4e85 (patch) | |
tree | 2eb9cb5bacebe9d8a0771834f531fc455e283a87 /kittybox-rs | |
parent | 63bb7a3d602cacbf93e3e9666b48ee9e586a3e8f (diff) | |
download | kittybox-e88b656a7bd4e87d431249b37db75dec5ecc4e85.tar.zst |
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.
Diffstat (limited to 'kittybox-rs')
-rw-r--r-- | kittybox-rs/indieauth/src/lib.rs | 9 | ||||
-rw-r--r-- | kittybox-rs/src/indieauth/mod.rs | 175 |
2 files changed, 156 insertions, 28 deletions
diff --git a/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs index 1f84270..745ee1e 100644 --- a/kittybox-rs/indieauth/src/lib.rs +++ b/kittybox-rs/indieauth/src/lib.rs @@ -208,11 +208,14 @@ impl axum_core::response::IntoResponse for Metadata { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Profile { /// User's chosen name. - pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, /// User's profile page. Fetching it may reveal an `h-card`. - pub url: Url, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option<Url>, /// User's profile picture suitable to represent them. - pub photo: Url, + #[serde(skip_serializing_if = "Option::is_none")] + pub photo: Option<Url>, /// User's email, if they've chosen to reveal it. This is guarded /// by the `email` scope. #[serde(skip_serializing_if = "Option::is_none")] 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<A: AuthBackend>( +async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( Host(host): Host, Form(auth): Form<RequestMaybeAuthorizationEndpoint>, - Extension(backend): Extension<A> + Extension(backend): Extension<A>, + Extension(db): Extension<D> ) -> 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<A: AuthBackend>( } }; - 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<A: AuthBackend>( }; (StatusCode::FOUND, - [("Location", redirect_uri.as_str())] + [("Location", location.as_str())] ) .into_response() }, @@ -142,15 +149,28 @@ async fn authorization_endpoint_post<A: AuthBackend>( 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<A: AuthBackend>( } } -async fn token_endpoint_post<A: AuthBackend>( +async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( Host(host): Host, Form(grant): Form<GrantRequest>, - Extension(backend): Extension<A> + Extension(backend): Extension<A>, + Extension(db): Extension<D> ) -> 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<A: AuthBackend>( } 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<A: AuthBackend>( 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<A: AuthBackend>( } async fn introspection_endpoint_post<A: AuthBackend>( - Host(host): Host, Form(token_request): Form<TokenIntrospectionRequest>, TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>, Extension(backend): Extension<A> ) -> 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<A: AuthBackend>( } async fn revocation_endpoint_post<A: AuthBackend>( - Host(host): Host, Form(revocation): Form<TokenRevocationRequest>, Extension(backend): Extension<A> ) -> impl IntoResponse { @@ -399,20 +456,84 @@ async fn revocation_endpoint_post<A: AuthBackend>( } } -async fn userinfo_endpoint_get<A: AuthBackend>( +async fn get_profile<D: Storage + 'static>( + db: D, + url: &str, + email: bool +) -> crate::database::Result<Option<Profile>> { + 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<A: AuthBackend, D: Storage + 'static>( Host(host): Host, TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>, - Extension(backend): Extension<A> + Extension(backend): Extension<A>, + Extension(db): Extension<D> ) -> 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<A: AuthBackend>(backend: A) -> axum::Router { +pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D) -> axum::Router { use axum::routing::{Router, get, post}; Router::new() @@ -422,10 +543,10 @@ pub fn router<A: AuthBackend>(backend: A) -> axum::Router { .route( "/auth", get(authorization_endpoint_get) - .post(authorization_endpoint_post::<A>)) + .post(authorization_endpoint_post::<A, D>)) .route( "/token", - post(token_endpoint_post::<A>)) + post(token_endpoint_post::<A, D>)) .route( "/token_status", post(introspection_endpoint_post::<A>)) @@ -434,7 +555,7 @@ pub fn router<A: AuthBackend>(backend: A) -> axum::Router { post(revocation_endpoint_post::<A>)) .route( "/userinfo", - get(userinfo_endpoint_get::<A>)) + get(userinfo_endpoint_get::<A, D>)) .layer(tower_http::cors::CorsLayer::new() .allow_methods([ axum::http::Method::GET, @@ -442,6 +563,10 @@ pub fn router<A: AuthBackend>(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", |