use std::sync::Mutex;
use actix_identity::Identity;
use actix_web::{get, post, web, HttpMessage, HttpRequest, HttpResponse, Responder, Scope};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
fn get_token() -> String {
use rand::Rng;
let rng = rand::thread_rng();
rng.sample_iter(&rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect()
}
fn generate_code_challenge() -> String {
let random_token = get_token();
let hash = Sha256::digest(random_token.as_bytes());
URL_SAFE_NO_PAD.encode(hash)
}
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub domain: String,
}
#[derive(Debug, Clone)]
pub struct AuthJob {
pub state: String,
pub token_url: String,
pub authorization_endpoint: String,
pub me: String,
}
#[derive(Debug)]
pub struct AuthState {
pub state: Mutex<Vec<AuthJob>>,
}
impl AuthConfig {
pub fn new(domain: String) -> AuthConfig {
AuthConfig { domain }
}
pub fn get_client_id(&self) -> String {
format!("https://{}", self.domain)
}
pub fn get_redirect_uri(&self) -> String {
format!("https://{}/auth/indieauth/redirect", self.domain)
}
}
#[get("/login")]
pub async fn login_form() -> impl Responder {
let form = r#"<!DOCTYPE html>
<html>
<head>
<title>IndieAuth Login</title>
</head>
<body>
<form action="/auth/indieauth/login" method="post">
<label for="me">Your website:</label>
<input type="url" name="me" placeholder="https://example.com">
<button type="submit">Login</button>
</form>
</body>
</html>
"#;
HttpResponse::Ok().body(form)
}
#[derive(Debug, Deserialize)]
pub struct LoginForm {
pub me: String,
}
#[derive(Deserialize)]
struct Metadata {
authorization_endpoint: String,
token_endpoint: String,
}
#[post("/login")]
pub async fn login(
form: web::Form<LoginForm>,
config: web::Data<AuthConfig>,
state: web::Data<AuthState>,
) -> impl Responder {
let mut me = form.me.clone();
if !(me.starts_with("https://") || me.starts_with("http://")) {
me = format!("https://{}", me);
}
if !me.ends_with('/') {
me = format!("{}/", me);
}
let html = {
use reqwest::Client;
let client = Client::new();
let resp = client.get(&me).send().await.unwrap();
resp.text().await.unwrap()
};
let endpoints: Endpoints = {
use dom_query::Document;
let doc = Document::from(html);
if let Some(md) = doc.select("link[rel=indieauth-metadata]").attr("href") {
let link = md.to_string();
let client = reqwest::Client::new();
let resp = client.get(link).send().await.unwrap();
let metadata = resp.json::<Metadata>().await.unwrap();
Endpoints {
authorization_endpoint: metadata.authorization_endpoint,
token_endpoint: metadata.token_endpoint,
}
} else {
let authorization_endpoint = doc
.select("link[rel=authorization_endpoint]")
.attr("href")
.unwrap()
.to_string();
let token_endpoint = doc
.select("link[rel=token_endpoint]")
.attr("href")
.unwrap()
.to_string();
Endpoints {
authorization_endpoint,
token_endpoint,
}
}
};
let csrf_token = get_token();
let auth_job = AuthJob {
state: csrf_token.clone(),
token_url: endpoints.token_endpoint.clone(),
authorization_endpoint: endpoints.authorization_endpoint.clone(),
me: me.clone(),
};
state.state.lock().unwrap().push(auth_job.clone());
println!("state: {:?}", state);
let state = csrf_token.clone();
let code_challenge = generate_code_challenge();
let url = format!(
"{}?client_id={}&redirect_uri={}&state={state}&code_challenge={code_challenge}&code_challenge_method=S256&me={me}&response_type=code&scope=profile",
endpoints.authorization_endpoint,
config.get_client_id(),
config.get_redirect_uri(),
);
println!("{:?}", endpoints);
HttpResponse::SeeOther()
.append_header(("Location", url))
.body("Redirecting to authorization endpoint")
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Endpoints {
pub authorization_endpoint: String,
pub token_endpoint: String,
}
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub code: String,
pub me: String,
pub state: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ProfileInfo {
pub me: String,
pub profile: Option<Profile>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Profile {
pub name: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Serialize)]
struct ProfileInfoReqest {
grant_type: String,
code: String,
client_id: String,
redirect_uri: String,
code_verified: String,
}
#[get("/redirect")]
pub async fn redirect(
request: HttpRequest,
query: web::Query<TokenResponse>,
config: web::Data<AuthConfig>,
state: web::Data<AuthState>,
) -> impl Responder {
let mut state = state.state.lock().unwrap();
let job_index = state.iter().position(|job| job.state == query.state);
if job_index.is_none() {
return HttpResponse::Forbidden().body("Invalid state"); }
let job_index = job_index.unwrap();
let job = state.get(job_index).unwrap().clone();
state.remove(job_index);
drop(state);
println!("job: {:?}", job);
println!("query: {:?}", query);
let profile: ProfileInfo = {
let request_form = ProfileInfoReqest {
grant_type: "authorization_code".to_string(),
code: query.code.clone(),
client_id: config.get_client_id(),
redirect_uri: config.get_redirect_uri(),
code_verified: job.state.clone(),
};
let client = reqwest::Client::new();
let resp = client
.post(&job.authorization_endpoint)
.header("Accept", "application/json")
.form(&request_form)
.send()
.await
.unwrap();
let profile = resp.json::<ProfileInfo>().await.unwrap();
println!("profile: {:?}", profile);
profile
};
if profile.me != job.me {
return HttpResponse::Forbidden().body("Invalid me"); }
println!("profile: {:?}", profile);
Identity::login(&request.extensions(), profile.me.clone()).unwrap();
HttpResponse::SeeOther()
.append_header(("Location", "/"))
.body("Redirecting to home page")
}
pub fn get_service(config: &AuthConfig) -> Scope {
let state = AuthState {
state: Mutex::new(Vec::new()),
};
web::scope("/auth/indieauth")
.app_data(web::Data::new(config.clone()))
.app_data(web::Data::new(state))
.service(login_form)
.service(login)
.service(redirect)
}