use std::marker::PhantomData;
use microformats::types::Class;
use tracing::error;
use serde::Deserialize;
use axum::{
extract::{Form, FromRef, Host, Json, Query, State}, http::StatusCode, response::{Html, IntoResponse, Response}
};
#[cfg_attr(not(feature = "webauthn"), allow(unused_imports))]
use axum_extra::extract::cookie::{CookieJar, Cookie};
use axum_extra::{headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, TypedHeader};
use crate::database::Storage;
use kittybox_indieauth::{
AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData, TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest
};
use std::str::FromStr;
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<A: AuthBackend>(pub(crate) TokenData, pub(crate) PhantomData<A>);
impl<A: AuthBackend> std::fmt::Debug for User<A> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("User").field(&self.0).finish()
}
}
impl<A: AuthBackend> std::ops::Deref for User<A> {
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": "not_authorized"}))
).into_response()
}
}
}
#[async_trait::async_trait]
impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::extract::FromRequestParts<St> for User<A> {
type Rejection = IndieAuthResourceError;
async fn from_request_parts(req: &mut axum::http::request::Parts, state: &St) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(token)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(req, state)
.await
.map_err(|_| IndieAuthResourceError::Unauthorized)?;
let auth = A::from_ref(state);
let Host(host) = Host::from_request_parts(req, state)
.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!("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()),
client_id_metadata_document_supported: true,
}
}
async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
Host(host): Host,
Query(request): Query<AuthorizationRequest>,
State(db): State<D>,
State(http): State<reqwest_middleware::ClientWithMiddleware>,
State(auth): State<A>
) -> Response {
let me: url::Url = format!("https://{host}/").parse().unwrap();
// XXX: attempt fetching OAuth application metadata
let h_app: ClientMetadata = if request.client_id.domain().unwrap() == "localhost" && me.domain().unwrap() != "localhost" {
// If client is localhost, but we aren't localhost, generate synthetic metadata.
tracing::warn!("Client is localhost, not fetching metadata");
let mut metadata = ClientMetadata::new(request.client_id.clone(), request.client_id.clone()).unwrap();
metadata.client_name = Some("Your locally hosted app".to_string());
metadata
} else {
tracing::debug!("Sending request to {} to fetch metadata", request.client_id);
let metadata_request = http.get(request.client_id.clone())
.header("Accept", "application/json, text/html");
match metadata_request.send().await
.and_then(|res| res.error_for_status()
.map_err(reqwest_middleware::Error::Reqwest))
{
Ok(response) if response.headers().typed_get::<ContentType>().to_owned().map(mime::Mime::from).map(|m| m.type_() == "text" && m.subtype() == "html").unwrap_or(false) => {
let url = response.url().clone();
let text = response.text().await.unwrap();
tracing::debug!("Received {} bytes in response", text.len());
match microformats::from_html(&text, url) {
Ok(mf2) => {
if let Some(relation) = mf2.rels.items.get(&request.redirect_uri) {
if !relation.rels.iter().any(|i| i == "redirect_uri") {
return (StatusCode::BAD_REQUEST,
[("Content-Type", "text/plain")],
"The redirect_uri provided was declared as \
something other than redirect_uri.")
.into_response()
}
} else if request.redirect_uri.origin() != request.client_id.origin() {
return (StatusCode::BAD_REQUEST,
[("Content-Type", "text/plain")],
"The redirect_uri didn't match the origin \
and wasn't explicitly allowed. You were being tricked.")
.into_response()
}
if let Some(app) = mf2.items
.iter()
.find(|&i| i.r#type.iter()
.any(|i| {
*i == Class::from_str("h-app").unwrap()
|| *i == Class::from_str("h-x-app").unwrap()
})
)
.cloned()
{
// Create a synthetic metadata document. Be forgiving.
let mut metadata = ClientMetadata::new(
request.client_id.clone(),
app.properties.get("url")
.and_then(|v| v.first())
.and_then(|i| match i {
microformats::types::PropertyValue::Url(url) => Some(url.clone()),
_ => None
})
.unwrap_or_else(|| request.client_id.clone())
).unwrap();
metadata.client_name = app.properties.get("name")
.and_then(|v| v.first())
.and_then(|i| match i {
microformats::types::PropertyValue::Plain(name) => Some(name.to_owned()),
_ => None
});
metadata.redirect_uris = mf2.rels.by_rels().remove("redirect_uri");
metadata
} else {
return (StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], "No h-app or JSON application metadata found.").into_response()
}
},
Err(err) => {
tracing::error!("Error parsing application metadata: {}", err);
return (
StatusCode::BAD_REQUEST,
[("Content-Type", "text/plain")],
"Parsing h-app metadata failed.").into_response()
}
}
},
Ok(response) => match response.json::<ClientMetadata>().await {
Ok(client_metadata) => {
client_metadata
},
Err(err) => {
tracing::error!("Error parsing JSON application metadata: {}", err);
return (
StatusCode::BAD_REQUEST,
[("Content-Type", "text/plain")],
format!("Parsing OAuth2 JSON app metadata failed: {}", err)
).into_response()
}
},
Err(err) => {
tracing::error!("Error fetching application metadata: {}", err);
return (
StatusCode::BAD_REQUEST,
[("Content-Type", "text/plain")],
format!("Fetching app metadata failed: {}", err)
).into_response()
}
}
};
tracing::debug!("Application metadata: {:#?}", h_app);
Html(kittybox_frontend_renderer::Template {
title: "Confirm sign-in via IndieAuth",
blog_name: "Kittybox",
feeds: vec![],
user: None,
content: kittybox_frontend_renderer::AuthorizationRequestPage {
request,
credentials: auth.list_user_credential_types(&me).await.unwrap(),
user: db.get_post(me.as_str()).await.unwrap().unwrap(),
app: h_app
}.to_string(),
}.to_string())
.into_response()
}
#[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
}
#[tracing::instrument(skip(auth, credential))]
async fn verify_credential<A: AuthBackend>(
auth: &A,
website: &url::Url,
credential: Credential,
#[cfg_attr(not(feature = "webauthn"), allow(unused_variables))]
challenge_id: Option<&str>
) -> std::io::Result<bool> {
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<A: AuthBackend>(
Host(host): Host,
State(backend): State<A>,
cookies: CookieJar,
Json(confirmation): Json<AuthorizationConfirmation>,
) -> 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::from(webauthn::CHALLENGE_ID_COOKIE))
)
.into_response()
}
#[tracing::instrument(skip(backend, db))]
async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>(
Host(host): Host,
State(backend): State<A>,
State(db): State<D>,
Form(grant): Form<GrantRequest>,
) -> Response {
tracing::debug!("Processing 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();
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 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) => {
tracing::debug!("Retrieved profile: {:?}", profile);
profile
},
Err(err) => {
tracing::error!("Error retrieving profile from database: {}", err);
return StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
} else {
None
};
GrantResponse::ProfileUrl(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<A: AuthBackend, D: Storage + 'static>(
Host(host): Host,
State(backend): State<A>,
State(db): State<D>,
Form(grant): Form<GrantRequest>,
) -> 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),
state: None
}.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),
state: None
}.into_response()
}
}
}
#[tracing::instrument(skip(backend, token_request))]
async fn introspection_endpoint_post<A: AuthBackend>(
Host(host): Host,
TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>,
State(backend): State<A>,
Form(token_request): Form<TokenIntrospectionRequest>,
) -> 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<A: AuthBackend>(
Host(host): Host,
State(backend): State<A>,
Form(revocation): Form<TokenRevocationRequest>,
) -> 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
}
}
#[tracing::instrument(skip(db))]
async fn get_profile<D: Storage + 'static>(
db: D,
url: &str,
email: bool
) -> crate::database::Result<Option<Profile>> {
fn get_first(v: serde_json::Value) -> Option<String> {
match v {
serde_json::Value::Array(mut a) => {
a.truncate(1);
match a.pop() {
Some(serde_json::Value::String(s)) => Some(s),
Some(serde_json::Value::Object(mut o)) => o.remove("value").and_then(get_first),
_ => None
}
},
_ => None
}
}
Ok(db.get_post(url).await?.map(|mut mf2| {
// Ruthlessly manually destructure the MF2 document to save memory
let mut properties = match mf2.as_object_mut().unwrap().remove("properties") {
Some(serde_json::Value::Object(props)) => props,
_ => unreachable!()
};
drop(mf2);
let name = properties.remove("name").and_then(get_first);
let url = properties.remove("uid").and_then(get_first).and_then(|u| u.parse().ok());
let photo = properties.remove("photo").and_then(get_first).and_then(|u| u.parse().ok());
let email = properties.remove("name").and_then(get_first);
Profile { name, url, photo, email }
}))
}
async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>(
Host(host): Host,
TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>,
State(backend): State<A>,
State(db): State<D>
) -> 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<St, A, S>() -> axum::Router<St>
where
S: Storage + FromRef<St> + 'static,
A: AuthBackend + FromRef<St>,
reqwest_middleware::ClientWithMiddleware: FromRef<St>,
St: Clone + Send + Sync + 'static
{
use axum::routing::{Router, get, post};
Router::new()
.nest(
"/.kittybox/indieauth",
Router::new()
.route("/metadata",
get(metadata))
.route(
"/auth",
get(authorization_endpoint_get::<A, S>)
.post(authorization_endpoint_post::<A, S>))
.route(
"/auth/confirm",
post(authorization_endpoint_confirm::<A>))
.route(
"/token",
post(token_endpoint_post::<A, S>))
.route(
"/token_status",
post(introspection_endpoint_post::<A>))
.route(
"/revoke_token",
post(revocation_endpoint_post::<A>))
.route(
"/userinfo",
get(userinfo_endpoint_get::<A, S>))
.route("/webauthn/pre_register",
get(
#[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::<A, S>,
#[cfg(not(feature = "webauthn"))] || std::future::ready(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))
)
.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::<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"),
#[allow(unreachable_patterns)]
other => panic!("Incorrect credential: {:?}", other)
}
assert_eq!(confirmation.request.state.as_ref(), "10101010");
}
}