use crate::auth::error::TokenVerificationError;
use crate::auth::id_token::claims::IdTokenClaims;
use crate::auth::id_token::jwks::JwksCache;
use crate::core::ProjectId;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
pub struct IdTokenVerifier {
project_id: ProjectId,
jwks: JwksCache,
}
impl IdTokenVerifier {
pub fn new(project_id: ProjectId, jwks: JwksCache) -> Self {
Self { project_id, jwks }
}
pub async fn verify(&self, token: &str) -> Result<IdTokenClaims, TokenVerificationError> {
let header = decode_header(token)?;
if header.alg != Algorithm::RS256 {
return Err(TokenVerificationError::InvalidSignature);
}
let kid = header.kid.ok_or(TokenVerificationError::InvalidSignature)?;
let (n, e) = self.jwks.public_key(&kid).await?;
let decoding_key = DecodingKey::from_rsa_components(&n, &e)
.map_err(|_| TokenVerificationError::InvalidSignature)?;
verify_with_key(
token,
&decoding_key,
self.project_id.as_str(),
"https://securetoken.google.com/",
)
}
}
pub(crate) fn verify_with_key(
token: &str,
decoding_key: &DecodingKey,
project_id: &str,
issuer_prefix: &str,
) -> Result<IdTokenClaims, TokenVerificationError> {
let expected_issuer = format!("{issuer_prefix}{project_id}");
let mut validation = Validation::new(Algorithm::RS256);
validation.set_audience(&[project_id]);
validation.set_issuer(&[expected_issuer]);
validation.validate_exp = true;
let token_data =
decode::<IdTokenClaims>(token, decoding_key, &validation).map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => TokenVerificationError::Expired,
jsonwebtoken::errors::ErrorKind::InvalidAudience => {
TokenVerificationError::AudienceMismatch
}
jsonwebtoken::errors::ErrorKind::InvalidIssuer => {
TokenVerificationError::IssuerMismatch
}
jsonwebtoken::errors::ErrorKind::InvalidSignature => {
TokenVerificationError::InvalidSignature
}
_ => TokenVerificationError::Malformed(e),
})?;
let claims = token_data.claims;
if claims.sub.trim().is_empty() {
return Err(TokenVerificationError::MissingSubject);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is before the Unix epoch")
.as_secs() as i64;
if claims.auth_time > now {
return Err(TokenVerificationError::NotYetValid);
}
Ok(claims)
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{encode, EncodingKey, Header};
use serde_json::json;
const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../tests/fixtures/test_private_key.pem");
const TEST_PROJECT_ID: &str = "test-project";
fn sign(claims: &serde_json::Value, alg: Algorithm) -> String {
let mut header = Header::new(alg);
header.kid = Some("test-key".to_string());
let key = EncodingKey::from_rsa_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).unwrap();
encode(&header, claims, &key).unwrap()
}
fn decoding_key() -> DecodingKey {
DecodingKey::from_rsa_pem(include_bytes!(
"../../../tests/fixtures/test_public_key.pem"
))
.unwrap()
}
fn valid_claims(now: i64) -> serde_json::Value {
json!({
"iss": format!("https://securetoken.google.com/{TEST_PROJECT_ID}"),
"aud": TEST_PROJECT_ID,
"iat": now - 10,
"exp": now + 3600,
"auth_time": now - 10,
"sub": "test-uid",
})
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[test]
fn accepts_a_valid_token() {
let token = sign(&valid_claims(now()), Algorithm::RS256);
let claims = verify_with_key(
&token,
&decoding_key(),
TEST_PROJECT_ID,
"https://securetoken.google.com/",
)
.unwrap();
assert_eq!(claims.sub, "test-uid");
}
#[test]
fn rejects_expired_token() {
let mut claims = valid_claims(now());
claims["exp"] = json!(now() - 100);
let token = sign(&claims, Algorithm::RS256);
let err = verify_with_key(
&token,
&decoding_key(),
TEST_PROJECT_ID,
"https://securetoken.google.com/",
)
.unwrap_err();
assert!(matches!(err, TokenVerificationError::Expired));
}
#[test]
fn rejects_wrong_audience() {
let mut claims = valid_claims(now());
claims["aud"] = json!("some-other-project");
let token = sign(&claims, Algorithm::RS256);
let err = verify_with_key(
&token,
&decoding_key(),
TEST_PROJECT_ID,
"https://securetoken.google.com/",
)
.unwrap_err();
assert!(matches!(err, TokenVerificationError::AudienceMismatch));
}
#[test]
fn rejects_wrong_issuer() {
let mut claims = valid_claims(now());
claims["iss"] = json!("https://securetoken.google.com/some-other-project");
let token = sign(&claims, Algorithm::RS256);
let err = verify_with_key(
&token,
&decoding_key(),
TEST_PROJECT_ID,
"https://securetoken.google.com/",
)
.unwrap_err();
assert!(matches!(err, TokenVerificationError::IssuerMismatch));
}
#[test]
fn rejects_missing_subject() {
let mut claims = valid_claims(now());
claims["sub"] = json!("");
let token = sign(&claims, Algorithm::RS256);
let err = verify_with_key(
&token,
&decoding_key(),
TEST_PROJECT_ID,
"https://securetoken.google.com/",
)
.unwrap_err();
assert!(matches!(err, TokenVerificationError::MissingSubject));
}
#[test]
fn rejects_not_yet_valid_auth_time() {
let mut claims = valid_claims(now());
claims["auth_time"] = json!(now() + 3600);
let token = sign(&claims, Algorithm::RS256);
let err = verify_with_key(
&token,
&decoding_key(),
TEST_PROJECT_ID,
"https://securetoken.google.com/",
)
.unwrap_err();
assert!(matches!(err, TokenVerificationError::NotYetValid));
}
#[test]
fn rejects_tampered_signature() {
let token = sign(&valid_claims(now()), Algorithm::RS256);
let mut parts: Vec<&str> = token.split('.').collect();
let tampered_payload = parts[1]
.chars()
.map(|c| if c == 'A' { 'B' } else { 'A' })
.collect::<String>();
parts[1] = &tampered_payload;
let tampered = parts.join(".");
let err = verify_with_key(
&tampered,
&decoding_key(),
TEST_PROJECT_ID,
"https://securetoken.google.com/",
)
.unwrap_err();
assert!(matches!(
err,
TokenVerificationError::InvalidSignature | TokenVerificationError::Malformed(_)
));
}
}