use actix_remote_ip::{RemoteIP, RemoteIPConfig}; use actix_web::middleware::Logger; use actix_web::{get, web, App, HttpResponse, HttpServer}; use askama::Template; use light_openid::basic_state_manager::BasicStateManager; use light_openid::primitives::OpenIDConfig; use clap::Parser; const REDIRECT_URI: &str = "/redirect"; /// Basic OpenID test client #[derive(Parser, Debug)] pub struct AppConfig { /// Listen URL #[arg(short, long, env, default_value = "0.0.0.0:7510")] pub listen_addr: String, /// Public URL, the URL where this service is accessible, without the trailing slash #[arg(short, long, env, default_value = "http://localhost:7510")] pub public_url: String, /// URL where the OpenID configuration can be found #[arg(short, long, env)] pub configuration_url: String, /// OpenID client ID #[arg(long, env)] pub client_id: String, /// OpenID client secret #[arg(long, env)] pub client_secret: String, /// Proxy IP, might end with a "*" #[clap(long, env)] pub proxy_ip: Option, } impl AppConfig { pub fn get() -> &'static Self { &CONF } pub fn redirect_url(&self) -> String { format!("{}{}", self.public_url, REDIRECT_URI) } } lazy_static::lazy_static! { static ref CONF: AppConfig = { AppConfig::parse() }; } #[get("/assets/bootstrap.min.css")] async fn bootstrap() -> HttpResponse { HttpResponse::Ok() .content_type("text/css") .body(include_str!("../assets/bootstrap.min.css")) } #[get("/assets/cover.css")] async fn cover() -> HttpResponse { HttpResponse::Ok() .content_type("text/css") .body(include_str!("../assets/cover.css")) } #[derive(Template)] #[template(path = "home.html")] struct HomeTemplate { remote_ip: String, redirect_url: String, } #[derive(Template)] #[template(path = "result.html")] struct ResultTemplate { token: String, user_info: String, } #[derive(Template)] #[template(path = "error.html")] struct ErrorTemplate<'a> { message: &'a str, } impl<'a> ErrorTemplate<'a> { pub fn build(message: &'a str) -> HttpResponse { HttpResponse::Unauthorized() .content_type("text/html") .body(Self { message }.render().unwrap()) } } #[get("/")] async fn home(remote_ip: RemoteIP) -> HttpResponse { HttpResponse::Ok().content_type("text/html").body( HomeTemplate { remote_ip: remote_ip.0.to_string(), redirect_url: AppConfig::get().redirect_url(), } .render() .unwrap(), ) } #[get("/start")] async fn start(remote_ip: RemoteIP, state_manager: web::Data) -> HttpResponse { let config = match OpenIDConfig::load_from_url(&AppConfig::get().configuration_url).await { Ok(c) => c, Err(e) => { log::error!("Failed to load OpenID configuration! {e}"); return ErrorTemplate::build("Failed to load OpenID configuration!"); } }; let state = match state_manager.gen_state(remote_ip.0) { Ok(s) => s, Err(e) => { log::error!("Failed to generate state! {:?}", e); return ErrorTemplate::build("Failed to generate state!"); } }; let authorization_url = config.gen_authorization_url( &AppConfig::get().client_id, &state, &AppConfig::get().redirect_url(), ); HttpResponse::Found() .append_header(("Location", authorization_url)) .finish() } #[derive(serde::Deserialize)] struct RedirectQuery { state: String, code: String, } #[get("/redirect")] async fn redirect( remote_ip: RemoteIP, query: web::Query, state_manager: web::Data, ) -> HttpResponse { // First, validate state if let Err(e) = state_manager.validate_state(remote_ip.0, &query.state) { log::error!("Failed to validate state {}: {:?}", query.state, e); return ErrorTemplate::build("State could not be validated!"); } // Then, load OpenID configuration let config = match OpenIDConfig::load_from_url(&AppConfig::get().configuration_url).await { Ok(c) => c, Err(e) => { log::error!("Failed to load OpenID configuration! {e}"); return ErrorTemplate::build("Failed to load OpenID configuration!"); } }; // Query token endpoint let (token, token_str) = match config .request_token( &AppConfig::get().client_id, &AppConfig::get().client_secret, &query.code, &AppConfig::get().redirect_url(), ) .await { Ok(t) => t, Err(e) => { log::error!("Failed to retrieve token! {}", e); return ErrorTemplate::build("Failed to retrieve access token!"); } }; // Query userinfo endpoint let (_user_info, user_info_str) = match config.request_user_info(&token).await { Ok(t) => t, Err(e) => { log::error!("Failed to retrieve user info! {}", e); return ErrorTemplate::build("Failed to retrieve user info!"); } }; HttpResponse::Ok().content_type("text/html").body( ResultTemplate { token: serde_json::to_string_pretty( &serde_json::from_str::(&token_str).unwrap(), ) .unwrap(), user_info: serde_json::to_string_pretty( &serde_json::from_str::(&user_info_str).unwrap(), ) .unwrap(), } .render() .unwrap(), ) } #[actix_web::main] async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); log::info!("Init state manager"); let state_manager = web::Data::new(BasicStateManager::new()); log::info!("Will listen on {}", AppConfig::get().listen_addr); HttpServer::new(move || { App::new() .wrap(Logger::default()) .app_data(web::Data::new(RemoteIPConfig { proxy: CONF.proxy_ip.clone(), })) .app_data(state_manager.clone()) .service(bootstrap) .service(cover) .service(home) .service(start) .service(redirect) }) .bind(&AppConfig::get().listen_addr) .expect("Failed to bind server!") .run() .await } #[cfg(test)] mod test { use crate::AppConfig; #[test] fn verify_cli() { use clap::CommandFactory; AppConfig::command().debug_assert(); } }