use base64ct::{Base64UrlUnpadded, Encoding};
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, VerifyingKey};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::{ALGORITHM, LEEWAY_SECS};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum VerifyError {
#[error("token is not three base64url segments")]
Malformed,
#[error("header is not JSON, or omits alg/kid")]
BadHeader,
#[error("algorithm {0} is not accepted; only {ALGORITHM} is")]
Algorithm(String),
#[error("no key with id {0} is published by the issuer")]
UnknownKey(String),
#[error("signature does not verify")]
Signature,
#[error("payload is not JSON, or omits a required claim")]
BadPayload,
#[error("issuer is {got}, expected {want}")]
Issuer { got: String, want: String },
#[error("audience is {got}, expected {want}")]
Audience { got: String, want: String },
#[error("token expired")]
Expired,
#[error("token is not valid yet")]
NotYetValid,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Claims {
pub sub: String,
pub aud: String,
pub iss: String,
pub sid: String,
pub exp: i64,
pub iat: i64,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub email_verified: Option<bool>,
#[serde(default)]
pub amr: Vec<String>,
#[serde(default)]
pub sso_connection: Option<String>,
}
impl Claims {
#[must_use]
pub fn signed_in_via_sso(&self, connection_id: &str) -> bool {
self.sso_connection.as_deref() == Some(connection_id)
&& self.amr.iter().any(|method| method == "sso")
}
}
#[derive(Deserialize)]
struct Header {
alg: String,
kid: String,
}
pub(crate) fn peek_kid(token: &str) -> Option<String> {
let header = decode_header(token).ok()?;
(header.alg == ALGORITHM).then_some(header.kid)
}
fn decode_header(token: &str) -> Result<Header, VerifyError> {
let encoded = token.split('.').next().ok_or(VerifyError::Malformed)?;
let bytes = Base64UrlUnpadded::decode_vec(encoded).map_err(|_| VerifyError::Malformed)?;
serde_json::from_slice(&bytes).map_err(|_| VerifyError::BadHeader)
}
pub(crate) fn verify_with(
token: &str,
keys: &[(String, VerifyingKey)],
issuer: &str,
audience: &str,
now: i64,
) -> Result<Claims, VerifyError> {
let mut parts = token.split('.');
let (Some(encoded_header), Some(encoded_payload), Some(encoded_signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(VerifyError::Malformed);
};
let header: Header = {
let bytes =
Base64UrlUnpadded::decode_vec(encoded_header).map_err(|_| VerifyError::Malformed)?;
serde_json::from_slice(&bytes).map_err(|_| VerifyError::BadHeader)?
};
if header.alg != ALGORITHM {
return Err(VerifyError::Algorithm(header.alg));
}
let key = keys
.iter()
.find(|(kid, _)| *kid == header.kid)
.map(|(_, key)| key)
.ok_or_else(|| VerifyError::UnknownKey(header.kid.clone()))?;
let signature_bytes =
Base64UrlUnpadded::decode_vec(encoded_signature).map_err(|_| VerifyError::Malformed)?;
let signature = Signature::from_slice(&signature_bytes).map_err(|_| VerifyError::Signature)?;
let signing_input = format!("{encoded_header}.{encoded_payload}");
key.verify(signing_input.as_bytes(), &signature)
.map_err(|_| VerifyError::Signature)?;
let payload_bytes =
Base64UrlUnpadded::decode_vec(encoded_payload).map_err(|_| VerifyError::Malformed)?;
let claims: Claims =
serde_json::from_slice(&payload_bytes).map_err(|_| VerifyError::BadPayload)?;
if claims.iss != issuer {
return Err(VerifyError::Issuer {
got: claims.iss,
want: issuer.to_owned(),
});
}
if claims.aud != audience {
return Err(VerifyError::Audience {
got: claims.aud,
want: audience.to_owned(),
});
}
if now - LEEWAY_SECS >= claims.exp {
return Err(VerifyError::Expired);
}
if claims.iat - LEEWAY_SECS > now {
return Err(VerifyError::NotYetValid);
}
Ok(claims)
}
#[must_use]
pub fn token_fingerprint(token: &str) -> String {
use std::fmt::Write as _;
let digest = Sha256::digest(token.as_bytes());
digest.iter().take(6).fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}