use sofie::core::Result;
use sofie::core::encoding::base64::{self, URL_SAFE};
use sofie::serde::Deserialize;
use sofie::serde_json::Value;
use sofie::values::Email;
use sofie_client::Outgoing;
use super::flow::{Identified, refused};
use super::keys::{Algorithm, Keys};
use super::provider::{Provider, TENANT_ID};
const LEEWAY_SECONDS: i64 = 60;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Refusal {
Unreadable,
Algorithm,
Signature,
Issuer,
Audience,
Expired,
IssuedAt,
Nonce,
}
impl Refusal {
fn reason(self) -> &'static str {
match self {
Self::Unreadable => "unreadable",
Self::Algorithm => "algorithm not accepted",
Self::Signature => "signature not by the provider's keys",
Self::Issuer => "another issuer",
Self::Audience => "another audience",
Self::Expired => "expired",
Self::IssuedAt => "issued at no time or in the future",
Self::Nonce => "another sign-in's nonce",
}
}
}
#[derive(Deserialize)]
#[serde(crate = "sofie::serde")]
struct Header {
alg: String,
kid: Option<String>,
crit: Option<Value>,
}
struct Signed<'t> {
message: &'t str,
header: Header,
claims: Value,
signature: Vec<u8>,
}
impl<'t> Signed<'t> {
fn read(token: &'t str) -> Option<Self> {
let (message, signature) = token.rsplit_once('.')?;
let (header, claims) = message.split_once('.')?;
Some(Self {
message,
header: json(header)?,
claims: json(claims)?,
signature: base64::decode(&URL_SAFE, signature)?,
})
}
}
fn json<T: sofie::serde::de::DeserializeOwned>(part: &str) -> Option<T> {
sofie::serde_json::from_slice(&base64::decode(&URL_SAFE, part)?).ok()
}
pub(crate) struct Expected<'a> {
pub(crate) issuer: &'a str,
pub(crate) keys: &'a Keys,
pub(crate) nonce: &'a str,
}
pub(crate) async fn identified(
http: &Outgoing,
provider: &Provider,
expected: &Expected<'_>,
token: &str,
now: i64,
) -> Result<Identified> {
let refusal = |refusal: Refusal| {
let provider_name = provider.name.clone();
let reason = refusal.reason().to_owned();
sofie::telemetry::warn!(provider_name, reason, "an ID token was refused");
let shown = match refusal {
Refusal::Unreadable => "sent an ID token that does not read",
_ => "sent an ID token for another sign-in",
};
refused(provider, shown)
};
let signed = Signed::read(token).ok_or_else(|| refusal(Refusal::Unreadable))?;
signature_holds(http, expected, &signed, now)
.await?
.map_err(refusal)?;
claims_hold(&signed.claims, provider, expected, now).map_err(refusal)?;
who(&signed.claims).ok_or_else(|| refused(provider, "named nobody"))
}
async fn signature_holds(
http: &Outgoing,
expected: &Expected<'_>,
signed: &Signed<'_>,
now: i64,
) -> Result<Result<(), Refusal>> {
let header = &signed.header;
let Some(algorithm) = Algorithm::named(&header.alg).filter(|_| header.crit.is_none()) else {
return Ok(Err(Refusal::Algorithm));
};
let kid = header.kid.as_deref();
let keys = expected
.keys
.current(http, expected.issuer, kid, now)
.await?;
let message = signed.message.as_bytes();
let verified = keys
.iter()
.filter(|key| key.named(kid))
.any(|key| key.verifies(algorithm, message, &signed.signature));
Ok(if verified {
Ok(())
} else {
Err(Refusal::Signature)
})
}
fn claims_hold(
claims: &Value,
provider: &Provider,
expected: &Expected<'_>,
now: i64,
) -> Result<(), Refusal> {
if !issued_by(claims, expected.issuer) {
return Err(Refusal::Issuer);
}
if !addressed_to(claims, &provider.client_id) {
return Err(Refusal::Audience);
}
if claims["exp"]
.as_i64()
.is_none_or(|expiry| expiry + LEEWAY_SECONDS <= now)
{
return Err(Refusal::Expired);
}
if claims["iat"]
.as_i64()
.is_none_or(|issued| issued - LEEWAY_SECONDS > now)
{
return Err(Refusal::IssuedAt);
}
if claims["nonce"] != expected.nonce {
return Err(Refusal::Nonce);
}
Ok(())
}
fn issued_by(claims: &Value, issuer: &str) -> bool {
let Some(tenant) = claims["tid"].as_str().filter(|tenant| !tenant.is_empty()) else {
return claims["iss"] == issuer;
};
claims["iss"] == issuer.replace(TENANT_ID, tenant)
}
fn addressed_to(claims: &Value, client_id: &str) -> bool {
match &claims["aud"] {
Value::String(audience) => audience == client_id,
Value::Array(audiences) => {
audiences.iter().any(|audience| audience == client_id)
&& (audiences.len() == 1 || claims["azp"] == client_id)
}
_ => false,
}
}
fn who(claims: &Value) -> Option<Identified> {
let subject = claims["sub"]
.as_str()
.filter(|subject| !subject.is_empty())?;
Some(Identified {
subject: subject.to_owned(),
email: claims["email"]
.as_str()
.and_then(|email| Email::parse(email).ok()),
verified: claims["email_verified"] == true || claims["email_verified"] == "true",
})
}
#[cfg(test)]
mod tests {
use sofie::serde_json::json;
use sofie::values::Secret;
use super::super::keys::KeysAt;
use super::*;
const ISSUED: &str =
"https://login.microsoftonline.com/0a1b2c3d-0000-4000-8000-000000000001/v2.0";
const NOW: i64 = 1_000_000_000;
fn microsoft() -> Provider {
let client = ("client".to_owned(), Secret::new("shh".to_owned()));
Provider::microsoft("common", client).unwrap()
}
fn checked(claims: &Value) -> Result<(), Refusal> {
let provider = microsoft();
let keys = Keys::new(KeysAt::Discovery);
let expected = Expected {
issuer: "https://login.microsoftonline.com/{tenantid}/v2.0",
keys: &keys,
nonce: "n",
};
claims_hold(claims, &provider, &expected, NOW)
}
fn claims(iss: &str, tid: &str) -> Value {
json!({
"iss": iss, "tid": tid, "aud": "client", "exp": NOW + 600, "iat": NOW,
"nonce": "n", "sub": "someone",
})
}
#[test]
fn microsoft_common_takes_the_tenant_its_token_names() {
let tenant = "0a1b2c3d-0000-4000-8000-000000000001";
assert_eq!(checked(&claims(ISSUED, tenant)), Ok(()));
let other_tenant = "ffffffff-0000-4000-8000-000000000001";
assert_eq!(checked(&claims(ISSUED, other_tenant)), Err(Refusal::Issuer));
assert_eq!(checked(&claims(ISSUED, "")), Err(Refusal::Issuer));
let elsewhere = claims("https://evil.example/v2.0", tenant);
assert_eq!(checked(&elsewhere), Err(Refusal::Issuer));
}
#[test]
fn audience_time_and_nonce_are_this_sign_ins() {
let tenant = "0a1b2c3d-0000-4000-8000-000000000001";
let with = |field: &str, value: Value| {
let mut claims = claims(ISSUED, tenant);
claims[field] = value;
checked(&claims)
};
assert_eq!(with("aud", json!("other")), Err(Refusal::Audience));
assert_eq!(
with("aud", json!(["client", "other"])),
Err(Refusal::Audience)
);
let mut shared = claims(ISSUED, tenant);
shared["aud"] = json!(["client", "other"]);
shared["azp"] = json!("client");
assert_eq!(checked(&shared), Ok(()));
assert_eq!(with("exp", json!(NOW - 61)), Err(Refusal::Expired));
assert_eq!(with("exp", json!(NOW - 30)), Ok(()));
assert_eq!(with("iat", json!(NOW + 61)), Err(Refusal::IssuedAt));
assert_eq!(with("iat", Value::Null), Err(Refusal::IssuedAt));
assert_eq!(with("nonce", json!("m")), Err(Refusal::Nonce));
}
}