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",
|
"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]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
@ -1043,6 +1059,7 @@ dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
"biscuit",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"decancer",
|
"decancer",
|
||||||
|
@ -1067,6 +1084,8 @@ dependencies = [
|
||||||
"opentelemetry-otlp",
|
"opentelemetry-otlp",
|
||||||
"opentelemetry-semantic-conventions",
|
"opentelemetry-semantic-conventions",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
|
"p256",
|
||||||
|
"p384",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pem",
|
"pem",
|
||||||
"privdrop",
|
"privdrop",
|
||||||
|
@ -1078,6 +1097,7 @@ dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.7",
|
"reqwest 0.12.7",
|
||||||
"ring 0.17.8",
|
"ring 0.17.8",
|
||||||
|
"rsa",
|
||||||
"rustls 0.23.13",
|
"rustls 0.23.13",
|
||||||
"rustls-pemfile 2.1.3",
|
"rustls-pemfile 2.1.3",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
@ -5838,6 +5858,7 @@ version = "1.0.128"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
|
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"indexmap 2.5.0",
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
"ryu",
|
"ryu",
|
||||||
|
@ -6418,6 +6439,7 @@ dependencies = [
|
||||||
"ahash 0.8.11",
|
"ahash 0.8.11",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"biscuit",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"common",
|
"common",
|
||||||
|
|
|
@ -62,6 +62,10 @@ xxhash-rust = { version = "0.8.5", features = ["xxh3"] }
|
||||||
psl = "2"
|
psl = "2"
|
||||||
dashmap = "6.0"
|
dashmap = "6.0"
|
||||||
aes-gcm-siv = "0.11.1"
|
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]
|
[target.'cfg(unix)'.dependencies]
|
||||||
privdrop = "0.5.3"
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod introspect;
|
pub mod introspect;
|
||||||
|
pub mod oidc;
|
||||||
pub mod token;
|
pub mod token;
|
||||||
|
|
||||||
pub const DEVICE_CODE_LEN: usize = 40;
|
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()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let key = &self.core.jmap.oauth_key;
|
let key = &self.core.oauth.oauth_key;
|
||||||
let context = format!(
|
let context = format!(
|
||||||
"{} {} {} {}",
|
"{} {} {} {}",
|
||||||
grant_type.as_str(),
|
grant_type.as_str(),
|
||||||
|
@ -165,7 +165,7 @@ impl Server {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build context
|
// Build context
|
||||||
let key = self.core.jmap.oauth_key.clone();
|
let key = self.core.oauth.oauth_key.clone();
|
||||||
let context = format!(
|
let context = format!(
|
||||||
"{} {} {} {}",
|
"{} {} {} {}",
|
||||||
grant_type.as_str(),
|
grant_type.as_str(),
|
||||||
|
|
|
@ -9,7 +9,6 @@ use std::{str::FromStr, time::Duration};
|
||||||
use jmap_proto::request::capability::BaseCapabilities;
|
use jmap_proto::request::capability::BaseCapabilities;
|
||||||
use mail_parser::HeaderName;
|
use mail_parser::HeaderName;
|
||||||
use nlp::language::Language;
|
use nlp::language::Language;
|
||||||
use store::rand::{distributions::Alphanumeric, thread_rng, Rng};
|
|
||||||
use utils::config::{cron::SimpleCron, utils::ParseValue, Config, Rate};
|
use utils::config::{cron::SimpleCron, utils::ParseValue, Config, Rate};
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
|
@ -63,13 +62,6 @@ pub struct JmapConfig {
|
||||||
pub web_socket_timeout: Duration,
|
pub web_socket_timeout: Duration,
|
||||||
pub web_socket_heartbeat: 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 fallback_admin: Option<(String, String)>,
|
||||||
pub master_user: Option<(String, String)>,
|
pub master_user: Option<(String, String)>,
|
||||||
|
|
||||||
|
@ -321,39 +313,6 @@ impl JmapConfig {
|
||||||
rate_anonymous: config
|
rate_anonymous: config
|
||||||
.property_or_default::<Option<Rate>>("jmap.rate-limit.anonymous", "100/1m")
|
.property_or_default::<Option<Rate>>("jmap.rate-limit.anonymous", "100/1m")
|
||||||
.unwrap_or_default(),
|
.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
|
event_source_throttle: config
|
||||||
.property_or_default("jmap.event-source.throttle", "1s")
|
.property_or_default("jmap.event-source.throttle", "1s")
|
||||||
.unwrap_or_else(|| Duration::from_secs(1)),
|
.unwrap_or_else(|| Duration::from_secs(1)),
|
||||||
|
|
|
@ -8,12 +8,14 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use directory::{Directories, Directory};
|
use directory::{Directories, Directory};
|
||||||
|
use ring::signature::{EcdsaKeyPair, RsaKeyPair};
|
||||||
use store::{BlobBackend, BlobStore, FtsStore, LookupStore, Store, Stores};
|
use store::{BlobBackend, BlobStore, FtsStore, LookupStore, Store, Stores};
|
||||||
use telemetry::Metrics;
|
use telemetry::Metrics;
|
||||||
use utils::config::Config;
|
use utils::config::Config;
|
||||||
|
|
||||||
use crate::{
|
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::{
|
use self::{
|
||||||
|
@ -163,6 +165,7 @@ impl Core {
|
||||||
smtp: SmtpConfig::parse(config).await,
|
smtp: SmtpConfig::parse(config).await,
|
||||||
jmap: JmapConfig::parse(config),
|
jmap: JmapConfig::parse(config),
|
||||||
imap: ImapConfig::parse(config),
|
imap: ImapConfig::parse(config),
|
||||||
|
oauth: OAuthConfig::parse(config),
|
||||||
acme: AcmeProviders::parse(config),
|
acme: AcmeProviders::parse(config),
|
||||||
metrics: Metrics::parse(config),
|
metrics: Metrics::parse(config),
|
||||||
storage: Storage {
|
storage: Storage {
|
||||||
|
@ -186,3 +189,36 @@ impl Core {
|
||||||
ArcSwap::from_pointee(self)
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum LicenseError {
|
pub enum LicenseError {
|
||||||
Expired,
|
Expired,
|
||||||
HostnameMismatch { issued_to: String, current: String },
|
DomainMismatch { issued_to: String, current: String },
|
||||||
Parse,
|
Parse,
|
||||||
Validation,
|
Validation,
|
||||||
Decode,
|
Decode,
|
||||||
|
@ -175,10 +175,12 @@ impl LicenseKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_validated_key(self, hostname: impl AsRef<str>) -> Result<Self, LicenseError> {
|
pub fn into_validated_key(self, hostname: impl AsRef<str>) -> Result<Self, LicenseError> {
|
||||||
if self.hostname != hostname.as_ref() {
|
let local_domain = psl::domain_str(hostname.as_ref()).unwrap_or("invalid-hostname");
|
||||||
Err(LicenseError::HostnameMismatch {
|
let license_domain = psl::domain_str(&self.hostname).expect("Invalid license hostname");
|
||||||
issued_to: self.hostname.clone(),
|
if local_domain != license_domain {
|
||||||
current: hostname.as_ref().to_string(),
|
Err(LicenseError::DomainMismatch {
|
||||||
|
issued_to: license_domain.to_string(),
|
||||||
|
current: local_domain.to_string(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Ok(self)
|
Ok(self)
|
||||||
|
@ -213,11 +215,10 @@ impl Display for LicenseError {
|
||||||
LicenseError::Validation => write!(f, "Failed to validate license key"),
|
LicenseError::Validation => write!(f, "Failed to validate license key"),
|
||||||
LicenseError::Decode => write!(f, "Failed to decode license key"),
|
LicenseError::Decode => write!(f, "Failed to decode license key"),
|
||||||
LicenseError::InvalidParameters => write!(f, "Invalid license key parameters"),
|
LicenseError::InvalidParameters => write!(f, "Invalid license key parameters"),
|
||||||
LicenseError::HostnameMismatch { issued_to, current } => {
|
LicenseError::DomainMismatch { issued_to, current } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
"License issued to {} does not match {}",
|
"License issued to domain {issued_to:?} does not match {current:?}",
|
||||||
issued_to, current
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ use std::{
|
||||||
|
|
||||||
use ahash::{AHashMap, AHashSet, RandomState};
|
use ahash::{AHashMap, AHashSet, RandomState};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use auth::{roles::RolePermissions, AccessToken};
|
use auth::{oauth::config::OAuthConfig, roles::RolePermissions, AccessToken};
|
||||||
use config::{
|
use config::{
|
||||||
imap::ImapConfig,
|
imap::ImapConfig,
|
||||||
jmap::settings::JmapConfig,
|
jmap::settings::JmapConfig,
|
||||||
|
@ -202,6 +202,7 @@ pub struct Core {
|
||||||
pub sieve: Scripting,
|
pub sieve: Scripting,
|
||||||
pub network: Network,
|
pub network: Network,
|
||||||
pub acme: AcmeProviders,
|
pub acme: AcmeProviders,
|
||||||
|
pub oauth: OAuthConfig,
|
||||||
pub smtp: SmtpConfig,
|
pub smtp: SmtpConfig,
|
||||||
pub jmap: JmapConfig,
|
pub jmap: JmapConfig,
|
||||||
pub imap: ImapConfig,
|
pub imap: ImapConfig,
|
||||||
|
|
|
@ -37,7 +37,7 @@ use crate::{
|
||||||
api::management::enterprise::telemetry::TelemetryApi,
|
api::management::enterprise::telemetry::TelemetryApi,
|
||||||
auth::{
|
auth::{
|
||||||
authenticate::{Authenticator, HttpHeaders},
|
authenticate::{Authenticator, HttpHeaders},
|
||||||
oauth::{auth::OAuthApiHandler, token::TokenHandler, FormData, OAuthMetadata},
|
oauth::{auth::OAuthApiHandler, openid::OpenIdHandler, token::TokenHandler, FormData},
|
||||||
rate_limit::RateLimiter,
|
rate_limit::RateLimiter,
|
||||||
},
|
},
|
||||||
blob::{download::BlobDownload, upload::BlobUpload, DownloadResponse, UploadResponse},
|
blob::{download::BlobDownload, upload::BlobUpload, DownloadResponse, UploadResponse},
|
||||||
|
@ -217,19 +217,22 @@ impl ParseHttp for Server {
|
||||||
let (_in_flight, access_token) =
|
let (_in_flight, access_token) =
|
||||||
self.authenticate_headers(&req, &session).await?;
|
self.authenticate_headers(&req, &session).await?;
|
||||||
|
|
||||||
return Ok(self
|
return self
|
||||||
.handle_session_resource(ctx.resolve_response_url(self).await, access_token)
|
.handle_session_resource(ctx.resolve_response_url(self).await, access_token)
|
||||||
.await?
|
.await
|
||||||
.into_http_response());
|
.map(|s| s.into_http_response());
|
||||||
}
|
}
|
||||||
("oauth-authorization-server", &Method::GET) => {
|
("oauth-authorization-server", &Method::GET) => {
|
||||||
// Limit anonymous requests
|
// Limit anonymous requests
|
||||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||||
|
|
||||||
return Ok(JsonResponse::new(OAuthMetadata::new(
|
return self.handle_oauth_metadata(req, session).await;
|
||||||
ctx.resolve_response_url(self).await,
|
}
|
||||||
))
|
("openid-configuration", &Method::GET) => {
|
||||||
.into_http_response());
|
// 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() => {
|
("acme-challenge", &Method::GET) if self.has_acme_http_providers() => {
|
||||||
if let Some(token) = path.next() {
|
if let Some(token) = path.next() {
|
||||||
|
@ -273,17 +276,12 @@ impl ParseHttp for Server {
|
||||||
("device", &Method::POST) => {
|
("device", &Method::POST) => {
|
||||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||||
|
|
||||||
let url = ctx.resolve_response_url(self).await;
|
return self.handle_device_auth(&mut req, session).await;
|
||||||
return self
|
|
||||||
.handle_device_auth(&mut req, url, session.session_id)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
("token", &Method::POST) => {
|
("token", &Method::POST) => {
|
||||||
self.is_anonymous_allowed(&session.remote_ip).await?;
|
self.is_anonymous_allowed(&session.remote_ip).await?;
|
||||||
|
|
||||||
return self
|
return self.handle_token_request(&mut req, session).await;
|
||||||
.handle_token_request(&mut req, session.session_id)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
("introspect", &Method::POST) => {
|
("introspect", &Method::POST) => {
|
||||||
// Authenticate request
|
// Authenticate request
|
||||||
|
@ -294,6 +292,19 @@ impl ParseHttp for Server {
|
||||||
.handle_token_introspect(&mut req, &access_token, session.session_id)
|
.handle_token_introspect(&mut req, &access_token, session.session_id)
|
||||||
.await;
|
.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) => {
|
(_, &Method::OPTIONS) => {
|
||||||
return Ok(StatusCode::NO_CONTENT.into_http_response());
|
return Ok(StatusCode::NO_CONTENT.into_http_response());
|
||||||
}
|
}
|
||||||
|
@ -655,17 +666,17 @@ impl SessionManager for JmapSessionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HttpContext<'x> {
|
pub struct HttpContext<'x> {
|
||||||
session: &'x HttpSessionData,
|
pub session: &'x HttpSessionData,
|
||||||
req: &'x HttpRequest,
|
pub req: &'x HttpRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'x> HttpContext<'x> {
|
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 }
|
Self { session, req }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_response_url(&self, server: &Server) -> String {
|
pub async fn resolve_response_url(&self, server: &Server) -> String {
|
||||||
server
|
server
|
||||||
.eval_if(
|
.eval_if(
|
||||||
&server.core.network.http_response_url,
|
&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
|
server
|
||||||
.eval_if(
|
.eval_if(
|
||||||
&server.core.network.http_allowed_endpoint,
|
&server.core.network.http_allowed_endpoint,
|
||||||
|
|
|
@ -14,6 +14,7 @@ use common::{
|
||||||
Server,
|
Server,
|
||||||
};
|
};
|
||||||
use rand::distributions::Standard;
|
use rand::distributions::Standard;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use store::{
|
use store::{
|
||||||
|
@ -23,12 +24,27 @@ use store::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse},
|
api::{
|
||||||
|
http::{HttpContext, HttpSessionData, ToHttpResponse},
|
||||||
|
HttpRequest, HttpResponse, JsonResponse,
|
||||||
|
},
|
||||||
auth::oauth::OAuthStatus,
|
auth::oauth::OAuthStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, MAX_POST_LEN};
|
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 {
|
pub trait OAuthApiHandler: Sync + Send {
|
||||||
fn handle_oauth_api_request(
|
fn handle_oauth_api_request(
|
||||||
&self,
|
&self,
|
||||||
|
@ -39,8 +55,13 @@ pub trait OAuthApiHandler: Sync + Send {
|
||||||
fn handle_device_auth(
|
fn handle_device_auth(
|
||||||
&self,
|
&self,
|
||||||
req: &mut HttpRequest,
|
req: &mut HttpRequest,
|
||||||
base_url: impl AsRef<str> + Send,
|
session: HttpSessionData,
|
||||||
session_id: u64,
|
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||||
|
|
||||||
|
fn handle_oauth_metadata(
|
||||||
|
&self,
|
||||||
|
req: HttpRequest,
|
||||||
|
session: HttpSessionData,
|
||||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,7 +119,7 @@ impl OAuthApiHandler for Server {
|
||||||
.key_set(
|
.key_set(
|
||||||
format!("oauth:{client_code}").into_bytes(),
|
format!("oauth:{client_code}").into_bytes(),
|
||||||
value,
|
value,
|
||||||
self.core.jmap.oauth_expiry_auth_code.into(),
|
self.core.oauth.oauth_expiry_auth_code.into(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
@ -146,7 +167,7 @@ impl OAuthApiHandler for Server {
|
||||||
.key_set(
|
.key_set(
|
||||||
format!("oauth:{device_code}").into_bytes(),
|
format!("oauth:{device_code}").into_bytes(),
|
||||||
auth_code.serialize(),
|
auth_code.serialize(),
|
||||||
self.core.jmap.oauth_expiry_auth_code.into(),
|
self.core.oauth.oauth_expiry_auth_code.into(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
@ -164,11 +185,10 @@ impl OAuthApiHandler for Server {
|
||||||
async fn handle_device_auth(
|
async fn handle_device_auth(
|
||||||
&self,
|
&self,
|
||||||
req: &mut HttpRequest,
|
req: &mut HttpRequest,
|
||||||
base_url: impl AsRef<str>,
|
session: HttpSessionData,
|
||||||
session_id: u64,
|
|
||||||
) -> trc::Result<HttpResponse> {
|
) -> trc::Result<HttpResponse> {
|
||||||
// Parse form
|
// 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?
|
.await?
|
||||||
.remove("client_id")
|
.remove("client_id")
|
||||||
.filter(|client_id| client_id.len() < CLIENT_ID_MAX_LEN)
|
.filter(|client_id| client_id.len() < CLIENT_ID_MAX_LEN)
|
||||||
|
@ -215,7 +235,7 @@ impl OAuthApiHandler for Server {
|
||||||
.key_set(
|
.key_set(
|
||||||
format!("oauth:{device_code}").into_bytes(),
|
format!("oauth:{device_code}").into_bytes(),
|
||||||
oauth_code.clone(),
|
oauth_code.clone(),
|
||||||
self.core.jmap.oauth_expiry_user_code.into(),
|
self.core.oauth.oauth_expiry_user_code.into(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
@ -226,20 +246,53 @@ impl OAuthApiHandler for Server {
|
||||||
.key_set(
|
.key_set(
|
||||||
format!("oauth:{user_code}").into_bytes(),
|
format!("oauth:{user_code}").into_bytes(),
|
||||||
oauth_code,
|
oauth_code,
|
||||||
self.core.jmap.oauth_expiry_user_code.into(),
|
self.core.oauth.oauth_expiry_user_code.into(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Build response
|
// 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 {
|
Ok(JsonResponse::new(DeviceAuthResponse {
|
||||||
verification_uri: format!("{}/authorize", base_url),
|
verification_uri: format!("{base_url}/authorize"),
|
||||||
verification_uri_complete: format!("{}/authorize/?code={}", base_url, user_code),
|
verification_uri_complete: format!("{base_url}/authorize/?code={user_code}"),
|
||||||
device_code,
|
device_code,
|
||||||
user_code,
|
user_code,
|
||||||
expires_in: self.core.jmap.oauth_expiry_user_code,
|
expires_in: self.core.oauth.oauth_expiry_user_code,
|
||||||
interval: 5,
|
interval: 5,
|
||||||
})
|
})
|
||||||
.into_http_response())
|
.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};
|
use crate::api::{http::fetch_body, HttpRequest};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod openid;
|
||||||
pub mod token;
|
pub mod token;
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
@ -110,6 +111,8 @@ pub struct OAuthResponse {
|
||||||
pub refresh_token: Option<String>,
|
pub refresh_token: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub scope: Option<String>,
|
pub scope: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
@ -136,38 +139,6 @@ pub enum ErrorType {
|
||||||
ExpiredToken,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum OAuthCodeRequest {
|
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 std::future::Future;
|
||||||
use store::write::Bincode;
|
use store::write::Bincode;
|
||||||
|
|
||||||
use crate::api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse};
|
use crate::api::{
|
||||||
|
http::{HttpContext, HttpSessionData, ToHttpResponse},
|
||||||
|
HttpRequest, HttpResponse, JsonResponse,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, MAX_POST_LEN,
|
ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, MAX_POST_LEN,
|
||||||
|
@ -22,7 +25,7 @@ pub trait TokenHandler: Sync + Send {
|
||||||
fn handle_token_request(
|
fn handle_token_request(
|
||||||
&self,
|
&self,
|
||||||
req: &mut HttpRequest,
|
req: &mut HttpRequest,
|
||||||
session_id: u64,
|
session: HttpSessionData,
|
||||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||||
|
|
||||||
fn handle_token_introspect(
|
fn handle_token_introspect(
|
||||||
|
@ -36,6 +39,7 @@ pub trait TokenHandler: Sync + Send {
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
|
issuer: String,
|
||||||
with_refresh_token: bool,
|
with_refresh_token: bool,
|
||||||
) -> impl Future<Output = trc::Result<OAuthResponse>> + Send;
|
) -> impl Future<Output = trc::Result<OAuthResponse>> + Send;
|
||||||
}
|
}
|
||||||
|
@ -45,14 +49,18 @@ impl TokenHandler for Server {
|
||||||
async fn handle_token_request(
|
async fn handle_token_request(
|
||||||
&self,
|
&self,
|
||||||
req: &mut HttpRequest,
|
req: &mut HttpRequest,
|
||||||
session_id: u64,
|
session: HttpSessionData,
|
||||||
) -> trc::Result<HttpResponse> {
|
) -> trc::Result<HttpResponse> {
|
||||||
// Parse form
|
// 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 grant_type = params.get("grant_type").unwrap_or_default();
|
||||||
|
|
||||||
let mut response = TokenResponse::error(ErrorType::InvalidGrant);
|
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") {
|
if grant_type.eq_ignore_ascii_case("authorization_code") {
|
||||||
response = if let (Some(code), Some(client_id), Some(redirect_uri)) = (
|
response = if let (Some(code), Some(client_id), Some(redirect_uri)) = (
|
||||||
params.get("code"),
|
params.get("code"),
|
||||||
|
@ -80,7 +88,7 @@ impl TokenHandler for Server {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Issue token
|
// Issue token
|
||||||
self.issue_token(oauth.account_id, &oauth.client_id, true)
|
self.issue_token(oauth.account_id, &oauth.client_id, issuer, true)
|
||||||
.await
|
.await
|
||||||
.map(TokenResponse::Granted)
|
.map(TokenResponse::Granted)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
|
@ -126,7 +134,7 @@ impl TokenHandler for Server {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Issue token
|
// Issue token
|
||||||
self.issue_token(oauth.account_id, &oauth.client_id, true)
|
self.issue_token(oauth.account_id, &oauth.client_id, issuer, true)
|
||||||
.await
|
.await
|
||||||
.map(TokenResponse::Granted)
|
.map(TokenResponse::Granted)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
|
@ -156,8 +164,9 @@ impl TokenHandler for Server {
|
||||||
.issue_token(
|
.issue_token(
|
||||||
token_info.account_id,
|
token_info.account_id,
|
||||||
&token_info.client_id,
|
&token_info.client_id,
|
||||||
|
issuer,
|
||||||
token_info.expires_in
|
token_info.expires_in
|
||||||
<= self.core.jmap.oauth_expiry_refresh_token_renew,
|
<= self.core.oauth.oauth_expiry_refresh_token_renew,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(TokenResponse::Granted)
|
.map(TokenResponse::Granted)
|
||||||
|
@ -171,7 +180,7 @@ impl TokenHandler for Server {
|
||||||
trc::error!(err
|
trc::error!(err
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.details("Failed to validate refresh token")
|
.details("Failed to validate refresh token")
|
||||||
.span_id(session_id));
|
.span_id(session.session_id));
|
||||||
TokenResponse::error(ErrorType::InvalidGrant)
|
TokenResponse::error(ErrorType::InvalidGrant)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -216,6 +225,7 @@ impl TokenHandler for Server {
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
|
issuer: String,
|
||||||
with_refresh_token: bool,
|
with_refresh_token: bool,
|
||||||
) -> trc::Result<OAuthResponse> {
|
) -> trc::Result<OAuthResponse> {
|
||||||
Ok(OAuthResponse {
|
Ok(OAuthResponse {
|
||||||
|
@ -224,23 +234,30 @@ impl TokenHandler for Server {
|
||||||
GrantType::AccessToken,
|
GrantType::AccessToken,
|
||||||
account_id,
|
account_id,
|
||||||
client_id,
|
client_id,
|
||||||
self.core.jmap.oauth_expiry_token,
|
self.core.oauth.oauth_expiry_token,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
token_type: "bearer".to_string(),
|
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 {
|
refresh_token: if with_refresh_token {
|
||||||
self.encode_access_token(
|
self.encode_access_token(
|
||||||
GrantType::RefreshToken,
|
GrantType::RefreshToken,
|
||||||
account_id,
|
account_id,
|
||||||
client_id,
|
client_id,
|
||||||
self.core.jmap.oauth_expiry_refresh_token,
|
self.core.oauth.oauth_expiry_refresh_token,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
None
|
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,
|
scope: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,7 @@ num_cpus = "1.15.0"
|
||||||
async-trait = "0.1.68"
|
async-trait = "0.1.68"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
ring = { version = "0.17" }
|
ring = { version = "0.17" }
|
||||||
|
biscuit = "0.7.0"
|
||||||
|
|
||||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||||
jemallocator = "0.5.0"
|
jemallocator = "0.5.0"
|
||||||
|
|
|
@ -7,11 +7,13 @@
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use base64::{engine::general_purpose, Engine};
|
use base64::{engine::general_purpose, Engine};
|
||||||
|
use biscuit::{jwk::JWKSet, SingleOrMultiple, JWT};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use common::auth::oauth::introspect::OAuthIntrospect;
|
use common::auth::oauth::introspect::OAuthIntrospect;
|
||||||
use imap_proto::ResponseType;
|
use imap_proto::ResponseType;
|
||||||
use jmap::auth::oauth::{
|
use jmap::auth::oauth::{
|
||||||
DeviceAuthResponse, ErrorType, OAuthCodeRequest, OAuthMetadata, TokenResponse,
|
auth::OAuthMetadata, openid::OpenIdMetadata, DeviceAuthResponse, ErrorType, OAuthCodeRequest,
|
||||||
|
TokenResponse,
|
||||||
};
|
};
|
||||||
use jmap_client::{
|
use jmap_client::{
|
||||||
client::{Client, Credentials},
|
client::{Client, Credentials},
|
||||||
|
@ -47,20 +49,18 @@ pub async fn test(params: &mut JMAPTest) {
|
||||||
|
|
||||||
// Create test account
|
// Create test account
|
||||||
let server = params.server.clone();
|
let server = params.server.clone();
|
||||||
let john_id = Id::from(
|
let john_int_id = server
|
||||||
server
|
.core
|
||||||
.core
|
.storage
|
||||||
.storage
|
.data
|
||||||
.data
|
.create_test_user(
|
||||||
.create_test_user(
|
"jdoe@example.com",
|
||||||
"jdoe@example.com",
|
"12345",
|
||||||
"12345",
|
"John Doe",
|
||||||
"John Doe",
|
&["jdoe@example.com"],
|
||||||
&["jdoe@example.com"],
|
)
|
||||||
)
|
.await;
|
||||||
.await,
|
let john_id = Id::from(john_int_id).to_string();
|
||||||
)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Build API
|
// Build API
|
||||||
let api = ManagementApi::new(8899, "jdoe@example.com", "12345");
|
let api = ManagementApi::new(8899, "jdoe@example.com", "12345");
|
||||||
|
@ -68,7 +68,13 @@ pub async fn test(params: &mut JMAPTest) {
|
||||||
// Obtain OAuth metadata
|
// Obtain OAuth metadata
|
||||||
let metadata: OAuthMetadata =
|
let metadata: OAuthMetadata =
|
||||||
get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await;
|
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
|
// Authorization code flow
|
||||||
|
@ -114,8 +120,8 @@ pub async fn test(params: &mut JMAPTest) {
|
||||||
|
|
||||||
// Obtain token
|
// Obtain token
|
||||||
token_params.insert("redirect_uri".to_string(), "https://localhost".to_string());
|
token_params.insert("redirect_uri".to_string(), "https://localhost".to_string());
|
||||||
let (token, refresh_token, _) =
|
let (token, refresh_token, id_token) =
|
||||||
unwrap_token_response(post(&metadata.token_endpoint, &token_params).await);
|
unwrap_oidc_token_response(post(&metadata.token_endpoint, &token_params).await);
|
||||||
|
|
||||||
// Connect to account using token and attempt to search
|
// Connect to account using token and attempt to search
|
||||||
let john_client = Client::new()
|
let john_client = Client::new()
|
||||||
|
@ -132,6 +138,18 @@ pub async fn test(params: &mut JMAPTest) {
|
||||||
.ids()
|
.ids()
|
||||||
.is_empty());
|
.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
|
// Introspect token
|
||||||
let access_introspect: OAuthIntrospect = post_with_auth::<OAuthIntrospect>(
|
let access_introspect: OAuthIntrospect = post_with_auth::<OAuthIntrospect>(
|
||||||
&metadata.introspection_endpoint,
|
&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),
|
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 = "3s"
|
||||||
refresh-token-renew = "2s"
|
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]
|
[session.extensions]
|
||||||
expn = true
|
expn = true
|
||||||
vrfy = true
|
vrfy = true
|
||||||
|
|
Loading…
Reference in a new issue