use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use axum08::Router;
use axum08::extract::{Query, State};
use axum08::http::StatusCode;
use axum08::response::{IntoResponse, Response};
use axum08::routing::{get, post};
use serde_json::json;
use super::{Endpoints, GoogleConfig, Url};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FakeAccount {
pub sub: String,
pub email: String,
pub email_verified: bool,
}
impl FakeAccount {
pub fn new(sub: impl Into<String>, email: impl Into<String>) -> Self {
Self {
sub: sub.into(),
email: email.into(),
email_verified: true,
}
}
pub fn unverified(self) -> Self {
Self {
email_verified: false,
..self
}
}
}
pub struct FakeGoogle {
endpoints: Endpoints,
shared: Arc<Shared>,
task: tokio::task::JoinHandle<()>,
}
struct Shared {
config: GoogleConfig,
codes: Mutex<HashMap<String, FakeAccount>>,
tokens: Mutex<HashMap<String, FakeAccount>>,
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
impl FakeGoogle {
pub async fn start(config: &GoogleConfig) -> std::io::Result<Self> {
let shared = Arc::new(Shared {
config: config.clone(),
codes: Mutex::new(HashMap::new()),
tokens: Mutex::new(HashMap::new()),
});
let app = Router::new()
.route("/token", post(token))
.route("/tokeninfo", get(tokeninfo))
.with_state(shared.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let task = tokio::spawn(async move {
let _ = axum08::serve(listener, app).await;
});
let url = |path: &str| {
Url::parse(&format!("http://{address}{path}")).expect("a local URL is valid")
};
Ok(Self {
endpoints: Endpoints {
auth: url("/auth"),
token: url("/token"),
tokeninfo: url("/tokeninfo"),
},
shared,
task,
})
}
pub fn endpoints(&self) -> Endpoints {
self.endpoints.clone()
}
pub fn code(&self, account: FakeAccount) -> String {
let code = crate::core::new_token();
lock(&self.shared.codes).insert(code.clone(), account);
code
}
pub fn approve(&self, auth_url: &Url, account: FakeAccount) -> Url {
let state = self.check_auth_url(auth_url);
let code = self.code(account);
self.callback(&[("code", &code), ("state", &state)])
}
pub fn deny(&self, auth_url: &Url) -> Url {
let state = self.check_auth_url(auth_url);
self.callback(&[("error", "access_denied"), ("state", &state)])
}
fn check_auth_url(&self, auth_url: &Url) -> String {
let mut base = auth_url.clone();
base.set_query(None);
assert_eq!(
base, self.endpoints.auth,
"the app did not redirect to the fake Google"
);
let query: HashMap<_, _> = auth_url.query_pairs().into_owned().collect();
let get = |key: &str| query.get(key).map(String::as_str);
let config = &self.shared.config;
assert_eq!(get("response_type"), Some("code"));
assert_eq!(get("client_id"), Some(config.client_id.as_str()));
assert_eq!(get("redirect_uri"), Some(config.redirect_url.as_str()));
get("state")
.expect("the authorization URL carries a state")
.to_owned()
}
fn callback(&self, pairs: &[(&str, &str)]) -> Url {
let mut url = self.shared.config.redirect_url.clone();
url.query_pairs_mut().extend_pairs(pairs);
url
}
}
impl Drop for FakeGoogle {
fn drop(&mut self) {
self.task.abort();
}
}
fn bad_request(error: &str) -> Response {
(
StatusCode::BAD_REQUEST,
axum08::Json(json!({ "error": error })),
)
.into_response()
}
async fn token(State(shared): State<Arc<Shared>>, body: String) -> Response {
let form: HashMap<String, String> = url::form_urlencoded::parse(body.as_bytes())
.into_owned()
.collect();
let get = |key: &str| form.get(key).map(String::as_str);
let config = &shared.config;
if get("grant_type") != Some("authorization_code") {
return bad_request("unsupported_grant_type");
}
if get("client_id") != Some(config.client_id.as_str())
|| get("client_secret") != Some(config.client_secret.as_str())
{
return bad_request("invalid_client");
}
if get("redirect_uri") != Some(config.redirect_url.as_str()) {
return bad_request("redirect_uri_mismatch");
}
let Some(account) = get("code").and_then(|code| lock(&shared.codes).remove(code)) else {
return bad_request("invalid_grant");
};
let access_token = crate::core::new_token();
lock(&shared.tokens).insert(access_token.clone(), account);
axum08::Json(json!({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3599,
}))
.into_response()
}
async fn tokeninfo(
State(shared): State<Arc<Shared>>,
Query(query): Query<HashMap<String, String>>,
) -> Response {
let account = query
.get("access_token")
.and_then(|token| lock(&shared.tokens).remove(token));
let Some(account) = account else {
return bad_request("invalid_token");
};
axum08::Json(json!({
"aud": shared.config.client_id,
"sub": account.sub,
"email": account.email,
"email_verified": account.email_verified.to_string(),
}))
.into_response()
}