Skip to content

Commit 230b719

Browse files
committed
Trigger push notification to trusted devices during 2FA
Apple now requires a POST to /auth/bridge/step/0 to initiate push notifications for 2FA codes. Without this, some accounts only receive a "website login" email instead of a 2FA code on their trusted devices. Ref: icloud-photos-downloader/icloud_photos_downloader#1327
1 parent 93b435f commit 230b719

File tree

3 files changed

+75
-2
lines changed

3 files changed

+75
-2
lines changed

src/auth/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ pub async fn authenticate(
115115
if requires_2fa {
116116
tracing::info!("Two-factor authentication is required");
117117

118+
// Trigger push notification to trusted devices. Some Apple accounts
119+
// require this bridge step to receive 2FA codes; without it they only
120+
// get a "website login" email. Non-fatal: we log and continue if it
121+
// fails, since the user can still enter a code from a trusted device.
122+
if let Err(e) =
123+
twofa::trigger_push_notification(&mut session, &endpoints, &client_id, domain).await
124+
{
125+
tracing::warn!(error = %e, "Failed to trigger push notification");
126+
}
127+
118128
let verified = if let Some(c) = code {
119129
// Headless: code provided directly (e.g. submit-code subcommand)
120130
twofa::submit_2fa_code(&mut session, &endpoints, &client_id, domain, c).await?

src/auth/srp.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ use super::session::Session;
1313
use crate::auth::error::AuthError;
1414

1515
/// Apple's public OAuth widget key — embedded in icloud.com's JavaScript.
16-
const APPLE_WIDGET_KEY: &str = "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d";
16+
pub(crate) const APPLE_WIDGET_KEY: &str =
17+
"d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d";
1718

1819
/// RFC 5054 2048-bit SRP group prime (same as srp::groups::G_2048).
1920
const N_HEX: &str = concat!(

src/auth/twofa.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,78 @@
1+
use std::fmt::Write as _;
12
use std::io::{self, Write};
3+
use std::time::{SystemTime, UNIX_EPOCH};
24

35
use anyhow::{Context, Result};
6+
use rand::Rng;
47
use reqwest::header::HeaderMap;
8+
use uuid::Uuid;
59

610
use super::endpoints::Endpoints;
711
use super::session::Session;
8-
use super::srp::get_auth_headers;
12+
use super::srp::{get_auth_headers, APPLE_WIDGET_KEY};
913
use crate::auth::error::AuthError;
1014
use crate::auth::responses::AccountLoginResponse;
1115

1216
const TWO_FA_CODE_LENGTH: usize = 6;
1317

18+
/// Trigger a push notification to trusted devices for 2FA code entry.
19+
///
20+
/// Apple requires a POST to `/auth/bridge/step/0` to initiate the push
21+
/// notification flow. Without this, some accounts receive a "website login"
22+
/// email instead of a 2FA code on their trusted devices.
23+
///
24+
/// See: icloud-photos-downloader/icloud_photos_downloader#1327
25+
pub async fn trigger_push_notification(
26+
session: &mut Session,
27+
endpoints: &Endpoints,
28+
client_id: &str,
29+
domain: &str,
30+
) -> Result<()> {
31+
let timestamp_ms = SystemTime::now()
32+
.duration_since(UNIX_EPOCH)
33+
.context("System clock before UNIX epoch")?
34+
.as_millis();
35+
let session_uuid = format!("{}-{}", Uuid::new_v4(), timestamp_ms);
36+
37+
let mut ptkn_bytes = [0u8; 64];
38+
rand::rng().fill_bytes(&mut ptkn_bytes);
39+
let mut ptkn = String::with_capacity(128);
40+
for b in &ptkn_bytes {
41+
write!(ptkn, "{b:02x}").expect("writing to String cannot fail");
42+
}
43+
44+
let data = serde_json::json!({
45+
"sessionUUID": session_uuid,
46+
"ptkn": ptkn,
47+
});
48+
49+
let overrides: [(&str, &str); 4] = [
50+
("Accept", "application/json, text/plain, */*"),
51+
("Content-type", "application/json; charset=utf-8"),
52+
("X-Apple-App-Id", APPLE_WIDGET_KEY),
53+
("X-Apple-Domain-Id", "3"),
54+
];
55+
let headers = get_auth_headers(domain, client_id, &session.session_data, Some(&overrides))?;
56+
57+
let url = format!("{}/bridge/step/0", endpoints.auth);
58+
tracing::debug!(url = %url, "Triggering push notification to trusted devices");
59+
60+
let response = session
61+
.post(&url, Some(data.to_string()), Some(headers))
62+
.await?;
63+
64+
let status = response.status();
65+
if !status.is_success() {
66+
let text = response.text().await.unwrap_or_default();
67+
tracing::warn!(
68+
status = %status,
69+
body = %text,
70+
"Bridge step failed, continuing with standard 2FA flow"
71+
);
72+
}
73+
Ok(())
74+
}
75+
1476
/// Check whether a string is a valid 6-digit 2FA code.
1577
fn is_valid_2fa_code(code: &str) -> bool {
1678
code.len() == TWO_FA_CODE_LENGTH && code.chars().all(|c| c.is_ascii_digit())

0 commit comments

Comments
 (0)