1use std::{
5 collections::HashMap,
6 sync::{Arc, Mutex},
7};
8
9use data_encoding::{BASE64, BASE64URL_NOPAD};
10use sha2::{Digest, Sha256};
11use tablo_core::OidcProvider;
12use tokio::{
13 io::{AsyncReadExt, AsyncWriteExt},
14 net::TcpListener,
15};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct FakeAccount {
20 pub subject: String,
22 pub email: Option<String>,
24 pub email_verified: bool,
26 pub name: Option<String>,
28}
29
30impl FakeAccount {
31 #[must_use]
33 pub fn verified(subject: &str, email: &str) -> Self {
34 Self {
35 subject: subject.to_string(),
36 email: Some(email.to_string()),
37 email_verified: true,
38 name: None,
39 }
40 }
41}
42
43struct Grant {
45 account: FakeAccount,
46 nonce: String,
47 challenge: String,
48 redirect_uri: String,
49}
50
51#[derive(Default)]
52struct State {
53 grants: HashMap<String, Grant>,
54 redeemed: usize,
55}
56
57pub struct FakeProvider {
65 issuer: String,
66 client_id: String,
67 client_secret: String,
68 state: Arc<Mutex<State>>,
69 server: tokio::task::JoinHandle<()>,
70}
71
72impl FakeProvider {
73 pub async fn start(client_id: &str, client_secret: &str) -> Self {
80 let listener = TcpListener::bind("127.0.0.1:0")
81 .await
82 .expect("a loopback port is free");
83 let issuer = format!(
84 "http://{}",
85 listener
86 .local_addr()
87 .expect("a bound listener has an address")
88 );
89 let state = Arc::new(Mutex::new(State::default()));
90 let server = tokio::spawn(serve(
91 listener,
92 Served {
93 issuer: issuer.clone(),
94 client_id: client_id.to_string(),
95 client_secret: client_secret.to_string(),
96 state: Arc::clone(&state),
97 },
98 ));
99 Self {
100 issuer,
101 client_id: client_id.to_string(),
102 client_secret: client_secret.to_string(),
103 state,
104 server,
105 }
106 }
107
108 #[must_use]
110 pub fn issuer(&self) -> &str {
111 &self.issuer
112 }
113
114 #[must_use]
116 pub fn provider(&self, key: &str) -> OidcProvider {
117 OidcProvider::new(
118 key,
119 key,
120 self.issuer.clone(),
121 self.client_id.clone(),
122 self.client_secret.clone(),
123 )
124 }
125
126 #[must_use]
134 pub fn authorize(&self, location: &str, account: &FakeAccount) -> String {
135 let query = self.authorization(location);
136 let code = BASE64URL_NOPAD.encode(uuid::Uuid::new_v4().as_bytes());
137 let redirect_uri = query["redirect_uri"].clone();
138 self.state.lock().expect("the fake's lock").grants.insert(
139 code.clone(),
140 Grant {
141 account: account.clone(),
142 nonce: query["nonce"].clone(),
143 challenge: query["code_challenge"].clone(),
144 redirect_uri: redirect_uri.clone(),
145 },
146 );
147 callback(
148 &redirect_uri,
149 &[("code", &code), ("state", &query["state"])],
150 )
151 }
152
153 #[must_use]
160 pub fn deny(&self, location: &str) -> String {
161 let query = self.authorization(location);
162 callback(
163 &query["redirect_uri"],
164 &[("error", "access_denied"), ("state", &query["state"])],
165 )
166 }
167
168 #[must_use]
174 pub fn redeemed(&self) -> usize {
175 self.state.lock().expect("the fake's lock").redeemed
176 }
177
178 fn authorization(&self, location: &str) -> HashMap<String, String> {
180 let (endpoint, query) = location
181 .split_once('?')
182 .expect("an authorization URL carries a query");
183 assert_eq!(endpoint, format!("{}/authorize", self.issuer));
184 let query: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
185 .into_owned()
186 .collect();
187 assert_eq!(query["client_id"], self.client_id);
188 assert_eq!(query["response_type"], "code");
189 assert_eq!(query["code_challenge_method"], "S256");
190 query
191 }
192}
193
194impl Drop for FakeProvider {
195 fn drop(&mut self) {
196 self.server.abort();
197 }
198}
199
200fn callback(redirect_uri: &str, pairs: &[(&str, &str)]) -> String {
202 let path = redirect_uri
203 .find("://")
204 .and_then(|scheme| {
205 let rest = &redirect_uri[scheme + 3..];
206 rest.find('/').map(|slash| &rest[slash..])
207 })
208 .expect("a redirect URI is absolute");
209 let mut query = form_urlencoded::Serializer::new(String::new());
210 for (key, value) in pairs {
211 query.append_pair(key, value);
212 }
213 format!("{path}?{}", query.finish())
214}
215
216struct Served {
218 issuer: String,
219 client_id: String,
220 client_secret: String,
221 state: Arc<Mutex<State>>,
222}
223
224async fn serve(listener: TcpListener, served: Served) {
226 while let Ok((mut stream, _)) = listener.accept().await {
227 let mut request = Vec::new();
228 let mut buffer = [0; 4096];
229 let (head, body) = loop {
230 let Ok(read) = stream.read(&mut buffer).await else {
231 break (String::new(), Vec::new());
232 };
233 if read == 0 {
234 break (String::new(), Vec::new());
235 }
236 request.extend_from_slice(&buffer[..read]);
237 let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") else {
238 continue;
239 };
240 let head = String::from_utf8_lossy(&request[..end]).into_owned();
241 let length = head
242 .lines()
243 .find_map(|line| {
244 let (name, value) = line.split_once(':')?;
245 name.eq_ignore_ascii_case("content-length")
246 .then(|| value.trim().parse::<usize>().ok())?
247 })
248 .unwrap_or(0);
249 if request.len() >= end + 4 + length {
250 break (head, request[end + 4..end + 4 + length].to_vec());
251 }
252 };
253 let (status, json) = served.answer(&head, &body);
254 let response = format!(
255 "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\
256 connection: close\r\n\r\n{json}",
257 json.len()
258 );
259 let _ = stream.write_all(response.as_bytes()).await;
260 let _ = stream.shutdown().await;
261 }
262}
263
264impl Served {
265 fn answer(&self, head: &str, body: &[u8]) -> (&'static str, String) {
267 let mut request_line = head.lines().next().unwrap_or("").split(' ');
268 let (method, path) = (request_line.next(), request_line.next());
269 match (method, path) {
270 (Some("GET"), Some("/.well-known/openid-configuration")) => (
271 "200 OK",
272 serde_json::json!({
273 "issuer": self.issuer,
274 "authorization_endpoint": format!("{}/authorize", self.issuer),
275 "token_endpoint": format!("{}/token", self.issuer),
276 })
277 .to_string(),
278 ),
279 (Some("POST"), Some("/token")) => match self.token(head, body) {
280 Some(id_token) => (
281 "200 OK",
282 serde_json::json!({
283 "access_token": "unused",
284 "token_type": "Bearer",
285 "id_token": id_token,
286 })
287 .to_string(),
288 ),
289 None => (
290 "400 Bad Request",
291 serde_json::json!({ "error": "invalid_grant" }).to_string(),
292 ),
293 },
294 _ => ("404 Not Found", "{}".to_string()),
295 }
296 }
297
298 fn token(&self, head: &str, body: &[u8]) -> Option<String> {
301 let expected = format!(
302 "Basic {}",
303 BASE64.encode(format!("{}:{}", self.client_id, self.client_secret).as_bytes())
304 );
305 let authenticated = head.lines().any(|line| {
306 line.split_once(':').is_some_and(|(name, value)| {
307 name.eq_ignore_ascii_case("authorization") && value.trim() == expected
308 })
309 });
310 let form: HashMap<String, String> = form_urlencoded::parse(body).into_owned().collect();
311 if !authenticated || form.get("grant_type")? != "authorization_code" {
312 return None;
313 }
314 let mut state = self.state.lock().ok()?;
315 let grant = state.grants.remove(form.get("code")?)?;
316 let challenge = BASE64URL_NOPAD.encode(&Sha256::digest(form.get("code_verifier")?));
317 if challenge != grant.challenge || *form.get("redirect_uri")? != grant.redirect_uri {
318 return None;
319 }
320 state.redeemed += 1;
321 let now = jiff::Timestamp::now().as_second();
322 let claims = serde_json::json!({
323 "iss": self.issuer,
324 "sub": grant.account.subject,
325 "aud": self.client_id,
326 "exp": now + 3600,
327 "iat": now,
328 "nonce": grant.nonce,
329 "email": grant.account.email,
330 "email_verified": grant.account.email_verified,
331 "name": grant.account.name,
332 });
333 let header = BASE64URL_NOPAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
334 let payload = BASE64URL_NOPAD.encode(claims.to_string().as_bytes());
335 Some(format!("{header}.{payload}.signature"))
336 }
337}
338
339pub fn provider_buttons(html: &str) -> Vec<(String, String)> {
342 use crate::protocol::dom::{attr_value, tags_where};
343
344 tags_where(html, |tag, attrs| {
345 tag == "a" && attr_value(attrs, "data-oidc-provider").is_some()
346 })
347 .into_iter()
348 .filter_map(|(_, attrs, _)| {
349 Some((
350 attr_value(&attrs, "data-oidc-provider")?,
351 attr_value(&attrs, "href")?,
352 ))
353 })
354 .collect()
355}