about summary refs log tree commit diff
path: root/kittybox-rs/src/indieauth
diff options
context:
space:
mode:
Diffstat (limited to 'kittybox-rs/src/indieauth')
-rw-r--r--kittybox-rs/src/indieauth/backend.rs21
-rw-r--r--kittybox-rs/src/indieauth/mod.rs369
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))
+}