diff --git a/crates/lib/api_clients/google_sign_in/src/download_certs.rs b/crates/lib/api_clients/google_sign_in/src/certs/download_certs.rs similarity index 100% rename from crates/lib/api_clients/google_sign_in/src/download_certs.rs rename to crates/lib/api_clients/google_sign_in/src/certs/download_certs.rs diff --git a/crates/lib/api_clients/google_sign_in/src/certs/mod.rs b/crates/lib/api_clients/google_sign_in/src/certs/mod.rs index 1f9a37d5c0..7e7af9af26 100644 --- a/crates/lib/api_clients/google_sign_in/src/certs/mod.rs +++ b/crates/lib/api_clients/google_sign_in/src/certs/mod.rs @@ -1,2 +1,3 @@ +pub mod download_certs; pub mod jwk_to_public_key; pub mod key_map; \ No newline at end of file diff --git a/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs b/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs index 6274124855..c4a4a3a21f 100644 --- a/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs +++ b/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs @@ -1,24 +1,27 @@ -use std::collections::HashSet; use crate::certs::key_map::KeyMap; use crate::claims::claims::Claims; use crate::claims::google_custom_claims::GoogleCustomClaims; +use crate::errors::VerifyError; use crate::jwt::decode_jwt_header::decode_jwt_header; -use errors::{anyhow, AnyhowResult}; +use errors::anyhow; use jwt_simple::algorithms::RS256PublicKey; use jwt_simple::algorithms::RSAPublicKeyLike; use jwt_simple::common::VerificationOptions; +use jwt_simple::JWTError; +use std::collections::HashSet; /// Decode a Google Sign In JWT. /// Verification options can be supplied to increase clock skew tolerance, etc. -pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: Option) -> AnyhowResult { - let header = decode_jwt_header(token)?; +pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: Option) -> Result { + let header = decode_jwt_header(token) + .map_err(|err| VerifyError::JwtDecodeError { source: err })?; let key_id = header.kid.as_deref() .or_else(|| keys.keys().next().map(|k|k.as_str())) .ok_or_else(|| anyhow!("No key ID found"))?; let key = keys.get(key_id) - .ok_or_else(|| anyhow!("Key not found"))?; + .ok_or_else(|| VerifyError::JwtKeyMissing { requested_key: key_id.to_string() })?; if options.is_none() { options = Some(VerificationOptions { @@ -33,13 +36,26 @@ pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: O decode_and_verify_token_claims_with_key(key, token, options) } -pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str, options: Option) -> AnyhowResult { - let claims = key.verify_token::(token, options)?; +pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str, options: Option) -> Result { + let result = key.verify_token::(token, options); + + let claims = match result { + Ok(claims) => claims, + Err(err) => { + if let Some(inner_error) = err.downcast_ref::() { + match inner_error { + JWTError::TokenHasExpired => return Err(VerifyError::JwtExpired), + _ => {}, // Fall-through + } + } + return Err(VerifyError::AnyhowError(err)); + } + }; match claims.issuer.as_deref() { Some("https://accounts.google.com" | "accounts.google.com") => {} // Permitted _ => { - return Err(anyhow!("Invalid issuer: {:?}", claims.issuer)); + return Err(VerifyError::InvalidIssuer { issuer: claims.issuer }); } } @@ -51,35 +67,34 @@ pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str #[cfg(test)] mod tests { use crate::certs::jwk_to_public_key::jwk_to_public_key; - use crate::decode_and_verify_token_claims::{decode_and_verify_token_claims, decode_and_verify_token_claims_with_key}; + use crate::decode_and_verify_token_claims::decode_and_verify_token_claims; + use crate::errors::VerifyError; use coarsetime::Duration; use jwt_simple::claims::Audiences; use jwt_simple::prelude::VerificationOptions; use std::fs::read_to_string; use testing::test_file_path::test_file_path; - /* - Example payload: - iss https://accounts.google.com - azp 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com - aud 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com - sub 113101967612396793777 - email vocodes2020@gmail.com - email_verified true - nbf 1726786100 - name Vocodes Vocodes - picture https://lh3.googleusercontent.com/a/ACg8ocLz2-2OaAm0MQxR6j8CNr-Po8_Xr-aryATiCn4c0i_TuDmL_g=s96-c - given_name Vocodes - family_name Vocodes - iat 1726786400 - exp 1726790000 - jti 4d44eeac06ce79fc0ab2270cfeea30d8acf77613 - */ + Example payload: + iss https://accounts.google.com + azp 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com + aud 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com + sub 113101967612396793777 + email vocodes2020@gmail.com + email_verified true + nbf 1726786100 + name Vocodes Vocodes + picture https://lh3.googleusercontent.com/a/ACg8ocLz2-2OaAm0MQxR6j8CNr-Po8_Xr-aryATiCn4c0i_TuDmL_g=s96-c + given_name Vocodes + family_name Vocodes + iat 1726786400 + exp 1726790000 + jti 4d44eeac06ce79fc0ab2270cfeea30d8acf77613 + */ #[test] fn test_decode() { let jwk_file = test_file_path("test_data/crypto/google_sign_in.2024-09-20.jwk").unwrap(); - //let jwk_file = test_file_path("test_data/crypto/google_sign_in.2024-04-01.jwk").unwrap(); let jwk_payload = read_to_string(jwk_file).unwrap(); let key_map = jwk_to_public_key(&jwk_payload).unwrap(); @@ -129,4 +144,53 @@ mod tests { Audiences::AsSet(_audiences) => panic!("Expected a single audience"), } } + + #[test] + fn test_decode_with_invalid_keys() { + let jwk_file = test_file_path("test_data/crypto/google_sign_in.2024-04-01.jwk").unwrap(); + let jwk_payload = read_to_string(jwk_file).unwrap(); + let key_map = jwk_to_public_key(&jwk_payload).unwrap(); + + let credential = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyNjIwZDVlN2YxMzJiNTJhZmU4ODc1Y2RmMzc3NmMwNjQyNDlkMDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTMxMDE5Njc2MTIzOTY3OTM3NzciLCJlbWFpbCI6InZvY29kZXMyMDIwQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYmYiOjE3MjY3ODYxMDAsIm5hbWUiOiJWb2NvZGVzIFZvY29kZXMiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTHoyLTJPYUFtME1ReFI2ajhDTnItUG84X1hyLWFyeUFUaUNuNGMwaV9UdURtTF9nPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlZvY29kZXMiLCJmYW1pbHlfbmFtZSI6IlZvY29kZXMiLCJpYXQiOjE3MjY3ODY0MDAsImV4cCI6MTcyNjc5MDAwMCwianRpIjoiNGQ0NGVlYWMwNmNlNzlmYzBhYjIyNzBjZmVlYTMwZDhhY2Y3NzYxMyJ9.EYg71yIkvhxFGc8ZVCXeTOAmPAtLYDphHnkdf1sh8b_Jz4Y7S1DpmiTqQ1ytxu7J1xNixvdwhuIDzSlCvlxaFl8475GvAlyPTNtZtmWbFD5SRM_XHLOynijOp8WQ4nej-CHvT1KjjqMfkZ1EeQMoWk1H72PxPg_RiUgzsklkUs1wOkLAySk7R3EIAl7bIzpoY_WH2pxv9ccFpBtKDHaDqHkxAWBUQX0-G7ZXZBPVz07V28ZfdbzFDapjZaUFbumazh_-J2-9AA6JkcteF4h_gpbBcLYAuxt5bWI5FECWbYe42khwb93WJ5SK12Tt0EPoyzIObJs14NWGAajtHTg3wA"; + + let options = VerificationOptions { + time_tolerance: Some(Duration::from_days(365 * 30)), + reject_before: None, + accept_future: false, + ..Default::default() + }; + + match decode_and_verify_token_claims(&key_map, credential, Some(options)) { + Err(VerifyError::JwtKeyMissing { requested_key}) => { + assert_eq!(requested_key, "b2620d5e7f132b52afe8875cdf3776c064249d04"); + } + Err(err) => { + panic!("Unexpected error: {:?}", err); + } + Ok(value) => { + panic!("Unexpected success: {:?}", value.claims); + } + } + } + + #[test] + fn test_decode_with_expired_token() { + let jwk_file = test_file_path("test_data/crypto/google_sign_in.2024-09-20.jwk").unwrap(); + let jwk_payload = read_to_string(jwk_file).unwrap(); + let key_map = jwk_to_public_key(&jwk_payload).unwrap(); + + let credential = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyNjIwZDVlN2YxMzJiNTJhZmU4ODc1Y2RmMzc3NmMwNjQyNDlkMDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTMxMDE5Njc2MTIzOTY3OTM3NzciLCJlbWFpbCI6InZvY29kZXMyMDIwQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYmYiOjE3MjY3ODYxMDAsIm5hbWUiOiJWb2NvZGVzIFZvY29kZXMiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTHoyLTJPYUFtME1ReFI2ajhDTnItUG84X1hyLWFyeUFUaUNuNGMwaV9UdURtTF9nPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlZvY29kZXMiLCJmYW1pbHlfbmFtZSI6IlZvY29kZXMiLCJpYXQiOjE3MjY3ODY0MDAsImV4cCI6MTcyNjc5MDAwMCwianRpIjoiNGQ0NGVlYWMwNmNlNzlmYzBhYjIyNzBjZmVlYTMwZDhhY2Y3NzYxMyJ9.EYg71yIkvhxFGc8ZVCXeTOAmPAtLYDphHnkdf1sh8b_Jz4Y7S1DpmiTqQ1ytxu7J1xNixvdwhuIDzSlCvlxaFl8475GvAlyPTNtZtmWbFD5SRM_XHLOynijOp8WQ4nej-CHvT1KjjqMfkZ1EeQMoWk1H72PxPg_RiUgzsklkUs1wOkLAySk7R3EIAl7bIzpoY_WH2pxv9ccFpBtKDHaDqHkxAWBUQX0-G7ZXZBPVz07V28ZfdbzFDapjZaUFbumazh_-J2-9AA6JkcteF4h_gpbBcLYAuxt5bWI5FECWbYe42khwb93WJ5SK12Tt0EPoyzIObJs14NWGAajtHTg3wA"; + + match decode_and_verify_token_claims(&key_map, credential, None) { + Err(VerifyError::JwtExpired) => { + // Expected error case + } + Err(err) => { + panic!("Unexpected error: {:?}", err); + } + Ok(value) => { + panic!("Unexpected success: {:?}", value.claims); + } + } + } } diff --git a/crates/lib/api_clients/google_sign_in/src/errors.rs b/crates/lib/api_clients/google_sign_in/src/errors.rs new file mode 100644 index 0000000000..cc4382b733 --- /dev/null +++ b/crates/lib/api_clients/google_sign_in/src/errors.rs @@ -0,0 +1,53 @@ +use errors::AnyhowError; +use std::error::Error; +use std::fmt::Display; + +#[derive(Debug)] +pub enum VerifyError { + /// Could not decode a JWT token. + JwtDecodeError { source: AnyhowError }, + + /// The JWT has expired (was issued before the current + /// clock + deadline + skew tolerance) + JwtExpired, + + /// The key requested by the JWT was not available. + /// This likely means we need to refresh our JWK key set. + JwtKeyMissing { requested_key: String }, + + /// The issuer of the claims was unexpected + InvalidIssuer { issuer: Option }, + + /// Any other error type we haven't wrapped. + AnyhowError(AnyhowError), +} + +impl Error for VerifyError {} + +impl Display for VerifyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::JwtDecodeError { source } => { + write!(f, "JWT decode error: {}", source) + } + Self::JwtExpired => { + write!(f, "JWT expired") + } + Self::JwtKeyMissing { requested_key } => { + write!(f, "JWT key absent: {}", requested_key) + } + Self::InvalidIssuer { issuer } => { + write!(f, "Invalid issuer: {:?}", issuer) + } + Self::AnyhowError(err) => { + write!(f, "AnyhowError: {}", err) + } + } + } +} + +impl From for VerifyError { + fn from(value: AnyhowError) -> Self { + Self::AnyhowError(value) + } +} diff --git a/crates/lib/api_clients/google_sign_in/src/lib.rs b/crates/lib/api_clients/google_sign_in/src/lib.rs index b081611a79..d85c382279 100644 --- a/crates/lib/api_clients/google_sign_in/src/lib.rs +++ b/crates/lib/api_clients/google_sign_in/src/lib.rs @@ -21,7 +21,7 @@ pub mod certs; pub mod claims; pub mod decode_and_verify_token_claims; -pub mod download_certs; +pub mod errors; pub(crate) mod jwt; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs index 0894eca391..c62c2cb5ff 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs @@ -7,25 +7,25 @@ use std::collections::HashMap; use std::fmt; use std::fmt::Formatter; +use actix_helpers::extractors::get_request_origin_uri::get_request_origin_uri; use actix_web::error::ResponseError; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; -use log::{info, warn}; -use sqlx::MySqlPool; -use utoipa::ToSchema; -use actix_helpers::extractors::get_request_origin_uri::get_request_origin_uri; use errors::AnyhowResult; +use google_sign_in::certs::download_certs::download_cert_key_set; use google_sign_in::decode_and_verify_token_claims::decode_and_verify_token_claims; -use google_sign_in::download_certs::download_cert_key_set; use google_sign_in::VerificationOptions; use http_server_common::request::get_request_ip::get_request_ip; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use log::{info, warn}; use mysql_queries::mediators::firehose_publisher::FirehosePublisher; use mysql_queries::queries::users::user::create_account::{create_account, CreateAccountArgs, CreateAccountError}; use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; use password::bcrypt_hash_password::bcrypt_hash_password; +use sqlx::MySqlPool; use tokens::tokens::user_sessions::UserSessionToken; use user_input_common::check_for_slurs::contains_slurs; +use utoipa::ToSchema; use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; use crate::http_server::validations::is_reserved_username::is_reserved_username; diff --git a/crates/service/web/storyteller_web/src/state/certs/google_sign_in_cert.rs b/crates/service/web/storyteller_web/src/state/certs/google_sign_in_cert.rs index c5312d9a72..dd2dffd57f 100644 --- a/crates/service/web/storyteller_web/src/state/certs/google_sign_in_cert.rs +++ b/crates/service/web/storyteller_web/src/state/certs/google_sign_in_cert.rs @@ -1,7 +1,7 @@ use crate::http_server::endpoints::users::create_account_from_google_sign_in_handler::GoogleCreateAccountErrorResponse; use errors::AnyhowResult; +use google_sign_in::certs::download_certs::download_cert_key_set; use google_sign_in::certs::key_map::KeyMap; -use google_sign_in::download_certs::download_cert_key_set; use log::{error, info, warn}; use std::sync::{Arc, LockResult, RwLock}; use anyhow::anyhow;