use http_types::Mime;
use log::{debug, error};
use rand::Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::convert::TryInto;
use std::str::FromStr;
use crate::frontend::templates::Template;
use crate::frontend::{FrontendError, IndiewebEndpoints};
use crate::{database::Storage, ApplicationState};
use kittybox_frontend_renderer::LoginPage;
pub async fn form<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
let owner = req.url().origin().ascii_serialization() + "/";
let storage = &req.state().storage;
let authorization_endpoint = req.state().authorization_endpoint.to_string();
let token_endpoint = req.state().token_endpoint.to_string();
let blog_name = storage
.get_setting("site_name", &owner)
.await
.unwrap_or_else(|_| "Kitty Box!".to_string());
let feeds = storage.get_channels(&owner).await.unwrap_or_default();
Ok(Response::builder(200)
.body(
Template {
title: "Sign in with IndieAuth",
blog_name: &blog_name,
endpoints: IndiewebEndpoints {
authorization_endpoint,
token_endpoint,
webmention: None,
microsub: None,
},
feeds,
user: req.session().get("user"),
content: LoginPage {}.to_string(),
}
.to_string(),
)
.content_type("text/html; charset=utf-8")
.build())
}
#[derive(Serialize, Deserialize)]
struct LoginForm {
url: String,
}
#[derive(Serialize, Deserialize)]
struct IndieAuthClientState {
/// A random value to protect from CSRF attacks.
nonce: String,
/// The user's initial "me" value.
me: String,
/// Authorization endpoint used.
authorization_endpoint: String,
}
#[derive(Serialize, Deserialize)]
struct IndieAuthRequestParams {
response_type: String, // can only have "code". TODO make an enum
client_id: String, // always a URL. TODO consider making a URL
redirect_uri: surf::Url, // callback URI for IndieAuth
state: String, // CSRF protection, should include randomness and be passed through
code_challenge: String, // base64-encoded PKCE challenge
code_challenge_method: String, // usually "S256". TODO make an enum
scope: Option<String>, // oAuth2 scopes to grant,
me: surf::Url, // User's entered profile URL
}
/// Handle login requests. Find the IndieAuth authorization endpoint and redirect to it.
pub async fn handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
let content_type = req.content_type();
if content_type.is_none() {
return Err(FrontendError::with_code(400, "Use the login form, Luke.").into());
}
if content_type.unwrap() != Mime::from_str("application/x-www-form-urlencoded").unwrap() {
return Err(
FrontendError::with_code(400, "Login form results must be a urlencoded form").into(),
);
}
let form = req.body_form::<LoginForm>().await?; // FIXME check if it returns 400 or 500 on error
let homepage_uri = surf::Url::parse(&form.url)?;
let http = &req.state().http_client;
let mut fetch_response = http.get(&homepage_uri).send().await?;
if fetch_response.status() != 200 {
return Err(FrontendError::with_code(
500,
"Error fetching your authorization endpoint. Check if your website's okay.",
)
.into());
}
let mut authorization_endpoint: Option<surf::Url> = None;
if let Some(links) = fetch_response.header("Link") {
// NOTE: this is the same Link header parser used in src/micropub/post.rs:459.
// One should refactor it to a function to use independently and improve later
for link in links.iter().flat_map(|i| i.as_str().split(',')) {
debug!("Trying to match {} as authorization_endpoint", link);
let mut split_link = link.split(';');
match split_link.next() {
Some(uri) => {
if let Some(uri) = uri.strip_prefix('<').and_then(|uri| uri.strip_suffix('>')) {
debug!("uri: {}", uri);
for prop in split_link {
debug!("prop: {}", prop);
let lowercased = prop.to_ascii_lowercase();
let trimmed = lowercased.trim();
if trimmed == "rel=\"authorization_endpoint\""
|| trimmed == "rel=authorization_endpoint"
{
if let Ok(endpoint) = homepage_uri.join(uri) {
debug!(
"Found authorization endpoint {} for user {}",
endpoint,
homepage_uri.as_str()
);
authorization_endpoint = Some(endpoint);
break;
}
}
}
}
}
None => continue,
}
}
}
// If the authorization_endpoint is still not found after the Link parsing gauntlet,
// bring out the big guns and parse HTML to find it.
if authorization_endpoint.is_none() {
let body = fetch_response.body_string().await?;
let pattern =
easy_scraper::Pattern::new(r#"<link rel="authorization_endpoint" href="{{url}}">"#)
.expect("Cannot parse the pattern for authorization_endpoint");
let matches = pattern.matches(&body);
debug!("Matches for authorization_endpoint in HTML: {:?}", matches);
if !matches.is_empty() {
if let Ok(endpoint) = homepage_uri.join(&matches[0]["url"]) {
debug!(
"Found authorization endpoint {} for user {}",
endpoint,
homepage_uri.as_str()
);
authorization_endpoint = Some(endpoint)
}
}
};
// If even after this the authorization endpoint is still not found, bail out.
if authorization_endpoint.is_none() {
error!(
"Couldn't find authorization_endpoint for {}",
homepage_uri.as_str()
);
return Err(FrontendError::with_code(
400,
"Your website doesn't support the IndieAuth protocol.",
)
.into());
}
let mut authorization_endpoint: surf::Url = authorization_endpoint.unwrap();
let mut rng = rand::thread_rng();
let state: String = data_encoding::BASE64URL.encode(
serde_urlencoded::to_string(IndieAuthClientState {
nonce: (0..8)
.map(|_| {
let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
INDIEAUTH_PKCE_CHARSET[idx] as char
})
.collect(),
me: homepage_uri.to_string(),
authorization_endpoint: authorization_endpoint.to_string(),
})?
.as_bytes(),
);
// PKCE code generation
let code_verifier: String = (0..128)
.map(|_| {
let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
INDIEAUTH_PKCE_CHARSET[idx] as char
})
.collect();
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let code_challenge: String = data_encoding::BASE64URL.encode(&hasher.finalize());
authorization_endpoint.set_query(Some(&serde_urlencoded::to_string(
IndieAuthRequestParams {
response_type: "code".to_string(),
client_id: req.url().origin().ascii_serialization(),
redirect_uri: req.url().join("login/callback")?,
state: state.clone(),
code_challenge,
code_challenge_method: "S256".to_string(),
scope: Some("profile".to_string()),
me: homepage_uri,
},
)?));
let cookies = vec![
format!(
r#"indieauth_state="{}"; Same-Site: None; Secure; Max-Age: 600"#,
state
),
format!(
r#"indieauth_code_verifier="{}"; Same-Site: None; Secure; Max-Age: 600"#,
code_verifier
),
];
let cookie_header = cookies
.iter()
.map(|i| -> http_types::headers::HeaderValue { (i as &str).try_into().unwrap() })
.collect::<Vec<_>>();
Ok(Response::builder(302)
.header("Location", authorization_endpoint.to_string())
.header("Set-Cookie", &*cookie_header)
.build())
}
const INDIEAUTH_PKCE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
abcdefghijklmnopqrstuvwxyz\
1234567890-._~";
#[derive(Deserialize)]
struct IndieAuthCallbackResponse {
code: Option<String>,
error: Option<String>,
error_description: Option<String>,
#[allow(dead_code)]
error_uri: Option<String>,
// This needs to be further decoded to receive state back and will always be present
state: String,
}
impl IndieAuthCallbackResponse {
fn is_successful(&self) -> bool {
self.code.is_some()
}
}
#[derive(Serialize, Deserialize)]
struct IndieAuthCodeRedeem {
grant_type: String,
code: String,
client_id: String,
redirect_uri: String,
code_verifier: String,
}
#[derive(Serialize, Deserialize)]
struct IndieWebProfile {
name: Option<String>,
url: Option<String>,
email: Option<String>,
photo: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct IndieAuthResponse {
me: String,
scope: Option<String>,
access_token: Option<String>,
token_type: Option<String>,
profile: Option<IndieWebProfile>,
}
/// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage.
pub async fn callback<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
let params: IndieAuthCallbackResponse = req.query()?;
let http: &surf::Client = &req.state().http_client;
let origin = req.url().origin().ascii_serialization();
if req.cookie("indieauth_state").unwrap().value() != params.state {
return Err(FrontendError::with_code(400, "The state doesn't match. A possible CSRF attack was prevented. Please try again later.").into());
}
let state: IndieAuthClientState =
serde_urlencoded::from_bytes(&data_encoding::BASE64URL.decode(params.state.as_bytes())?)?;
if !params.is_successful() {
return Err(FrontendError::with_code(
400,
&format!(
"The authorization endpoint indicated a following error: {:?}: {:?}",
¶ms.error, ¶ms.error_description
),
)
.into());
}
let authorization_endpoint = surf::Url::parse(&state.authorization_endpoint).unwrap();
let mut code_response = http
.post(authorization_endpoint)
.body_string(serde_urlencoded::to_string(IndieAuthCodeRedeem {
grant_type: "authorization_code".to_string(),
code: params.code.unwrap().to_string(),
client_id: origin.to_string(),
redirect_uri: origin + "/login/callback",
code_verifier: req
.cookie("indieauth_code_verifier")
.unwrap()
.value()
.to_string(),
})?)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.send()
.await?;
if code_response.status() != 200 {
return Err(FrontendError::with_code(
code_response.status(),
&format!(
"Authorization endpoint returned an error when redeeming the code: {}",
code_response.body_string().await?
),
)
.into());
}
let json: IndieAuthResponse = code_response.body_json().await?;
let session = req.session_mut();
session.insert("user", &json.me)?;
// TODO redirect to the page user came from
Ok(Response::builder(302).header("Location", "/").build())
}