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 }; 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(pub(crate) TokenData, pub(crate) PhantomData); impl std::fmt::Debug for User { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_tuple("User").field(&self.0).finish() } } impl std::ops::Deref for User { 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 axum::extract::FromRequest for User { type Rejection = IndieAuthResourceError; async fn from_request(req: &mut axum::extract::RequestParts) -> Result { let TypedHeader(Authorization(token)) = TypedHeader::>::from_request(req) .await .map_err(|_| IndieAuthResourceError::Unauthorized)?; let axum::Extension(auth) = axum::Extension::::from_request(req) .await .unwrap(); let Host(host) = Host::from_request(req) .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( Host(host): Host, 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 Html(kittybox_templates::Template { title: "Confirm sign-in via IndieAuth", blog_name: "Kittybox", feeds: vec![], user: None, 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), #[cfg(feature = "webauthn")] 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, #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] challenge_id: Option<&str> ) -> std::io::Result { 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( Host(host): Host, Json(confirmation): Json, Extension(backend): Extension, cookies: CookieJar, ) -> 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() } async fn authorization_endpoint_post( Host(host): Host, Form(grant): Form, Extension(backend): Extension, Extension(db): Extension ) -> 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 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); 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( Host(host): Host, Form(grant): Form, Extension(backend): Extension, Extension(db): Extension ) -> 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() } } } 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(&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( 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(&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( db: D, url: &str, email: bool ) -> crate::database::Result> { 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( Host(host): Host, TypedHeader(Authorization(auth_token)): TypedHeader>, Extension(backend): Extension, Extension(db): Extension ) -> 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() } } } pub fn router(backend: A, db: D) -> 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::) .post(authorization_endpoint_post::)) .route( "/auth/confirm", post(authorization_endpoint_confirm::)) .route( "/token", post(token_endpoint_post::)) .route( "/token_status", post(introspection_endpoint_post::)) .route( "/revoke_token", post(revocation_endpoint_post::)) .route( "/userinfo", get(userinfo_endpoint_get::)) .route("/webauthn/pre_register", get( #[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::, #[cfg(not(feature = "webauthn"))] || async { 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)) ) .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::(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"); } }