Skip to main content

tablo_test/
oidc.rs

1//! [`FakeProvider`]: an OpenID Connect provider on a loopback port, for a test of a panel's
2//! provider sign-in to sign accounts in through without the network.
3
4use 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/// An account at a [`FakeProvider`]: what the ID token it issues says.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct FakeAccount {
20    /// The provider's id for the account (`sub`).
21    pub subject: String,
22    /// The account's address.
23    pub email: Option<String>,
24    /// Whether the provider says the account proved it reads the address.
25    pub email_verified: bool,
26    /// The account's display name.
27    pub name: Option<String>,
28}
29
30impl FakeAccount {
31    /// The account `subject` holding the verified address `email`.
32    #[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
43/// A code the fake handed out, waiting for the token request that redeems it.
44struct 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
57/// An OpenID Connect provider serving its discovery document and token endpoint on a loopback
58/// port, which a panel's [`OidcProvider`] reaches as it would a real one.
59///
60/// The browser's visit to the provider is [`authorize`](Self::authorize): it reads the
61/// authorization URL the panel redirected to and answers the callback the provider would send
62/// the browser back to. The token endpoint checks the client's secret, the PKCE verifier and the
63/// redirect URI, takes each code once, and issues an ID token carrying the nonce the panel sent.
64pub 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    /// A provider for the client `client_id` with `client_secret`, listening on a free loopback
74    /// port until dropped.
75    ///
76    /// # Panics
77    ///
78    /// When no loopback port is free.
79    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    /// The issuer, `http://127.0.0.1:{port}`.
109    #[must_use]
110    pub fn issuer(&self) -> &str {
111        &self.issuer
112    }
113
114    /// This provider as a panel installs it, under `key`, labelled `key`.
115    #[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    /// Signs `account` in at `location`, the authorization URL a panel redirected the browser
127    /// to, and answers the path and query the provider sends the browser back to.
128    ///
129    /// # Panics
130    ///
131    /// When `location` is not this provider's authorization URL for its client, or asks for no
132    /// PKCE `S256` challenge.
133    #[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    /// The account declines at `location`: the path and query the provider sends the browser
154    /// back to, carrying `error=access_denied`.
155    ///
156    /// # Panics
157    ///
158    /// As [`authorize`](Self::authorize).
159    #[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    /// How many codes the token endpoint has redeemed.
169    ///
170    /// # Panics
171    ///
172    /// When a request to the fake panicked while holding its lock.
173    #[must_use]
174    pub fn redeemed(&self) -> usize {
175        self.state.lock().expect("the fake's lock").redeemed
176    }
177
178    /// The query of `location`, checked as this provider's authorization request.
179    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
200/// The path and query of `redirect_uri` with `pairs` appended: where the browser lands.
201fn 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
216/// What the server task answers with.
217struct Served {
218    issuer: String,
219    client_id: String,
220    client_secret: String,
221    state: Arc<Mutex<State>>,
222}
223
224/// Answers one request per connection until the task is aborted.
225async 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    /// The status line and JSON body answering the request `head` with `body`.
266    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    /// The ID token a token request redeems, when its client authenticates, its code is live
299    /// and its verifier and redirect URI match the code's.
300    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
339/// The provider buttons a login or sign-up page shows, as each provider's key and the link that
340/// starts its sign-in, in order.
341pub 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}