diff --git a/proxmox-openid/Cargo.toml b/proxmox-openid/Cargo.toml new file mode 100644 index 00000000..2410ac5f --- /dev/null +++ b/proxmox-openid/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "proxmox-openid" +version = "0.9.9" +authors = ["Dietmar Maurer "] +edition = "2018" +license = "AGPL-3" +exclude = [ + "build", + "debian", +] + +[lib] +name = "proxmox_openid" +path = "src/lib.rs" + +[dependencies] +anyhow = "1.0" +http = "0.2" +nix = "0.26" +openidconnect = { version = "2.4", default-features = false, features = ["accept-rfc3339-timestamps"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror="1.0" +ureq = { version = "2.4", default-features = false, features = ["native-tls", "gzip"] } +native-tls = "0.2" +url = "2.1" + +proxmox-time = "1" +proxmox-sys = { version = "0.4", features = ["timer"] } diff --git a/proxmox-openid/debian/changelog b/proxmox-openid/debian/changelog new file mode 100644 index 00000000..21341cea --- /dev/null +++ b/proxmox-openid/debian/changelog @@ -0,0 +1,141 @@ +rust-proxmox-openid (0.9.9-1) stable; urgency=medium + + * update openidconnect to 2.4 + + -- Proxmox Support Team Wed, 11 Jan 2023 18:41:25 +0100 + +rust-proxmox-openid (0.9.8-1) stable; urgency=medium + + * update nix to 0.26 + + -- Proxmox Support Team Thu, 05 Jan 2023 12:25:10 +0100 + +rust-proxmox-openid (0.9.7-1) stable; urgency=medium + + * bump proxmox-sys to 0.4 + + -- Proxmox Support Team Thu, 28 Jul 2022 13:40:44 +0200 + +rust-proxmox-openid (0.9.6-1) stable; urgency=medium + + * rebuild with nix 0.24 and proxmox-sys 0.3 + + -- Proxmox Support Team Thu, 2 Jun 2022 12:38:28 +0200 + +rust-proxmox-openid (0.9.5-1) stable; urgency=medium + + * avoid chunked transfer-encoding when submitting to the provider's token + endpoint, as some providers like Microsoft's Azure are quite inflexible + and cannot cope with such basic HTTP requests. + + -- Proxmox Support Team Fri, 01 Apr 2022 15:56:07 +0200 + +rust-proxmox-openid (0.9.4-1) stable; urgency=medium + + * re-add HTTP proxy support via the ALL_PROXY environment variable. This got + lost with switching the HTTP client from curl to ureq. + + -- Proxmox Support Team Tue, 22 Mar 2022 11:31:08 +0100 + +rust-proxmox-openid (0.9.3-1) stable; urgency=medium + + * use much simpler ureq (with native-tls) HTTP client instead of curl + + * enable "accept-rfc3339-timestamps" feature to fix support for some OIDC + providers like `auth0` + + -- Proxmox Support Team Tue, 01 Feb 2022 09:08:31 +0100 + +rust-proxmox-openid (0.9.2-1) stable; urgency=medium + + * depend on proxmox-sys 0.2 + + -- Proxmox Support Team Tue, 23 Nov 2021 12:35:41 +0100 + +rust-proxmox-openid (0.9.1-1) unstable; urgency=medium + + * rebuild with openidconnect 0.2.1 + + -- Proxmox Support Team Thu, 18 Nov 2021 12:54:24 +0100 + +rust-proxmox-openid (0.9.0-1) unstable; urgency=medium + + * allow to configure used scopes + + * allow to configure prompt behaviour + + * allow to configure acr values + + * new helper verify_authorization_code_simple() + + * also return data from UserInfo endpoint + + -- Proxmox Support Team Thu, 18 Nov 2021 09:36:29 +0100 + +rust-proxmox-openid (0.8.1-1) unstable; urgency=medium + + * add fsync parameter to replace_file + + * Depend on proxmox 0.15.0 + + -- Proxmox Support Team Thu, 21 Oct 2021 07:14:52 +0200 + +rust-proxmox-openid (0.8.0-1) unstable; urgency=medium + + * update to proxmox crate split + + -- Proxmox Support Team Fri, 08 Oct 2021 12:19:55 +0200 + +rust-proxmox-openid (0.7.0-1) unstable; urgency=medium + + * bump proxmox to 0.13.0 + + -- Proxmox Support Team Tue, 24 Aug 2021 16:06:55 +0200 + +rust-proxmox-openid (0.6.1-1) unstable; urgency=medium + + * depend on proxmox 0.12.0 + + -- Proxmox Support Team Tue, 20 Jul 2021 13:19:23 +0200 + +rust-proxmox-openid (0.6.0-2) unstable; urgency=medium + + * remove debug output + + -- Proxmox Support Team Wed, 30 Jun 2021 08:43:06 +0200 + +rust-proxmox-openid (0.6.0-1) unstable; urgency=medium + + * use one lock file per realm + + -- Proxmox Support Team Fri, 25 Jun 2021 11:09:08 +0200 + +rust-proxmox-openid (0.5.0-1) unstable; urgency=medium + + * avoid unused features "sortable-macro" and "api-macro" + + -- Proxmox Support Team Wed, 23 Jun 2021 11:29:05 +0200 + +rust-proxmox-openid (0.4.0-1) unstable; urgency=medium + + * set "default-features = false" for proxmox crate + + -- Proxmox Support Team Wed, 23 Jun 2021 11:17:22 +0200 + +rust-proxmox-openid (0.3.0-1) unstable; urgency=medium + + * return authorize_url() as string + + -- Proxmox Support Team Tue, 22 Jun 2021 09:23:33 +0200 + +rust-proxmox-openid (0.2.0-1) devel; urgency=medium + + * implement Deserialize/Serialize for OpenIdConfig + + -- Proxmox Support Team Mon, 21 Jun 2021 13:37:24 +0200 + +rust-proxmox-openid (0.1.0-1) devel; urgency=medium + + * initial release + + -- Proxmox Support Team Fri, 18 Jun 2021 16:05:49 +0200 diff --git a/proxmox-openid/debian/control b/proxmox-openid/debian/control new file mode 100644 index 00000000..4c5e7c5e --- /dev/null +++ b/proxmox-openid/debian/control @@ -0,0 +1,61 @@ +Source: rust-proxmox-openid +Section: rust +Priority: optional +Build-Depends: debhelper (>= 12), + dh-cargo (>= 25), + cargo:native , + rustc:native , + libstd-rust-dev , + librust-anyhow-1+default-dev , + librust-http-0.2+default-dev , + librust-native-tls-0.2+default-dev , + librust-nix-0.26+default-dev , + librust-openidconnect-2+accept-rfc3339-timestamps-dev (>= 2.4-~~) , + librust-proxmox-sys-0.4+default-dev , + librust-proxmox-sys-0.4+timer-dev , + librust-proxmox-time-1+default-dev , + librust-serde-1+default-dev , + librust-serde-1+derive-dev , + librust-serde-json-1+default-dev , + librust-thiserror-1+default-dev , + librust-ureq-2+gzip-dev (>= 2.4-~~) , + librust-ureq-2+native-tls-dev (>= 2.4-~~) , + librust-url-2+default-dev (>= 2.1-~~) +Maintainer: Proxmox Support Team +Standards-Version: 4.6.1 +Vcs-Git: +Vcs-Browser: +X-Cargo-Crate: proxmox-openid +Rules-Requires-Root: no + +Package: librust-proxmox-openid-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-anyhow-1+default-dev, + librust-http-0.2+default-dev, + librust-native-tls-0.2+default-dev, + librust-nix-0.26+default-dev, + librust-openidconnect-2+accept-rfc3339-timestamps-dev (>= 2.4-~~), + librust-proxmox-sys-0.4+default-dev, + librust-proxmox-sys-0.4+timer-dev, + librust-proxmox-time-1+default-dev, + librust-serde-1+default-dev, + librust-serde-1+derive-dev, + librust-serde-json-1+default-dev, + librust-thiserror-1+default-dev, + librust-ureq-2+gzip-dev (>= 2.4-~~), + librust-ureq-2+native-tls-dev (>= 2.4-~~), + librust-url-2+default-dev (>= 2.1-~~) +Provides: + librust-proxmox-openid+default-dev (= ${binary:Version}), + librust-proxmox-openid-0-dev (= ${binary:Version}), + librust-proxmox-openid-0+default-dev (= ${binary:Version}), + librust-proxmox-openid-0.9-dev (= ${binary:Version}), + librust-proxmox-openid-0.9+default-dev (= ${binary:Version}), + librust-proxmox-openid-0.9.9-dev (= ${binary:Version}), + librust-proxmox-openid-0.9.9+default-dev (= ${binary:Version}) +Description: Rust crate "proxmox-openid" - Rust source code + This package contains the source for the Rust proxmox-openid crate, packaged by + debcargo for use with cargo and dh-cargo. diff --git a/proxmox-openid/debian/copyright b/proxmox-openid/debian/copyright new file mode 100644 index 00000000..477c3058 --- /dev/null +++ b/proxmox-openid/debian/copyright @@ -0,0 +1,16 @@ +Copyright (C) 2020-2021 Proxmox Server Solutions GmbH + +This software is written by Proxmox Server Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/proxmox-openid/debian/debcargo.toml b/proxmox-openid/debian/debcargo.toml new file mode 100644 index 00000000..703440fc --- /dev/null +++ b/proxmox-openid/debian/debcargo.toml @@ -0,0 +1,8 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +# TODO: update once public +vcs_git = "" +vcs_browser = "" diff --git a/proxmox-openid/src/auth_state.rs b/proxmox-openid/src/auth_state.rs new file mode 100644 index 00000000..7692ff31 --- /dev/null +++ b/proxmox-openid/src/auth_state.rs @@ -0,0 +1,116 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Error}; +use serde_json::{json, Value}; + +use proxmox_sys::fs::{ + replace_file, + open_file_locked, + file_get_json, + CreateOptions, +}; +use proxmox_time::epoch_i64; + +use super::{PublicAuthState, PrivateAuthState}; + +fn load_auth_state_locked( + state_dir: &Path, + realm: &str, + default: Option, +) -> Result<(PathBuf, std::fs::File, Vec), Error> { + + let mut lock_path = state_dir.to_owned(); + lock_path.push(format!("proxmox-openid-auth-state-{}.lck", realm)); + + let lock = open_file_locked( + lock_path, + std::time::Duration::new(10, 0), + true, + CreateOptions::new() + )?; + + let mut path = state_dir.to_owned(); + path.push(format!("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: &Path, + data: &Vec, +) -> Result<(), Error> { + + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0600); + let options = CreateOptions::new().perm(mode); + let raw = serde_json::to_string_pretty(data)?; + + replace_file(path, raw.as_bytes(), options, false)?; + + Ok(()) +} + +pub fn verify_public_auth_state( + state_dir: &Path, + state: &str, +) -> Result<(String, PrivateAuthState), Error> { + + let public_auth_state: PublicAuthState = serde_json::from_str(state)?; + + let (path, _lock, old_data) = load_auth_state_locked(state_dir, &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)?; + + Ok((public_auth_state.realm, entry)) +} + +pub fn store_auth_state( + state_dir: &Path, + realm: &str, + auth_state: &PrivateAuthState, +) -> Result<(), Error> { + + let (path, _lock, mut data) = load_auth_state_locked(state_dir, 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)?; + + Ok(()) +} diff --git a/proxmox-openid/src/http_client.rs b/proxmox-openid/src/http_client.rs new file mode 100644 index 00000000..e391421a --- /dev/null +++ b/proxmox-openid/src/http_client.rs @@ -0,0 +1,100 @@ +use std::env; +use std::sync::Arc; + +use http::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; +use http::method::Method; +use http::status::StatusCode; + +use openidconnect::{HttpRequest, HttpResponse}; + +// Copied from OAuth2 create, because we want to use ureq with +// native-tls. But current OAuth2 crate pulls in rustls, so we cannot +// use their 'ureq' feature. + +/// +/// Error type returned by failed ureq HTTP requests. +/// +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Non-ureq HTTP error. + #[error("HTTP error - {0}")] + Http(#[from] http::Error), + + /// IO error + #[error("IO error - {0}")] + IO(#[from] std::io::Error), + + /// Error returned by ureq crate. + // boxed due to https://github.com/algesten/ureq/issues/296 + #[error("ureq request failed - {0}")] + Ureq(#[from] Box), + + #[error("TLS error - {0}")] + Tls(#[from] native_tls::Error), + + /// Other error. + #[error("Other error: {0}")] + Other(String), +} + +fn ureq_agent() -> Result { + let mut agent = + ureq::AgentBuilder::new().tls_connector(Arc::new(native_tls::TlsConnector::new()?)); + if let Ok(val) = env::var("all_proxy").or_else(|_| env::var("ALL_PROXY")) { + let proxy = ureq::Proxy::new(val).map_err(Box::new)?; + agent = agent.proxy(proxy); + } + + Ok(agent.build()) +} + +/// +/// Synchronous HTTP client for ureq. +/// +pub fn http_client(request: HttpRequest) -> Result { + let agent = ureq_agent()?; + let mut req = if let Method::POST = request.method { + agent.post(&request.url.to_string()) + } else { + agent.get(&request.url.to_string()) + }; + + for (name, value) in request.headers { + if let Some(name) = name { + req = req.set( + &name.to_string(), + value.to_str().map_err(|_| { + Error::Other(format!( + "invalid {} header value {:?}", + name, + value.as_bytes() + )) + })?, + ); + } + } + + let response = if let Method::POST = request.method { + // send_bytes makes sure that Content-Length is set. This is important, because some + // endpoints don't accept `Transfer-Encoding: chunked`, which would otherwise be set. + // see https://docs.rs/ureq/2.4.0/ureq/index.html#content-length-and-transfer-encoding + req.send_bytes(request.body.as_slice()) + } else { + req.call() + } + .map_err(Box::new)?; + + let status_code = + StatusCode::from_u16(response.status()).map_err(|err| Error::Http(err.into()))?; + + let content_type = + HeaderValue::from_str(response.content_type()).map_err(|err| Error::Http(err.into()))?; + + Ok(HttpResponse { + status_code, + headers: vec![(CONTENT_TYPE, content_type)] + .into_iter() + .collect::(), + body: response.into_string()?.as_bytes().into(), + }) +} diff --git a/proxmox-openid/src/lib.rs b/proxmox-openid/src/lib.rs new file mode 100644 index 00000000..e9fbd940 --- /dev/null +++ b/proxmox-openid/src/lib.rs @@ -0,0 +1,253 @@ +use std::path::Path; + +use anyhow::{format_err, Error}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +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, + CoreGenderClaim, + }, + PkceCodeChallenge, + PkceCodeVerifier, + AuthorizationCode, + ClientId, + ClientSecret, + CsrfToken, + IssuerUrl, + Nonce, + OAuth2TokenResponse, + RedirectUrl, + Scope, + UserInfoClaims, + AdditionalClaims, + AuthenticationContextClass, +}; + +/// Stores Additional Claims into a serde_json::Value; +#[derive(Debug, Deserialize, Serialize)] +pub struct GenericClaims(Value); +impl AdditionalClaims for GenericClaims {} + +pub type GenericUserInfoClaims = UserInfoClaims; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct OpenIdConfig { + pub issuer_url: String, + pub client_id: String, + #[serde(skip_serializing_if="Option::is_none")] + pub client_key: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub scopes: Option>, + #[serde(skip_serializing_if="Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub acr_values: Option>, +} + +pub struct OpenIdAuthenticator { + client: CoreClient, + config: OpenIdConfig, +} + +#[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_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, + config: config.clone(), + }) + } + + pub fn authorize_url(&self, state_dir: &str, realm: &str) -> 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(Path::new(state_dir), realm, &private_auth_state)?; + + // Generate the authorization URL to which we'll redirect the user. + let mut request = self.client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + || CsrfToken::new(public_auth_state), + || nonce, + ) + .set_pkce_challenge(private_auth_state.pkce_challenge()); + + request = request.set_display(CoreAuthDisplay::Page); + + match self.config.prompt.as_deref() { + None => { /* nothing */ }, + Some("none") => { + request = request.add_prompt(CoreAuthPrompt::None); + } + Some("login") => { + request = request.add_prompt(CoreAuthPrompt::Login); + } + Some("consent") => { + request = request.add_prompt(CoreAuthPrompt::Consent); + } + Some("select_account") => { + request = request.add_prompt(CoreAuthPrompt::SelectAccount); + } + Some(extension) => { + request = request.add_prompt(CoreAuthPrompt::Extension(extension.into())); + } + } + + if let Some(ref scopes) = self.config.scopes { + for scope in scopes.clone() { + request = request.add_scope(Scope::new(scope)); + } + } + + if let Some(ref acr_values) = self.config.acr_values { + for acr in acr_values.clone() { + request = request.add_auth_context_value(AuthenticationContextClass::new(acr)); + } + } + + let (authorize_url, _csrf_state, _nonce) = request.url(); + + Ok(authorize_url.to_string()) + } + + pub fn verify_public_auth_state( + state_dir: &str, + state: &str, + ) -> Result<(String, PrivateAuthState), Error> { + verify_public_auth_state(Path::new(state_dir), state) + } + + pub fn verify_authorization_code( + &self, + code: &str, + private_auth_state: &PrivateAuthState, + ) -> Result<(CoreIdTokenClaims, GenericUserInfoClaims), Error> { + + 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))?; + + 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))?; + + let userinfo_claims: GenericUserInfoClaims = self.client + .user_info(token_response.access_token().to_owned(), None)? + .request(http_client) + .map_err(|err| format_err!("Failed to contact userinfo endpoint: {}", err))?; + + Ok((id_token_claims.clone(), userinfo_claims)) + } + + /// Like verify_authorization_code(), but returns claims as serde_json::Value + pub fn verify_authorization_code_simple( + &self, + code: &str, + private_auth_state: &PrivateAuthState, + ) -> Result { + + let (id_token_claims, userinfo_claims) = self.verify_authorization_code(&code, &private_auth_state)?; + + let mut data = serde_json::to_value(id_token_claims)?; + + let data2 = serde_json::to_value(userinfo_claims)?; + + if let Some(map) = data2.as_object() { + for (key, value) in map { + if data[key] != Value::Null { + continue; // already set + } + data[key] = value.clone(); + } + } + + Ok(data) + } +}