|
|
@@ -4,25 +4,97 @@ use std::collections::HashMap;
|
|
|
use std::ops::Deref;
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
+use bitcoin::base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
|
+use bitcoin::base64::Engine;
|
|
|
use cdk_common::HttpClient;
|
|
|
-use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet};
|
|
|
-use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
|
|
|
-use serde::Deserialize;
|
|
|
-#[cfg(feature = "wallet")]
|
|
|
-use serde::Serialize;
|
|
|
+use serde::{Deserialize, Serialize};
|
|
|
use thiserror::Error;
|
|
|
use tokio::sync::RwLock;
|
|
|
use tracing::instrument;
|
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Own JWK types (replaces jsonwebtoken::jwk::*)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+/// JSON Web Key Set
|
|
|
+#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
+pub struct JwkSet {
|
|
|
+ /// The keys in the set
|
|
|
+ pub keys: Vec<Jwk>,
|
|
|
+}
|
|
|
+
|
|
|
+impl JwkSet {
|
|
|
+ /// Find a key by its `kid`
|
|
|
+ pub fn find(&self, kid: &str) -> Option<&Jwk> {
|
|
|
+ self.keys.iter().find(|k| k.kid.as_deref() == Some(kid))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// JSON Web Key
|
|
|
+#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
+pub struct Jwk {
|
|
|
+ /// Key type (e.g. "RSA", "EC")
|
|
|
+ pub kty: String,
|
|
|
+ /// Key ID
|
|
|
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
+ pub kid: Option<String>,
|
|
|
+ /// Algorithm
|
|
|
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
+ pub alg: Option<String>,
|
|
|
+ /// RSA modulus
|
|
|
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
+ pub n: Option<String>,
|
|
|
+ /// RSA exponent
|
|
|
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
+ pub e: Option<String>,
|
|
|
+ /// EC curve name
|
|
|
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
+ pub crv: Option<String>,
|
|
|
+ /// EC x coordinate
|
|
|
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
+ pub x: Option<String>,
|
|
|
+ /// EC y coordinate
|
|
|
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
+ pub y: Option<String>,
|
|
|
+ /// Additional fields (preserved for Web Crypto importKey)
|
|
|
+ #[serde(flatten)]
|
|
|
+ pub extra: serde_json::Map<String, serde_json::Value>,
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// JWT header parsing (cross-platform, no crypto)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+#[derive(Debug, Deserialize)]
|
|
|
+struct JwtHeader {
|
|
|
+ alg: String,
|
|
|
+ kid: Option<String>,
|
|
|
+}
|
|
|
+
|
|
|
+fn decode_jwt_header(token: &str) -> Result<JwtHeader, Error> {
|
|
|
+ let header_b64 = token.split('.').next().ok_or(Error::InvalidJwtFormat)?;
|
|
|
+ let header_bytes = URL_SAFE_NO_PAD
|
|
|
+ .decode(header_b64)
|
|
|
+ .map_err(|_| Error::InvalidJwtFormat)?;
|
|
|
+ serde_json::from_slice(&header_bytes).map_err(|_| Error::InvalidJwtFormat)
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Error
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
/// OIDC Error
|
|
|
#[derive(Debug, Error)]
|
|
|
pub enum Error {
|
|
|
/// From HTTP error
|
|
|
#[error(transparent)]
|
|
|
Http(#[from] cdk_common::HttpError),
|
|
|
- /// From JWT error
|
|
|
- #[error(transparent)]
|
|
|
- Jwt(#[from] jsonwebtoken::errors::Error),
|
|
|
+ /// JWT verification failed
|
|
|
+ #[error("JWT verification failed: {0}")]
|
|
|
+ JwtVerification(String),
|
|
|
+ /// Invalid JWT format
|
|
|
+ #[error("Invalid JWT format")]
|
|
|
+ InvalidJwtFormat,
|
|
|
/// Missing kid header
|
|
|
#[error("Missing kid header")]
|
|
|
MissingKidHeader,
|
|
|
@@ -47,9 +119,13 @@ impl From<Error> for cdk_common::error::Error {
|
|
|
/// Open Id Config
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
pub struct OidcConfig {
|
|
|
+ /// JWKS URI
|
|
|
pub jwks_uri: String,
|
|
|
+ /// Issuer
|
|
|
pub issuer: String,
|
|
|
+ /// Token endpoint
|
|
|
pub token_endpoint: String,
|
|
|
+ /// Device authorization endpoint
|
|
|
pub device_authorization_endpoint: String,
|
|
|
}
|
|
|
|
|
|
@@ -63,27 +139,38 @@ pub struct OidcClient {
|
|
|
jwks_set: Arc<RwLock<Option<JwkSet>>>,
|
|
|
}
|
|
|
|
|
|
+/// OAuth2 grant type
|
|
|
#[cfg(feature = "wallet")]
|
|
|
#[derive(Debug, Clone, Copy, Serialize)]
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
pub enum GrantType {
|
|
|
+ /// Refresh token grant
|
|
|
RefreshToken,
|
|
|
}
|
|
|
|
|
|
+/// OAuth2 refresh token request
|
|
|
#[cfg(feature = "wallet")]
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
pub struct RefreshTokenRequest {
|
|
|
+ /// Grant type
|
|
|
pub grant_type: GrantType,
|
|
|
+ /// Client ID
|
|
|
pub client_id: String,
|
|
|
+ /// Refresh token
|
|
|
pub refresh_token: String,
|
|
|
}
|
|
|
|
|
|
+/// OAuth2 token response
|
|
|
#[cfg(feature = "wallet")]
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
pub struct TokenResponse {
|
|
|
+ /// Access token
|
|
|
pub access_token: String,
|
|
|
+ /// Refresh token
|
|
|
pub refresh_token: Option<String>,
|
|
|
+ /// Expires in seconds
|
|
|
pub expires_in: Option<i64>,
|
|
|
+ /// Token type
|
|
|
pub token_type: String,
|
|
|
}
|
|
|
|
|
|
@@ -129,9 +216,9 @@ impl OidcClient {
|
|
|
#[instrument(skip_all)]
|
|
|
pub async fn verify_cat(&self, cat_jwt: &str) -> Result<(), Error> {
|
|
|
tracing::debug!("Verifying cat");
|
|
|
- let header = decode_header(cat_jwt)?;
|
|
|
+ let header = decode_jwt_header(cat_jwt)?;
|
|
|
|
|
|
- let kid = header.kid.ok_or(Error::MissingKidHeader)?;
|
|
|
+ let kid = header.kid.as_deref().ok_or(Error::MissingKidHeader)?;
|
|
|
|
|
|
let oidc_config = {
|
|
|
let locked = self.oidc_config.read().await;
|
|
|
@@ -155,66 +242,46 @@ impl OidcClient {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- let jwk = match jwks.find(&kid) {
|
|
|
+ let jwk = match jwks.find(kid) {
|
|
|
Some(jwk) => jwk.clone(),
|
|
|
None => {
|
|
|
let refreshed_jwks = self.get_jwkset(&oidc_config.jwks_uri).await?;
|
|
|
refreshed_jwks
|
|
|
- .find(&kid)
|
|
|
+ .find(kid)
|
|
|
.ok_or(Error::MissingKidHeader)?
|
|
|
.clone()
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- let decoding_key = match &jwk.algorithm {
|
|
|
- AlgorithmParameters::RSA(rsa) => DecodingKey::from_rsa_components(&rsa.n, &rsa.e)?,
|
|
|
- AlgorithmParameters::EllipticCurve(ecdsa) => {
|
|
|
- DecodingKey::from_ec_components(&ecdsa.x, &ecdsa.y)?
|
|
|
- }
|
|
|
- _ => return Err(Error::UnsupportedSigningAlgo),
|
|
|
- };
|
|
|
+ let claims =
|
|
|
+ verify_jwt_signature(cat_jwt, &jwk, &header.alg, &oidc_config.issuer).await?;
|
|
|
|
|
|
- let validation = {
|
|
|
- let mut validation = Validation::new(header.alg);
|
|
|
- validation.validate_exp = true;
|
|
|
- validation.validate_aud = false;
|
|
|
- validation.set_issuer(&[oidc_config.issuer]);
|
|
|
- validation
|
|
|
- };
|
|
|
+ tracing::debug!("Successfully verified cat");
|
|
|
+ tracing::debug!("Claims: {:?}", claims);
|
|
|
|
|
|
- match decode::<HashMap<String, serde_json::Value>>(cat_jwt, &decoding_key, &validation) {
|
|
|
- Ok(claims) => {
|
|
|
- tracing::debug!("Successfully verified cat");
|
|
|
- tracing::debug!("Claims: {:?}", claims.claims);
|
|
|
- if let Some(client_id) = &self.client_id {
|
|
|
- if let Some(token_client_id) = claims.claims.get("client_id") {
|
|
|
- if let Some(token_client_id_value) = token_client_id.as_str() {
|
|
|
- if token_client_id_value != client_id {
|
|
|
- tracing::warn!(
|
|
|
- "Client ID mismatch: expected {}, got {}",
|
|
|
- client_id,
|
|
|
- token_client_id_value
|
|
|
- );
|
|
|
- return Err(Error::InvalidClientId);
|
|
|
- }
|
|
|
- }
|
|
|
- } else if let Some(azp) = claims.claims.get("azp") {
|
|
|
- if let Some(azp_value) = azp.as_str() {
|
|
|
- if azp_value != client_id {
|
|
|
- tracing::warn!(
|
|
|
- "Client ID (azp) mismatch: expected {}, got {}",
|
|
|
- client_id,
|
|
|
- azp_value
|
|
|
- );
|
|
|
- return Err(Error::InvalidClientId);
|
|
|
- }
|
|
|
- }
|
|
|
+ if let Some(client_id) = &self.client_id {
|
|
|
+ if let Some(token_client_id) = claims.get("client_id") {
|
|
|
+ if let Some(token_client_id_value) = token_client_id.as_str() {
|
|
|
+ if token_client_id_value != client_id {
|
|
|
+ tracing::warn!(
|
|
|
+ "Client ID mismatch: expected {}, got {}",
|
|
|
+ client_id,
|
|
|
+ token_client_id_value
|
|
|
+ );
|
|
|
+ return Err(Error::InvalidClientId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if let Some(azp) = claims.get("azp") {
|
|
|
+ if let Some(azp_value) = azp.as_str() {
|
|
|
+ if azp_value != client_id {
|
|
|
+ tracing::warn!(
|
|
|
+ "Client ID (azp) mismatch: expected {}, got {}",
|
|
|
+ client_id,
|
|
|
+ azp_value
|
|
|
+ );
|
|
|
+ return Err(Error::InvalidClientId);
|
|
|
}
|
|
|
}
|
|
|
- }
|
|
|
- Err(err) => {
|
|
|
- tracing::debug!("Could not verify cat: {}", err);
|
|
|
- return Err(err.into());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -241,3 +308,615 @@ impl OidcClient {
|
|
|
Ok(response)
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+// ===========================================================================
|
|
|
+// Native JWT verification (using jsonwebtoken crate)
|
|
|
+// ===========================================================================
|
|
|
+
|
|
|
+/// Verify a JWT signature and return the claims.
|
|
|
+#[cfg(not(target_arch = "wasm32"))]
|
|
|
+pub async fn verify_jwt_signature(
|
|
|
+ token: &str,
|
|
|
+ jwk: &Jwk,
|
|
|
+ alg: &str,
|
|
|
+ expected_issuer: &str,
|
|
|
+) -> Result<HashMap<String, serde_json::Value>, Error> {
|
|
|
+ let decoding_key = match jwk.kty.as_str() {
|
|
|
+ "RSA" => {
|
|
|
+ let n = jwk.n.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
|
|
|
+ let e = jwk.e.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
|
|
|
+ jsonwebtoken::DecodingKey::from_rsa_components(n, e)
|
|
|
+ .map_err(|e| Error::JwtVerification(e.to_string()))?
|
|
|
+ }
|
|
|
+ "EC" => {
|
|
|
+ let x = jwk.x.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
|
|
|
+ let y = jwk.y.as_deref().ok_or(Error::UnsupportedSigningAlgo)?;
|
|
|
+ jsonwebtoken::DecodingKey::from_ec_components(x, y)
|
|
|
+ .map_err(|e| Error::JwtVerification(e.to_string()))?
|
|
|
+ }
|
|
|
+ _ => return Err(Error::UnsupportedSigningAlgo),
|
|
|
+ };
|
|
|
+
|
|
|
+ let algorithm: jsonwebtoken::Algorithm = alg
|
|
|
+ .parse()
|
|
|
+ .map_err(|_| Error::JwtVerification(format!("unsupported algorithm: {alg}")))?;
|
|
|
+
|
|
|
+ let validation = {
|
|
|
+ let mut v = jsonwebtoken::Validation::new(algorithm);
|
|
|
+ v.validate_exp = true;
|
|
|
+ v.validate_aud = false;
|
|
|
+ v.set_issuer(&[expected_issuer]);
|
|
|
+ v
|
|
|
+ };
|
|
|
+
|
|
|
+ let token_data =
|
|
|
+ jsonwebtoken::decode::<HashMap<String, serde_json::Value>>(token, &decoding_key, &validation)
|
|
|
+ .map_err(|e| Error::JwtVerification(e.to_string()))?;
|
|
|
+
|
|
|
+ Ok(token_data.claims)
|
|
|
+}
|
|
|
+
|
|
|
+// ===========================================================================
|
|
|
+// WASM JWT verification (using Web Crypto API)
|
|
|
+// ===========================================================================
|
|
|
+
|
|
|
+/// Verify a JWT signature and return the claims.
|
|
|
+#[cfg(target_arch = "wasm32")]
|
|
|
+pub async fn verify_jwt_signature(
|
|
|
+ token: &str,
|
|
|
+ jwk: &Jwk,
|
|
|
+ alg: &str,
|
|
|
+ expected_issuer: &str,
|
|
|
+) -> Result<HashMap<String, serde_json::Value>, Error> {
|
|
|
+ use js_sys::{Reflect, Uint8Array};
|
|
|
+ use wasm_bindgen::JsValue;
|
|
|
+ use wasm_bindgen_futures::JsFuture;
|
|
|
+
|
|
|
+ let parts: Vec<&str> = token.splitn(3, '.').collect();
|
|
|
+ if parts.len() != 3 {
|
|
|
+ return Err(Error::InvalidJwtFormat);
|
|
|
+ }
|
|
|
+ let (header_b64, payload_b64, sig_b64) = (parts[0], parts[1], parts[2]);
|
|
|
+
|
|
|
+ // Decode signature
|
|
|
+ let sig_bytes = URL_SAFE_NO_PAD
|
|
|
+ .decode(sig_b64)
|
|
|
+ .map_err(|_| Error::InvalidJwtFormat)?;
|
|
|
+
|
|
|
+ // The message that was signed is "header.payload" (the raw base64url text)
|
|
|
+ let message = format!("{}.{}", header_b64, payload_b64);
|
|
|
+
|
|
|
+ // Get SubtleCrypto (works in both Window and Worker contexts)
|
|
|
+ let global = js_sys::global();
|
|
|
+ let crypto = Reflect::get(&global, &JsValue::from_str("crypto"))
|
|
|
+ .map_err(|_| Error::JwtVerification("crypto not available".into()))?;
|
|
|
+ let subtle = Reflect::get(&crypto, &JsValue::from_str("subtle"))
|
|
|
+ .map_err(|_| Error::JwtVerification("subtle crypto not available".into()))?;
|
|
|
+ let subtle: web_sys::SubtleCrypto = subtle.into();
|
|
|
+
|
|
|
+ // Build algorithm objects for importKey and verify
|
|
|
+ let (import_algo, verify_algo) = web_crypto_algorithms(alg, &jwk.kty)?;
|
|
|
+
|
|
|
+ // Build JWK as a plain JS Object for Web Crypto importKey.
|
|
|
+ // We cannot use serde_wasm_bindgen::to_value() because the #[serde(flatten)]
|
|
|
+ // on the `extra` field produces a JS Map instead of a plain Object, which
|
|
|
+ // Web Crypto's importKey rejects with DataError.
|
|
|
+ let jwk_js = jwk_to_js_object(jwk)?;
|
|
|
+
|
|
|
+ // importKey("jwk", jwk_js, algo, false, ["verify"])
|
|
|
+ let usages = js_sys::Array::new();
|
|
|
+ usages.push(&JsValue::from_str("verify"));
|
|
|
+ let key_promise = subtle
|
|
|
+ .import_key_with_object("jwk", &jwk_js, &import_algo, false, &usages)
|
|
|
+ .map_err(|e| Error::JwtVerification(format!("importKey setup failed: {e:?}")))?;
|
|
|
+ let key: web_sys::CryptoKey = JsFuture::from(key_promise)
|
|
|
+ .await
|
|
|
+ .map_err(|e| Error::JwtVerification(format!("importKey failed: {e:?}")))?
|
|
|
+ .into();
|
|
|
+
|
|
|
+ // verify(algo, key, signature, data)
|
|
|
+ let sig_array = Uint8Array::from(sig_bytes.as_slice());
|
|
|
+ let msg_array = Uint8Array::from(message.as_bytes() as &[u8]);
|
|
|
+ let verify_promise = subtle
|
|
|
+ .verify_with_object_and_buffer_source_and_buffer_source(
|
|
|
+ &verify_algo,
|
|
|
+ &key,
|
|
|
+ &sig_array,
|
|
|
+ &msg_array,
|
|
|
+ )
|
|
|
+ .map_err(|e| Error::JwtVerification(format!("verify setup failed: {e:?}")))?;
|
|
|
+ let result = JsFuture::from(verify_promise)
|
|
|
+ .await
|
|
|
+ .map_err(|e| Error::JwtVerification(format!("verify failed: {e:?}")))?;
|
|
|
+
|
|
|
+ let valid = result
|
|
|
+ .as_bool()
|
|
|
+ .ok_or_else(|| Error::JwtVerification("verify returned non-boolean".into()))?;
|
|
|
+ if !valid {
|
|
|
+ return Err(Error::JwtVerification("signature verification failed".into()));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Decode payload and validate claims
|
|
|
+ let payload_bytes = URL_SAFE_NO_PAD
|
|
|
+ .decode(payload_b64)
|
|
|
+ .map_err(|_| Error::InvalidJwtFormat)?;
|
|
|
+ let claims: HashMap<String, serde_json::Value> =
|
|
|
+ serde_json::from_slice(&payload_bytes).map_err(|_| Error::InvalidJwtFormat)?;
|
|
|
+
|
|
|
+ // Validate expiration
|
|
|
+ if let Some(exp) = claims.get("exp").and_then(|v| v.as_u64()) {
|
|
|
+ let now = web_time::SystemTime::now()
|
|
|
+ .duration_since(web_time::SystemTime::UNIX_EPOCH)
|
|
|
+ .map_err(|_| Error::JwtVerification("system time error".into()))?
|
|
|
+ .as_secs();
|
|
|
+ if now > exp {
|
|
|
+ return Err(Error::JwtVerification("token expired".into()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate issuer
|
|
|
+ if let Some(iss) = claims.get("iss").and_then(|v| v.as_str()) {
|
|
|
+ if iss != expected_issuer {
|
|
|
+ return Err(Error::JwtVerification(format!(
|
|
|
+ "issuer mismatch: expected {expected_issuer}, got {iss}"
|
|
|
+ )));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(claims)
|
|
|
+}
|
|
|
+
|
|
|
+/// Build Web Crypto algorithm objects for importKey and verify.
|
|
|
+/// Returns (import_algorithm, verify_algorithm) - they differ for ECDSA.
|
|
|
+#[cfg(target_arch = "wasm32")]
|
|
|
+fn web_crypto_algorithms(
|
|
|
+ alg: &str,
|
|
|
+ kty: &str,
|
|
|
+) -> Result<(js_sys::Object, js_sys::Object), Error> {
|
|
|
+ use js_sys::Object;
|
|
|
+ use wasm_bindgen::JsValue;
|
|
|
+
|
|
|
+ let set = |obj: &Object, key: &str, val: &JsValue| {
|
|
|
+ js_sys::Reflect::set(obj, &JsValue::from_str(key), val).ok();
|
|
|
+ };
|
|
|
+
|
|
|
+ let hash_obj = |name: &str| -> Object {
|
|
|
+ let o = Object::new();
|
|
|
+ set(&o, "name", &JsValue::from_str(name));
|
|
|
+ o
|
|
|
+ };
|
|
|
+
|
|
|
+ match kty {
|
|
|
+ "RSA" => {
|
|
|
+ let hash_name = match alg {
|
|
|
+ "RS256" => "SHA-256",
|
|
|
+ "RS384" => "SHA-384",
|
|
|
+ "RS512" => "SHA-512",
|
|
|
+ _ => return Err(Error::UnsupportedSigningAlgo),
|
|
|
+ };
|
|
|
+ let algo = Object::new();
|
|
|
+ set(&algo, "name", &JsValue::from_str("RSASSA-PKCS1-v1_5"));
|
|
|
+ set(&algo, "hash", &hash_obj(hash_name).into());
|
|
|
+ // For RSA, import and verify use the same algorithm object
|
|
|
+ Ok((algo.clone(), algo))
|
|
|
+ }
|
|
|
+ "EC" => {
|
|
|
+ let (curve, hash_name) = match alg {
|
|
|
+ "ES256" => ("P-256", "SHA-256"),
|
|
|
+ "ES384" => ("P-384", "SHA-384"),
|
|
|
+ _ => return Err(Error::UnsupportedSigningAlgo),
|
|
|
+ };
|
|
|
+ let import_algo = Object::new();
|
|
|
+ set(&import_algo, "name", &JsValue::from_str("ECDSA"));
|
|
|
+ set(&import_algo, "namedCurve", &JsValue::from_str(curve));
|
|
|
+
|
|
|
+ let verify_algo = Object::new();
|
|
|
+ set(&verify_algo, "name", &JsValue::from_str("ECDSA"));
|
|
|
+ set(&verify_algo, "hash", &hash_obj(hash_name).into());
|
|
|
+
|
|
|
+ Ok((import_algo, verify_algo))
|
|
|
+ }
|
|
|
+ _ => Err(Error::UnsupportedSigningAlgo),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Convert a [`Jwk`] to a plain JS Object suitable for Web Crypto `importKey`.
|
|
|
+///
|
|
|
+/// We build the object manually with `Reflect::set` rather than using
|
|
|
+/// `serde_wasm_bindgen::to_value` because the `#[serde(flatten)]` on the
|
|
|
+/// `extra` field can produce a JS `Map` instead of a plain `Object`, which
|
|
|
+/// Web Crypto rejects with `DataError`.
|
|
|
+#[cfg(target_arch = "wasm32")]
|
|
|
+fn jwk_to_js_object(jwk: &Jwk) -> Result<js_sys::Object, Error> {
|
|
|
+ use js_sys::Object;
|
|
|
+ use wasm_bindgen::JsValue;
|
|
|
+
|
|
|
+ let obj = Object::new();
|
|
|
+ let set = |key: &str, val: &JsValue| {
|
|
|
+ js_sys::Reflect::set(&obj, &JsValue::from_str(key), val).ok();
|
|
|
+ };
|
|
|
+
|
|
|
+ set("kty", &JsValue::from_str(&jwk.kty));
|
|
|
+
|
|
|
+ if let Some(ref kid) = jwk.kid {
|
|
|
+ set("kid", &JsValue::from_str(kid));
|
|
|
+ }
|
|
|
+ if let Some(ref alg) = jwk.alg {
|
|
|
+ set("alg", &JsValue::from_str(alg));
|
|
|
+ }
|
|
|
+ if let Some(ref n) = jwk.n {
|
|
|
+ set("n", &JsValue::from_str(n));
|
|
|
+ }
|
|
|
+ if let Some(ref e) = jwk.e {
|
|
|
+ set("e", &JsValue::from_str(e));
|
|
|
+ }
|
|
|
+ if let Some(ref crv) = jwk.crv {
|
|
|
+ set("crv", &JsValue::from_str(crv));
|
|
|
+ }
|
|
|
+ if let Some(ref x) = jwk.x {
|
|
|
+ set("x", &JsValue::from_str(x));
|
|
|
+ }
|
|
|
+ if let Some(ref y) = jwk.y {
|
|
|
+ set("y", &JsValue::from_str(y));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Copy any extra fields (e.g. "use", "key_ops")
|
|
|
+ for (key, value) in &jwk.extra {
|
|
|
+ let js_val = serde_wasm_bindgen::to_value(value)
|
|
|
+ .map_err(|e| Error::JwtVerification(format!("failed to serialize JWK extra field '{key}': {e}")))?;
|
|
|
+ set(key, &js_val);
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(obj)
|
|
|
+}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod tests {
|
|
|
+ use super::*;
|
|
|
+
|
|
|
+ const TEST_ISSUER: &str = "https://test.issuer.example.com";
|
|
|
+ const TEST_CLIENT_ID: &str = "test-client-123";
|
|
|
+ const TEST_KID: &str = "test-key-1";
|
|
|
+
|
|
|
+ const RSA_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
|
|
|
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+JkygTgZoUYX1
|
|
|
+y5qVuL1Iwph8vz8DXPzzX1Iz9LfzjbeaUY7I9mhHyApQwxKFBps9qe4cASlKihjY
|
|
|
+pczryUB0bRSJG3/8y6lBYVE/kxo+DjACQnEBkValVKTG6Sz8/wTeJxx7jxEOO8ie
|
|
|
+E0gHplJhBD1XNdguEI84FUijkJG4Px9dA8lT1MgCEY9QVhdohxGcvxyEQKYYFei8
|
|
|
+dTXrHcvFjnBlcD8InQd+xnZO73uXDIWY1vZ6dgG0H02CvEfHe0WwTS4iR604KsrH
|
|
|
+t7GuNYD+bpcdjGX4cH2LEjv3VweMD+C5JqydancdJVd2rzG+Zs5lS2knTC7gtL0/
|
|
|
+A4C+8lojAgMBAAECggEATYmIjYXDjx5PJd+UdaETbmwLijLiGxj7/LHN72nG6QXM
|
|
|
+7Jx9QO1ZsIudyTkCgEQlYYu9kKXYlJCjeRSC71LteYxRZ2dTVV4m8oYgf3AYr11R
|
|
|
+rloxgpYlYt2VI5dJxRCoh34jWy8HoWo3cF4kbRohVXZJHRrTwFT4UcI8EJaPFTXT
|
|
|
+nFWcFUS4I5wvtWeCpWwLB0IhvIvOfPSmsrEEyC0AlCm71X9WLGhln0YpPk7rEmdY
|
|
|
+p2pHTSed4pL822lUsuXQjkB3yjN7IJDq6BpJzMnsnO9QUsZhATI6ElPajeez2iYD
|
|
|
+zqSdrENAyNY7g9fiB1lJAF/eBchmg/zjmtp6MSb5IQKBgQDwD1GD4LAsdte8CPGE
|
|
|
+RRipVkxaUd4hTu0J/saJbHKve7V66xQZaa7p7JZhk2684840cDh75/ajd48GZJ+5
|
|
|
+IbQHQunswVLQEOIlN7V/ufV/QNylwzm2ctdh0f/Pk2fbt+d7GBkeQ0qSMg/A/BlR
|
|
|
+2hBMtWFnaUNa/Y1kr5LLQ0kqnwKBgQDKxpOmyYqiSNtNYcNLoPjr04XK+/WDqtIB
|
|
|
+4m5/MSKAP3rGM46TetlAVAnpZAWxnwbhZeYu8E82SePG/7RorDELRcu6XDZMMox5
|
|
|
+VZARKb561cmbMd88nIT10k4Jg5nvnTFEl9B5iYO9aXcaT1Sj2wiG4ekGoZRgqZs4
|
|
|
+6ZTot53l/QKBgQDGgdRFOhpUWl2gxsB4QO7YsFzo3wPRBUPr3PrtjnjzdVenchan
|
|
|
+wQV7uEDOJS0QE0xjflym0TTZDbd+cNzHKQCGdKWdOmuxVBS88+ko2mwQM2GBzQK/
|
|
|
+RQ2nfRCM5HagcN2Ao3e516klZTIzwgNp9Ok/lUkbSycAaeRu2uSHInexPwKBgD9t
|
|
|
+pjoByQ/CYnbuIbzCeZqq66r27XFmOZLFVXML15LuSGU/M1YGk3Z/0yniU7Wf3PMc
|
|
|
+gMgl+2Er88gHrFMhqaWxC20b8xAar740eYAk4qRhw1J8Yyv+eci7JBXtyHUfYo3m
|
|
|
+PxwFW+Qf3VWpgvxOQ3iNqeqdGGC0LQ28Ywv4g7exAoGBAIRHfk6SrxRIuDwpuPOO
|
|
|
+RUeg/Tv2OqPJ1cjgy2wugAmnAP3+KypuwS0C6YbBvGlWirXlDJiUunkINmmzNj4Z
|
|
|
+svTaxs4N6hhBpf1QxUOpWg2367VYEgGQTMx/n7c6Mm2WTtzABXRP8dx2mn2JHGf5
|
|
|
+XFfzjKNUBsA9nINwBgWd2wHD
|
|
|
+-----END PRIVATE KEY-----"#;
|
|
|
+
|
|
|
+ const RSA_N: &str = "viZMoE4GaFGF9cualbi9SMKYfL8_A1z8819SM_S38423mlGOyPZoR8gKUMMShQabPanuHAEpSooY2KXM68lAdG0UiRt__MupQWFRP5MaPg4wAkJxAZFWpVSkxuks_P8E3icce48RDjvInhNIB6ZSYQQ9VzXYLhCPOBVIo5CRuD8fXQPJU9TIAhGPUFYXaIcRnL8chECmGBXovHU16x3LxY5wZXA_CJ0HfsZ2Tu97lwyFmNb2enYBtB9NgrxHx3tFsE0uIketOCrKx7exrjWA_m6XHYxl-HB9ixI791cHjA_guSasnWp3HSVXdq8xvmbOZUtpJ0wu4LS9PwOAvvJaIw";
|
|
|
+ const RSA_E: &str = "AQAB";
|
|
|
+
|
|
|
+ const EC_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
|
|
|
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgRmG7VzFSrog+8+W7
|
|
|
+Yt1bbQuMxHY0ggnL9+oGaaLPWPOhRANCAAT8alfHOrABxYWsUg0Hh6Es9JYBtcQS
|
|
|
+3tNOASUhqQY6Ga/giwV+YXqVJ9jyfbR4r7w8Ly/52X8JdSq2o5UUMMyH
|
|
|
+-----END PRIVATE KEY-----"#;
|
|
|
+
|
|
|
+ const EC_X: &str = "_GpXxzqwAcWFrFINB4ehLPSWAbXEEt7TTgElIakGOhk";
|
|
|
+ const EC_Y: &str = "r-CLBX5hepUn2PJ9tHivvDwvL_nZfwl1KrajlRQwzIc";
|
|
|
+
|
|
|
+ fn rsa_jwk() -> Jwk {
|
|
|
+ Jwk {
|
|
|
+ kty: "RSA".to_string(),
|
|
|
+ kid: Some(TEST_KID.to_string()),
|
|
|
+ alg: Some("RS256".to_string()),
|
|
|
+ n: Some(RSA_N.to_string()),
|
|
|
+ e: Some(RSA_E.to_string()),
|
|
|
+ crv: None,
|
|
|
+ x: None,
|
|
|
+ y: None,
|
|
|
+ extra: Default::default(),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fn ec_jwk() -> Jwk {
|
|
|
+ Jwk {
|
|
|
+ kty: "EC".to_string(),
|
|
|
+ kid: Some(TEST_KID.to_string()),
|
|
|
+ alg: Some("ES256".to_string()),
|
|
|
+ n: None,
|
|
|
+ e: None,
|
|
|
+ crv: Some("P-256".to_string()),
|
|
|
+ x: Some(EC_X.to_string()),
|
|
|
+ y: Some(EC_Y.to_string()),
|
|
|
+ extra: Default::default(),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fn test_claims(issuer: &str, client_id: &str, expired: bool) -> HashMap<String, serde_json::Value> {
|
|
|
+ let exp = if expired { 1_000_000_000u64 } else { 4_102_444_800u64 };
|
|
|
+ let mut claims = HashMap::new();
|
|
|
+ claims.insert("iss".into(), serde_json::json!(issuer));
|
|
|
+ claims.insert("client_id".into(), serde_json::json!(client_id));
|
|
|
+ claims.insert("exp".into(), serde_json::json!(exp));
|
|
|
+ claims.insert("sub".into(), serde_json::json!("test-user"));
|
|
|
+ claims
|
|
|
+ }
|
|
|
+
|
|
|
+ fn sign_jwt(
|
|
|
+ claims: &HashMap<String, serde_json::Value>,
|
|
|
+ alg: jsonwebtoken::Algorithm,
|
|
|
+ kid: &str,
|
|
|
+ pem: &[u8],
|
|
|
+ ) -> String {
|
|
|
+ let mut header = jsonwebtoken::Header::new(alg);
|
|
|
+ header.kid = Some(kid.to_string());
|
|
|
+ let key = match alg {
|
|
|
+ jsonwebtoken::Algorithm::RS256 => {
|
|
|
+ jsonwebtoken::EncodingKey::from_rsa_pem(pem).unwrap()
|
|
|
+ }
|
|
|
+ jsonwebtoken::Algorithm::ES256 => {
|
|
|
+ jsonwebtoken::EncodingKey::from_ec_pem(pem).unwrap()
|
|
|
+ }
|
|
|
+ _ => panic!("unsupported alg in test"),
|
|
|
+ };
|
|
|
+ jsonwebtoken::encode(&header, claims, &key).unwrap()
|
|
|
+ }
|
|
|
+
|
|
|
+ fn make_test_client(
|
|
|
+ client_id: Option<String>,
|
|
|
+ oidc_config: OidcConfig,
|
|
|
+ jwks: JwkSet,
|
|
|
+ ) -> OidcClient {
|
|
|
+ OidcClient {
|
|
|
+ client: HttpClient::new(),
|
|
|
+ openid_discovery: "https://unused.example.com".into(),
|
|
|
+ client_id,
|
|
|
+ oidc_config: Arc::new(RwLock::new(Some(oidc_config))),
|
|
|
+ jwks_set: Arc::new(RwLock::new(Some(jwks))),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fn test_oidc_config() -> OidcConfig {
|
|
|
+ OidcConfig {
|
|
|
+ jwks_uri: "https://unused.example.com/jwks".into(),
|
|
|
+ issuer: TEST_ISSUER.into(),
|
|
|
+ token_endpoint: "https://unused.example.com/token".into(),
|
|
|
+ device_authorization_endpoint: "https://unused.example.com/device".into(),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+ // decode_jwt_header tests (cross-platform, no crypto)
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_decode_jwt_header_rs256() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let header = decode_jwt_header(&jwt).unwrap();
|
|
|
+ assert_eq!(header.alg, "RS256");
|
|
|
+ assert_eq!(header.kid.as_deref(), Some(TEST_KID));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_decode_jwt_header_es256() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::ES256, TEST_KID, EC_PEM);
|
|
|
+ let header = decode_jwt_header(&jwt).unwrap();
|
|
|
+ assert_eq!(header.alg, "ES256");
|
|
|
+ assert_eq!(header.kid.as_deref(), Some(TEST_KID));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_decode_jwt_header_no_dots() {
|
|
|
+ assert!(matches!(
|
|
|
+ decode_jwt_header("nodots"),
|
|
|
+ Err(Error::InvalidJwtFormat)
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_decode_jwt_header_invalid_base64() {
|
|
|
+ assert!(matches!(
|
|
|
+ decode_jwt_header("!!!.payload.sig"),
|
|
|
+ Err(Error::InvalidJwtFormat)
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_decode_jwt_header_invalid_json() {
|
|
|
+ let not_json = URL_SAFE_NO_PAD.encode(b"not json");
|
|
|
+ let token = format!("{}.payload.sig", not_json);
|
|
|
+ assert!(matches!(
|
|
|
+ decode_jwt_header(&token),
|
|
|
+ Err(Error::InvalidJwtFormat)
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+ // JwkSet tests
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_jwkset_find() {
|
|
|
+ let set = JwkSet {
|
|
|
+ keys: vec![rsa_jwk(), ec_jwk()],
|
|
|
+ };
|
|
|
+ assert!(set.find(TEST_KID).is_some());
|
|
|
+ assert!(set.find("nonexistent").is_none());
|
|
|
+ }
|
|
|
+
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+ // verify_jwt_signature tests (native)
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_jwt_rsa_rs256() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let result = verify_jwt_signature(&jwt, &rsa_jwk(), "RS256", TEST_ISSUER).await;
|
|
|
+ let result_claims = result.unwrap();
|
|
|
+ assert_eq!(result_claims["iss"], TEST_ISSUER);
|
|
|
+ assert_eq!(result_claims["client_id"], TEST_CLIENT_ID);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_jwt_ec_es256() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::ES256, TEST_KID, EC_PEM);
|
|
|
+ let result = verify_jwt_signature(&jwt, &ec_jwk(), "ES256", TEST_ISSUER).await;
|
|
|
+ let result_claims = result.unwrap();
|
|
|
+ assert_eq!(result_claims["iss"], TEST_ISSUER);
|
|
|
+ assert_eq!(result_claims["client_id"], TEST_CLIENT_ID);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_jwt_expired() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, true);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let result = verify_jwt_signature(&jwt, &rsa_jwk(), "RS256", TEST_ISSUER).await;
|
|
|
+ assert!(result.is_err());
|
|
|
+ let err = result.unwrap_err();
|
|
|
+ assert!(
|
|
|
+ matches!(err, Error::JwtVerification(ref msg) if msg.contains("expired") || msg.contains("Expired")),
|
|
|
+ "expected expiration error, got: {err}"
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_jwt_wrong_issuer() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let result =
|
|
|
+ verify_jwt_signature(&jwt, &rsa_jwk(), "RS256", "https://wrong.issuer.com").await;
|
|
|
+ assert!(result.is_err());
|
|
|
+ let err = result.unwrap_err();
|
|
|
+ assert!(
|
|
|
+ matches!(err, Error::JwtVerification(ref msg) if msg.contains("issuer") || msg.contains("Issuer")),
|
|
|
+ "expected issuer error, got: {err}"
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_jwt_bad_signature() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ // Tamper with the signature
|
|
|
+ let parts: Vec<&str> = jwt.splitn(3, '.').collect();
|
|
|
+ let tampered = format!("{}.{}.{}AAAA", parts[0], parts[1], parts[2]);
|
|
|
+ let result = verify_jwt_signature(&tampered, &rsa_jwk(), "RS256", TEST_ISSUER).await;
|
|
|
+ assert!(result.is_err());
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_jwt_unsupported_kty() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let mut jwk = rsa_jwk();
|
|
|
+ jwk.kty = "OKP".into();
|
|
|
+ let result = verify_jwt_signature(&jwt, &jwk, "RS256", TEST_ISSUER).await;
|
|
|
+ assert!(matches!(result, Err(Error::UnsupportedSigningAlgo)));
|
|
|
+ }
|
|
|
+
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+ // verify_cat tests (native, needs pre-populated OidcClient)
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_cat_valid() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let jwks = JwkSet {
|
|
|
+ keys: vec![rsa_jwk()],
|
|
|
+ };
|
|
|
+ let client = make_test_client(
|
|
|
+ Some(TEST_CLIENT_ID.to_string()),
|
|
|
+ test_oidc_config(),
|
|
|
+ jwks,
|
|
|
+ );
|
|
|
+ client.verify_cat(&jwt).await.unwrap();
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_cat_wrong_client_id() {
|
|
|
+ let claims = test_claims(TEST_ISSUER, "wrong-client", false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let jwks = JwkSet {
|
|
|
+ keys: vec![rsa_jwk()],
|
|
|
+ };
|
|
|
+ let client = make_test_client(
|
|
|
+ Some(TEST_CLIENT_ID.to_string()),
|
|
|
+ test_oidc_config(),
|
|
|
+ jwks,
|
|
|
+ );
|
|
|
+ let result = client.verify_cat(&jwt).await;
|
|
|
+ assert!(matches!(result, Err(Error::InvalidClientId)));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_cat_azp_fallback() {
|
|
|
+ // Token has azp instead of client_id
|
|
|
+ let mut claims = HashMap::new();
|
|
|
+ claims.insert("iss".into(), serde_json::json!(TEST_ISSUER));
|
|
|
+ claims.insert("azp".into(), serde_json::json!(TEST_CLIENT_ID));
|
|
|
+ claims.insert("exp".into(), serde_json::json!(4_102_444_800u64));
|
|
|
+ claims.insert("sub".into(), serde_json::json!("test-user"));
|
|
|
+
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let jwks = JwkSet {
|
|
|
+ keys: vec![rsa_jwk()],
|
|
|
+ };
|
|
|
+ let client = make_test_client(
|
|
|
+ Some(TEST_CLIENT_ID.to_string()),
|
|
|
+ test_oidc_config(),
|
|
|
+ jwks,
|
|
|
+ );
|
|
|
+ client.verify_cat(&jwt).await.unwrap();
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_cat_no_client_id_check() {
|
|
|
+ // OidcClient without client_id configured - should accept any token
|
|
|
+ let claims = test_claims(TEST_ISSUER, "any-client", false);
|
|
|
+ let jwt = sign_jwt(&claims, jsonwebtoken::Algorithm::RS256, TEST_KID, RSA_PEM);
|
|
|
+ let jwks = JwkSet {
|
|
|
+ keys: vec![rsa_jwk()],
|
|
|
+ };
|
|
|
+ let client = make_test_client(None, test_oidc_config(), jwks);
|
|
|
+ client.verify_cat(&jwt).await.unwrap();
|
|
|
+ }
|
|
|
+
|
|
|
+ #[tokio::test]
|
|
|
+ async fn test_verify_cat_missing_kid() {
|
|
|
+ // Create a JWT without kid in header
|
|
|
+ let claims = test_claims(TEST_ISSUER, TEST_CLIENT_ID, false);
|
|
|
+ let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
|
|
|
+ let key = jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM).unwrap();
|
|
|
+ let jwt = jsonwebtoken::encode(&header, &claims, &key).unwrap();
|
|
|
+ let jwks = JwkSet {
|
|
|
+ keys: vec![rsa_jwk()],
|
|
|
+ };
|
|
|
+ let client = make_test_client(
|
|
|
+ Some(TEST_CLIENT_ID.to_string()),
|
|
|
+ test_oidc_config(),
|
|
|
+ jwks,
|
|
|
+ );
|
|
|
+ let result = client.verify_cat(&jwt).await;
|
|
|
+ assert!(matches!(result, Err(Error::MissingKidHeader)));
|
|
|
+ }
|
|
|
+}
|