about summary refs log tree commit diff
path: root/kittybox-rs/src/indieauth/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'kittybox-rs/src/indieauth/mod.rs')
-rw-r--r--kittybox-rs/src/indieauth/mod.rs416
1 files changed, 291 insertions, 125 deletions
diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs
index 8a37959..adf669e 100644
--- a/kittybox-rs/src/indieauth/mod.rs
+++ b/kittybox-rs/src/indieauth/mod.rs
@@ -1,20 +1,23 @@
+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
 };
+use axum_extra::extract::cookie::{CookieJar, Cookie};
 use crate::database::Storage;
 use kittybox_indieauth::{
     Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod,
-    Scope, Scopes, PKCEMethod, Error, ErrorKind,
-    ResponseType, RequestMaybeAuthorizationEndpoint,
+    Scope, Scopes, PKCEMethod, Error, ErrorKind, ResponseType,
     AuthorizationRequest, AuthorizationResponse,
     GrantType, GrantRequest, GrantResponse, Profile,
     TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, TokenData
 };
 
 pub mod backend;
+mod webauthn;
 use backend::AuthBackend;
 
 const ACCESS_TOKEN_VALIDITY: u64 = 7 * 24 * 60 * 60; // 7 days
@@ -24,10 +27,19 @@ const KITTYBOX_TOKEN_STATUS: &str = "kittybox:token_status";
 
 pub async fn metadata(
     Host(host): Host
-) -> Json<Metadata> {
-    let issuer: url::Url = format!("https://{}/", host).parse().unwrap();
+) -> 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();
-    Json(Metadata {
+    Metadata {
         issuer,
         authorization_endpoint: indieauth.join("auth").unwrap(),
         token_endpoint: indieauth.join("token").unwrap(),
@@ -52,136 +64,230 @@ pub async fn metadata(
         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(
+async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
     Host(host): Host,
-    Query(auth): Query<AuthorizationRequest>,
+    Query(request): Query<AuthorizationRequest>,
+    Extension(db): Extension<D>,
+    Extension(auth): Extension<A>
 ) -> Html<String> {
+    let me = format!("https://{}/", host).parse().unwrap();
     // TODO fetch h-app from client_id
     // TODO verify redirect_uri registration
-    // TODO fetch user profile to display it in a pretty page
-
     Html(kittybox_templates::Template {
         title: "Confirm sign-in via IndieAuth",
         blog_name: "Kittybox",
         feeds: vec![],
-        // TODO
         user: None,
-        content: todo!(),
+        content: kittybox_templates::AuthorizationRequestPage {
+            request,
+            credentials: auth.list_user_credential_types(&me).await.unwrap(),
+            user: db.get_post(me.as_str()).await.unwrap().unwrap(),
+            // XXX parse MF2
+            app: serde_json::json!({
+                "type": [
+                    "h-app",
+                    "h-x-app"
+                ],
+                "properties": {
+                    "name": [
+                        "Quill"
+                    ],
+                    "logo": [
+                        "https://quill.p3k.io/images/quill-logo-144.png"
+                    ],
+                    "url": [
+                        "https://quill.p3k.io/"
+                    ]
+                }
+            })
+        }.to_string(),
     }.to_string())
 }
 
+#[derive(Deserialize, Debug)]
+#[serde(untagged)]
+enum Credential {
+    Password(String),
+    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,
+    challenge_id: Option<&str>
+) -> std::io::Result<bool> {
+    match credential {
+        Credential::Password(password) => auth.verify_password(website, password).await,
+        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,
+    Json(confirmation): Json<AuthorizationConfirmation>,
+    Extension(backend): Extension<A>,
+    cookies: CookieJar,
+) -> Response {
+    tracing::debug!("Received authorization confirmation from user");
+    let challenge_id = cookies.get(webauthn::CHALLENGE_ID_COOKIE)
+        .map(|cookie| cookie.value());
+    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())],
+     cookies.remove(Cookie::named(webauthn::CHALLENGE_ID_COOKIE))
+    )
+        .into_response()
+}
+
 async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>(
     Host(host): Host,
-    Form(auth): Form<RequestMaybeAuthorizationEndpoint>,
+    Form(grant): Form<GrantRequest>,
     Extension(backend): Extension<A>,
     Extension(db): Extension<D>
 ) -> Response {
-    use RequestMaybeAuthorizationEndpoint::*;
-    match auth {
-        Authorization(auth) => {
-            // Cloning these two values, because we can't destructure
-            // the AuthorizationRequest - we need it for the code
-            let state = auth.state.clone();
-            let redirect_uri = auth.redirect_uri.clone();
-
-            let code = match backend.create_code(auth).await {
-                Ok(code) => code,
+    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 creating authorization code: {}", 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 dbg!(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) => dbg!(profile),
+                    Err(err) => {
+                        tracing::error!("Error retrieving profile from database: {}", err);
 
-            let location = {
-                let mut uri = redirect_uri;
-                uri.set_query(Some(&serde_urlencoded::to_string(
-                    AuthorizationResponse {
-                        code, state,
-                        iss: format!("https://{}/", host).parse().unwrap()
+                        return StatusCode::INTERNAL_SERVER_ERROR.into_response()
                     }
-                ).unwrap()));
-
-                uri
+                }
+            } else {
+                None
             };
 
-            (StatusCode::FOUND,
-             [("Location", location.as_str())]
-            )
-                .into_response()
+            GrantResponse::ProfileUrl { me, profile }.into_response()
         },
-        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 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();
-                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) => 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()
-        }
+        _ => 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,
     Form(grant): Form<GrantRequest>,
@@ -224,9 +330,15 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
         }
     }
 
+    let me: url::Url = format!("https://{}/", host).parse().unwrap();
+
     match grant {
-        GrantRequest::AuthorizationCode { code, client_id, redirect_uri, code_verifier } => {
-            // TODO load the information corresponding to the code
+        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 {
@@ -240,7 +352,7 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 }
             };
 
-            let me: url::Url = format!("https://{}/", host).parse().unwrap();
+            tracing::debug!("Retrieved authorization request: {:?}", request);
 
             let scope = if let Some(scope) = request.scope { scope } else {
                 return Error {
@@ -271,13 +383,23 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 }.into_response();
             }
 
-            let profile = if scope.has(&Scope::Profile) {
+            // 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) => profile,
+                    Ok(profile) => dbg!(profile),
                     Err(err) => {
                         tracing::error!("Error retrieving profile from database: {}", err);
 
@@ -316,8 +438,12 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 refresh_token: Some(refresh_token)
             }.into_response()
         },
-        GrantRequest::RefreshToken { refresh_token, client_id, scope } => {
-            let data = match backend.get_refresh_token(&refresh_token).await {
+        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,
@@ -391,7 +517,7 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                     return StatusCode::INTERNAL_SERVER_ERROR.into_response();
                 }
             };
-            if let Err(err) = backend.revoke_refresh_token(&old_refresh_token).await {
+            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();
             }
@@ -408,13 +534,17 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
 }
 
 async fn introspection_endpoint_post<A: AuthBackend>(
+    Host(host): Host,
     Form(token_request): Form<TokenIntrospectionRequest>,
     TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>,
     Extension(backend): Extension<A>
 ) -> Response {
     use serde_json::json;
+
+    let me: url::Url = format!("https://{}/", host).parse().unwrap();
+
     // Check authentication first
-    match backend.get_token(auth_token.token()).await {
+    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
@@ -428,7 +558,7 @@ async fn introspection_endpoint_post<A: AuthBackend>(
             return StatusCode::INTERNAL_SERVER_ERROR.into_response()
         }
     }
-    let response: TokenIntrospectionResponse = match backend.get_token(&token_request.token).await {
+    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);
@@ -440,12 +570,15 @@ async fn introspection_endpoint_post<A: AuthBackend>(
 }
 
 async fn revocation_endpoint_post<A: AuthBackend>(
+    Host(host): Host,
     Form(revocation): Form<TokenRevocationRequest>,
     Extension(backend): Extension<A>
 ) -> impl IntoResponse {
+    let me: url::Url = format!("https://{}/", host).parse().unwrap();
+
     if let Err(err) = tokio::try_join!(
-        backend.revoke_token(&revocation.token),
-        backend.revoke_refresh_token(&revocation.token)
+        backend.revoke_token(&me, &revocation.token),
+        backend.revoke_refresh_token(&me, &revocation.token)
     ) {
         tracing::error!("Error revoking token: {}", err);
 
@@ -495,7 +628,9 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>(
 ) -> Response {
     use serde_json::json;
 
-    match backend.get_token(auth_token.token()).await {
+    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!({
@@ -508,7 +643,7 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>(
                 }))).into_response();
             }
 
-            match get_profile(db, &format!("https://{}/", host), token.scope.has(&Scope::Email)).await {
+            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
@@ -539,11 +674,16 @@ pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D) -> axum::
         .nest(
             "/.kittybox/indieauth",
             Router::new()
+                .route("/metadata",
+                       get(metadata))
                 .route(
                     "/auth",
-                    get(authorization_endpoint_get)
+                    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(
@@ -555,6 +695,8 @@ pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D) -> axum::
                 .route(
                     "/userinfo",
                     get(userinfo_endpoint_get::<A, D>))
+                .route("/webauthn/pre_register",
+                       get(webauthn::webauthn_pre_register::<A, D>))
                 .layer(tower_http::cors::CorsLayer::new()
                        .allow_methods([
                            axum::http::Method::GET,
@@ -570,13 +712,37 @@ pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D) -> axum::
         .route(
             "/.well-known/oauth-authorization-server",
             get(|| std::future::ready(
-                (
-                    StatusCode::FOUND,
-                    [
-                        ("Location",
-                         "/.kittybox/indieauth/metadata")
-                    ]
+                (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"),
+            other => panic!("Incorrect credential: {:?}", other)
+        }
+        assert_eq!(confirmation.request.state.as_ref(), "10101010");
+    }
+}