use axum::{ extract::{Query, Json, Host, Form}, response::{Html, IntoResponse, Response}, http::StatusCode, TypedHeader, headers::{Authorization, authorization::Bearer}, Extension }; use crate::database::Storage; use kittybox_indieauth::{ Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod, Scope, Scopes, PKCEMethod, Error, ErrorKind, ResponseType, RequestMaybeAuthorizationEndpoint, AuthorizationRequest, AuthorizationResponse, GrantType, GrantRequest, GrantResponse, Profile, TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, 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 /// Internal scope for accessing the token introspection endpoint. 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(); 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, 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(auth): Query, ) -> Html { // 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!(), }.to_string()) } async fn authorization_endpoint_post( Host(host): Host, Form(auth): 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, Err(err) => { tracing::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: format!("https://{}/", host).parse().unwrap() } ).unwrap())); uri }; (StatusCode::FOUND, [("Location", location.as_str())] ) .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() } } } 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() } } match grant { GrantRequest::AuthorizationCode { code, client_id, redirect_uri, code_verifier } => { // TODO load the information corresponding to the code 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(); } }; let me: url::Url = format!("https://{}/", host).parse().unwrap(); 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(); } let profile = if scope.has(&Scope::Profile) { match get_profile( db, 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(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, 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 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) ).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(); } 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( Form(token_request): Form, TypedHeader(Authorization(auth_token)): TypedHeader>, Extension(backend): Extension ) -> Response { use serde_json::json; // Check authentication first match backend.get_token(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(&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( Form(revocation): Form, Extension(backend): Extension ) -> 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 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; match backend.get_token(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, &format!("https://{}/", host), 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( "/auth", get(authorization_endpoint_get) .post(authorization_endpoint_post::)) .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::)) .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() )) ) }