diff options
Diffstat (limited to 'kittybox-rs/src/indieauth/mod.rs')
-rw-r--r-- | kittybox-rs/src/indieauth/mod.rs | 883 |
1 files changed, 0 insertions, 883 deletions
diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs deleted file mode 100644 index 0ad2702..0000000 --- a/kittybox-rs/src/indieauth/mod.rs +++ /dev/null @@ -1,883 +0,0 @@ -use std::marker::PhantomData; - -use tracing::error; -use serde::Deserialize; -use axum::{ - extract::{Query, Json, Host, Form}, - response::{Html, IntoResponse, Response}, - http::StatusCode, TypedHeader, headers::{Authorization, authorization::Bearer}, - Extension -}; -#[cfg_attr(not(feature = "webauthn"), allow(unused_imports))] -use axum_extra::extract::cookie::{CookieJar, Cookie}; -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 -}; -use std::str::FromStr; -use std::ops::Deref; - -pub mod backend; -#[cfg(feature = "webauthn")] -mod webauthn; -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(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 <S: Send + Sync, A: AuthBackend> axum::extract::FromRequestParts<S> for User<A> { - type Rejection = IndieAuthResourceError; - - async fn from_request_parts(req: &mut axum::http::request::Parts, state: &S) -> Result<Self, Self::Rejection> { - let TypedHeader(Authorization(token)) = - TypedHeader::<Authorization<Bearer>>::from_request_parts(req, state) - .await - .map_err(|_| IndieAuthResourceError::Unauthorized)?; - - let axum::Extension(auth) = axum::Extension::<A>::from_request_parts(req, state) - .await - .unwrap(); - - let Host(host) = Host::from_request_parts(req, state) - .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 { - let issuer: url::Url = format!( - "{}://{}/", - if cfg!(debug_assertions) { - "http" - } else { - "https" - }, - host - ).parse().unwrap(); - - let indieauth: url::Url = issuer.join("/.kittybox/indieauth/").unwrap(); - Metadata { - issuer, - authorization_endpoint: indieauth.join("auth").unwrap(), - token_endpoint: indieauth.join("token").unwrap(), - introspection_endpoint: indieauth.join("token_status").unwrap(), - introspection_endpoint_auth_methods_supported: Some(vec![ - IntrospectionEndpointAuthMethod::Bearer - ]), - revocation_endpoint: Some(indieauth.join("revoke_token").unwrap()), - revocation_endpoint_auth_methods_supported: Some(vec![ - RevocationEndpointAuthMethod::None - ]), - scopes_supported: Some(vec![ - Scope::Create, - Scope::Update, - Scope::Delete, - Scope::Media, - Scope::Profile - ]), - response_types_supported: Some(vec![ResponseType::Code]), - grant_types_supported: Some(vec![GrantType::AuthorizationCode, GrantType::RefreshToken]), - service_documentation: None, - code_challenge_methods_supported: vec![PKCEMethod::S256], - authorization_response_iss_parameter_supported: Some(true), - userinfo_endpoint: Some(indieauth.join("userinfo").unwrap()), - } -} - -async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( - Host(host): Host, - Query(request): Query<AuthorizationRequest>, - Extension(db): Extension<D>, - Extension(http): Extension<reqwest::Client>, - Extension(auth): Extension<A> -) -> Response { - let me = format!("https://{host}/").parse().unwrap(); - let h_app = { - tracing::debug!("Sending request to {} to fetch metadata", request.client_id); - match http.get(request.client_id.clone()).send().await { - Ok(response) => { - let url = response.url().clone(); - let text = response.text().await.unwrap(); - tracing::debug!("Received {} bytes in response", text.len()); - match microformats::from_html(&text, url) { - 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() - } - } 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() - } - - mf2.items.iter() - .cloned() - .find(|i| (**i).borrow().r#type.iter() - .any(|i| *i == microformats::types::Class::from_str("h-app").unwrap() - || *i == microformats::types::Class::from_str("h-x-app").unwrap())) - .map(|i| serde_json::to_value(i.borrow().deref()).unwrap()) - }, - Err(err) => { - tracing::error!("Error parsing application metadata: {}", err); - return (StatusCode::BAD_REQUEST, - [("Content-Type", "text/plain")], - "Parsing application metadata failed.").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() - } - } - }; - - 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() -} - -#[derive(Deserialize, Debug)] -#[serde(untagged)] -enum Credential { - Password(String), - #[cfg(feature = "webauthn")] - WebAuthn(::webauthn::prelude::PublicKeyCredential) -} - -#[derive(Deserialize, Debug)] -struct AuthorizationConfirmation { - authorization_method: Credential, - request: AuthorizationRequest -} - -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> -) -> 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 - } -} - -#[tracing::instrument(skip(backend, confirmation))] -async fn authorization_endpoint_confirm<A: AuthBackend>( - Host(host): Host, - Extension(backend): Extension<A>, - cookies: CookieJar, - Json(confirmation): Json<AuthorizationConfirmation>, -) -> Response { - tracing::debug!("Received authorization confirmation from user"); - #[cfg(feature = "webauthn")] - let challenge_id = cookies.get(webauthn::CHALLENGE_ID_COOKIE) - .map(|cookie| cookie.value()); - #[cfg(not(feature = "webauthn"))] - let challenge_id = None; - - let website = format!("https://{}/", host).parse().unwrap(); - let AuthorizationConfirmation { - 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."); - return StatusCode::UNAUTHORIZED.into_response(); - }, - Err(err) => { - error!("Error while verifying credential: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - } - // Insert the correct `me` value into the request - // - // From this point, the `me` value that hits the backend is - // guaranteed to be authoritative and correct, and can be safely - // unwrapped. - auth.me = Some(website.clone()); - // 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) => { - error!("Error creating authorization code: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - let location = { - let mut uri = redirect_uri; - uri.set_query(Some(&serde_urlencoded::to_string( - AuthorizationResponse { code, state, iss: website } - ).unwrap())); - - uri - }; - - // 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::named(webauthn::CHALLENGE_ID_COOKIE)) - ) - .into_response() -} - -#[tracing::instrument(skip(backend, db))] -async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( - Host(host): Host, - Extension(backend): Extension<A>, - Extension(db): Extension<D>, - Form(grant): Form<GrantRequest>, -) -> Response { - match grant { - GrantRequest::AuthorizationCode { - code, - client_id, - redirect_uri, - 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(), - Err(err) => { - tracing::error!("Error retrieving auth request: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.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() - } - 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() - } - 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() - } - 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() - } - 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() - .map(|s| s.has(&Scope::Email)) - .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() - } - } - } else { - None - }; - - GrantResponse::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() - } -} - -#[tracing::instrument(skip(backend, db))] -async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( - Host(host): Host, - Extension(backend): Extension<A>, - Extension(db): Extension<D>, - Form(grant): Form<GrantRequest>, -) -> Response { - #[inline] - fn prepare_access_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData { - TokenData { - 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(), - iat: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - .into() - } - } - - #[inline] - fn prepare_refresh_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData { - TokenData { - 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(), - iat: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - .into() - } - } - - let me: url::Url = format!("https://{}/", host).parse().unwrap(); - - match grant { - GrantRequest::AuthorizationCode { - code, - client_id, - redirect_uri, - 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(), - Err(err) => { - tracing::error!("Error retrieving auth request: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - tracing::debug!("Retrieved authorization request: {:?}", request); - - 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(); - }; - 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() - } - 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() - } - 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(); - } - - // Note: we can trust the `request.me` value, since we set - // it earlier before generating the authorization code - 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() - } - - let profile = if dbg!(scope.has(&Scope::Profile)) { - 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() - } - } - } else { - None - }; - - 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); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - // 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 { - Ok(token) => token, - Err(err) => { - tracing::error!("Error creating refresh token: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - GrantResponse::AccessToken { - me, - profile, - access_token, - token_type: kittybox_indieauth::TokenType::Bearer, - scope: Some(scope), - expires_in: Some(ACCESS_TOKEN_VALIDITY), - refresh_token: Some(refresh_token) - }.into_response() - }, - GrantRequest::RefreshToken { - refresh_token, - client_id, - 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(), - Err(err) => { - tracing::error!("Error retrieving refresh token: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - }; - - if data.client_id != client_id { - return Error { - kind: ErrorKind::InvalidGrant, - msg: Some("This refresh token is not yours.".to_string()), - 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(); - } - - scope - } else { - // Note: check skipped because of redundancy (comparing a scope list with itself) - data.scope - }; - - - let profile = if scope.has(&Scope::Profile) { - 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 - }; - - 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); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - 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 { - Ok(token) => token, - Err(err) => { - tracing::error!("Error creating refresh token: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - if let Err(err) = backend.revoke_refresh_token(&me, &old_refresh_token).await { - tracing::error!("Error revoking refresh token: {}", err); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - - GrantResponse::AccessToken { - me: data.me, - profile, - access_token, - token_type: kittybox_indieauth::TokenType::Bearer, - scope: Some(scope), - expires_in: Some(ACCESS_TOKEN_VALIDITY), - refresh_token: Some(refresh_token) - }.into_response() - } - } -} - -#[tracing::instrument(skip(backend, token_request))] -async fn introspection_endpoint_post<A: AuthBackend>( - Host(host): Host, - TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>, - Extension(backend): Extension<A>, - Form(token_request): Form<TokenIntrospectionRequest>, -) -> Response { - use serde_json::json; - - let me: url::Url = format!("https://{}/", host).parse().unwrap(); - - // 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(), - 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(&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() -} - -async fn revocation_endpoint_post<A: AuthBackend>( - Host(host): Host, - Extension(backend): Extension<A>, - Form(revocation): Form<TokenRevocationRequest>, -) -> impl IntoResponse { - let me: url::Url = format!("https://{}/", host).parse().unwrap(); - - if let Err(err) = tokio::try_join!( - backend.revoke_token(&me, &revocation.token), - backend.revoke_refresh_token(&me, &revocation.token) - ) { - tracing::error!("Error revoking token: {}", err); - - StatusCode::INTERNAL_SERVER_ERROR - } else { - StatusCode::OK - } -} - -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(db): Extension<D> -) -> Response { - use serde_json::json; - - let me: url::Url = format!("https://{}/", host).parse().unwrap(); - - 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(); - } - if !token.scope.has(&Scope::Profile) { - 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 { - 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() - } - } -} - -#[must_use] -pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D, http: reqwest::Client) -> axum::Router { - use axum::routing::{Router, get, post}; - - Router::new() - .nest( - "/.kittybox/indieauth", - Router::new() - .route("/metadata", - get(metadata)) - .route( - "/auth", - get(authorization_endpoint_get::<A, D>) - .post(authorization_endpoint_post::<A, D>)) - .route( - "/auth/confirm", - post(authorization_endpoint_confirm::<A>)) - .route( - "/token", - post(token_endpoint_post::<A, D>)) - .route( - "/token_status", - post(introspection_endpoint_post::<A>)) - .route( - "/revoke_token", - post(revocation_endpoint_post::<A>)) - .route( - "/userinfo", - get(userinfo_endpoint_get::<A, D>)) - - .route("/webauthn/pre_register", - get( - #[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::<A, D>, - #[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(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)) - .layer(Extension(http)) - ) - .route( - "/.well-known/oauth-authorization-server", - get(|| std::future::ready( - (StatusCode::FOUND, - [("Location", - "/.kittybox/indieauth/metadata")] - ).into_response() - )) - ) -} - -#[cfg(test)] -mod tests { - #[test] - fn test_deserialize_authorization_confirmation() { - use super::{Credential, AuthorizationConfirmation}; - - let confirmation = serde_json::from_str::<AuthorizationConfirmation>(r#"{ - "request":{ - "response_type": "code", - "client_id": "https://quill.p3k.io/", - "redirect_uri": "https://quill.p3k.io/", - "state": "10101010", - "code_challenge": "awooooooooooo", - "code_challenge_method": "S256", - "scope": "create+media" - }, - "authorization_method": "swordfish" - }"#).unwrap(); - - match confirmation.authorization_method { - Credential::Password(password) => assert_eq!(password.as_str(), "swordfish"), - #[allow(unreachable_patterns)] - other => panic!("Incorrect credential: {:?}", other) - } - assert_eq!(confirmation.request.state.as_ref(), "10101010"); - } -} |