From 66049566ae865e1a4bd049257d6afc0abded16e9 Mon Sep 17 00:00:00 2001 From: Vika Date: Mon, 19 Sep 2022 17:30:38 +0300 Subject: feat: indieauth support Working: - Tokens and codes - Authenticating with a password Not working: - Setting the password (need to patch onboarding) - WebAuthn (the JavaScript is too complicated) --- kittybox-rs/src/indieauth/mod.rs | 416 +++++++++++++++++++++++++++------------ 1 file changed, 291 insertions(+), 125 deletions(-) (limited to 'kittybox-rs/src/indieauth/mod.rs') 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 { - 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( Host(host): Host, - Query(auth): Query, + Query(request): Query, + Extension(db): Extension, + Extension(auth): Extension ) -> Html { + 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( + auth: &A, + website: &url::Url, + credential: Credential, + challenge_id: Option<&str> +) -> std::io::Result { + 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( + Host(host): Host, + Json(confirmation): Json, + Extension(backend): Extension, + 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( Host(host): Host, - Form(auth): Form, + Form(grant): Form, Extension(backend): Extension, Extension(db): Extension ) -> 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( Host(host): Host, Form(grant): Form, @@ -224,9 +330,15 @@ async fn token_endpoint_post( } } + 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( } }; - 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( }.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( 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( 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( } async fn introspection_endpoint_post( + Host(host): Host, Form(token_request): Form, TypedHeader(Authorization(auth_token)): TypedHeader>, Extension(backend): Extension ) -> 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( 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( } async fn revocation_endpoint_post( + Host(host): Host, Form(revocation): Form, Extension(backend): Extension ) -> 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( ) -> 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( }))).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,10 +674,15 @@ pub fn router(backend: A, db: D) -> axum:: .nest( "/.kittybox/indieauth", Router::new() + .route("/metadata", + get(metadata)) .route( "/auth", - get(authorization_endpoint_get) + get(authorization_endpoint_get::) .post(authorization_endpoint_post::)) + .route( + "/auth/confirm", + post(authorization_endpoint_confirm::)) .route( "/token", post(token_endpoint_post::)) @@ -555,6 +695,8 @@ pub fn router(backend: A, db: D) -> axum:: .route( "/userinfo", get(userinfo_endpoint_get::)) + .route("/webauthn/pre_register", + get(webauthn::webauthn_pre_register::)) .layer(tower_http::cors::CorsLayer::new() .allow_methods([ axum::http::Method::GET, @@ -570,13 +712,37 @@ pub fn router(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::(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"); + } +} -- cgit 1.4.1