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",
]
[[package]]
name = "biscuit"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e28fc7c56c61743a01d0d1b73e4fed68b8a4f032ea3a2d4bb8c6520a33fc05a"
dependencies = [
"chrono",
"data-encoding",
"num-bigint",
"num-traits",
"once_cell",
"ring 0.17.8",
"serde",
"serde_json",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@ -1043,6 +1059,7 @@ dependencies = [
"arc-swap",
"base64 0.22.1",
"bincode",
"biscuit",
"chrono",
"dashmap",
"decancer",
@ -1067,6 +1084,8 @@ dependencies = [
"opentelemetry-otlp",
"opentelemetry-semantic-conventions",
"opentelemetry_sdk",
"p256",
"p384",
"parking_lot",
"pem",
"privdrop",
@ -1078,6 +1097,7 @@ dependencies = [
"regex",
"reqwest 0.12.7",
"ring 0.17.8",
"rsa",
"rustls 0.23.13",
"rustls-pemfile 2.1.3",
"rustls-pki-types",
@ -5838,6 +5858,7 @@ version = "1.0.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
dependencies = [
"indexmap 2.5.0",
"itoa",
"memchr",
"ryu",
@ -6418,6 +6439,7 @@ dependencies = [
"ahash 0.8.11",
"async-trait",
"base64 0.22.1",
"biscuit",
"bytes",
"chrono",
"common",

View file

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

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
*/
pub mod config;
pub mod crypto;
pub mod introspect;
pub mod oidc;
pub mod token;
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()
};
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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ use utils::map::vec_map::VecMap;
use crate::api::{http::fetch_body, HttpRequest};
pub mod auth;
pub mod openid;
pub mod token;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -110,6 +111,8 @@ pub struct OAuthResponse {
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -136,38 +139,6 @@ pub enum ErrorType {
ExpiredToken,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthMetadata {
pub issuer: String,
pub token_endpoint: String,
pub authorization_endpoint: String,
pub device_authorization_endpoint: String,
pub introspection_endpoint: String,
pub grant_types_supported: Vec<String>,
pub response_types_supported: Vec<String>,
pub scopes_supported: Vec<String>,
}
impl OAuthMetadata {
pub fn new(base_url: impl AsRef<str>) -> Self {
let base_url = base_url.as_ref();
OAuthMetadata {
issuer: base_url.into(),
authorization_endpoint: format!("{base_url}/authorize/code",),
token_endpoint: format!("{base_url}/auth/token"),
grant_types_supported: vec![
"authorization_code".to_string(),
"implicit".to_string(),
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
],
device_authorization_endpoint: format!("{base_url}/auth/device"),
response_types_supported: vec!["code".to_string(), "code token".to_string()],
scopes_supported: vec!["offline_access".to_string()],
introspection_endpoint: format!("{base_url}/auth/introspect"),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum OAuthCodeRequest {

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

View file

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

View file

@ -7,11 +7,13 @@
use std::time::{Duration, Instant};
use base64::{engine::general_purpose, Engine};
use biscuit::{jwk::JWKSet, SingleOrMultiple, JWT};
use bytes::Bytes;
use common::auth::oauth::introspect::OAuthIntrospect;
use imap_proto::ResponseType;
use jmap::auth::oauth::{
DeviceAuthResponse, ErrorType, OAuthCodeRequest, OAuthMetadata, TokenResponse,
auth::OAuthMetadata, openid::OpenIdMetadata, DeviceAuthResponse, ErrorType, OAuthCodeRequest,
TokenResponse,
};
use jmap_client::{
client::{Client, Credentials},
@ -47,8 +49,7 @@ pub async fn test(params: &mut JMAPTest) {
// Create test account
let server = params.server.clone();
let john_id = Id::from(
server
let john_int_id = server
.core
.storage
.data
@ -58,9 +59,8 @@ pub async fn test(params: &mut JMAPTest) {
"John Doe",
&["jdoe@example.com"],
)
.await,
)
.to_string();
.await;
let john_id = Id::from(john_int_id).to_string();
// Build API
let api = ManagementApi::new(8899, "jdoe@example.com", "12345");
@ -68,7 +68,13 @@ pub async fn test(params: &mut JMAPTest) {
// Obtain OAuth metadata
let metadata: OAuthMetadata =
get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await;
//println!("OAuth metadata: {:#?}", metadata);
let oidc_metadata: OpenIdMetadata =
get("https://127.0.0.1:8899/.well-known/openid-configuration").await;
let jwk_set: JWKSet<()> = get(&oidc_metadata.jwks_uri).await;
/*println!("OAuth metadata: {:#?}", metadata);
println!("OpenID metadata: {:#?}", oidc_metadata);
println!("JWKSet: {:#?}", jwk_set);*/
// ------------------------
// Authorization code flow
@ -114,8 +120,8 @@ pub async fn test(params: &mut JMAPTest) {
// Obtain token
token_params.insert("redirect_uri".to_string(), "https://localhost".to_string());
let (token, refresh_token, _) =
unwrap_token_response(post(&metadata.token_endpoint, &token_params).await);
let (token, refresh_token, id_token) =
unwrap_oidc_token_response(post(&metadata.token_endpoint, &token_params).await);
// Connect to account using token and attempt to search
let john_client = Client::new()
@ -132,6 +138,18 @@ pub async fn test(params: &mut JMAPTest) {
.ids()
.is_empty());
// Verify ID token using the JWK set
let id_token = JWT::<(), biscuit::Empty>::new_encoded(&id_token)
.decode_with_jwks(&jwk_set, None)
.unwrap();
let claims = &id_token.payload().unwrap().registered;
assert_eq!(claims.issuer, Some(oidc_metadata.issuer));
assert_eq!(claims.subject, Some(john_int_id.to_string()));
assert_eq!(
claims.audience,
Some(SingleOrMultiple::Single("OAuthyMcOAuthFace".to_string()))
);
// Introspect token
let access_introspect: OAuthIntrospect = post_with_auth::<OAuthIntrospect>(
&metadata.introspection_endpoint,
@ -441,3 +459,17 @@ fn unwrap_token_response(response: TokenResponse) -> (String, Option<String>, u6
TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error),
}
}
fn unwrap_oidc_token_response(response: TokenResponse) -> (String, Option<String>, String) {
match response {
TokenResponse::Granted(granted) => {
assert_eq!(granted.token_type, "bearer");
(
granted.access_token,
granted.refresh_token,
granted.id_token.unwrap(),
)
}
TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error),
}
}

View file

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