diff options
author | Vika <vika@fireburn.ru> | 2025-04-09 23:31:02 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2025-04-09 23:31:57 +0300 |
commit | 8826d9446e6c492db2243b9921e59ce496027bef (patch) | |
tree | 63738aa9001cb73b11cb0e974e93129bcdf1adbb /src/indieauth/mod.rs | |
parent | 519cadfbb298f50cbf819dde757037ab56e2863e (diff) | |
download | kittybox-8826d9446e6c492db2243b9921e59ce496027bef.tar.zst |
cargo fmt
Change-Id: I80e81ebba3f0cdf8c094451c9fe3ee4126b8c888
Diffstat (limited to 'src/indieauth/mod.rs')
-rw-r--r-- | src/indieauth/mod.rs | 727 |
1 files changed, 437 insertions, 290 deletions
diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs index 00ae393..2f90a19 100644 --- a/src/indieauth/mod.rs +++ b/src/indieauth/mod.rs @@ -1,18 +1,29 @@ -use std::marker::PhantomData; -use microformats::types::Class; -use tracing::error; -use serde::Deserialize; +use crate::database::Storage; use axum::{ - extract::{Form, FromRef, Json, Query, State}, http::StatusCode, response::{Html, IntoResponse, Response} + extract::{Form, FromRef, Json, Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Response}, }; #[cfg_attr(not(feature = "webauthn"), allow(unused_imports))] -use axum_extra::extract::{Host, cookie::{CookieJar, Cookie}}; -use axum_extra::{headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, TypedHeader}; -use crate::database::Storage; +use axum_extra::extract::{ + cookie::{Cookie, CookieJar}, + Host, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, + TypedHeader, +}; use kittybox_indieauth::{ - AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData, TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest + AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, + GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, + ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData, + TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, }; +use microformats::types::Class; +use serde::Deserialize; +use std::marker::PhantomData; use std::str::FromStr; +use tracing::error; pub mod backend; #[cfg(feature = "webauthn")] @@ -41,35 +52,42 @@ impl<A: AuthBackend> std::ops::Deref for User<A> { pub enum IndieAuthResourceError { InvalidRequest, Unauthorized, - InvalidToken + 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(), + Unauthorized => { + (StatusCode::UNAUTHORIZED, [("WWW-Authenticate", "Bearer")]).into_response() + } InvalidRequest => ( StatusCode::BAD_REQUEST, - Json(&serde_json::json!({"error": "invalid_request"})) - ).into_response(), + Json(&serde_json::json!({"error": "invalid_request"})), + ) + .into_response(), InvalidToken => ( StatusCode::UNAUTHORIZED, [("WWW-Authenticate", "Bearer, error=\"invalid_token\"")], - Json(&serde_json::json!({"error": "not_authorized"})) - ).into_response() + Json(&serde_json::json!({"error": "not_authorized"})), + ) + .into_response(), } } } -impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::extract::OptionalFromRequestParts<St> for User<A> { +impl<A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> + axum::extract::OptionalFromRequestParts<St> for User<A> +{ type Rejection = <Self as axum::extract::FromRequestParts<St>>::Rejection; - async fn from_request_parts(req: &mut axum::http::request::Parts, state: &St) -> Result<Option<Self>, Self::Rejection> { - let res = <Self as axum::extract::FromRequestParts<St>>::from_request_parts(req, state).await; + async fn from_request_parts( + req: &mut axum::http::request::Parts, + state: &St, + ) -> Result<Option<Self>, Self::Rejection> { + let res = + <Self as axum::extract::FromRequestParts<St>>::from_request_parts(req, state).await; match res { Ok(user) => Ok(Some(user)), @@ -79,14 +97,19 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext } } -impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::extract::FromRequestParts<St> for User<A> { +impl<A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> + axum::extract::FromRequestParts<St> for User<A> +{ type Rejection = IndieAuthResourceError; - async fn from_request_parts(req: &mut axum::http::request::Parts, state: &St) -> Result<Self, Self::Rejection> { + async fn from_request_parts( + req: &mut axum::http::request::Parts, + state: &St, + ) -> Result<Self, Self::Rejection> { let TypedHeader(Authorization(token)) = TypedHeader::<Authorization<Bearer>>::from_request_parts(req, state) - .await - .map_err(|_| IndieAuthResourceError::Unauthorized)?; + .await + .map_err(|_| IndieAuthResourceError::Unauthorized)?; let auth = A::from_ref(state); @@ -94,10 +117,7 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext .await .map_err(|_| IndieAuthResourceError::InvalidRequest)?; - auth.get_token( - &format!("https://{host}/").parse().unwrap(), - token.token() - ) + auth.get_token(&format!("https://{host}/").parse().unwrap(), token.token()) .await .unwrap() .ok_or(IndieAuthResourceError::InvalidToken) @@ -105,9 +125,7 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext } } -pub async fn metadata( - Host(host): Host -) -> Metadata { +pub async fn metadata(Host(host): Host) -> Metadata { let issuer: url::Url = format!("https://{}/", host).parse().unwrap(); let indieauth: url::Url = issuer.join("/.kittybox/indieauth/").unwrap(); @@ -117,18 +135,16 @@ pub async fn metadata( token_endpoint: indieauth.join("token").unwrap(), introspection_endpoint: indieauth.join("token_status").unwrap(), introspection_endpoint_auth_methods_supported: Some(vec![ - IntrospectionEndpointAuthMethod::Bearer + IntrospectionEndpointAuthMethod::Bearer, ]), revocation_endpoint: Some(indieauth.join("revoke_token").unwrap()), - revocation_endpoint_auth_methods_supported: Some(vec![ - RevocationEndpointAuthMethod::None - ]), + revocation_endpoint_auth_methods_supported: Some(vec![RevocationEndpointAuthMethod::None]), scopes_supported: Some(vec![ Scope::Create, Scope::Update, Scope::Delete, Scope::Media, - Scope::Profile + Scope::Profile, ]), response_types_supported: Some(vec![ResponseType::Code]), grant_types_supported: Some(vec![GrantType::AuthorizationCode, GrantType::RefreshToken]), @@ -145,27 +161,39 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( Query(request): Query<AuthorizationRequest>, State(db): State<D>, State(http): State<reqwest_middleware::ClientWithMiddleware>, - State(auth): State<A> + State(auth): State<A>, ) -> Response { 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" { + 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(); + 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); - let metadata_request = http.get(request.client_id.clone()) + 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() - .map_err(reqwest_middleware::Error::Reqwest)) - { - Ok(response) if response.headers().typed_get::<ContentType>().to_owned().map(mime::Mime::from).map(|m| m.type_() == "text" && m.subtype() == "html").unwrap_or(false) => { + match metadata_request.send().await.and_then(|res| { + res.error_for_status() + .map_err(reqwest_middleware::Error::Reqwest) + }) { + Ok(response) + if response + .headers() + .typed_get::<ContentType>() + .to_owned() + .map(mime::Mime::from) + .map(|m| m.type_() == "text" && m.subtype() == "html") + .unwrap_or(false) => + { let url = response.url().clone(); let text = response.text().await.unwrap(); tracing::debug!("Received {} bytes in response", text.len()); @@ -173,76 +201,95 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( Ok(mf2) => { if let Some(relation) = mf2.rels.items.get(&request.redirect_uri) { if !relation.rels.iter().any(|i| i == "redirect_uri") { - return (StatusCode::BAD_REQUEST, - [("Content-Type", "text/plain")], - "The redirect_uri provided was declared as \ - something other than redirect_uri.") - .into_response() + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "The redirect_uri provided was declared as \ + something other than redirect_uri.", + ) + .into_response(); } } else if request.redirect_uri.origin() != request.client_id.origin() { - return (StatusCode::BAD_REQUEST, - [("Content-Type", "text/plain")], - "The redirect_uri didn't match the origin \ - and wasn't explicitly allowed. You were being tricked.") - .into_response() + return ( + StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "The redirect_uri didn't match the origin \ + and wasn't explicitly allowed. You were being tricked.", + ) + .into_response(); } - if let Some(app) = mf2.items + if let Some(app) = mf2 + .items .iter() - .find(|&i| i.r#type.iter() - .any(|i| { + .find(|&i| { + i.r#type.iter().any(|i| { *i == Class::from_str("h-app").unwrap() || *i == Class::from_str("h-x-app").unwrap() }) - ) + }) .cloned() { // Create a synthetic metadata document. Be forgiving. let mut metadata = ClientMetadata::new( request.client_id.clone(), - app.properties.get("url") + app.properties + .get("url") .and_then(|v| v.first()) .and_then(|i| match i { - microformats::types::PropertyValue::Url(url) => Some(url.clone()), - _ => None + microformats::types::PropertyValue::Url(url) => { + Some(url.clone()) + } + _ => None, }) - .unwrap_or_else(|| request.client_id.clone()) - ).unwrap(); + .unwrap_or_else(|| request.client_id.clone()), + ) + .unwrap(); - metadata.client_name = app.properties.get("name") + 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 + microformats::types::PropertyValue::Plain(name) => { + Some(name.to_owned()) + } + _ => None, }); metadata.redirect_uris = mf2.rels.by_rels().remove("redirect_uri"); metadata } else { - return (StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], "No h-app or JSON application metadata found.").into_response() + 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 h-app metadata failed.").into_response() + "Parsing h-app metadata failed.", + ) + .into_response(); } } - }, + } Ok(response) => match response.json::<ClientMetadata>().await { - Ok(client_metadata) => { - client_metadata - }, + 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() + format!("Parsing OAuth2 JSON app metadata failed: {}", err), + ) + .into_response(); } }, Err(err) => { @@ -250,27 +297,32 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( return ( StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], - format!("Fetching app metadata failed: {}", err) - ).into_response() + format!("Fetching app metadata failed: {}", err), + ) + .into_response(); } } }; tracing::debug!("Application metadata: {:#?}", h_app); - Html(kittybox_frontend_renderer::Template { - title: "Confirm sign-in via IndieAuth", - blog_name: "Kittybox", - feeds: vec![], - user: None, - content: kittybox_frontend_renderer::AuthorizationRequestPage { - request, - credentials: auth.list_user_credential_types(&me).await.unwrap(), - user: db.get_post(me.as_str()).await.unwrap().unwrap(), - app: h_app - }.to_string(), - }.to_string()) - .into_response() + Html( + kittybox_frontend_renderer::Template { + title: "Confirm sign-in via IndieAuth", + blog_name: "Kittybox", + feeds: vec![], + user: None, + content: kittybox_frontend_renderer::AuthorizationRequestPage { + request, + credentials: auth.list_user_credential_types(&me).await.unwrap(), + user: db.get_post(me.as_str()).await.unwrap().unwrap(), + app: h_app, + } + .to_string(), + } + .to_string(), + ) + .into_response() } #[derive(Deserialize, Debug)] @@ -278,7 +330,7 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( enum Credential { Password(String), #[cfg(feature = "webauthn")] - WebAuthn(::webauthn::prelude::PublicKeyCredential) + WebAuthn(::webauthn::prelude::PublicKeyCredential), } // The IndieAuth standard doesn't prescribe a format for confirming @@ -291,7 +343,7 @@ enum Credential { #[derive(Deserialize, Debug)] struct AuthorizationConfirmation { authorization_method: Credential, - request: AuthorizationRequest + request: AuthorizationRequest, } #[tracing::instrument(skip(auth, credential))] @@ -299,18 +351,14 @@ async fn verify_credential<A: AuthBackend>( auth: &A, website: &url::Url, credential: Credential, - #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] - challenge_id: Option<&str> + #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] challenge_id: Option<&str>, ) -> std::io::Result<bool> { match credential { Credential::Password(password) => auth.verify_password(website, password).await, #[cfg(feature = "webauthn")] - Credential::WebAuthn(credential) => webauthn::verify( - auth, - website, - credential, - challenge_id.unwrap() - ).await + Credential::WebAuthn(credential) => { + webauthn::verify(auth, website, credential, challenge_id.unwrap()).await + } } } @@ -323,7 +371,8 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( ) -> Response { tracing::debug!("Received authorization confirmation from user"); #[cfg(feature = "webauthn")] - let challenge_id = cookies.get(webauthn::CHALLENGE_ID_COOKIE) + let challenge_id = cookies + .get(webauthn::CHALLENGE_ID_COOKIE) .map(|cookie| cookie.value()); #[cfg(not(feature = "webauthn"))] let challenge_id = None; @@ -331,14 +380,16 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( let website = format!("https://{}/", host).parse().unwrap(); let AuthorizationConfirmation { authorization_method: credential, - request: mut auth + request: mut auth, } = confirmation; match verify_credential(&backend, &website, credential, challenge_id).await { - Ok(verified) => if !verified { - error!("User failed verification, bailing out."); - return StatusCode::UNAUTHORIZED.into_response(); - }, + Ok(verified) => { + if !verified { + error!("User failed verification, bailing out."); + return StatusCode::UNAUTHORIZED.into_response(); + } + } Err(err) => { error!("Error while verifying credential: {}", err); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); @@ -365,9 +416,14 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( let location = { let mut uri = redirect_uri; - uri.set_query(Some(&serde_urlencoded::to_string( - AuthorizationResponse { code, state, iss: website } - ).unwrap())); + uri.set_query(Some( + &serde_urlencoded::to_string(AuthorizationResponse { + code, + state, + iss: website, + }) + .unwrap(), + )); uri }; @@ -375,10 +431,11 @@ async fn authorization_endpoint_confirm<A: AuthBackend>( // DO NOT SET `StatusCode::FOUND` here! `fetch()` cannot read from // redirects, it can only follow them or choose to receive an // opaque response instead that is completely useless - (StatusCode::NO_CONTENT, - [("Location", location.as_str())], - #[cfg(feature = "webauthn")] - cookies.remove(Cookie::from(webauthn::CHALLENGE_ID_COOKIE)) + ( + StatusCode::NO_CONTENT, + [("Location", location.as_str())], + #[cfg(feature = "webauthn")] + cookies.remove(Cookie::from(webauthn::CHALLENGE_ID_COOKIE)), ) .into_response() } @@ -396,15 +453,18 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( code, client_id, redirect_uri, - code_verifier + code_verifier, } => { let request: AuthorizationRequest = match backend.get_code(&code).await { Ok(Some(request)) => request, - Ok(None) => return Error { - kind: ErrorKind::InvalidGrant, - msg: Some("The provided authorization code is invalid.".to_string()), - error_uri: None - }.into_response(), + Ok(None) => { + return Error { + kind: ErrorKind::InvalidGrant, + msg: Some("The provided authorization code is invalid.".to_string()), + error_uri: None, + } + .into_response() + } Err(err) => { tracing::error!("Error retrieving auth request: {}", err); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); @@ -414,51 +474,66 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization code isn't yours.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } if redirect_uri != request.redirect_uri { return Error { kind: ErrorKind::InvalidGrant, - msg: Some("This redirect_uri doesn't match the one the code has been sent to.".to_string()), - error_uri: None - }.into_response() + msg: Some( + "This redirect_uri doesn't match the one the code has been sent to." + .to_string(), + ), + error_uri: None, + } + .into_response(); } if !request.code_challenge.verify(code_verifier) { return Error { kind: ErrorKind::InvalidGrant, msg: Some("The PKCE challenge failed.".to_string()), // are RFCs considered human-readable? 😝 - error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok() - }.into_response() + error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6" + .parse() + .ok(), + } + .into_response(); } let me: url::Url = format!("https://{}/", host).parse().unwrap(); if request.me.unwrap() != me { return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization endpoint does not serve this user.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } - let profile = if request.scope.as_ref() - .map(|s| s.has(&Scope::Profile)) - .unwrap_or_default() + let profile = if request + .scope + .as_ref() + .map(|s| s.has(&Scope::Profile)) + .unwrap_or_default() { match get_profile( db, me.as_str(), - request.scope.as_ref() + request + .scope + .as_ref() .map(|s| s.has(&Scope::Email)) - .unwrap_or_default() - ).await { + .unwrap_or_default(), + ) + .await + { Ok(profile) => { tracing::debug!("Retrieved profile: {:?}", profile); profile - }, + } Err(err) => { tracing::error!("Error retrieving profile from database: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } } else { @@ -466,12 +541,15 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( }; GrantResponse::ProfileUrl(ProfileUrl { me, profile }).into_response() - }, + } _ => Error { kind: ErrorKind::InvalidGrant, msg: Some("The provided grant_type is unusable on this endpoint.".to_string()), - error_uri: "https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code".parse().ok() - }.into_response() + error_uri: "https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code" + .parse() + .ok(), + } + .into_response(), } } @@ -485,36 +563,40 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( #[inline] fn prepare_access_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData { TokenData { - me, client_id, scope, + me, + client_id, + scope, exp: (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - + std::time::Duration::from_secs(ACCESS_TOKEN_VALIDITY)) - .as_secs() - .into(), + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + + std::time::Duration::from_secs(ACCESS_TOKEN_VALIDITY)) + .as_secs() + .into(), iat: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() - .into() + .into(), } } #[inline] fn prepare_refresh_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData { TokenData { - me, client_id, scope, + me, + client_id, + scope, exp: (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - + std::time::Duration::from_secs(REFRESH_TOKEN_VALIDITY)) - .as_secs() - .into(), + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + + std::time::Duration::from_secs(REFRESH_TOKEN_VALIDITY)) + .as_secs() + .into(), iat: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() - .into() + .into(), } } @@ -525,15 +607,18 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( code, client_id, redirect_uri, - code_verifier + code_verifier, } => { let request: AuthorizationRequest = match backend.get_code(&code).await { Ok(Some(request)) => request, - Ok(None) => return Error { - kind: ErrorKind::InvalidGrant, - msg: Some("The provided authorization code is invalid.".to_string()), - error_uri: None - }.into_response(), + Ok(None) => { + return Error { + kind: ErrorKind::InvalidGrant, + msg: Some("The provided authorization code is invalid.".to_string()), + error_uri: None, + } + .into_response() + } Err(err) => { tracing::error!("Error retrieving auth request: {}", err); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); @@ -542,33 +627,46 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( tracing::debug!("Retrieved authorization request: {:?}", request); - let scope = if let Some(scope) = request.scope { scope } else { + let scope = if let Some(scope) = request.scope { + scope + } else { return Error { kind: ErrorKind::InvalidScope, msg: Some("Tokens cannot be issued if no scopes are requested.".to_string()), - error_uri: "https://indieauth.spec.indieweb.org/#access-token-response".parse().ok() - }.into_response(); + error_uri: "https://indieauth.spec.indieweb.org/#access-token-response" + .parse() + .ok(), + } + .into_response(); }; if client_id != request.client_id { return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization code isn't yours.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } if redirect_uri != request.redirect_uri { return Error { kind: ErrorKind::InvalidGrant, - msg: Some("This redirect_uri doesn't match the one the code has been sent to.".to_string()), - error_uri: None - }.into_response() + msg: Some( + "This redirect_uri doesn't match the one the code has been sent to." + .to_string(), + ), + error_uri: None, + } + .into_response(); } if !request.code_challenge.verify(code_verifier) { return Error { kind: ErrorKind::InvalidGrant, msg: Some("The PKCE challenge failed.".to_string()), - error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok() - }.into_response(); + error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6" + .parse() + .ok(), + } + .into_response(); } // Note: we can trust the `request.me` value, since we set @@ -577,30 +675,32 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( return Error { kind: ErrorKind::InvalidGrant, msg: Some("This authorization endpoint does not serve this user.".to_string()), - error_uri: None - }.into_response() + error_uri: None, + } + .into_response(); } let profile = if dbg!(scope.has(&Scope::Profile)) { - match get_profile( - db, - me.as_str(), - scope.has(&Scope::Email) - ).await { + match get_profile(db, me.as_str(), scope.has(&Scope::Email)).await { Ok(profile) => dbg!(profile), Err(err) => { tracing::error!("Error retrieving profile from database: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } } else { None }; - let access_token = match backend.create_token( - prepare_access_token(me.clone(), client_id.clone(), scope.clone()) - ).await { + let access_token = match backend + .create_token(prepare_access_token( + me.clone(), + client_id.clone(), + scope.clone(), + )) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating access token: {}", err); @@ -608,9 +708,10 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( } }; // TODO: only create refresh token if user allows it - let refresh_token = match backend.create_refresh_token( - prepare_refresh_token(me.clone(), client_id, scope.clone()) - ).await { + let refresh_token = match backend + .create_refresh_token(prepare_refresh_token(me.clone(), client_id, scope.clone())) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating refresh token: {}", err); @@ -626,24 +727,28 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( scope: Some(scope), expires_in: Some(ACCESS_TOKEN_VALIDITY), refresh_token: Some(refresh_token), - state: None - }.into_response() - }, + state: None, + } + .into_response() + } GrantRequest::RefreshToken { refresh_token, client_id, - scope + scope, } => { let data = match backend.get_refresh_token(&me, &refresh_token).await { Ok(Some(token)) => token, - Ok(None) => return Error { - kind: ErrorKind::InvalidGrant, - msg: Some("This refresh token is not valid.".to_string()), - error_uri: None - }.into_response(), + Ok(None) => { + return Error { + kind: ErrorKind::InvalidGrant, + msg: Some("This refresh token is not valid.".to_string()), + error_uri: None, + } + .into_response() + } Err(err) => { tracing::error!("Error retrieving refresh token: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; @@ -651,17 +756,22 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( return Error { kind: ErrorKind::InvalidGrant, msg: Some("This refresh token is not yours.".to_string()), - error_uri: None - }.into_response(); + error_uri: None, + } + .into_response(); } let scope = if let Some(scope) = scope { if !data.scope.has_all(scope.as_ref()) { return Error { kind: ErrorKind::InvalidScope, - msg: Some("You can't request additional scopes through the refresh token grant.".to_string()), - error_uri: None - }.into_response(); + msg: Some( + "You can't request additional scopes through the refresh token grant." + .to_string(), + ), + error_uri: None, + } + .into_response(); } scope @@ -670,27 +780,27 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( data.scope }; - let profile = if scope.has(&Scope::Profile) { - match get_profile( - db, - data.me.as_str(), - scope.has(&Scope::Email) - ).await { + 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() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } } else { None }; - let access_token = match backend.create_token( - prepare_access_token(data.me.clone(), client_id.clone(), scope.clone()) - ).await { + let access_token = match backend + .create_token(prepare_access_token( + data.me.clone(), + client_id.clone(), + scope.clone(), + )) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating access token: {}", err); @@ -699,9 +809,14 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( }; let old_refresh_token = refresh_token; - let refresh_token = match backend.create_refresh_token( - prepare_refresh_token(data.me.clone(), client_id, scope.clone()) - ).await { + let refresh_token = match backend + .create_refresh_token(prepare_refresh_token( + data.me.clone(), + client_id, + scope.clone(), + )) + .await + { Ok(token) => token, Err(err) => { tracing::error!("Error creating refresh token: {}", err); @@ -721,8 +836,9 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( scope: Some(scope), expires_in: Some(ACCESS_TOKEN_VALIDITY), refresh_token: Some(refresh_token), - state: None - }.into_response() + state: None, + } + .into_response() } } } @@ -740,26 +856,39 @@ async fn introspection_endpoint_post<A: AuthBackend>( // Check authentication first match backend.get_token(&me, 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(), + 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() + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } - let response: TokenIntrospectionResponse = match backend.get_token(&me, &token_request.token).await { - Ok(maybe_data) => maybe_data.into(), - Err(err) => { - tracing::error!("Error retrieving token data: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - }; + let response: TokenIntrospectionResponse = + match backend.get_token(&me, &token_request.token).await { + Ok(maybe_data) => maybe_data.into(), + Err(err) => { + tracing::error!("Error retrieving token data: {}", err); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; response.into_response() } @@ -787,7 +916,7 @@ async fn revocation_endpoint_post<A: AuthBackend>( async fn get_profile<D: Storage + 'static>( db: D, url: &str, - email: bool + email: bool, ) -> crate::database::Result<Option<Profile>> { fn get_first(v: serde_json::Value) -> Option<String> { match v { @@ -796,10 +925,10 @@ async fn get_profile<D: Storage + 'static>( match a.pop() { Some(serde_json::Value::String(s)) => Some(s), Some(serde_json::Value::Object(mut o)) => o.remove("value").and_then(get_first), - _ => None + _ => None, } - }, - _ => None + } + _ => None, } } @@ -807,15 +936,26 @@ async fn get_profile<D: Storage + 'static>( // Ruthlessly manually destructure the MF2 document to save memory let mut properties = match mf2.as_object_mut().unwrap().remove("properties") { Some(serde_json::Value::Object(props)) => props, - _ => unreachable!() + _ => unreachable!(), }; drop(mf2); let name = properties.remove("name").and_then(get_first); - let url = properties.remove("uid").and_then(get_first).and_then(|u| u.parse().ok()); - let photo = properties.remove("photo").and_then(get_first).and_then(|u| u.parse().ok()); + let url = properties + .remove("uid") + .and_then(get_first) + .and_then(|u| u.parse().ok()); + let photo = properties + .remove("photo") + .and_then(get_first) + .and_then(|u| u.parse().ok()); let email = properties.remove("name").and_then(get_first); - Profile { name, url, photo, email } + Profile { + name, + url, + photo, + email, + } })) } @@ -823,7 +963,7 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( Host(host): Host, TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>, State(backend): State<A>, - State(db): State<D> + State(db): State<D>, ) -> Response { use serde_json::json; @@ -832,14 +972,22 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( match backend.get_token(&me, auth_token.token()).await { Ok(Some(token)) => { if token.expired() { - return (StatusCode::UNAUTHORIZED, Json(json!({ - "error": kittybox_indieauth::ResourceErrorKind::InvalidToken - }))).into_response(); + 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(); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope + })), + ) + .into_response(); } match get_profile(db, me.as_str(), token.scope.has(&Scope::Email)).await { @@ -847,17 +995,19 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( Ok(None) => Json(json!({ // We do this because ResourceErrorKind is IndieAuth errors only "error": "invalid_request" - })).into_response(), + })) + .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(), + })) + .into_response(), Err(err) => { tracing::error!("Error reading token: {}", err); @@ -871,57 +1021,51 @@ where S: Storage + FromRef<St> + 'static, A: AuthBackend + FromRef<St>, reqwest_middleware::ClientWithMiddleware: FromRef<St>, - St: Clone + Send + Sync + 'static + St: Clone + Send + Sync + 'static, { - use axum::routing::{Router, get, post}; + use axum::routing::{get, post, Router}; Router::new() .nest( "/.kittybox/indieauth", Router::new() - .route("/metadata", - get(metadata)) + .route("/metadata", get(metadata)) .route( "/auth", get(authorization_endpoint_get::<A, S>) - .post(authorization_endpoint_post::<A, S>)) - .route( - "/auth/confirm", - post(authorization_endpoint_confirm::<A>)) - .route( - "/token", - post(token_endpoint_post::<A, S>)) - .route( - "/token_status", - post(introspection_endpoint_post::<A>)) - .route( - "/revoke_token", - post(revocation_endpoint_post::<A>)) + .post(authorization_endpoint_post::<A, S>), + ) + .route("/auth/confirm", post(authorization_endpoint_confirm::<A>)) + .route("/token", post(token_endpoint_post::<A, S>)) + .route("/token_status", post(introspection_endpoint_post::<A>)) + .route("/revoke_token", post(revocation_endpoint_post::<A>)) + .route("/userinfo", get(userinfo_endpoint_get::<A, S>)) .route( - "/userinfo", - get(userinfo_endpoint_get::<A, S>)) - - .route("/webauthn/pre_register", - get( - #[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::<A, S>, - #[cfg(not(feature = "webauthn"))] || std::future::ready(axum::http::StatusCode::NOT_FOUND) - ) + "/webauthn/pre_register", + get( + #[cfg(feature = "webauthn")] + webauthn::webauthn_pre_register::<A, S>, + #[cfg(not(feature = "webauthn"))] + || std::future::ready(axum::http::StatusCode::NOT_FOUND), + ), ) - .layer(tower_http::cors::CorsLayer::new() - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST - ]) - .allow_origin(tower_http::cors::Any)) + .layer( + tower_http::cors::CorsLayer::new() + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin(tower_http::cors::Any), + ), ) .route( "/.well-known/oauth-authorization-server", - get(|| std::future::ready( - (StatusCode::FOUND, - [("Location", - "/.kittybox/indieauth/metadata")] - ).into_response() - )) + get(|| { + std::future::ready( + ( + StatusCode::FOUND, + [("Location", "/.kittybox/indieauth/metadata")], + ) + .into_response(), + ) + }), ) } @@ -929,9 +1073,10 @@ where mod tests { #[test] fn test_deserialize_authorization_confirmation() { - use super::{Credential, AuthorizationConfirmation}; + use super::{AuthorizationConfirmation, Credential}; - let confirmation = serde_json::from_str::<AuthorizationConfirmation>(r#"{ + let confirmation = serde_json::from_str::<AuthorizationConfirmation>( + r#"{ "request":{ "response_type": "code", "client_id": "https://quill.p3k.io/", @@ -942,12 +1087,14 @@ mod tests { "scope": "create+media" }, "authorization_method": "swordfish" - }"#).unwrap(); + }"#, + ) + .unwrap(); match confirmation.authorization_method { Credential::Password(password) => assert_eq!(password.as_str(), "swordfish"), #[allow(unreachable_patterns)] - other => panic!("Incorrect credential: {:?}", other) + other => panic!("Incorrect credential: {:?}", other), } assert_eq!(confirmation.request.state.as_ref(), "10101010"); } |