Draft: SSO login (OAuth 2.0 + OpenID Connect) #1012

Open
avdb13 wants to merge 11 commits from oidc into next
6 changed files with 137 additions and 227 deletions
Showing only changes of commit 03d312ca68 - Show all commits

89
Cargo.lock generated
View file

@ -540,7 +540,6 @@ dependencies = [
"jsonwebtoken", "jsonwebtoken",
"lazy_static", "lazy_static",
"lru-cache", "lru-cache",
"macaroon",
"nix", "nix",
"num_cpus", "num_cpus",
"openidconnect", "openidconnect",
@ -845,19 +844,10 @@ dependencies = [
"digest", "digest",
"elliptic-curve", "elliptic-curve",
"rfc6979", "rfc6979",
"signature 2.2.0", "signature",
"spki", "spki",
] ]
[[package]]
name = "ed25519"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
dependencies = [
"signature 1.6.4",
]
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@ -865,7 +855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [ dependencies = [
"pkcs8", "pkcs8",
"signature 2.2.0", "signature",
] ]
[[package]] [[package]]
@ -875,7 +865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519 2.2.3", "ed25519",
"rand_core", "rand_core",
"serde", "serde",
"sha2", "sha2",
@ -1678,18 +1668,6 @@ dependencies = [
"zstd-sys", "zstd-sys",
] ]
[[package]]
name = "libsodium-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b779387cd56adfbc02ea4a668e704f729be8d6a6abd2c27ca5ee537849a92fd"
dependencies = [
"cc",
"libc",
"pkg-config",
"walkdir",
]
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.26.0" version = "0.26.0"
@ -1753,19 +1731,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "macaroon"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b15778101dd1d3a58a95fd2af33821b38bc44a408bcf03e0e5e194f42c08050"
dependencies = [
"base64 0.13.1",
"log",
"serde",
"serde_json",
"sodiumoxide",
]
[[package]] [[package]]
name = "maplit" name = "maplit"
version = "1.0.2" version = "1.0.2"
@ -2610,7 +2575,7 @@ dependencies = [
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core",
"signature 2.2.0", "signature",
"spki", "spki",
"subtle", "subtle",
"zeroize", "zeroize",
@ -2907,15 +2872,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.23" version = "0.1.23"
@ -3184,12 +3140,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "1.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
[[package]] [[package]]
name = "signature" name = "signature"
version = "2.2.0" version = "2.2.0"
@ -3243,18 +3193,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "sodiumoxide"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e26be3acb6c2d9a7aac28482586a7856436af4cfe7100031d219de2d2ecb0028"
dependencies = [
"ed25519 1.5.3",
"libc",
"libsodium-sys",
"serde",
]
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.5.2" version = "0.5.2"
@ -3899,16 +3837,6 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@ -4040,15 +3968,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"

View file

@ -55,8 +55,6 @@ bytes = "1.4.0"
http = "0.2.9" http = "0.2.9"
# Used to find data directory for default db path # Used to find data directory for default db path
directories = "4.0.1" directories = "4.0.1"
# Used for SSO authorization
macaroon = "0.3.0"
# Used for ruma wrapper # Used for ruma wrapper
serde_json = { version = "1.0.96", features = ["raw_value"] } serde_json = { version = "1.0.96", features = ["raw_value"] }
# Used for appservice registration files # Used for appservice registration files

View file

@ -1,20 +1,20 @@
use crate::{ use crate::{
service::sso::{templates, Provider, COOKIE_STATE_EXPIRATION_SECS}, service::sso::{macaroon::Macaroon, templates, COOKIE_STATE_EXPIRATION_SECS},
services, Error, Ruma, RumaResponse, services, Error, Ruma,
}; };
use askama::Template; use askama::Template;
use axum::{body::Full, response::IntoResponse}; use axum::{body::Full, response::IntoResponse};
use axum_extra::extract::cookie::{Cookie, SameSite}; use axum_extra::extract::cookie::{Cookie, SameSite};
use bytes::BytesMut; use bytes::BytesMut;
use http::StatusCode; use http::{HeaderValue, StatusCode};
use macaroon::ByteString; use openidconnect::{
use openidconnect::{reqwest::{http_client, async_http_client}, AuthorizationCode, CsrfToken, TokenResponse}; AuthorizationCode, CsrfToken,
};
use ruma::api::{ use ruma::api::{
client::{error::ErrorKind, session}, client::{error::ErrorKind, session},
OutgoingResponse, OutgoingResponse,
}; };
use serde::Deserialize; use serde::Deserialize;
use time::macros::format_description;
/// # `GET /_matrix/client/v3/login/sso/redirect` /// # `GET /_matrix/client/v3/login/sso/redirect`
/// ///
@ -50,11 +50,13 @@ pub async fn get_sso_redirect_with_provider(
let location = Some(body.idp_id.clone()); let location = Some(body.idp_id.clone());
let (url, nonce, cookie) = match services().sso.find_one(&body.idp_id).map(|provider| provider.handle_redirect(body.redirect_url.as_deref().unwrap_or_default())) { let (url, nonce, cookie) =
Ok(fut)=> fut.await, match services().sso.find_one(&body.idp_id).map(|provider| {
Err(e)=> return e.into_response(), provider.handle_redirect(body.redirect_url.unwrap())
}; }) {
Ok(fut) => fut.await,
Err(e) => return e.into_response(),
};
let cookie = Cookie::build("openid-state", cookie) let cookie = Cookie::build("openid-state", cookie)
.path("/_conduit/client/sso") .path("/_conduit/client/sso")
@ -107,8 +109,11 @@ fn get_sso_fallback_template(redirect_url: &str) -> axum::response::Response {
pub struct Callback { pub struct Callback {
pub code: AuthorizationCode, pub code: AuthorizationCode,
pub state: CsrfToken, pub state: CsrfToken,
pub verifier: String,
} }
pub struct Session {}
/// # `GET /_conduit/client/oidc/callback` /// # `GET /_conduit/client/oidc/callback`
/// ///
/// Verify the response received from the identity provider. /// Verify the response received from the identity provider.
@ -117,9 +122,16 @@ pub async fn get_sso_callback(
cookie: axum::extract::TypedHeader<axum::headers::Cookie>, cookie: axum::extract::TypedHeader<axum::headers::Cookie>,
axum::extract::Query(callback): axum::extract::Query<Callback>, axum::extract::Query(callback): axum::extract::Query<Callback>,
) -> axum::response::Response { ) -> axum::response::Response {
// TODO let clear_cookie = Cookie::build("openid-state", "")
.path("/_conduit/client/sso")
.finish()
.to_string();
let Callback { code, state } = callback; let Callback {
code,
state,
verifier,
} = callback;
let Some(cookie) = cookie.get("openid-state") else { let Some(cookie) = cookie.get("openid-state") else {
return Error::BadRequest( return Error::BadRequest(
@ -129,26 +141,24 @@ pub async fn get_sso_callback(
.into_response(); .into_response();
}; };
let provider = match Provider::verify_macaroon(cookie.as_bytes(), state) let macaroon = match Macaroon::verify(cookie, state.secret()) {
.and_then(|macaroon| services().sso.find_one(macaroon.identifier().into())) Ok(macaroon) => macaroon,
{
Ok(provider) => provider,
Err(error) => return error.into_response(), Err(error) => return error.into_response(),
}; };
let provider = match services().sso.find_one(macaroon.idp_id.as_ref()) {
Ok(provider) => provider,
Err(error) => return error.into_response(),
};
let session = serde_json::to_string(cookie).unwrap();
let user_info = provider.handle_callback(code, macaroon.nonce).await;
(
let cookie = Cookie::build("openid-state", "") axum::TypedHeader(axum::headers::Location(
.path("/_conduit/client/sso") HeaderValue::from_str(clear_cookie.as_str()).unwrap(),
.finish() )),
.to_string(); "Hello, World!",
)
let user_info = provider.handle_callback(code, nonce); .into_response()
// if let Some(verifier) = pkce {
// macaroon.add_first_party_caveat(format!("verifier = {}", verifier).into());
// }
(TypedHeader(ContentType::text_utf8()), "Hello, World!").into_response()
} }

View file

@ -75,7 +75,7 @@ pub struct Service {
pub rotate: RotationHandler, pub rotate: RotationHandler,
pub shutdown: AtomicBool, pub shutdown: AtomicBool,
pub macaroon: Option<macaroon::MacaroonKey>, pub macaroon_key: Option<String>,
} }
/// Handles "rotation" of long-polling requests. "Rotation" in this context is similar to "rotation" of log files and the like. /// Handles "rotation" of long-polling requests. "Rotation" in this context is similar to "rotation" of log files and the like.
@ -183,11 +183,6 @@ impl Service {
// Experimental, partially supported room versions // Experimental, partially supported room versions
let unstable_room_versions = vec![RoomVersionId::V3, RoomVersionId::V4, RoomVersionId::V5]; let unstable_room_versions = vec![RoomVersionId::V3, RoomVersionId::V4, RoomVersionId::V5];
let macaroon = config
.macaroon_key
.as_ref()
.map(|s| macaroon::MacaroonKey::generate(s.as_bytes()));
let mut s = Self { let mut s = Self {
db, db,
config, config,
@ -218,7 +213,7 @@ impl Service {
sync_receivers: RwLock::new(HashMap::new()), sync_receivers: RwLock::new(HashMap::new()),
rotate: RotationHandler::new(), rotate: RotationHandler::new(),
shutdown: AtomicBool::new(false), shutdown: AtomicBool::new(false),
macaroon, macaroon_key: config.macaroon_key.clone(),
}; };
fs::create_dir_all(s.get_media_folder())?; fs::create_dir_all(s.get_media_folder())?;

View file

@ -0,0 +1,44 @@
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use openidconnect::{Nonce, PkceCodeVerifier, RedirectUrl, CsrfToken};
use serde::{Deserialize, Serialize};
use crate::Error;
#[derive(Serialize, Deserialize)]
pub struct Macaroon {
pub idp_id: String,
pub nonce: Nonce,
pub csrf: CsrfToken,
pub redirect_url: Option<RedirectUrl>,
pub pkce_verifier: Option<PkceCodeVerifier>,
pub time: i64,
}
impl Macaroon {
pub fn encode(&self, macaroon: &str) -> Result<String, jsonwebtoken::errors::Error> {
jsonwebtoken::encode(
&Header::default(),
self,
&EncodingKey::from_secret(macaroon.as_bytes()),
)
}
pub fn verify(token: &str, macaroon: &str) -> Result<Self, Error> {
let decoded = jsonwebtoken::decode::<Self>(
token,
&DecodingKey::from_secret(macaroon.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.map_err(|_| {
Error::BadRequest(
ruma::api::client::error::ErrorKind::Unauthorized,
"macaroon decoding",
)
})?;
Err(Error::BadRequest(
ruma::api::client::error::ErrorKind::Unauthorized,
"macaroon invalid",
))
}
}

View file

@ -1,24 +1,32 @@
use std::sync::Arc; use std::sync::Arc;
mod session;
use futures_util::future::{self}; use futures_util::future::{self};
use macaroon::{Macaroon, Verifier};
use openidconnect::{ use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreGenderClaim, CoreProviderMetadata}, core::{
CoreAuthenticationFlow, CoreClient, CoreGenderClaim, CoreIdTokenClaims,
CoreProviderMetadata, CoreUserInfoClaims,
},
reqwest::async_http_client, reqwest::async_http_client,
AccessTokenHash, AdditionalClaims, AuthUrl, AuthorizationCode, ClientId, ClientSecret, AccessTokenHash, AdditionalClaims, AuthUrl, AuthorizationCode, ClientId, ClientSecret,
CsrfToken, IssuerUrl, Nonce, NonceVerifier, OAuth2TokenResponse, PkceCodeChallenge, CsrfToken, IssuerUrl, Nonce, NonceVerifier, OAuth2TokenResponse, PkceCodeChallenge,
RedirectUrl, Scope, SubjectIdentifier, TokenResponse, TokenUrl, UserInfoClaims, UserInfoUrl, PkceCodeVerifier, RedirectUrl, Scope, SubjectIdentifier, TokenResponse, TokenUrl,
UserInfoClaims, UserInfoUrl,
}; };
use ruma::api::client::{error::ErrorKind, session::get_login_types::v3::IdentityProvider}; use ruma::api::client::{error::ErrorKind, session::get_login_types::v3::IdentityProvider};
use time::macros::format_description; use time::{macros::format_description, OffsetDateTime};
use crate::{ use crate::{
config::{ClientConfig, DiscoveryConfig as Discovery, ProviderConfig}, config::{ClientConfig, DiscoveryConfig as Discovery, ProviderConfig},
services, Config, Error, services, Config, Error,
}; };
use self::macaroon::Macaroon;
pub const COOKIE_STATE_EXPIRATION_SECS: i64 = 60 * 60; pub const COOKIE_STATE_EXPIRATION_SECS: i64 = 60 * 60;
pub mod macaroon;
pub mod templates; pub mod templates;
pub struct Service { pub struct Service {
@ -128,116 +136,54 @@ impl Provider {
Ok(Arc::new(config)) Ok(Arc::new(config))
} }
pub async fn handle_redirect(&self, redirect_url: &str) -> (url::Url, String, String) { pub async fn handle_redirect(&self, redirect_url: &RedirectUrl) -> (url::Url, String, String) {
let client = self.client.clone(); let client = self.client.clone();
let scopes = self.scopes.iter().map(ToOwned::to_owned).map(Scope::new); let scopes = self.scopes.iter().map(ToOwned::to_owned).map(Scope::new);
let mut req = client let mut req = client
.authorize_url( .authorize_url(
CoreAuthenticationFlow::Implicit(true), CoreAuthenticationFlow::Implicit(true),
|| CsrfToken::new_random_len(36), || CsrfToken::new_random_len(48),
|| Nonce::new_random_len(36), || Nonce::new_random_len(48),
) )
.add_scopes(scopes); .add_scopes(scopes);
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256(); let pkce_verifier = match self.pkce {
if let Some(true) = self.pkce { Some(true) => {
req = req.set_pkce_challenge(challenge); let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
} req = req.set_pkce_challenge(challenge);
Some(verifier)
}
_ => None,
};
let (url, csrf, nonce) = req.url(); let (url, csrf, nonce) = req.url();
let cookie = self.generate_macaroon( let key = services()
self.inner.id.as_str(), .globals
csrf.secret(), .macaroon_key
nonce.secret(), .as_deref()
redirect_url, .expect("macaroon key")
self.pkce.map(|_| verifier.secret().as_str()), .to_owned();
); let cookie = Macaroon {
idp_id: self.inner.id.clone(),
csrf,
nonce: nonce.clone(),
time: OffsetDateTime::now_utc().unix_timestamp(),
redirect_url: Some(redirect_url.clone()),
pkce_verifier,
};
let cookie = cookie.encode(&key).expect("bad key");
(url, nonce.secret().to_owned(), cookie) (url, nonce.secret().to_owned(), cookie)
} }
pub fn generate_macaroon(
&self,
idp_id: &str,
state: &str,
nonce: &str,
redirect_url: &str,
pkce: Option<&str>,
) -> String {
let key = services().globals.macaroon.unwrap();
let mut macaroon = Macaroon::create(None, &key, idp_id.into()).unwrap();
let expires = (time::OffsetDateTime::now_utc()
+ time::Duration::seconds(COOKIE_STATE_EXPIRATION_SECS))
.to_string();
let idp_id = self.inner.id.as_str();
for caveat in [
format!("idp_id = {idp_id}"),
format!("state = {state}"),
format!("nonce = {nonce}"),
format!("redirect_url = {redirect_url}"),
format!("time < {expires}"),
] {
macaroon.add_first_party_caveat(caveat.into());
}
if let Some(verifier) = pkce {
macaroon.add_first_party_caveat(format!("verifier = {}", verifier).into());
}
macaroon.serialize(macaroon::Format::V2).unwrap()
}
pub fn verify_macaroon(cookie: &[u8], state: CsrfToken) -> Result<Macaroon, Error> {
let mut verifier = Verifier::default();
let macaroon = Macaroon::deserialize(cookie).map_err(|e| {
Error::BadRequest(ErrorKind::BadJson, "Could not deserialize SSO macaroon")
})?;
verifier.satisfy_exact(format!("state = {}", state.secret()).into());
// let verification = |s: &ByteString, id: &str| {
// s.0.starts_with(format!("{id} =").as_bytes()); // TODO
// };
verifier.satisfy_general(|s| s.0.starts_with(b"idp_id ="));
verifier.satisfy_general(|s| s.0.starts_with(b"nonce ="));
verifier.satisfy_general(|s| s.0.starts_with(b"redirect_url ="));
verifier.satisfy_general(|s| {
let format_desc = format_description!(
"[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour \
sign:mandatory]:[offset_minute]:[offset_second]"
);
let now = time::OffsetDateTime::now_utc();
time::OffsetDateTime::parse(std::str::from_utf8(&s.0).unwrap(), format_desc)
.map(|expires| now < expires)
.unwrap_or(false)
});
let key = services().globals.macaroon.unwrap();
verifier
.verify(&macaroon, &key, Default::default())
.map_err(|e| {
Error::BadRequest(ErrorKind::Unauthorized, "Macaroon verification failed")
})?;
Ok(macaroon)
}
pub async fn handle_callback<Claims: AdditionalClaims>( pub async fn handle_callback<Claims: AdditionalClaims>(
&self, &self,
code: AuthorizationCode, code: AuthorizationCode,
nonce: Nonce, nonce: Nonce,
) -> Result<UserInfoClaims<Claims, CoreGenderClaim>, Error> { ) -> Result<(), Error> {
let resp = self let resp = self
.client .client
.exchange_code(code) .exchange_code(code)
@ -260,14 +206,12 @@ impl Provider {
} }
} }
let Ok(req) = self.client.user_info( // match self.client.user_info(
resp.access_token().to_owned(), // resp.access_token().to_owned(),
self.subject_claim.clone().map(SubjectIdentifier::new), // self.subject_claim.clone().map(SubjectIdentifier::new),
) else { // ).map(|req| req.request_async(async_http_client)) {
resp.extra_fields(); // Err(e) => Ok(claims),
panic!() // Ok(req) => req.await,
}; // }
Ok(req.request_async(async_http_client).await.unwrap())
} }
} }