OIDC login support (#222)

This commit is contained in:
Eugene 2022-08-05 20:04:40 +02:00 committed by GitHub
parent 83442ea350
commit f7bb12e44d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1676 additions and 336 deletions

View file

@ -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
View file

@ -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"

View file

@ -9,6 +9,7 @@ members = [
"warpgate-protocol-http",
"warpgate-protocol-mysql",
"warpgate-protocol-ssh",
"warpgate-sso",
"warpgate-web",
]
default-members = ["warpgate"]

View file

@ -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.

View file

@ -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}}

View file

@ -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"

View 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,
}
}
}

View 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::*;

View 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
}
}

View 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(),
}
}
}

View 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);
}
}
}

View file

@ -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(),

View file

@ -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(

View file

@ -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,

View file

@ -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,
})
}
}

View file

@ -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"

View file

@ -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,

View file

@ -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,
)
}

View 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 })))
}
}

View 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"))
}
}

View file

@ -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(())
}

View file

@ -1,5 +1,5 @@
#![feature(type_alias_impl_trait, let_else, try_blocks)]
mod api;
pub mod api;
mod catchall;
mod common;
mod error;

View file

@ -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)]

View file

@ -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 } => {

View file

@ -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
View 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
View 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
View 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
View 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::*;

View 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(),
})
}
}

View 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
View 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(),
})
}
}

View file

@ -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",

View file

@ -22,6 +22,7 @@ async function _click () {
<Button
on:click={_click}
class={$$props.class}
outline={outline}
color={color}
type={type}

View file

@ -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}

View file

@ -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": [

View file

@ -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"