mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2024-11-28 09:07:32 +00:00
OpenID Connect implementation (closes #298)
Some checks are pending
trivy / Check (push) Waiting to run
Some checks are pending
trivy / Check (push) Waiting to run
This commit is contained in:
parent
1fed40a926
commit
6a5f963b43
18 changed files with 899 additions and 149 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
@ -581,6 +581,22 @@ dependencies = [
|
|||
"syn 2.0.77",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "biscuit"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e28fc7c56c61743a01d0d1b73e4fed68b8a4f032ea3a2d4bb8c6520a33fc05a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"data-encoding",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"ring 0.17.8",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
|
@ -1043,6 +1059,7 @@ dependencies = [
|
|||
"arc-swap",
|
||||
"base64 0.22.1",
|
||||
"bincode",
|
||||
"biscuit",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"decancer",
|
||||
|
@ -1067,6 +1084,8 @@ dependencies = [
|
|||
"opentelemetry-otlp",
|
||||
"opentelemetry-semantic-conventions",
|
||||
"opentelemetry_sdk",
|
||||
"p256",
|
||||
"p384",
|
||||
"parking_lot",
|
||||
"pem",
|
||||
"privdrop",
|
||||
|
@ -1078,6 +1097,7 @@ dependencies = [
|
|||
"regex",
|
||||
"reqwest 0.12.7",
|
||||
"ring 0.17.8",
|
||||
"rsa",
|
||||
"rustls 0.23.13",
|
||||
"rustls-pemfile 2.1.3",
|
||||
"rustls-pki-types",
|
||||
|
@ -5838,6 +5858,7 @@ version = "1.0.128"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
|
||||
dependencies = [
|
||||
"indexmap 2.5.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
|
@ -6418,6 +6439,7 @@ dependencies = [
|
|||
"ahash 0.8.11",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"biscuit",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"common",
|
||||
|
|
|
@ -62,6 +62,10 @@ xxhash-rust = { version = "0.8.5", features = ["xxh3"] }
|
|||
psl = "2"
|
||||
dashmap = "6.0"
|
||||
aes-gcm-siv = "0.11.1"
|
||||
biscuit = "0.7.0"
|
||||
rsa = "0.9.2"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
p384 = { version = "0.13", features = ["ecdh"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
privdrop = "0.5.3"
|
||||
|
|
330
crates/common/src/auth/oauth/config.rs
Normal file
330
crates/common/src/auth/oauth/config.rs
Normal file
|
@ -0,0 +1,330 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use biscuit::{
|
||||
jwa::{Algorithm, SignatureAlgorithm},
|
||||
jwk::{
|
||||
AlgorithmParameters, CommonParameters, EllipticCurve, EllipticCurveKeyParameters,
|
||||
EllipticCurveKeyType, JWKSet, OctetKeyParameters, OctetKeyType, PublicKeyUse,
|
||||
RSAKeyParameters, RSAKeyType, JWK,
|
||||
},
|
||||
jws::Secret,
|
||||
};
|
||||
use ring::signature::{self, KeyPair};
|
||||
use rsa::{pkcs1::DecodeRsaPublicKey, traits::PublicKeyParts, RsaPublicKey};
|
||||
use store::rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
use utils::config::Config;
|
||||
use x509_parser::num_bigint::BigUint;
|
||||
|
||||
use crate::{
|
||||
config::{build_ecdsa_pem, build_rsa_keypair},
|
||||
manager::webadmin::Resource,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OAuthConfig {
|
||||
pub oauth_key: String,
|
||||
pub oauth_expiry_user_code: u64,
|
||||
pub oauth_expiry_auth_code: u64,
|
||||
pub oauth_expiry_token: u64,
|
||||
pub oauth_expiry_refresh_token: u64,
|
||||
pub oauth_expiry_refresh_token_renew: u64,
|
||||
pub oauth_max_auth_attempts: u32,
|
||||
|
||||
pub oidc_expiry_id_token: u64,
|
||||
pub oidc_signing_secret: Secret,
|
||||
pub oidc_signature_algorithm: SignatureAlgorithm,
|
||||
pub oidc_jwks: Resource<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl OAuthConfig {
|
||||
pub fn parse(config: &mut Config) -> Self {
|
||||
let oidc_signature_algorithm = match config.value("oauth.oidc.signature-algorithm") {
|
||||
Some(alg) => match alg.to_uppercase().as_str() {
|
||||
"HS256" => SignatureAlgorithm::HS256,
|
||||
"HS384" => SignatureAlgorithm::HS384,
|
||||
"HS512" => SignatureAlgorithm::HS512,
|
||||
|
||||
"RS256" => SignatureAlgorithm::RS256,
|
||||
"RS384" => SignatureAlgorithm::RS384,
|
||||
"RS512" => SignatureAlgorithm::RS512,
|
||||
|
||||
"ES256" => SignatureAlgorithm::ES256,
|
||||
"ES384" => SignatureAlgorithm::ES384,
|
||||
|
||||
"PS256" => SignatureAlgorithm::PS256,
|
||||
"PS384" => SignatureAlgorithm::PS384,
|
||||
"PS512" => SignatureAlgorithm::PS512,
|
||||
_ => {
|
||||
config.new_parse_error(
|
||||
"oauth.oidc.signature-algorithm",
|
||||
format!("Invalid OIDC signature algorithm: {}", alg),
|
||||
);
|
||||
SignatureAlgorithm::HS256
|
||||
}
|
||||
},
|
||||
None => SignatureAlgorithm::HS256,
|
||||
};
|
||||
|
||||
let rand_key = thread_rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
.into_bytes();
|
||||
|
||||
let (oidc_signing_secret, algorithm) = match oidc_signature_algorithm {
|
||||
SignatureAlgorithm::None
|
||||
| SignatureAlgorithm::HS256
|
||||
| SignatureAlgorithm::HS384
|
||||
| SignatureAlgorithm::HS512 => {
|
||||
let key = config
|
||||
.value("oauth.oidc.signature-key")
|
||||
.map(|s| s.to_string().into_bytes())
|
||||
.unwrap_or(rand_key);
|
||||
|
||||
(
|
||||
Secret::Bytes(key.clone()),
|
||||
AlgorithmParameters::OctetKey(OctetKeyParameters {
|
||||
key_type: OctetKeyType::Octet,
|
||||
value: key,
|
||||
}),
|
||||
)
|
||||
}
|
||||
SignatureAlgorithm::RS256
|
||||
| SignatureAlgorithm::RS384
|
||||
| SignatureAlgorithm::RS512
|
||||
| SignatureAlgorithm::PS256
|
||||
| SignatureAlgorithm::PS384
|
||||
| SignatureAlgorithm::PS512 => parse_rsa_key(config).unwrap_or_else(|| {
|
||||
(
|
||||
Secret::Bytes(rand_key.clone()),
|
||||
AlgorithmParameters::OctetKey(OctetKeyParameters {
|
||||
key_type: OctetKeyType::Octet,
|
||||
value: rand_key,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
SignatureAlgorithm::ES256 | SignatureAlgorithm::ES384 | SignatureAlgorithm::ES512 => {
|
||||
parse_ecdsa_key(config, oidc_signature_algorithm).unwrap_or_else(|| {
|
||||
(
|
||||
Secret::Bytes(rand_key.clone()),
|
||||
AlgorithmParameters::OctetKey(OctetKeyParameters {
|
||||
key_type: OctetKeyType::Octet,
|
||||
value: rand_key,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let oidc_jwks = Resource {
|
||||
content_type: "application/json".into(),
|
||||
contents: serde_json::to_string(&JWKSet {
|
||||
keys: vec![JWK {
|
||||
common: CommonParameters {
|
||||
public_key_use: PublicKeyUse::Signature.into(),
|
||||
algorithm: Algorithm::Signature(oidc_signature_algorithm).into(),
|
||||
key_id: "default".to_string().into(),
|
||||
..Default::default()
|
||||
},
|
||||
algorithm,
|
||||
additional: (),
|
||||
}],
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.into_bytes(),
|
||||
};
|
||||
|
||||
OAuthConfig {
|
||||
oauth_key: config
|
||||
.value("oauth.key")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
thread_rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
}),
|
||||
oauth_expiry_user_code: config
|
||||
.property_or_default::<Duration>("oauth.expiry.user-code", "30m")
|
||||
.unwrap_or_else(|| Duration::from_secs(30 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_auth_code: config
|
||||
.property_or_default::<Duration>("oauth.expiry.auth-code", "10m")
|
||||
.unwrap_or_else(|| Duration::from_secs(10 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_token: config
|
||||
.property_or_default::<Duration>("oauth.expiry.token", "1h")
|
||||
.unwrap_or_else(|| Duration::from_secs(60 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_refresh_token: config
|
||||
.property_or_default::<Duration>("oauth.expiry.refresh-token", "30d")
|
||||
.unwrap_or_else(|| Duration::from_secs(30 * 24 * 60 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_refresh_token_renew: config
|
||||
.property_or_default::<Duration>("oauth.expiry.refresh-token-renew", "4d")
|
||||
.unwrap_or_else(|| Duration::from_secs(4 * 24 * 60 * 60))
|
||||
.as_secs(),
|
||||
oauth_max_auth_attempts: config
|
||||
.property_or_default("oauth.auth.max-attempts", "3")
|
||||
.unwrap_or(10),
|
||||
oidc_expiry_id_token: config
|
||||
.property_or_default::<Duration>("oauth.oidc.expiry.id-token", "15m")
|
||||
.unwrap_or_else(|| Duration::from_secs(15 * 60))
|
||||
.as_secs(),
|
||||
oidc_signing_secret,
|
||||
oidc_signature_algorithm,
|
||||
oidc_jwks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OAuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
oauth_key: Default::default(),
|
||||
oauth_expiry_user_code: Default::default(),
|
||||
oauth_expiry_auth_code: Default::default(),
|
||||
oauth_expiry_token: Default::default(),
|
||||
oauth_expiry_refresh_token: Default::default(),
|
||||
oauth_expiry_refresh_token_renew: Default::default(),
|
||||
oauth_max_auth_attempts: Default::default(),
|
||||
oidc_expiry_id_token: Default::default(),
|
||||
oidc_signing_secret: Secret::Bytes("secret".to_string().into_bytes()),
|
||||
oidc_signature_algorithm: SignatureAlgorithm::HS256,
|
||||
oidc_jwks: Resource {
|
||||
content_type: "application/json".into(),
|
||||
contents: serde_json::to_string(&JWKSet::<()> { keys: vec![] })
|
||||
.unwrap_or_default()
|
||||
.into_bytes(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rsa_key(config: &mut Config) -> Option<(Secret, AlgorithmParameters)> {
|
||||
let rsa_key_pair = match build_rsa_keypair(config.value_require("oauth.oidc.signature-key")?) {
|
||||
Ok(key) => key,
|
||||
Err(err) => {
|
||||
config.new_build_error(
|
||||
"oauth.oidc.signature-key",
|
||||
format!("Failed to build RSA key: {}", err),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let rsa_public_key = match RsaPublicKey::from_pkcs1_der(rsa_key_pair.public_key().as_ref()) {
|
||||
Ok(key) => key,
|
||||
Err(err) => {
|
||||
config.new_build_error(
|
||||
"oauth.oidc.signature-key",
|
||||
format!("Failed to obtain RSA public key: {}", err),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let rsa_key_params = RSAKeyParameters {
|
||||
key_type: RSAKeyType::RSA,
|
||||
n: BigUint::from_bytes_be(&rsa_public_key.n().to_bytes_be()),
|
||||
e: BigUint::from_bytes_be(&rsa_public_key.e().to_bytes_be()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
(
|
||||
Secret::RsaKeyPair(rsa_key_pair.into()),
|
||||
AlgorithmParameters::RSA(rsa_key_params),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn parse_ecdsa_key(
|
||||
config: &mut Config,
|
||||
oidc_signature_algorithm: SignatureAlgorithm,
|
||||
) -> Option<(Secret, AlgorithmParameters)> {
|
||||
let (alg, curve) = match oidc_signature_algorithm {
|
||||
SignatureAlgorithm::ES256 => (
|
||||
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
|
||||
EllipticCurve::P256,
|
||||
),
|
||||
SignatureAlgorithm::ES384 => (
|
||||
&signature::ECDSA_P384_SHA384_FIXED_SIGNING,
|
||||
EllipticCurve::P384,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let ecdsa_key_pair =
|
||||
match build_ecdsa_pem(alg, config.value_require("oauth.oidc.signature-key")?) {
|
||||
Ok(key) => key,
|
||||
Err(err) => {
|
||||
config.new_build_error(
|
||||
"oauth.oidc.signature-key",
|
||||
format!("Failed to build ECDSA key: {}", err),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let ecdsa_public_key = ecdsa_key_pair.public_key().as_ref();
|
||||
|
||||
let (x, y) = match oidc_signature_algorithm {
|
||||
SignatureAlgorithm::ES256 => {
|
||||
let points = match p256::EncodedPoint::from_bytes(ecdsa_public_key) {
|
||||
Ok(points) => points,
|
||||
Err(err) => {
|
||||
config.new_build_error(
|
||||
"oauth.oidc.signature-key",
|
||||
format!("Failed to parse ECDSA key: {}", err),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
points.x().map(|x| x.to_vec()).unwrap_or_default(),
|
||||
points.y().map(|y| y.to_vec()).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
SignatureAlgorithm::ES384 => {
|
||||
let points = match p384::EncodedPoint::from_bytes(ecdsa_public_key) {
|
||||
Ok(points) => points,
|
||||
Err(err) => {
|
||||
config.new_build_error(
|
||||
"oauth.oidc.signature-key",
|
||||
format!("Failed to parse ECDSA key: {}", err),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
points.x().map(|x| x.to_vec()).unwrap_or_default(),
|
||||
points.y().map(|y| y.to_vec()).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let ecdsa_key_params = EllipticCurveKeyParameters {
|
||||
key_type: EllipticCurveKeyType::EC,
|
||||
curve,
|
||||
x,
|
||||
y,
|
||||
d: None,
|
||||
};
|
||||
|
||||
(
|
||||
Secret::EcdsaKeyPair(ecdsa_key_pair.into()),
|
||||
AlgorithmParameters::EllipticCurve(ecdsa_key_params),
|
||||
)
|
||||
.into()
|
||||
}
|
|
@ -4,8 +4,10 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod introspect;
|
||||
pub mod oidc;
|
||||
pub mod token;
|
||||
|
||||
pub const DEVICE_CODE_LEN: usize = 40;
|
||||
|
|
153
crates/common/src/auth/oauth/oidc.rs
Normal file
153
crates/common/src/auth/oauth/oidc.rs
Normal file
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use biscuit::{jws::RegisteredHeader, ClaimsSet, RegisteredClaims, SingleOrMultiple, JWT};
|
||||
use serde::{
|
||||
de::{self, Visitor},
|
||||
Deserialize, Deserializer, Serialize,
|
||||
};
|
||||
use store::write::now;
|
||||
|
||||
use crate::Server;
|
||||
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Userinfo {
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sub: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub given_name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub family_name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub middle_name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nickname: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_username: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub picture: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
|
||||
#[serde(default, deserialize_with = "any_bool")]
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub email_verified: bool,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub zoneinfo: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub locale: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn issue_id_token(
|
||||
&self,
|
||||
subject: impl Into<String>,
|
||||
issuer: impl Into<String>,
|
||||
audience: impl Into<String>,
|
||||
) -> trc::Result<String> {
|
||||
let now = now() as i64;
|
||||
|
||||
JWT::new_decoded(
|
||||
From::from(RegisteredHeader {
|
||||
algorithm: self.core.oauth.oidc_signature_algorithm,
|
||||
key_id: Some("default".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
ClaimsSet::<()> {
|
||||
registered: RegisteredClaims {
|
||||
issuer: Some(issuer.into()),
|
||||
subject: Some(subject.into()),
|
||||
audience: Some(SingleOrMultiple::Single(audience.into())),
|
||||
not_before: Some(now.into()),
|
||||
issued_at: Some(now.into()),
|
||||
expiry: Some((now + self.core.oauth.oidc_expiry_id_token as i64).into()),
|
||||
..Default::default()
|
||||
},
|
||||
private: (),
|
||||
},
|
||||
)
|
||||
.into_encoded(&self.core.oauth.oidc_signing_secret)
|
||||
.map(|token| token.unwrap_encoded().to_string())
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.reason(err)
|
||||
.details("Failed to encode ID token")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn any_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct AnyBoolVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for AnyBoolVisitor {
|
||||
type Value = bool;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a boolean value")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<bool, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
match value {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
_ => Err(E::custom(format!("Unknown boolean: {value}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_bool<E>(self, value: bool) -> Result<bool, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(AnyBoolVisitor)
|
||||
}
|
|
@ -55,7 +55,7 @@ impl Server {
|
|||
String::new()
|
||||
};
|
||||
|
||||
let key = &self.core.jmap.oauth_key;
|
||||
let key = &self.core.oauth.oauth_key;
|
||||
let context = format!(
|
||||
"{} {} {} {}",
|
||||
grant_type.as_str(),
|
||||
|
@ -165,7 +165,7 @@ impl Server {
|
|||
};
|
||||
|
||||
// Build context
|
||||
let key = self.core.jmap.oauth_key.clone();
|
||||
let key = self.core.oauth.oauth_key.clone();
|
||||
let context = format!(
|
||||
"{} {} {} {}",
|
||||
grant_type.as_str(),
|
||||
|
|
|
@ -9,7 +9,6 @@ use std::{str::FromStr, time::Duration};
|
|||
use jmap_proto::request::capability::BaseCapabilities;
|
||||
use mail_parser::HeaderName;
|
||||
use nlp::language::Language;
|
||||
use store::rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
use utils::config::{cron::SimpleCron, utils::ParseValue, Config, Rate};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
|
@ -63,13 +62,6 @@ pub struct JmapConfig {
|
|||
pub web_socket_timeout: Duration,
|
||||
pub web_socket_heartbeat: Duration,
|
||||
|
||||
pub oauth_key: String,
|
||||
pub oauth_expiry_user_code: u64,
|
||||
pub oauth_expiry_auth_code: u64,
|
||||
pub oauth_expiry_token: u64,
|
||||
pub oauth_expiry_refresh_token: u64,
|
||||
pub oauth_expiry_refresh_token_renew: u64,
|
||||
pub oauth_max_auth_attempts: u32,
|
||||
pub fallback_admin: Option<(String, String)>,
|
||||
pub master_user: Option<(String, String)>,
|
||||
|
||||
|
@ -321,39 +313,6 @@ impl JmapConfig {
|
|||
rate_anonymous: config
|
||||
.property_or_default::<Option<Rate>>("jmap.rate-limit.anonymous", "100/1m")
|
||||
.unwrap_or_default(),
|
||||
oauth_key: config
|
||||
.value("oauth.key")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
thread_rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
}),
|
||||
oauth_expiry_user_code: config
|
||||
.property_or_default::<Duration>("oauth.expiry.user-code", "30m")
|
||||
.unwrap_or_else(|| Duration::from_secs(30 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_auth_code: config
|
||||
.property_or_default::<Duration>("oauth.expiry.auth-code", "10m")
|
||||
.unwrap_or_else(|| Duration::from_secs(10 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_token: config
|
||||
.property_or_default::<Duration>("oauth.expiry.token", "1h")
|
||||
.unwrap_or_else(|| Duration::from_secs(60 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_refresh_token: config
|
||||
.property_or_default::<Duration>("oauth.expiry.refresh-token", "30d")
|
||||
.unwrap_or_else(|| Duration::from_secs(30 * 24 * 60 * 60))
|
||||
.as_secs(),
|
||||
oauth_expiry_refresh_token_renew: config
|
||||
.property_or_default::<Duration>("oauth.expiry.refresh-token-renew", "4d")
|
||||
.unwrap_or_else(|| Duration::from_secs(4 * 24 * 60 * 60))
|
||||
.as_secs(),
|
||||
oauth_max_auth_attempts: config
|
||||
.property_or_default("oauth.auth.max-attempts", "3")
|
||||
.unwrap_or(10),
|
||||
event_source_throttle: config
|
||||
.property_or_default("jmap.event-source.throttle", "1s")
|
||||
.unwrap_or_else(|| Duration::from_secs(1)),
|
||||
|
|
|
@ -8,12 +8,14 @@ use std::sync::Arc;
|
|||
|
||||
use arc_swap::ArcSwap;
|
||||
use directory::{Directories, Directory};
|
||||
use ring::signature::{EcdsaKeyPair, RsaKeyPair};
|
||||
use store::{BlobBackend, BlobStore, FtsStore, LookupStore, Store, Stores};
|
||||
use telemetry::Metrics;
|
||||
use utils::config::Config;
|
||||
|
||||
use crate::{
|
||||
expr::*, listener::tls::AcmeProviders, manager::config::ConfigManager, Core, Network, Security,
|
||||
auth::oauth::config::OAuthConfig, expr::*, listener::tls::AcmeProviders,
|
||||
manager::config::ConfigManager, Core, Network, Security,
|
||||
};
|
||||
|
||||
use self::{
|
||||
|
@ -163,6 +165,7 @@ impl Core {
|
|||
smtp: SmtpConfig::parse(config).await,
|
||||
jmap: JmapConfig::parse(config),
|
||||
imap: ImapConfig::parse(config),
|
||||
oauth: OAuthConfig::parse(config),
|
||||
acme: AcmeProviders::parse(config),
|
||||
metrics: Metrics::parse(config),
|
||||
storage: Storage {
|
||||
|
@ -186,3 +189,36 @@ impl Core {
|
|||
ArcSwap::from_pointee(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_rsa_keypair(pem: &str) -> Result<RsaKeyPair, String> {
|
||||
match rustls_pemfile::read_one(&mut pem.as_bytes()) {
|
||||
Ok(Some(rustls_pemfile::Item::Pkcs1Key(key))) => {
|
||||
RsaKeyPair::from_der(key.secret_pkcs1_der())
|
||||
.map_err(|err| format!("Failed to parse PKCS1 RSA key: {err}"))
|
||||
}
|
||||
Ok(Some(rustls_pemfile::Item::Pkcs8Key(key))) => {
|
||||
RsaKeyPair::from_pkcs8(key.secret_pkcs8_der())
|
||||
.map_err(|err| format!("Failed to parse PKCS8 RSA key: {err}"))
|
||||
}
|
||||
Err(err) => Err(format!("Failed to read PEM: {err}")),
|
||||
Ok(Some(key)) => Err(format!("Unsupported key type: {key:?}")),
|
||||
Ok(None) => Err("No RSA key found in PEM".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_ecdsa_pem(
|
||||
alg: &'static ring::signature::EcdsaSigningAlgorithm,
|
||||
pem: &str,
|
||||
) -> Result<EcdsaKeyPair, String> {
|
||||
match rustls_pemfile::read_one(&mut pem.as_bytes()) {
|
||||
Ok(Some(rustls_pemfile::Item::Pkcs8Key(key))) => EcdsaKeyPair::from_pkcs8(
|
||||
alg,
|
||||
key.secret_pkcs8_der(),
|
||||
&ring::rand::SystemRandom::new(),
|
||||
)
|
||||
.map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}")),
|
||||
Err(err) => Err(format!("Failed to read PEM: {err}")),
|
||||
Ok(Some(key)) => Err(format!("Unsupported key type: {key:?}")),
|
||||
Ok(None) => Err("No ECDSA key found in PEM".to_string()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ pub struct LicenseKey {
|
|||
#[derive(Debug)]
|
||||
pub enum LicenseError {
|
||||
Expired,
|
||||
HostnameMismatch { issued_to: String, current: String },
|
||||
DomainMismatch { issued_to: String, current: String },
|
||||
Parse,
|
||||
Validation,
|
||||
Decode,
|
||||
|
@ -175,10 +175,12 @@ impl LicenseKey {
|
|||
}
|
||||
|
||||
pub fn into_validated_key(self, hostname: impl AsRef<str>) -> Result<Self, LicenseError> {
|
||||
if self.hostname != hostname.as_ref() {
|
||||
Err(LicenseError::HostnameMismatch {
|
||||
issued_to: self.hostname.clone(),
|
||||
current: hostname.as_ref().to_string(),
|
||||
let local_domain = psl::domain_str(hostname.as_ref()).unwrap_or("invalid-hostname");
|
||||
let license_domain = psl::domain_str(&self.hostname).expect("Invalid license hostname");
|
||||
if local_domain != license_domain {
|
||||
Err(LicenseError::DomainMismatch {
|
||||
issued_to: license_domain.to_string(),
|
||||
current: local_domain.to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(self)
|
||||
|
@ -213,11 +215,10 @@ impl Display for LicenseError {
|
|||
LicenseError::Validation => write!(f, "Failed to validate license key"),
|
||||
LicenseError::Decode => write!(f, "Failed to decode license key"),
|
||||
LicenseError::InvalidParameters => write!(f, "Invalid license key parameters"),
|
||||
LicenseError::HostnameMismatch { issued_to, current } => {
|
||||
LicenseError::DomainMismatch { issued_to, current } => {
|
||||
write!(
|
||||
f,
|
||||
"License issued to {} does not match {}",
|
||||
issued_to, current
|
||||
"License issued to domain {issued_to:?} does not match {current:?}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ use std::{
|
|||
|
||||
use ahash::{AHashMap, AHashSet, RandomState};
|
||||
use arc_swap::ArcSwap;
|
||||
use auth::{roles::RolePermissions, AccessToken};
|
||||
use auth::{oauth::config::OAuthConfig, roles::RolePermissions, AccessToken};
|
||||
use config::{
|
||||
imap::ImapConfig,
|
||||
jmap::settings::JmapConfig,
|
||||
|
@ -202,6 +202,7 @@ pub struct Core {
|
|||
pub sieve: Scripting,
|
||||
pub network: Network,
|
||||
pub acme: AcmeProviders,
|
||||
pub oauth: OAuthConfig,
|
||||
pub smtp: SmtpConfig,
|
||||
pub jmap: JmapConfig,
|
||||
pub imap: ImapConfig,
|
||||
|
|
|
@ -37,7 +37,7 @@ use crate::{
|
|||
api::management::enterprise::telemetry::TelemetryApi,
|
||||
auth::{
|
||||
authenticate::{Authenticator, HttpHeaders},
|
||||
oauth::{auth::OAuthApiHandler, token::TokenHandler, FormData, OAuthMetadata},
|
||||
oauth::{auth::OAuthApiHandler, openid::OpenIdHandler, token::TokenHandler, FormData},
|
||||
rate_limit::RateLimiter,
|
||||
},
|
||||
blob::{download::BlobDownload, upload::BlobUpload, DownloadResponse, UploadResponse},
|
||||
|
@ -217,19 +217,22 @@ impl ParseHttp for Server {
|
|||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
return Ok(self
|
||||
return self
|
||||
.handle_session_resource(ctx.resolve_response_url(self).await, access_token)
|
||||
.await?
|
||||
.into_http_response());
|
||||
.await
|
||||
.map(|s| s.into_http_response());
|
||||
}
|
||||
("oauth-authorization-server", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||
|
||||
return Ok(JsonResponse::new(OAuthMetadata::new(
|
||||
ctx.resolve_response_url(self).await,
|
||||
))
|
||||
.into_http_response());
|
||||
return self.handle_oauth_metadata(req, session).await;
|
||||
}
|
||||
("openid-configuration", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||
|
||||
return self.handle_oidc_metadata(req, session).await;
|
||||
}
|
||||
("acme-challenge", &Method::GET) if self.has_acme_http_providers() => {
|
||||
if let Some(token) = path.next() {
|
||||
|
@ -273,17 +276,12 @@ impl ParseHttp for Server {
|
|||
("device", &Method::POST) => {
|
||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||
|
||||
let url = ctx.resolve_response_url(self).await;
|
||||
return self
|
||||
.handle_device_auth(&mut req, url, session.session_id)
|
||||
.await;
|
||||
return self.handle_device_auth(&mut req, session).await;
|
||||
}
|
||||
("token", &Method::POST) => {
|
||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||
|
||||
return self
|
||||
.handle_token_request(&mut req, session.session_id)
|
||||
.await;
|
||||
return self.handle_token_request(&mut req, session).await;
|
||||
}
|
||||
("introspect", &Method::POST) => {
|
||||
// Authenticate request
|
||||
|
@ -294,6 +292,19 @@ impl ParseHttp for Server {
|
|||
.handle_token_introspect(&mut req, &access_token, session.session_id)
|
||||
.await;
|
||||
}
|
||||
("userinfo", &Method::GET) => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) =
|
||||
self.authenticate_headers(&req, &session).await?;
|
||||
|
||||
return self.handle_userinfo_request(&access_token).await;
|
||||
}
|
||||
("jwks.json", &Method::GET) => {
|
||||
// Limit anonymous requests
|
||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||
|
||||
return Ok(self.core.oauth.oidc_jwks.clone().into_http_response());
|
||||
}
|
||||
(_, &Method::OPTIONS) => {
|
||||
return Ok(StatusCode::NO_CONTENT.into_http_response());
|
||||
}
|
||||
|
@ -655,17 +666,17 @@ impl SessionManager for JmapSessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
struct HttpContext<'x> {
|
||||
session: &'x HttpSessionData,
|
||||
req: &'x HttpRequest,
|
||||
pub struct HttpContext<'x> {
|
||||
pub session: &'x HttpSessionData,
|
||||
pub req: &'x HttpRequest,
|
||||
}
|
||||
|
||||
impl<'x> HttpContext<'x> {
|
||||
fn new(session: &'x HttpSessionData, req: &'x HttpRequest) -> Self {
|
||||
pub fn new(session: &'x HttpSessionData, req: &'x HttpRequest) -> Self {
|
||||
Self { session, req }
|
||||
}
|
||||
|
||||
async fn resolve_response_url(&self, server: &Server) -> String {
|
||||
pub async fn resolve_response_url(&self, server: &Server) -> String {
|
||||
server
|
||||
.eval_if(
|
||||
&server.core.network.http_response_url,
|
||||
|
@ -683,7 +694,7 @@ impl<'x> HttpContext<'x> {
|
|||
})
|
||||
}
|
||||
|
||||
async fn has_endpoint_access(&self, server: &Server) -> StatusCode {
|
||||
pub async fn has_endpoint_access(&self, server: &Server) -> StatusCode {
|
||||
server
|
||||
.eval_if(
|
||||
&server.core.network.http_allowed_endpoint,
|
||||
|
|
|
@ -14,6 +14,7 @@ use common::{
|
|||
Server,
|
||||
};
|
||||
use rand::distributions::Standard;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
|
@ -23,12 +24,27 @@ use store::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse},
|
||||
api::{
|
||||
http::{HttpContext, HttpSessionData, ToHttpResponse},
|
||||
HttpRequest, HttpResponse, JsonResponse,
|
||||
},
|
||||
auth::oauth::OAuthStatus,
|
||||
};
|
||||
|
||||
use super::{DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, MAX_POST_LEN};
|
||||
|
||||
#[derive(Debug, serde::Serialize, Deserialize)]
|
||||
pub struct OAuthMetadata {
|
||||
pub issuer: String,
|
||||
pub token_endpoint: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub device_authorization_endpoint: String,
|
||||
pub introspection_endpoint: String,
|
||||
pub grant_types_supported: Vec<String>,
|
||||
pub response_types_supported: Vec<String>,
|
||||
pub scopes_supported: Vec<String>,
|
||||
}
|
||||
|
||||
pub trait OAuthApiHandler: Sync + Send {
|
||||
fn handle_oauth_api_request(
|
||||
&self,
|
||||
|
@ -39,8 +55,13 @@ pub trait OAuthApiHandler: Sync + Send {
|
|||
fn handle_device_auth(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
base_url: impl AsRef<str> + Send,
|
||||
session_id: u64,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oauth_metadata(
|
||||
&self,
|
||||
req: HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
|
@ -98,7 +119,7 @@ impl OAuthApiHandler for Server {
|
|||
.key_set(
|
||||
format!("oauth:{client_code}").into_bytes(),
|
||||
value,
|
||||
self.core.jmap.oauth_expiry_auth_code.into(),
|
||||
self.core.oauth.oauth_expiry_auth_code.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
@ -146,7 +167,7 @@ impl OAuthApiHandler for Server {
|
|||
.key_set(
|
||||
format!("oauth:{device_code}").into_bytes(),
|
||||
auth_code.serialize(),
|
||||
self.core.jmap.oauth_expiry_auth_code.into(),
|
||||
self.core.oauth.oauth_expiry_auth_code.into(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
@ -164,11 +185,10 @@ impl OAuthApiHandler for Server {
|
|||
async fn handle_device_auth(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
base_url: impl AsRef<str>,
|
||||
session_id: u64,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse form
|
||||
let client_id = FormData::from_request(req, MAX_POST_LEN, session_id)
|
||||
let client_id = FormData::from_request(req, MAX_POST_LEN, session.session_id)
|
||||
.await?
|
||||
.remove("client_id")
|
||||
.filter(|client_id| client_id.len() < CLIENT_ID_MAX_LEN)
|
||||
|
@ -215,7 +235,7 @@ impl OAuthApiHandler for Server {
|
|||
.key_set(
|
||||
format!("oauth:{device_code}").into_bytes(),
|
||||
oauth_code.clone(),
|
||||
self.core.jmap.oauth_expiry_user_code.into(),
|
||||
self.core.oauth.oauth_expiry_user_code.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
@ -226,20 +246,53 @@ impl OAuthApiHandler for Server {
|
|||
.key_set(
|
||||
format!("oauth:{user_code}").into_bytes(),
|
||||
oauth_code,
|
||||
self.core.jmap.oauth_expiry_user_code.into(),
|
||||
self.core.oauth.oauth_expiry_user_code.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Build response
|
||||
let base_url = base_url.as_ref();
|
||||
let base_url = HttpContext::new(&session, req)
|
||||
.resolve_response_url(self)
|
||||
.await;
|
||||
Ok(JsonResponse::new(DeviceAuthResponse {
|
||||
verification_uri: format!("{}/authorize", base_url),
|
||||
verification_uri_complete: format!("{}/authorize/?code={}", base_url, user_code),
|
||||
verification_uri: format!("{base_url}/authorize"),
|
||||
verification_uri_complete: format!("{base_url}/authorize/?code={user_code}"),
|
||||
device_code,
|
||||
user_code,
|
||||
expires_in: self.core.jmap.oauth_expiry_user_code,
|
||||
expires_in: self.core.oauth.oauth_expiry_user_code,
|
||||
interval: 5,
|
||||
})
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_oauth_metadata(
|
||||
&self,
|
||||
req: HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let base_url = HttpContext::new(&session, &req)
|
||||
.resolve_response_url(self)
|
||||
.await;
|
||||
|
||||
Ok(JsonResponse::new(OAuthMetadata {
|
||||
authorization_endpoint: format!("{base_url}/authorize/code",),
|
||||
token_endpoint: format!("{base_url}/auth/token"),
|
||||
grant_types_supported: vec![
|
||||
"authorization_code".to_string(),
|
||||
"implicit".to_string(),
|
||||
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
|
||||
],
|
||||
device_authorization_endpoint: format!("{base_url}/auth/device"),
|
||||
response_types_supported: vec![
|
||||
"code".to_string(),
|
||||
"id_token".to_string(),
|
||||
"code token".to_string(),
|
||||
"id_token token".to_string(),
|
||||
],
|
||||
scopes_supported: vec!["openid".to_string(), "offline_access".to_string()],
|
||||
introspection_endpoint: format!("{base_url}/auth/introspect"),
|
||||
issuer: base_url,
|
||||
})
|
||||
.into_http_response())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ use utils::map::vec_map::VecMap;
|
|||
use crate::api::{http::fetch_body, HttpRequest};
|
||||
|
||||
pub mod auth;
|
||||
pub mod openid;
|
||||
pub mod token;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
@ -110,6 +111,8 @@ pub struct OAuthResponse {
|
|||
pub refresh_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
@ -136,38 +139,6 @@ pub enum ErrorType {
|
|||
ExpiredToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OAuthMetadata {
|
||||
pub issuer: String,
|
||||
pub token_endpoint: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub device_authorization_endpoint: String,
|
||||
pub introspection_endpoint: String,
|
||||
pub grant_types_supported: Vec<String>,
|
||||
pub response_types_supported: Vec<String>,
|
||||
pub scopes_supported: Vec<String>,
|
||||
}
|
||||
|
||||
impl OAuthMetadata {
|
||||
pub fn new(base_url: impl AsRef<str>) -> Self {
|
||||
let base_url = base_url.as_ref();
|
||||
OAuthMetadata {
|
||||
issuer: base_url.into(),
|
||||
authorization_endpoint: format!("{base_url}/authorize/code",),
|
||||
token_endpoint: format!("{base_url}/auth/token"),
|
||||
grant_types_supported: vec![
|
||||
"authorization_code".to_string(),
|
||||
"implicit".to_string(),
|
||||
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
|
||||
],
|
||||
device_authorization_endpoint: format!("{base_url}/auth/device"),
|
||||
response_types_supported: vec!["code".to_string(), "code token".to_string()],
|
||||
scopes_supported: vec!["offline_access".to_string()],
|
||||
introspection_endpoint: format!("{base_url}/auth/introspect"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum OAuthCodeRequest {
|
||||
|
|
116
crates/jmap/src/auth/oauth/openid.rs
Normal file
116
crates/jmap/src/auth/oauth/openid.rs
Normal file
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use common::{
|
||||
auth::{oauth::oidc::Userinfo, AccessToken},
|
||||
Server,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::{
|
||||
http::{HttpContext, HttpSessionData, ToHttpResponse},
|
||||
HttpRequest, HttpResponse, JsonResponse,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OpenIdMetadata {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub userinfo_endpoint: String,
|
||||
pub jwks_uri: String,
|
||||
pub registration_endpoint: String,
|
||||
pub scopes_supported: Vec<String>,
|
||||
pub response_types_supported: Vec<String>,
|
||||
pub subject_types_supported: Vec<String>,
|
||||
pub grant_types_supported: Vec<String>,
|
||||
pub id_token_signing_alg_values_supported: Vec<String>,
|
||||
pub claims_supported: Vec<String>,
|
||||
}
|
||||
|
||||
pub trait OpenIdHandler: Sync + Send {
|
||||
fn handle_userinfo_request(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oidc_metadata(
|
||||
&self,
|
||||
req: HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
impl OpenIdHandler for Server {
|
||||
async fn handle_userinfo_request(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
Ok(JsonResponse::new(Userinfo {
|
||||
sub: Some(access_token.primary_id.to_string()),
|
||||
name: access_token.description.clone(),
|
||||
preferred_username: Some(access_token.name.clone()),
|
||||
email: access_token.emails.first().cloned(),
|
||||
email_verified: !access_token.emails.is_empty(),
|
||||
..Default::default()
|
||||
})
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_oidc_metadata(
|
||||
&self,
|
||||
req: HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let base_url = HttpContext::new(&session, &req)
|
||||
.resolve_response_url(self)
|
||||
.await;
|
||||
|
||||
Ok(JsonResponse::new(OpenIdMetadata {
|
||||
authorization_endpoint: format!("{base_url}/authorize/code",),
|
||||
token_endpoint: format!("{base_url}/auth/token"),
|
||||
userinfo_endpoint: format!("{base_url}/auth/userinfo"),
|
||||
jwks_uri: format!("{base_url}/auth/jwks.json"),
|
||||
registration_endpoint: format!("{base_url}/auth/register"),
|
||||
response_types_supported: vec![
|
||||
"code".to_string(),
|
||||
"id_token".to_string(),
|
||||
"id_token token".to_string(),
|
||||
],
|
||||
grant_types_supported: vec![
|
||||
"authorization_code".to_string(),
|
||||
"implicit".to_string(),
|
||||
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
|
||||
],
|
||||
scopes_supported: vec!["openid".to_string(), "offline_access".to_string()],
|
||||
subject_types_supported: vec!["public".to_string()],
|
||||
id_token_signing_alg_values_supported: vec![
|
||||
"RS256".to_string(),
|
||||
"RS384".to_string(),
|
||||
"RS512".to_string(),
|
||||
"ES256".to_string(),
|
||||
"ES384".to_string(),
|
||||
"PS256".to_string(),
|
||||
"PS384".to_string(),
|
||||
"PS512".to_string(),
|
||||
"HS256".to_string(),
|
||||
"HS384".to_string(),
|
||||
"HS512".to_string(),
|
||||
],
|
||||
claims_supported: vec![
|
||||
"sub".to_string(),
|
||||
"name".to_string(),
|
||||
"preferred_username".to_string(),
|
||||
"email".to_string(),
|
||||
"email_verified".to_string(),
|
||||
],
|
||||
issuer: base_url,
|
||||
})
|
||||
.into_http_response())
|
||||
}
|
||||
}
|
|
@ -12,7 +12,10 @@ use hyper::StatusCode;
|
|||
use std::future::Future;
|
||||
use store::write::Bincode;
|
||||
|
||||
use crate::api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse};
|
||||
use crate::api::{
|
||||
http::{HttpContext, HttpSessionData, ToHttpResponse},
|
||||
HttpRequest, HttpResponse, JsonResponse,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, MAX_POST_LEN,
|
||||
|
@ -22,7 +25,7 @@ pub trait TokenHandler: Sync + Send {
|
|||
fn handle_token_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session_id: u64,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_token_introspect(
|
||||
|
@ -36,6 +39,7 @@ pub trait TokenHandler: Sync + Send {
|
|||
&self,
|
||||
account_id: u32,
|
||||
client_id: &str,
|
||||
issuer: String,
|
||||
with_refresh_token: bool,
|
||||
) -> impl Future<Output = trc::Result<OAuthResponse>> + Send;
|
||||
}
|
||||
|
@ -45,14 +49,18 @@ impl TokenHandler for Server {
|
|||
async fn handle_token_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session_id: u64,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse form
|
||||
let params = FormData::from_request(req, MAX_POST_LEN, session_id).await?;
|
||||
let params = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?;
|
||||
let grant_type = params.get("grant_type").unwrap_or_default();
|
||||
|
||||
let mut response = TokenResponse::error(ErrorType::InvalidGrant);
|
||||
|
||||
let issuer = HttpContext::new(&session, req)
|
||||
.resolve_response_url(self)
|
||||
.await;
|
||||
|
||||
if grant_type.eq_ignore_ascii_case("authorization_code") {
|
||||
response = if let (Some(code), Some(client_id), Some(redirect_uri)) = (
|
||||
params.get("code"),
|
||||
|
@ -80,7 +88,7 @@ impl TokenHandler for Server {
|
|||
.await?;
|
||||
|
||||
// Issue token
|
||||
self.issue_token(oauth.account_id, &oauth.client_id, true)
|
||||
self.issue_token(oauth.account_id, &oauth.client_id, issuer, true)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
|
@ -126,7 +134,7 @@ impl TokenHandler for Server {
|
|||
.await?;
|
||||
|
||||
// Issue token
|
||||
self.issue_token(oauth.account_id, &oauth.client_id, true)
|
||||
self.issue_token(oauth.account_id, &oauth.client_id, issuer, true)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
|
@ -156,8 +164,9 @@ impl TokenHandler for Server {
|
|||
.issue_token(
|
||||
token_info.account_id,
|
||||
&token_info.client_id,
|
||||
issuer,
|
||||
token_info.expires_in
|
||||
<= self.core.jmap.oauth_expiry_refresh_token_renew,
|
||||
<= self.core.oauth.oauth_expiry_refresh_token_renew,
|
||||
)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
|
@ -171,7 +180,7 @@ impl TokenHandler for Server {
|
|||
trc::error!(err
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to validate refresh token")
|
||||
.span_id(session_id));
|
||||
.span_id(session.session_id));
|
||||
TokenResponse::error(ErrorType::InvalidGrant)
|
||||
}
|
||||
};
|
||||
|
@ -216,6 +225,7 @@ impl TokenHandler for Server {
|
|||
&self,
|
||||
account_id: u32,
|
||||
client_id: &str,
|
||||
issuer: String,
|
||||
with_refresh_token: bool,
|
||||
) -> trc::Result<OAuthResponse> {
|
||||
Ok(OAuthResponse {
|
||||
|
@ -224,23 +234,30 @@ impl TokenHandler for Server {
|
|||
GrantType::AccessToken,
|
||||
account_id,
|
||||
client_id,
|
||||
self.core.jmap.oauth_expiry_token,
|
||||
self.core.oauth.oauth_expiry_token,
|
||||
)
|
||||
.await?,
|
||||
token_type: "bearer".to_string(),
|
||||
expires_in: self.core.jmap.oauth_expiry_token,
|
||||
expires_in: self.core.oauth.oauth_expiry_token,
|
||||
refresh_token: if with_refresh_token {
|
||||
self.encode_access_token(
|
||||
GrantType::RefreshToken,
|
||||
account_id,
|
||||
client_id,
|
||||
self.core.jmap.oauth_expiry_refresh_token,
|
||||
self.core.oauth.oauth_expiry_refresh_token,
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
id_token: match self.issue_id_token(account_id.to_string(), issuer, client_id) {
|
||||
Ok(id_token) => Some(id_token),
|
||||
Err(err) => {
|
||||
trc::error!(err);
|
||||
None
|
||||
}
|
||||
},
|
||||
scope: None,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ num_cpus = "1.15.0"
|
|||
async-trait = "0.1.68"
|
||||
chrono = "0.4"
|
||||
ring = { version = "0.17" }
|
||||
biscuit = "0.7.0"
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
jemallocator = "0.5.0"
|
||||
|
|
|
@ -7,11 +7,13 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use biscuit::{jwk::JWKSet, SingleOrMultiple, JWT};
|
||||
use bytes::Bytes;
|
||||
use common::auth::oauth::introspect::OAuthIntrospect;
|
||||
use imap_proto::ResponseType;
|
||||
use jmap::auth::oauth::{
|
||||
DeviceAuthResponse, ErrorType, OAuthCodeRequest, OAuthMetadata, TokenResponse,
|
||||
auth::OAuthMetadata, openid::OpenIdMetadata, DeviceAuthResponse, ErrorType, OAuthCodeRequest,
|
||||
TokenResponse,
|
||||
};
|
||||
use jmap_client::{
|
||||
client::{Client, Credentials},
|
||||
|
@ -47,8 +49,7 @@ pub async fn test(params: &mut JMAPTest) {
|
|||
|
||||
// Create test account
|
||||
let server = params.server.clone();
|
||||
let john_id = Id::from(
|
||||
server
|
||||
let john_int_id = server
|
||||
.core
|
||||
.storage
|
||||
.data
|
||||
|
@ -58,9 +59,8 @@ pub async fn test(params: &mut JMAPTest) {
|
|||
"John Doe",
|
||||
&["jdoe@example.com"],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.to_string();
|
||||
.await;
|
||||
let john_id = Id::from(john_int_id).to_string();
|
||||
|
||||
// Build API
|
||||
let api = ManagementApi::new(8899, "jdoe@example.com", "12345");
|
||||
|
@ -68,7 +68,13 @@ pub async fn test(params: &mut JMAPTest) {
|
|||
// Obtain OAuth metadata
|
||||
let metadata: OAuthMetadata =
|
||||
get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await;
|
||||
//println!("OAuth metadata: {:#?}", metadata);
|
||||
let oidc_metadata: OpenIdMetadata =
|
||||
get("https://127.0.0.1:8899/.well-known/openid-configuration").await;
|
||||
let jwk_set: JWKSet<()> = get(&oidc_metadata.jwks_uri).await;
|
||||
|
||||
/*println!("OAuth metadata: {:#?}", metadata);
|
||||
println!("OpenID metadata: {:#?}", oidc_metadata);
|
||||
println!("JWKSet: {:#?}", jwk_set);*/
|
||||
|
||||
// ------------------------
|
||||
// Authorization code flow
|
||||
|
@ -114,8 +120,8 @@ pub async fn test(params: &mut JMAPTest) {
|
|||
|
||||
// Obtain token
|
||||
token_params.insert("redirect_uri".to_string(), "https://localhost".to_string());
|
||||
let (token, refresh_token, _) =
|
||||
unwrap_token_response(post(&metadata.token_endpoint, &token_params).await);
|
||||
let (token, refresh_token, id_token) =
|
||||
unwrap_oidc_token_response(post(&metadata.token_endpoint, &token_params).await);
|
||||
|
||||
// Connect to account using token and attempt to search
|
||||
let john_client = Client::new()
|
||||
|
@ -132,6 +138,18 @@ pub async fn test(params: &mut JMAPTest) {
|
|||
.ids()
|
||||
.is_empty());
|
||||
|
||||
// Verify ID token using the JWK set
|
||||
let id_token = JWT::<(), biscuit::Empty>::new_encoded(&id_token)
|
||||
.decode_with_jwks(&jwk_set, None)
|
||||
.unwrap();
|
||||
let claims = &id_token.payload().unwrap().registered;
|
||||
assert_eq!(claims.issuer, Some(oidc_metadata.issuer));
|
||||
assert_eq!(claims.subject, Some(john_int_id.to_string()));
|
||||
assert_eq!(
|
||||
claims.audience,
|
||||
Some(SingleOrMultiple::Single("OAuthyMcOAuthFace".to_string()))
|
||||
);
|
||||
|
||||
// Introspect token
|
||||
let access_introspect: OAuthIntrospect = post_with_auth::<OAuthIntrospect>(
|
||||
&metadata.introspection_endpoint,
|
||||
|
@ -441,3 +459,17 @@ fn unwrap_token_response(response: TokenResponse) -> (String, Option<String>, u6
|
|||
TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_oidc_token_response(response: TokenResponse) -> (String, Option<String>, String) {
|
||||
match response {
|
||||
TokenResponse::Granted(granted) => {
|
||||
assert_eq!(granted.token_type, "bearer");
|
||||
(
|
||||
granted.access_token,
|
||||
granted.refresh_token,
|
||||
granted.id_token.unwrap(),
|
||||
)
|
||||
}
|
||||
TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -289,6 +289,47 @@ token = "1s"
|
|||
refresh-token = "3s"
|
||||
refresh-token-renew = "2s"
|
||||
|
||||
[oauth.oidc]
|
||||
signature-key = '''-----BEGIN PRIVATE KEY-----
|
||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDMXJI1bL3z8gaF
|
||||
Ze/6493VjL+jHkFMP2Pc7fLwRF1fhkuIdYTp69LabzrSEJCRCz0UI2NHqPOgtOta
|
||||
+zRHKAMr7c7Z6uKO0K+aXiQYHw4Y70uSG8CnmNl7kb4OM/CAcoO6fePmvBsyESfn
|
||||
TmkJ5bfHEZQFDQEAoDlDjtjxuwYsAQQVQXuAydi8j8pyTWKAJ1RDgnUT+HbOub7j
|
||||
JrQ7sPe6MPCjXv5N76v9RMHKktfYwRNMlkLkxImQU55+vlvghNztgFlIlJDFfNiy
|
||||
UQPV5FTEZJli9BzMoj1JQK3sZyV8WV0W1zN41QQ+glAAC6+K7iTDPRMINBSwbHyn
|
||||
6Lb9Q6U7AgMBAAECggEAB93qZ5xrhYgEFeoyKO4mUdGsu4qZyJB0zNeWGgdaXCfZ
|
||||
zC4l8zFM+R6osix0EY6lXRtC95+6h9hfFQNa5FWseupDzmIQiEnim1EowjWef87l
|
||||
Eayi0nDRB8TjqZKjR/aLOUhzrPlXHKrKEUk/RDkacCiDklwz9S0LIfLOSXlByBDM
|
||||
/n/eczfX2gUATexMHSeIXs8vN2jpuiVv0r+FPXcRvqdzDZnYSzS8BJ9k6RYXVQ4o
|
||||
NzCbfqgFIpVryB7nHgSTrNX9G7299If8/dXmesXWSFEJvvDSSpcBoINKbfgSlrxd
|
||||
6ubjiotcEIBUSlbaanRrydwShhLHnXyupNAb7tlvyQKBgQDsIipSK4+H9FGl1rAk
|
||||
Gg9DLJ7P/94sidhoq1KYnj/CxwGLoRq22khZEUYZkSvYXDu1Qkj9Avi3TRhw8uol
|
||||
l2SK1VylL5FQvTLKhWB7b2hjrUd5llMRgS3/NIdLhOgDMB7w3UxJnCA/df/Rj+dM
|
||||
WhkyS1f0x3t7XPLwWGurW0nJcwKBgQDdjhrNfabrK7OQvDpAvNJizuwZK9WUL7CD
|
||||
rR0V0MpDGYW12BTEOY6tUK6XZgiRitAXf4EkEI6R0Q0bFzwDDLrg7TvGdTuzNeg/
|
||||
8vm8IlRlOkrdihtHZI4uRB7Ytmz24vzywEBE0p6enA7v4oniscUks/KKmDGr0V90
|
||||
yT9gIVrjGQKBgQCjnWC5otlHGLDiOgm+WhgtMWOxN9dYAQNkMyF+Alinu4CEoVKD
|
||||
VGhA3sk1ufMpbW8pvw4X0dFIITFIQeift3DBCemxw23rBc2FqjkaDi3EszINO22/
|
||||
eUTHyjvcxfCFFPi7aHsNnhJyJm7lY9Kegudmg/Ij93zGE7d5darVBuHvpQKBgBBY
|
||||
YovUgFMLR1UfPeD2zUKy52I4BKrJFemxBNtOKw3mPSIcTfPoFymcMTVENs+eARoq
|
||||
svlZK1uAo8ni3e+Pqd3cQrOyhHQFPxwwrdH+amGJemp7vOV4erDZH7l3Q/S27Fhw
|
||||
bI1nSIKFGukBupB58wRxLiyha9C0QqmYC0/pRg5JAn8Rbj5tP26oVCXjZEfWJL8J
|
||||
axxSxsGA4Vol6i6LYnVgZG+1ez2rP8vUORo1lRzmdeP4o1BSJf9TPwXkuppE5J+t
|
||||
UZVKtYGlEn1RqwGNd8I9TiWvU84rcY9nsxlDR86xwKRWFvYqVOiGYtzRyewYRdjU
|
||||
rTs9aqB3v1+OVxGxR6Na
|
||||
-----END PRIVATE KEY-----
|
||||
'''
|
||||
signature-algorithm = "RS256"
|
||||
|
||||
[oauth.oidc-ignore]
|
||||
signature-key = '''-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggybcqc86ulFFiOon
|
||||
WiYrLO4z8/kmkqvA7wGElBok9IqhRANCAAQxZK68FnQtHC0eyh8CA05xRIvxhVHn
|
||||
0ymka6XBh9aFtW4wfeoKhTkSKjHc/zjh9Rr2dr3kvmYe80fMGhW4ycGA
|
||||
-----END PRIVATE KEY-----
|
||||
'''
|
||||
signature-algorithm = "ES256"
|
||||
|
||||
[session.extensions]
|
||||
expn = true
|
||||
vrfy = true
|
||||
|
|
Loading…
Reference in a new issue