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, 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 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", endpoints: None, feeds: vec![], // TODO user: None, content: todo!(), }.to_string()) } async fn authorization_endpoint_post( Host(host): Host, Form(auth): Form, Extension(backend): Extension ) -> 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 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 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() }, _ => 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 ) -> 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) { 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 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) { 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( Host(host): Host, Form(token_request): Form, TypedHeader(Authorization(auth_token)): TypedHeader>, Extension(backend): Extension ) -> 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( Host(host): Host, 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 userinfo_endpoint_get( Host(host): Host, TypedHeader(Authorization(auth_token)): TypedHeader>, Extension(backend): Extension ) -> Response { Json(Profile { name: todo!(), url: todo!(), photo: todo!(), email: Some(todo!()) }).into_response() } pub fn router(backend: A) -> 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)) ) .route( "/.well-known/oauth-authorization-server", get(|| std::future::ready( ( StatusCode::FOUND, [ ("Location", "/.kittybox/indieauth/metadata") ] ).into_response() )) ) }