OpenID Connect implementation (closes #298)
Some checks are pending
trivy / Check (push) Waiting to run

This commit is contained in:
mdecimus 2024-09-30 16:57:34 +02:00
parent 1fed40a926
commit 6a5f963b43
18 changed files with 899 additions and 149 deletions

22
Cargo.lock generated
View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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