indieauth-client 0.1.0

A small library for actix-web to log your users in using IndieAuth
Documentation
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;

    // generate a random token (alphanumeric)
    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();

    // create the url, the user is redirected to
    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"); // TODO: return nice error
    }
    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 url = format!(
        //     "{}?grant_type=authorization_code&code={}&client_id={}&redirect_uri={}&code_verified={}",
        //     job.token_url,
        //     query.code,
        //     config.get_client_id(),
        //     config.get_redirect_uri(),
        //     job.state
        // );

        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"); // TODO: return nice error
    }

    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)
}