commit ca65d297b773126b951fb9b8c475edea819571a0 Author: Dietmar Maurer Date: Fri Jun 18 10:08:01 2021 +0200 initial import diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 00000000..3b5b6e48 --- /dev/null +++ b/.cargo/config @@ -0,0 +1,5 @@ +[source] +[source.debian-packages] +directory = "/usr/share/cargo/registry" +[source.crates-io] +replace-with = "debian-packages" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..bc6b8a10 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "proxmox-openid" +version = "0.1.0" +authors = ["Dietmar Maurer "] +edition = "2018" + +[lib] +name = "proxmox_openid" +path = "src/lib.rs" + + +[dependencies] +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +url = "2.1" +http = "0.2" +curl = { version = "0.4.33" } +proxmox = { version = "0.11.5", features = [ "sortable-macro", "api-macro" ] } +nix = "0.19.1" +openidconnect = { version = "2.0", default-features = false, features = ["curl"] } diff --git a/src/auth_state.rs b/src/auth_state.rs new file mode 100644 index 00000000..29caea52 --- /dev/null +++ b/src/auth_state.rs @@ -0,0 +1,107 @@ +use anyhow::{bail, Error}; +use serde_json::{json, Value}; +use nix::unistd::Uid; + +use proxmox::tools::{ + time::epoch_i64, + fs::{ + replace_file, + open_file_locked, + file_get_json, + CreateOptions, + }, +}; + +use super::{PublicAuthState, PrivateAuthState}; + +fn load_auth_state_locked(realm: &str, default: Option) -> Result<(String, std::fs::File, Vec), Error> { + + let lock = open_file_locked( + "/tmp/proxmox-openid-auth-state.lock", + std::time::Duration::new(10, 0), + true + )?; + + let path = format!("/tmp/proxmox-openid-auth-state-{}", realm); + + let now = epoch_i64(); + + let old_data = file_get_json(&path, default)?; + + let mut data: Vec = Vec::new(); + + let timeout = 10*60; // 10 minutes + + for v in old_data.as_array().unwrap() { + let ctime = v["ctime"].as_i64().unwrap_or(0); + if (ctime + timeout) < now { + continue; + } + data.push(v.clone()); + } + + Ok((path, lock, data)) +} + +fn replace_auth_state(path: &str, data: &Vec, state_owner: Uid) -> Result<(), Error> { + + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0600); + // set the correct owner/group/permissions while saving file + // owner(rw) = root + let options = CreateOptions::new() + .perm(mode) + .owner(state_owner); + + let raw = serde_json::to_string_pretty(data)?; + + replace_file(&path, raw.as_bytes(), options)?; + + Ok(()) +} + +pub fn verify_public_auth_state(state: &str, state_owner: Uid) -> Result<(String, PrivateAuthState), Error> { + + let public_auth_state: PublicAuthState = serde_json::from_str(state)?; + + let (path, _lock, old_data) = load_auth_state_locked(&public_auth_state.realm, None)?; + + let mut data: Vec = Vec::new(); + + let mut entry: Option = None; + let find_csrf_token = public_auth_state.csrf_token.secret(); + for v in old_data { + if v["csrf_token"].as_str() == Some(find_csrf_token) { + entry = Some(serde_json::from_value(v)?); + } else { + data.push(v); + } + } + + let entry = match entry { + None => bail!("no openid auth state found (possible timeout)"), + Some(entry) => entry, + }; + + replace_auth_state(&path, &data, state_owner)?; + + Ok((public_auth_state.realm, entry)) +} + +pub fn store_auth_state( + realm: &str, + auth_state: &PrivateAuthState, + state_owner: Uid, +) -> Result<(), Error> { + + let (path, _lock, mut data) = load_auth_state_locked(realm, Some(json!([])))?; + + if data.len() > 100 { + bail!("too many pending openid auth request for realm {}", realm); + } + + data.push(serde_json::to_value(&auth_state)?); + + replace_auth_state(&path, &data, state_owner)?; + + Ok(()) +} diff --git a/src/http_client.rs b/src/http_client.rs new file mode 100644 index 00000000..f07aed0d --- /dev/null +++ b/src/http_client.rs @@ -0,0 +1,92 @@ +use std::io::Read; + +use curl::easy::Easy; +use http::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; +use http::method::Method; +use http::status::StatusCode; + +use openidconnect::{ + HttpRequest, + HttpResponse, +}; + +/// Synchronous Curl HTTP client. +/// +/// Copied fron OAuth2 create, added fix https://github.com/ramosbugs/oauth2-rs/pull/147 +pub fn http_client(request: HttpRequest) -> Result { + + use openidconnect::curl::Error; + + let mut easy = Easy::new(); + easy.url(&request.url.to_string()[..]) + .map_err(Error::Curl)?; + + let mut headers = curl::easy::List::new(); + request + .headers + .iter() + .map(|(name, value)| { + headers + .append(&format!( + "{}: {}", + name, + value.to_str().map_err(|_| Error::Other(format!( + "invalid {} header value {:?}", + name, + value.as_bytes() + )))? + )) + .map_err(Error::Curl) + }) + .collect::>()?; + + easy.http_headers(headers).map_err(Error::Curl)?; + + if let Method::POST = request.method { + easy.post(true).map_err(Error::Curl)?; + easy.post_field_size(request.body.len() as u64) + .map_err(Error::Curl)?; + } else { + assert_eq!(request.method, Method::GET); + } + + let mut form_slice = &request.body[..]; + let mut data = Vec::new(); + { + let mut transfer = easy.transfer(); + + transfer + .read_function(|buf| Ok(form_slice.read(buf).unwrap_or(0))) + .map_err(Error::Curl)?; + + transfer + .write_function(|new_data| { + data.extend_from_slice(new_data); + Ok(new_data.len()) + }) + .map_err(Error::Curl)?; + + transfer.perform().map_err(Error::Curl)?; + } + + let status_code = easy.response_code().map_err(Error::Curl)? as u16; + + Ok(HttpResponse { + status_code: StatusCode::from_u16(status_code).map_err(|err| Error::Http(err.into()))?, + headers: easy + .content_type() + .map_err(Error::Curl)? + .map(|content_type| { + Ok(vec![( + CONTENT_TYPE, + HeaderValue::from_str(content_type).map_err(|err| Error::Http(err.into()))?, + )] + .into_iter() + .collect::()) + }) + .transpose()? + .unwrap_or_else(HeaderMap::new), + body: data, + }) +} + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..eb2d9c7b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,181 @@ +use anyhow::{format_err, Error}; +use serde::{Deserialize, Serialize}; +use url::Url; +use nix::unistd::Uid; + +mod http_client; +pub use http_client::http_client; + +mod auth_state; +pub use auth_state::*; + + +use openidconnect::{ + //curl::http_client, + core::{ + CoreProviderMetadata, + CoreClient, + CoreIdTokenClaims, + CoreIdTokenVerifier, + CoreAuthenticationFlow, + CoreAuthDisplay, + CoreAuthPrompt, + }, + PkceCodeChallenge, + PkceCodeVerifier, + AuthorizationCode, + ClientId, + ClientSecret, + CsrfToken, + IssuerUrl, + Nonce, + OAuth2TokenResponse, + RedirectUrl, + Scope, +}; + +pub struct OpenIdConfig { + pub issuer_url: String, + pub client_id: String, + pub client_key: Option, +} + +pub struct OpenIdAuthenticator { + client: CoreClient, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PublicAuthState { + pub csrf_token: CsrfToken, + pub realm: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PrivateAuthState { + pub csrf_token: CsrfToken, + pub nonce: Nonce, + pub pkce_verifier: PkceCodeVerifier, + pub ctime: i64, +} + +impl PrivateAuthState { + + pub fn new() -> Self { + let nonce = Nonce::new_random(); + let csrf_token = CsrfToken::new_random(); + let (_pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + PrivateAuthState { + csrf_token, + nonce, + pkce_verifier, + ctime: proxmox::tools::time::epoch_i64(), + } + } + + pub fn pkce_verifier(&self) -> PkceCodeVerifier { + // Note: PkceCodeVerifier does not impl. clone() + PkceCodeVerifier::new(self.pkce_verifier.secret().to_string()) + } + + pub fn pkce_challenge(&self) -> PkceCodeChallenge { + PkceCodeChallenge::from_code_verifier_sha256(&self.pkce_verifier) + } + + pub fn public_state_string(&self, realm: String) -> Result { + let pub_state = PublicAuthState { + csrf_token: self.csrf_token.clone(), + realm, + }; + Ok(serde_json::to_string(&pub_state)?) + } +} + +impl OpenIdAuthenticator { + + pub fn discover(config: &OpenIdConfig, redirect_url: &str) -> Result { + + let client_id = ClientId::new(config.client_id.clone()); + let client_key = config.client_key.clone().map(|key| ClientSecret::new(key)); + let issuer_url = IssuerUrl::new(config.issuer_url.clone())?; + + let provider_metadata = CoreProviderMetadata::discover(&issuer_url, http_client)?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + client_id, + client_key, + ).set_redirect_uri(RedirectUrl::new(String::from(redirect_url))?); + + Ok(Self { + client, + }) + } + + pub fn authorize_url(&self, realm: &str, state_owner: Uid) -> Result { + + let private_auth_state = PrivateAuthState::new(); + let public_auth_state = private_auth_state.public_state_string(realm.to_string())?; + let nonce = private_auth_state.nonce.clone(); + + store_auth_state(realm, &private_auth_state, state_owner)?; + + // Generate the authorization URL to which we'll redirect the user. + let (authorize_url, _csrf_state, _nonce) = self.client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + || CsrfToken::new(public_auth_state), + || nonce, + ) + .set_display(CoreAuthDisplay::Page) + .add_prompt(CoreAuthPrompt::Login) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .set_pkce_challenge(private_auth_state.pkce_challenge()) + .url(); + + Ok(authorize_url.into()) + } + + pub fn verify_public_auth_state( + state: &str, + state_owner: Uid, + ) -> Result<(String, PrivateAuthState), Error> { + verify_public_auth_state(state, state_owner) + } + + pub fn verify_authorization_code( + &self, + code: &str, + private_auth_state: &PrivateAuthState, + ) -> Result { + + let code = AuthorizationCode::new(code.to_string()); + // Exchange the code with a token. + let token_response = self.client + .exchange_code(code) + .set_pkce_verifier(private_auth_state.pkce_verifier()) + .request(http_client) + .map_err(|err| format_err!("Failed to contact token endpoint: {}", err))?; + + println!( + "OpenId returned access token:\n{}\n", + token_response.access_token().secret() + ); + + println!("OpenId returned scopes: {:?}", token_response.scopes()); + + let id_token_verifier: CoreIdTokenVerifier = self.client.id_token_verifier(); + let id_token_claims: &CoreIdTokenClaims = token_response + .extra_fields() + .id_token() + .expect("Server did not return an ID token") + .claims(&id_token_verifier, &private_auth_state.nonce) + .map_err(|err| format_err!("Failed to verify ID token: {}", err))?; + + println!("Google returned ID token: {:?}", id_token_claims); + + Ok(id_token_claims.clone()) + } + +}