use std::sync::{Arc, Mutex};
use std::time::Duration;
use autumn_web::time::{ClockSource, clock_unix_secs};
use futures::future::BoxFuture;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use serde_json::Value;
use crate::activity::Activity;
use crate::error::TeamsError;
use crate::metrics::TeamsMetrics;
use crate::policy::{MAX_STALE_KEY_AGE_SECS, keys_fresh, may_refresh_keys, token_time_valid};
use crate::transport::{HttpRequest, HttpTransport, Method};
pub const MAX_TOKEN_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AuthError {
#[error("no Authorization header")]
MissingToken,
#[error("malformed bearer token")]
Malformed,
#[error("token algorithm is not RS256")]
Algorithm,
#[error("unknown signing key")]
UnknownKey,
#[error("bad token signature")]
Signature,
#[error("token issuer is not accepted")]
Issuer,
#[error("token audience is not the app ID")]
Audience,
#[error("token is expired or not yet valid")]
Expired,
#[error("signing keys are not available")]
KeysUnavailable,
#[error("signing key has no endorsement for the channel")]
Endorsement,
#[error("serviceUrl claim does not match the activity")]
ServiceUrl,
#[error("channel is not allowed")]
Channel,
#[error("tenant is not allowed")]
Tenant,
}
impl AuthError {
#[must_use]
pub const fn status(&self) -> u16 {
match self {
Self::KeysUnavailable => 503,
Self::Endorsement | Self::ServiceUrl | Self::Channel | Self::Tenant => 403,
_ => 401,
}
}
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::MissingToken => "missing_token",
Self::Malformed => "malformed_token",
Self::Algorithm => "bad_algorithm",
Self::UnknownKey => "unknown_key",
Self::Signature => "bad_signature",
Self::Issuer => "bad_issuer",
Self::Audience => "bad_audience",
Self::Expired => "expired",
Self::KeysUnavailable => "keys_unavailable",
Self::Endorsement => "no_endorsement",
Self::ServiceUrl => "service_url_mismatch",
Self::Channel => "channel_not_allowed",
Self::Tenant => "tenant_not_allowed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct SigningKey {
pub kid: String,
pub n: String,
pub e: String,
#[serde(default)]
pub endorsements: Vec<String>,
}
impl SigningKey {
#[must_use]
pub fn new(
kid: impl Into<String>,
n: impl Into<String>,
e: impl Into<String>,
endorsements: Vec<String>,
) -> Self {
Self {
kid: kid.into(),
n: n.into(),
e: e.into(),
endorsements,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KeySet {
keys: Vec<SigningKey>,
}
impl KeySet {
#[must_use]
pub const fn new(keys: Vec<SigningKey>) -> Self {
Self { keys }
}
pub fn from_jwks(jwks: &Value) -> Result<Self, TeamsError> {
let list = jwks
.get("keys")
.and_then(Value::as_array)
.ok_or_else(|| TeamsError::Decode("jwks has no keys list".to_owned()))?;
let keys = list
.iter()
.filter(|k| k.get("kty").and_then(Value::as_str) == Some("RSA"))
.filter_map(|k| SigningKey::deserialize(k).ok())
.collect();
Ok(Self { keys })
}
#[must_use]
pub fn get(&self, kid: &str) -> Option<&SigningKey> {
self.keys.iter().find(|k| k.kid == kid)
}
#[must_use]
pub const fn len(&self) -> usize {
self.keys.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
pub trait KeySource: Send + Sync + 'static {
fn fetch(&self) -> BoxFuture<'_, Result<KeySet, TeamsError>>;
}
#[derive(Debug, Clone)]
pub struct StaticKeys(pub KeySet);
impl KeySource for StaticKeys {
fn fetch(&self) -> BoxFuture<'_, Result<KeySet, TeamsError>> {
let keys = self.0.clone();
Box::pin(async move { Ok(keys) })
}
}
pub struct OpenIdKeySource {
transport: Arc<dyn HttpTransport>,
metadata_url: String,
}
impl std::fmt::Debug for OpenIdKeySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenIdKeySource")
.field("metadata_url", &self.metadata_url)
.finish_non_exhaustive()
}
}
impl OpenIdKeySource {
#[must_use]
pub fn new(transport: Arc<dyn HttpTransport>, metadata_url: impl Into<String>) -> Self {
Self {
transport,
metadata_url: metadata_url.into(),
}
}
}
impl KeySource for OpenIdKeySource {
fn fetch(&self) -> BoxFuture<'_, Result<KeySet, TeamsError>> {
Box::pin(async move {
let meta = get_json(self.transport.as_ref(), &self.metadata_url).await?;
let jwks_uri = meta
.get("jwks_uri")
.and_then(Value::as_str)
.filter(|u| crate::config::valid_url(u))
.ok_or_else(|| TeamsError::Decode("metadata has no https jwks_uri".to_owned()))?;
KeySet::from_jwks(&get_json(self.transport.as_ref(), jwks_uri).await?)
})
}
}
async fn get_json(transport: &dyn HttpTransport, url: &str) -> Result<Value, TeamsError> {
let reply = transport
.send(HttpRequest::new(Method::Get, url))
.await
.map_err(|e| TeamsError::Transport(e.0))?;
if !(200..300).contains(&reply.status) {
return Err(TeamsError::Api {
operation: "openid_keys",
status: reply.status,
code: "unknown".to_owned(),
message: None,
});
}
serde_json::from_slice(&reply.body).map_err(|e| TeamsError::Decode(format!("keys: {e}")))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VerifiedToken {
pub service_url: Option<String>,
pub endorsements: Vec<String>,
}
#[derive(Default)]
struct Cache {
keys: Option<(KeySet, u64)>,
last_failure: Option<u64>,
last_unknown_kid: Option<u64>,
}
#[derive(Deserialize)]
struct Claims {
iss: Option<String>,
aud: Option<Value>,
exp: Option<u64>,
nbf: Option<u64>,
#[serde(alias = "serviceUrl")]
serviceurl: Option<String>,
}
pub(crate) struct VerifierRules {
pub app_id: String,
pub issuers: Vec<String>,
pub clock_skew_secs: u64,
pub key_cache_secs: u64,
pub key_min_refresh_secs: u64,
}
const KEY_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
struct KeyCache {
source: Arc<dyn KeySource>,
metrics: Arc<TeamsMetrics>,
clock: Arc<dyn ClockSource>,
cache_secs: u64,
min_refresh_secs: u64,
cache: Mutex<Cache>,
refresh: Arc<tokio::sync::Mutex<()>>,
}
pub(crate) struct Verifier {
rules: VerifierRules,
keys: Arc<KeyCache>,
}
impl Verifier {
pub(crate) fn new(
rules: VerifierRules,
source: Arc<dyn KeySource>,
metrics: Arc<TeamsMetrics>,
clock: Arc<dyn ClockSource>,
) -> Self {
let keys = Arc::new(KeyCache {
source,
metrics,
clock,
cache_secs: rules.key_cache_secs,
min_refresh_secs: rules.key_min_refresh_secs,
cache: Mutex::new(Cache::default()),
refresh: Arc::new(tokio::sync::Mutex::const_new(())),
});
Self { rules, keys }
}
pub(crate) async fn verify(
&self,
authorization: Option<&str>,
) -> Result<VerifiedToken, AuthError> {
let now = self.keys.now();
let header = authorization.ok_or(AuthError::MissingToken)?;
if header.len() > MAX_TOKEN_BYTES {
return Err(AuthError::Malformed);
}
let token = match header.split_once(' ') {
Some((scheme, t)) if scheme.eq_ignore_ascii_case("bearer") => t.trim(),
_ => return Err(AuthError::Malformed),
};
let head = jsonwebtoken::decode_header(token).map_err(|_| AuthError::Malformed)?;
if head.alg != Algorithm::RS256 {
return Err(AuthError::Algorithm);
}
let kid = head.kid.ok_or(AuthError::UnknownKey)?;
let key = self.keys.key(&kid, now).await?;
let decoding =
DecodingKey::from_rsa_components(&key.n, &key.e).map_err(|_| AuthError::Signature)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.validate_exp = false;
validation.validate_nbf = false;
validation.validate_aud = false;
validation.required_spec_claims.clear();
let claims = jsonwebtoken::decode::<Claims>(token, &decoding, &validation)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::InvalidSignature => AuthError::Signature,
_ => AuthError::Malformed,
})?
.claims;
let r = &self.rules;
if !claims.iss.is_some_and(|i| r.issuers.contains(&i)) {
return Err(AuthError::Issuer);
}
let aud_ok = match &claims.aud {
Some(Value::String(a)) => *a == r.app_id,
Some(Value::Array(list)) => list.iter().any(|a| a.as_str() == Some(&r.app_id)),
_ => false,
};
if !aud_ok {
return Err(AuthError::Audience);
}
let exp = claims.exp.ok_or(AuthError::Expired)?;
if !token_time_valid(now, claims.nbf, exp, r.clock_skew_secs) {
return Err(AuthError::Expired);
}
Ok(VerifiedToken {
service_url: claims.serviceurl,
endorsements: key.endorsements,
})
}
}
impl KeyCache {
fn now(&self) -> u64 {
clock_unix_secs(self.clock.as_ref())
}
fn find(&self, kid: &str, now: u64) -> (Option<SigningKey>, bool, bool) {
let cache = lock(&self.cache);
cache
.keys
.as_ref()
.map_or((None, false, false), |(set, at)| {
(
set.get(kid).cloned(),
keys_fresh(now, *at, self.cache_secs),
keys_fresh(now, *at, MAX_STALE_KEY_AGE_SECS),
)
})
}
async fn key(self: &Arc<Self>, kid: &str, now: u64) -> Result<SigningKey, AuthError> {
let (found, fresh, usable) = self.find(kid, now);
if let Some(k) = found {
if fresh {
return Ok(k);
}
if usable {
if let Ok(turn) = Arc::clone(&self.refresh).try_lock_owned() {
let me = Arc::clone(self);
drop(tokio::spawn(async move { me.refresh(None, turn).await }));
}
return Ok(k);
}
}
if fresh && !self.may_refresh_unknown(now) {
return Err(AuthError::UnknownKey);
}
let me = Arc::clone(self);
let want = kid.to_owned();
tokio::spawn(async move {
me.refresh(Some(&want), Arc::clone(&me.refresh).lock_owned().await)
.await;
})
.await
.map_err(|_| AuthError::KeysUnavailable)?;
let now = self.now();
let cache = lock(&self.cache);
match &cache.keys {
Some((set, at)) if keys_fresh(now, *at, MAX_STALE_KEY_AGE_SECS) => {
set.get(kid).cloned().ok_or(AuthError::UnknownKey)
}
_ => Err(AuthError::KeysUnavailable),
}
}
fn may_refresh_unknown(&self, now: u64) -> bool {
may_refresh_keys(
now,
lock(&self.cache).last_unknown_kid,
self.min_refresh_secs,
)
}
async fn refresh(&self, kid: Option<&str>, _turn: tokio::sync::OwnedMutexGuard<()>) {
let now = self.now();
let min = self.min_refresh_secs;
let load = {
let mut cache = lock(&self.cache);
let fresh = cache
.keys
.as_ref()
.is_some_and(|(_, at)| keys_fresh(now, *at, self.cache_secs));
let known = kid.is_none_or(|k| {
cache
.keys
.as_ref()
.is_some_and(|(set, _)| set.get(k).is_some())
});
if fresh && known {
false
} else if fresh {
let ok = may_refresh_keys(now, cache.last_unknown_kid, min);
if ok {
cache.last_unknown_kid = Some(now);
}
ok
} else {
may_refresh_keys(now, cache.last_failure, min)
}
};
if !load {
return;
}
match tokio::time::timeout(KEY_FETCH_TIMEOUT, self.source.fetch()).await {
Ok(Ok(set)) => {
self.metrics.key_refresh("ok");
let mut cache = lock(&self.cache);
cache.keys = Some((set, now));
cache.last_failure = None;
cache.last_unknown_kid = Some(now);
}
failed => {
self.metrics.key_refresh("error");
if let Ok(Err(e)) = failed {
tracing::warn!(error = %e, "teams: signing key refresh failed");
} else {
tracing::warn!("teams: signing key refresh timed out");
}
lock(&self.cache).last_failure = Some(now);
}
}
}
}
pub(crate) fn check_activity(
token: &VerifiedToken,
activity: &Activity,
allowed_channels: &[String],
allowed_tenants: &[String],
) -> Result<(), AuthError> {
let channel = activity
.channel_id
.as_deref()
.ok_or(AuthError::Endorsement)?;
if !token.endorsements.iter().any(|e| e == channel) {
return Err(AuthError::Endorsement);
}
if !allowed_channels.iter().any(|c| c == channel) {
return Err(AuthError::Channel);
}
match (&token.service_url, &activity.service_url) {
(Some(claim), Some(url)) if same_service_url(claim, url) => {}
_ => return Err(AuthError::ServiceUrl),
}
if !allowed_tenants.is_empty()
&& !activity
.tenant_id()
.is_some_and(|t| allowed_tenants.iter().any(|a| a == t))
{
return Err(AuthError::Tenant);
}
Ok(())
}
fn same_service_url(a: &str, b: &str) -> bool {
let norm = |s: &str| {
url::Url::parse(s).map_or_else(
|_| s.trim_end_matches('/').to_owned(),
|u| u.as_str().trim_end_matches('/').to_owned(),
)
};
norm(a) == norm(b)
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn token(url: Option<&str>, endorsements: &[&str]) -> VerifiedToken {
VerifiedToken {
service_url: url.map(str::to_owned),
endorsements: endorsements.iter().map(|e| (*e).to_owned()).collect(),
}
}
fn activity() -> Activity {
let mut a = Activity::message("x");
a.service_url = Some("https://smba.trafficmanager.net/teams/".into());
a.channel_id = Some("msteams".into());
a.channel_data = Some(json!({"tenant": {"id": "t1"}}));
a
}
#[test]
fn statuses_and_labels() {
assert_eq!(AuthError::MissingToken.status(), 401);
assert_eq!(AuthError::Signature.status(), 401);
assert_eq!(AuthError::Endorsement.status(), 403);
assert_eq!(AuthError::Tenant.status(), 403);
assert_eq!(AuthError::KeysUnavailable.status(), 503);
let all = [
AuthError::MissingToken,
AuthError::Malformed,
AuthError::Algorithm,
AuthError::UnknownKey,
AuthError::Signature,
AuthError::Issuer,
AuthError::Audience,
AuthError::Expired,
AuthError::KeysUnavailable,
AuthError::Endorsement,
AuthError::ServiceUrl,
AuthError::Channel,
AuthError::Tenant,
];
let labels: std::collections::HashSet<_> = all.iter().map(AuthError::label).collect();
assert_eq!(labels.len(), all.len(), "labels are unique");
assert!(all.iter().all(|e| !e.to_string().is_empty()));
}
#[test]
fn check_activity_matrix() {
let ch = vec!["msteams".to_owned()];
let any: Vec<String> = Vec::new();
let url = Some("https://smba.trafficmanager.net/teams/");
let ok = token(url, &["msteams"]);
assert_eq!(check_activity(&ok, &activity(), &ch, &any), Ok(()));
let mut a = activity();
a.service_url = Some("https://SMBA.trafficmanager.net/teams".into());
assert_eq!(check_activity(&ok, &a, &ch, &any), Ok(()));
assert_eq!(
check_activity(&token(url, &["skype"]), &activity(), &ch, &any),
Err(AuthError::Endorsement)
);
assert_eq!(
check_activity(&token(None, &["msteams"]), &activity(), &ch, &any),
Err(AuthError::ServiceUrl)
);
let mut a = activity();
a.service_url = None;
assert_eq!(
check_activity(&ok, &a, &ch, &any),
Err(AuthError::ServiceUrl)
);
let mut a = activity();
a.channel_id = None;
assert_eq!(
check_activity(&ok, &a, &ch, &any),
Err(AuthError::Endorsement)
);
let both = token(url, &["msteams", "webchat"]);
let mut a = activity();
a.channel_id = Some("webchat".into());
assert_eq!(
check_activity(&both, &a, &ch, &any),
Err(AuthError::Channel)
);
let only = vec!["t1".to_owned()];
assert_eq!(check_activity(&ok, &activity(), &ch, &only), Ok(()));
let other = vec!["t2".to_owned()];
assert_eq!(
check_activity(&ok, &activity(), &ch, &other),
Err(AuthError::Tenant)
);
}
#[test]
fn jwks_parse_skips_unusable_keys() {
let set = KeySet::from_jwks(&json!({"keys": [
{"kty": "RSA", "kid": "a", "n": "n1", "e": "AQAB", "endorsements": ["msteams"]},
{"kty": "RSA", "kid": "b", "n": "n2", "e": "AQAB"},
{"kty": "EC", "kid": "c"},
{"kty": "RSA", "n": "n3", "e": "AQAB"},
"junk",
]}))
.unwrap();
assert_eq!(set.len(), 2);
assert!(set.get("b").unwrap().endorsements.is_empty());
assert!(set.get("c").is_none());
assert!(!set.is_empty());
assert!(KeySet::from_jwks(&json!([])).is_err());
assert!(KeySet::default().is_empty());
}
}