From 375b6b006737589627dc3c79568d7e3f91771b95 Mon Sep 17 00:00:00 2001 From: James Michael Date: Fri, 22 May 2026 12:14:47 -0700 Subject: [PATCH 1/5] Add support for multiple clients As part of Project Prism we are expanding the apps and will need them all to be able to add backup methods. This updates the backup service to respect the client name when determining the appropriate bundle identifiers for verification. I've added this header to all of the routes for completeness, but add_factor is the primary one we're interested in right now. --- Cargo.lock | 6 +- deny.toml | 5 +- src/auth.rs | 10 +- src/headers.rs | 4 + src/lib.rs | 1 + src/oidc_token_verifier.rs | 114 ++++++++++++------ src/routes/add_factor.rs | 5 + src/routes/add_sync_factor.rs | 6 + src/routes/create_backup.rs | 6 + src/routes/delete_backup.rs | 6 + src/routes/delete_factor.rs | 5 + src/routes/retrieve_from_challenge.rs | 13 +-- src/routes/retrieve_metadata.rs | 6 + src/routes/sync_backup.rs | 5 + src/routes/verify_factor.rs | 13 +-- src/types/environment.rs | 159 ++++++++++++++++++++++---- 16 files changed, 292 insertions(+), 72 deletions(-) create mode 100644 src/headers.rs diff --git a/Cargo.lock b/Cargo.lock index a8c5b01..4b47eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3401,7 +3401,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -3440,9 +3440,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/deny.toml b/deny.toml index 61d9b14..27f4aec 100644 --- a/deny.toml +++ b/deny.toml @@ -8,7 +8,10 @@ unknown-registry = "deny" [advisories] ignore = [ { id = "RUSTSEC-2023-0071", reason = "a potential timing attack to recover a private key from rsa crate. however, the rsa library is used either for tests or signature verification, so private key material is not exposed." }, - { id = "RUSTSEC-2026-0049", reason = "`rustls-webpki` (requires upstream dep updates); low severity, requires compromised CA (2026-03-23)"} + { id = "RUSTSEC-2026-0049", reason = "`rustls-webpki` (requires upstream dep updates); low severity, requires compromised CA (2026-03-23)" }, + { id = "RUSTSEC-2026-0098", reason = "`rustls-webpki` 0.101.7 via AWS SDK rustls 0.21.x; no fix in the 0.101.x range, requires upstream AWS SDK to bump rustls" }, + { id = "RUSTSEC-2026-0099", reason = "`rustls-webpki` 0.101.7 via AWS SDK rustls 0.21.x; no fix in the 0.101.x range, requires upstream AWS SDK to bump rustls" }, + { id = "RUSTSEC-2026-0104", reason = "`rustls-webpki` 0.101.7 via AWS SDK rustls 0.21.x; no fix in the 0.101.x range, requires upstream AWS SDK to bump rustls" }, ] [licenses] diff --git a/src/auth.rs b/src/auth.rs index ac68c74..ddebb85 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -140,6 +140,7 @@ impl AuthHandler { expected_factor_scope: FactorScope, expected_challenge_context: ChallengeContext, challenge_token: String, + client_name: Option<&str>, ) -> Result<(String, BackupMetadata), AuthError> { // Step 1: Verify that the authorization type is supported // `ECKeyPair` is the only supported factor type for `Sync` scope, other factors are rejected. @@ -184,6 +185,7 @@ impl AuthHandler { signature, &challenge_token_payload, expected_factor_scope, + client_name, ) .await? } @@ -222,6 +224,7 @@ impl AuthHandler { expected_challenge_context: ChallengeContext, turnkey_provider_id: Option, is_sync_factor: bool, + client_name: Option<&str>, ) -> Result { // Step 1: Verify that the authorization type is valid for the factor scope // Sync factors must be EC keypairs - passkeys and OIDC accounts are not allowed as sync factors @@ -267,6 +270,7 @@ impl AuthHandler { signature, &challenge_token_payload, turnkey_provider_id.ok_or_else(|| AuthError::MissingTurnkeyProviderId)?, + client_name, ) .await? } @@ -439,10 +443,11 @@ impl AuthHandler { signature: &str, challenge_token_payload: &[u8], turnkey_provider_id: String, + client_name: Option<&str>, ) -> Result<(Factor, FactorToLookup), AuthError> { let claims = self .oidc_token_verifier - .verify_token(oidc_token, public_key.to_string()) + .verify_token(oidc_token, public_key.to_string(), client_name) .await?; verify_signature(public_key, signature, challenge_token_payload)?; @@ -486,10 +491,11 @@ impl AuthHandler { signature: &str, challenge_token_payload: &[u8], expected_factor_scope: FactorScope, + client_name: Option<&str>, ) -> Result<(String, BackupMetadata), AuthError> { let claims = self .oidc_token_verifier - .verify_token(oidc_token, public_key.to_string()) + .verify_token(oidc_token, public_key.to_string(), client_name) .await?; verify_signature(public_key, signature, challenge_token_payload)?; diff --git a/src/headers.rs b/src/headers.rs new file mode 100644 index 0000000..415a554 --- /dev/null +++ b/src/headers.rs @@ -0,0 +1,4 @@ +use http::HeaderName; + +pub static CLIENT_NAME: HeaderName = HeaderName::from_static("client-name"); +pub static CLIENT_VERSION: HeaderName = HeaderName::from_static("client-version"); diff --git a/src/lib.rs b/src/lib.rs index da525fc..04bd9b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod backup_storage; pub mod challenge_manager; pub mod factor_lookup; +pub mod headers; pub mod kms_jwe; pub mod middleware; pub mod oidc_nonce_verifier; diff --git a/src/oidc_token_verifier.rs b/src/oidc_token_verifier.rs index 88ab325..0913321 100644 --- a/src/oidc_token_verifier.rs +++ b/src/oidc_token_verifier.rs @@ -103,19 +103,20 @@ impl OidcTokenVerifier { &self, token: &OidcToken, expected_public_key_sec1_base64: String, + client_name: Option<&str>, ) -> Result, OidcTokenVerifierError> { // Step 1: Extract the token and other parameters based on the OIDC provider let (oidc_token, jwk_set_url, client_id, issuer_url) = match token { OidcToken::Google { token } => ( token, self.environment.google_jwk_set_url(), - self.environment.google_client_id(), + self.environment.google_client_id(client_name), self.environment.google_issuer_url(), ), OidcToken::Apple { token } => ( token, self.environment.apple_jwk_set_url(), - self.environment.apple_client_id(), + self.environment.apple_client_id(client_name), self.environment.apple_issuer_url(), ), }; @@ -123,29 +124,29 @@ impl OidcTokenVerifier { // Load the public keys from the OIDC provider let signature_keys = self.get_jwk_set(&jwk_set_url).await?.as_ref().clone(); - // Step 3: Create the token verifier - let token_verifier = - CoreIdTokenVerifier::new_public_client(client_id, issuer_url.clone(), signature_keys) - .set_issue_time_verifier_fn(issue_time_verifier); - - // Step 4: Verify the token and extract claims + // Step 3: Verify the token and extract claims let oidc_token = CoreIdToken::from_str(oidc_token).map_err(|err| { tracing::warn!(message = "Failed to parse OIDC token", err = ?err); OidcTokenVerifierError::TokenParseError })?; - // Step 5: Verify the nonce and extract the claims + // Step 4: Verify the token against the client ID selected by client_name let claims = oidc_token .claims( - &token_verifier, - OidcNonceVerifier::new(expected_public_key_sec1_base64), + &CoreIdTokenVerifier::new_public_client( + client_id, + issuer_url.clone(), + signature_keys.clone(), + ) + .set_issue_time_verifier_fn(issue_time_verifier), + OidcNonceVerifier::new(expected_public_key_sec1_base64.clone()), ) .map_err(|err| { tracing::error!(message = "Token verification error", err = ?err, issuer = ?issuer_url); match err { ClaimsVerificationError::InvalidNonce(e) => OidcTokenVerifierError::InvalidNonce(e.clone()), - _ => OidcTokenVerifierError::TokenVerificationError + _ => OidcTokenVerifierError::TokenVerificationError, } })?; @@ -226,16 +227,17 @@ mod tests { provider: OidcProvider, token: String, public_key: String, + client_name: Option<&str>, ) -> Result, OidcTokenVerifierError> { match provider { OidcProvider::Google => { verifier - .verify_token(&OidcToken::Google { token }, public_key) + .verify_token(&OidcToken::Google { token }, public_key, client_name) .await } OidcProvider::Apple => { verifier - .verify_token(&OidcToken::Apple { token }, public_key) + .verify_token(&OidcToken::Apple { token }, public_key, client_name) .await } } @@ -257,8 +259,14 @@ mod tests { let token = oidc_server.generate_token(provider.into(), None, &public_key); // Verify the token - let result = - verify_token_for_provider(&verifier, provider, token, public_key.clone()).await; + let result = verify_token_for_provider( + &verifier, + provider, + token, + public_key.clone(), + Some("ios-id"), + ) + .await; // The test should pass with a valid token assert!(result.is_ok()); @@ -281,8 +289,14 @@ mod tests { let token = oidc_server.generate_expired_token(provider.into()); // Verify the token - let result = - verify_token_for_provider(&verifier, provider, token, public_key.clone()).await; + let result = verify_token_for_provider( + &verifier, + provider, + token, + public_key.clone(), + Some("ios-id"), + ) + .await; // The test should fail with an expired token assert!(result.is_err()); @@ -309,8 +323,14 @@ mod tests { let token = oidc_server.generate_incorrectly_signed_token(provider.into()); // Verify the token - let result = - verify_token_for_provider(&verifier, provider, token, public_key.clone()).await; + let result = verify_token_for_provider( + &verifier, + provider, + token, + public_key.clone(), + Some("ios-id"), + ) + .await; // The test should fail with an incorrectly signed token assert!(result.is_err()); @@ -338,8 +358,14 @@ mod tests { oidc_server.generate_token_with_incorrect_issuer(provider.into(), &public_key); // Verify the token - let result = - verify_token_for_provider(&verifier, provider, token, public_key.clone()).await; + let result = verify_token_for_provider( + &verifier, + provider, + token, + public_key.clone(), + Some("ios-id"), + ) + .await; // The test should fail with an incorrect issuer assert!(result.is_err()); @@ -367,8 +393,14 @@ mod tests { oidc_server.generate_token_with_incorrect_audience(provider.into(), &public_key); // Verify the token - let result = - verify_token_for_provider(&verifier, provider, token, public_key.clone()).await; + let result = verify_token_for_provider( + &verifier, + provider, + token, + public_key.clone(), + Some("ios-id"), + ) + .await; // The test should fail with an incorrect audience assert!(result.is_err()); @@ -396,8 +428,14 @@ mod tests { oidc_server.generate_token_with_incorrect_issued_at(provider.into(), &public_key); // Verify the token - let result = - verify_token_for_provider(&verifier, provider, token, public_key.clone()).await; + let result = verify_token_for_provider( + &verifier, + provider, + token, + public_key.clone(), + Some("ios-id"), + ) + .await; // The test should fail with an incorrect issued_at assert!(result.is_err()); @@ -431,9 +469,14 @@ mod tests { let token = oidc_server.generate_token(provider.into(), None, &correct_public_key); // Verify the token but pass a different public key - let result = - verify_token_for_provider(&verifier, provider, token, incorrect_public_key.clone()) - .await; + let result = verify_token_for_provider( + &verifier, + provider, + token, + incorrect_public_key.clone(), + Some("ios-id"), + ) + .await; // The test should fail with an incorrect public key assert!(result.is_err()); @@ -460,6 +503,7 @@ mod tests { OidcProvider::Google, token.clone(), public_key.clone(), + None, ) .await .unwrap(); // The first time is successful @@ -470,6 +514,7 @@ mod tests { OidcProvider::Google, token.clone(), public_key.clone(), + None, ) .await; assert!(result.is_err()); @@ -484,9 +529,14 @@ mod tests { let new_token = oidc_server.generate_token(OidcProvider::Google.into(), None, &public_key); assert_ne!(token, new_token); - let result = - verify_token_for_provider(&verifier, OidcProvider::Google, token, public_key.clone()) - .await; + let result = verify_token_for_provider( + &verifier, + OidcProvider::Google, + token, + public_key.clone(), + None, + ) + .await; assert!(result.is_err()); assert!(matches!( result, diff --git a/src/routes/add_factor.rs b/src/routes/add_factor.rs index 6865870..4d2da62 100644 --- a/src/routes/add_factor.rs +++ b/src/routes/add_factor.rs @@ -4,6 +4,7 @@ use crate::auth::{AuthError, AuthHandler}; use crate::backup_storage::BackupStorage; use crate::challenge_manager::{ChallengeContext, ChallengeManager, ChallengeType, NewFactorType}; use crate::factor_lookup::{FactorLookup, FactorScope, FactorToLookup}; +use crate::headers::CLIENT_NAME; use crate::turnkey_activity::{ verify_turnkey_activity_parameters, verify_turnkey_activity_webauthn_stamp, }; @@ -15,6 +16,7 @@ use axum::{Extension, Json}; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::Engine; use chrono::Duration; +use http::HeaderMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use webauthn_rs::prelude::PublicKeyCredential; @@ -64,8 +66,10 @@ pub async fn handler( Extension(challenge_manager): Extension>, Extension(factor_lookup): Extension>, Extension(auth_handler): Extension, + headers: HeaderMap, request: Json, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); // Step 1: Check authorization for the existing factor and get the backup ID let (backup_id, expected_new_factor) = match &request.existing_factor_authorization { Authorization::Passkey { credential, .. } => { @@ -252,6 +256,7 @@ pub async fn handler( ChallengeContext::AddFactorByNewFactor {}, request.turnkey_provider_id.clone(), false, // not a sync factor + client_name, ) .await?; diff --git a/src/routes/add_sync_factor.rs b/src/routes/add_sync_factor.rs index 142739d..326f17d 100644 --- a/src/routes/add_sync_factor.rs +++ b/src/routes/add_sync_factor.rs @@ -4,9 +4,11 @@ use crate::auth::AuthHandler; use crate::backup_storage::BackupStorage; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::{FactorLookup, FactorScope}; +use crate::headers::CLIENT_NAME; use crate::redis_cache::RedisCacheManager; use crate::types::{Authorization, ErrorResponse}; use axum::{Extension, Json}; +use http::HeaderMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -35,8 +37,11 @@ pub async fn handler( Extension(factor_lookup): Extension>, Extension(redis_cache_manager): Extension>, Extension(auth_handler): Extension, + headers: HeaderMap, request: Json, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); + // Step 1: Validate the new sync factor using AuthHandler let validation_result = auth_handler .validate_factor_registration( @@ -45,6 +50,7 @@ pub async fn handler( ChallengeContext::AddSyncFactor {}, None, true, // is_sync_factor + client_name, ) .await?; diff --git a/src/routes/create_backup.rs b/src/routes/create_backup.rs index 6271f8a..0787da8 100644 --- a/src/routes/create_backup.rs +++ b/src/routes/create_backup.rs @@ -4,6 +4,7 @@ use crate::auth::AuthHandler; use crate::backup_storage::BackupStorage; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::{FactorLookup, FactorScope}; +use crate::headers::CLIENT_NAME; use crate::redis_cache::RedisCacheManager; use crate::types::backup_metadata::{BackupMetadata, ExportedBackupMetadata}; use crate::types::encryption_key::BackupEncryptionKey; @@ -12,6 +13,7 @@ use crate::utils::extract_fields_from_multipart; use crate::{normalize_hex_32, validate_backup_account_id}; use axum::extract::Multipart; use axum::{extract::Extension, Json}; +use http::HeaderMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -57,8 +59,10 @@ pub async fn handler( Extension(factor_lookup): Extension>, Extension(auth_handler): Extension, Extension(redis_cache_manager): Extension>, + headers: HeaderMap, mut multipart: Multipart, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); // Step 1: Parse multipart form data. It should include the main JSON payload with parameters // and the attached backup file. let multipart_fields = extract_fields_from_multipart(&mut multipart).await?; @@ -106,6 +110,7 @@ pub async fn handler( ChallengeContext::Create {}, request.turnkey_provider_id.clone(), false, // not a sync factor + client_name, ) .await?; @@ -121,6 +126,7 @@ pub async fn handler( ChallengeContext::Create {}, None, true, // is a sync factor + client_name, ) .await?; diff --git a/src/routes/delete_backup.rs b/src/routes/delete_backup.rs index af5af3c..bcca656 100644 --- a/src/routes/delete_backup.rs +++ b/src/routes/delete_backup.rs @@ -4,9 +4,11 @@ use crate::auth::AuthHandler; use crate::backup_storage::BackupStorage; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::{FactorLookup, FactorScope}; +use crate::headers::CLIENT_NAME; use crate::types::{Authorization, ErrorResponse}; use axum::http::StatusCode; use axum::{Extension, Json}; +use http::HeaderMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::Instrument; @@ -23,8 +25,11 @@ pub async fn handler( Extension(backup_storage): Extension>, Extension(factor_lookup): Extension>, Extension(auth_handler): Extension, + headers: HeaderMap, request: Json, ) -> Result { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); + // Step 1: Auth. Verify the solved challenge let (backup_id, _backup_metadata) = auth_handler .verify( @@ -32,6 +37,7 @@ pub async fn handler( FactorScope::Sync, ChallengeContext::DeleteBackup {}, request.challenge_token.clone(), + client_name, ) .await?; diff --git a/src/routes/delete_factor.rs b/src/routes/delete_factor.rs index 218936d..1dadfb5 100644 --- a/src/routes/delete_factor.rs +++ b/src/routes/delete_factor.rs @@ -4,11 +4,13 @@ use crate::auth::AuthHandler; use crate::backup_storage::{BackupStorage, DeletionResult}; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::{FactorLookup, FactorScope}; +use crate::headers::CLIENT_NAME; use crate::types::backup_metadata::ExportedBackupMetadata; use crate::types::encryption_key::BackupEncryptionKey; use crate::types::{Authorization, Environment, ErrorResponse}; use aide::transform::TransformOperation; use axum::{Extension, Json}; +use http::HeaderMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::Instrument; @@ -53,8 +55,10 @@ pub async fn handler( Extension(backup_storage): Extension>, Extension(factor_lookup): Extension>, Extension(auth_handler): Extension, + headers: HeaderMap, request: Json, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); // Step 1: Extract the factor IDs from the request let factor_id = request.factor_id.clone(); let encryption_key = request.encryption_key.clone(); @@ -79,6 +83,7 @@ pub async fn handler( factor_id: request.factor_id.clone(), }, request.challenge_token.clone(), + client_name, ) .await?; diff --git a/src/routes/retrieve_from_challenge.rs b/src/routes/retrieve_from_challenge.rs index 213eea6..5b94995 100644 --- a/src/routes/retrieve_from_challenge.rs +++ b/src/routes/retrieve_from_challenge.rs @@ -4,6 +4,7 @@ use crate::auth::AuthHandler; use crate::backup_storage::BackupStorage; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::FactorScope; +use crate::headers::{CLIENT_NAME, CLIENT_VERSION}; use crate::redis_cache::RedisCacheManager; use crate::types::backup_metadata::ExportedBackupMetadata; use crate::types::{Authorization, ErrorResponse}; @@ -49,6 +50,8 @@ pub async fn handler( headers: HeaderMap, request: Json, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); + // Step 1: Auth. Verify the solved challenge let (backup_id, backup_metadata) = auth_handler .verify( @@ -56,20 +59,16 @@ pub async fn handler( FactorScope::Main, ChallengeContext::Retrieve {}, request.challenge_token.clone(), + client_name, ) .await?; let client_version = headers - .get("client-version") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default(); - - let client_name = headers - .get("client-name") + .get(&CLIENT_VERSION) .and_then(|v| v.to_str().ok()) .unwrap_or_default(); - let span = tracing::info_span!("retrieve_backup_from_challenge", backup_id = %backup_id, client_version = %client_version, client_name = %client_name); + let span = tracing::info_span!("retrieve_backup_from_challenge", backup_id = %backup_id, client_version = %client_version, client_name = %client_name.unwrap_or_default()); async move { // Step 2: Fetch the backup from S3 diff --git a/src/routes/retrieve_metadata.rs b/src/routes/retrieve_metadata.rs index 566306b..4b364b7 100644 --- a/src/routes/retrieve_metadata.rs +++ b/src/routes/retrieve_metadata.rs @@ -4,9 +4,11 @@ use crate::auth::{AuthError, AuthHandler}; use crate::backup_storage::BackupStorage; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::FactorScope; +use crate::headers::CLIENT_NAME; use crate::types::backup_metadata::ExportedBackupMetadata; use crate::types::{Authorization, ErrorResponse}; use axum::{extract::Extension, Json}; +use http::HeaderMap; use schemars::JsonSchema; use serde::Deserialize; @@ -28,8 +30,11 @@ pub struct RetrieveMetadataRequest { pub async fn handler( Extension(auth_handler): Extension, Extension(backup_storage): Extension>, + headers: HeaderMap, Json(request): Json, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); + // Step 1: Auth. Verify the solved challenge let backup_metadata = auth_handler .verify( @@ -37,6 +42,7 @@ pub async fn handler( FactorScope::Sync, ChallengeContext::RetrieveMetadata {}, request.challenge_token, + client_name, ) .await; diff --git a/src/routes/sync_backup.rs b/src/routes/sync_backup.rs index 1f4d416..417aac3 100644 --- a/src/routes/sync_backup.rs +++ b/src/routes/sync_backup.rs @@ -4,12 +4,14 @@ use crate::auth::AuthHandler; use crate::backup_storage::BackupStorage; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::FactorScope; +use crate::headers::CLIENT_NAME; use crate::normalize_hex_32; use crate::redis_cache::RedisCacheManager; use crate::types::{Authorization, Environment, ErrorResponse}; use crate::utils::extract_fields_from_multipart; use axum::extract::Multipart; use axum::{extract::Extension, Json}; +use http::HeaderMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::Instrument; @@ -53,8 +55,10 @@ pub async fn handler( Extension(backup_storage): Extension>, Extension(auth_handler): Extension, Extension(redis_cache_manager): Extension>, + headers: HeaderMap, mut multipart: Multipart, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); // Step 1: Parse multipart form data. It should include the main JSON payload with parameters // and the attached backup file. let multipart_fields = extract_fields_from_multipart(&mut multipart).await?; @@ -99,6 +103,7 @@ pub async fn handler( FactorScope::Sync, ChallengeContext::Sync {}, request.challenge_token, + client_name, ) .await?; diff --git a/src/routes/verify_factor.rs b/src/routes/verify_factor.rs index 31e2541..e6274d8 100644 --- a/src/routes/verify_factor.rs +++ b/src/routes/verify_factor.rs @@ -1,6 +1,7 @@ use crate::auth::AuthHandler; use crate::challenge_manager::ChallengeContext; use crate::factor_lookup::FactorScope; +use crate::headers::{CLIENT_NAME, CLIENT_VERSION}; use crate::types::{Authorization, ErrorResponse}; use aide::transform::TransformOperation; use axum::{Extension, Json}; @@ -35,26 +36,24 @@ pub async fn handler( headers: HeaderMap, request: Json, ) -> Result, ErrorResponse> { + let client_name = headers.get(&CLIENT_NAME).and_then(|v| v.to_str().ok()); + let (backup_id, _backup_metadata) = auth_handler .verify( &request.authorization, FactorScope::Main, ChallengeContext::VerifyFactor {}, request.challenge_token.clone(), + client_name, ) .await?; let client_version = headers - .get("client-version") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default(); - - let client_name = headers - .get("client-name") + .get(&CLIENT_VERSION) .and_then(|v| v.to_str().ok()) .unwrap_or_default(); - let span = tracing::info_span!("verify_factor", backup_id = %backup_id, client_version = %client_version, client_name = %client_name); + let span = tracing::info_span!("verify_factor", backup_id = %backup_id, client_version = %client_version, client_name = %client_name.unwrap_or_default()); async move { Ok(Json(VerifyFactorResponse { backup_id })) } .instrument(span) diff --git a/src/types/environment.rs b/src/types/environment.rs index e71fa65..672ccbc 100644 --- a/src/types/environment.rs +++ b/src/types/environment.rs @@ -216,19 +216,31 @@ impl Environment { } } - /// The client ID for the Google OIDC provider + /// Returns the Google OIDC client ID to use for token verification. + /// + /// Uses the `client-name` header to select the correct `GIDClientID`: + /// - `"ios-id"` → World ID app client ID (varies by environment) + /// - anything else → World Money app client ID + /// + /// Note: Google ID tokens carry `aud = GIDClientID` (the iOS OAuth client ID), + /// not `GIDServerClientID`. #[must_use] - pub fn google_client_id(&self) -> ClientId { - match self { - Self::Production | Self::Staging => ClientId::new( - "730924878354-jvi49m445q2mv6s1dn4oklm8i4vlpct9.apps.googleusercontent.com" - .to_string(), - ), - Self::Development { .. } => ClientId::new( + pub fn google_client_id(&self, client_name: Option<&str>) -> ClientId { + let client_id = match (self, client_name) { + (Self::Production, Some("ios-id")) => { + "730924878354-e3lj7tmie6g3650au7a65o473pa471nu.apps.googleusercontent.com" + } + (Self::Staging, Some("ios-id")) => { + "730924878354-t4l58lbu00r1voco48ivsijlo984obmn.apps.googleusercontent.com" + } + (Self::Production | Self::Staging, _) => { + "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + } + (Self::Development { .. }, _) => { "949370763172-0pu3c8c3rmp8ad665jsb1qkf8lai592i.apps.googleusercontent.com" - .to_string(), - ), - } + } + }; + ClientId::new(client_id.to_string()) } /// Issuer URL for the Google OIDC provider @@ -262,17 +274,21 @@ impl Environment { } } - /// The client ID for the Apple OIDC provider + /// Returns the Apple OIDC client ID to use for token verification. /// - /// # Panics - /// Will not panic. Values are hardcoded per environment. + /// Uses the `client-name` header to select the correct bundle ID: + /// - `"ios-id"` → World ID app (`org.world.id` / `org.world.staging.id`) + /// - anything else → World Money app (`org.worldcoin.insight` / `org.worldcoin.insight.staging`) #[must_use] - pub fn apple_client_id(&self) -> ClientId { - match self { - Self::Production => ClientId::new("org.worldcoin.insight".to_string()), - Self::Staging => ClientId::new("org.worldcoin.insight.staging".to_string()), - Self::Development { .. } => ClientId::new("placeholder".to_string()), - } + pub fn apple_client_id(&self, client_name: Option<&str>) -> ClientId { + let bundle_id = match (self, client_name) { + (Self::Production, Some("ios-id")) => "org.world.id", + (Self::Production, _) => "org.worldcoin.insight", + (Self::Staging, Some("ios-id")) => "org.world.staging.id", + (Self::Staging, _) => "org.worldcoin.insight.staging", + (Self::Development { .. }, _) => "placeholder", + }; + ClientId::new(bundle_id.to_string()) } /// Issuer URL for the Apple OIDC provider @@ -313,3 +329,106 @@ impl Environment { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apple_client_id_production() { + let env = Environment::Production; + assert_eq!(env.apple_client_id(None).as_str(), "org.worldcoin.insight"); + assert_eq!(env.apple_client_id(Some("ios-id")).as_str(), "org.world.id"); + assert_eq!( + env.apple_client_id(Some("ios-money")).as_str(), + "org.worldcoin.insight" + ); + assert_eq!( + env.apple_client_id(Some("unknown")).as_str(), + "org.worldcoin.insight" + ); + } + + #[test] + fn test_apple_client_id_staging() { + let env = Environment::Staging; + assert_eq!( + env.apple_client_id(None).as_str(), + "org.worldcoin.insight.staging" + ); + assert_eq!( + env.apple_client_id(Some("ios-id")).as_str(), + "org.world.staging.id" + ); + assert_eq!( + env.apple_client_id(Some("ios-money")).as_str(), + "org.worldcoin.insight.staging" + ); + assert_eq!( + env.apple_client_id(Some("unknown")).as_str(), + "org.worldcoin.insight.staging" + ); + } + + #[test] + fn test_apple_client_id_development() { + let env = Environment::development(None); + assert_eq!(env.apple_client_id(None).as_str(), "placeholder"); + assert_eq!(env.apple_client_id(Some("ios-id")).as_str(), "placeholder"); + assert_eq!( + env.apple_client_id(Some("ios-money")).as_str(), + "placeholder" + ); + } + + #[test] + fn test_google_client_id_production() { + let env = Environment::Production; + assert_eq!( + env.google_client_id(None).as_str(), + "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + ); + assert_eq!( + env.google_client_id(Some("ios-id")).as_str(), + "730924878354-e3lj7tmie6g3650au7a65o473pa471nu.apps.googleusercontent.com" + ); + assert_eq!( + env.google_client_id(Some("ios-money")).as_str(), + "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + ); + assert_eq!( + env.google_client_id(Some("unknown")).as_str(), + "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + ); + } + + #[test] + fn test_google_client_id_staging() { + let env = Environment::Staging; + assert_eq!( + env.google_client_id(None).as_str(), + "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + ); + assert_eq!( + env.google_client_id(Some("ios-id")).as_str(), + "730924878354-t4l58lbu00r1voco48ivsijlo984obmn.apps.googleusercontent.com" + ); + assert_eq!( + env.google_client_id(Some("ios-money")).as_str(), + "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + ); + } + + #[test] + fn test_google_client_id_development() { + let env = Environment::development(None); + assert_eq!( + env.google_client_id(None).as_str(), + "949370763172-0pu3c8c3rmp8ad665jsb1qkf8lai592i.apps.googleusercontent.com" + ); + assert_eq!( + env.google_client_id(Some("ios-id")).as_str(), + "949370763172-0pu3c8c3rmp8ad665jsb1qkf8lai592i.apps.googleusercontent.com" + ); + } +} From c5af7ca174a99fdf0bcdf35eafe4a04331a89255 Mon Sep 17 00:00:00 2001 From: aurel-fr <105201452+aurel-fr@users.noreply.github.com> Date: Fri, 22 May 2026 15:04:57 -0700 Subject: [PATCH 2/5] docs --- src/routes/add_factor.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/add_factor.rs b/src/routes/add_factor.rs index 4d2da62..9e4edce 100644 --- a/src/routes/add_factor.rs +++ b/src/routes/add_factor.rs @@ -208,7 +208,9 @@ pub async fn handler( (backup_id, new_factor_type) } Authorization::OidcAccount { .. } | Authorization::EcKeypair { .. } => { - // TODO/FIXME: Implement the logic for verifying the existing factor for OIDC and EC keypair + // TODO/FIXME: Implement the logic for verifying the existing factor for OIDC and EC keypair. + // When implementing OIDC here, pass `client_name` to `validate_oidc_authentication` so the + // correct provider client_id (per `Environment::{google,apple}_client_id`) is used. return Err(ErrorResponse::bad_request("not_supported", "Not supported")); } }; From b0bf35f16fe3b04bacd87477750e7961465e5a52 Mon Sep 17 00:00:00 2001 From: aurel-fr <105201452+aurel-fr@users.noreply.github.com> Date: Fri, 22 May 2026 15:17:19 -0700 Subject: [PATCH 3/5] chore: tracing --- src/oidc_token_verifier.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/oidc_token_verifier.rs b/src/oidc_token_verifier.rs index 0913321..9330541 100644 --- a/src/oidc_token_verifier.rs +++ b/src/oidc_token_verifier.rs @@ -124,25 +124,25 @@ impl OidcTokenVerifier { // Load the public keys from the OIDC provider let signature_keys = self.get_jwk_set(&jwk_set_url).await?.as_ref().clone(); - // Step 3: Verify the token and extract claims + // Step 3: Create the token verifier. + let token_verifier = + CoreIdTokenVerifier::new_public_client(client_id, issuer_url.clone(), signature_keys) + .set_issue_time_verifier_fn(issue_time_verifier); + + // Step 4: Parse the OIDC token let oidc_token = CoreIdToken::from_str(oidc_token).map_err(|err| { tracing::warn!(message = "Failed to parse OIDC token", err = ?err); OidcTokenVerifierError::TokenParseError })?; - // Step 4: Verify the token against the client ID selected by client_name + // Step 5: Verify the nonce and extract the claims let claims = oidc_token .claims( - &CoreIdTokenVerifier::new_public_client( - client_id, - issuer_url.clone(), - signature_keys.clone(), - ) - .set_issue_time_verifier_fn(issue_time_verifier), - OidcNonceVerifier::new(expected_public_key_sec1_base64.clone()), + &token_verifier, + OidcNonceVerifier::new(expected_public_key_sec1_base64), ) .map_err(|err| { - tracing::error!(message = "Token verification error", err = ?err, issuer = ?issuer_url); + tracing::error!(message = "Token verification error", err = ?err, issuer = ?issuer_url, client_name = ?client_name); match err { ClaimsVerificationError::InvalidNonce(e) => OidcTokenVerifierError::InvalidNonce(e.clone()), From ec0c2347df086b9bc52f5fd41423198ad716ec1e Mon Sep 17 00:00:00 2001 From: James Michael Date: Fri, 22 May 2026 15:34:19 -0700 Subject: [PATCH 4/5] remove google client id mapping, add android-id Google backup is okay. Just need android-id in here so apple backup can be implemented on android devices down the road --- src/oidc_token_verifier.rs | 2 +- src/types/environment.rs | 76 ++++++++++++-------------------------- 2 files changed, 24 insertions(+), 54 deletions(-) diff --git a/src/oidc_token_verifier.rs b/src/oidc_token_verifier.rs index 9330541..5f8631a 100644 --- a/src/oidc_token_verifier.rs +++ b/src/oidc_token_verifier.rs @@ -110,7 +110,7 @@ impl OidcTokenVerifier { OidcToken::Google { token } => ( token, self.environment.google_jwk_set_url(), - self.environment.google_client_id(client_name), + self.environment.google_client_id(), self.environment.google_issuer_url(), ), OidcToken::Apple { token } => ( diff --git a/src/types/environment.rs b/src/types/environment.rs index 672ccbc..d215fde 100644 --- a/src/types/environment.rs +++ b/src/types/environment.rs @@ -216,31 +216,18 @@ impl Environment { } } - /// Returns the Google OIDC client ID to use for token verification. - /// - /// Uses the `client-name` header to select the correct `GIDClientID`: - /// - `"ios-id"` → World ID app client ID (varies by environment) - /// - anything else → World Money app client ID - /// - /// Note: Google ID tokens carry `aud = GIDClientID` (the iOS OAuth client ID), - /// not `GIDServerClientID`. #[must_use] - pub fn google_client_id(&self, client_name: Option<&str>) -> ClientId { - let client_id = match (self, client_name) { - (Self::Production, Some("ios-id")) => { - "730924878354-e3lj7tmie6g3650au7a65o473pa471nu.apps.googleusercontent.com" - } - (Self::Staging, Some("ios-id")) => { - "730924878354-t4l58lbu00r1voco48ivsijlo984obmn.apps.googleusercontent.com" - } - (Self::Production | Self::Staging, _) => { - "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" - } - (Self::Development { .. }, _) => { + pub fn google_client_id(&self) -> ClientId { + match self { + Self::Production | Self::Staging => ClientId::new( + "730924878354-jvi49m445q2mv6s1dn4oklm8i4vlpct9.apps.googleusercontent.com" + .to_string(), + ), + Self::Development { .. } => ClientId::new( "949370763172-0pu3c8c3rmp8ad665jsb1qkf8lai592i.apps.googleusercontent.com" - } - }; - ClientId::new(client_id.to_string()) + .to_string(), + ), + } } /// Issuer URL for the Google OIDC provider @@ -282,9 +269,10 @@ impl Environment { #[must_use] pub fn apple_client_id(&self, client_name: Option<&str>) -> ClientId { let bundle_id = match (self, client_name) { - (Self::Production, Some("ios-id")) => "org.world.id", + (Self::Production, Some("ios-id" | "android-id")) => "org.world.id", (Self::Production, _) => "org.worldcoin.insight", (Self::Staging, Some("ios-id")) => "org.world.staging.id", + (Self::Staging, Some("android-id")) => "org.world.id.staging", (Self::Staging, _) => "org.worldcoin.insight.staging", (Self::Development { .. }, _) => "placeholder", }; @@ -339,6 +327,7 @@ mod tests { let env = Environment::Production; assert_eq!(env.apple_client_id(None).as_str(), "org.worldcoin.insight"); assert_eq!(env.apple_client_id(Some("ios-id")).as_str(), "org.world.id"); + assert_eq!(env.apple_client_id(Some("android-id")).as_str(), "org.world.id"); assert_eq!( env.apple_client_id(Some("ios-money")).as_str(), "org.worldcoin.insight" @@ -360,6 +349,10 @@ mod tests { env.apple_client_id(Some("ios-id")).as_str(), "org.world.staging.id" ); + assert_eq!( + env.apple_client_id(Some("android-id")).as_str(), + "org.world.id.staging" + ); assert_eq!( env.apple_client_id(Some("ios-money")).as_str(), "org.worldcoin.insight.staging" @@ -375,6 +368,7 @@ mod tests { let env = Environment::development(None); assert_eq!(env.apple_client_id(None).as_str(), "placeholder"); assert_eq!(env.apple_client_id(Some("ios-id")).as_str(), "placeholder"); + assert_eq!(env.apple_client_id(Some("android-id")).as_str(), "placeholder"); assert_eq!( env.apple_client_id(Some("ios-money")).as_str(), "placeholder" @@ -385,20 +379,8 @@ mod tests { fn test_google_client_id_production() { let env = Environment::Production; assert_eq!( - env.google_client_id(None).as_str(), - "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" - ); - assert_eq!( - env.google_client_id(Some("ios-id")).as_str(), - "730924878354-e3lj7tmie6g3650au7a65o473pa471nu.apps.googleusercontent.com" - ); - assert_eq!( - env.google_client_id(Some("ios-money")).as_str(), - "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" - ); - assert_eq!( - env.google_client_id(Some("unknown")).as_str(), - "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + env.google_client_id().as_str(), + "730924878354-jvi49m445q2mv6s1dn4oklm8i4vlpct9.apps.googleusercontent.com" ); } @@ -406,16 +388,8 @@ mod tests { fn test_google_client_id_staging() { let env = Environment::Staging; assert_eq!( - env.google_client_id(None).as_str(), - "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" - ); - assert_eq!( - env.google_client_id(Some("ios-id")).as_str(), - "730924878354-t4l58lbu00r1voco48ivsijlo984obmn.apps.googleusercontent.com" - ); - assert_eq!( - env.google_client_id(Some("ios-money")).as_str(), - "730924878354-m0sg73ei8l1iohgb1nfj65traocbml16.apps.googleusercontent.com" + env.google_client_id().as_str(), + "730924878354-jvi49m445q2mv6s1dn4oklm8i4vlpct9.apps.googleusercontent.com" ); } @@ -423,11 +397,7 @@ mod tests { fn test_google_client_id_development() { let env = Environment::development(None); assert_eq!( - env.google_client_id(None).as_str(), - "949370763172-0pu3c8c3rmp8ad665jsb1qkf8lai592i.apps.googleusercontent.com" - ); - assert_eq!( - env.google_client_id(Some("ios-id")).as_str(), + env.google_client_id().as_str(), "949370763172-0pu3c8c3rmp8ad665jsb1qkf8lai592i.apps.googleusercontent.com" ); } From 25a34187a506b91319a423a57f408184aea6e458 Mon Sep 17 00:00:00 2001 From: aurel-fr <105201452+aurel-fr@users.noreply.github.com> Date: Fri, 22 May 2026 15:47:56 -0700 Subject: [PATCH 5/5] fmt --- src/types/environment.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/types/environment.rs b/src/types/environment.rs index d215fde..7725a08 100644 --- a/src/types/environment.rs +++ b/src/types/environment.rs @@ -327,7 +327,10 @@ mod tests { let env = Environment::Production; assert_eq!(env.apple_client_id(None).as_str(), "org.worldcoin.insight"); assert_eq!(env.apple_client_id(Some("ios-id")).as_str(), "org.world.id"); - assert_eq!(env.apple_client_id(Some("android-id")).as_str(), "org.world.id"); + assert_eq!( + env.apple_client_id(Some("android-id")).as_str(), + "org.world.id" + ); assert_eq!( env.apple_client_id(Some("ios-money")).as_str(), "org.worldcoin.insight" @@ -368,7 +371,10 @@ mod tests { let env = Environment::development(None); assert_eq!(env.apple_client_id(None).as_str(), "placeholder"); assert_eq!(env.apple_client_id(Some("ios-id")).as_str(), "placeholder"); - assert_eq!(env.apple_client_id(Some("android-id")).as_str(), "placeholder"); + assert_eq!( + env.apple_client_id(Some("android-id")).as_str(), + "placeholder" + ); assert_eq!( env.apple_client_id(Some("ios-money")).as_str(), "placeholder"