use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use data_encoding::{BASE64, BASE64URL_NOPAD};
use sha2::{Digest, Sha256};
use tablo_core::OidcProvider;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FakeAccount {
pub subject: String,
pub email: Option<String>,
pub email_verified: bool,
pub name: Option<String>,
}
impl FakeAccount {
#[must_use]
pub fn verified(subject: &str, email: &str) -> Self {
Self {
subject: subject.to_string(),
email: Some(email.to_string()),
email_verified: true,
name: None,
}
}
}
struct Grant {
account: FakeAccount,
nonce: String,
challenge: String,
redirect_uri: String,
}
#[derive(Default)]
struct State {
grants: HashMap<String, Grant>,
redeemed: usize,
}
pub struct FakeProvider {
issuer: String,
client_id: String,
client_secret: String,
state: Arc<Mutex<State>>,
server: tokio::task::JoinHandle<()>,
}
impl FakeProvider {
pub async fn start(client_id: &str, client_secret: &str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("a loopback port is free");
let issuer = format!(
"http://{}",
listener
.local_addr()
.expect("a bound listener has an address")
);
let state = Arc::new(Mutex::new(State::default()));
let server = tokio::spawn(serve(
listener,
Served {
issuer: issuer.clone(),
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
state: Arc::clone(&state),
},
));
Self {
issuer,
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
state,
server,
}
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.issuer
}
#[must_use]
pub fn provider(&self, key: &str) -> OidcProvider {
OidcProvider::new(
key,
key,
self.issuer.clone(),
self.client_id.clone(),
self.client_secret.clone(),
)
}
#[must_use]
pub fn authorize(&self, location: &str, account: &FakeAccount) -> String {
let query = self.authorization(location);
let code = BASE64URL_NOPAD.encode(uuid::Uuid::new_v4().as_bytes());
let redirect_uri = query["redirect_uri"].clone();
self.state.lock().expect("the fake's lock").grants.insert(
code.clone(),
Grant {
account: account.clone(),
nonce: query["nonce"].clone(),
challenge: query["code_challenge"].clone(),
redirect_uri: redirect_uri.clone(),
},
);
callback(
&redirect_uri,
&[("code", &code), ("state", &query["state"])],
)
}
#[must_use]
pub fn deny(&self, location: &str) -> String {
let query = self.authorization(location);
callback(
&query["redirect_uri"],
&[("error", "access_denied"), ("state", &query["state"])],
)
}
#[must_use]
pub fn redeemed(&self) -> usize {
self.state.lock().expect("the fake's lock").redeemed
}
fn authorization(&self, location: &str) -> HashMap<String, String> {
let (endpoint, query) = location
.split_once('?')
.expect("an authorization URL carries a query");
assert_eq!(endpoint, format!("{}/authorize", self.issuer));
let query: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect();
assert_eq!(query["client_id"], self.client_id);
assert_eq!(query["response_type"], "code");
assert_eq!(query["code_challenge_method"], "S256");
query
}
}
impl Drop for FakeProvider {
fn drop(&mut self) {
self.server.abort();
}
}
fn callback(redirect_uri: &str, pairs: &[(&str, &str)]) -> String {
let path = redirect_uri
.find("://")
.and_then(|scheme| {
let rest = &redirect_uri[scheme + 3..];
rest.find('/').map(|slash| &rest[slash..])
})
.expect("a redirect URI is absolute");
let mut query = form_urlencoded::Serializer::new(String::new());
for (key, value) in pairs {
query.append_pair(key, value);
}
format!("{path}?{}", query.finish())
}
struct Served {
issuer: String,
client_id: String,
client_secret: String,
state: Arc<Mutex<State>>,
}
async fn serve(listener: TcpListener, served: Served) {
while let Ok((mut stream, _)) = listener.accept().await {
let mut request = Vec::new();
let mut buffer = [0; 4096];
let (head, body) = loop {
let Ok(read) = stream.read(&mut buffer).await else {
break (String::new(), Vec::new());
};
if read == 0 {
break (String::new(), Vec::new());
}
request.extend_from_slice(&buffer[..read]);
let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") else {
continue;
};
let head = String::from_utf8_lossy(&request[..end]).into_owned();
let length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())?
})
.unwrap_or(0);
if request.len() >= end + 4 + length {
break (head, request[end + 4..end + 4 + length].to_vec());
}
};
let (status, json) = served.answer(&head, &body);
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\
connection: close\r\n\r\n{json}",
json.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
}
impl Served {
fn answer(&self, head: &str, body: &[u8]) -> (&'static str, String) {
let mut request_line = head.lines().next().unwrap_or("").split(' ');
let (method, path) = (request_line.next(), request_line.next());
match (method, path) {
(Some("GET"), Some("/.well-known/openid-configuration")) => (
"200 OK",
serde_json::json!({
"issuer": self.issuer,
"authorization_endpoint": format!("{}/authorize", self.issuer),
"token_endpoint": format!("{}/token", self.issuer),
})
.to_string(),
),
(Some("POST"), Some("/token")) => match self.token(head, body) {
Some(id_token) => (
"200 OK",
serde_json::json!({
"access_token": "unused",
"token_type": "Bearer",
"id_token": id_token,
})
.to_string(),
),
None => (
"400 Bad Request",
serde_json::json!({ "error": "invalid_grant" }).to_string(),
),
},
_ => ("404 Not Found", "{}".to_string()),
}
}
fn token(&self, head: &str, body: &[u8]) -> Option<String> {
let expected = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", self.client_id, self.client_secret).as_bytes())
);
let authenticated = head.lines().any(|line| {
line.split_once(':').is_some_and(|(name, value)| {
name.eq_ignore_ascii_case("authorization") && value.trim() == expected
})
});
let form: HashMap<String, String> = form_urlencoded::parse(body).into_owned().collect();
if !authenticated || form.get("grant_type")? != "authorization_code" {
return None;
}
let mut state = self.state.lock().ok()?;
let grant = state.grants.remove(form.get("code")?)?;
let challenge = BASE64URL_NOPAD.encode(&Sha256::digest(form.get("code_verifier")?));
if challenge != grant.challenge || *form.get("redirect_uri")? != grant.redirect_uri {
return None;
}
state.redeemed += 1;
let now = jiff::Timestamp::now().as_second();
let claims = serde_json::json!({
"iss": self.issuer,
"sub": grant.account.subject,
"aud": self.client_id,
"exp": now + 3600,
"iat": now,
"nonce": grant.nonce,
"email": grant.account.email,
"email_verified": grant.account.email_verified,
"name": grant.account.name,
});
let header = BASE64URL_NOPAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
let payload = BASE64URL_NOPAD.encode(claims.to_string().as_bytes());
Some(format!("{header}.{payload}.signature"))
}
}
pub fn provider_buttons(html: &str) -> Vec<(String, String)> {
use crate::protocol::dom::{attr_value, tags_where};
tags_where(html, |tag, attrs| {
tag == "a" && attr_value(attrs, "data-oidc-provider").is_some()
})
.into_iter()
.filter_map(|(_, attrs, _)| {
Some((
attr_value(&attrs, "data-oidc-provider")?,
attr_value(&attrs, "href")?,
))
})
.collect()
}