diff options
author | Vika <vika@fireburn.ru> | 2022-07-15 02:14:09 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2022-07-15 02:14:09 +0300 |
commit | 54d6afbe21a94c8de53852d4cd550de75473557f (patch) | |
tree | 7c202718455feda7fc34c3002d4ea56a9cbf6bd4 /kittybox-rs/src/indieauth | |
parent | 36535deba96b19cf0af3fe56763f982fc6ae3fc0 (diff) | |
download | kittybox-54d6afbe21a94c8de53852d4cd550de75473557f.tar.zst |
WIP: IndieAuth progress
- Some kittybox-indieauth crate bugs were fixed - Things should mostly work... - ...if you somehow supply your own backend store - YES I MADE IT MODULAR AGAIN - NO I AM NOT SORRY - YOU WILL THANK ME LATER - DO NOT DENY THE HEAVENLY GIFT OF GENERICS IN RUST - Retrieving profiles doesn't work for now because I am unsure how to implement it best
Diffstat (limited to 'kittybox-rs/src/indieauth')
-rw-r--r-- | kittybox-rs/src/indieauth/backend.rs | 21 | ||||
-rw-r--r-- | kittybox-rs/src/indieauth/mod.rs | 369 |
2 files changed, 390 insertions, 0 deletions
diff --git a/kittybox-rs/src/indieauth/backend.rs b/kittybox-rs/src/indieauth/backend.rs new file mode 100644 index 0000000..f420db9 --- /dev/null +++ b/kittybox-rs/src/indieauth/backend.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; + +use kittybox_indieauth::{ + AuthorizationRequest, TokenData +}; + +type Result<T> = std::io::Result<T>; + +#[async_trait::async_trait] +pub trait AuthBackend: Clone + Send + Sync + 'static { + async fn create_code(&self, data: AuthorizationRequest) -> Result<String>; + async fn get_code(&self, code: &str) -> Result<Option<AuthorizationRequest>>; + async fn create_token(&self, data: TokenData) -> Result<String>; + async fn get_token(&self, token: &str) -> Result<Option<TokenData>>; + async fn list_tokens(&self, website: url::Url) -> Result<HashMap<String, TokenData>>; + async fn revoke_token(&self, token: &str) -> Result<()>; + async fn create_refresh_token(&self, data: TokenData) -> Result<String>; + async fn get_refresh_token(&self, token: &str) -> Result<Option<TokenData>>; + async fn list_refresh_tokens(&self, website: url::Url) -> Result<HashMap<String, TokenData>>; + async fn revoke_refresh_token(&self, token: &str) -> Result<()>; +} diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs new file mode 100644 index 0000000..a985d16 --- /dev/null +++ b/kittybox-rs/src/indieauth/mod.rs @@ -0,0 +1,369 @@ +use axum::{ + extract::{Query, Json, Host, Form}, + response::{Html, IntoResponse, Response}, + http::StatusCode, TypedHeader, headers::{Authorization, authorization::Bearer}, + Extension +}; +use kittybox_indieauth::{ + Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod, + Scope, Scopes, PKCEMethod, + ResponseType, RequestMaybeAuthorizationEndpoint, + AuthorizationRequest, AuthorizationResponse, + GrantType, GrantRequest, GrantResponse, Profile, + TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, IndieAuthError, TokenData +}; + +pub mod backend; +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 + +pub async fn metadata( + Host(host): Host +) -> Json<Metadata> { + let issuer: url::Url = format!("https://{}/", host).parse().unwrap(); + let indieauth: url::Url = issuer.join("/.kittybox/indieauth/").unwrap(); + Json(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]), + 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( + Host(host): Host, + Query(auth): Query<AuthorizationRequest>, +) -> Html<String> { + // TODO fetch h-app from client_id + // TODO verify redirect_uri registration + + Html(kittybox_templates::Template { + title: "Confirm sign-in via IndieAuth", + blog_name: "Kittybox", + endpoints: None, + feeds: vec![], + // TODO + user: None, + content: todo!(), + }.to_string()) +} + +async fn authorization_endpoint_post<A: AuthBackend>( + Host(host): Host, + Form(auth): Form<RequestMaybeAuthorizationEndpoint>, + Extension(backend): Extension<A> +) -> Response { + use RequestMaybeAuthorizationEndpoint::*; + match auth { + Authorization(auth) => { + 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) => { + tracing::error!("Error creating authorization code: {}", err); + return IntoResponse::into_response(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let redirect_uri = { + let mut uri = redirect_uri; + uri.set_query(Some(&serde_urlencoded::to_string( + AuthorizationResponse { + code, state, + iss: format!("https://{}/", host).parse().unwrap() + } + ).unwrap())); + + uri + }; + + IntoResponse::into_response(( + StatusCode::FOUND, + [("Location", redirect_uri.as_str())] + )) + }, + Grant(grant) => 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 Json(IndieAuthError::InvalidRequest).into_response(), + Err(err) => { + tracing::error!("Error retrieving auth request: {}", err); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + if !request.code_challenge.verify(code_verifier) { + return Json(IndieAuthError::InvalidRequest).into_response() + } + let profile = if request.scope + .map(|s| s.has(&Scope::Profile)) + .unwrap_or_default() + { + Some(todo!()) + } else { + None + }; + let me = format!("https://{}/", host).parse().unwrap(); + + Json(GrantResponse::ProfileUrl { me, profile }).into_response() + }, + _ => Json(IndieAuthError::InvalidRequest).into_response() + } + } +} + +async fn token_endpoint_post<A: AuthBackend>( + Host(host): Host, + Form(grant): Form<GrantRequest>, + Extension(backend): Extension<A> +) -> 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() + } + } + + match grant { + GrantRequest::AuthorizationCode { code, client_id, redirect_uri, code_verifier } => { + // TODO load the information corresponding to the code + // TODO verify PKCE challenge using grant.code_verifier + let request: AuthorizationRequest = match backend.get_code(&code).await { + Ok(Some(request)) => request, + Ok(None) => return Json(IndieAuthError::InvalidRequest).into_response(), + Err(err) => { + tracing::error!("Error retrieving auth request: {}", err); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let me: url::Url = format!("https://{}/", host).parse().unwrap(); + + let scope = if let Some(scope) = request.scope { scope } else { + return Json(IndieAuthError::InvalidRequest).into_response(); + }; + + let profile = if scope.has(&Scope::Profile) { + Some(todo!()) + } 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(); + } + }; + + Json(GrantResponse::AccessToken { + me, + profile, + access_token, + 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(&refresh_token).await { + Ok(Some(token)) => token, + Ok(None) => return Json(IndieAuthError::InvalidToken).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 Json(IndieAuthError::InvalidRequest).into_response(); + } + + let scope = if let Some(scope) = scope { scope } else { + return Json(IndieAuthError::InvalidRequest).into_response(); + }; + if !data.scope.has_all(scope.as_ref()) { + return Json(IndieAuthError::InsufficientScope).into_response(); + } + + let profile = if scope.has(&Scope::Profile) { + Some(todo!()) + } 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) + ).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(&old_refresh_token).await { + tracing::error!("Error revoking refresh token: {}", err); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + Json(GrantResponse::AccessToken { + me: data.me, + profile, + access_token, + expires_in: Some(ACCESS_TOKEN_VALIDITY), + refresh_token: Some(refresh_token) + }).into_response() + } + } +} + +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 { + let response: TokenIntrospectionResponse = match backend.get_token(&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() + } + }; + + Json(response).into_response() +} + +async fn revocation_endpoint_post<A: AuthBackend>( + Host(host): Host, + Form(revocation): Form<TokenRevocationRequest>, + Extension(backend): Extension<A> +) -> impl IntoResponse { + if let Err(err) = tokio::try_join!( + backend.revoke_token(&revocation.token), + backend.revoke_refresh_token(&revocation.token) + ) { + tracing::error!("Error revoking token: {}", err); + + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } +} + +async fn userinfo_endpoint_get<A: AuthBackend>( + Host(host): Host, + TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>, + Extension(backend): Extension<A> +) -> Response { + Json(Profile { + name: todo!(), + url: todo!(), + photo: todo!(), + email: Some(todo!()) + }).into_response() +} + +pub fn router<A: AuthBackend>(backend: A) -> axum::Router { + use axum::routing::{Router, get, post}; + + Router::new() + .route( + "/auth", + get(authorization_endpoint_get) + .post(authorization_endpoint_post::<A>)) + .route( + "/token", + post(token_endpoint_post::<A>)) + .route( + "/token_status", + post(introspection_endpoint_post::<A>)) + .route( + "/revoke_token", + post(revocation_endpoint_post::<A>)) + .route( + "/userinfo", + get(userinfo_endpoint_get::<A>)) + .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)) +} |