From d6f1a3c38edbfef527de03a1c1cc9d608c103391 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Mon, 26 Jan 2026 19:18:35 +0000 Subject: [PATCH 1/9] impl(auth): move away from jsonwebtoken crate --- Cargo.lock | 31 +--- src/auth/Cargo.toml | 9 +- src/auth/src/credentials/idtoken.rs | 31 ++-- src/auth/src/credentials/idtoken/verifier.rs | 132 ++++++++++++++---- .../src/credentials/internal/jwk_client.rs | 87 +++++++----- src/auth/src/credentials/service_account.rs | 4 +- .../src/credentials/service_account/jws.rs | 12 +- src/auth/src/lib.rs | 8 +- 8 files changed, 189 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0537a7d36..a20d383145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", - "untrusted 0.7.1", "zeroize", ] @@ -1430,7 +1429,6 @@ dependencies = [ "google-cloud-gax", "http", "httptest", - "jsonwebtoken", "mockall", "mutants", "regex", @@ -5878,21 +5876,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "10.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" -dependencies = [ - "aws-lc-rs", - "base64", - "getrandom 0.2.17", - "js-sys", - "serde", - "serde_json", - "signature", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -6697,9 +6680,6 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] [[package]] name = "rand_core" @@ -6858,7 +6838,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -6876,6 +6856,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2", "signature", "spki", "subtle", @@ -6984,7 +6965,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -8133,12 +8114,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml index fef8a9ab53..9605aa2f31 100644 --- a/src/auth/Cargo.toml +++ b/src/auth/Cargo.toml @@ -46,7 +46,7 @@ serde_json.workspace = true thiserror.workspace = true time = { workspace = true, features = ["serde"] } tokio = { workspace = true, features = ["fs", "process"] } -jsonwebtoken = { workspace = true, optional = true } +rsa = { workspace = true, optional = true, features = ["pem", "sha2"] } # We do not use this directly, but without it the minimal-versions build breaks. # See: https://github.com/Keats/jsonwebtoken/pull/481 aws-lc-rs = { workspace = true, optional = true } @@ -58,7 +58,6 @@ anyhow.workspace = true httptest.workspace = true mockall.workspace = true regex.workspace = true -rsa = { workspace = true, features = ["pem"] } scoped-env.workspace = true serial_test.workspace = true tempfile.workspace = true @@ -72,12 +71,12 @@ mutants.workspace = true default = ["default-idtoken-backend", "default-rustls-provider"] # The `idtoken` feature enables support to create and validate OIDC ID Tokens. # See the create top-level documentation for more information. -idtoken = ["dep:jsonwebtoken", "jsonwebtoken"] +idtoken = ["dep:rsa"] # By default this crate enables the `aws_lc_rs` backend. Applications can # link `google-cloud-auth` with `default-features = false, features = ["idtoken"] # and then directly configure the `jsonwebtoken` features to select the # `rust_crypto` backend. -default-idtoken-backend = ["dep:aws-lc-rs", "jsonwebtoken?/aws_lc_rs"] +default-idtoken-backend = [] # Enabled by default. Use the default rustls crypto provider ([aws-lc-rs]) for # TLS and authentication. Applications with specific requirements for # cryptography (such as exclusively using the [ring] crate) should disable this @@ -85,7 +84,7 @@ default-idtoken-backend = ["dep:aws-lc-rs", "jsonwebtoken?/aws_lc_rs"] default-rustls-provider = ["reqwest/default-tls", "rustls/aws-lc-rs"] # Do not use, this was a mistake in the 1.3 release. We accidentally introduced # this feature. The intent was to only introduce `idtoken`. -jsonwebtoken = ["dep:jsonwebtoken"] +jsonwebtoken = [] [lints] workspace = true diff --git a/src/auth/src/credentials/idtoken.rs b/src/auth/src/credentials/idtoken.rs index 8f6b5b4cf9..c04b5bb61b 100644 --- a/src/auth/src/credentials/idtoken.rs +++ b/src/auth/src/credentials/idtoken.rs @@ -334,9 +334,12 @@ fn instant_from_epoch_seconds(secs: u64, now: SystemTime) -> Option { #[cfg(test)] pub(crate) mod tests { use super::*; - use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use crate::credentials::service_account::jws::JwsHeader; + use crate::credentials::tests::PKCS8_PK; use mds::Format; - use rsa::pkcs1::EncodeRsaPrivateKey; + use rsa::pkcs8::DecodePrivateKey; + use rsa::sha2::{Digest, Sha256}; + use rsa::{Pkcs1v15Sign, RsaPrivateKey}; use serde_json::json; use serial_test::parallel; use std::collections::HashMap; @@ -367,8 +370,11 @@ pub(crate) mod tests { let now = now.duration_since(UNIX_EPOCH).unwrap(); let then = now + DEFAULT_TEST_TOKEN_EXPIRATION; - let mut header = Header::new(Algorithm::RS256); - header.kid = Some(TEST_KEY_ID.to_string()); + let header = JwsHeader { + alg: "RS256", + typ: "JWT", + kid: Some(TEST_KEY_ID.to_string()), + }; let mut claims: HashMap<&str, Value> = HashMap::new(); claims.insert("aud", Value::String(audience)); @@ -380,13 +386,20 @@ pub(crate) mod tests { claims.insert(k, v); } - let private_cert = crate::credentials::tests::RSA_PRIVATE_KEY - .to_pkcs1_der() - .expect("Failed to encode private key to PKCS#1 DER"); + let key = + RsaPrivateKey::from_pkcs8_pem(&PKCS8_PK).expect("Failed to parse private key PEM"); + + let encoded_header = header.encode().unwrap(); + let encoded_claims = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); - let private_key = EncodingKey::from_rsa_der(private_cert.as_bytes()); + let to_sign = format!("{}.{}", encoded_header, encoded_claims); + let digest = Sha256::digest(to_sign.as_bytes()); + let sig = key + .sign(Pkcs1v15Sign::new::(), &digest) + .expect("Failed to sign"); + let encoded_sig = URL_SAFE_NO_PAD.encode(sig); - jsonwebtoken::encode(&header, &claims, &private_key).expect("failed to encode jwt") + format!("{}.{}", to_sign, encoded_sig) } #[tokio::test(start_paused = true)] diff --git a/src/auth/src/credentials/idtoken/verifier.rs b/src/auth/src/credentials/idtoken/verifier.rs index 9811a73af5..333ba8e2b4 100644 --- a/src/auth/src/credentials/idtoken/verifier.rs +++ b/src/auth/src/credentials/idtoken/verifier.rs @@ -37,12 +37,16 @@ //! [OIDC ID Tokens]: https://cloud.google.com/docs/authentication/token-types#identity-tokens use crate::credentials::internal::jwk_client::JwkClient; -use jsonwebtoken::Validation; +use crate::credentials::service_account::jws::JwsHeader; +use base64::Engine; +use rsa::Pkcs1v15Sign; +use rsa::sha2::{Digest, Sha256}; /// Represents the claims in an ID token. pub use serde_json::Map; /// Represents a claim value in an ID token. pub use serde_json::Value; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; /// Builder is used construct a [Verifier] of id tokens. pub struct Builder { @@ -165,38 +169,99 @@ pub struct Verifier { impl Verifier { /// Verifies the ID token and returns the claims. pub async fn verify(&self, token: &str) -> std::result::Result, Error> { - let header = jsonwebtoken::decode_header(token).map_err(Error::decode)?; + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(Error::decode("token must have 3 parts")); + } + + let header_json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(Error::decode)?; + let header: JwsHeader = serde_json::from_slice(&header_json).map_err(Error::decode)?; let key_id = header .kid .ok_or_else(|| Error::invalid_field("kid", "kid header is missing"))?; - let mut validation = Validation::new(header.alg); - validation.leeway = self.clock_skew.as_secs(); - // TODO(#3591): Support TPC/REP that can have different issuers - validation.set_issuer(&["https://accounts.google.com", "accounts.google.com"]); - validation.set_audience(&self.audiences); + if header.alg != "RS256" { + return Err(Error::invalid_field( + "alg", + format!("Unsupported algorithm: {}", header.alg), + )); + } + + let claims_json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(Error::decode)?; + let claims: Map = + serde_json::from_slice(&claims_json).map_err(Error::decode)?; + + let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(Error::decode)?; let expected_email = self.email.clone(); let jwks_url = self.jwks_url.clone(); let cert = self .jwk_client - .get_or_load_cert(key_id, header.alg, jwks_url) + .get_or_load_cert(key_id.to_string(), header.alg, jwks_url) .await .map_err(Error::load_cert)?; - let token = jsonwebtoken::decode::>(&token, &cert, &validation) - .map_err(|e| match e.clone().into_kind() { - jsonwebtoken::errors::ErrorKind::InvalidIssuer => Error::invalid_field("iss", e), - jsonwebtoken::errors::ErrorKind::InvalidAudience => Error::invalid_field("aud", e), - jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(field) => { - Error::invalid_field(field.as_str(), e) - } - _ => Error::invalid(e), - })?; + let message = format!("{}.{}", parts[0], parts[1]); + let hashed = Sha256::digest(message.as_bytes()); + + cert.verify(Pkcs1v15Sign::new::(), &hashed, &signature) + .map_err(|e| Error::invalid(format!("Invalid signature: {}", e)))?; + + // Validate Payload claims + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let exp = claims + .get("exp") + .and_then(|v| v.as_u64()) + .ok_or_else(|| Error::invalid_field("exp", "exp claim is missing"))?; + + if now > exp + self.clock_skew.as_secs() { + return Err(Error::invalid_field("exp", "Token has expired")); + } + + let iss = claims + .get("iss") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::invalid_field("iss", "iss claim is missing"))?; + + // TODO(#3591): Support TPC/REP that can have different issuers + let valid_issuers = ["https://accounts.google.com", "accounts.google.com"]; + if !valid_issuers.contains(&iss) { + return Err(Error::invalid_field( + "iss", + format!("Invalid issuer: {}", iss), + )); + } + + let aud_val = claims + .get("aud") + .ok_or_else(|| Error::invalid_field("aud", "aud claim is missing"))?; + let aud_match = match aud_val { + Value::String(s) => self.audiences.contains(s), + Value::Array(arr) => arr.iter().any(|v| { + v.as_str() + .map_or(false, |s| self.audiences.contains(&s.to_string())) + }), + _ => return Err(Error::invalid_field("aud", "Invalid aud format")), + }; + + if !aud_match { + return Err(Error::invalid_field( + "aud", + format!("Invalid audience: {:?}", aud_val), + )); + } - let claims = token.claims; if let Some(email) = expected_email { let email_verified = claims["email_verified"] .as_bool() @@ -297,13 +362,13 @@ pub(crate) mod tests { use crate::credentials::idtoken::tests::{ TEST_KEY_ID, generate_test_id_token, generate_test_id_token_with_claims, }; + use crate::credentials::service_account::jws::JwsHeader; use base64::Engine; use httptest::matchers::{all_of, request}; use httptest::responders::{json_encoded, status_code}; use httptest::{Expectation, Server}; - use jsonwebtoken::{Algorithm, EncodingKey, Header}; - use rsa::pkcs1::EncodeRsaPrivateKey; use rsa::traits::PublicKeyParts; + use serde_json::json; use std::collections::HashMap; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -581,17 +646,22 @@ pub(crate) mod tests { #[tokio::test] async fn test_verify_missing_kid() -> TestResult { - let header = Header::new(Algorithm::RS256); - let claims: HashMap<&str, Value> = HashMap::new(); - - let private_cert = crate::credentials::tests::RSA_PRIVATE_KEY - .to_pkcs1_der() - .expect("Failed to encode private key to PKCS#1 DER"); - - let private_key = EncodingKey::from_rsa_der(private_cert.as_bytes()); - - let token = - jsonwebtoken::encode(&header, &claims, &private_key).expect("failed to encode jwt"); + let header = JwsHeader { + alg: "RS256", + typ: "JWT", + kid: None, + }; + + let claims_json = json!({}); + let encoded_claims = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims_json.to_string()); + + let token = format!( + "{}.{}.{}", + header.encode()?, + encoded_claims, + "signature_placeholder" + ); let verifier = Builder::new(["https://example.com"]).build(); diff --git a/src/auth/src/credentials/internal/jwk_client.rs b/src/auth/src/credentials/internal/jwk_client.rs index 8a3698c9dd..d453c603d3 100644 --- a/src/auth/src/credentials/internal/jwk_client.rs +++ b/src/auth/src/credentials/internal/jwk_client.rs @@ -14,7 +14,9 @@ use crate::Result; use crate::errors::CredentialsError; -use jsonwebtoken::{Algorithm, DecodingKey, jwk::JwkSet}; +use base64::Engine; +use rsa::{BigUint, RsaPublicKey}; +use serde::Deserialize; use std::{ collections::HashMap, sync::Arc, @@ -26,9 +28,27 @@ const IAP_JWK_URL: &str = "https://www.gstatic.com/iap/verify/public_key-jwk"; const OAUTH2_JWK_URL: &str = "https://www.googleapis.com/oauth2/v3/certs"; const CACHE_TTL: Duration = Duration::from_secs(3600); +#[derive(Clone, Debug, Deserialize)] +struct Jwk { + pub n: String, + pub e: String, + pub kid: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct JwkSet { + keys: Vec, +} + +impl JwkSet { + fn find(&self, kid: &str) -> Option<&Jwk> { + self.keys.iter().find(|k| k.kid == kid) + } +} + #[derive(Clone, Debug)] struct CacheEntry { - key: DecodingKey, + key: RsaPublicKey, expires_at: Instant, } @@ -57,9 +77,9 @@ impl JwkClient { pub async fn get_or_load_cert( &self, key_id: String, - alg: Algorithm, + alg: &str, jwks_url: Option, - ) -> Result { + ) -> Result { let key_id_str = key_id.as_str(); let mut cache = self.cache.try_write().map_err(|_e| { CredentialsError::from_msg(false, "failed to obtain lock to read certificate cache") @@ -76,8 +96,17 @@ impl JwkClient { CredentialsError::from_msg(false, "JWKS did not contain a matching `kid`") })?; - let key = DecodingKey::from_jwk(jwk) - .map_err(|e| CredentialsError::new(false, "failed to parse JWK", e))?; + let n_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&jwk.n) + .map_err(|e| CredentialsError::from_msg(false, format!("Invalid n in JWK: {}", e)))?; + let e_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&jwk.e) + .map_err(|e| CredentialsError::from_msg(false, format!("Invalid e in JWK: {}", e)))?; + + let n = BigUint::from_bytes_be(&n_bytes); + let e = BigUint::from_bytes_be(&e_bytes); + let key = RsaPublicKey::new(n, e) + .map_err(|e| CredentialsError::from_msg(false, format!("Invalid RSA key: {}", e)))?; let entry = CacheEntry { key: key.clone(), @@ -88,13 +117,13 @@ impl JwkClient { Ok(key) } - fn resolve_jwks_url(&self, alg: Algorithm, jwks_url: Option) -> Result { + fn resolve_jwks_url(&self, alg: &str, jwks_url: Option) -> Result { if let Some(jwks_url) = jwks_url { return Ok(jwks_url); } match alg { - Algorithm::RS256 => Ok(OAUTH2_JWK_URL.to_string()), - Algorithm::ES256 => Ok(IAP_JWK_URL.to_string()), + "RS256" => Ok(OAUTH2_JWK_URL.to_string()), + "ES256" => Ok(IAP_JWK_URL.to_string()), _ => Err(CredentialsError::from_msg( false, format!( @@ -134,7 +163,6 @@ mod tests { use httptest::matchers::{all_of, request}; use httptest::responders::json_encoded; use httptest::{Expectation, Server}; - use jsonwebtoken::Algorithm; use rsa::traits::PublicKeyParts; use serial_test::parallel; @@ -174,16 +202,12 @@ mod tests { // First call, should fetch from URL let _key = client - .get_or_load_cert( - TEST_KEY_ID.to_string(), - Algorithm::RS256, - Some(jwks_url.clone()), - ) + .get_or_load_cert(TEST_KEY_ID.to_string(), "RS256", Some(jwks_url.clone())) .await?; // Second call, should use cache let _key = client - .get_or_load_cert(TEST_KEY_ID.to_string(), Algorithm::RS256, Some(jwks_url)) + .get_or_load_cert(TEST_KEY_ID.to_string(), "RS256", Some(jwks_url)) .await?; Ok(()) @@ -204,7 +228,7 @@ mod tests { let jwks_url = format!("http://{}/certs", server.addr()); let result = client - .get_or_load_cert("unknown-kid".to_string(), Algorithm::RS256, Some(jwks_url)) + .get_or_load_cert("unknown-kid".to_string(), "RS256", Some(jwks_url)) .await; assert!(result.is_err()); @@ -231,7 +255,7 @@ mod tests { let jwks_url = format!("http://{}/certs", server.addr()); let result = client - .get_or_load_cert(TEST_KEY_ID.to_string(), Algorithm::RS256, Some(jwks_url)) + .get_or_load_cert(TEST_KEY_ID.to_string(), "RS256", Some(jwks_url)) .await; assert!(result.is_err()); @@ -249,26 +273,21 @@ mod tests { // Custom URL let url = "https://example.com/jwks".to_string(); assert_eq!( - client - .resolve_jwks_url(Algorithm::RS256, Some(url.clone())) - .unwrap(), + client.resolve_jwks_url("RS256", Some(url.clone())).unwrap(), url ); // Default for RS256 assert_eq!( - client.resolve_jwks_url(Algorithm::RS256, None).unwrap(), + client.resolve_jwks_url("RS256", None).unwrap(), OAUTH2_JWK_URL ); // Default for ES256 - assert_eq!( - client.resolve_jwks_url(Algorithm::ES256, None).unwrap(), - IAP_JWK_URL - ); + assert_eq!(client.resolve_jwks_url("ES256", None).unwrap(), IAP_JWK_URL); // Unsupported algorithm - let result = client.resolve_jwks_url(Algorithm::HS256, None); + let result = client.resolve_jwks_url("HS256", None); assert!(result.is_err()); Ok(()) @@ -290,20 +309,12 @@ mod tests { // First call, should fetch from URL and cache it. let _key = client - .get_or_load_cert( - TEST_KEY_ID.to_string(), - Algorithm::RS256, - Some(jwks_url.clone()), - ) + .get_or_load_cert(TEST_KEY_ID.to_string(), "RS256", Some(jwks_url.clone())) .await?; // Second call, should still be cached. let _key = client - .get_or_load_cert( - TEST_KEY_ID.to_string(), - Algorithm::RS256, - Some(jwks_url.clone()), - ) + .get_or_load_cert(TEST_KEY_ID.to_string(), "RS256", Some(jwks_url.clone())) .await?; // Wait for the cache to expire. @@ -311,7 +322,7 @@ mod tests { // This call should fetch from URL again. let _key = client - .get_or_load_cert(TEST_KEY_ID.to_string(), Algorithm::RS256, Some(jwks_url)) + .get_or_load_cert(TEST_KEY_ID.to_string(), "RS256", Some(jwks_url)) .await?; Ok(()) diff --git a/src/auth/src/credentials/service_account.rs b/src/auth/src/credentials/service_account.rs index 625d382d5d..e34d263408 100644 --- a/src/auth/src/credentials/service_account.rs +++ b/src/auth/src/credentials/service_account.rs @@ -70,7 +70,7 @@ //! [Service Account]: https://cloud.google.com/iam/docs/service-account-overview //! [service account key]: https://cloud.google.com/iam/docs/keys-create-delete#creating -mod jws; +pub(crate) mod jws; use crate::build_errors::Error as BuilderError; use crate::constants::DEFAULT_SCOPE; @@ -549,7 +549,7 @@ impl ServiceAccountTokenGenerator { let header = JwsHeader { alg: "RS256", typ: "JWT", - kid: &self.service_account_key.private_key_id, + kid: Some(self.service_account_key.private_key_id.clone()), }; let encoded_header_claims = format!("{}.{}", header.encode()?, claims.encode()?); let sig = signer diff --git a/src/auth/src/credentials/service_account/jws.rs b/src/auth/src/credentials/service_account/jws.rs index 46e4c8cace..daf85cf3d7 100644 --- a/src/auth/src/credentials/service_account/jws.rs +++ b/src/auth/src/credentials/service_account/jws.rs @@ -14,7 +14,7 @@ use crate::credentials::Result; use crate::errors; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::time::Duration; use time::OffsetDateTime; @@ -69,12 +69,12 @@ impl JwsClaims { } /// The header that describes who, what, and how a token was created. -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Debug)] pub struct JwsHeader<'a> { pub alg: &'a str, pub typ: &'a str, - #[serde(skip_serializing_if = "str::is_empty")] - pub kid: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option, } impl JwsHeader<'_> { @@ -210,7 +210,7 @@ mod tests { let header = JwsHeader { alg: "RS256", typ: "JWT", - kid: "some_key_id", + kid: Some("some_key_id".to_string()), }; let encoded = header.encode().unwrap(); let decoded = String::from_utf8( @@ -231,7 +231,7 @@ mod tests { let header = JwsHeader { alg: "RS256", typ: "JWT", - kid: "", + kid: None, }; let encoded = header.encode().unwrap(); let decoded = String::from_utf8( diff --git a/src/auth/src/lib.rs b/src/auth/src/lib.rs index c68433f4cf..b674035ab7 100644 --- a/src/auth/src/lib.rs +++ b/src/auth/src/lib.rs @@ -35,17 +35,13 @@ //! verify [OIDC ID Tokens]. //! - `default-idtoken-backend`: enabled by default, this feature enables a default //! backend for the `idtoken` feature. Currently the feature is implemented using -//! the [jsonwebtoken] crate and uses `aws-lc-rs` as its default backend. We may -//! change the default backend at any time, applications that have specific needs -//! for this backend should not rely on the current default. To control the -//! backend selection: +//! the [rsa] crate. //! - Configure this crate with `default-features = false`, and //! `features = ["idtoken"]` -//! - Select the desired backend for `jsonwebtoken`. //! //! [aws-lc-rs]: https://crates.io/crates/aws-lc-rs //! [ring]: https://crates.io/crates/ring -//! [jsonwebtoken]: https://crates.io/crates/jsonwebtoken +//! [rsa]: https://crates.io/crates/rsa //! [oidc id tokens]: https://cloud.google.com/docs/authentication/token-types#identity-tokens //! [Authentication methods at Google]: https://cloud.google.com/docs/authentication //! [Principals]: https://cloud.google.com/docs/authentication#principal From c88d38c2026351c5149eb283af77aa593a22c351 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Mon, 26 Jan 2026 19:27:37 +0000 Subject: [PATCH 2/9] fix: clippy all the things --- src/auth/src/credentials/idtoken/verifier.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/src/credentials/idtoken/verifier.rs b/src/auth/src/credentials/idtoken/verifier.rs index 333ba8e2b4..dbb135f227 100644 --- a/src/auth/src/credentials/idtoken/verifier.rs +++ b/src/auth/src/credentials/idtoken/verifier.rs @@ -250,7 +250,7 @@ impl Verifier { Value::String(s) => self.audiences.contains(s), Value::Array(arr) => arr.iter().any(|v| { v.as_str() - .map_or(false, |s| self.audiences.contains(&s.to_string())) + .is_some_and(|s| self.audiences.contains(&s.to_string())) }), _ => return Err(Error::invalid_field("aud", "Invalid aud format")), }; From 63adda6a9affa556cdff6c37854ae2424cefb1fc Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Mon, 26 Jan 2026 19:55:43 +0000 Subject: [PATCH 3/9] fix: rsa still a dev dep --- src/auth/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml index 9605aa2f31..5dc3d2ec93 100644 --- a/src/auth/Cargo.toml +++ b/src/auth/Cargo.toml @@ -58,6 +58,7 @@ anyhow.workspace = true httptest.workspace = true mockall.workspace = true regex.workspace = true +rsa = { workspace = true, features = ["pem"] } scoped-env.workspace = true serial_test.workspace = true tempfile.workspace = true From d953d399d046d6175d7c9b93c35fc163848b0909 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Mon, 26 Jan 2026 20:05:35 +0000 Subject: [PATCH 4/9] fix: add jsonwebtoken feature back --- src/auth/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml index 5dc3d2ec93..cc14b53d8e 100644 --- a/src/auth/Cargo.toml +++ b/src/auth/Cargo.toml @@ -72,7 +72,7 @@ mutants.workspace = true default = ["default-idtoken-backend", "default-rustls-provider"] # The `idtoken` feature enables support to create and validate OIDC ID Tokens. # See the create top-level documentation for more information. -idtoken = ["dep:rsa"] +idtoken = ["dep:rsa", "jsonwebtoken"] # By default this crate enables the `aws_lc_rs` backend. Applications can # link `google-cloud-auth` with `default-features = false, features = ["idtoken"] # and then directly configure the `jsonwebtoken` features to select the From 8f281beefacc317b60201e0fb4a560fdfe2c061d Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Mon, 26 Jan 2026 22:39:04 +0000 Subject: [PATCH 5/9] fix: remove rsa as dependency and move to rustls --- Cargo.lock | 7 ++- src/auth/Cargo.toml | 11 ++-- src/auth/src/credentials/idtoken/verifier.rs | 39 ++++++++++-- .../src/credentials/internal/jwk_client.rs | 61 ++++++++++++++----- 4 files changed, 89 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a650a139c..6ccd9a191e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,7 +1423,6 @@ version = "1.5.0" dependencies = [ "anyhow", "async-trait", - "aws-lc-rs", "base64", "bytes", "google-cloud-gax", @@ -1431,6 +1430,7 @@ dependencies = [ "httptest", "mockall", "mutants", + "pkcs1", "regex", "reqwest 0.13.1", "rsa", @@ -1441,6 +1441,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "spki", "tempfile", "test-case", "thiserror 2.0.18", @@ -5893,9 +5894,9 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linux-raw-sys" diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml index cc14b53d8e..e44fda9f04 100644 --- a/src/auth/Cargo.toml +++ b/src/auth/Cargo.toml @@ -43,13 +43,12 @@ rustls = { workspace = true, features = ["logging", "std", "tls12 rustls-pki-types = { workspace = true, features = ["std"] } serde.workspace = true serde_json.workspace = true +# TODO: move new deps to workspace +pkcs1 = { version = "0.7", optional = true, features = ["alloc"] } +spki = { version = "0.7", optional = true, features = ["alloc"] } thiserror.workspace = true time = { workspace = true, features = ["serde"] } tokio = { workspace = true, features = ["fs", "process"] } -rsa = { workspace = true, optional = true, features = ["pem", "sha2"] } -# We do not use this directly, but without it the minimal-versions build breaks. -# See: https://github.com/Keats/jsonwebtoken/pull/481 -aws-lc-rs = { workspace = true, optional = true } # Local dependencies gax.workspace = true @@ -58,7 +57,7 @@ anyhow.workspace = true httptest.workspace = true mockall.workspace = true regex.workspace = true -rsa = { workspace = true, features = ["pem"] } +rsa = { workspace = true, features = ["pem", "sha2"] } scoped-env.workspace = true serial_test.workspace = true tempfile.workspace = true @@ -72,7 +71,7 @@ mutants.workspace = true default = ["default-idtoken-backend", "default-rustls-provider"] # The `idtoken` feature enables support to create and validate OIDC ID Tokens. # See the create top-level documentation for more information. -idtoken = ["dep:rsa", "jsonwebtoken"] +idtoken = ["dep:pkcs1", "dep:spki", "jsonwebtoken"] # By default this crate enables the `aws_lc_rs` backend. Applications can # link `google-cloud-auth` with `default-features = false, features = ["idtoken"] # and then directly configure the `jsonwebtoken` features to select the diff --git a/src/auth/src/credentials/idtoken/verifier.rs b/src/auth/src/credentials/idtoken/verifier.rs index dbb135f227..f42fc2c14a 100644 --- a/src/auth/src/credentials/idtoken/verifier.rs +++ b/src/auth/src/credentials/idtoken/verifier.rs @@ -39,8 +39,7 @@ use crate::credentials::internal::jwk_client::JwkClient; use crate::credentials::service_account::jws::JwsHeader; use base64::Engine; -use rsa::Pkcs1v15Sign; -use rsa::sha2::{Digest, Sha256}; +use rustls::crypto::CryptoProvider; /// Represents the claims in an ID token. pub use serde_json::Map; /// Represents a claim value in an ID token. @@ -203,17 +202,45 @@ impl Verifier { let expected_email = self.email.clone(); let jwks_url = self.jwks_url.clone(); - let cert = self + let spki = self .jwk_client .get_or_load_cert(key_id.to_string(), header.alg, jwks_url) .await .map_err(Error::load_cert)?; let message = format!("{}.{}", parts[0], parts[1]); - let hashed = Sha256::digest(message.as_bytes()); + #[cfg(feature = "default-rustls-provider")] + let provider_arc = CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider())); + #[cfg(feature = "default-rustls-provider")] + let provider = &provider_arc; + + #[cfg(not(feature = "default-rustls-provider"))] + let provider = CryptoProvider::get_default().expect( + r###" +The default rustls::CryptoProvider should be configured by the application. The +`google-cloud-auth` crate was compiled without the `default-rustls-provider` +feature. Without this feature the crate expects the application to initialize +the rustls crypto provider using `rustls::CryptoProvider::install_default()`. + +Note that the application must use the exact same version of `rustls` as the +`google-cloud-auth` crate does. Otherwise `install_default()` has no effect."###, + ); + + // spki is already SubjectPublicKeyInfoDer + let algs = provider.signature_verification_algorithms; + let mapping = algs.mapping; + let alg = mapping + .iter() + .find(|(scheme, _)| *scheme == rustls::SignatureScheme::RSA_PKCS1_SHA256) + .map(|(_, candidates)| candidates[0]) + .ok_or_else(|| Error::invalid("RSA_PKCS1_SHA256 not supported by current provider"))?; + + // Check compatibility is handled by verify_signature - cert.verify(Pkcs1v15Sign::new::(), &hashed, &signature) - .map_err(|e| Error::invalid(format!("Invalid signature: {}", e)))?; + alg.verify_signature(&spki, message.as_bytes(), &signature) + .map_err(|e| Error::invalid(format!("Invalid signature: {:?}", e)))?; // Validate Payload claims let now = SystemTime::now() diff --git a/src/auth/src/credentials/internal/jwk_client.rs b/src/auth/src/credentials/internal/jwk_client.rs index d453c603d3..0ab556b435 100644 --- a/src/auth/src/credentials/internal/jwk_client.rs +++ b/src/auth/src/credentials/internal/jwk_client.rs @@ -15,8 +15,14 @@ use crate::Result; use crate::errors::CredentialsError; use base64::Engine; -use rsa::{BigUint, RsaPublicKey}; +use pkcs1::RsaPublicKey; +use rustls::pki_types::SubjectPublicKeyInfoDer; use serde::Deserialize; +use spki::der::{ + Encode, + asn1::{BitString, Null, UintRef}, +}; +use spki::{AlgorithmIdentifier, SubjectPublicKeyInfo}; use std::{ collections::HashMap, sync::Arc, @@ -48,7 +54,7 @@ impl JwkSet { #[derive(Clone, Debug)] struct CacheEntry { - key: RsaPublicKey, + key: SubjectPublicKeyInfoDer<'static>, expires_at: Instant, } @@ -79,7 +85,7 @@ impl JwkClient { key_id: String, alg: &str, jwks_url: Option, - ) -> Result { + ) -> Result> { let key_id_str = key_id.as_str(); let mut cache = self.cache.try_write().map_err(|_e| { CredentialsError::from_msg(false, "failed to obtain lock to read certificate cache") @@ -96,17 +102,7 @@ impl JwkClient { CredentialsError::from_msg(false, "JWKS did not contain a matching `kid`") })?; - let n_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&jwk.n) - .map_err(|e| CredentialsError::from_msg(false, format!("Invalid n in JWK: {}", e)))?; - let e_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&jwk.e) - .map_err(|e| CredentialsError::from_msg(false, format!("Invalid e in JWK: {}", e)))?; - - let n = BigUint::from_bytes_be(&n_bytes); - let e = BigUint::from_bytes_be(&e_bytes); - let key = RsaPublicKey::new(n, e) - .map_err(|e| CredentialsError::from_msg(false, format!("Invalid RSA key: {}", e)))?; + let key = create_der(&jwk)?; let entry = CacheEntry { key: key.clone(), @@ -156,6 +152,43 @@ impl JwkClient { } } +// Creates a DER-encoded SubjectPublicKeyInfo from a JWK. +fn create_der(jwk: &Jwk) -> Result> { + let n_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&jwk.n) + .map_err(|e| CredentialsError::from_msg(false, format!("invalid n in JWK: {}", e)))?; + let e_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&jwk.e) + .map_err(|e| CredentialsError::from_msg(false, format!("invalid e in JWK: {}", e)))?; + + let rsa_pub_key = RsaPublicKey { + modulus: UintRef::new(&n_bytes).map_err(|e| { + CredentialsError::from_msg(false, format!("invalid modulus in JWK: {}", e)) + })?, + public_exponent: UintRef::new(&e_bytes).map_err(|e| { + CredentialsError::from_msg(false, format!("invalid public exponent in JWK: {}", e)) + })?, + }; + let rsa_pub_key_der = rsa_pub_key.to_der().map_err(|e| { + CredentialsError::from_msg(false, format!("failed to encode RSA public key: {}", e)) + })?; + + let spki = SubjectPublicKeyInfo { + algorithm: AlgorithmIdentifier:: { + oid: pkcs1::ALGORITHM_OID, + parameters: Some(Null), + }, + subject_public_key: BitString::from_bytes(&rsa_pub_key_der).map_err(|e| { + CredentialsError::from_msg(false, format!("failed to create public key bitstring: {}", e)) + })?, + }; + + let der_bytes = spki + .to_der() + .map_err(|e| CredentialsError::from_msg(false, format!("Failed to encode SPKI: {}", e)))?; + Ok(SubjectPublicKeyInfoDer::from(der_bytes)) +} + #[cfg(test)] mod tests { use super::*; From 342ac661a2fa3efeff67dc580f31a1d541b597d1 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Mon, 26 Jan 2026 22:45:55 +0000 Subject: [PATCH 6/9] fix: clippy all the things --- src/auth/src/credentials/internal/jwk_client.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/auth/src/credentials/internal/jwk_client.rs b/src/auth/src/credentials/internal/jwk_client.rs index 0ab556b435..961a86b531 100644 --- a/src/auth/src/credentials/internal/jwk_client.rs +++ b/src/auth/src/credentials/internal/jwk_client.rs @@ -102,7 +102,7 @@ impl JwkClient { CredentialsError::from_msg(false, "JWKS did not contain a matching `kid`") })?; - let key = create_der(&jwk)?; + let key = create_der(jwk)?; let entry = CacheEntry { key: key.clone(), @@ -179,7 +179,10 @@ fn create_der(jwk: &Jwk) -> Result> { parameters: Some(Null), }, subject_public_key: BitString::from_bytes(&rsa_pub_key_der).map_err(|e| { - CredentialsError::from_msg(false, format!("failed to create public key bitstring: {}", e)) + CredentialsError::from_msg( + false, + format!("failed to create public key bitstring: {}", e), + ) })?, }; From b28ca5a441c2391ad78c6edc7354b352c0bee017 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Mon, 26 Jan 2026 22:46:10 +0000 Subject: [PATCH 7/9] test: remove jsonwebtoken dep check --- tests/crypto-providers/test-metadata/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/crypto-providers/test-metadata/src/lib.rs b/tests/crypto-providers/test-metadata/src/lib.rs index 23030713b1..1a745093b8 100644 --- a/tests/crypto-providers/test-metadata/src/lib.rs +++ b/tests/crypto-providers/test-metadata/src/lib.rs @@ -220,8 +220,6 @@ mod tests { assert!(v.is_ok(), "{v:?}"); let v = super::find_version(&metadata, "rustls"); assert!(v.is_ok(), "{v:?}"); - let v = super::find_version(&metadata, "jsonwebtoken"); - assert!(v.is_ok(), "{v:?}"); Ok(()) } } From bedebad3d93413afd37f27ca05d868e99e54d3be Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Tue, 27 Jan 2026 15:17:06 +0000 Subject: [PATCH 8/9] cleanup: simplify crypto provider usage --- src/auth/src/credentials/idtoken/verifier.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/auth/src/credentials/idtoken/verifier.rs b/src/auth/src/credentials/idtoken/verifier.rs index f42fc2c14a..469548f7b1 100644 --- a/src/auth/src/credentials/idtoken/verifier.rs +++ b/src/auth/src/credentials/idtoken/verifier.rs @@ -209,15 +209,12 @@ impl Verifier { .map_err(Error::load_cert)?; let message = format!("{}.{}", parts[0], parts[1]); - #[cfg(feature = "default-rustls-provider")] - let provider_arc = CryptoProvider::get_default() - .cloned() - .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider())); - #[cfg(feature = "default-rustls-provider")] - let provider = &provider_arc; + let provider = CryptoProvider::get_default().cloned(); + #[cfg(feature = "default-rustls-provider")] + let provider = provider.unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider())); #[cfg(not(feature = "default-rustls-provider"))] - let provider = CryptoProvider::get_default().expect( + let provider = provider.expect( r###" The default rustls::CryptoProvider should be configured by the application. The `google-cloud-auth` crate was compiled without the `default-rustls-provider` @@ -228,16 +225,15 @@ Note that the application must use the exact same version of `rustls` as the `google-cloud-auth` crate does. Otherwise `install_default()` has no effect."###, ); - // spki is already SubjectPublicKeyInfoDer let algs = provider.signature_verification_algorithms; let mapping = algs.mapping; let alg = mapping .iter() .find(|(scheme, _)| *scheme == rustls::SignatureScheme::RSA_PKCS1_SHA256) .map(|(_, candidates)| candidates[0]) - .ok_or_else(|| Error::invalid("RSA_PKCS1_SHA256 not supported by current provider"))?; - - // Check compatibility is handled by verify_signature + .ok_or_else(|| { + Error::invalid("RSA_PKCS1_SHA256 not supported by current crypto provider") + })?; alg.verify_signature(&spki, message.as_bytes(), &signature) .map_err(|e| Error::invalid(format!("Invalid signature: {:?}", e)))?; From 83eae1b9a1994521daba7def599525ecbf397d23 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Tue, 27 Jan 2026 20:14:40 +0000 Subject: [PATCH 9/9] impl: support ES256 certs --- src/auth/Cargo.toml | 3 +- src/auth/src/credentials/idtoken/verifier.rs | 49 ++++++---- .../src/credentials/internal/jwk_client.rs | 93 +++++++++++++++++-- 3 files changed, 118 insertions(+), 27 deletions(-) diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml index a24bcb54ba..a58efb4824 100644 --- a/src/auth/Cargo.toml +++ b/src/auth/Cargo.toml @@ -46,6 +46,7 @@ serde_json.workspace = true # TODO: move new deps to workspace pkcs1 = { version = "0.7", optional = true, features = ["alloc"] } spki = { version = "0.7", optional = true, features = ["alloc"] } +p256 = { workspace = true, optional = true, features = ["ecdsa"] } thiserror.workspace = true time = { workspace = true, features = ["serde"] } tokio = { workspace = true, features = ["fs", "process"] } @@ -72,7 +73,7 @@ mutants.workspace = true default = ["default-idtoken-backend", "default-rustls-provider"] # The `idtoken` feature enables support to create and validate OIDC ID Tokens. # See the create top-level documentation for more information. -idtoken = ["dep:pkcs1", "dep:spki", "jsonwebtoken"] +idtoken = ["dep:pkcs1", "dep:spki", "dep:p256", "jsonwebtoken"] # By default this crate enables the `aws_lc_rs` backend. Applications can # link `google-cloud-auth` with `default-features = false, features = ["idtoken"] # and then directly configure the `jsonwebtoken` features to select the diff --git a/src/auth/src/credentials/idtoken/verifier.rs b/src/auth/src/credentials/idtoken/verifier.rs index 1106039bf6..eb0d46ed71 100644 --- a/src/auth/src/credentials/idtoken/verifier.rs +++ b/src/auth/src/credentials/idtoken/verifier.rs @@ -39,6 +39,7 @@ use crate::credentials::internal::jwk_client::JwkClient; use crate::credentials::service_account::jws::JwsHeader; use base64::Engine; +use p256::ecdsa::Signature; use rustls::crypto::CryptoProvider; /// Represents the claims in an ID token. pub use serde_json::Map; @@ -182,26 +183,30 @@ impl Verifier { .kid .ok_or_else(|| Error::invalid_field("kid", "kid header is missing"))?; - if header.alg != "RS256" { - return Err(Error::invalid_field( - "alg", - format!("Unsupported algorithm: {}", header.alg), - )); - } - - let claims_json = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(parts[1]) - .map_err(Error::decode)?; - let claims: Map = - serde_json::from_slice(&claims_json).map_err(Error::decode)?; + let scheme = match header.alg { + "RS256" => rustls::SignatureScheme::RSA_PKCS1_SHA256, + "ES256" => rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + _ => { + return Err(Error::invalid_field( + "alg", + format!("Unsupported algorithm: {}", header.alg), + )); + } + }; let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(parts[2]) .map_err(Error::decode)?; - let expected_email = self.email.clone(); - let jwks_url = self.jwks_url.clone(); + let signature = if header.alg == "ES256" { + let sig = Signature::from_slice(&signature) + .map_err(|_| Error::invalid("invalid ECDSA signature format"))?; + sig.to_der().as_bytes().to_vec() + } else { + signature + }; + let jwks_url = self.jwks_url.clone(); let spki = self .jwk_client .get_or_load_cert(key_id.to_string(), header.alg, jwks_url) @@ -230,16 +235,26 @@ Note that the application must use the exact same version of `rustls` as the let mapping = algs.mapping; let alg = mapping .iter() - .find(|(scheme, _)| *scheme == rustls::SignatureScheme::RSA_PKCS1_SHA256) + .find(|(candidate_scheme, _)| *candidate_scheme == scheme) .map(|(_, candidates)| candidates[0]) .ok_or_else(|| { - Error::invalid("RSA_PKCS1_SHA256 not supported by current crypto provider") + Error::invalid(format!( + "{:?} not supported by current crypto provider", + scheme + )) })?; alg.verify_signature(&spki, message.as_bytes(), &signature) .map_err(|e| Error::invalid(format!("Invalid signature: {:?}", e)))?; - // Validate Payload claims + // claims validation + let expected_email = self.email.clone(); + let claims_json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(Error::decode)?; + let claims: Map = + serde_json::from_slice(&claims_json).map_err(Error::decode)?; + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() diff --git a/src/auth/src/credentials/internal/jwk_client.rs b/src/auth/src/credentials/internal/jwk_client.rs index 391bbae071..e2d6879664 100644 --- a/src/auth/src/credentials/internal/jwk_client.rs +++ b/src/auth/src/credentials/internal/jwk_client.rs @@ -36,8 +36,12 @@ const CACHE_TTL: Duration = Duration::from_secs(3600); #[derive(Clone, Debug, Deserialize)] struct Jwk { - pub n: String, - pub e: String, + pub n: Option, + pub e: Option, + pub x: Option, + pub y: Option, + pub crv: Option, + pub kty: String, pub kid: String, } @@ -154,11 +158,31 @@ impl JwkClient { // Creates a DER-encoded SubjectPublicKeyInfo from a JWK. fn create_der(jwk: &Jwk) -> Result> { + match jwk.kty.as_str() { + "RSA" => create_rsa_der(jwk), + "EC" => create_ec_der(jwk), + _ => Err(CredentialsError::from_msg( + false, + format!("unsupported key type: {}", jwk.kty), + )), + } +} + +fn create_rsa_der(jwk: &Jwk) -> Result> { + let n = jwk + .n + .as_ref() + .ok_or_else(|| CredentialsError::from_msg(false, "missing n in RSA JWK"))?; + let e = jwk + .e + .as_ref() + .ok_or_else(|| CredentialsError::from_msg(false, "missing e in RSA JWK"))?; + let n_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&jwk.n) + .decode(n) .map_err(|e| CredentialsError::from_msg(false, format!("invalid n in JWK: {}", e)))?; let e_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&jwk.e) + .decode(e) .map_err(|e| CredentialsError::from_msg(false, format!("invalid e in JWK: {}", e)))?; let rsa_pub_key = RsaPublicKey { @@ -192,6 +216,38 @@ fn create_der(jwk: &Jwk) -> Result> { Ok(SubjectPublicKeyInfoDer::from(der_bytes)) } +fn create_ec_der(jwk: &Jwk) -> Result> { + let x = jwk + .x + .as_ref() + .ok_or_else(|| CredentialsError::from_msg(false, "missing x in EC JWK"))?; + let y = jwk + .y + .as_ref() + .ok_or_else(|| CredentialsError::from_msg(false, "missing y in EC JWK"))?; + if jwk.crv.as_deref() != Some("P-256") { + return Err(CredentialsError::from_msg( + false, + format!("unsupported curve: {:?}", jwk.crv), + )); + } + + let x_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(x) + .map_err(|e| CredentialsError::from_msg(false, format!("invalid x in JWK: {}", e)))?; + let y_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(y) + .map_err(|e| CredentialsError::from_msg(false, format!("invalid y in JWK: {}", e)))?; + + // Uncompressed point format: 0x04 || x || y + let mut public_key_bytes = Vec::with_capacity(1 + x_bytes.len() + y_bytes.len()); + public_key_bytes.push(0x04); + public_key_bytes.extend_from_slice(&x_bytes); + public_key_bytes.extend_from_slice(&y_bytes); + + Ok(SubjectPublicKeyInfoDer::from(public_key_bytes)) +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -399,11 +455,7 @@ pub(crate) mod tests { // First call, should fetch from URL let _key = client - .get_or_load_cert( - TEST_KEY_ID.to_string(), - "ES256", - Some(jwks_url.clone()), - ) + .get_or_load_cert(TEST_KEY_ID.to_string(), "ES256", Some(jwks_url.clone())) .await?; // Second call, should use cache @@ -413,4 +465,27 @@ pub(crate) mod tests { Ok(()) } + + // TODO: remove since this is probably gonna be flaky. + // Added just to make sure it works with real certs. + #[tokio::test] + async fn test_fetch_real_certs() -> TestResult { + let client = JwkClient::new(); + + println!("Fetching OAuth2 certs from {}", OAUTH2_JWK_URL); + let jwk_set = client.fetch_certs(OAUTH2_JWK_URL.to_string()).await?; + assert!(!jwk_set.keys.is_empty(), "OAuth2 keys should not be empty"); + for key in &jwk_set.keys { + create_der(key).expect(&format!("failed to create DER for key {}", key.kid)); + } + + println!("Fetching IAP certs from {}", IAP_JWK_URL); + let jwk_set = client.fetch_certs(IAP_JWK_URL.to_string()).await?; + assert!(!jwk_set.keys.is_empty(), "IAP keys should not be empty"); + for key in &jwk_set.keys { + create_der(key).expect(&format!("failed to create DER for key {}", key.kid)); + } + + Ok(()) + } }