From 7ab4c7655be5e2cba2430594bb4de28f5e05e8f3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 13 Jan 2025 23:16:09 +0100 Subject: [PATCH] Implement Google JWT verification and build time environment variables. --- Cargo.lock | 80 +++++ Cargo.toml | 1 + Dockerfile | 1 + dfx.json | 6 +- package.json | 4 +- scripts/build | 2 +- src/internet_identity/Cargo.toml | 3 +- src/internet_identity/build.rs | 17 ++ src/internet_identity/src/constants.rs | 2 + src/internet_identity/src/main.rs | 1 + src/internet_identity/src/openid.rs | 38 +++ src/internet_identity/src/openid/google.rs | 334 ++++++++++++++++++++- 12 files changed, 476 insertions(+), 13 deletions(-) create mode 100644 src/internet_identity/build.rs create mode 100644 src/internet_identity/src/constants.rs diff --git a/Cargo.lock b/Cargo.lock index 18cab70b21..b07a69e6cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1977,6 +1978,7 @@ dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", "regex", + "rsa", "serde", "serde_bytes", "serde_cbor", @@ -2134,6 +2136,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128" @@ -2147,6 +2152,12 @@ version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + [[package]] name = "libredox" version = "0.1.3" @@ -2339,6 +2350,23 @@ dependencies = [ "serde", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2354,6 +2382,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg 1.3.0", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2361,6 +2400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg 1.3.0", + "libm", ] [[package]] @@ -2449,6 +2489,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2512,6 +2561,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -2987,6 +3047,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.24" diff --git a/Cargo.toml b/Cargo.toml index d3fa406327..303bf79027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,3 +48,4 @@ serde = "1" serde_bytes = "0.11" serde_cbor = "0.11" sha2 = "0.10" +rsa = "0.9.7" diff --git a/Dockerfile b/Dockerfile index 45b57aa0f2..96f789fdf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,6 +80,7 @@ ARG II_FETCH_ROOT_KEY= ARG II_DUMMY_CAPTCHA= ARG II_DUMMY_AUTH= ARG II_DEV_CSP= +ARG II_OPENID_GOOGLE_CLIENT_ID= RUN touch src/*/src/lib.rs RUN npm ci diff --git a/dfx.json b/dfx.json index c2c2c01ba8..024d53ae40 100644 --- a/dfx.json +++ b/dfx.json @@ -4,9 +4,9 @@ "type": "custom", "candid": "src/internet_identity/internet_identity.did", "wasm": "internet_identity.wasm.gz", - "build": "bash -c 'II_DEV_CSP=1 II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=${II_DUMMY_CAPTCHA:-1} scripts/build'", + "build": "bash -c 'II_DEV_CSP=1 II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=${II_DUMMY_CAPTCHA:-1} II_OPENID_GOOGLE_CLIENT_ID=\"45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com\" scripts/build'", "init_arg": "(opt record { captcha_config = opt record { max_unsolved_captchas= 50:nat64; captcha_trigger = variant {Static = variant {CaptchaDisabled}}}})", - "shrink" : false + "shrink": false }, "test_app": { "type": "custom", @@ -20,7 +20,7 @@ "wasm": "demos/vc_issuer/vc_demo_issuer.wasm.gz", "build": "demos/vc_issuer/build.sh", "post_install": "bash -c 'demos/vc_issuer/provision'", - "dependencies": [ "internet_identity" ] + "dependencies": ["internet_identity"] } }, "defaults": { diff --git a/package.json b/package.json index 02f7ae2d09..9ba44988de 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "private": true, "license": "SEE LICENSE IN LICENSE.md", "scripts": { - "dev": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com vite", - "host": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com vite --host", + "dev": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=\"45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com\" vite", + "host": "II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 II_OPENID_GOOGLE_CLIENT_ID=\"45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com\" vite --host", "showcase": "astro dev --root ./src/showcase", "build": "tsc --noEmit && vite build", "check": "tsc --project ./tsconfig.all.json --noEmit", diff --git a/scripts/build b/scripts/build index 672345da25..7b69f3db11 100755 --- a/scripts/build +++ b/scripts/build @@ -134,7 +134,7 @@ function build_canister() { echo Running cargo build "${cargo_build_args[@]}" echo RUSTFLAGS: "$RUSTFLAGS" - RUSTFLAGS="$RUSTFLAGS" cargo build "${cargo_build_args[@]}" + II_OPENID_GOOGLE_CLIENT_ID="$II_OPENID_GOOGLE_CLIENT_ID" RUSTFLAGS="$RUSTFLAGS" cargo build "${cargo_build_args[@]}" if [ "$ONLY_DEPS" != "1" ] then diff --git a/src/internet_identity/Cargo.toml b/src/internet_identity/Cargo.toml index 736ac96da9..d488893930 100644 --- a/src/internet_identity/Cargo.toml +++ b/src/internet_identity/Cargo.toml @@ -14,8 +14,9 @@ serde.workspace = true serde_bytes.workspace = true serde_cbor.workspace = true serde_json = { version = "1.0", default-features = false, features = ["std"] } -sha2.workspace = true +sha2 = { workspace = true, features = ["oid"]} base64.workspace = true +rsa.workspace = true # Captcha deps lodepng = "*" diff --git a/src/internet_identity/build.rs b/src/internet_identity/build.rs new file mode 100644 index 0000000000..fd0c8655ea --- /dev/null +++ b/src/internet_identity/build.rs @@ -0,0 +1,17 @@ +use std::path::Path; +use std::{env, fs}; + +// OpenID Google client id used by tests +const TEST_OPENID_GOOGLE_CLIENT_ID: &str = + "45431994619-cbbfgtn7o0pp0dpfcg2l66bc4rcg7qbu.apps.googleusercontent.com"; + +// Write environment variables to constants during build time +fn main() { + let openid_google_client_id = + env::var("II_OPENID_GOOGLE_CLIENT_ID").unwrap_or(TEST_OPENID_GOOGLE_CLIENT_ID.into()); + fs::write( + Path::new(&env::var("OUT_DIR").unwrap()).join("constants.rs"), + format!("pub const OPENID_GOOGLE_CLIENT_ID: &str = \"{openid_google_client_id}\";"), + ) + .unwrap(); +} diff --git a/src/internet_identity/src/constants.rs b/src/internet_identity/src/constants.rs new file mode 100644 index 0000000000..6551c47dda --- /dev/null +++ b/src/internet_identity/src/constants.rs @@ -0,0 +1,2 @@ +// Include the generated constants.rs file +include!(concat!(env!("OUT_DIR"), "/constants.rs")); diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 3bd9ee59e8..b9cbd1b1b7 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -41,6 +41,7 @@ mod state; mod stats; mod storage; mod vc_mvp; +mod constants; // Some time helpers const fn secs_to_nanos(secs: u64) -> u64 { diff --git a/src/internet_identity/src/openid.rs b/src/internet_identity/src/openid.rs index 3bab9c8d79..8a1e9092bf 100644 --- a/src/internet_identity/src/openid.rs +++ b/src/internet_identity/src/openid.rs @@ -1,5 +1,43 @@ +use candid::{Deserialize, Principal}; +use identity_jose::jws::Decoder; +use internet_identity_interface::internet_identity::types::{MetadataEntryV2, Timestamp}; +use std::collections::HashMap; + mod google; +#[derive(Debug, PartialEq)] +pub struct OpenIdCredential { + pub iss: String, + pub sub: String, + pub aud: String, + pub principal: Principal, + pub last_usage_timestamp: Timestamp, + pub metadata: HashMap, +} + +#[derive(Deserialize)] +struct PartialClaims { + iss: String, +} + pub fn setup_timers() { google::setup_timers(); } + +#[allow(unused)] +pub fn verify( + jwt: &str, + session_principal: &Principal, + session_salt: &[u8; 32], + timestamp: Timestamp, +) -> Result { + let validation_item = Decoder::new() + .decode_compact_serialization(jwt.as_bytes(), None) + .map_err(|_| "Failed to decode JWT")?; + let claims: PartialClaims = + serde_json::from_slice(validation_item.claims()).map_err(|_| "Unable to decode claims")?; + match claims.iss.as_str() { + google::ISSUER => google::verify(jwt, session_principal, session_salt, timestamp), + _ => Err(format!("Unsupported issuer: {}", claims.iss)), + } +} diff --git a/src/internet_identity/src/openid/google.rs b/src/internet_identity/src/openid/google.rs index 4d56eb9590..21f426f1c2 100644 --- a/src/internet_identity/src/openid/google.rs +++ b/src/internet_identity/src/openid/google.rs @@ -1,16 +1,35 @@ +use crate::constants; +use crate::openid::OpenIdCredential; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use base64::Engine; +use candid::Principal; use candid::{Deserialize, Nat}; use ic_cdk::api::management_canister::http_request::{ http_request_with_closure, CanisterHttpRequestArgument, HttpHeader, HttpMethod, HttpResponse, }; use ic_cdk::{spawn, trap}; use ic_cdk_timers::set_timer; -use identity_jose::jwk::Jwk; +use ic_stable_structures::Storable; +use identity_jose::jwk::{Jwk, JwkParamsRsa}; +use identity_jose::jws::JwsAlgorithm::RS256; +use identity_jose::jws::{ + Decoder, JwsVerifierFn, SignatureVerificationError, SignatureVerificationErrorKind, + VerificationInput, +}; +use internet_identity_interface::internet_identity::types::{MetadataEntryV2, Timestamp}; +use rsa::{Pkcs1v15Sign, RsaPublicKey}; use serde::Serialize; +use sha2::{Digest, Sha256}; use std::cell::RefCell; use std::cmp::min; +use std::collections::HashMap; use std::convert::Into; use std::time::Duration; +pub const ISSUER: &str = "https://accounts.google.com"; + +const AUDIENCE: &str = constants::OPENID_GOOGLE_CLIENT_ID; + const CERTS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs"; // The amount of cycles needed to make the HTTP outcall with a large enough margin @@ -22,11 +41,28 @@ const HTTP_STATUS_OK: u8 = 200; // valid for at least 5 hours so that should be enough margin. const FETCH_CERTS_INTERVAL: u64 = 60 * 60; +// A JWT is only valid for a very small window, even if the JWT itself says it's valid for longer, +// we only need it right after it's being issued to create a JWT delegation with its own expiry. +const MAX_VALIDITY_WINDOW: u64 = 60_000_000_000; // 5 minutes in nanos, same as ingress expiry + #[derive(Serialize, Deserialize)] -struct GoogleCerts { +struct Certs { keys: Vec, } +#[derive(Deserialize)] +struct Claims { + iss: String, + sub: String, + aud: String, + nonce: String, + iat: u64, + // Google specific claims + email: String, + name: String, + picture: String, +} + thread_local! { static CERTS: RefCell> = const { RefCell::new(vec![]) }; } @@ -76,7 +112,7 @@ async fn fetch_certs() -> Result, String> { .await .map_err(|(_, err)| err)?; - serde_json::from_slice::(response.body.as_slice()) + serde_json::from_slice::(response.body.as_slice()) .map_err(|_| "Invalid JSON".into()) .map(|res| res.keys) } @@ -91,14 +127,14 @@ fn transform_certs(response: HttpResponse) -> HttpResponse { trap("Invalid response status") }; - let certs: GoogleCerts = + let certs: Certs = serde_json::from_slice(response.body.as_slice()).unwrap_or_else(|_| trap("Invalid JSON")); let mut sorted_keys = certs.keys.clone(); sorted_keys.sort_by_key(|key| key.kid().unwrap_or_else(|| trap("Invalid JSON")).to_owned()); - let body = serde_json::to_vec(&GoogleCerts { keys: sorted_keys }) - .unwrap_or_else(|_| trap("Invalid JSON")); + let body = + serde_json::to_vec(&Certs { keys: sorted_keys }).unwrap_or_else(|_| trap("Invalid JSON")); // All headers are ignored including the Cache-Control header, instead we fetch the certs // hourly since responses are always valid for at least 5 hours based on analysis of the @@ -110,6 +146,105 @@ fn transform_certs(response: HttpResponse) -> HttpResponse { } } +pub fn verify( + jwt: &str, + session_principal: &Principal, + session_salt: &[u8; 32], + timestamp: Timestamp, +) -> Result { + let validation_item = Decoder::new() + .decode_compact_serialization(jwt.as_bytes(), None) + .map_err(|_| "Unable to decode JWT")?; + let kid = validation_item.kid().ok_or("JWT is missing kid")?; + let certs = CERTS.with(|certs| certs.borrow().clone()); + let cert = certs + .iter() + .find(|cert| cert.kid().is_some_and(|v| v == kid)) + .ok_or(format!("Certificate not found for {kid}"))?; + + let claims: Claims = serde_json::from_slice(validation_item.claims()) + .map_err(|_| "Unable to decode claims or expected claims are missing")?; + + validation_item + .verify(&JwsVerifierFn::from(verify_signature), cert) + .map_err(|_| "Invalid signature")?; + verify_claims(&claims, session_principal, session_salt, timestamp)?; + + Ok(OpenIdCredential { + iss: claims.iss, + sub: claims.sub, + aud: claims.aud, + principal: Principal::anonymous(), + last_usage_timestamp: timestamp, + metadata: HashMap::from([ + ("email".into(), MetadataEntryV2::String(claims.email)), + ("name".into(), MetadataEntryV2::String(claims.name)), + ("picture".into(), MetadataEntryV2::String(claims.picture)), + ]), + }) +} + +fn verify_claims( + claims: &Claims, + session_principal: &Principal, + session_salt: &[u8; 32], + timestamp: Timestamp, +) -> Result<(), String> { + let mut hasher = Sha256::new(); + hasher.update(session_salt); + hasher.update(session_principal.to_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let expected_nonce = BASE64_URL_SAFE_NO_PAD.encode(hash); + + if claims.iss != ISSUER { + return Err(format!("Invalid issuer: {}", claims.iss)); + } + if claims.aud != AUDIENCE { + return Err(format!("Invalid audience: {}", claims.aud)); + } + if claims.nonce != expected_nonce { + return Err(format!("Invalid nonce: {}", claims.nonce)); + } + if timestamp > claims.iat * 1_000_000_000 + MAX_VALIDITY_WINDOW { + return Err("JWT is no longer valid".into()); + } + if timestamp < claims.iat * 1_000_000_000 { + return Err("JWT is not valid yet".into()); + } + + Ok(()) +} + +#[allow(clippy::needless_pass_by_value)] +fn verify_signature(input: VerificationInput, jwk: &Jwk) -> Result<(), SignatureVerificationError> { + if input.alg != RS256 { + return Err(SignatureVerificationErrorKind::UnsupportedAlg.into()); + } + + let hashed_input = Sha256::digest(input.signing_input); + let scheme = Pkcs1v15Sign::new::(); + let JwkParamsRsa { n, e, .. } = jwk.try_rsa_params().map_err(|_| { + SignatureVerificationError::from(SignatureVerificationErrorKind::KeyDecodingFailure) + })?; + let n = BASE64_URL_SAFE_NO_PAD.decode(&n).map_err(|_| { + SignatureVerificationError::from(SignatureVerificationErrorKind::KeyDecodingFailure) + })?; + let e = BASE64_URL_SAFE_NO_PAD.decode(&e).map_err(|_| { + SignatureVerificationError::from(SignatureVerificationErrorKind::KeyDecodingFailure) + })?; + let rsa_key = RsaPublicKey::new( + rsa::BigUint::from_bytes_be(&n), + rsa::BigUint::from_bytes_be(&e), + ) + .map_err(|_| { + SignatureVerificationError::from(SignatureVerificationErrorKind::KeyDecodingFailure) + })?; + + rsa_key + .verify(scheme, &hashed_input, input.decoded_signature.as_ref()) + .map_err(|_| SignatureVerificationErrorKind::InvalidSignature.into()) +} + #[test] fn should_transform_certs_to_same() { let input = HttpResponse { @@ -128,3 +263,190 @@ fn should_transform_certs_to_same() { assert_eq!(transform_certs(input), expected); } + +#[cfg(test)] +fn valid_verification_test_data() -> (String, Certs, Principal, [u8; 32], Timestamp, Claims) { + // This JWT is for testing purposes, it's already been expired before this commit has been made, + // additionally the audience of this JWT is a test Google client registration, not production. + let jwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImRkMTI1ZDVmNDYyZmJjNjAxNGFlZGFiODFkZGYzYmNlZGFiNzA4NDciLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0NTQzMTk5NDYxOS1jYmJmZ3RuN28wcHAwZHBmY2cybDY2YmM0cmNnN3FidS5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsImF1ZCI6IjQ1NDMxOTk0NjE5LWNiYmZndG43bzBwcDBkcGZjZzJsNjZiYzRyY2c3cWJ1LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwic3ViIjoiMTE1MTYwNzE2MzM4ODEzMDA2OTAyIiwiaGQiOiJkZmluaXR5Lm9yZyIsImVtYWlsIjoidGhvbWFzLmdsYWRkaW5lc0BkZmluaXR5Lm9yZyIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJub25jZSI6ImV0aURhTEdjUmRtNS1yY3FlMFpRVWVNZ3BmcDR2OVRPT1lVUGJoUng3bkkiLCJuYmYiOjE3MzY3OTM4MDIsIm5hbWUiOiJUaG9tYXMgR2xhZGRpbmVzIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hL0FDZzhvY0lTTWxja0M1RjZxaGlOWnpfREZtWGp5OTY4LXlPaEhPTjR4TGhRdXVNSDNuQlBXQT1zOTYtYyIsImdpdmVuX25hbWUiOiJUaG9tYXMiLCJmYW1pbHlfbmFtZSI6IkdsYWRkaW5lcyIsImlhdCI6MTczNjc5NDEwMiwiZXhwIjoxNzM2Nzk3NzAyLCJqdGkiOiIwMWM1NmYyMGM1MzFkNDhhYjU0ZDMwY2I4ZmRiNzU0MmM0ZjdmNjg4In0.f47b0HNskm-85sT5XtoRzORnfobK2nzVFG8jTH6eS_qAyu0ojNDqVsBtGN4A7HdjDDCOIMSu-R5e413xuGJIWLadKrLwXmguRFo3SzLrXeja-A-rP-axJsb5QUJZx1mwYd1vUNzLB9bQojU3Na6Hdvq09bMtTwaYdCn8Q9v3RErN-5VUxELmSbSXbf10A-IsS7jtzPjxHV6ueq687Ppeww6Q7AGGFB4t9H8qcDbI1unSdugX3-MfMWJLzVHbVxDgfAcLem1c2iAspvv_D5aPLeJF5HLRR2zg-Jil1BFTOoEPAAPFr1MEsvDMWSTt5jLyuMrnS4jiMGudGGPV4DDDww"; + let certs: Certs = serde_json::from_str(r#"{ + "keys": [ + { + "n": "jwstqI4w2drqbTTVRDriFqepwVVI1y05D5TZCmGvgMK5hyOsVW0tBRiY9Jk9HKDRue3vdXiMgarwqZEDOyOA0rpWh-M76eauFhRl9lTXd5gkX0opwh2-dU1j6UsdWmMa5OpVmPtqXl4orYr2_3iAxMOhHZ_vuTeD0KGeAgbeab7_4ijyLeJ-a8UmWPVkglnNb5JmG8To77tSXGcPpBcAFpdI_jftCWr65eL1vmAkPNJgUTgI4sGunzaybf98LSv_w4IEBc3-nY5GfL-mjPRqVCRLUtbhHO_5AYDpqGj6zkKreJ9-KsoQUP6RrAVxkNuOHV9g1G-CHihKsyAifxNN2Q", + "use": "sig", + "kty": "RSA", + "alg": "RS256", + "kid": "dd125d5f462fbc6014aedab81ddf3bcedab70847", + "e": "AQAB" + } + ] + }"#).unwrap(); + let session_principal = + Principal::from_text("x4gp4-hxabd-5jt4d-wc6uw-qk4qo-5am4u-mncv3-wz3rt-usgjp-od3c2-oae") + .unwrap(); + let session_salt: [u8; 32] = [ + 143, 79, 158, 224, 218, 125, 157, 169, 98, 43, 205, 227, 243, 123, 173, 255, 132, 83, 81, + 139, 161, 18, 224, 243, 4, 129, 26, 123, 229, 242, 200, 189, + ]; + let timestamp: u64 = 1_736_794_102_000_000_000; + let validation_item = Decoder::new() + .decode_compact_serialization(jwt.as_bytes(), None) + .unwrap(); + let claims: Claims = serde_json::from_slice(validation_item.claims()).unwrap(); + + ( + jwt.into(), + certs, + session_principal, + session_salt, + timestamp, + claims, + ) +} + +#[test] +fn should_return_credential() { + let (jwt, certs, session_principal, session_salt, timestamp, claims) = + valid_verification_test_data(); + CERTS.replace(certs.keys); + let credential = OpenIdCredential { + iss: claims.iss, + sub: claims.sub, + aud: claims.aud, + principal: Principal::anonymous(), + last_usage_timestamp: timestamp, + metadata: HashMap::from([ + ("email".into(), MetadataEntryV2::String(claims.email)), + ("name".into(), MetadataEntryV2::String(claims.name)), + ("picture".into(), MetadataEntryV2::String(claims.picture)), + ]), + }; + + assert_eq!( + verify(&jwt, &session_principal, &session_salt, timestamp), + Ok(credential) + ); +} + +#[test] +fn cert_should_be_missing() { + let (jwt, _, session_principal, session_salt, timestamp, _) = + valid_verification_test_data(); + CERTS.replace(vec![]); + + assert_eq!( + verify(&jwt, &session_principal, &session_salt, timestamp), + Err("Certificate not found for dd125d5f462fbc6014aedab81ddf3bcedab70847".into()) + ); +} + +#[test] +fn signature_should_be_invalid() { + let (jwt, certs, session_principal, session_salt, timestamp, _) = + valid_verification_test_data(); + CERTS.replace(certs.keys); + let chunks: Vec<&str> = jwt.split('.').collect(); + let header = chunks[0]; + let payload = chunks[1]; + let invalid_signature = "f47b0sNskm-85sT5XtoRzORnfobK2nzVFF8jTH6eS_qAyu0ojNDqVsBtGN4A7HdjDDCOIMSu-R5e413xuGJIWLadKrLwXmguRFo3SzLrXeja-A-rP-axJsb5QUJZx1mwYd1vUNzLB9bQojU3Na6Hdvq09bMtTwaYdCn8Q9v3RErN-5VUxELmSbSXbf10A-IsS7jtzPjxHV6ueq687Ppeww5Q7AGGFB4t9H8qcDbI1unSdugX3-MfMWJLzVHbVxDgfAcLem1c2iAspvv_D5aPLeJF5HLRR2zg-Jil1BFTOoEPAAPFr1MEsvDMWSTt5jLyuMrnS4jiMGudGGPV4DDDww"; + let invalid_jwt = [header, payload, invalid_signature].join("."); + + assert_eq!( + verify(&invalid_jwt, &session_principal, &session_salt, timestamp), + Err("Invalid signature".into()) + ); +} + +#[test] +fn issuer_should_be_invalid() { + let (_, _, session_principal, session_salt, timestamp, claims) = + valid_verification_test_data(); + let mut invalid_claims = claims; + invalid_claims.iss = "invalid-issuer".into(); + assert_eq!( + verify_claims( + &invalid_claims, + &session_principal, + &session_salt, + timestamp + ), + Err(format!("Invalid issuer: {}", invalid_claims.iss)) + ); +} + +#[test] +fn audience_should_be_invalid() { + let (_, _, session_principal, session_salt, timestamp, claims) = + valid_verification_test_data(); + let mut invalid_claims = claims; + invalid_claims.aud = "invalid-audience".into(); + assert_eq!( + verify_claims( + &invalid_claims, + &session_principal, + &session_salt, + timestamp + ), + Err(format!("Invalid audience: {}", invalid_claims.aud)) + ); +} + +#[test] +fn nonce_should_be_invalid() { + let (_, _, session_principal, session_salt, timestamp, claims) = valid_verification_test_data(); + let invalid_session_principal = + Principal::from_text("necp6-24oof-6e2i2-xg7fk-pawxw-nlol2-by5bb-mltvt-sazk6-nqrzz-zae") + .unwrap(); + let invalid_session_salt: [u8; 32] = [ + 143, 79, 58, 224, 18, 15, 157, 169, 98, 43, 205, 227, 243, 123, 173, 255, 132, 83, 81, 139, + 161, 218, 224, 243, 4, 120, 26, 123, 229, 242, 200, 189, + ]; + + assert_eq!( + verify_claims( + &claims, + &invalid_session_principal, + &session_salt, + timestamp + ), + Err("Invalid nonce: etiDaLGcRdm5-rcqe0ZQUeMgpfp4v9TOOYUPbhRx7nI".into()) + ); + assert_eq!( + verify_claims( + &claims, + &session_principal, + &invalid_session_salt, + timestamp + ), + Err("Invalid nonce: etiDaLGcRdm5-rcqe0ZQUeMgpfp4v9TOOYUPbhRx7nI".into()) + ); +} + +#[test] +fn should_be_no_longer_invalid() { + let (_, _, session_principal, session_salt, timestamp, claims) = valid_verification_test_data(); + + assert_eq!( + verify_claims( + &claims, + &session_principal, + &session_salt, + timestamp + MAX_VALIDITY_WINDOW + 1 + ), + Err("JWT is no longer valid".into()) + ); +} +#[test] +fn should_be_not_valid_yet() { + let (_, _, session_principal, session_salt, timestamp, claims) = valid_verification_test_data(); + + assert_eq!( + verify_claims( + &claims, + &session_principal, + &session_salt, + timestamp - 1 + ), + Err("JWT is not valid yet".into()) + ); +}