diff --git a/Cargo.lock b/Cargo.lock index 1e6b364..14fd81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2", + "socket2 0.5.10", "tokio", "tracing", ] @@ -228,7 +228,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2", + "socket2 0.5.10", "time", "tracing", "url", @@ -1358,7 +1358,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1542,6 +1542,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2123,7 +2134,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2", + "socket2 0.5.10", "thiserror", "tokio", "tracing", @@ -2160,7 +2171,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -2367,6 +2378,7 @@ dependencies = [ "serde", "serde_yaml", "tera", + "tokio", "uuid", ] @@ -2512,9 +2524,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", @@ -2631,6 +2643,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -2780,19 +2802,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", - "windows-sys 0.52.0", + "slab", + "socket2 0.6.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 86e63b9..854c628 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,8 @@ google-login = ["third-party-login"] github-login = ["third-party-login"] gitlab-login = ["third-party-login"] microsoft-login = ["third-party-login"] -all-login = ["google-login", "github-login", "gitlab-login", "microsoft-login"] +oidc-login = ["third-party-login"] +all-login = ["google-login", "github-login", "gitlab-login", "microsoft-login", "oidc-login"] [dev-dependencies] dotenvy = "0.15.6" @@ -38,4 +39,5 @@ serde = { version = "1", features = ["derive"] } serde_yaml = "0.9.34-deprecated" uuid = { version = "1", features = ["serde", "v4"] } tera = { version = "1", optional = true } -reqwest = { version = "0.12.4", features = ["json", "rustls-tls"], default-features = false, optional = true } \ No newline at end of file +reqwest = { version = "0.12.4", features = ["json", "rustls-tls"], default-features = false, optional = true } +tokio = { version = "1.47.1", features = ["sync"] } \ No newline at end of file diff --git a/README.md b/README.md index 8c2d605..db68eff 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ rtabby-web-api store tabby's configuration in a database. You can choose between ``` Token must be a valid and unique uuid v4. You can create one [here](https://www.uuidgenerator.net/version4). - rTabby supports OAuth2 providers like Github, Gitlab, Google or Microsoft. You can enable them by adding OAuth client and secret through env var in your `docker-compose.yml`. + rTabby supports OAuth2 providers like Github, Gitlab, Google, Microsoft or OpenID Connect (OIDC). You can enable them by adding OAuth client and secret through env var in your `docker-compose.yml`. For OIDC, you'll also need to provide your configuration url (ends with `/.well-known/openid-configuration`). OAuth login callback is `/login/{provider}/callback`. ```yml @@ -84,6 +84,9 @@ rtabby-web-api store tabby's configuration in a database. You can choose between #- GOOGLE_APP_CLIENT_SECRET= #- MICROSOFT_APP_CLIENT_ID= #- MICROSOFT_APP_CLIENT_SECRET= + #- OIDC_APP_CLIENT_ID= + #- OIDC_APP_CLIENT_SECRET= + #- OIDC_APP_CONFIG_URL= ``` When using OAuth prividers, browse to `http:///login` to authenticate and create your user and token. diff --git a/docker-compose-sqlite.yml b/docker-compose-sqlite.yml index 453d653..6372ff4 100644 --- a/docker-compose-sqlite.yml +++ b/docker-compose-sqlite.yml @@ -32,6 +32,9 @@ services: #- GOOGLE_APP_CLIENT_SECRET= #- MICROSOFT_APP_CLIENT_ID= #- MICROSOFT_APP_CLIENT_SECRET= + #- OIDC_APP_CLIENT_ID= + #- OIDC_APP_CLIENT_SECRET= + #- OIDC_APP_CONFIG_URL= volumes: - ./config:/config networks: diff --git a/docker-compose.yml b/docker-compose.yml index cbe8b4d..db08093 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,9 @@ services: #- GOOGLE_APP_CLIENT_SECRET= #- MICROSOFT_APP_CLIENT_ID= #- MICROSOFT_APP_CLIENT_SECRET= + #- OIDC_APP_CLIENT_ID= + #- OIDC_APP_CLIENT_SECRET= + #- OIDC_APP_CONFIG_URL= volumes: - ./config:/config diff --git a/src/login/error.rs b/src/login/error.rs index 52a9ed9..002ea7d 100644 --- a/src/login/error.rs +++ b/src/login/error.rs @@ -22,6 +22,7 @@ impl fmt::Display for ProviderError { pub enum OauthError { UserInfo(reqwest::Error), AccessToken(reqwest::Error), + OIDCConfiguration(reqwest::Error), } impl error::Error for OauthError {} @@ -32,7 +33,8 @@ impl fmt::Display for OauthError { Self::UserInfo(ref err) => write!(f, "Unable to retreive OAuth user info: {err}"), Self::AccessToken(ref err) => { write!(f, "Unable to retreive OAuth user access token: {err}") - } + }, + Self::OIDCConfiguration(ref err) => write!(f, "Unable to retreive OIDC configuration: {err}") } } } diff --git a/src/login/mod.rs b/src/login/mod.rs index 0cf910c..cc3f074 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -18,6 +18,8 @@ use providers::gitlab; use providers::google; #[cfg(feature = "microsoft-login")] use providers::microsoft; +#[cfg(feature = "oidc-login")] +use providers::oidc; use self::providers::OauthInfo; @@ -94,6 +96,16 @@ pub fn get_provider_config() -> ProvidersConfig { })); } + #[cfg(feature = "oidc-login")] + if app_env::var(oidc::env::ENV_OIDC_APP_CLIENT_ID).is_ok() + && app_env::var(oidc::env::ENV_OIDC_APP_CLIENT_SECRET).is_ok() + { + available_providers.push(providers::Provider::Oidc(OauthInfo { + client_id: app_env::var(oidc::env::ENV_OIDC_APP_CLIENT_ID).unwrap(), + client_secret: app_env::var(oidc::env::ENV_OIDC_APP_CLIENT_SECRET).unwrap(), + })); + } + ProvidersConfig { https_callback, available_providers, diff --git a/src/login/providers/mod.rs b/src/login/providers/mod.rs index 27aa7a7..125058a 100644 --- a/src/login/providers/mod.rs +++ b/src/login/providers/mod.rs @@ -6,6 +6,8 @@ pub mod gitlab; pub mod google; #[cfg(feature = "microsoft-login")] pub mod microsoft; +#[cfg(feature = "oidc-login")] +pub mod oidc; use super::error::OauthError; use serde::{Deserialize, Serialize}; @@ -22,6 +24,7 @@ pub struct OauthInfo { #[derive(Debug, Deserialize)] pub struct OauthUserInfo { + #[serde(rename = "sub")] // "id" is called "sub" in the OIDC speficication id: I, name: N, } @@ -52,6 +55,8 @@ pub enum Provider { Google(OauthInfo), #[cfg(feature = "microsoft-login")] Microsoft(OauthInfo), + #[cfg(feature = "oidc-login")] + Oidc(OauthInfo), } impl Provider { @@ -69,6 +74,8 @@ impl Provider { Self::Google(oauth) => oauth.clone(), #[cfg(feature = "microsoft-login")] Self::Microsoft(oauth) => oauth.clone(), + #[cfg(feature = "oidc-login")] + Self::Oidc(oauth) => oauth.clone(), } } @@ -108,6 +115,10 @@ impl Provider { Self::Microsoft(_) => { params.push(("scope", "https://graph.microsoft.com/User.Read".to_string())); } + #[cfg(feature = "oidc-login")] + Self::Oidc(_) => { + params.push(("scope", "profile openid".to_string())); + } #[cfg(feature = "github-login")] _ => {} } @@ -115,8 +126,14 @@ impl Provider { params } - pub fn get_login_url(&self, scheme: Scheme, host: String, state: String) -> String { + pub async fn get_login_url( + &self, + scheme: Scheme, + host: String, + state: String, + ) -> Result { let params = self.get_login_url_params(scheme, host, state); + let oidc_config = oidc::get_oidc_config().await?; let oauth_url = match self { #[cfg(feature = "github-login")] @@ -127,11 +144,15 @@ impl Provider { Self::Google(_) => google::GOOGLE_OAUTH_AUTHORIZE_URL, #[cfg(feature = "microsoft-login")] Self::Microsoft(_) => microsoft::MICROSOFT_OAUTH_AUTHORIZE_URL, + #[cfg(feature = "oidc-login")] + Self::Oidc(_) => &oidc_config.authorization_endpoint, }; - reqwest::Url::parse_with_params(oauth_url, params) + let url = reqwest::Url::parse_with_params(oauth_url, params) .unwrap() - .to_string() + .to_string(); + + Ok(url.to_string()) } #[allow(unused_variables)] @@ -152,6 +173,8 @@ impl Provider { Self::Microsoft(oauth) => microsoft::user_info(scheme, oauth, host, token) .await? .into(), + #[cfg(feature = "oidc-login")] + Self::Oidc(oauth) => oidc::user_info(scheme, oauth, host, token).await?.into(), }; Ok(ThirdPartyUserInfo { @@ -182,6 +205,8 @@ impl fmt::Display for Provider { Self::Google(_) => write!(f, "Google"), #[cfg(feature = "microsoft-login")] Self::Microsoft(_) => write!(f, "Microsoft"), + #[cfg(feature = "oidc-login")] + Self::Oidc(_) => write!(f, "OIDC"), } } } diff --git a/src/login/providers/oidc.rs b/src/login/providers/oidc.rs new file mode 100644 index 0000000..57cb4d8 --- /dev/null +++ b/src/login/providers/oidc.rs @@ -0,0 +1,74 @@ +use std::sync::LazyLock; + +use crate::env as app_env; +use crate::login::error::OauthError; +use crate::login::providers::{get_access_token, get_user_info, OauthInfo, OauthUserInfo}; +use actix_web::http::uri::Scheme; +use serde::Deserialize; +use tokio::sync::OnceCell; + +pub mod env { + pub const ENV_OIDC_APP_CLIENT_ID: &str = "OIDC_APP_CLIENT_ID"; + pub const ENV_OIDC_APP_CLIENT_SECRET: &str = "OIDC_APP_CLIENT_SECRET"; + pub const ENV_OIDC_APP_CONFIG_URL: &str = "OIDC_APP_CONFIG_URL"; +} + +static OIDC_CONFIG_CELL: LazyLock> = LazyLock::new(|| OnceCell::new()); + + +#[derive(Deserialize)] +pub struct OidcConfiguration { + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: String, +} + +pub async fn get_oidc_config() -> Result<&'static OidcConfiguration, OauthError> { + OIDC_CONFIG_CELL.get_or_try_init(|| async { + fetch_oidc_config().await + }).await +} + +pub async fn fetch_oidc_config() -> Result { + let client = reqwest::Client::new(); + let well_known_url = app_env::var(env::ENV_OIDC_APP_CONFIG_URL).unwrap(); + let res = client + .get(&well_known_url) + .send() + .await + .map_err(OauthError::OIDCConfiguration)?; + let oidc_config = res + .json::() + .await + .map_err(OauthError::OIDCConfiguration)?; + + Ok(oidc_config) +} + +pub type OidcUserInfo = OauthUserInfo; + +pub async fn user_info( + scheme: Scheme, + oauth: &OauthInfo, + host: String, + token: String, +) -> Result { + let oidc_config = get_oidc_config().await?; + let redirect_uri = format!("{}://{}/login/oidc/callback", scheme, host); + + let token = get_access_token( + &oidc_config.token_endpoint, + token, + oauth.client_id.clone(), + oauth.client_secret.clone(), + "authorization_code", + Some(redirect_uri), + ) + .await?; + get_user_info(&oidc_config.userinfo_endpoint, token) + .await + .map_err(OauthError::UserInfo)? + .json::() + .await + .map_err(OauthError::UserInfo) +} diff --git a/src/login/routes.rs b/src/login/routes.rs index 4fe3155..3ebbc29 100644 --- a/src/login/routes.rs +++ b/src/login/routes.rs @@ -89,8 +89,16 @@ async fn login( let host = req.connection_info().host().to_string(); let state = Uuid::new_v4().to_string(); - let login_url = - provider.get_login_url(providers_config.get_callback_scheme(), host, state.clone()); + let login_url = match provider + .get_login_url(providers_config.get_callback_scheme(), host, state.clone()) + .await + { + Ok(url) => url, + Err(e) => { + return Ok(HttpResponse::InternalServerError() + .body(format!("Unable to get the login URL: {}", e))); + } + }; let mut response = HttpResponse::TemporaryRedirect() .append_header(("Location", login_url)) diff --git a/src/main.rs b/src/main.rs index 02abc03..81d5754 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,14 +31,15 @@ mod tls; #[actix_web::main] async fn main() -> Result<(), Box> { - // third-party-login should only be enable by one of the features below (github-login, gitlab-login, google-login, microsoft-login) + // third-party-login should only be enable by one of the features below (github-login, gitlab-login, google-login, microsoft-login, oidc-login) #[cfg(feature = "third-party-login")] { #[cfg(not(any( feature = "github-login", feature = "gitlab-login", feature = "google-login", - feature = "microsoft-login" + feature = "microsoft-login", + feature = "oidc-login" )))] { compile_error!(