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