indieauth_client/
lib.rs

1use std::sync::Mutex;
2
3use actix_identity::Identity;
4use actix_web::{get, post, web, HttpMessage, HttpRequest, HttpResponse, Responder, Scope};
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use base64::Engine;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10fn get_token() -> String {
11    use rand::Rng;
12
13    // generate a random token (alphanumeric)
14    let rng = rand::thread_rng();
15    rng.sample_iter(&rand::distributions::Alphanumeric)
16        .take(32)
17        .map(char::from)
18        .collect()
19}
20
21fn generate_code_challenge() -> String {
22    let random_token = get_token();
23    let hash = Sha256::digest(random_token.as_bytes());
24    URL_SAFE_NO_PAD.encode(hash)
25}
26
27#[derive(Debug, Clone)]
28pub struct AuthConfig {
29    pub domain: String,
30}
31
32#[derive(Debug, Clone)]
33pub struct AuthJob {
34    pub state: String,
35    pub token_url: String,
36    pub authorization_endpoint: String,
37    pub me: String,
38}
39
40#[derive(Debug)]
41pub struct AuthState {
42    pub state: Mutex<Vec<AuthJob>>,
43}
44
45impl AuthConfig {
46    pub fn new(domain: String) -> AuthConfig {
47        AuthConfig { domain }
48    }
49
50    pub fn get_client_id(&self) -> String {
51        format!("https://{}", self.domain)
52    }
53
54    pub fn get_redirect_uri(&self) -> String {
55        format!("https://{}/auth/indieauth/redirect", self.domain)
56    }
57}
58
59#[get("/login")]
60pub async fn login_form() -> impl Responder {
61    let form = r#"<!DOCTYPE html>
62    <html>
63        <head>
64            <title>IndieAuth Login</title>
65        </head>
66        <body>
67            <form action="/auth/indieauth/login" method="post">
68                <label for="me">Your website:</label>
69                <input type="url" name="me" placeholder="https://example.com">
70                <button type="submit">Login</button>
71            </form>
72        </body>
73    </html>
74    "#;
75
76    HttpResponse::Ok().body(form)
77}
78
79#[derive(Debug, Deserialize)]
80pub struct LoginForm {
81    pub me: String,
82}
83
84#[derive(Deserialize)]
85struct Metadata {
86    authorization_endpoint: String,
87    token_endpoint: String,
88}
89
90#[post("/login")]
91pub async fn login(
92    form: web::Form<LoginForm>,
93    config: web::Data<AuthConfig>,
94    state: web::Data<AuthState>,
95) -> impl Responder {
96    let mut me = form.me.clone();
97    if !(me.starts_with("https://") || me.starts_with("http://")) {
98        me = format!("https://{}", me);
99    }
100
101    if !me.ends_with('/') {
102        me = format!("{}/", me);
103    }
104
105    let html = {
106        use reqwest::Client;
107
108        let client = Client::new();
109        let resp = client.get(&me).send().await.unwrap();
110        resp.text().await.unwrap()
111    };
112
113    let endpoints: Endpoints = {
114        use dom_query::Document;
115
116        let doc = Document::from(html);
117
118        if let Some(md) = doc.select("link[rel=indieauth-metadata]").attr("href") {
119            let link = md.to_string();
120
121            let client = reqwest::Client::new();
122            let resp = client.get(link).send().await.unwrap();
123            let metadata = resp.json::<Metadata>().await.unwrap();
124            Endpoints {
125                authorization_endpoint: metadata.authorization_endpoint,
126                token_endpoint: metadata.token_endpoint,
127            }
128        } else {
129            let authorization_endpoint = doc
130                .select("link[rel=authorization_endpoint]")
131                .attr("href")
132                .unwrap()
133                .to_string();
134
135            let token_endpoint = doc
136                .select("link[rel=token_endpoint]")
137                .attr("href")
138                .unwrap()
139                .to_string();
140
141            Endpoints {
142                authorization_endpoint,
143                token_endpoint,
144            }
145        }
146    };
147
148    let csrf_token = get_token();
149
150    let auth_job = AuthJob {
151        state: csrf_token.clone(),
152        token_url: endpoints.token_endpoint.clone(),
153        authorization_endpoint: endpoints.authorization_endpoint.clone(),
154        me: me.clone(),
155    };
156
157    state.state.lock().unwrap().push(auth_job.clone());
158    println!("state: {:?}", state);
159
160    let state = csrf_token.clone();
161
162    let code_challenge = generate_code_challenge();
163
164    // create the url, the user is redirected to
165    let url = format!(
166        "{}?client_id={}&redirect_uri={}&state={state}&code_challenge={code_challenge}&code_challenge_method=S256&me={me}&response_type=code&scope=profile",
167        endpoints.authorization_endpoint,
168        config.get_client_id(),
169        config.get_redirect_uri(),
170    );
171
172    println!("{:?}", endpoints);
173
174    HttpResponse::SeeOther()
175        .append_header(("Location", url))
176        .body("Redirecting to authorization endpoint")
177}
178
179#[derive(Debug, Deserialize, Serialize, Clone)]
180pub struct Endpoints {
181    pub authorization_endpoint: String,
182    pub token_endpoint: String,
183}
184
185#[derive(Debug, Deserialize)]
186pub struct TokenResponse {
187    pub code: String,
188    pub me: String,
189    pub state: String,
190}
191
192#[derive(Debug, Deserialize, Serialize)]
193pub struct ProfileInfo {
194    pub me: String,
195    pub profile: Option<Profile>,
196}
197
198#[derive(Debug, Deserialize, Serialize)]
199pub struct Profile {
200    pub name: Option<String>,
201    pub url: Option<String>,
202}
203
204#[derive(Debug, Serialize)]
205struct ProfileInfoReqest {
206    grant_type: String,
207    code: String,
208    client_id: String,
209    redirect_uri: String,
210    code_verified: String,
211}
212
213#[get("/redirect")]
214pub async fn redirect(
215    request: HttpRequest,
216    query: web::Query<TokenResponse>,
217    config: web::Data<AuthConfig>,
218    state: web::Data<AuthState>,
219) -> impl Responder {
220    let mut state = state.state.lock().unwrap();
221    let job_index = state.iter().position(|job| job.state == query.state);
222    if job_index.is_none() {
223        return HttpResponse::Forbidden().body("Invalid state"); // TODO: return nice error
224    }
225    let job_index = job_index.unwrap();
226    let job = state.get(job_index).unwrap().clone();
227    state.remove(job_index);
228    drop(state);
229
230    println!("job: {:?}", job);
231    println!("query: {:?}", query);
232
233    let profile: ProfileInfo = {
234        // let url = format!(
235        //     "{}?grant_type=authorization_code&code={}&client_id={}&redirect_uri={}&code_verified={}",
236        //     job.token_url,
237        //     query.code,
238        //     config.get_client_id(),
239        //     config.get_redirect_uri(),
240        //     job.state
241        // );
242
243        let request_form = ProfileInfoReqest {
244            grant_type: "authorization_code".to_string(),
245            code: query.code.clone(),
246            client_id: config.get_client_id(),
247            redirect_uri: config.get_redirect_uri(),
248            code_verified: job.state.clone(),
249        };
250
251        let client = reqwest::Client::new();
252        let resp = client
253            .post(&job.authorization_endpoint)
254            .header("Accept", "application/json")
255            .form(&request_form)
256            .send()
257            .await
258            .unwrap();
259        let profile = resp.json::<ProfileInfo>().await.unwrap();
260
261        println!("profile: {:?}", profile);
262
263        profile
264    };
265
266    if profile.me != job.me {
267        return HttpResponse::Forbidden().body("Invalid me"); // TODO: return nice error
268    }
269
270    println!("profile: {:?}", profile);
271
272    Identity::login(&request.extensions(), profile.me.clone()).unwrap();
273
274    HttpResponse::SeeOther()
275        .append_header(("Location", "/"))
276        .body("Redirecting to home page")
277}
278
279pub fn get_service(config: &AuthConfig) -> Scope {
280    let state = AuthState {
281        state: Mutex::new(Vec::new()),
282    };
283
284    web::scope("/auth/indieauth")
285        .app_data(web::Data::new(config.clone()))
286        .app_data(web::Data::new(state))
287        .service(login_form)
288        .service(login)
289        .service(redirect)
290}