summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormdecimus <mauro@stalw.art>2024-09-30 16:57:34 +0200
committermdecimus <mauro@stalw.art>2024-09-30 16:57:34 +0200
commit6a5f963b43851e1720f17df24d58b413ea6d2926 (patch)
treee9f8bb5887b119722ee93c744fc3c169c0764375
parent1fed40a926b7070ad59fc69cb57ee1b67d53502f (diff)
OpenID Connect implementation (closes #298)
-rw-r--r--Cargo.lock22
-rw-r--r--crates/common/Cargo.toml4
-rw-r--r--crates/common/src/auth/oauth/config.rs330
-rw-r--r--crates/common/src/auth/oauth/mod.rs2
-rw-r--r--crates/common/src/auth/oauth/oidc.rs153
-rw-r--r--crates/common/src/auth/oauth/token.rs4
-rw-r--r--crates/common/src/config/jmap/settings.rs41
-rw-r--r--crates/common/src/config/mod.rs38
-rw-r--r--crates/common/src/enterprise/license.rs17
-rw-r--r--crates/common/src/lib.rs3
-rw-r--r--crates/jmap/src/api/http.rs53
-rw-r--r--crates/jmap/src/auth/oauth/auth.rs81
-rw-r--r--crates/jmap/src/auth/oauth/mod.rs35
-rw-r--r--crates/jmap/src/auth/oauth/openid.rs116
-rw-r--r--crates/jmap/src/auth/oauth/token.rs39
-rw-r--r--tests/Cargo.toml1
-rw-r--r--tests/src/jmap/auth_oauth.rs68
-rw-r--r--tests/src/jmap/mod.rs41
18 files changed, 899 insertions, 149 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d843da27..3d84cb1b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -582,6 +582,22 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
index c3c9289d..ed0f3d21 100644
--- a/crates/common/Cargo.toml
+++ b/crates/common/Cargo.toml
@@ -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"
diff --git a/crates/common/src/auth/oauth/config.rs b/crates/common/src/auth/oauth/config.rs
new file mode 100644
index 00000000..a283d458
--- /dev/null
+++ b/crates/common/src/auth/oauth/config.rs
@@ -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()
+}
diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs
index 20078868..680b6e68 100644
--- a/crates/common/src/auth/oauth/mod.rs
+++ b/crates/common/src/auth/oauth/mod.rs
@@ -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;
diff --git a/crates/common/src/auth/oauth/oidc.rs b/crates/common/src/auth/oauth/oidc.rs
new file mode 100644
index 00000000..16a9192b
--- /dev/null
+++ b/crates/common/src/auth/oauth/oidc.rs
@@ -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)
+}
diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs
index ec6cb946..410487ea 100644
--- a/crates/common/src/auth/oauth/token.rs
+++ b/crates/common/src/auth/oauth/token.rs
@@ -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(),
diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs
index 700a5464..78f4c4a0 100644
--- a/crates/common/src/config/jmap/settings.rs
+++ b/crates/common/src/config/jmap/settings.rs
@@ -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)),
diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs
index 79b7c059..443f5253 100644
--- a/crates/common/src/config/mod.rs
+++ b/crates/common/src/config/mod.rs
@@ -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()),
+ }
+}
diff --git a/crates/common/src/enterprise/license.rs b/crates/common/src/enterprise/license.rs
index a0b96303..718af5ab 100644
--- a/crates/common/src/enterprise/license.rs
+++ b/crates/common/src/enterprise/license.rs
@@ -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:?}",
)
}
}
diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs
index cda2f96f..95c83fb1 100644
--- a/crates/common/src/lib.rs
+++ b/crates/common/src/lib.rs
@@ -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,
diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs
index 9de2e2ab..8a147a79 100644
--- a/crates/jmap/src/api/http.rs
+++ b/crates/jmap/src/api/http.rs
@@ -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,
diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs
index 9cb93a3f..44179cff 100644
--- a/crates/jmap/src/auth/oauth/auth.rs
+++ b/crates/jmap/src/auth/oauth/auth.rs
@@ -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())
+ }
}
diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs
index eaa8c0c1..14d1caa9 100644
--- a/crates/jmap/src/auth/oauth/mod.rs
+++ b/crates/jmap/src/auth/oauth/mod.rs
@@ -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)]
@@ -137,38 +140,6 @@ pub enum ErrorType {
}
#[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 {
Code {
diff --git a/crates/jmap/src/auth/oauth/openid.rs b/crates/jmap/src/auth/oauth/openid.rs
new file mode 100644
index 00000000..47825c6c
--- /dev/null
+++ b/crates/jmap/src/auth/oauth/openid.rs
@@ -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())
+ }
+}
diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs
index 49046746..677028b9 100644
--- a/crates/jmap/src/auth/oauth/token.rs
+++ b/crates/jmap/src/auth/oauth/token.rs
@@ -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,
})
}
diff --git a/tests/Cargo.toml b/tests/Cargo.toml
index 1c2a085d..72a86abe 100644
--- a/tests/Cargo.toml
+++ b/tests/Cargo.toml
@@ -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"
diff --git a/tests/src/jmap/auth_oauth.rs b/tests/src/jmap/auth_oauth.rs
index 7b74be57..dafe5e4e 100644
--- a/tests/src/jmap/auth_oauth.rs
+++ b/tests/src/jmap/auth_oauth.rs
@@ -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,20 +49,18 @@ pub async fn test(params: &mut JMAPTest) {
// Create test account
let server = params.server.clone();
- let john_id = Id::from(
- server
- .core
- .storage
- .data
- .create_test_user(
- "jdoe@example.com",
- "12345",
- "John Doe",
- &["jdoe@example.com"],
- )
- .await,
- )
- .to_string();
+ let john_int_id = server
+ .core
+ .storage
+ .data
+ .create_test_user(
+ "jdoe@example.com",
+ "12345",
+ "John Doe",
+ &["jdoe@example.com"],
+ )
+ .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),
+ }
+}
diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs
index ee06ef75..8634f3e7 100644
--- a/tests/src/jmap/mod.rs
+++ b/tests/src/jmap/mod.rs
@@ -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