OIDC RP-initiated logout (SSO single logout) support (#992)

Fixes #935
This commit is contained in:
Eugene 2024-09-10 23:16:42 +02:00 committed by GitHub
parent 116bf9fd4d
commit fe521f2a39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 375 additions and 94 deletions

101
Cargo.lock generated
View file

@ -499,6 +499,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.6.0" version = "1.6.0"
@ -821,7 +827,7 @@ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
"clap_lex 0.7.0", "clap_lex 0.7.0",
"strsim 0.11.0", "strsim 0.11.1",
] ]
[[package]] [[package]]
@ -1129,16 +1135,6 @@ dependencies = [
"syn 2.0.55", "syn 2.0.55",
] ]
[[package]]
name = "darling"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.14.4" version = "0.14.4"
@ -1150,17 +1146,13 @@ dependencies = [
] ]
[[package]] [[package]]
name = "darling_core" name = "darling"
version = "0.13.4" version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
dependencies = [ dependencies = [
"fnv", "darling_core 0.20.10",
"ident_case", "darling_macro 0.20.10",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 1.0.109",
] ]
[[package]] [[package]]
@ -1178,14 +1170,17 @@ dependencies = [
] ]
[[package]] [[package]]
name = "darling_macro" name = "darling_core"
version = "0.13.4" version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [ dependencies = [
"darling_core 0.13.4", "fnv",
"ident_case",
"proc-macro2",
"quote", "quote",
"syn 1.0.109", "strsim 0.11.1",
"syn 2.0.55",
] ]
[[package]] [[package]]
@ -1199,6 +1194,17 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "darling_macro"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core 0.20.10",
"quote",
"syn 2.0.55",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.5.0" version = "2.5.0"
@ -1312,6 +1318,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dyn-clone"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.16.9" version = "0.16.9"
@ -2129,6 +2141,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown 0.12.3", "hashbrown 0.12.3",
"serde",
] ]
[[package]] [[package]]
@ -2139,6 +2152,7 @@ checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.14.3", "hashbrown 0.14.3",
"serde",
] ]
[[package]] [[package]]
@ -2776,19 +2790,23 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openidconnect" name = "openidconnect"
version = "2.5.1" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98dd5b7049bac4fdd2233b8c9767d42c05da8006fdb79cc903258556d2b18009" checksum = "f47e80a9cfae4462dd29c41e987edd228971d6565553fbc14b8a11e666d91590"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"chrono", "chrono",
"dyn-clone",
"ed25519-dalek",
"hmac",
"http", "http",
"itertools 0.10.5", "itertools 0.10.5",
"log", "log",
"num-bigint",
"oauth2", "oauth2",
"p256",
"p384",
"rand", "rand",
"ring 0.16.20", "rsa",
"serde", "serde",
"serde-value", "serde-value",
"serde_derive", "serde_derive",
@ -2796,6 +2814,7 @@ dependencies = [
"serde_path_to_error", "serde_path_to_error",
"serde_plain", "serde_plain",
"serde_with", "serde_with",
"sha2",
"subtle", "subtle",
"thiserror", "thiserror",
"url", "url",
@ -4336,24 +4355,32 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "1.14.0" version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857"
dependencies = [ dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.2.6",
"serde", "serde",
"serde_derive",
"serde_json",
"serde_with_macros", "serde_with_macros",
"time",
] ]
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "1.5.2" version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350"
dependencies = [ dependencies = [
"darling 0.13.4", "darling 0.20.10",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "syn 2.0.55",
] ]
[[package]] [[package]]
@ -4830,9 +4857,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "strum" name = "strum"

View file

@ -0,0 +1,39 @@
[
{
"ClientId": "implicit-mock-client",
"Description": "Client for implicit flow",
"AllowedGrantTypes": ["implicit"],
"AllowAccessTokensViaBrowser": true,
"RedirectUris": [
"https://warpgate.com/@warpgate/api/sso/return",
"https://127.0.0.1:8888/@warpgate/api/sso/return"
],
"AllowedScopes": ["openid", "profile", "email", "warpgate-scope"],
"IdentityTokenLifetime": 3600,
"AccessTokenLifetime": 3600
},
{
"ClientId": "client-credentials-mock-client",
"ClientSecrets": ["client-credentials-mock-client-secret"],
"Description": "Client for client credentials flow",
"AllowedGrantTypes": ["authorization_code"],
"AllowedScopes": ["openid", "profile", "email", "warpgate-scope"],
"ClientClaimsPrefix": "",
"RedirectUris": [
"https://warpgate.com/@warpgate/api/sso/return",
"https://127.0.0.1:8888/@warpgate/api/sso/return"
],
"Claims": [
{
"Type": "string_claim",
"Value": "string_claim_value",
"ValueType": "string"
},
{
"Type": "json_claim",
"Value": "[\"value1\", \"value2\"]",
"ValueType": "json"
}
]
}
]

View file

@ -4,7 +4,7 @@ services:
container_name: oidc-server-mock container_name: oidc-server-mock
image: ghcr.io/soluto/oidc-server-mock:latest image: ghcr.io/soluto/oidc-server-mock:latest
ports: ports:
- '4011:80' - '4011:8080'
environment: environment:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
SERVER_OPTIONS_INLINE: | SERVER_OPTIONS_INLINE: |

View file

@ -1,3 +1,4 @@
use std::ops::DerefMut;
use std::sync::Arc; use std::sync::Arc;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@ -14,6 +15,7 @@ use warpgate_common::auth::{AuthCredential, AuthResult, AuthState, CredentialKin
use warpgate_common::{Secret, WarpgateError}; use warpgate_common::{Secret, WarpgateError};
use warpgate_core::Services; use warpgate_core::Services;
use super::common::logout;
use crate::common::{ use crate::common::{
authorize_session, endpoint_auth, get_auth_state_for_request, SessionAuthorization, SessionExt, authorize_session, endpoint_auth, get_auth_state_for_request, SessionAuthorization, SessionExt,
}; };
@ -209,9 +211,7 @@ impl Api {
session: &Session, session: &Session,
session_middleware: Data<&Arc<Mutex<SessionStore>>>, session_middleware: Data<&Arc<Mutex<SessionStore>>>,
) -> poem::Result<LogoutResponse> { ) -> poem::Result<LogoutResponse> {
session_middleware.lock().await.remove_session(session); logout(session, session_middleware.lock().await.deref_mut());
session.clear();
info!("Logged out");
Ok(LogoutResponse::Success) Ok(LogoutResponse::Success)
} }

View file

@ -0,0 +1,10 @@
use poem::session::Session;
use tracing::info;
use crate::session::SessionStore;
pub fn logout(session: &Session, session_middleware: &mut SessionStore) {
session_middleware.remove_session(session);
session.clear();
info!("Logged out");
}

View file

@ -25,6 +25,7 @@ pub struct Info {
external_host: Option<String>, external_host: Option<String>,
ports: PortsInfo, ports: PortsInfo,
authorized_via_ticket: bool, authorized_via_ticket: bool,
authorized_via_sso_with_single_logout: bool,
} }
#[derive(ApiResponse)] #[derive(ApiResponse)]
@ -64,6 +65,9 @@ impl Api {
session.get_auth(), session.get_auth(),
Some(SessionAuthorization::Ticket { .. }) Some(SessionAuthorization::Ticket { .. })
), ),
authorized_via_sso_with_single_logout: session
.get_sso_login_state()
.map_or(false, |state| state.supports_single_logout),
ports: if session.is_authenticated() { ports: if session.is_authenticated() {
PortsInfo { PortsInfo {
ssh: if config.store.ssh.enable { ssh: if config.store.ssh.enable {

View file

@ -1,6 +1,7 @@
use poem_openapi::OpenApi; use poem_openapi::OpenApi;
pub mod auth; pub mod auth;
mod common;
pub mod info; pub mod info;
pub mod sso_provider_detail; pub mod sso_provider_detail;
pub mod sso_provider_list; pub mod sso_provider_list;

View file

@ -5,6 +5,7 @@ use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json; use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi}; use poem_openapi::{ApiResponse, Object, OpenApi};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::*;
use warpgate_core::Services; use warpgate_core::Services;
use warpgate_sso::{SsoClient, SsoLoginRequest}; use warpgate_sso::{SsoClient, SsoLoginRequest};
@ -31,6 +32,7 @@ pub struct SsoContext {
pub provider: String, pub provider: String,
pub request: SsoLoginRequest, pub request: SsoLoginRequest,
pub next_url: Option<String>, pub next_url: Option<String>,
pub supports_single_logout: bool,
} }
#[OpenApi] #[OpenApi]
@ -54,6 +56,7 @@ impl Api {
let mut return_url = config.construct_external_url(Some(req))?; let mut return_url = config.construct_external_url(Some(req))?;
return_url.set_path("@warpgate/api/sso/return"); return_url.set_path("@warpgate/api/sso/return");
debug!("Return URL: {}", &return_url);
let Some(provider_config) = config.store.sso_providers.iter().find(|p| p.name == *name) let Some(provider_config) = config.store.sso_providers.iter().find(|p| p.name == *name)
else { else {
@ -74,6 +77,10 @@ impl Api {
provider: name, provider: name,
request: sso_req, request: sso_req,
next_url: next.0.clone(), next_url: next.0.clone(),
supports_single_logout: client
.supports_single_logout()
.await
.map_err(poem::error::InternalServerError)?,
}, },
); );

View file

@ -1,3 +1,6 @@
use std::ops::DerefMut;
use std::sync::Arc;
use poem::session::Session; use poem::session::Session;
use poem::web::{Data, Form}; use poem::web::{Data, Form};
use poem::Request; use poem::Request;
@ -5,13 +8,17 @@ use poem_openapi::param::Query;
use poem_openapi::payload::{Html, Json, Response}; use poem_openapi::payload::{Html, Json, Response};
use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::Mutex;
use tracing::*; use tracing::*;
use warpgate_common::auth::{AuthCredential, AuthResult}; use warpgate_common::auth::{AuthCredential, AuthResult};
use warpgate_core::Services; use warpgate_core::Services;
use warpgate_sso::SsoInternalProviderConfig; use warpgate_sso::{SsoClient, SsoInternalProviderConfig};
use super::sso_provider_detail::{SsoContext, SSO_CONTEXT_SESSION_KEY}; use super::sso_provider_detail::{SsoContext, SSO_CONTEXT_SESSION_KEY};
use crate::common::{authorize_session, get_auth_state_for_request}; use crate::api::common::logout;
use crate::common::{authorize_session, get_auth_state_for_request, SessionExt};
use crate::session::SessionStore;
use crate::SsoLoginState;
pub struct Api; pub struct Api;
@ -55,6 +62,22 @@ pub struct ReturnToSsoFormData {
pub code: Option<String>, pub code: Option<String>,
} }
#[derive(Object)]
struct StartSloResponseParams {
url: String,
}
#[allow(clippy::large_enum_variant)]
#[derive(ApiResponse)]
enum StartSloResponse {
#[oai(status = 200)]
Ok(Json<StartSloResponseParams>),
#[oai(status = 400)]
NotInSsoSession,
#[oai(status = 404)]
NotFound,
}
fn make_redirect_url(err: &str) -> String { fn make_redirect_url(err: &str) -> String {
error!("SSO error: {err}"); error!("SSO error: {err}");
format!("/@warpgate?login_error={err}") format!("/@warpgate?login_error={err}")
@ -175,7 +198,7 @@ impl Api {
let provider = context.provider.clone(); let provider = context.provider.clone();
let cred = AuthCredential::Sso { let cred = AuthCredential::Sso {
provider: context.provider, provider: context.provider.clone(),
email: email.clone(), email: email.clone(),
}; };
@ -204,11 +227,20 @@ impl Api {
if cp.validate_credential(&username, &cred).await? { if cp.validate_credential(&username, &cred).await? {
state.add_valid_credential(cred); state.add_valid_credential(cred);
} else {
return Ok(Err(format!(
"Failed to validate SSO credential for {username}"
)));
} }
if let AuthResult::Accepted { username } = state.verify() { if let AuthResult::Accepted { username } = state.verify() {
auth_state_store.complete(state.id()).await; auth_state_store.complete(state.id()).await;
authorize_session(req, username).await?; authorize_session(req, username).await?;
session.set_sso_login_state(SsoLoginState {
provider: context.provider,
token: response.id_token,
supports_single_logout: context.supports_single_logout,
});
} }
let providers_config = services.config.lock().await.store.sso_providers.clone(); let providers_config = services.config.lock().await.store.sso_providers.clone();
@ -249,4 +281,47 @@ impl Api {
.unwrap_or("/@warpgate#/login") .unwrap_or("/@warpgate#/login")
.to_owned())) .to_owned()))
} }
#[oai(
path = "/sso/logout",
method = "get",
operation_id = "initiate_sso_logout"
)]
async fn api_start_slo(
&self,
req: &Request,
session: &Session,
services: Data<&Services>,
session_middleware: Data<&Arc<Mutex<SessionStore>>>,
) -> poem::Result<StartSloResponse> {
let Some(state) = session.get_sso_login_state() else {
return Ok(StartSloResponse::NotInSsoSession);
};
let config = services.config.lock().await;
let return_url = config.construct_external_url(Some(req))?;
debug!("Return URL: {}", &return_url);
let Some(provider_config) = config
.store
.sso_providers
.iter()
.find(|p| p.name == state.provider)
else {
return Ok(StartSloResponse::NotFound);
};
let client = SsoClient::new(provider_config.provider.clone());
let logout_url = client
.logout(state.token, return_url)
.await
.map_err(poem::error::InternalServerError)?;
logout(session, session_middleware.lock().await.deref_mut());
Ok(StartSloResponse::Ok(Json(StartSloResponseParams {
url: logout_url.to_string(),
})))
}
} }

View file

@ -11,6 +11,7 @@ use uuid::Uuid;
use warpgate_common::auth::{AuthState, CredentialKind}; use warpgate_common::auth::{AuthState, CredentialKind};
use warpgate_common::{ProtocolName, TargetOptions, WarpgateError}; use warpgate_common::{ProtocolName, TargetOptions, WarpgateError};
use warpgate_core::{AuthStateStore, Services}; use warpgate_core::{AuthStateStore, Services};
use warpgate_sso::CoreIdToken;
use crate::session::SessionStore; use crate::session::SessionStore;
@ -18,8 +19,16 @@ pub const PROTOCOL_NAME: ProtocolName = "HTTP";
static TARGET_SESSION_KEY: &str = "target_name"; static TARGET_SESSION_KEY: &str = "target_name";
static AUTH_SESSION_KEY: &str = "auth"; static AUTH_SESSION_KEY: &str = "auth";
static AUTH_STATE_ID_SESSION_KEY: &str = "auth_state_id"; static AUTH_STATE_ID_SESSION_KEY: &str = "auth_state_id";
static AUTH_SSO_LOGIN_STATE: &str = "auth_sso_login_state";
pub static SESSION_COOKIE_NAME: &str = "warpgate-http-session"; pub static SESSION_COOKIE_NAME: &str = "warpgate-http-session";
#[derive(Serialize, Deserialize)]
pub struct SsoLoginState {
pub token: CoreIdToken,
pub provider: String,
pub supports_single_logout: bool,
}
pub trait SessionExt { pub trait SessionExt {
fn get_target_name(&self) -> Option<String>; fn get_target_name(&self) -> Option<String>;
fn set_target_name(&self, target_name: String); fn set_target_name(&self, target_name: String);
@ -29,6 +38,9 @@ pub trait SessionExt {
fn set_auth(&self, auth: SessionAuthorization); fn set_auth(&self, auth: SessionAuthorization);
fn get_auth_state_id(&self) -> Option<AuthStateId>; fn get_auth_state_id(&self) -> Option<AuthStateId>;
fn clear_auth_state(&self); fn clear_auth_state(&self);
fn get_sso_login_state(&self) -> Option<SsoLoginState>;
fn set_sso_login_state(&self, token: SsoLoginState);
} }
impl SessionExt for Session { impl SessionExt for Session {
@ -63,6 +75,17 @@ impl SessionExt for Session {
fn clear_auth_state(&self) { fn clear_auth_state(&self) {
self.remove(AUTH_STATE_ID_SESSION_KEY) self.remove(AUTH_STATE_ID_SESSION_KEY)
} }
fn get_sso_login_state(&self) -> Option<SsoLoginState> {
self.get::<String>(AUTH_SSO_LOGIN_STATE)
.and_then(|x| serde_json::from_str(&x).ok())
}
fn set_sso_login_state(&self, state: SsoLoginState) {
if let Ok(json) = serde_json::to_string(&state) {
self.set(AUTH_SSO_LOGIN_STATE, json)
}
}
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]

View file

@ -17,7 +17,7 @@ use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use common::page_admin_auth; use common::page_admin_auth;
pub use common::PROTOCOL_NAME; pub use common::{PROTOCOL_NAME, SsoLoginState};
use http::HeaderValue; use http::HeaderValue;
use logging::{get_client_ip, log_request_result, span_for_request}; use logging::{get_client_ip, log_request_result, span_for_request};
use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint}; use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint};

View file

@ -9,7 +9,7 @@ bytes = "1.3"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1.20", features = ["tracing", "macros"] } tokio = { version = "1.20", features = ["tracing", "macros"] }
tracing = "0.1" tracing = "0.1"
openidconnect = { version = "2.4", features = ["reqwest", "rustls-tls", "accept-string-booleans"] } openidconnect = { version = "3.5", features = ["reqwest", "rustls-tls", "accept-string-booleans"] }
serde = "1.0" serde = "1.0"
serde_json = "1.0" serde_json = "1.0"
once_cell = "1.17" once_cell = "1.17"

View file

@ -24,6 +24,8 @@ pub enum SsoError {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("JWT error: {0}")] #[error("JWT error: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error), Jwt(#[from] jsonwebtoken::errors::Error),
#[error("the OIDC provider doesn't support RP-initiated logout")]
LogoutNotSupported,
#[error(transparent)] #[error(transparent)]
Other(Box<dyn Error + Send + Sync>), Other(Box<dyn Error + Send + Sync>),
} }

View file

@ -9,3 +9,5 @@ pub use error::*;
pub use request::*; pub use request::*;
pub use response::*; pub use response::*;
pub use sso::*; pub use sso::*;
pub use openidconnect::core::CoreIdToken;

View file

@ -1,5 +1,5 @@
use futures::future::OptionFuture; use futures::future::OptionFuture;
use openidconnect::core::CoreGenderClaim; use openidconnect::core::{CoreGenderClaim, CoreIdToken};
use openidconnect::reqwest::async_http_client; use openidconnect::reqwest::async_http_client;
use openidconnect::url::Url; use openidconnect::url::Url;
use openidconnect::{ use openidconnect::{
@ -63,7 +63,7 @@ impl SsoLoginRequest {
e => SsoError::Verification(format!("{e}")), e => SsoError::Verification(format!("{e}")),
})?; })?;
let id_token = token_response.id_token().ok_or(SsoError::NotOidc)?; let id_token: &CoreIdToken = token_response.id_token().ok_or(SsoError::NotOidc)?;
let claims = id_token.claims(&client.id_token_verifier(), &self.nonce)?; let claims = id_token.claims(&client.id_token_verifier(), &self.nonce)?;
let user_info_req = client let user_info_req = client
@ -119,6 +119,8 @@ impl SsoLoginRequest {
email_verified: get_claim!(email_verified), email_verified: get_claim!(email_verified),
groups: userinfo_claims.and_then(|x| x.additional_claims().warpgate_roles.clone()), groups: userinfo_claims.and_then(|x| x.additional_claims().warpgate_roles.clone()),
id_token: id_token.clone(),
}) })
} }
} }

View file

@ -1,7 +1,10 @@
use openidconnect::core::CoreIdToken;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SsoLoginResponse { pub struct SsoLoginResponse {
pub name: Option<String>, pub name: Option<String>,
pub email: Option<String>, pub email: Option<String>,
pub email_verified: Option<bool>, pub email_verified: Option<bool>,
pub groups: Option<Vec<String>>, pub groups: Option<Vec<String>>,
pub id_token: CoreIdToken,
} }

View file

@ -1,9 +1,13 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::ops::Deref; use std::ops::Deref;
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}; use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken};
use openidconnect::reqwest::async_http_client; use openidconnect::reqwest::async_http_client;
use openidconnect::{CsrfToken, DiscoveryError, Nonce, PkceCodeChallenge, RedirectUrl, Scope}; use openidconnect::url::Url;
use openidconnect::{
CsrfToken, DiscoveryError, LogoutRequest, Nonce, PkceCodeChallenge, PostLogoutRedirectUrl,
ProviderMetadataWithLogout, RedirectUrl, Scope,
};
use crate::config::SsoInternalProviderConfig; use crate::config::SsoInternalProviderConfig;
use crate::request::SsoLoginRequest; use crate::request::SsoLoginRequest;
@ -13,15 +17,21 @@ pub struct SsoClient {
config: SsoInternalProviderConfig, config: SsoInternalProviderConfig,
} }
pub async fn make_client(config: &SsoInternalProviderConfig) -> Result<CoreClient, SsoError> { pub async fn discover_metadata(
let metadata = CoreProviderMetadata::discover_async(config.issuer_url()?, async_http_client) config: &SsoInternalProviderConfig,
) -> Result<ProviderMetadataWithLogout, SsoError> {
ProviderMetadataWithLogout::discover_async(config.issuer_url()?, async_http_client)
.await .await
.map_err(|e| { .map_err(|e| {
SsoError::Discovery(match e { SsoError::Discovery(match e {
DiscoveryError::Request(inner) => format!("Request error: {inner}"), DiscoveryError::Request(inner) => format!("Request error: {inner}"),
e => format!("{e}"), e => format!("{e}"),
}) })
})?; })
}
pub async fn make_client(config: &SsoInternalProviderConfig) -> Result<CoreClient, SsoError> {
let metadata = discover_metadata(config).await?;
let client = CoreClient::from_provider_metadata( let client = CoreClient::from_provider_metadata(
metadata, metadata,
@ -44,6 +54,14 @@ impl SsoClient {
Self { config } Self { config }
} }
pub async fn supports_single_logout(&self) -> Result<bool, SsoError> {
let metadata = discover_metadata(&self.config).await?;
Ok(metadata
.additional_metadata()
.end_session_endpoint
.is_some())
}
pub async fn start_login(&self, redirect_url: String) -> Result<SsoLoginRequest, SsoError> { pub async fn start_login(&self, redirect_url: String) -> Result<SsoLoginRequest, SsoError> {
let redirect_url = RedirectUrl::new(redirect_url)?; let redirect_url = RedirectUrl::new(redirect_url)?;
let client = make_client(&self.config).await?; let client = make_client(&self.config).await?;
@ -82,4 +100,16 @@ impl SsoClient {
config: self.config.clone(), config: self.config.clone(),
}) })
} }
pub async fn logout(&self, token: CoreIdToken, redirect_url: Url) -> Result<Url, SsoError> {
let metadata = discover_metadata(&self.config).await?;
let Some(ref url) = metadata.additional_metadata().end_session_endpoint else {
return Err(SsoError::LogoutNotSupported);
};
let mut req: LogoutRequest = url.clone().into();
req = req.set_id_token_hint(&token);
req = req.set_client_id(self.config.client_id().clone());
req = req.set_post_logout_redirect_uri(PostLogoutRedirectUrl::from_url(redirect_url));
return Ok(req.http_get_url());
}
} }

View file

@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import { faSignOut } from '@fortawesome/free-solid-svg-icons'
import { api } from 'gateway/lib/api'
import { serverInfo, reloadServerInfo } from 'gateway/lib/store' import { serverInfo, reloadServerInfo } from 'gateway/lib/store'
import Fa from 'svelte-fa'
import Router, { link } from 'svelte-spa-router' import Router, { link } from 'svelte-spa-router'
import active from 'svelte-spa-router/active' import active from 'svelte-spa-router/active'
@ -10,17 +7,12 @@ import { wrap } from 'svelte-spa-router/wrap'
import ThemeSwitcher from 'common/ThemeSwitcher.svelte' import ThemeSwitcher from 'common/ThemeSwitcher.svelte'
import Logo from 'common/Logo.svelte' import Logo from 'common/Logo.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte' import DelayedSpinner from 'common/DelayedSpinner.svelte'
import AuthBar from 'common/AuthBar.svelte'
async function init () { async function init () {
await reloadServerInfo() await reloadServerInfo()
} }
async function logout () {
await api.logout()
await reloadServerInfo()
location.href = '/@warpgate'
}
init() init()
const routes = { const routes = {
@ -86,14 +78,7 @@ const routes = {
<a use:link use:active href="/ssh">SSH</a> <a use:link use:active href="/ssh">SSH</a>
<a use:link use:active href="/log">Log</a> <a use:link use:active href="/log">Log</a>
{/if} {/if}
{#if $serverInfo?.username} <AuthBar />
<div class="username ms-auto">
{$serverInfo?.username}
</div>
<button class="btn btn-link" on:click={logout} title="Log out">
<Fa icon={faSignOut} fw />
</button>
{/if}
</header> </header>
<main> <main>
<Router {routes}/> <Router {routes}/>

View file

@ -2,7 +2,7 @@
"openapi": "3.0.0", "openapi": "3.0.0",
"info": { "info": {
"title": "Warpgate Web Admin", "title": "Warpgate Web Admin",
"version": "0.9.1" "version": "0.10.2"
}, },
"servers": [ "servers": [
{ {

View file

@ -0,0 +1,50 @@
<script lang="ts">
import { faSignOut } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import { api } from 'gateway/lib/api'
import { serverInfo, reloadServerInfo } from 'gateway/lib/store'
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from '@sveltestrap/sveltestrap'
async function logout () {
await api.logout()
await reloadServerInfo()
location.href = '/@warpgate'
}
async function singleLogout () {
const response = await api.initiateSsoLogout()
location.href = response.url
}
</script>
{#if $serverInfo?.username}
<div class="ms-auto">
{$serverInfo.username}
{#if $serverInfo.authorizedViaTicket}
<span class="ml-2">(ticket auth)</span>
{/if}
</div>
{#if $serverInfo?.authorizedViaSsoWithSingleLogout}
<Dropdown>
<DropdownToggle color="link" title="Log out options">
<Fa icon={faSignOut} fw />
</DropdownToggle>
<DropdownMenu right={true}>
<DropdownItem on:click={logout}>
<Fa icon={faSignOut} fw />
Log out of Warpgate
</DropdownItem>
<DropdownItem on:click={singleLogout}>
<Fa icon={faSignOut} fw />
Log out everywhere
</DropdownItem>
</DropdownMenu>
</Dropdown>
{:else}
<Button color="link" on:click={logout} title="Log out">
<Fa icon={faSignOut} fw />
</Button>
{/if}
{/if}

View file

@ -1,15 +1,13 @@
<script lang="ts"> <script lang="ts">
import { faSignOut } from '@fortawesome/free-solid-svg-icons'
import { Alert } from '@sveltestrap/sveltestrap' import { Alert } from '@sveltestrap/sveltestrap'
import Fa from 'svelte-fa'
import Router, { push, type RouteDetail } from 'svelte-spa-router' import Router, { push, type RouteDetail } from 'svelte-spa-router'
import { wrap } from 'svelte-spa-router/wrap' import { wrap } from 'svelte-spa-router/wrap'
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { api } from 'gateway/lib/api'
import { reloadServerInfo, serverInfo } from 'gateway/lib/store' import { reloadServerInfo, serverInfo } from 'gateway/lib/store'
import ThemeSwitcher from 'common/ThemeSwitcher.svelte' import ThemeSwitcher from 'common/ThemeSwitcher.svelte'
import Logo from 'common/Logo.svelte' import Logo from 'common/Logo.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte' import DelayedSpinner from 'common/DelayedSpinner.svelte'
import AuthBar from 'common/AuthBar.svelte'
let redirecting = false let redirecting = false
let serverInfoPromise = reloadServerInfo() let serverInfoPromise = reloadServerInfo()
@ -18,12 +16,6 @@ async function init () {
await serverInfoPromise await serverInfoPromise
} }
async function logout () {
await api.logout()
await reloadServerInfo()
push('/login')
}
function onPageResume () { function onPageResume () {
redirecting = false redirecting = false
init() init()
@ -76,17 +68,7 @@ init()
<Logo /> <Logo />
</a> </a>
{#if $serverInfo?.username} <AuthBar />
<div class="ms-auto">
{$serverInfo.username}
{#if $serverInfo.authorizedViaTicket}
<span class="ml-2">(ticket auth)</span>
{/if}
</div>
<button class="btn btn-link" on:click={logout} title="Log out">
<Fa icon={faSignOut} fw />
</button>
{/if}
</div> </div>
<main> <main>

View file

@ -2,7 +2,7 @@
"openapi": "3.0.0", "openapi": "3.0.0",
"info": { "info": {
"title": "Warpgate HTTP proxy", "title": "Warpgate HTTP proxy",
"version": "0.9.1" "version": "0.10.2"
}, },
"servers": [ "servers": [
{ {
@ -324,6 +324,29 @@
"operationId": "return_to_sso_with_form_data" "operationId": "return_to_sso_with_form_data"
} }
}, },
"/sso/logout": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/StartSloResponseParams"
}
}
}
},
"400": {
"description": ""
},
"404": {
"description": ""
}
},
"operationId": "initiate_sso_logout"
}
},
"/sso/providers/{name}/start": { "/sso/providers/{name}/start": {
"get": { "get": {
"parameters": [ "parameters": [
@ -414,7 +437,8 @@
"required": [ "required": [
"version", "version",
"ports", "ports",
"authorized_via_ticket" "authorized_via_ticket",
"authorized_via_sso_with_single_logout"
], ],
"properties": { "properties": {
"version": { "version": {
@ -434,6 +458,9 @@
}, },
"authorized_via_ticket": { "authorized_via_ticket": {
"type": "boolean" "type": "boolean"
},
"authorized_via_sso_with_single_logout": {
"type": "boolean"
} }
} }
}, },
@ -519,6 +546,17 @@
"Custom" "Custom"
] ]
}, },
"StartSloResponseParams": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string"
}
}
},
"StartSsoResponseParams": { "StartSsoResponseParams": {
"type": "object", "type": "object",
"required": [ "required": [

View file

@ -11,7 +11,7 @@
@import "bootstrap/scss/forms"; @import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons"; @import "bootstrap/scss/buttons";
@import "bootstrap/scss/transitions"; @import "bootstrap/scss/transitions";
// @import "bootstrap/scss/dropdown"; @import "bootstrap/scss/dropdown";
// @import "bootstrap/scss/button-group"; // @import "bootstrap/scss/button-group";
// @import "bootstrap/scss/nav"; // @import "bootstrap/scss/nav";
// @import "bootstrap/scss/navbar"; // @import "bootstrap/scss/navbar";

View file

@ -7,3 +7,4 @@ $pagination-active-bg: transparent;
$pagination-hover-bg: transparent; $pagination-hover-bg: transparent;
$pagination-focus-bg: transparent; $pagination-focus-bg: transparent;
$modal-header-border-color: transparent; $modal-header-border-color: transparent;
$dropdown-link-hover-bg: transparent;