mirror of
https://github.com/warp-tech/warpgate.git
synced 2024-09-20 06:46:17 +08:00
OIDC login support (#222)
This commit is contained in:
parent
83442ea350
commit
f7bb12e44d
|
@ -39,6 +39,10 @@ replace = version = "{new_version}"
|
|||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:warpgate-sso/Cargo.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:warpgate-web/Cargo.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
|
103
Cargo.lock
generated
103
Cargo.lock
generated
|
@ -1434,8 +1434,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.10.0+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2320,6 +2322,26 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauth2"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d62c436394991641b970a92e23e8eeb4eb9bca74af4f5badc53bcd568daadbd"
|
||||
dependencies = [
|
||||
"base64 0.13.0",
|
||||
"chrono",
|
||||
"getrandom",
|
||||
"http",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2 0.10.2",
|
||||
"thiserror",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.28.3"
|
||||
|
@ -2341,6 +2363,30 @@ version = "0.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
||||
|
||||
[[package]]
|
||||
name = "openidconnect"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e26afc60b2bf11b9a039db1f3a3c0d5fe201eebdbe646a8ecb8342c8240e3271"
|
||||
dependencies = [
|
||||
"base64 0.13.0",
|
||||
"chrono",
|
||||
"http",
|
||||
"itertools",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"oauth2",
|
||||
"rand",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde-value",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"thiserror",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.41"
|
||||
|
@ -2396,6 +2442,15 @@ dependencies = [
|
|||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.0.0"
|
||||
|
@ -3058,6 +3113,7 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
|
@ -3526,6 +3582,16 @@ dependencies = [
|
|||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||
dependencies = [
|
||||
"ordered-float",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.137"
|
||||
|
@ -3548,6 +3614,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7868ad3b8196a8a0aea99a8220b124278ee5320a55e4fde97794b6f85b1a377"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
|
@ -4393,6 +4468,7 @@ dependencies = [
|
|||
"idna",
|
||||
"matches",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -4569,6 +4645,7 @@ dependencies = [
|
|||
"uuid",
|
||||
"warpgate-db-entities",
|
||||
"warpgate-db-migrations",
|
||||
"warpgate-sso",
|
||||
"webpki",
|
||||
]
|
||||
|
||||
|
@ -4634,6 +4711,7 @@ dependencies = [
|
|||
"warpgate-admin",
|
||||
"warpgate-common",
|
||||
"warpgate-db-entities",
|
||||
"warpgate-sso",
|
||||
"warpgate-web",
|
||||
]
|
||||
|
||||
|
@ -4687,6 +4765,22 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "warpgate-sso"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes 1.2.1",
|
||||
"once_cell",
|
||||
"openidconnect",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "warpgate-web"
|
||||
version = "0.4.0"
|
||||
|
@ -4795,6 +4889,15 @@ dependencies = [
|
|||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1c760f0d366a6c24a02ed7816e23e691f5d92291f94d15e836006fd11b04daf"
|
||||
dependencies = [
|
||||
"webpki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wepoll-ffi"
|
||||
version = "0.1.2"
|
||||
|
|
|
@ -9,6 +9,7 @@ members = [
|
|||
"warpgate-protocol-http",
|
||||
"warpgate-protocol-mysql",
|
||||
"warpgate-protocol-ssh",
|
||||
"warpgate-sso",
|
||||
"warpgate-web",
|
||||
]
|
||||
default-members = ["warpgate"]
|
||||
|
|
|
@ -13,7 +13,7 @@ Warpgate is a smart SSH, HTTPS and MySQL bastion host for Linux that doesn't nee
|
|||
* Set it up in your DMZ, add user accounts and easily assign them to specific hosts and URLs within the network.
|
||||
* Warpgate will record every session for you to view (live) and replay later through a built-in admin web UI.
|
||||
* Not a jump host - forwards your connections straight to the target instead.
|
||||
* 2FA support
|
||||
* Native 2FA and SSO support
|
||||
* Single binary with no dependencies.
|
||||
* Written in 100% safe Rust.
|
||||
|
||||
|
|
2
justfile
2
justfile
|
@ -1,4 +1,4 @@
|
|||
projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql"
|
||||
projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql warpgate-sso"
|
||||
|
||||
run *ARGS:
|
||||
RUST_BACKTRACE=1 RUST_LOG=warpgate cd warpgate && cargo run -- --config ../config.yaml {{ARGS}}
|
||||
|
|
|
@ -43,6 +43,7 @@ url = "2.2"
|
|||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
warpgate-db-entities = { version = "*", path = "../warpgate-db-entities" }
|
||||
warpgate-db-migrations = { version = "*", path = "../warpgate-db-migrations" }
|
||||
warpgate-sso = { version = "*", path = "../warpgate-sso" }
|
||||
rustls = { version = "0.20", features = ["dangerous_configuration"] }
|
||||
rustls-pemfile = "1.0"
|
||||
webpki = "0.22"
|
||||
|
|
41
warpgate-common/src/auth/cred.rs
Normal file
41
warpgate-common/src/auth/cred.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Secret;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CredentialKind {
|
||||
#[serde(rename = "password")]
|
||||
Password,
|
||||
#[serde(rename = "publickey")]
|
||||
PublicKey,
|
||||
#[serde(rename = "otp")]
|
||||
Otp,
|
||||
#[serde(rename = "sso")]
|
||||
Sso,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthCredential {
|
||||
Otp(Secret<String>),
|
||||
Password(Secret<String>),
|
||||
PublicKey {
|
||||
kind: String,
|
||||
public_key_bytes: Bytes,
|
||||
},
|
||||
Sso {
|
||||
provider: String,
|
||||
email: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AuthCredential {
|
||||
pub fn kind(&self) -> CredentialKind {
|
||||
match self {
|
||||
Self::Password { .. } => CredentialKind::Password,
|
||||
Self::PublicKey { .. } => CredentialKind::PublicKey,
|
||||
Self::Otp { .. } => CredentialKind::Otp,
|
||||
Self::Sso { .. } => CredentialKind::Sso,
|
||||
}
|
||||
}
|
||||
}
|
10
warpgate-common/src/auth/mod.rs
Normal file
10
warpgate-common/src/auth/mod.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
mod cred;
|
||||
mod policy;
|
||||
mod selector;
|
||||
mod state;
|
||||
mod store;
|
||||
pub use cred::*;
|
||||
pub use policy::*;
|
||||
pub use selector::*;
|
||||
pub use state::*;
|
||||
pub use store::*;
|
46
warpgate-common/src/auth/policy.rs
Normal file
46
warpgate-common/src/auth/policy.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use super::{AuthCredential, CredentialKind};
|
||||
use crate::UserRequireCredentialsPolicy;
|
||||
|
||||
pub enum CredentialPolicyResponse {
|
||||
Ok,
|
||||
Need(CredentialKind),
|
||||
}
|
||||
|
||||
pub trait CredentialPolicy {
|
||||
fn is_sufficient(
|
||||
&self,
|
||||
protocol: &str,
|
||||
valid_credentials: &[AuthCredential],
|
||||
) -> CredentialPolicyResponse;
|
||||
}
|
||||
|
||||
impl CredentialPolicy for UserRequireCredentialsPolicy {
|
||||
fn is_sufficient(
|
||||
&self,
|
||||
protocol: &str,
|
||||
valid_credentials: &[AuthCredential],
|
||||
) -> CredentialPolicyResponse {
|
||||
let required_kinds = match protocol {
|
||||
"SSH" => &self.ssh,
|
||||
"HTTP" => &self.http,
|
||||
"MySQL" => &self.mysql,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if let Some(required_kinds) = required_kinds {
|
||||
let mut remaining_required_kinds = HashSet::<CredentialKind>::new();
|
||||
remaining_required_kinds.extend(required_kinds);
|
||||
for kind in required_kinds {
|
||||
if valid_credentials.iter().any(|x| x.kind() == *kind) {
|
||||
remaining_required_kinds.remove(kind);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(kind) = remaining_required_kinds.into_iter().next() {
|
||||
return CredentialPolicyResponse::Need(kind);
|
||||
}
|
||||
}
|
||||
CredentialPolicyResponse::Ok
|
||||
}
|
||||
}
|
68
warpgate-common/src/auth/state.rs
Normal file
68
warpgate-common/src/auth/state.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use tracing::*;
|
||||
|
||||
use super::{AuthCredential, CredentialPolicy, CredentialPolicyResponse};
|
||||
use crate::AuthResult;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub static TIMEOUT: Lazy<Duration> = Lazy::new(|| Duration::from_secs(60 * 10));
|
||||
|
||||
pub struct AuthState {
|
||||
username: String,
|
||||
protocol: String,
|
||||
policy: Option<Box<dyn CredentialPolicy + Sync + Send>>,
|
||||
valid_credentials: Vec<AuthCredential>,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
pub fn new(
|
||||
username: String,
|
||||
protocol: String,
|
||||
policy: Option<Box<dyn CredentialPolicy + Sync + Send>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
username,
|
||||
protocol,
|
||||
policy,
|
||||
valid_credentials: vec![],
|
||||
started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn username(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
|
||||
pub fn add_valid_credential(&mut self, credential: AuthCredential) {
|
||||
self.valid_credentials.push(credential);
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.started_at.elapsed() > *TIMEOUT
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> AuthResult {
|
||||
if self.valid_credentials.is_empty() {
|
||||
warn!(
|
||||
username=%self.username,
|
||||
"No matching valid credentials"
|
||||
);
|
||||
return AuthResult::Rejected;
|
||||
}
|
||||
|
||||
if let Some(ref policy) = self.policy {
|
||||
match policy.is_sufficient(&self.protocol, &self.valid_credentials[..]) {
|
||||
CredentialPolicyResponse::Ok => {}
|
||||
CredentialPolicyResponse::Need(kind) => {
|
||||
return AuthResult::Need(kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthResult::Accepted {
|
||||
username: self.username.clone(),
|
||||
}
|
||||
}
|
||||
}
|
63
warpgate-common/src/auth/store.rs
Normal file
63
warpgate-common/src/auth/store.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::AuthState;
|
||||
use crate::{ConfigProvider, WarpgateError};
|
||||
|
||||
pub struct AuthStateStore {
|
||||
config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>,
|
||||
store: HashMap<Uuid, AuthState>,
|
||||
}
|
||||
|
||||
impl AuthStateStore {
|
||||
pub fn new(config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>) -> Self {
|
||||
Self {
|
||||
store: HashMap::new(),
|
||||
config_provider,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_key(&mut self, id: &Uuid) -> bool {
|
||||
self.store.contains_key(id)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut AuthState> {
|
||||
self.store.get_mut(id)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&mut self,
|
||||
username: &str,
|
||||
protocol: &str,
|
||||
) -> Result<(Uuid, &mut AuthState), WarpgateError> {
|
||||
let id = Uuid::new_v4();
|
||||
let state = AuthState::new(
|
||||
username.to_string(),
|
||||
protocol.to_string(),
|
||||
self.config_provider
|
||||
.lock()
|
||||
.await
|
||||
.get_credential_policy(username)
|
||||
.await?,
|
||||
);
|
||||
self.store.insert(id, state);
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
Ok((id, self.store.get_mut(&id).unwrap()))
|
||||
}
|
||||
|
||||
pub async fn vacuum(&mut self) {
|
||||
let mut to_remove = vec![];
|
||||
for (id, state) in self.store.iter() {
|
||||
if state.is_expired() {
|
||||
to_remove.push(*id);
|
||||
}
|
||||
}
|
||||
for id in to_remove {
|
||||
self.store.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,9 @@ use std::time::Duration;
|
|||
|
||||
use poem_openapi::{Enum, Object, Union};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warpgate_sso::SsoProviderConfig;
|
||||
|
||||
use crate::auth::CredentialKind;
|
||||
use crate::helpers::otp::OtpSecretKey;
|
||||
use crate::{ListenEndpoint, Secret};
|
||||
|
||||
|
@ -194,16 +196,32 @@ pub enum UserAuthCredential {
|
|||
#[serde(with = "crate::helpers::serde_base64_secret")]
|
||||
key: OtpSecretKey,
|
||||
},
|
||||
#[serde(rename = "sso")]
|
||||
Sso {
|
||||
provider: Option<String>,
|
||||
email: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl UserAuthCredential {
|
||||
pub fn kind(&self) -> CredentialKind {
|
||||
match self {
|
||||
Self::Password { .. } => CredentialKind::Password,
|
||||
Self::PublicKey { .. } => CredentialKind::PublicKey,
|
||||
Self::Totp { .. } => CredentialKind::Otp,
|
||||
Self::Sso { .. } => CredentialKind::Sso,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct UserRequireCredentialsPolicy {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<Vec<String>>,
|
||||
pub http: Option<Vec<CredentialKind>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<Vec<String>>,
|
||||
pub ssh: Option<Vec<CredentialKind>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mysql: Option<Vec<String>>,
|
||||
pub mysql: Option<Vec<CredentialKind>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
|
@ -359,6 +377,7 @@ pub struct WarpgateConfigStore {
|
|||
pub targets: Vec<Target>,
|
||||
pub users: Vec<User>,
|
||||
pub roles: Vec<Role>,
|
||||
pub sso_providers: Vec<SsoProviderConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub recordings: RecordingsConfig,
|
||||
|
@ -388,6 +407,7 @@ impl Default for WarpgateConfigStore {
|
|||
targets: vec![],
|
||||
users: vec![],
|
||||
roles: vec![],
|
||||
sso_providers: vec![],
|
||||
recordings: RecordingsConfig::default(),
|
||||
external_host: None,
|
||||
database_url: _default_database_url(),
|
|
@ -11,12 +11,10 @@ use uuid::Uuid;
|
|||
use warpgate_db_entities::Ticket;
|
||||
|
||||
use super::ConfigProvider;
|
||||
use crate::auth::{AuthCredential, CredentialPolicy};
|
||||
use crate::helpers::hash::verify_password_hash;
|
||||
use crate::helpers::otp::verify_totp;
|
||||
use crate::{
|
||||
AuthCredential, AuthResult, ProtocolName, Target, User, UserAuthCredential, UserSnapshot,
|
||||
WarpgateConfig, WarpgateError,
|
||||
};
|
||||
use crate::{Target, User, UserAuthCredential, UserSnapshot, WarpgateConfig, WarpgateError};
|
||||
|
||||
pub struct FileConfigProvider {
|
||||
db: Arc<Mutex<DatabaseConnection>>,
|
||||
|
@ -35,14 +33,6 @@ impl FileConfigProvider {
|
|||
}
|
||||
}
|
||||
|
||||
fn credential_is_type(c: &UserAuthCredential, k: &str) -> bool {
|
||||
match c {
|
||||
UserAuthCredential::Password { .. } => k == "password",
|
||||
UserAuthCredential::PublicKey { .. } => k == "publickey",
|
||||
UserAuthCredential::Totp { .. } => k == "otp",
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigProvider for FileConfigProvider {
|
||||
async fn list_users(&mut self) -> Result<Vec<UserSnapshot>, WarpgateError> {
|
||||
|
@ -69,16 +59,10 @@ impl ConfigProvider for FileConfigProvider {
|
|||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
async fn get_credential_policy(
|
||||
&mut self,
|
||||
username: &str,
|
||||
credentials: &[AuthCredential],
|
||||
protocol: ProtocolName,
|
||||
) -> Result<AuthResult, WarpgateError> {
|
||||
if credentials.is_empty() {
|
||||
return Ok(AuthResult::Rejected);
|
||||
}
|
||||
|
||||
) -> Result<Option<Box<dyn CredentialPolicy + Sync + Send>>, WarpgateError> {
|
||||
let user = {
|
||||
self.config
|
||||
.lock()
|
||||
|
@ -91,115 +75,125 @@ impl ConfigProvider for FileConfigProvider {
|
|||
};
|
||||
let Some(user) = user else {
|
||||
error!("Selected user not found: {}", username);
|
||||
return Ok(AuthResult::Rejected);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut valid_credentials = vec![];
|
||||
Ok(user
|
||||
.require
|
||||
.map(|r| Box::new(r) as Box<dyn CredentialPolicy + Sync + Send>))
|
||||
}
|
||||
|
||||
for client_credential in credentials {
|
||||
match client_credential {
|
||||
AuthCredential::PublicKey {
|
||||
kind,
|
||||
public_key_bytes,
|
||||
} => {
|
||||
let mut base64_bytes = BASE64.encode(public_key_bytes);
|
||||
async fn username_for_sso_credential(
|
||||
&mut self,
|
||||
client_credential: &AuthCredential,
|
||||
) -> Result<Option<String>, WarpgateError> {
|
||||
let AuthCredential::Sso { provider: client_provider, email : client_email} = client_credential else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let client_key = format!("{} {}", kind, base64_bytes);
|
||||
debug!(username = &user.username[..], "Client key: {}", client_key);
|
||||
Ok(self
|
||||
.config
|
||||
.lock()
|
||||
.await
|
||||
.store
|
||||
.users
|
||||
.iter()
|
||||
.find(|x| {
|
||||
for cred in x.credentials.iter() {
|
||||
if let UserAuthCredential::Sso { provider, email } = cred {
|
||||
if provider.as_ref().unwrap_or(client_provider) == client_provider
|
||||
&& email == client_email
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.map(|x| x.username.clone()))
|
||||
}
|
||||
|
||||
if let Some(credential) =
|
||||
user.credentials.iter().find(|credential| match credential {
|
||||
UserAuthCredential::PublicKey { key: ref user_key } => {
|
||||
&client_key == user_key.expose_secret()
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
async fn validate_credential(
|
||||
&mut self,
|
||||
username: &str,
|
||||
client_credential: &AuthCredential,
|
||||
) -> Result<bool, WarpgateError> {
|
||||
let user = {
|
||||
self.config
|
||||
.lock()
|
||||
.await
|
||||
.store
|
||||
.users
|
||||
.iter()
|
||||
.find(|x| x.username == username)
|
||||
.map(User::to_owned)
|
||||
};
|
||||
let Some(user) = user else {
|
||||
error!("Selected user not found: {}", username);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
match client_credential {
|
||||
AuthCredential::PublicKey {
|
||||
kind,
|
||||
public_key_bytes,
|
||||
} => {
|
||||
let base64_bytes = BASE64.encode(public_key_bytes);
|
||||
|
||||
let client_key = format!("{} {}", kind, base64_bytes);
|
||||
debug!(username = &user.username[..], "Client key: {}", client_key);
|
||||
|
||||
return Ok(user.credentials.iter().any(|credential| match credential {
|
||||
UserAuthCredential::PublicKey { key: ref user_key } => {
|
||||
&client_key == user_key.expose_secret()
|
||||
}
|
||||
_ => false,
|
||||
}));
|
||||
}
|
||||
AuthCredential::Password(client_password) => {
|
||||
return Ok(user.credentials.iter().any(|credential| match credential {
|
||||
UserAuthCredential::Password {
|
||||
hash: ref user_password_hash,
|
||||
} => verify_password_hash(
|
||||
client_password.expose_secret(),
|
||||
user_password_hash.expose_secret(),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
username = &user.username[..],
|
||||
"Error verifying password hash: {}", e
|
||||
);
|
||||
false
|
||||
}),
|
||||
_ => false,
|
||||
}))
|
||||
}
|
||||
AuthCredential::Otp(client_otp) => {
|
||||
return Ok(user.credentials.iter().any(|credential| match credential {
|
||||
UserAuthCredential::Totp {
|
||||
key: ref user_otp_key,
|
||||
} => verify_totp(client_otp.expose_secret(), user_otp_key),
|
||||
_ => false,
|
||||
}))
|
||||
}
|
||||
AuthCredential::Sso {
|
||||
provider: client_provider,
|
||||
email: client_email,
|
||||
} => {
|
||||
for credential in user.credentials.iter() {
|
||||
if let UserAuthCredential::Sso {
|
||||
ref provider,
|
||||
ref email,
|
||||
} = credential
|
||||
{
|
||||
valid_credentials.push(credential)
|
||||
}
|
||||
}
|
||||
AuthCredential::Password(client_password) => {
|
||||
match user.credentials.iter().find(|credential| match credential {
|
||||
UserAuthCredential::Password {
|
||||
hash: ref user_password_hash,
|
||||
} => verify_password_hash(
|
||||
client_password.expose_secret(),
|
||||
user_password_hash.expose_secret(),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
username = &user.username[..],
|
||||
"Error verifying password hash: {}", e
|
||||
);
|
||||
false
|
||||
}),
|
||||
_ => false,
|
||||
}) {
|
||||
Some(credential) => valid_credentials.push(credential),
|
||||
None => return Ok(AuthResult::Rejected),
|
||||
}
|
||||
}
|
||||
AuthCredential::Otp(client_otp) => {
|
||||
match user.credentials.iter().find(|credential| match credential {
|
||||
UserAuthCredential::Totp {
|
||||
key: ref user_otp_key,
|
||||
} => verify_totp(client_otp.expose_secret(), user_otp_key),
|
||||
_ => false,
|
||||
}) {
|
||||
Some(credential) => valid_credentials.push(credential),
|
||||
None => return Ok(AuthResult::Rejected),
|
||||
if provider.as_ref().unwrap_or(client_provider) == client_provider {
|
||||
return Ok(email == client_email);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
if valid_credentials.is_empty() {
|
||||
warn!(
|
||||
username = &user.username[..],
|
||||
"Client credentials did not match"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref policy) = user.require {
|
||||
let required_kinds = match protocol {
|
||||
"SSH" => &policy.ssh,
|
||||
"HTTP" => &policy.http,
|
||||
"MySQL" => &policy.mysql,
|
||||
_ => {
|
||||
error!(%protocol, "Unkown protocol");
|
||||
return Ok(AuthResult::Rejected);
|
||||
}
|
||||
};
|
||||
if let Some(required_kinds) = required_kinds {
|
||||
let mut remaining_required_kinds = HashSet::new();
|
||||
remaining_required_kinds.extend(required_kinds);
|
||||
for kind in required_kinds {
|
||||
if valid_credentials
|
||||
.iter()
|
||||
.any(|x| credential_is_type(x, kind))
|
||||
{
|
||||
remaining_required_kinds.remove(kind);
|
||||
}
|
||||
}
|
||||
if remaining_required_kinds.is_empty() {
|
||||
return Ok(AuthResult::Accepted {
|
||||
username: user.username.clone(),
|
||||
});
|
||||
} else if remaining_required_kinds.contains(&"otp".to_string()) {
|
||||
return Ok(AuthResult::OtpNeeded);
|
||||
} else {
|
||||
return Ok(AuthResult::Rejected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if !valid_credentials.is_empty() {
|
||||
AuthResult::Accepted {
|
||||
username: user.username.clone(),
|
||||
}
|
||||
} else {
|
||||
AuthResult::Rejected
|
||||
})
|
||||
}
|
||||
|
||||
async fn authorize_target(
|
||||
|
|
|
@ -2,7 +2,6 @@ mod file;
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
pub use file::FileConfigProvider;
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use tokio::sync::Mutex;
|
||||
|
@ -10,35 +9,37 @@ use tracing::*;
|
|||
use uuid::Uuid;
|
||||
use warpgate_db_entities::Ticket;
|
||||
|
||||
use crate::{ProtocolName, Secret, Target, UserSnapshot, WarpgateError};
|
||||
use crate::auth::{AuthCredential, CredentialKind, CredentialPolicy};
|
||||
use crate::{Secret, Target, UserSnapshot, WarpgateError};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AuthResult {
|
||||
Accepted { username: String },
|
||||
OtpNeeded,
|
||||
Need(CredentialKind),
|
||||
Rejected,
|
||||
}
|
||||
|
||||
pub enum AuthCredential {
|
||||
Otp(Secret<String>),
|
||||
Password(Secret<String>),
|
||||
PublicKey {
|
||||
kind: String,
|
||||
public_key_bytes: Bytes,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ConfigProvider {
|
||||
async fn list_users(&mut self) -> Result<Vec<UserSnapshot>, WarpgateError>;
|
||||
|
||||
async fn list_targets(&mut self) -> Result<Vec<Target>, WarpgateError>;
|
||||
|
||||
async fn authorize(
|
||||
async fn validate_credential(
|
||||
&mut self,
|
||||
username: &str,
|
||||
credentials: &[AuthCredential],
|
||||
protocol: ProtocolName,
|
||||
) -> Result<AuthResult, WarpgateError>;
|
||||
client_credential: &AuthCredential,
|
||||
) -> Result<bool, WarpgateError>;
|
||||
|
||||
async fn username_for_sso_credential(
|
||||
&mut self,
|
||||
client_credential: &AuthCredential,
|
||||
) -> Result<Option<String>, WarpgateError>;
|
||||
|
||||
async fn get_credential_policy(
|
||||
&mut self,
|
||||
username: &str,
|
||||
) -> Result<Option<Box<dyn CredentialPolicy + Sync + Send>>, WarpgateError>;
|
||||
|
||||
async fn authorize_target(
|
||||
&mut self,
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::auth::AuthStateStore;
|
||||
use crate::db::{connect_to_db, sanitize_db};
|
||||
use crate::recordings::SessionRecordings;
|
||||
use crate::{ConfigProvider, FileConfigProvider, State, WarpgateConfig};
|
||||
|
@ -15,6 +17,7 @@ pub struct Services {
|
|||
pub config: Arc<Mutex<WarpgateConfig>>,
|
||||
pub state: Arc<Mutex<State>>,
|
||||
pub config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>,
|
||||
pub auth_state_store: Arc<Mutex<AuthStateStore>>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
|
@ -29,12 +32,25 @@ impl Services {
|
|||
let config = Arc::new(Mutex::new(config));
|
||||
let config_provider = Arc::new(Mutex::new(FileConfigProvider::new(&db, &config).await));
|
||||
|
||||
let auth_state_store = Arc::new(Mutex::new(AuthStateStore::new(config_provider.clone())));
|
||||
|
||||
tokio::spawn({
|
||||
let auth_state_store = auth_state_store.clone();
|
||||
async move {
|
||||
loop {
|
||||
auth_state_store.lock().await.vacuum().await;
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
db: db.clone(),
|
||||
recordings,
|
||||
config: config.clone(),
|
||||
state: State::new(&db),
|
||||
config_provider,
|
||||
auth_state_store,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ warpgate-admin = {version = "*", path = "../warpgate-admin"}
|
|||
warpgate-common = {version = "*", path = "../warpgate-common"}
|
||||
warpgate-db-entities = {version = "*", path = "../warpgate-db-entities"}
|
||||
warpgate-web = {version = "*", path = "../warpgate-web"}
|
||||
warpgate-sso = {version = "*", path = "../warpgate-sso"}
|
||||
percent-encoding = "2.1"
|
||||
uuid = {version = "1.0", features = ["v4"]}
|
||||
regex = "1.6"
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
use crate::common::{SessionExt, SessionAuthorization};
|
||||
use crate::session::SessionStore;
|
||||
use anyhow::Context;
|
||||
use std::sync::Arc;
|
||||
|
||||
use poem::session::Session;
|
||||
use poem::web::Data;
|
||||
use poem::Request;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::*;
|
||||
use warpgate_common::{AuthCredential, AuthResult, Secret, Services};
|
||||
use warpgate_common::auth::{AuthCredential, CredentialKind};
|
||||
use warpgate_common::{AuthResult, Secret, Services};
|
||||
|
||||
use crate::common::{authorize_session, get_auth_state_for_request, SessionExt};
|
||||
use crate::session::SessionStore;
|
||||
|
||||
pub struct Api;
|
||||
|
||||
|
@ -17,18 +19,26 @@ pub struct Api;
|
|||
struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
otp: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct OtpLoginRequest {
|
||||
otp: String,
|
||||
}
|
||||
|
||||
#[derive(Enum)]
|
||||
enum LoginFailureReason {
|
||||
InvalidCredentials,
|
||||
enum ApiAuthState {
|
||||
NotStarted,
|
||||
Failed,
|
||||
PasswordNeeded,
|
||||
OtpNeeded,
|
||||
SsoNeeded,
|
||||
Success,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct LoginFailureResponse {
|
||||
reason: LoginFailureReason,
|
||||
state: ApiAuthState,
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
|
@ -46,6 +56,30 @@ enum LogoutResponse {
|
|||
Success,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct AuthStateResponseInternal {
|
||||
pub state: ApiAuthState,
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum AuthStateResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<AuthStateResponseInternal>),
|
||||
}
|
||||
|
||||
impl From<AuthResult> for ApiAuthState {
|
||||
fn from(state: AuthResult) -> Self {
|
||||
match state {
|
||||
AuthResult::Rejected => ApiAuthState::Failed,
|
||||
AuthResult::Need(CredentialKind::Password) => ApiAuthState::PasswordNeeded,
|
||||
AuthResult::Need(CredentialKind::Otp) => ApiAuthState::OtpNeeded,
|
||||
AuthResult::Need(CredentialKind::Sso) => ApiAuthState::SsoNeeded,
|
||||
AuthResult::Need(CredentialKind::PublicKey) => ApiAuthState::Failed,
|
||||
AuthResult::Accepted { .. } => ApiAuthState::Success,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[OpenApi]
|
||||
impl Api {
|
||||
#[oai(path = "/auth/login", method = "post", operation_id = "login")]
|
||||
|
@ -54,51 +88,93 @@ impl Api {
|
|||
req: &Request,
|
||||
session: &Session,
|
||||
services: Data<&Services>,
|
||||
session_middleware: Data<&Arc<Mutex<SessionStore>>>,
|
||||
body: Json<LoginRequest>,
|
||||
) -> poem::Result<LoginResponse> {
|
||||
let mut credentials = vec![AuthCredential::Password(Secret::new(body.password.clone()))];
|
||||
if let Some(ref otp) = body.otp {
|
||||
credentials.push(AuthCredential::Otp(otp.clone().into()));
|
||||
let mut auth_state_store = services.auth_state_store.lock().await;
|
||||
let state =
|
||||
get_auth_state_for_request(&body.username, session, &mut auth_state_store).await?;
|
||||
|
||||
let mut cp = services.config_provider.lock().await;
|
||||
|
||||
let password_cred = AuthCredential::Password(Secret::new(body.password.clone()));
|
||||
if cp
|
||||
.validate_credential(&body.username, &password_cred)
|
||||
.await?
|
||||
{
|
||||
state.add_valid_credential(password_cred);
|
||||
}
|
||||
|
||||
let result = {
|
||||
let mut config_provider = services.config_provider.lock().await;
|
||||
config_provider
|
||||
.authorize(&body.username, &credentials, crate::common::PROTOCOL_NAME)
|
||||
.await
|
||||
.context("Failed to authorize user")?
|
||||
};
|
||||
|
||||
match result {
|
||||
match state.verify() {
|
||||
AuthResult::Accepted { username } => {
|
||||
let server_handle = session_middleware
|
||||
.lock()
|
||||
.await
|
||||
.create_handle_for(&req)
|
||||
.await?;
|
||||
server_handle
|
||||
.lock()
|
||||
.await
|
||||
.set_username(username.clone())
|
||||
.await?;
|
||||
info!(%username, "Authenticated");
|
||||
session.set_auth(SessionAuthorization::User(username));
|
||||
authorize_session(req, username).await?;
|
||||
Ok(LoginResponse::Success)
|
||||
}
|
||||
x => {
|
||||
error!("Auth rejected");
|
||||
Ok(LoginResponse::Failure(Json(LoginFailureResponse {
|
||||
reason: match x {
|
||||
AuthResult::Accepted { .. } => unreachable!(),
|
||||
AuthResult::Rejected => LoginFailureReason::InvalidCredentials,
|
||||
AuthResult::OtpNeeded => LoginFailureReason::OtpNeeded,
|
||||
},
|
||||
state: x.into(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[oai(path = "/auth/otp", method = "post", operation_id = "otpLogin")]
|
||||
async fn api_auth_otp_login(
|
||||
&self,
|
||||
req: &Request,
|
||||
session: &Session,
|
||||
services: Data<&Services>,
|
||||
body: Json<OtpLoginRequest>,
|
||||
) -> poem::Result<LoginResponse> {
|
||||
let state_id = session.get_auth_state_id();
|
||||
|
||||
let mut auth_state_store = services.auth_state_store.lock().await;
|
||||
|
||||
let Some(state) = state_id.and_then(|id| auth_state_store.get_mut(&id.0)) else {
|
||||
return Ok(LoginResponse::Failure(Json(LoginFailureResponse {
|
||||
state: ApiAuthState::NotStarted,
|
||||
})))
|
||||
};
|
||||
|
||||
let mut cp = services.config_provider.lock().await;
|
||||
|
||||
let otp_cred = AuthCredential::Otp(body.otp.clone().into());
|
||||
if cp.validate_credential(state.username(), &otp_cred).await? {
|
||||
state.add_valid_credential(otp_cred);
|
||||
}
|
||||
|
||||
match state.verify() {
|
||||
AuthResult::Accepted { username } => {
|
||||
authorize_session(req, username).await?;
|
||||
Ok(LoginResponse::Success)
|
||||
}
|
||||
x => Ok(LoginResponse::Failure(Json(LoginFailureResponse {
|
||||
state: x.into(),
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
#[oai(path = "/auth/state", method = "get", operation_id = "getAuthState")]
|
||||
async fn api_auth_state(
|
||||
&self,
|
||||
session: &Session,
|
||||
services: Data<&Services>,
|
||||
) -> poem::Result<AuthStateResponse> {
|
||||
let state_id = session.get_auth_state_id();
|
||||
|
||||
let mut auth_state_store = services.auth_state_store.lock().await;
|
||||
|
||||
let Some(state) = state_id.and_then(|id| auth_state_store.get_mut(&id.0)) else {
|
||||
return Ok(AuthStateResponse::Ok(Json(AuthStateResponseInternal {
|
||||
state: ApiAuthState::NotStarted,
|
||||
})));
|
||||
};
|
||||
|
||||
Ok(AuthStateResponse::Ok(Json(AuthStateResponseInternal {
|
||||
state: state.verify().into(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[oai(path = "/auth/logout", method = "post", operation_id = "logout")]
|
||||
async fn api_auth_logout(
|
||||
&self,
|
||||
|
|
|
@ -2,8 +2,16 @@ use poem_openapi::OpenApi;
|
|||
|
||||
pub mod auth;
|
||||
pub mod info;
|
||||
pub mod sso_provider_detail;
|
||||
pub mod sso_provider_list;
|
||||
pub mod targets_list;
|
||||
|
||||
pub fn get() -> impl OpenApi {
|
||||
(auth::Api, info::Api, targets_list::Api)
|
||||
(
|
||||
auth::Api,
|
||||
info::Api,
|
||||
targets_list::Api,
|
||||
sso_provider_list::Api,
|
||||
sso_provider_detail::Api,
|
||||
)
|
||||
}
|
||||
|
|
94
warpgate-protocol-http/src/api/sso_provider_detail.rs
Normal file
94
warpgate-protocol-http/src/api/sso_provider_detail.rs
Normal file
|
@ -0,0 +1,94 @@
|
|||
use poem::session::Session;
|
||||
use poem::web::Data;
|
||||
use poem::Request;
|
||||
use poem_openapi::param::Path;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::{ApiResponse, Object, OpenApi};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warpgate_common::Services;
|
||||
use warpgate_sso::{SsoClient, SsoLoginRequest};
|
||||
|
||||
pub struct Api;
|
||||
|
||||
#[derive(Object)]
|
||||
struct StartSsoResponseParams {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(ApiResponse)]
|
||||
enum StartSsoResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<StartSsoResponseParams>),
|
||||
#[oai(status = 404)]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
pub static SSO_CONTEXT_SESSION_KEY: &str = "sso_request";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SsoContext {
|
||||
pub provider: String,
|
||||
pub request: SsoLoginRequest,
|
||||
}
|
||||
|
||||
#[OpenApi]
|
||||
impl Api {
|
||||
#[oai(
|
||||
path = "/sso/providers/:name/start",
|
||||
method = "get",
|
||||
operation_id = "start_sso"
|
||||
)]
|
||||
async fn api_start_sso(
|
||||
&self,
|
||||
req: &Request,
|
||||
session: &Session,
|
||||
services: Data<&Services>,
|
||||
name: Path<String>,
|
||||
) -> poem::Result<StartSsoResponse> {
|
||||
let config = services.config.lock().await;
|
||||
|
||||
let name = name.0;
|
||||
let ext_host = config
|
||||
.store
|
||||
.external_host
|
||||
.as_deref()
|
||||
.or_else(|| req.original_uri().host());
|
||||
let Some(ext_host) = ext_host else {
|
||||
return Err(poem::Error::from_string("external_host config option is required for SSO", http::status::StatusCode::INTERNAL_SERVER_ERROR));
|
||||
};
|
||||
let ext_port = config.store.http.listen.port();
|
||||
|
||||
let mut return_url = Url::parse(&format!("https://{ext_host}/@warpgate/api/sso/return"))
|
||||
.map_err(|e| {
|
||||
poem::Error::from_string(
|
||||
format!("failed to construct the return URL: {e}"),
|
||||
http::status::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
})?;
|
||||
|
||||
if ext_port != 443 {
|
||||
let _ = return_url.set_port(Some(ext_port));
|
||||
}
|
||||
|
||||
let Some(provider_config) = config.store.sso_providers.iter().find(|p| p.name == *name) else {
|
||||
return Ok(StartSsoResponse::NotFound);
|
||||
};
|
||||
|
||||
let client = SsoClient::new(provider_config.provider.clone());
|
||||
|
||||
let sso_req = client
|
||||
.start_login(return_url.to_string())
|
||||
.await
|
||||
.map_err(poem::error::InternalServerError)?;
|
||||
|
||||
let url = sso_req.auth_url().to_string();
|
||||
session.set(SSO_CONTEXT_SESSION_KEY, SsoContext {
|
||||
provider: name,
|
||||
request: sso_req,
|
||||
});
|
||||
|
||||
Ok(StartSsoResponse::Ok(Json(StartSsoResponseParams { url })))
|
||||
}
|
||||
}
|
140
warpgate-protocol-http/src/api/sso_provider_list.rs
Normal file
140
warpgate-protocol-http/src/api/sso_provider_list.rs
Normal file
|
@ -0,0 +1,140 @@
|
|||
use poem::session::Session;
|
||||
use poem::web::Data;
|
||||
use poem::Request;
|
||||
use poem_openapi::param::Query;
|
||||
use poem_openapi::payload::{Json, Response};
|
||||
use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
|
||||
use tracing::*;
|
||||
use warpgate_common::auth::AuthCredential;
|
||||
use warpgate_common::{AuthResult, Services};
|
||||
use warpgate_sso::SsoInternalProviderConfig;
|
||||
|
||||
use super::sso_provider_detail::{SsoContext, SSO_CONTEXT_SESSION_KEY};
|
||||
use crate::common::{authorize_session, get_auth_state_for_request};
|
||||
|
||||
pub struct Api;
|
||||
|
||||
#[derive(Enum)]
|
||||
pub enum SsoProviderKind {
|
||||
Google,
|
||||
Apple,
|
||||
Azure,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
pub struct SsoProviderDescription {
|
||||
pub name: String,
|
||||
pub label: String,
|
||||
pub kind: SsoProviderKind,
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum GetSsoProvidersResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<Vec<SsoProviderDescription>>),
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(ApiResponse)]
|
||||
enum ReturnToSsoResponse {
|
||||
#[oai(status = 307)]
|
||||
Ok,
|
||||
}
|
||||
|
||||
#[OpenApi]
|
||||
impl Api {
|
||||
#[oai(
|
||||
path = "/sso/providers",
|
||||
method = "get",
|
||||
operation_id = "get_sso_providers"
|
||||
)]
|
||||
async fn api_get_all_sso_providers(
|
||||
&self,
|
||||
services: Data<&Services>,
|
||||
) -> poem::Result<GetSsoProvidersResponse> {
|
||||
let mut providers = services.config.lock().await.store.sso_providers.clone();
|
||||
providers.sort_by(|a, b| a.label().cmp(&b.label()));
|
||||
Ok(GetSsoProvidersResponse::Ok(Json(
|
||||
providers
|
||||
.into_iter()
|
||||
.map(|p| SsoProviderDescription {
|
||||
name: p.name.clone(),
|
||||
label: p.label().to_string(),
|
||||
kind: match p.provider {
|
||||
SsoInternalProviderConfig::Google { .. } => SsoProviderKind::Google,
|
||||
SsoInternalProviderConfig::Apple { .. } => SsoProviderKind::Apple,
|
||||
SsoInternalProviderConfig::Azure { .. } => SsoProviderKind::Azure,
|
||||
SsoInternalProviderConfig::Custom { .. } => SsoProviderKind::Custom,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
)))
|
||||
}
|
||||
|
||||
#[oai(path = "/sso/return", method = "get", operation_id = "return_to_sso")]
|
||||
async fn api_return_to_sso(
|
||||
&self,
|
||||
req: &Request,
|
||||
session: &Session,
|
||||
services: Data<&Services>,
|
||||
code: Query<Option<String>>,
|
||||
) -> poem::Result<Response<ReturnToSsoResponse>> {
|
||||
fn make_err_response(err: &str) -> poem::Result<Response<ReturnToSsoResponse>> {
|
||||
error!("SSO error: {err}");
|
||||
Ok(Response::new(ReturnToSsoResponse::Ok)
|
||||
.header("Location", format!("/@warpgate?login_error={err}")))
|
||||
}
|
||||
|
||||
let Some(context) = session.get::<SsoContext>(SSO_CONTEXT_SESSION_KEY) else {
|
||||
return make_err_response("Not in an active SSO process");
|
||||
};
|
||||
|
||||
let Some(ref code) = *code else {
|
||||
return make_err_response("No authorization code in the return URL request");
|
||||
};
|
||||
|
||||
let response = context
|
||||
.request
|
||||
.verify_code((*code).clone())
|
||||
.await
|
||||
.map_err(poem::error::InternalServerError)?;
|
||||
|
||||
if !response.email_verified.unwrap_or(true) {
|
||||
return make_err_response("The SSO account's e-mail is not verified");
|
||||
}
|
||||
|
||||
let Some(email) = response.email else {
|
||||
return make_err_response("No e-mail information in the SSO response");
|
||||
};
|
||||
|
||||
info!("SSO login as {email}");
|
||||
|
||||
let cred = AuthCredential::Sso {
|
||||
provider: context.provider,
|
||||
email: email.clone(),
|
||||
};
|
||||
|
||||
let Some(username) = services.config_provider.lock().await.username_for_sso_credential(&cred).await? else {
|
||||
return make_err_response(&format!("No user matching {email}"));
|
||||
};
|
||||
|
||||
let mut auth_state_store = services.auth_state_store.lock().await;
|
||||
let state = get_auth_state_for_request(&username, session, &mut auth_state_store).await?;
|
||||
|
||||
let mut cp = services.config_provider.lock().await;
|
||||
|
||||
if cp.validate_credential(&username, &cred).await? {
|
||||
state.add_valid_credential(cred);
|
||||
}
|
||||
|
||||
match state.verify() {
|
||||
AuthResult::Accepted { username } => {
|
||||
authorize_session(req, username).await?;
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
|
||||
Ok(Response::new(ReturnToSsoResponse::Ok).header("Location", "/@warpgate"))
|
||||
}
|
||||
}
|
|
@ -7,11 +7,18 @@ use poem::session::Session;
|
|||
use poem::web::{Data, Redirect};
|
||||
use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warpgate_common::{ProtocolName, Services, TargetOptions};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::*;
|
||||
use uuid::Uuid;
|
||||
use warpgate_common::auth::{AuthState, AuthStateStore};
|
||||
use warpgate_common::{ProtocolName, Services, TargetOptions, WarpgateError};
|
||||
|
||||
use crate::session::SessionStore;
|
||||
|
||||
pub const PROTOCOL_NAME: ProtocolName = "HTTP";
|
||||
static TARGET_SESSION_KEY: &str = "target_name";
|
||||
static AUTH_SESSION_KEY: &str = "auth";
|
||||
static AUTH_STATE_ID_SESSION_KEY: &str = "auth_state_id";
|
||||
pub static SESSION_MAX_AGE: Duration = Duration::from_secs(60 * 30);
|
||||
pub static COOKIE_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24);
|
||||
|
||||
|
@ -23,6 +30,7 @@ pub trait SessionExt {
|
|||
fn get_username(&self) -> Option<String>;
|
||||
fn get_auth(&self) -> Option<SessionAuthorization>;
|
||||
fn set_auth(&self, auth: SessionAuthorization);
|
||||
fn get_auth_state_id(&self) -> Option<AuthStateId>;
|
||||
}
|
||||
|
||||
impl SessionExt for Session {
|
||||
|
@ -53,8 +61,15 @@ impl SessionExt for Session {
|
|||
fn set_auth(&self, auth: SessionAuthorization) {
|
||||
self.set(AUTH_SESSION_KEY, auth);
|
||||
}
|
||||
|
||||
fn get_auth_state_id(&self) -> Option<AuthStateId> {
|
||||
self.get(AUTH_STATE_ID_SESSION_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AuthStateId(pub Uuid);
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub enum SessionAuthorization {
|
||||
User(String),
|
||||
|
@ -161,3 +176,50 @@ pub fn gateway_redirect(req: &Request) -> Response {
|
|||
|
||||
Redirect::temporary(path).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_auth_state_for_request<'a>(
|
||||
username: &str,
|
||||
session: &Session,
|
||||
store: &'a mut AuthStateStore,
|
||||
) -> Result<&'a mut AuthState, WarpgateError> {
|
||||
match session.get_auth_state_id() {
|
||||
Some(id) => {
|
||||
if !store.contains_key(&id.0) {
|
||||
session.remove(AUTH_STATE_ID_SESSION_KEY)
|
||||
}
|
||||
}
|
||||
None => (),
|
||||
};
|
||||
|
||||
match session.get_auth_state_id() {
|
||||
Some(id) => Ok(store.get_mut(&id.0).unwrap()),
|
||||
None => {
|
||||
let (id, state) = store
|
||||
.create(&username, crate::common::PROTOCOL_NAME)
|
||||
.await?;
|
||||
session.set(AUTH_STATE_ID_SESSION_KEY, AuthStateId(id));
|
||||
Ok(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_session(req: &Request, username: String) -> poem::Result<()> {
|
||||
let session_middleware: Data<&Arc<Mutex<SessionStore>>> =
|
||||
<_>::from_request_without_body(&req).await?;
|
||||
let session: &Session = <_>::from_request_without_body(&req).await?;
|
||||
|
||||
let server_handle = session_middleware
|
||||
.lock()
|
||||
.await
|
||||
.create_handle_for(&req)
|
||||
.await?;
|
||||
server_handle
|
||||
.lock()
|
||||
.await
|
||||
.set_username(username.clone())
|
||||
.await?;
|
||||
info!(%username, "Authenticated");
|
||||
session.set_auth(SessionAuthorization::User(username));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
#![feature(type_alias_impl_trait, let_else, try_blocks)]
|
||||
mod api;
|
||||
pub mod api;
|
||||
mod catchall;
|
||||
mod common;
|
||||
mod error;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
#![feature(type_alias_impl_trait, let_else, try_blocks)]
|
||||
mod api;
|
||||
use warpgate_protocol_http::api;
|
||||
use regex::Regex;
|
||||
mod common;
|
||||
mod session;
|
||||
mod session_handle;
|
||||
use poem_openapi::OpenApiService;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
|
|
|
@ -7,11 +7,11 @@ use tokio::net::TcpStream;
|
|||
use tokio::sync::Mutex;
|
||||
use tracing::*;
|
||||
use uuid::Uuid;
|
||||
use warpgate_common::auth::AuthSelector;
|
||||
use warpgate_common::auth::{AuthCredential, AuthSelector, AuthState};
|
||||
use warpgate_common::helpers::rng::get_crypto_rng;
|
||||
use warpgate_common::{
|
||||
authorize_ticket, AuthCredential, AuthResult, Secret, Services, TargetMySqlOptions,
|
||||
TargetOptions, WarpgateServerHandle,
|
||||
authorize_ticket, AuthResult, Secret, Services, TargetMySqlOptions, TargetOptions,
|
||||
WarpgateServerHandle,
|
||||
};
|
||||
use warpgate_database_protocols::io::{BufExt, Decode};
|
||||
use warpgate_database_protocols::mysql::protocol::auth::AuthPlugin;
|
||||
|
@ -175,20 +175,25 @@ impl MySqlSession {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
let credentials = vec![AuthCredential::Password(password)];
|
||||
match selector {
|
||||
AuthSelector::User {
|
||||
username,
|
||||
target_name,
|
||||
} => {
|
||||
let user_auth_result: AuthResult = {
|
||||
self.services
|
||||
.config_provider
|
||||
.lock()
|
||||
.await
|
||||
.authorize(&username, &credentials, crate::common::PROTOCOL_NAME)
|
||||
.await
|
||||
.map_err(MySqlError::other)?
|
||||
let user_auth_result = {
|
||||
let mut cp = self.services.config_provider.lock().await;
|
||||
|
||||
let credential = AuthCredential::Password(password);
|
||||
let mut state = AuthState::new(
|
||||
username.clone(),
|
||||
crate::common::PROTOCOL_NAME.to_string(),
|
||||
cp.get_credential_policy(&username).await?,
|
||||
);
|
||||
if cp.validate_credential(&username, &credential).await? {
|
||||
state.add_valid_credential(credential);
|
||||
}
|
||||
|
||||
state.verify()
|
||||
};
|
||||
|
||||
match user_auth_result {
|
||||
|
@ -211,7 +216,7 @@ impl MySqlSession {
|
|||
}
|
||||
self.run_authorized(handshake, username, target_name).await
|
||||
}
|
||||
AuthResult::Rejected | AuthResult::OtpNeeded => fail(&mut self).await,
|
||||
AuthResult::Rejected | AuthResult::Need(_) => fail(&mut self).await, // TODO SSO
|
||||
}
|
||||
}
|
||||
AuthSelector::Ticket { secret } => {
|
||||
|
|
|
@ -17,15 +17,15 @@ use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
|||
use tokio::sync::{oneshot, Mutex};
|
||||
use tracing::*;
|
||||
use uuid::Uuid;
|
||||
use warpgate_common::auth::AuthSelector;
|
||||
use warpgate_common::auth::{AuthCredential, AuthSelector, AuthState, CredentialKind};
|
||||
use warpgate_common::eventhub::{EventHub, EventSender};
|
||||
use warpgate_common::recordings::{
|
||||
self, ConnectionRecorder, TerminalRecorder, TerminalRecordingStreamId, TrafficConnectionParams,
|
||||
TrafficRecorder,
|
||||
};
|
||||
use warpgate_common::{
|
||||
authorize_ticket, AuthCredential, AuthResult, Secret, Services, SessionId,
|
||||
SshHostKeyVerificationMode, Target, TargetOptions, TargetSSHOptions, WarpgateServerHandle,
|
||||
authorize_ticket, AuthResult, Secret, Services, SessionId, SshHostKeyVerificationMode, Target,
|
||||
TargetOptions, TargetSSHOptions, WarpgateServerHandle,
|
||||
};
|
||||
|
||||
use super::service_output::ServiceOutput;
|
||||
|
@ -71,10 +71,10 @@ pub struct ServerSession {
|
|||
target: TargetSelection,
|
||||
traffic_recorders: HashMap<(String, u32), TrafficRecorder>,
|
||||
traffic_connection_recorders: HashMap<Uuid, ConnectionRecorder>,
|
||||
credentials: Vec<AuthCredential>,
|
||||
hub: EventHub<Event>,
|
||||
event_sender: EventSender<Event>,
|
||||
service_output: ServiceOutput,
|
||||
auth_state: Option<AuthState>,
|
||||
}
|
||||
|
||||
fn session_debug_tag(id: &SessionId, remote_address: &SocketAddr) -> String {
|
||||
|
@ -136,12 +136,12 @@ impl ServerSession {
|
|||
target: TargetSelection::None,
|
||||
traffic_recorders: HashMap::new(),
|
||||
traffic_connection_recorders: HashMap::new(),
|
||||
credentials: vec![],
|
||||
hub,
|
||||
event_sender: event_sender.clone(),
|
||||
service_output: ServiceOutput::new(Box::new(move |data| {
|
||||
so_tx.send(BytesMut::from(data).freeze()).context("x")
|
||||
})),
|
||||
auth_state: None,
|
||||
};
|
||||
|
||||
let this = Arc::new(Mutex::new(this));
|
||||
|
@ -217,6 +217,20 @@ impl ServerSession {
|
|||
Ok(this)
|
||||
}
|
||||
|
||||
async fn get_auth_state(&mut self, username: &str) -> Result<&mut AuthState> {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
if self.auth_state.is_none() || self.auth_state.as_ref().unwrap().username() != username {
|
||||
let mut cp = self.services.config_provider.lock().await;
|
||||
self.auth_state = Some(AuthState::new(
|
||||
username.to_string(),
|
||||
crate::PROTOCOL_NAME.to_string(),
|
||||
cp.get_credential_policy(username).await?,
|
||||
));
|
||||
}
|
||||
#[allow(clippy::unwrap_used)]
|
||||
Ok(self.auth_state.as_mut().unwrap())
|
||||
}
|
||||
|
||||
pub fn make_logging_span(&self) -> tracing::Span {
|
||||
match self.username {
|
||||
Some(ref username) => info_span!("SSH", session=%self.id, session_username=%username),
|
||||
|
@ -913,15 +927,19 @@ impl ServerSession {
|
|||
key.fingerprint()
|
||||
);
|
||||
|
||||
self.credentials.push(AuthCredential::PublicKey {
|
||||
kind: key.name().to_string(),
|
||||
public_key_bytes: Bytes::from(key.public_key_bytes()),
|
||||
});
|
||||
|
||||
match self.try_auth(&selector).await {
|
||||
match self
|
||||
.try_auth(
|
||||
&selector,
|
||||
AuthCredential::PublicKey {
|
||||
kind: key.name().to_string(),
|
||||
public_key_bytes: Bytes::from(key.public_key_bytes()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,
|
||||
Ok(AuthResult::Rejected) => russh::server::Auth::Reject,
|
||||
Ok(AuthResult::OtpNeeded) => russh::server::Auth::Reject,
|
||||
Ok(AuthResult::Need(_)) => russh::server::Auth::Reject,
|
||||
Err(error) => {
|
||||
error!(?error, "Failed to verify credentials");
|
||||
russh::server::Auth::Reject
|
||||
|
@ -937,12 +955,13 @@ impl ServerSession {
|
|||
let selector: AuthSelector = ssh_username.expose_secret().into();
|
||||
info!("Password key auth as {:?}", selector);
|
||||
|
||||
self.credentials.push(AuthCredential::Password(password));
|
||||
|
||||
match self.try_auth(&selector).await {
|
||||
match self
|
||||
.try_auth(&selector, AuthCredential::Password(password))
|
||||
.await
|
||||
{
|
||||
Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,
|
||||
Ok(AuthResult::Rejected) => russh::server::Auth::Reject,
|
||||
Ok(AuthResult::OtpNeeded) => russh::server::Auth::Reject,
|
||||
Ok(AuthResult::Need(_)) => russh::server::Auth::Reject,
|
||||
Err(error) => {
|
||||
error!(?error, "Failed to verify credentials");
|
||||
russh::server::Auth::Reject
|
||||
|
@ -958,18 +977,19 @@ impl ServerSession {
|
|||
let selector: AuthSelector = ssh_username.expose_secret().into();
|
||||
info!("Keyboard-interactive auth as {:?}", selector);
|
||||
|
||||
if let Some(otp) = response {
|
||||
self.credentials.push(AuthCredential::Otp(otp));
|
||||
}
|
||||
let Some(otp) = response else {
|
||||
return russh::server::Auth::Reject
|
||||
};
|
||||
|
||||
match self.try_auth(&selector).await {
|
||||
match self.try_auth(&selector, AuthCredential::Otp(otp)).await {
|
||||
Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,
|
||||
Ok(AuthResult::Rejected) => russh::server::Auth::Reject,
|
||||
Ok(AuthResult::OtpNeeded) => russh::server::Auth::Partial {
|
||||
Ok(AuthResult::Need(CredentialKind::Otp)) => russh::server::Auth::Partial {
|
||||
name: Cow::Borrowed("Two-factor authentication"),
|
||||
instructions: Cow::Borrowed(""),
|
||||
prompts: Cow::Owned(vec![(Cow::Borrowed("One-time password: "), true)]),
|
||||
},
|
||||
Ok(AuthResult::Need(_)) => russh::server::Auth::Reject, // TODO SSO
|
||||
Err(error) => {
|
||||
error!(?error, "Failed to verify credentials");
|
||||
russh::server::Auth::Reject
|
||||
|
@ -977,20 +997,28 @@ impl ServerSession {
|
|||
}
|
||||
}
|
||||
|
||||
async fn try_auth(&mut self, selector: &AuthSelector) -> Result<AuthResult> {
|
||||
async fn try_auth(
|
||||
&mut self,
|
||||
selector: &AuthSelector,
|
||||
credential: AuthCredential,
|
||||
) -> Result<AuthResult> {
|
||||
match selector {
|
||||
AuthSelector::User {
|
||||
username,
|
||||
target_name,
|
||||
} => {
|
||||
let user_auth_result: AuthResult = {
|
||||
self.services
|
||||
.config_provider
|
||||
.lock()
|
||||
.await
|
||||
.authorize(username, &self.credentials, crate::PROTOCOL_NAME)
|
||||
.await?
|
||||
};
|
||||
let cp = self.services.config_provider.clone();
|
||||
let state = self.get_auth_state(username).await?;
|
||||
if cp
|
||||
.lock()
|
||||
.await
|
||||
.validate_credential(username, &credential)
|
||||
.await?
|
||||
{
|
||||
state.add_valid_credential(credential);
|
||||
}
|
||||
|
||||
let user_auth_result = state.verify();
|
||||
|
||||
match user_auth_result {
|
||||
AuthResult::Accepted { username } => {
|
||||
|
|
17
warpgate-sso/Cargo.toml
Normal file
17
warpgate-sso/Cargo.toml
Normal file
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
name = "warpgate-sso"
|
||||
version = "0.4.0"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
bytes = "1.2"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.20", features = ["tracing", "macros"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
openidconnect = { version = "2.3", features = ["reqwest", "rustls-tls"] }
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
once_cell = "1.13"
|
113
warpgate-sso/src/config.rs
Normal file
113
warpgate-sso/src/config.rs
Normal file
|
@ -0,0 +1,113 @@
|
|||
use once_cell::sync::Lazy;
|
||||
use openidconnect::{ClientId, ClientSecret, IssuerUrl};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::SsoError;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub static GOOGLE_ISSUER_URL: Lazy<IssuerUrl> =
|
||||
Lazy::new(|| IssuerUrl::new("https://accounts.google.com".to_string()).unwrap());
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub static APPLE_ISSUER_URL: Lazy<IssuerUrl> =
|
||||
Lazy::new(|| IssuerUrl::new("https://appleid.apple.com".to_string()).unwrap());
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SsoProviderConfig {
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider: SsoInternalProviderConfig,
|
||||
}
|
||||
|
||||
impl SsoProviderConfig {
|
||||
pub fn label(&self) -> &str {
|
||||
return self
|
||||
.label
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| self.provider.label());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SsoInternalProviderConfig {
|
||||
#[serde(rename = "google")]
|
||||
Google {
|
||||
client_id: ClientId,
|
||||
client_secret: ClientSecret,
|
||||
},
|
||||
#[serde(rename = "apple")]
|
||||
Apple {
|
||||
client_id: ClientId,
|
||||
client_secret: ClientSecret,
|
||||
},
|
||||
#[serde(rename = "azure")]
|
||||
Azure {
|
||||
client_id: ClientId,
|
||||
client_secret: ClientSecret,
|
||||
tenant: String,
|
||||
},
|
||||
#[serde(rename = "custom")]
|
||||
Custom {
|
||||
name: String,
|
||||
label: String,
|
||||
client_id: ClientId,
|
||||
client_secret: ClientSecret,
|
||||
issuer_url: IssuerUrl,
|
||||
scopes: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SsoInternalProviderConfig {
|
||||
#[inline]
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
SsoInternalProviderConfig::Google { .. } => "Google",
|
||||
SsoInternalProviderConfig::Apple { .. } => "Apple",
|
||||
SsoInternalProviderConfig::Azure { .. } => "Azure",
|
||||
SsoInternalProviderConfig::Custom { .. } => "SSO",
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn client_id(&self) -> &ClientId {
|
||||
match self {
|
||||
SsoInternalProviderConfig::Google { client_id, .. }
|
||||
| SsoInternalProviderConfig::Apple { client_id, .. }
|
||||
| SsoInternalProviderConfig::Azure { client_id, .. }
|
||||
| SsoInternalProviderConfig::Custom { client_id, .. } => client_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn client_secret(&self) -> &ClientSecret {
|
||||
match self {
|
||||
SsoInternalProviderConfig::Google { client_secret, .. }
|
||||
| SsoInternalProviderConfig::Apple { client_secret, .. }
|
||||
| SsoInternalProviderConfig::Azure { client_secret, .. }
|
||||
| SsoInternalProviderConfig::Custom { client_secret, .. } => client_secret,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn issuer_url(&self) -> Result<IssuerUrl, SsoError> {
|
||||
Ok(match self {
|
||||
SsoInternalProviderConfig::Google { .. } => GOOGLE_ISSUER_URL.clone(),
|
||||
SsoInternalProviderConfig::Apple { .. } => APPLE_ISSUER_URL.clone(),
|
||||
SsoInternalProviderConfig::Azure { tenant, .. } => {
|
||||
IssuerUrl::new(format!("https://login.microsoftonline.com/{tenant}/v2.0"))?
|
||||
}
|
||||
SsoInternalProviderConfig::Custom { issuer_url, .. } => issuer_url.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn scopes(&self) -> Vec<String> {
|
||||
match self {
|
||||
SsoInternalProviderConfig::Google { .. }
|
||||
| SsoInternalProviderConfig::Apple { .. }
|
||||
| SsoInternalProviderConfig::Azure { .. } => vec!["email".to_string()],
|
||||
SsoInternalProviderConfig::Custom { scopes, .. } => scopes.clone(),
|
||||
}
|
||||
}
|
||||
}
|
25
warpgate-sso/src/error.rs
Normal file
25
warpgate-sso/src/error.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use std::error::Error;
|
||||
|
||||
use openidconnect::{ClaimsVerificationError, SigningError};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum SsoError {
|
||||
#[error("provider is OAuth2, not OIDC")]
|
||||
NotOidc,
|
||||
#[error("the token was replaced in flight")]
|
||||
Mitm,
|
||||
#[error("config parse error: {0}")]
|
||||
UrlParse(#[from] openidconnect::url::ParseError),
|
||||
#[error("provider discovery error: {0}")]
|
||||
Discovery(String),
|
||||
#[error("code verification error: {0}")]
|
||||
Verification(String),
|
||||
#[error("claims verification error: {0}")]
|
||||
ClaimsVerification(#[from] ClaimsVerificationError),
|
||||
#[error("signing error: {0}")]
|
||||
Signing(#[from] SigningError),
|
||||
#[error("I/O: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Other(Box<dyn Error + Send + Sync>),
|
||||
}
|
11
warpgate-sso/src/lib.rs
Normal file
11
warpgate-sso/src/lib.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
mod config;
|
||||
mod error;
|
||||
mod request;
|
||||
mod response;
|
||||
mod sso;
|
||||
|
||||
pub use config::*;
|
||||
pub use error::*;
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
pub use sso::*;
|
65
warpgate-sso/src/request.rs
Normal file
65
warpgate-sso/src/request.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use openidconnect::reqwest::async_http_client;
|
||||
use openidconnect::url::Url;
|
||||
use openidconnect::{
|
||||
AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeVerifier,
|
||||
RedirectUrl, TokenResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{make_client, SsoError, SsoInternalProviderConfig, SsoLoginResponse};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SsoLoginRequest {
|
||||
pub(crate) auth_url: Url,
|
||||
pub(crate) csrf_token: CsrfToken,
|
||||
pub(crate) nonce: Nonce,
|
||||
pub(crate) redirect_url: RedirectUrl,
|
||||
pub(crate) pkce_verifier: PkceCodeVerifier,
|
||||
pub(crate) config: SsoInternalProviderConfig,
|
||||
}
|
||||
|
||||
impl SsoLoginRequest {
|
||||
pub fn auth_url(&self) -> &Url {
|
||||
&self.auth_url
|
||||
}
|
||||
|
||||
pub fn csrf_token(&self) -> &CsrfToken {
|
||||
&self.csrf_token
|
||||
}
|
||||
|
||||
pub async fn verify_code(self, code: String) -> Result<SsoLoginResponse, SsoError> {
|
||||
let client = make_client(&self.config)
|
||||
.await?
|
||||
.set_redirect_uri(self.redirect_url.clone());
|
||||
|
||||
let token_response = client
|
||||
.exchange_code(AuthorizationCode::new(code))
|
||||
.set_pkce_verifier(self.pkce_verifier)
|
||||
.request_async(async_http_client)
|
||||
.await
|
||||
.map_err(|e| SsoError::Verification(format!("{e}")))?;
|
||||
|
||||
let id_token = token_response.id_token().ok_or(SsoError::NotOidc)?;
|
||||
let claims = id_token.claims(&client.id_token_verifier(), &self.nonce)?;
|
||||
|
||||
if let Some(expected_access_token_hash) = claims.access_token_hash() {
|
||||
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||
token_response.access_token(),
|
||||
&id_token.signing_alg()?,
|
||||
)?;
|
||||
if actual_access_token_hash != *expected_access_token_hash {
|
||||
return Err(SsoError::Mitm);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SsoLoginResponse {
|
||||
name: claims
|
||||
.name()
|
||||
.and_then(|x| x.get(None))
|
||||
.map(|x| x.as_str())
|
||||
.map(ToString::to_string),
|
||||
email: claims.email().map(|x| x.as_str()).map(ToString::to_string),
|
||||
email_verified: claims.email_verified(),
|
||||
})
|
||||
}
|
||||
}
|
6
warpgate-sso/src/response.rs
Normal file
6
warpgate-sso/src/response.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct SsoLoginResponse {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: Option<bool>,
|
||||
}
|
59
warpgate-sso/src/sso.rs
Normal file
59
warpgate-sso/src/sso.rs
Normal file
|
@ -0,0 +1,59 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
|
||||
use openidconnect::reqwest::async_http_client;
|
||||
use openidconnect::{CsrfToken, Nonce, PkceCodeChallenge, RedirectUrl, Scope};
|
||||
|
||||
use crate::config::SsoInternalProviderConfig;
|
||||
use crate::request::SsoLoginRequest;
|
||||
use crate::SsoError;
|
||||
|
||||
pub struct SsoClient {
|
||||
config: SsoInternalProviderConfig,
|
||||
}
|
||||
|
||||
pub async fn make_client(config: &SsoInternalProviderConfig) -> Result<CoreClient, SsoError> {
|
||||
let metadata = CoreProviderMetadata::discover_async(config.issuer_url()?, async_http_client)
|
||||
.await
|
||||
.map_err(|e| SsoError::Discovery(format!("{e}")))?;
|
||||
Ok(CoreClient::from_provider_metadata(
|
||||
metadata,
|
||||
config.client_id().clone(),
|
||||
Some(config.client_secret().clone()),
|
||||
))
|
||||
}
|
||||
|
||||
impl SsoClient {
|
||||
pub fn new(config: SsoInternalProviderConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub async fn start_login(&self, redirect_url: String) -> Result<SsoLoginRequest, SsoError> {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let redirect_url = RedirectUrl::new(redirect_url)?;
|
||||
let client = make_client(&self.config).await?;
|
||||
let mut auth_req = client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.set_redirect_uri(Cow::Owned(redirect_url.clone()));
|
||||
|
||||
for scope in self.config.scopes() {
|
||||
auth_req = auth_req.add_scope(Scope::new(scope.to_string()));
|
||||
}
|
||||
|
||||
let (auth_url, csrf_token, nonce) = auth_req.set_pkce_challenge(pkce_challenge).url();
|
||||
|
||||
Ok(SsoLoginRequest {
|
||||
auth_url,
|
||||
csrf_token,
|
||||
nonce,
|
||||
pkce_verifier,
|
||||
redirect_url,
|
||||
config: self.config.clone(),
|
||||
})
|
||||
}
|
||||
}
|
|
@ -19,8 +19,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/work-sans": "^4.5.7",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.1.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.2",
|
||||
"@openapitools/openapi-generator-cli": "^2.5.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.49",
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
|
|
|
@ -22,6 +22,7 @@ async function _click () {
|
|||
|
||||
<Button
|
||||
on:click={_click}
|
||||
class={$$props.class}
|
||||
outline={outline}
|
||||
color={color}
|
||||
type={type}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { replace } from 'svelte-spa-router'
|
||||
import { Alert, FormGroup } from 'sveltestrap'
|
||||
import { api, LoginFailureReason, LoginFailureResponseFromJSON } from 'gateway/lib/api'
|
||||
import { Alert, FormGroup, Spinner } from 'sveltestrap'
|
||||
import Fa from 'svelte-fa'
|
||||
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faGoogle, faMicrosoft, faApple } from '@fortawesome/free-brands-svg-icons'
|
||||
|
||||
import { api, ApiAuthState, LoginFailureResponseFromJSON, SsoProviderDescription, SsoProviderKind } from 'gateway/lib/api'
|
||||
import { reloadServerInfo } from 'gateway/lib/store'
|
||||
import AsyncButton from 'common/AsyncButton.svelte'
|
||||
|
||||
|
@ -9,10 +13,32 @@ let error: Error|null = null
|
|||
let username = ''
|
||||
let password = ''
|
||||
let otp = ''
|
||||
let incorrectCredentials = false
|
||||
let otpInputVisible = false
|
||||
let busy = false
|
||||
|
||||
let authState = ApiAuthState.NotStarted
|
||||
|
||||
let ssoProvidersPromise = api.getSsoProviders()
|
||||
|
||||
const nextURL = new URLSearchParams(location.search).get('next')
|
||||
const serverErrorMessage = new URLSearchParams(location.search).get('login_error')
|
||||
|
||||
async function init () {
|
||||
authState = (await api.getAuthState()).state
|
||||
continueWithState()
|
||||
}
|
||||
|
||||
async function continueWithState () {
|
||||
if (authState === ApiAuthState.SsoNeeded) {
|
||||
const providers = await ssoProvidersPromise
|
||||
if (!providers.length) {
|
||||
// todo
|
||||
}
|
||||
if (providers.length === 1) {
|
||||
startSSO(providers[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login () {
|
||||
busy = true
|
||||
try {
|
||||
|
@ -24,18 +50,23 @@ async function login () {
|
|||
|
||||
async function _login () {
|
||||
error = null
|
||||
incorrectCredentials = false
|
||||
try {
|
||||
await api.login({
|
||||
loginRequest: {
|
||||
username,
|
||||
password,
|
||||
otp: otp || undefined,
|
||||
},
|
||||
})
|
||||
let next = new URLSearchParams(location.search).get('next')
|
||||
if (next) {
|
||||
location.href = next
|
||||
if (authState === ApiAuthState.OtpNeeded) {
|
||||
await api.otpLogin({
|
||||
otpLoginRequest: {
|
||||
otp,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await api.login({
|
||||
loginRequest: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (nextURL) {
|
||||
location.href = nextURL
|
||||
} else {
|
||||
await reloadServerInfo()
|
||||
replace('/')
|
||||
|
@ -45,11 +76,8 @@ async function _login () {
|
|||
const response = err as Response
|
||||
if (response.status === 401) {
|
||||
const failure = LoginFailureResponseFromJSON(await response.json())
|
||||
if (failure.reason === LoginFailureReason.InvalidCredentials) {
|
||||
incorrectCredentials = true
|
||||
} else if (failure.reason === LoginFailureReason.OtpNeeded) {
|
||||
presentOTPInput()
|
||||
}
|
||||
authState = failure.state
|
||||
continueWithState()
|
||||
} else {
|
||||
error = new Error(await response.text())
|
||||
}
|
||||
|
@ -59,71 +87,117 @@ async function _login () {
|
|||
}
|
||||
}
|
||||
|
||||
function presentOTPInput () {
|
||||
otpInputVisible = true
|
||||
}
|
||||
|
||||
function onInputKey (event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
async function startSSO (provider: SsoProviderDescription) {
|
||||
busy = true
|
||||
try {
|
||||
const params = await api.startSso(provider)
|
||||
location.href = params.url
|
||||
} catch {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="mt-5" autocomplete="on">
|
||||
<div class="page-summary-bar">
|
||||
<h1>Welcome</h1>
|
||||
</div>
|
||||
{#await init()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
<form class="mt-5" autocomplete="on">
|
||||
<div class="page-summary-bar">
|
||||
{#if authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed}
|
||||
<h1>Welcome</h1>
|
||||
{:else}
|
||||
<h1>Continue login</h1>
|
||||
{/if}
|
||||
</div>
|
||||
{#if authState === ApiAuthState.OtpNeeded}
|
||||
<FormGroup floating label="One-time password">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={otp}
|
||||
on:keypress={onInputKey}
|
||||
name="otp"
|
||||
autofocus
|
||||
disabled={busy}
|
||||
class="form-control" />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{#if authState === ApiAuthState.NotStarted || authState === ApiAuthState.PasswordNeeded || authState === ApiAuthState.Failed}
|
||||
<FormGroup floating label="Username">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={username}
|
||||
on:keypress={onInputKey}
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
disabled={busy}
|
||||
class="form-control"
|
||||
autofocus />
|
||||
</FormGroup>
|
||||
|
||||
{#if !otpInputVisible}
|
||||
<FormGroup floating label="Username">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={username}
|
||||
on:keypress={onInputKey}
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
disabled={busy}
|
||||
class="form-control"
|
||||
autofocus />
|
||||
</FormGroup>
|
||||
<FormGroup floating label="Password">
|
||||
<input
|
||||
bind:value={password}
|
||||
on:keypress={onInputKey}
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
disabled={busy}
|
||||
class="form-control" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup floating label="Password">
|
||||
<input
|
||||
bind:value={password}
|
||||
on:keypress={onInputKey}
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
disabled={busy}
|
||||
class="form-control" />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
<AsyncButton
|
||||
outline
|
||||
class="d-flex align-items-center"
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
click={login}
|
||||
>
|
||||
Login
|
||||
<Fa class="ms-2" icon={faArrowRight} />
|
||||
</AsyncButton>
|
||||
|
||||
{#if otpInputVisible}
|
||||
<FormGroup floating label="One-time password">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={otp}
|
||||
on:keypress={onInputKey}
|
||||
name="otp"
|
||||
autofocus
|
||||
disabled={busy}
|
||||
class="form-control" />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{#if authState === ApiAuthState.Failed}
|
||||
<Alert color="danger">Incorrect credentials</Alert>
|
||||
{/if}
|
||||
{#if serverErrorMessage}
|
||||
<Alert color="danger">{serverErrorMessage}</Alert>
|
||||
{/if}
|
||||
{#if error}
|
||||
<Alert color="danger">{error}</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<AsyncButton
|
||||
outline
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
click={login}
|
||||
>Login</AsyncButton>
|
||||
|
||||
{#if incorrectCredentials}
|
||||
<Alert color="danger">Incorrect credentials</Alert>
|
||||
{/if}
|
||||
{#if error}
|
||||
<Alert color="danger">{error}</Alert>
|
||||
{/if}
|
||||
</form>
|
||||
{#await ssoProvidersPromise then ssoProviders}
|
||||
{#if authState === ApiAuthState.SsoNeeded || authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed}
|
||||
<div class="mt-5">
|
||||
{#each ssoProviders as ssoProvider}
|
||||
<button
|
||||
class="btn d-flex align-items-center w-100 mb-2 btn-outline-primary"
|
||||
disabled={busy}
|
||||
on:click={() => startSSO(ssoProvider)}
|
||||
>
|
||||
<span class="m-auto">
|
||||
{#if ssoProvider.kind === SsoProviderKind.Google}
|
||||
<Fa fw class="me-2" icon={faGoogle} />
|
||||
{/if}
|
||||
{#if ssoProvider.kind === SsoProviderKind.Microsoft || ssoProvider.kind === SsoProviderKind.Azure}
|
||||
<Fa fw class="me-2" icon={faMicrosoft} />
|
||||
{/if}
|
||||
{#if ssoProvider.kind === SsoProviderKind.Apple}
|
||||
<Fa fw class="me-2" icon={faApple} />
|
||||
{/if}
|
||||
{ssoProvider.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
{/await}
|
||||
|
|
|
@ -41,6 +41,53 @@
|
|||
"operationId": "login"
|
||||
}
|
||||
},
|
||||
"/auth/otp": {
|
||||
"post": {
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OtpLoginRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginFailureResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operationId": "otpLogin"
|
||||
}
|
||||
},
|
||||
"/auth/state": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthStateResponseInternal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operationId": "getAuthState"
|
||||
}
|
||||
},
|
||||
"/auth/logout": {
|
||||
"post": {
|
||||
"responses": {
|
||||
|
@ -87,10 +134,104 @@
|
|||
},
|
||||
"operationId": "get_targets"
|
||||
}
|
||||
},
|
||||
"/sso/providers": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SsoProviderDescription"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operationId": "get_sso_providers"
|
||||
}
|
||||
},
|
||||
"/sso/return": {
|
||||
"get": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"deprecated": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"307": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"operationId": "return_to_sso"
|
||||
}
|
||||
},
|
||||
"/sso/providers/{name}/start": {
|
||||
"get": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"deprecated": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StartSsoResponseParams"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"operationId": "start_sso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ApiAuthState": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"NotStarted",
|
||||
"Failed",
|
||||
"PasswordNeeded",
|
||||
"OtpNeeded",
|
||||
"SsoNeeded",
|
||||
"Success"
|
||||
]
|
||||
},
|
||||
"AuthStateResponseInternal": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"state": {
|
||||
"$ref": "#/components/schemas/ApiAuthState"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Info": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
@ -119,21 +260,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"LoginFailureReason": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"InvalidCredentials",
|
||||
"OtpNeeded"
|
||||
]
|
||||
},
|
||||
"LoginFailureResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"reason"
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"reason": {
|
||||
"$ref": "#/components/schemas/LoginFailureReason"
|
||||
"state": {
|
||||
"$ref": "#/components/schemas/ApiAuthState"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -149,7 +283,15 @@
|
|||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"OtpLoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"otp"
|
||||
],
|
||||
"properties": {
|
||||
"otp": {
|
||||
"type": "string"
|
||||
}
|
||||
|
@ -172,6 +314,46 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"SsoProviderDescription": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"label",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/SsoProviderKind"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SsoProviderKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Google",
|
||||
"Apple",
|
||||
"Microsoft",
|
||||
"Azure",
|
||||
"Custom"
|
||||
]
|
||||
},
|
||||
"StartSsoResponseParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Target": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
@ -48,24 +48,31 @@
|
|||
resolved "https://registry.yarnpkg.com/@fontsource/work-sans/-/work-sans-4.5.7.tgz#e8d070896af8d751ca4064e9b0dd134faad3536b"
|
||||
integrity sha512-DlVEYsShbL0ZUV96yPhie6rJN3eeCta4iI6UbLdbLptlLnkoryfbMIqeQLe+o7OsIoMNWqHTunKMW0x1BmUNpw==
|
||||
|
||||
"@fortawesome/fontawesome-common-types@6.1.1":
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.1.tgz#7dc996042d21fc1ae850e3173b5c67b0549f9105"
|
||||
integrity sha512-wVn5WJPirFTnzN6tR95abCx+ocH+3IFLXAgyavnf9hUmN0CfWoDjPT/BAWsUVwSlYYVBeCLJxaqi7ZGe4uSjBA==
|
||||
"@fortawesome/fontawesome-common-types@6.1.2":
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.2.tgz#c1095b1bbabf19f37f9ff0719db38d92a410bcfe"
|
||||
integrity sha512-wBaAPGz1Awxg05e0PBRkDRuTsy4B3dpBm+zreTTyd9TH4uUM27cAL4xWyWR0rLJCrRwzVsQ4hF3FvM6rqydKPA==
|
||||
|
||||
"@fortawesome/free-regular-svg-icons@^6.1.1":
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.1.1.tgz#3f2f58262a839edf0643cbacee7a8a8230061c98"
|
||||
integrity sha512-xXiW7hcpgwmWtndKPOzG+43fPH7ZjxOaoeyooptSztGmJxCAflHZxXNK0GcT0uEsR4jTGQAfGklDZE5NHoBhKg==
|
||||
"@fortawesome/free-brands-svg-icons@^6.1.2":
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.1.2.tgz#14160348b8ad5986b3805797dc4377a96e0014d9"
|
||||
integrity sha512-b2eMfXQBsSxh52pcPtYchURQs6BWNh3zVTG8XH8Lv6V4kDhEg7D0kHN+K1SZniDiPb/e5tBlaygsinMUvetITA==
|
||||
dependencies:
|
||||
"@fortawesome/fontawesome-common-types" "6.1.1"
|
||||
"@fortawesome/fontawesome-common-types" "6.1.2"
|
||||
|
||||
"@fortawesome/free-solid-svg-icons@^6.1.1":
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.1.1.tgz#3369e673f8fe8be2fba30b1ec274d47490a830a6"
|
||||
integrity sha512-0/5exxavOhI/D4Ovm2r3vxNojGZioPwmFrKg0ZUH69Q68uFhFPs6+dhAToh6VEQBntxPRYPuT5Cg1tpNa9JUPg==
|
||||
"@fortawesome/free-regular-svg-icons@^6.1.2":
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.1.2.tgz#9f04009098addcc11d0d185126f058ed042c3099"
|
||||
integrity sha512-xR4hA+tAwsaTHGfb+25H1gVU/aJ0Rzu+xIUfnyrhaL13yNQ7TWiI2RvzniAaB+VGHDU2a+Pk96Ve+pkN3/+TTQ==
|
||||
dependencies:
|
||||
"@fortawesome/fontawesome-common-types" "6.1.1"
|
||||
"@fortawesome/fontawesome-common-types" "6.1.2"
|
||||
|
||||
"@fortawesome/free-solid-svg-icons@^6.1.2":
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.1.2.tgz#491d668b8a6603698d0ce1ac620f66fd22b74c84"
|
||||
integrity sha512-lTgZz+cMpzjkHmCwOG3E1ilUZrnINYdqMmrkv30EC3XbRsGlbIOL8H9LaNp5SV4g0pNJDfQ4EdTWWaMvdwyLiQ==
|
||||
dependencies:
|
||||
"@fortawesome/fontawesome-common-types" "6.1.2"
|
||||
|
||||
"@humanwhocodes/config-array@^0.9.2":
|
||||
version "0.9.3"
|
||||
|
@ -2258,9 +2265,9 @@ svelte-check@^2.7.2:
|
|||
typescript "*"
|
||||
|
||||
svelte-fa@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/svelte-fa/-/svelte-fa-3.0.1.tgz#a8f64031c25e13bf09670152fb631f274ad127a1"
|
||||
integrity sha512-p0+sp0Zzcjzqi/3cg4TsGG8MO1DE6vgNxUqcELZXMvkMgad/1VFTSKZ9QRkQMWBcIHsu3IEr16HldQFU4bJQ2g==
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/svelte-fa/-/svelte-fa-3.0.3.tgz#49aa8627725b2533fb4d109b39cd8e64f7e54b7c"
|
||||
integrity sha512-GIikJjcVCD+5Y/x9hZc2R4gvuA0gVftacuWu1a+zVQWSFjFYZ+hhU825x+QNs2slsppfrgmFiUyU9Sz9gj4Rdw==
|
||||
|
||||
svelte-hmr@^0.14.12:
|
||||
version "0.14.12"
|
||||
|
|
Loading…
Reference in a new issue