| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- use cdk::oidc_client::{verify_jwt_signature, Jwk};
- use js_sys::{Object, Reflect, Uint8Array};
- use wasm_bindgen::JsValue;
- use wasm_bindgen_futures::JsFuture;
- use wasm_bindgen_test::*;
- wasm_bindgen_test_configure!(run_in_browser);
- const TEST_ISSUER: &str = "https://test.issuer.example.com";
- const TEST_CLIENT_ID: &str = "test-client-123";
- // Pre-generated RSA key components (base64url, no padding)
- const RSA_N: &str = "viZMoE4GaFGF9cualbi9SMKYfL8_A1z8819SM_S38423mlGOyPZoR8gKUMMShQabPanuHAEpSooY2KXM68lAdG0UiRt__MupQWFRP5MaPg4wAkJxAZFWpVSkxuks_P8E3icce48RDjvInhNIB6ZSYQQ9VzXYLhCPOBVIo5CRuD8fXQPJU9TIAhGPUFYXaIcRnL8chECmGBXovHU16x3LxY5wZXA_CJ0HfsZ2Tu97lwyFmNb2enYBtB9NgrxHx3tFsE0uIketOCrKx7exrjWA_m6XHYxl-HB9ixI791cHjA_guSasnWp3HSVXdq8xvmbOZUtpJ0wu4LS9PwOAvvJaIw";
- const RSA_E: &str = "AQAB";
- const RSA_D: &str = "TYmIjYXDjx5PJd-UdaETbmwLijLiGxj7_LHN72nG6QXM7Jx9QO1ZsIudyTkCgEQlYYu9kKXYlJCjeRSC71LteYxRZ2dTVV4m8oYgf3AYr11RrloxgpYlYt2VI5dJxRCoh34jWy8HoWo3cF4kbRohVXZJHRrTwFT4UcI8EJaPFTXTnFWcFUS4I5wvtWeCpWwLB0IhvIvOfPSmsrEEyC0AlCm71X9WLGhln0YpPk7rEmdYp2pHTSed4pL822lUsuXQjkB3yjN7IJDq6BpJzMnsnO9QUsZhATI6ElPajeez2iYDzqSdrENAyNY7g9fiB1lJAF_eBchmg_zjmtp6MSb5IQ";
- const RSA_P: &str = "8A9Rg-CwLHbXvAjxhEUYqVZMWlHeIU7tCf7GiWxyr3u1eusUGWmu6eyWYZNuvOPONHA4e-f2o3ePBmSfuSG0B0Lp7MFS0BDiJTe1f7n1f0DcpcM5tnLXYdH_z5Nn27fnexgZHkNKkjIPwPwZUdoQTLVhZ2lDWv2NZK-Sy0NJKp8";
- const RSA_Q: &str = "ysaTpsmKokjbTWHDS6D469OFyvv1g6rSAeJufzEigD96xjOOk3rZQFQJ6WQFsZ8G4WXmLvBPNknjxv-0aKwxC0XLulw2TDKMeVWQESm-etXJmzHfPJyE9dJOCYOZ750xRJfQeYmDvWl3Gk9Uo9sIhuHpBqGUYKmbOOmU6Led5f0";
- const RSA_DP: &str = "xoHURToaVFpdoMbAeEDu2LBc6N8D0QVD69z67Y5483VXp3IWp8EFe7hAziUtEBNMY35cptE02Q23fnDcxykAhnSlnTprsVQUvPPpKNpsEDNhgc0Cv0UNp30QjOR2oHDdgKN3udepJWUyM8IDafTpP5VJG0snAGnkbtrkhyJ3sT8";
- const RSA_DQ: &str = "P22mOgHJD8Jidu4hvMJ5mqrrqvbtcWY5ksVVcwvXku5IZT8zVgaTdn_TKeJTtZ_c8xyAyCX7YSvzyAesUyGppbELbRvzEBqvvjR5gCTipGHDUnxjK_55yLskFe3IdR9ijeY_HAVb5B_dVamC_E5DeI2p6p0YYLQtDbxjC_iDt7E";
- const RSA_QI: &str = "hEd-TpKvFEi4PCm4845FR6D9O_Y6o8nVyODLbC6ACacA_f4rKm7BLQLphsG8aVaKteUMmJS6eQg2abM2Phmy9NrGzg3qGEGl_VDFQ6laDbfrtVgSAZBMzH-ftzoybZZO3MAFdE_x3HaafYkcZ_lcV_OMo1QGwD2cg3AGBZ3bAcM";
- // Pre-generated EC P-256 key components (base64url, no padding)
- const EC_X: &str = "_GpXxzqwAcWFrFINB4ehLPSWAbXEEt7TTgElIakGOhk";
- const EC_Y: &str = "r-CLBX5hepUn2PJ9tHivvDwvL_nZfwl1KrajlRQwzIc";
- const EC_D: &str = "RmG7VzFSrog-8-W7Yt1bbQuMxHY0ggnL9-oGaaLPWPM";
- // ---------------------------------------------------------------------------
- // Base64url encoding (no-pad) — avoids depending on bitcoin crate
- // ---------------------------------------------------------------------------
- const B64URL_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
- fn base64url_encode(data: &[u8]) -> String {
- let mut out = String::with_capacity((data.len() * 4 + 2) / 3);
- for chunk in data.chunks(3) {
- let b0 = chunk[0] as usize;
- let b1 = if chunk.len() > 1 { chunk[1] as usize } else { 0 };
- let b2 = if chunk.len() > 2 { chunk[2] as usize } else { 0 };
- let n = (b0 << 16) | (b1 << 8) | b2;
- out.push(B64URL_CHARS[(n >> 18) & 0x3f] as char);
- out.push(B64URL_CHARS[(n >> 12) & 0x3f] as char);
- if chunk.len() > 1 {
- out.push(B64URL_CHARS[(n >> 6) & 0x3f] as char);
- }
- if chunk.len() > 2 {
- out.push(B64URL_CHARS[n & 0x3f] as char);
- }
- }
- out
- }
- // ---------------------------------------------------------------------------
- // JS Object helpers
- // ---------------------------------------------------------------------------
- fn js_set(obj: &Object, key: &str, val: &JsValue) {
- Reflect::set(obj, &JsValue::from_str(key), val).unwrap();
- }
- fn js_str(s: &str) -> JsValue {
- JsValue::from_str(s)
- }
- fn hash_obj(name: &str) -> Object {
- let o = Object::new();
- js_set(&o, "name", &js_str(name));
- o
- }
- fn get_subtle() -> web_sys::SubtleCrypto {
- let global = js_sys::global();
- let crypto = Reflect::get(&global, &js_str("crypto")).unwrap();
- let subtle = Reflect::get(&crypto, &js_str("subtle")).unwrap();
- subtle.into()
- }
- // ---------------------------------------------------------------------------
- // JWK builders — construct JS Objects directly for Web Crypto importKey
- // ---------------------------------------------------------------------------
- fn build_rsa_private_jwk_js() -> Object {
- let o = Object::new();
- js_set(&o, "kty", &js_str("RSA"));
- js_set(&o, "n", &js_str(RSA_N));
- js_set(&o, "e", &js_str(RSA_E));
- js_set(&o, "d", &js_str(RSA_D));
- js_set(&o, "p", &js_str(RSA_P));
- js_set(&o, "q", &js_str(RSA_Q));
- js_set(&o, "dp", &js_str(RSA_DP));
- js_set(&o, "dq", &js_str(RSA_DQ));
- js_set(&o, "qi", &js_str(RSA_QI));
- o
- }
- fn build_ec_private_jwk_js() -> Object {
- let o = Object::new();
- js_set(&o, "kty", &js_str("EC"));
- js_set(&o, "crv", &js_str("P-256"));
- js_set(&o, "x", &js_str(EC_X));
- js_set(&o, "y", &js_str(EC_Y));
- js_set(&o, "d", &js_str(EC_D));
- o
- }
- fn rsa_public_jwk() -> Jwk {
- Jwk {
- kty: "RSA".into(),
- kid: Some("test-key-1".into()),
- alg: Some("RS256".into()),
- n: Some(RSA_N.into()),
- e: Some(RSA_E.into()),
- crv: None,
- x: None,
- y: None,
- extra: Default::default(),
- }
- }
- fn ec_public_jwk() -> Jwk {
- Jwk {
- kty: "EC".into(),
- kid: Some("test-key-1".into()),
- alg: Some("ES256".into()),
- n: None,
- e: None,
- crv: Some("P-256".into()),
- x: Some(EC_X.into()),
- y: Some(EC_Y.into()),
- extra: Default::default(),
- }
- }
- // ---------------------------------------------------------------------------
- // Algorithm builders
- // ---------------------------------------------------------------------------
- fn rsa_algo() -> Object {
- let algo = Object::new();
- js_set(&algo, "name", &js_str("RSASSA-PKCS1-v1_5"));
- js_set(&algo, "hash", &hash_obj("SHA-256").into());
- algo
- }
- fn ec_import_algo() -> Object {
- let algo = Object::new();
- js_set(&algo, "name", &js_str("ECDSA"));
- js_set(&algo, "namedCurve", &js_str("P-256"));
- algo
- }
- fn ec_sign_algo() -> Object {
- let algo = Object::new();
- js_set(&algo, "name", &js_str("ECDSA"));
- js_set(&algo, "hash", &hash_obj("SHA-256").into());
- algo
- }
- // ---------------------------------------------------------------------------
- // JWT signing helper using Web Crypto
- // ---------------------------------------------------------------------------
- async fn sign_jwt_web_crypto(
- claims_json: &str,
- private_jwk: &Object,
- alg_name: &str,
- kid: &str,
- import_algo: &Object,
- sign_algo: &Object,
- ) -> String {
- let subtle = get_subtle();
- let header = format!(r#"{{"alg":"{}","typ":"JWT","kid":"{}"}}"#, alg_name, kid);
- let header_b64 = base64url_encode(header.as_bytes());
- let payload_b64 = base64url_encode(claims_json.as_bytes());
- let message = format!("{}.{}", header_b64, payload_b64);
- // importKey("jwk", private_jwk, algo, false, ["sign"])
- let usages = js_sys::Array::new();
- usages.push(&js_str("sign"));
- let key_promise = subtle
- .import_key_with_object("jwk", private_jwk, import_algo, false, &usages)
- .unwrap();
- let key: web_sys::CryptoKey = JsFuture::from(key_promise).await.unwrap().into();
- // sign(algo, key, data)
- let msg_array = Uint8Array::from(message.as_bytes() as &[u8]);
- let sign_promise = subtle
- .sign_with_object_and_buffer_source(sign_algo, &key, &msg_array)
- .unwrap();
- let sig_buffer = JsFuture::from(sign_promise).await.unwrap();
- let sig_array = Uint8Array::new(&sig_buffer);
- let mut sig_bytes = vec![0u8; sig_array.length() as usize];
- sig_array.copy_to(&mut sig_bytes);
- let sig_b64 = base64url_encode(&sig_bytes);
- format!("{}.{}.{}", header_b64, payload_b64, sig_b64)
- }
- fn claims_str(issuer: &str, client_id: &str, expired: bool) -> String {
- let exp = if expired { 1_000_000_000u64 } else { 4_102_444_800u64 };
- format!(
- r#"{{"iss":"{}","client_id":"{}","exp":{},"sub":"test-user"}}"#,
- issuer, client_id, exp
- )
- }
- // ---------------------------------------------------------------------------
- // Helpers: sign a JWT for reuse across multiple tests
- // ---------------------------------------------------------------------------
- async fn make_rsa_jwt(issuer: &str, client_id: &str, expired: bool) -> String {
- let algo = rsa_algo();
- sign_jwt_web_crypto(
- &claims_str(issuer, client_id, expired),
- &build_rsa_private_jwk_js(),
- "RS256",
- "test-key-1",
- &algo,
- &algo,
- )
- .await
- }
- async fn make_ec_jwt(issuer: &str, client_id: &str, expired: bool) -> String {
- sign_jwt_web_crypto(
- &claims_str(issuer, client_id, expired),
- &build_ec_private_jwk_js(),
- "ES256",
- "test-key-1",
- &ec_import_algo(),
- &ec_sign_algo(),
- )
- .await
- }
- // ---------------------------------------------------------------------------
- // Tests
- // ---------------------------------------------------------------------------
- #[wasm_bindgen_test]
- async fn test_verify_jwt_rsa_rs256() {
- let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
- let result = verify_jwt_signature(&jwt, &rsa_public_jwk(), "RS256", TEST_ISSUER).await;
- let claims = result.unwrap();
- assert_eq!(claims["iss"], TEST_ISSUER);
- assert_eq!(claims["client_id"], TEST_CLIENT_ID);
- }
- #[wasm_bindgen_test]
- async fn test_verify_jwt_ec_es256() {
- let jwt = make_ec_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
- let result = verify_jwt_signature(&jwt, &ec_public_jwk(), "ES256", TEST_ISSUER).await;
- let claims = result.unwrap();
- assert_eq!(claims["iss"], TEST_ISSUER);
- assert_eq!(claims["client_id"], TEST_CLIENT_ID);
- }
- #[wasm_bindgen_test]
- async fn test_verify_jwt_expired() {
- let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, true).await;
- let result = verify_jwt_signature(&jwt, &rsa_public_jwk(), "RS256", TEST_ISSUER).await;
- assert!(result.is_err(), "expected error for expired token");
- }
- #[wasm_bindgen_test]
- async fn test_verify_jwt_wrong_issuer() {
- let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
- let result =
- verify_jwt_signature(&jwt, &rsa_public_jwk(), "RS256", "https://wrong.issuer.com").await;
- assert!(result.is_err(), "expected error for wrong issuer");
- }
- #[wasm_bindgen_test]
- async fn test_verify_jwt_bad_signature() {
- let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
- // 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_public_jwk(), "RS256", TEST_ISSUER).await;
- assert!(result.is_err(), "expected error for tampered signature");
- }
- #[wasm_bindgen_test]
- async fn test_verify_jwt_unsupported_kty() {
- let jwt = make_rsa_jwt(TEST_ISSUER, TEST_CLIENT_ID, false).await;
- let mut jwk = rsa_public_jwk();
- jwk.kty = "OKP".into();
- let result = verify_jwt_signature(&jwt, &jwk, "RS256", TEST_ISSUER).await;
- assert!(result.is_err(), "expected error for unsupported key type");
- }
|