Switch to a better, more minimal handling, of HTTP headers
Dadoum

Dadoum commited on 2026-09-28 01:27:28
Showing 10 changed files, with 707 additions and 589 deletions.


Only include the necessary ones for most requests.
... ...
@@ -1,6 +1,7 @@
1
+mod account;
1 2
 mod anisette;
2
-mod authenticated_session;
3
-mod post_data;
3
+mod app;
4
+mod heartbeat;
4 5
 mod secondary_actions;
5 6
 mod url_switch;
6 7
 
... ...
@@ -15,8 +16,9 @@ use crate::plist_request::dict_to_body;
15 16
 use adi::proxy::{ADIError, ADIResult};
16 17
 use aes::cipher::block_padding;
17 18
 use aes::cipher::block_padding::Pkcs7;
19
+pub use account::*;
18 20
 pub use anisette::*;
19
-pub use authenticated_session::*;
21
+pub use app::*;
20 22
 use base64::Engine;
21 23
 use base64::prelude::BASE64_STANDARD;
22 24
 use bytes::Bytes;
... ...
@@ -26,7 +28,7 @@ use hmac::{Hmac, KeyInit, Mac};
26 28
 use log::trace;
27 29
 use plist::{Dictionary, Value};
28 30
 use plist_macros::{array, dict};
29
-pub use post_data::*;
31
+pub use heartbeat::*;
30 32
 use reqwest::{Certificate, Method};
31 33
 use sha2::{Digest, Sha256};
32 34
 use srp::groups::G2048;
... ...
@@ -125,8 +127,8 @@ pub async fn http_session_with_custom_bag_url<'lt>(
125 127
 
126 128
 #[derive(Debug)]
127 129
 pub enum AuthOutcome {
128
-    Success(Dictionary),
129
-    SecondaryActionRequired(Option<Dictionary>, String),
130
+    Success(ServerProvidedData),
131
+    SecondaryActionRequired(Option<ServerProvidedData>, String),
130 132
     AnisetteResyncRequired(Vec<u8>),
131 133
     AnisetteReprovisionRequired,
132 134
     UrlSwitchingRequired(String),
... ...
@@ -499,7 +501,7 @@ async fn login_with_credential(
499 501
 
500 502
             trace!("Parsed server provided data: {:#?}", server_provided_data);
501 503
 
502
-            Ok(server_provided_data)
504
+            Ok(ServerProvidedData(server_provided_data))
503 505
         })
504 506
         .transpose()?;
505 507
 
... ...
@@ -546,51 +548,3 @@ async fn login_with_credential(
546 548
         }
547 549
     }
548 550
 }
549
-
550
-#[derive(Error, Debug)]
551
-pub enum ValidateCodeError {
552
-    #[error("The provided code is not valid.")]
553
-    InvalidCode,
554
-    #[error("Could not log-in to Apple servers: {0}")]
555
-    Apple(#[from] AppleError),
556
-    #[error("Cannot generate device authentication data: {0}")]
557
-    Anisette(#[from] ADIError),
558
-    #[error("Network error: {0}")]
559
-    Network(#[from] reqwest::Error),
560
-    // vvv Internal errors vvv
561
-    #[error("Invalid URL bag")]
562
-    InvalidURLBag,
563
-    #[error("Cannot parse server response: {0}")]
564
-    Parsing(plist::Error),
565
-}
566
-
567
-pub async fn validate_code(
568
-    http_session: &AnisetteHTTPSession<'_, '_>,
569
-    second_factor_code: u32
570
-) -> Result<(), ValidateCodeError> {
571
-    let validate_code_url = http_session
572
-        .url_bag()
573
-        .get("validateCode")
574
-        .and_then(Value::as_string)
575
-        .ok_or(ValidateCodeError::InvalidURLBag)?;
576
-
577
-    let response = http_session
578
-        .anisette_request_builder(Method::POST, validate_code_url)?
579
-        .header("security-code", second_factor_code.to_string())
580
-        .send()
581
-        .await?
582
-        .error_for_status()?
583
-        .bytes()
584
-        .await?;
585
-
586
-    let response_plist: Dictionary =
587
-        plist::from_bytes(&response).map_err(ValidateCodeError::Parsing)?;
588
-
589
-    parse_status(&response_plist)
590
-        .map_err(|err|
591
-            match err {
592
-                AppleError { code: -21669, .. } => ValidateCodeError::InvalidCode,
593
-                err => ValidateCodeError::Apple(err)
594
-            }
595
-        )
596
-}
... ...
@@ -0,0 +1,305 @@
1
+use super::app::{AppToken, Token, TokenBag};
2
+use crate::grandslam::{ContinuationToken, build_client_provided_data};
3
+use crate::http_session::{AnisetteHTTPSession, AppleError, parse_status};
4
+use adi::proxy::{ADIError, ADIResult};
5
+use aes::Aes256;
6
+use aes::cipher::consts::U16;
7
+use aes_gcm::aead::{Aead, Payload};
8
+use aes_gcm::{AesGcm, KeyInit};
9
+use base64::Engine;
10
+use base64::prelude::BASE64_STANDARD;
11
+use hmac::{Hmac, Mac};
12
+use log::{trace, warn};
13
+use plist::{Dictionary, Value};
14
+use plist_macros::{array, dict};
15
+use reqwest::{Method, RequestBuilder};
16
+use serde::{Deserialize, Serialize};
17
+use sha2::Sha256;
18
+use thiserror::Error;
19
+
20
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21
+#[serde(transparent)]
22
+pub struct AltDsid(pub(super) String);
23
+
24
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25
+#[serde(transparent)]
26
+pub struct IdmsToken(String);
27
+
28
+#[derive(Debug, Clone, Serialize, Deserialize)]
29
+#[serde(transparent)]
30
+pub struct SessionKey(Vec<u8>);
31
+
32
+#[derive(Debug, Clone, Serialize, Deserialize)]
33
+#[serde(transparent)]
34
+pub struct AuthCookie(Vec<u8>);
35
+
36
+#[derive(Debug, Clone)]
37
+pub struct ServerProvidedData(pub(super) Dictionary);
38
+
39
+impl ServerProvidedData {
40
+    pub fn alt_dsid(&self) -> Option<AltDsid> {
41
+        Some(AltDsid(self.0.get("adsid")?.as_string()?.to_owned()))
42
+    }
43
+
44
+    pub fn idms_token(&self) -> Option<IdmsToken> {
45
+        Some(IdmsToken(
46
+            self.0.get("GsIdmsToken")?.as_string()?.to_owned(),
47
+        ))
48
+    }
49
+
50
+    pub fn session_key(&self) -> Option<SessionKey> {
51
+        Some(SessionKey(self.0.get("sk")?.as_data()?.to_vec()))
52
+    }
53
+
54
+    pub fn cookie(&self) -> Option<AuthCookie> {
55
+        Some(AuthCookie(self.0.get("c")?.as_data()?.to_vec()))
56
+    }
57
+
58
+    pub fn continuation_token(&self) -> Option<ContinuationToken> {
59
+        Some(ContinuationToken(self.0.get("ck")?.as_string()?.to_owned()))
60
+    }
61
+
62
+    pub fn tokens(&self) -> Option<TokenBag> {
63
+        Some(TokenBag(self.0.get("t")?.as_dictionary()?.clone()))
64
+    }
65
+}
66
+
67
+#[derive(Debug, Error)]
68
+pub enum AppTokenRequestError {
69
+    #[error("Cannot proceed: {0}")]
70
+    Apple(#[from] AppleError),
71
+    #[error("Cannot generate device authentication data: {0}")]
72
+    Anisette(#[from] ADIError),
73
+    #[error("The provided authentication token is not valid")]
74
+    InvalidAuthToken,
75
+    // vvvv Internal errors vvvv
76
+    #[error("Invalid URL bag")]
77
+    InvalidURLBag,
78
+    #[error("Network error: {0}")]
79
+    Network(#[from] reqwest::Error),
80
+    #[error("Failed to parse the app token request response: {0}")]
81
+    Parsing(#[from] plist::Error),
82
+    #[error("Failed to parse the app token request response")]
83
+    Structure(Dictionary),
84
+    #[error("Invalid token returned by the server")]
85
+    InvalidResponse(Dictionary),
86
+}
87
+
88
+#[derive(Debug, Error)]
89
+pub enum ValidateCodeError {
90
+    #[error("The provided code is not valid.")]
91
+    InvalidCode,
92
+    #[error("Could not validate the code: {0}")]
93
+    Apple(#[from] AppleError),
94
+    #[error("Cannot generate device authentication data: {0}")]
95
+    Anisette(#[from] ADIError),
96
+    #[error("Network error: {0}")]
97
+    Network(#[from] reqwest::Error),
98
+    #[error("Invalid URL bag")]
99
+    InvalidURLBag,
100
+    #[error("Unexpected validation HTTP status: {0}")]
101
+    UnexpectedStatus(reqwest::StatusCode),
102
+    #[error("Cannot parse server response: {0}")]
103
+    Parsing(#[from] plist::Error),
104
+    #[error("Invalid validation response: {0:?}")]
105
+    InvalidResponse(Dictionary),
106
+}
107
+
108
+#[derive(Clone)]
109
+pub struct AccountHTTPSession<'lt, 'adi> {
110
+    pub http_session: AnisetteHTTPSession<'lt, 'adi>,
111
+    pub alt_dsid: AltDsid,
112
+}
113
+
114
+impl<'lt, 'adi> AccountHTTPSession<'lt, 'adi> {
115
+    pub fn new(http_session: AnisetteHTTPSession<'lt, 'adi>, alt_dsid: AltDsid) -> Self {
116
+        Self {
117
+            http_session,
118
+            alt_dsid,
119
+        }
120
+    }
121
+
122
+    pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder {
123
+        self.http_session.simple_request_builder(method, url)
124
+    }
125
+
126
+    pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
127
+        self.http_session.anisette_request_builder(method, url)
128
+    }
129
+
130
+    /// Adds Anisette and the account ID; the endpoint's credential is supplied separately.
131
+    pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
132
+        Ok(self
133
+            .anisette_request_builder(method, url)?
134
+            .header("X-Apple-I-Identity-Id", &self.alt_dsid.0))
135
+    }
136
+
137
+    pub async fn get_app_token<T: AppToken>(
138
+        &self,
139
+        idms_token: &IdmsToken,
140
+        session_key: &SessionKey,
141
+        cookie: &AuthCookie,
142
+    ) -> Result<Token<T>, AppTokenRequestError> {
143
+        let app_token_identifier = T::APP_TOKEN_IDENTIFIER;
144
+        let alt_dsid = self.alt_dsid.0.as_str();
145
+
146
+        let gs_service_url = self
147
+            .http_session
148
+            .url_bag()
149
+            .get("gsService")
150
+            .and_then(Value::as_string)
151
+            .ok_or(AppTokenRequestError::InvalidURLBag)?;
152
+
153
+        let cpd = build_client_provided_data(&self.http_session)
154
+            .map_err(AppTokenRequestError::Anisette)?;
155
+
156
+        let checksum = Hmac::<Sha256>::new_from_slice(&session_key.0)
157
+            .map_err(|_| AppTokenRequestError::InvalidAuthToken)?
158
+            .chain_update("apptokens")
159
+            .chain_update(alt_dsid)
160
+            .chain_update(app_token_identifier)
161
+            .finalize()
162
+            .into_bytes()
163
+            .to_vec();
164
+
165
+        let request_plist = dict! {
166
+            "Header": dict!{
167
+                "Version": "1.0.1"
168
+            },
169
+            "Request": dict!{
170
+                "u": alt_dsid,
171
+                "app": array![
172
+                    app_token_identifier
173
+                ],
174
+                "c": Value::Data(cookie.0.to_vec()),
175
+                "t": idms_token.0.as_str(),
176
+                "checksum": Value::Data(checksum),
177
+                "cpd": cpd,
178
+                "o": "apptokens",
179
+            }
180
+        };
181
+
182
+        let mut request_body = Vec::new();
183
+        plist::to_writer_xml(&mut request_body, &request_plist).expect("Serializing plist failed?");
184
+
185
+        let response = self
186
+            .anisette_request_builder(Method::POST, gs_service_url)
187
+            .map_err(AppTokenRequestError::Anisette)?
188
+            .header("Content-Type", "text/x-xml-plist")
189
+            .body(request_body)
190
+            .send()
191
+            .await
192
+            .map_err(AppTokenRequestError::Network)?
193
+            .error_for_status()?
194
+            .bytes()
195
+            .await
196
+            .map_err(AppTokenRequestError::Network)?;
197
+
198
+        let response_plist: Dictionary =
199
+            plist::from_bytes(&response).map_err(AppTokenRequestError::Parsing)?;
200
+
201
+        let response_dict = response_plist
202
+            .get("Response")
203
+            .and_then(|response| response.as_dictionary())
204
+            .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?;
205
+
206
+        // println!("Response: {response_dict:?}");
207
+
208
+        let status = response_dict
209
+            .get("Status")
210
+            .and_then(|status| status.as_dictionary())
211
+            .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?;
212
+
213
+        parse_status(status)?;
214
+
215
+        let encrypted_tokens = response_dict
216
+            .get("et")
217
+            .and_then(|et| et.as_data())
218
+            .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?;
219
+
220
+        if encrypted_tokens.len() < 19 {
221
+            return Err(AppTokenRequestError::InvalidResponse(response_plist));
222
+        }
223
+
224
+        let associated_data = &encrypted_tokens[0..3];
225
+        if associated_data != b"XYZ" {
226
+            warn!("Surprised by the associated data provided by Apple: {associated_data:02X?}");
227
+            // return Err(AppTokenRequestError::InvalidResponse);
228
+        }
229
+
230
+        let iv = &encrypted_tokens[3..19];
231
+        let encrypted_token = &encrypted_tokens[19..];
232
+
233
+        let tokens_data = AesGcm::<Aes256, U16>::new_from_slice(&session_key.0)
234
+            .map_err(|_| AppTokenRequestError::InvalidAuthToken)?
235
+            .decrypt(
236
+                // iv is of fixed size. It shall not fail.
237
+                iv.try_into().expect("Invalid IV size??"),
238
+                Payload {
239
+                    msg: encrypted_token,
240
+                    aad: associated_data,
241
+                },
242
+            )
243
+            .map_err(|_| AppTokenRequestError::InvalidResponse(response_plist.clone()))?;
244
+
245
+        let tokens: Dictionary =
246
+            plist::from_bytes(&tokens_data).map_err(AppTokenRequestError::Parsing)?;
247
+
248
+        trace!("Decrypted token response: {:#?}", tokens);
249
+
250
+        let tokens = ServerProvidedData(tokens);
251
+        tokens
252
+            .tokens()
253
+            .and_then(|tokens| tokens.get())
254
+            .ok_or_else(|| AppTokenRequestError::InvalidResponse(tokens.0))
255
+    }
256
+
257
+    pub async fn validate_code(
258
+        &self,
259
+        idms_token: &IdmsToken,
260
+        validation_code: &str,
261
+    ) -> Result<(), ValidateCodeError> {
262
+        let validate_code_url = self
263
+            .http_session
264
+            .url_bag()
265
+            .get("validateCode")
266
+            .and_then(Value::as_string)
267
+            .ok_or(ValidateCodeError::InvalidURLBag)?;
268
+
269
+        // AuthKitWin uses POST only when attaching a piggyback verification body.
270
+        let response = self
271
+            .anisette_request_builder(Method::GET, validate_code_url)?
272
+            .header(
273
+                "X-Apple-Identity-Token",
274
+                BASE64_STANDARD.encode(format!("{}:{}", self.alt_dsid.0, idms_token.0)),
275
+            )
276
+            .header("security-code", validation_code)
277
+            .send()
278
+            .await?
279
+            .error_for_status()?;
280
+
281
+        if response.status() != reqwest::StatusCode::OK {
282
+            return Err(ValidateCodeError::UnexpectedStatus(response.status()));
283
+        }
284
+
285
+        let response_plist: Dictionary = plist::from_bytes(&response.bytes().await?)?;
286
+        let code = response_plist
287
+            .get("ec")
288
+            .and_then(Value::as_signed_integer)
289
+            .ok_or_else(|| ValidateCodeError::InvalidResponse(response_plist.clone()))?;
290
+
291
+        match code {
292
+            0 => Ok(()),
293
+            -21669 => Err(ValidateCodeError::InvalidCode),
294
+            code => Err(AppleError {
295
+                code,
296
+                message: response_plist
297
+                    .get("em")
298
+                    .and_then(Value::as_string)
299
+                    .unwrap_or("Unknown validation error")
300
+                    .to_owned(),
301
+            }
302
+            .into()),
303
+        }
304
+    }
305
+}
... ...
@@ -0,0 +1,69 @@
1
+use super::account::AccountHTTPSession;
2
+use adi::proxy::ADIResult;
3
+use plist::Dictionary;
4
+use reqwest::{Method, RequestBuilder};
5
+use serde::{Deserialize, Serialize};
6
+
7
+#[derive(Debug, Deserialize, Serialize, Clone)]
8
+pub struct Token<T = String> {
9
+    pub duration: u64,
10
+    // #[serde(rename = "cts")]
11
+    // pub start_epoch_millis: u64,
12
+    #[serde(rename = "expiry")]
13
+    pub expiry_epoch_millis: u64,
14
+    pub token: T,
15
+}
16
+
17
+pub trait AppToken: From<String> + AsRef<str> {
18
+    const APP_TOKEN_IDENTIFIER: &'static str;
19
+}
20
+
21
+#[derive(Debug, Clone)]
22
+pub struct TokenBag(pub(super) Dictionary);
23
+
24
+impl TokenBag {
25
+    pub fn get<T: AppToken>(&self) -> Option<Token<T>> {
26
+        let token: Token = plist::from_value(self.0.get(T::APP_TOKEN_IDENTIFIER)?).ok()?;
27
+        Some(Token {
28
+            duration: token.duration,
29
+            expiry_epoch_millis: token.expiry_epoch_millis,
30
+            token: T::from(token.token),
31
+        })
32
+    }
33
+}
34
+
35
+#[derive(Clone)]
36
+pub struct AppHTTPSession<'lt, 'adi, T: AppToken> {
37
+    pub http_session: AccountHTTPSession<'lt, 'adi>,
38
+    pub token: Token<T>,
39
+}
40
+
41
+impl<'lt, 'adi, T: AppToken> AppHTTPSession<'lt, 'adi, T> {
42
+    pub fn new(http_session: AccountHTTPSession<'lt, 'adi>, token: Token<T>) -> Self {
43
+        Self {
44
+            http_session,
45
+            token,
46
+        }
47
+    }
48
+
49
+    pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder {
50
+        self.http_session.simple_request_builder(method, url)
51
+    }
52
+
53
+    pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
54
+        self.http_session.anisette_request_builder(method, url)
55
+    }
56
+
57
+    pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
58
+        self.http_session.account_request_builder(method, url)
59
+    }
60
+
61
+    pub fn app_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
62
+        Ok(self
63
+            .account_request_builder(method, url)?
64
+            .header("Content-Type", "text/x-xml-plist")
65
+            .header("Accept", "text/x-xml-plist")
66
+            .header("X-Apple-App-Info", T::APP_TOKEN_IDENTIFIER)
67
+            .header("X-Apple-GS-Token", self.token.token.as_ref()))
68
+    }
69
+}
... ...
@@ -1,363 +0,0 @@
1
-use crate::grandslam::build_client_provided_data;
2
-use crate::http_session::{AnisetteHTTPSession, AppleError, URLBag, parse_status};
3
-use adi::proxy::{ADIError, ADIResult};
4
-use aes::Aes256;
5
-use aes::cipher::consts::U16;
6
-use aes_gcm::aead::{Aead, Payload};
7
-use aes_gcm::{AesGcm, KeyInit};
8
-use base64::Engine;
9
-use base64::prelude::BASE64_STANDARD;
10
-use chrono::{Local, SecondsFormat};
11
-use hmac::{Hmac, Mac};
12
-use log::{trace, warn};
13
-use plist::{Dictionary, Value};
14
-use plist_macros::{array, dict};
15
-use reqwest::header::{HeaderValue, InvalidHeaderValue};
16
-use reqwest::{Method, RequestBuilder};
17
-use serde::{Deserialize, Serialize};
18
-use sha2::Sha256;
19
-use thiserror::Error;
20
-
21
-#[derive(Debug, Deserialize, Serialize, Clone)]
22
-pub struct Token {
23
-    pub duration: u64,
24
-    // #[serde(rename = "cts")]
25
-    // pub start_epoch_millis: u64,
26
-    #[serde(rename = "expiry")]
27
-    pub expiry_epoch_millis: u64,
28
-    pub token: String,
29
-}
30
-
31
-impl TryFrom<&Token> for HeaderValue {
32
-    type Error = InvalidHeaderValue;
33
-
34
-    fn try_from(token: &Token) -> Result<Self, Self::Error> {
35
-        HeaderValue::from_str(token.token.as_str())
36
-    }
37
-}
38
-
39
-#[derive(Debug, Deserialize, Serialize, Clone)]
40
-pub struct AuthToken {
41
-    pub alt_dsid: String,
42
-    pub idms_token: String,
43
-    pub session_key: Vec<u8>,
44
-    pub cookie: Vec<u8>,
45
-    pub identity_token: String,
46
-}
47
-
48
-impl AuthToken {
49
-    fn new(alt_dsid: String, idms_token: String, session_key: Vec<u8>, cookie: Vec<u8>) -> Self {
50
-        let identity_token = BASE64_STANDARD.encode(format!("{alt_dsid}:{idms_token}"));
51
-        AuthToken {
52
-            alt_dsid,
53
-            idms_token,
54
-            session_key,
55
-            cookie,
56
-            identity_token,
57
-        }
58
-    }
59
-}
60
-
61
-pub fn parse_tokens_from_server_provided_data(
62
-    server_provided_data: &Dictionary,
63
-) -> Option<(AuthToken, Vec<(String, Token)>)> {
64
-    let alt_dsid = server_provided_data
65
-        .get("adsid")
66
-        .and_then(|alt_dsid| alt_dsid.as_string())
67
-        .map(|alt_dsid| alt_dsid.to_string());
68
-
69
-    let idms_token = server_provided_data
70
-        .get("GsIdmsToken")
71
-        .and_then(|idms_token| idms_token.as_string())
72
-        .map(|idms_token| idms_token.to_string());
73
-
74
-    let session_key = server_provided_data
75
-        .get("sk")
76
-        .and_then(|session_key| session_key.as_data())
77
-        .map(|session_key| session_key.to_vec());
78
-
79
-    let cookie = server_provided_data
80
-        .get("c")
81
-        .and_then(|cookie| cookie.as_data())
82
-        .map(|cookie| cookie.to_vec());
83
-
84
-    let tokens = server_provided_data
85
-        .get("t")
86
-        .and_then(|tokens| tokens.as_dictionary())
87
-        .map(|tokens| {
88
-            tokens
89
-                .iter()
90
-                .filter_map(|(key, token)| {
91
-                    plist::from_value(token)
92
-                        .ok()
93
-                        .map(|token| (key.clone(), token))
94
-                })
95
-                .collect()
96
-        });
97
-
98
-    match (alt_dsid, idms_token, session_key, cookie) {
99
-        (Some(alt_dsid), Some(idms_token), Some(session_key), Some(cookie)) => Some((
100
-            AuthToken::new(alt_dsid, idms_token, session_key, cookie),
101
-            tokens.unwrap_or_default(),
102
-        )),
103
-        _ => None,
104
-    }
105
-}
106
-
107
-#[derive(Debug, Error)]
108
-pub enum AppTokenRequestError {
109
-    #[error("Cannot proceed: {0}")]
110
-    Apple(#[from] AppleError),
111
-    #[error("Cannot generate device authentication data: {0}")]
112
-    Anisette(#[from] ADIError),
113
-    #[error("The provided authentication token is not valid")]
114
-    InvalidAuthToken,
115
-    // vvvv Internal errors vvvv
116
-    #[error("Invalid URL bag")]
117
-    InvalidURLBag,
118
-    #[error("Network error: {0}")]
119
-    Network(#[from] reqwest::Error),
120
-    #[error("Failed to parse the app token request response: {0}")]
121
-    Parsing(#[from] plist::Error),
122
-    #[error("Failed to parse the app token request response")]
123
-    Structure(Dictionary),
124
-    #[error("Invalid token returned by the server")]
125
-    InvalidResponse,
126
-}
127
-
128
-pub struct AppTokenIdentifier<'lt>(pub &'lt str);
129
-
130
-#[derive(Debug, Error)]
131
-pub enum AuthenticatedRequestError {
132
-    #[error("Server returned: {0}")]
133
-    Apple(#[from] AppleError),
134
-    #[error("Cannot generate Anisette headers: {0}")]
135
-    Anisette(#[from] ADIError),
136
-    #[error("Invalid URL bag")]
137
-    InvalidURLBag,
138
-    #[error("Network error: {0}")]
139
-    Network(#[from] reqwest::Error),
140
-    #[error("Invalid server response")]
141
-    InvalidResponse(plist::Error),
142
-}
143
-
144
-pub type AuthenticatedRequestResult<T> = Result<T, AuthenticatedRequestError>;
145
-
146
-#[derive(Clone)]
147
-pub struct AuthenticatedHTTPSession<'lt, 'adi> {
148
-    pub http_session: AnisetteHTTPSession<'lt, 'adi>,
149
-    pub auth_token: AuthToken,
150
-    pub hb_token: String,
151
-}
152
-
153
-impl<'a, 'b> AuthenticatedHTTPSession<'a, 'b> {
154
-    pub fn new(
155
-        http_session: AnisetteHTTPSession<'a, 'b>,
156
-        auth_token: AuthToken,
157
-        heartbeat_token: Token,
158
-    ) -> Self {
159
-        let hb_token =
160
-            BASE64_STANDARD.encode(format!("{}:{}", auth_token.alt_dsid, heartbeat_token.token));
161
-
162
-        Self {
163
-            http_session,
164
-            auth_token,
165
-            hb_token,
166
-        }
167
-    }
168
-
169
-    pub fn url_bag(&self) -> &URLBag {
170
-        self.http_session.url_bag()
171
-    }
172
-
173
-    pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder {
174
-        self.http_session.simple_request_builder(method, url)
175
-    }
176
-
177
-    pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
178
-        self.http_session.anisette_request_builder(method, url)
179
-    }
180
-
181
-    pub fn authenticated_request_builder(
182
-        &self,
183
-        method: Method,
184
-        url: &str,
185
-    ) -> ADIResult<RequestBuilder> {
186
-        self.anisette_request_builder(method, url).map(|builder| {
187
-            let client_time = Local::now()
188
-                .to_utc()
189
-                .to_rfc3339_opts(SecondsFormat::Secs, true);
190
-
191
-            let timezone = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string());
192
-            let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
193
-            let apple_locale = locale.replace('-', "_");
194
-
195
-            builder
196
-                .header("Accept-Language", locale)
197
-                .header("X-Apple-I-Identity-Id", self.auth_token.alt_dsid.as_str())
198
-                .header("X-Apple-HB-Token", self.hb_token.as_str())
199
-                .header("X-Apple-Locale", apple_locale)
200
-                .header("X-Apple-I-Client-Time", client_time)
201
-                .header("X-Apple-I-TimeZone", timezone)
202
-        })
203
-    }
204
-
205
-    pub async fn get_app_token(
206
-        &self,
207
-        app_token_identifier: AppTokenIdentifier<'_>,
208
-    ) -> Result<Token, AppTokenRequestError> {
209
-        let app_token_identifier = app_token_identifier.0;
210
-        let AuthenticatedHTTPSession {
211
-            http_session,
212
-            auth_token,
213
-            ..
214
-        } = self;
215
-
216
-        let gs_service_url = http_session
217
-            .url_bag()
218
-            .get("gsService")
219
-            .and_then(Value::as_string)
220
-            .ok_or(AppTokenRequestError::InvalidURLBag)?;
221
-
222
-        let cpd =
223
-            build_client_provided_data(&http_session).map_err(AppTokenRequestError::Anisette)?;
224
-
225
-        let checksum = Hmac::<Sha256>::new_from_slice(&auth_token.session_key)
226
-            .map_err(|_| AppTokenRequestError::InvalidAuthToken)?
227
-            .chain_update("apptokens")
228
-            .chain_update(&auth_token.alt_dsid)
229
-            .chain_update(app_token_identifier)
230
-            .finalize()
231
-            .into_bytes()
232
-            .to_vec();
233
-
234
-        let request_plist = dict! {
235
-            "Header": dict!{
236
-                "Version": "1.0.1"
237
-            },
238
-            "Request": dict!{
239
-                "u": auth_token.alt_dsid.clone(),
240
-                "app": array![
241
-                    app_token_identifier
242
-                ],
243
-                "c": Value::Data(auth_token.cookie.clone()),
244
-                "t": auth_token.idms_token.clone(),
245
-                "checksum": Value::Data(checksum),
246
-                "cpd": cpd,
247
-                "o": "apptokens",
248
-            }
249
-        };
250
-
251
-        let mut request_body = Vec::new();
252
-        plist::to_writer_xml(&mut request_body, &request_plist).expect("Serializing plist failed?");
253
-
254
-        let response = http_session
255
-            .anisette_request_builder(Method::POST, gs_service_url)
256
-            .map_err(AppTokenRequestError::Anisette)?
257
-            .header("Content-Type", "text/x-xml-plist")
258
-            .body(request_body)
259
-            .send()
260
-            .await
261
-            .map_err(AppTokenRequestError::Network)?
262
-            .error_for_status()?
263
-            .bytes()
264
-            .await
265
-            .map_err(AppTokenRequestError::Network)?;
266
-
267
-        let response_plist: Dictionary =
268
-            plist::from_bytes(&response).map_err(AppTokenRequestError::Parsing)?;
269
-
270
-        let response_dict = response_plist
271
-            .get("Response")
272
-            .and_then(|response| response.as_dictionary())
273
-            .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?;
274
-
275
-        // println!("Response: {response_dict:?}");
276
-
277
-        let status = response_dict
278
-            .get("Status")
279
-            .and_then(|status| status.as_dictionary())
280
-            .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?;
281
-
282
-        parse_status(status)?;
283
-
284
-        let encrypted_tokens = response_dict
285
-            .get("et")
286
-            .and_then(|et| et.as_data())
287
-            .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?;
288
-
289
-        let associated_data = &encrypted_tokens[0..3];
290
-        if associated_data != b"XYZ" {
291
-            warn!("Surprised by the associated data provided by Apple: {associated_data:02X?}");
292
-            // return Err(AppTokenRequestError::InvalidResponse);
293
-        }
294
-
295
-        let iv = &encrypted_tokens[3..19];
296
-        let encrypted_token = &encrypted_tokens[19..];
297
-
298
-        let tokens_data = AesGcm::<Aes256, U16>::new_from_slice(&auth_token.session_key)
299
-            .map_err(|_| AppTokenRequestError::InvalidAuthToken)?
300
-            .decrypt(
301
-                // iv is of fixed size. It shall not fail.
302
-                iv.try_into().expect("Invalid IV size??"),
303
-                Payload {
304
-                    msg: encrypted_token,
305
-                    aad: associated_data,
306
-                },
307
-            )
308
-            .map_err(|_| AppTokenRequestError::InvalidResponse)?;
309
-
310
-        let tokens: Dictionary =
311
-            plist::from_bytes(&tokens_data).map_err(AppTokenRequestError::Parsing)?;
312
-
313
-        trace!("Decrypted token response: {:#?}", tokens);
314
-
315
-        tokens
316
-            .get("t")
317
-            .and_then(|token| token.as_dictionary())
318
-            .and_then(|token| token.get(app_token_identifier))
319
-            .and_then(|token| plist::from_value(token).ok())
320
-            .ok_or(AppTokenRequestError::InvalidResponse)
321
-    }
322
-
323
-    pub async fn fetch_user_info(&self) -> AuthenticatedRequestResult<Dictionary> {
324
-        let url = self
325
-            .http_session
326
-            .url_bag()
327
-            .get("fetchUserInfo")
328
-            .and_then(Value::as_string)
329
-            .ok_or(AuthenticatedRequestError::InvalidURLBag)?;
330
-
331
-        let response = self
332
-            .authenticated_request_builder(Method::GET, url)?
333
-            .send()
334
-            .await?
335
-            .bytes()
336
-            .await?;
337
-
338
-        let dict =
339
-            plist::from_bytes(&response).map_err(AuthenticatedRequestError::InvalidResponse)?;
340
-
341
-        parse_status(&dict)?;
342
-
343
-        Ok(dict)
344
-    }
345
-
346
-    pub async fn validate_code(
347
-        &self,
348
-        validation_code: &str,
349
-    ) -> Result<(), Box<dyn std::error::Error>> {
350
-        // TODO: change that error type.
351
-        let validate_code_url = self
352
-            .url_bag()
353
-            .get("validateCode")
354
-            .and_then(Value::as_string)
355
-            .ok_or(AppTokenRequestError::InvalidURLBag)?;
356
-
357
-        let _ = self
358
-            .authenticated_request_builder(Method::POST, validate_code_url)?
359
-            .header("security-code", validation_code);
360
-
361
-        todo!()
362
-    }
363
-}
... ...
@@ -0,0 +1,193 @@
1
+use super::account::AccountHTTPSession;
2
+use super::app::{AppToken, Token};
3
+use crate::http_session::{AppleError, parse_status};
4
+use crate::plist_request::plist_to_body;
5
+use adi::proxy::{ADIError, ADIResult};
6
+use base64::Engine;
7
+use base64::prelude::BASE64_STANDARD;
8
+use chrono::{Local, SecondsFormat};
9
+use plist::{Dictionary, Value};
10
+use reqwest::{Method, RequestBuilder};
11
+use serde::{Deserialize, Serialize};
12
+use thiserror::Error;
13
+
14
+#[derive(Debug, Clone, Serialize, Deserialize)]
15
+#[serde(transparent)]
16
+pub struct HeartbeatToken(String);
17
+
18
+impl From<String> for HeartbeatToken {
19
+    fn from(token: String) -> Self {
20
+        Self(token)
21
+    }
22
+}
23
+
24
+impl AsRef<str> for HeartbeatToken {
25
+    fn as_ref(&self) -> &str {
26
+        &self.0
27
+    }
28
+}
29
+
30
+impl AppToken for HeartbeatToken {
31
+    const APP_TOKEN_IDENTIFIER: &'static str = "com.apple.gs.idms.hb";
32
+}
33
+
34
+#[derive(Debug, Error)]
35
+pub enum AuthenticatedRequestError {
36
+    #[error("Server returned: {0}")]
37
+    Apple(#[from] AppleError),
38
+    #[error("Cannot generate Anisette headers: {0}")]
39
+    Anisette(#[from] ADIError),
40
+    #[error("Invalid URL bag")]
41
+    InvalidURLBag,
42
+    #[error("Network error: {0}")]
43
+    Network(#[from] reqwest::Error),
44
+    #[error("Invalid server response")]
45
+    InvalidResponse(plist::Error),
46
+}
47
+
48
+pub type AuthenticatedRequestResult<T> = Result<T, AuthenticatedRequestError>;
49
+
50
+#[derive(Debug, Error)]
51
+pub enum PostDataError {
52
+    #[error("Serialization failure! {0}")]
53
+    Serialization(#[from] plist::Error),
54
+    #[error("Failed to perform HTTP request! {0}")]
55
+    Network(#[from] reqwest::Error),
56
+    #[error("Failed to perform the request! {0}")]
57
+    Anisette(#[from] ADIError),
58
+    #[error("Failed to perform the request! {0}")]
59
+    Apple(#[from] AppleError),
60
+    #[error("Invalid URL bag.")]
61
+    InvalidURLBag,
62
+}
63
+
64
+#[derive(Debug, Default, Serialize)]
65
+pub struct DeviceData {
66
+    #[serde(rename = "circleStatus")]
67
+    pub circle_status: Option<bool>,
68
+    #[serde(rename = "dc")]
69
+    pub device_color: Option<String>,
70
+    #[serde(rename = "dn")]
71
+    pub device_name: Option<String>,
72
+    #[serde(rename = "event")]
73
+    pub event: Option<String>, // liveness
74
+    #[serde(rename = "imei")]
75
+    pub imei: Option<String>,
76
+    #[serde(rename = "loc")]
77
+    pub locale: Option<String>,
78
+    #[serde(rename = "pn")]
79
+    pub phone_number: Option<String>,
80
+    #[serde(rename = "ptkn")]
81
+    pub push_token: Option<String>,
82
+    pub services: Option<Vec<String>>,
83
+    #[serde(rename = "sn")]
84
+    pub serial_number: Option<String>,
85
+}
86
+
87
+#[derive(Clone)]
88
+pub struct HeartbeatHTTPSession<'lt, 'adi> {
89
+    pub http_session: AccountHTTPSession<'lt, 'adi>,
90
+    pub token: Token<HeartbeatToken>,
91
+}
92
+
93
+impl<'lt, 'adi> HeartbeatHTTPSession<'lt, 'adi> {
94
+    pub fn new(http_session: AccountHTTPSession<'lt, 'adi>, token: Token<HeartbeatToken>) -> Self {
95
+        Self {
96
+            http_session,
97
+            token,
98
+        }
99
+    }
100
+
101
+    pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder {
102
+        self.http_session.simple_request_builder(method, url)
103
+    }
104
+
105
+    pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
106
+        self.http_session.anisette_request_builder(method, url)
107
+    }
108
+
109
+    pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
110
+        self.http_session.account_request_builder(method, url)
111
+    }
112
+
113
+    pub fn heartbeat_request_builder(
114
+        &self,
115
+        method: Method,
116
+        url: &str,
117
+    ) -> ADIResult<RequestBuilder> {
118
+        self.account_request_builder(method, url).map(|builder| {
119
+            let client_time = Local::now()
120
+                .to_utc()
121
+                .to_rfc3339_opts(SecondsFormat::Secs, true);
122
+
123
+            let timezone = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string());
124
+            let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
125
+            let apple_locale = locale.replace('-', "_");
126
+
127
+            builder
128
+                .header("Accept-Language", locale)
129
+                .header(
130
+                    "X-Apple-HB-Token",
131
+                    BASE64_STANDARD.encode(format!(
132
+                        "{}:{}",
133
+                        self.http_session.alt_dsid.0, self.token.token.0
134
+                    )),
135
+                )
136
+                .header("X-Apple-Locale", apple_locale)
137
+                .header("X-Apple-I-Client-Time", client_time)
138
+                .header("X-Apple-I-TimeZone", timezone)
139
+        })
140
+    }
141
+
142
+    pub async fn fetch_user_info(&self) -> AuthenticatedRequestResult<Dictionary> {
143
+        let url = self
144
+            .http_session
145
+            .http_session
146
+            .url_bag()
147
+            .get("fetchUserInfo")
148
+            .and_then(Value::as_string)
149
+            .ok_or(AuthenticatedRequestError::InvalidURLBag)?;
150
+
151
+        let response = self
152
+            .heartbeat_request_builder(Method::GET, url)?
153
+            .send()
154
+            .await?
155
+            .bytes()
156
+            .await?;
157
+
158
+        let dict =
159
+            plist::from_bytes(&response).map_err(AuthenticatedRequestError::InvalidResponse)?;
160
+
161
+        parse_status(&dict)?;
162
+
163
+        Ok(dict)
164
+    }
165
+
166
+    pub async fn post_data(&self, device_data: DeviceData) -> Result<(), PostDataError> {
167
+        let post_data = plist::to_value(&device_data).map_err(PostDataError::Serialization)?;
168
+        let post_data_url = self
169
+            .http_session
170
+            .http_session
171
+            .url_bag()
172
+            .get("postData")
173
+            .and_then(Value::as_string)
174
+            .ok_or(PostDataError::InvalidURLBag)?;
175
+
176
+        let mut request = Dictionary::new();
177
+        request.insert("Request".into(), post_data);
178
+
179
+        let response = self
180
+            .heartbeat_request_builder(Method::POST, post_data_url)?
181
+            .header("Content-Type", "text/x-xml-plist")
182
+            .body(plist_to_body(request.into()))
183
+            .send()
184
+            .await?
185
+            .bytes()
186
+            .await?;
187
+
188
+        let status: Dictionary =
189
+            plist::from_bytes(&response).map_err(PostDataError::Serialization)?;
190
+
191
+        parse_status(&status).map_err(PostDataError::Apple)
192
+    }
193
+}
... ...
@@ -1,74 +0,0 @@
1
-use crate::grandslam::AuthenticatedHTTPSession;
2
-use crate::http_session::{AppleError, parse_status};
3
-use crate::plist_request::plist_to_body;
4
-use adi::proxy::ADIError;
5
-use plist::{Dictionary, Value};
6
-use reqwest::Method;
7
-use serde::Serialize;
8
-use thiserror::Error;
9
-
10
-#[derive(Debug, Error)]
11
-pub enum PostDataError {
12
-    #[error("Serialization failure! {0}")]
13
-    Serialization(#[from] plist::Error),
14
-    #[error("Failed to perform HTTP request! {0}")]
15
-    Network(#[from] reqwest::Error),
16
-    #[error("Failed to perform the request! {0}")]
17
-    Anisette(#[from] ADIError),
18
-    #[error("Failed to perform the request! {0}")]
19
-    Apple(#[from] AppleError),
20
-    #[error("Invalid URL bag.")]
21
-    InvalidURLBag,
22
-}
23
-
24
-#[derive(Debug, Default, Serialize)]
25
-pub struct DeviceData {
26
-    #[serde(rename = "circleStatus")]
27
-    pub circle_status: Option<bool>,
28
-    #[serde(rename = "dc")]
29
-    pub device_color: Option<String>,
30
-    #[serde(rename = "dn")]
31
-    pub device_name: Option<String>,
32
-    #[serde(rename = "event")]
33
-    pub event: Option<String>, // liveness
34
-    #[serde(rename = "imei")]
35
-    pub imei: Option<String>,
36
-    #[serde(rename = "loc")]
37
-    pub locale: Option<String>,
38
-    #[serde(rename = "pn")]
39
-    pub phone_number: Option<String>,
40
-    #[serde(rename = "ptkn")]
41
-    pub push_token: Option<String>,
42
-    pub services: Option<Vec<String>>,
43
-    #[serde(rename = "sn")]
44
-    pub serial_number: Option<String>,
45
-}
46
-
47
-impl AuthenticatedHTTPSession<'_, '_> {
48
-    pub async fn post_data(&self, device_data: DeviceData) -> Result<(), PostDataError> {
49
-        let post_data = plist::to_value(&device_data).map_err(PostDataError::Serialization)?;
50
-        let post_data_url = self
51
-            .http_session
52
-            .url_bag()
53
-            .get("postData")
54
-            .and_then(Value::as_string)
55
-            .ok_or(PostDataError::InvalidURLBag)?;
56
-
57
-        let mut request = Dictionary::new();
58
-        request.insert("Request".into(), post_data);
59
-
60
-        let response = self
61
-            .authenticated_request_builder(Method::POST, post_data_url)?
62
-            .header("Content-Type", "text/x-xml-plist")
63
-            .body(plist_to_body(request.into()))
64
-            .send()
65
-            .await?
66
-            .bytes()
67
-            .await?;
68
-
69
-        let status: Dictionary =
70
-            plist::from_bytes(&response).map_err(PostDataError::Serialization)?;
71
-
72
-        parse_status(&status).map_err(PostDataError::Apple)
73
-    }
74
-}
... ...
@@ -3,10 +3,10 @@ use crate::backend::developer::keychain::{token_is_near_expiry, DeveloperAccount
3 3
 use crate::backend::{BackendError, BackendResult};
4 4
 use crate::domain::{AdiBackendKind, MachineIdentity};
5 5
 use grandslam::http_session::AnisetteHTTPSession;
6
-use grandslam::AuthenticatedHTTPSession;
6
+use grandslam::AccountHTTPSession;
7 7
 use std::future::Future;
8 8
 use std::pin::Pin;
9
-use xcode::{XcodeSession, XCODE_BUNDLE_INFORMATION, XCODE_TOKEN_IDENTIFIER};
9
+use xcode::{XcodeSession, XCODE_BUNDLE_INFORMATION};
10 10
 
11 11
 #[derive(Clone, Debug)]
12 12
 pub(crate) struct DeveloperSessionConfig {
... ...
@@ -40,15 +40,15 @@ where
40 40
         BackendError::Network(format!("Failed to create Apple developer session: {error}"))
41 41
     })?;
42 42
     let anisette_session = AnisetteHTTPSession::new(http_session, proxy.as_ref());
43
-    let authenticated_session = AuthenticatedHTTPSession::new(
44
-        anisette_session,
45
-        session.auth_token.clone(),
46
-        session.heartbeat_token.clone(),
47
-    );
48
-
43
+    let account_session =
44
+        AccountHTTPSession::new(anisette_session, session.auth_token.alt_dsid.clone());
49 45
     let xcode_token = if token_is_near_expiry(session.xcode_token.expiry_epoch_millis) {
50
-        authenticated_session
51
-            .get_app_token(XCODE_TOKEN_IDENTIFIER)
46
+        account_session
47
+            .get_app_token(
48
+                &session.auth_token.idms_token,
49
+                &session.auth_token.session_key,
50
+                &session.auth_token.cookie,
51
+            )
52 52
             .await
53 53
             .map_err(|error| {
54 54
                 BackendError::AppleAuth(format!("Failed to refresh the Xcode app token: {error}"))
... ...
@@ -56,12 +56,12 @@ where
56 56
     } else {
57 57
         session.xcode_token.clone()
58 58
     };
59
+    let xcode_session = XcodeSession::new(account_session, xcode_token.clone());
59 60
     let keychain_session = DeveloperAccountKeychainSession::new(
60 61
         session.auth_token,
61 62
         session.heartbeat_token,
62
-        xcode_token.clone(),
63
+        xcode_token,
63 64
     );
64
-    let xcode_session = XcodeSession::new(authenticated_session, xcode_token);
65 65
     let value = action(&xcode_session).await?;
66 66
 
67 67
     Ok(DeveloperApiSessionResult {
... ...
@@ -3,11 +3,12 @@
3 3
 mod windows_session_store;
4 4
 
5 5
 use crate::backend::{BackendError, BackendResult};
6
-use grandslam::{AuthToken, Token};
6
+use grandslam::{AltDsid, AuthCookie, HeartbeatToken, IdmsToken, SessionKey, Token};
7 7
 use serde::{Deserialize, Serialize};
8 8
 use std::collections::HashMap;
9 9
 use std::sync::{Mutex, MutexGuard, OnceLock};
10 10
 use std::time::{SystemTime, UNIX_EPOCH};
11
+use xcode::XcodeToken;
11 12
 
12 13
 const KEYCHAIN_SESSION_VERSION: u32 = 1;
13 14
 const KEYRING_SERVICE: &str = "zone.dadoum.SuperSideloader";
... ...
@@ -42,16 +43,29 @@ impl CachedKeychainLookup {
42 43
     }
43 44
 }
44 45
 
46
+// Keep the existing keychain representation; request APIs take individual credentials.
47
+#[derive(Clone, Debug, Deserialize, Serialize)]
48
+pub(crate) struct StoredAuthToken {
49
+    pub(crate) alt_dsid: AltDsid,
50
+    pub(crate) idms_token: IdmsToken,
51
+    pub(crate) session_key: SessionKey,
52
+    pub(crate) cookie: AuthCookie,
53
+}
54
+
45 55
 #[derive(Clone, Debug, Deserialize, Serialize)]
46 56
 pub(crate) struct DeveloperAccountKeychainSession {
47 57
     pub(crate) version: u32,
48
-    pub(crate) auth_token: AuthToken,
49
-    pub(crate) heartbeat_token: Token,
50
-    pub(crate) xcode_token: Token,
58
+    pub(crate) auth_token: StoredAuthToken,
59
+    pub(crate) heartbeat_token: Token<HeartbeatToken>,
60
+    pub(crate) xcode_token: Token<XcodeToken>,
51 61
 }
52 62
 
53 63
 impl DeveloperAccountKeychainSession {
54
-    pub(crate) fn new(auth_token: AuthToken, heartbeat_token: Token, xcode_token: Token) -> Self {
64
+    pub(crate) fn new(
65
+        auth_token: StoredAuthToken,
66
+        heartbeat_token: Token<HeartbeatToken>,
67
+        xcode_token: Token<XcodeToken>,
68
+    ) -> Self {
55 69
         Self {
56 70
             version: KEYCHAIN_SESSION_VERSION,
57 71
             auth_token,
... ...
@@ -250,25 +264,28 @@ mod tests {
250 264
     }
251 265
 
252 266
     fn test_session() -> DeveloperAccountKeychainSession {
253
-        DeveloperAccountKeychainSession::new(
254
-            AuthToken {
255
-                alt_dsid: "alt-dsid".to_string(),
256
-                idms_token: "idms-token".to_string(),
257
-                session_key: vec![1, 2, 3],
258
-                cookie: vec![4, 5, 6],
259
-                identity_token: "identity-token".to_string(),
260
-            },
261
-            Token {
262
-                duration: 3600,
263
-                expiry_epoch_millis: current_epoch_millis() + 3_600_000,
264
-                token: "heartbeat-token".to_string(),
265
-            },
266
-            Token {
267
-                duration: 3600,
268
-                expiry_epoch_millis: current_epoch_millis() + 3_600_000,
269
-                token: "xcode-token".to_string(),
270
-            },
271
-        )
267
+        // Version 1 payload, including the old redundant identity_token field.
268
+        let contents = format!(
269
+            r#"
270
+version = 1
271
+[auth_token]
272
+alt_dsid = "alt-dsid"
273
+idms_token = "idms-token"
274
+session_key = [1, 2, 3]
275
+cookie = [4, 5, 6]
276
+identity_token = "identity-token"
277
+[heartbeat_token]
278
+duration = 3600
279
+expiry = {expiry}
280
+token = "heartbeat-token"
281
+[xcode_token]
282
+duration = 3600
283
+expiry = {expiry}
284
+token = "xcode-token"
285
+"#,
286
+            expiry = current_epoch_millis() + 3_600_000
287
+        );
288
+        decode_keychain_session(&contents).unwrap().session
272 289
     }
273 290
 
274 291
     #[test]
... ...
@@ -308,7 +325,10 @@ mod tests {
308 325
             .collect::<Vec<_>>();
309 326
 
310 327
         for handle in handles {
311
-            assert_eq!(handle.join().unwrap().auth_token.idms_token, "idms-token");
328
+            assert_eq!(
329
+                handle.join().unwrap().auth_token.idms_token,
330
+                test_session().auth_token.idms_token
331
+            );
312 332
         }
313 333
         assert_eq!(reads.load(Ordering::SeqCst), 1);
314 334
     }
... ...
@@ -13,28 +13,26 @@ use crate::backend::developer::certificates::{
13 13
 use crate::backend::developer::client::{with_developer_session, DeveloperSessionConfig};
14 14
 use crate::backend::developer::keychain::{
15 15
     cache_keychain_session, delete_keychain_session, load_keychain_session, save_keychain_session,
16
-    token_is_near_expiry, DeveloperAccountKeychainSession,
16
+    token_is_near_expiry, DeveloperAccountKeychainSession, StoredAuthToken,
17 17
 };
18 18
 use crate::backend::runtime as backend_runtime;
19 19
 use crate::backend::{BackendError, BackendResult};
20 20
 use crate::domain::{AdiBackendKind, DeveloperAccount, DeveloperDevice, MachineIdentity};
21 21
 use chrono::{DateTime, Local};
22
+use grandslam::bundle_information::AKD_BUNDLE_INFORMATION;
22 23
 use grandslam::http_session::AnisetteHTTPSession;
23
-use grandslam::{AuthOutcome, AuthenticatedHTTPSession, Token};
24
+use grandslam::{AccountHTTPSession, AuthOutcome, HeartbeatToken};
24 25
 use plist::{Dictionary, Value};
25 26
 use std::path::PathBuf;
26 27
 use std::time::{Duration, UNIX_EPOCH};
27
-use grandslam::bundle_information::AKD_BUNDLE_INFORMATION;
28 28
 use xcode::{
29 29
     AddAppIdAction, AddDeviceAction, AppIdFeature, DeleteAppIdAction, DeleteDeviceAction,
30 30
     DeveloperTeam, DownloadTeamProvisioningProfileAction, IOSRequest,
31 31
     ListAllDevelopmentCertsAction, ListAppIdsAction, ListDevicesAction, ListTeamsAction,
32 32
     RevokeDevelopmentCertAction, SubmitDevelopmentCsrAction, UpdateAppIdAction,
33
-    ViewDeveloperAction, XcodeSession, XCODE_BUNDLE_INFORMATION, XCODE_TOKEN_IDENTIFIER,
33
+    ViewDeveloperAction, XcodeSession,
34 34
 };
35 35
 
36
-const HEARTBEAT_TOKEN_IDENTIFIER: &str = "com.apple.gs.idms.hb";
37
-
38 36
 #[derive(Clone, Debug)]
39 37
 pub(crate) struct DeveloperLoginRequest {
40 38
     pub(crate) email: String,
... ...
@@ -921,9 +919,12 @@ async fn login_developer_account_async(
921 919
         }
922 920
     };
923 921
 
924
-    let Some((auth_token, tokens)) =
925
-        grandslam::parse_tokens_from_server_provided_data(&server_provided_data)
926
-    else {
922
+    let (Some(alt_dsid), Some(idms_token), Some(session_key), Some(cookie)) = (
923
+        server_provided_data.alt_dsid(),
924
+        server_provided_data.idms_token(),
925
+        server_provided_data.session_key(),
926
+        server_provided_data.cookie(),
927
+    ) else {
927 928
         if let Some(action_url) = secondary_action_url {
928 929
             return Ok(DeveloperLoginOutcome::RequiresSecondaryAction {
929 930
                 detail: format!(
... ...
@@ -936,7 +937,12 @@ async fn login_developer_account_async(
936 937
             "Apple login succeeded but no reusable account tokens were returned.".to_string(),
937 938
         ));
938 939
     };
939
-    let heartbeat_token = match heartbeat_token(&tokens) {
940
+    let heartbeat_token = match server_provided_data
941
+        .tokens()
942
+        .and_then(|tokens| tokens.get::<HeartbeatToken>())
943
+        .ok_or_else(|| {
944
+            BackendError::AppleAuth("Apple login did not return a heartbeat token.".to_string())
945
+        }) {
940 946
         Ok(token) => token,
941 947
         Err(error) if secondary_action_url.is_some() => {
942 948
             let action_url = secondary_action_url.as_deref().unwrap_or_default();
... ...
@@ -948,13 +954,9 @@ async fn login_developer_account_async(
948 954
         }
949 955
         Err(error) => return Err(error),
950 956
     };
951
-    let authenticated_session = AuthenticatedHTTPSession::new(
952
-        anisette_session,
953
-        auth_token.clone(),
954
-        heartbeat_token.clone(),
955
-    );
956
-    let xcode_token = match authenticated_session
957
-        .get_app_token(XCODE_TOKEN_IDENTIFIER)
957
+    let account_session = AccountHTTPSession::new(anisette_session, alt_dsid.clone());
958
+    let xcode_token = match account_session
959
+        .get_app_token(&idms_token, &session_key, &cookie)
958 960
         .await
959 961
     {
960 962
         Ok(token) => token,
... ...
@@ -973,9 +975,17 @@ async fn login_developer_account_async(
973 975
         }
974 976
     };
975 977
 
976
-    let keychain_session =
977
-        DeveloperAccountKeychainSession::new(auth_token, heartbeat_token, xcode_token.clone());
978
-    let xcode_session = XcodeSession::new(authenticated_session, xcode_token);
978
+    let xcode_session = XcodeSession::new(account_session, xcode_token.clone());
979
+    let keychain_session = DeveloperAccountKeychainSession::new(
980
+        StoredAuthToken {
981
+            alt_dsid,
982
+            idms_token,
983
+            session_key,
984
+            cookie,
985
+        },
986
+        heartbeat_token,
987
+        xcode_token,
988
+    );
979 989
     let mut account_cache =
980 990
         refresh_full_account(account_id, request.email.trim().to_string(), &xcode_session).await?;
981 991
     touch_account_cache(&mut account_cache, keychain_session.expires_at_millis());
... ...
@@ -1326,28 +1336,6 @@ fn developer_team_type(team: &DeveloperTeam) -> String {
1326 1336
         .to_string()
1327 1337
 }
1328 1338
 
1329
-fn heartbeat_token(tokens: &[(String, Token)]) -> BackendResult<Token> {
1330
-    tokens
1331
-        .iter()
1332
-        .find(|(key, _)| key == HEARTBEAT_TOKEN_IDENTIFIER)
1333
-        .or_else(|| tokens.iter().find(|(key, _)| key.contains(".hb")))
1334
-        .map(|(_, token)| token.clone())
1335
-        .ok_or_else(|| {
1336
-            let identifiers = tokens
1337
-                .iter()
1338
-                .map(|(key, _)| key.as_str())
1339
-                .collect::<Vec<_>>()
1340
-                .join(", ");
1341
-            if identifiers.is_empty() {
1342
-                BackendError::AppleAuth("Apple login did not return a heartbeat token.".to_string())
1343
-            } else {
1344
-                BackendError::AppleAuth(format!(
1345
-                    "Apple login did not return the expected heartbeat token. Returned tokens: {identifiers}"
1346
-                ))
1347
-            }
1348
-        })
1349
-}
1350
-
1351 1339
 fn format_now() -> String {
1352 1340
     Local::now().format("%Y-%m-%d %H:%M").to_string()
1353 1341
 }
... ...
@@ -1,11 +1,11 @@
1
-use adi::proxy::ADIError;
1
+use adi::proxy::{ADIError, ADIResult};
2 2
 use grandslam::bundle_information::BundleInformation;
3 3
 use grandslam::plist_request::plist_to_body;
4
-use grandslam::{AppTokenIdentifier, AuthenticatedHTTPSession, Token};
4
+use grandslam::{AccountHTTPSession, AppHTTPSession, AppToken, Token};
5 5
 use plist::{Dictionary, Value};
6 6
 use plist_macros::{array, dict};
7
-use reqwest::Method;
8
-use serde::Serialize;
7
+use reqwest::{Method, RequestBuilder};
8
+use serde::{Deserialize, Serialize};
9 9
 use std::borrow::Cow;
10 10
 use std::fmt::Display;
11 11
 use thiserror::Error;
... ...
@@ -17,9 +17,6 @@ pub const XCODE_BUNDLE_INFORMATION: BundleInformation = BundleInformation {
17 17
     bundle_version: "23792",
18 18
 };
19 19
 
20
-pub const XCODE_TOKEN_IDENTIFIER: AppTokenIdentifier =
21
-    AppTokenIdentifier("com.apple.gs.xcode.auth");
22
-
23 20
 const CLIENT_ID: &str = "XABBG36SBA";
24 21
 const PROTOCOL_VERSION: &str = "QH65B2";
25 22
 
... ...
@@ -195,17 +192,51 @@ pub enum XcodeError {
195 192
     Parsing(#[from] plist::Error),
196 193
 }
197 194
 
195
+#[derive(Debug, Clone, Serialize, Deserialize)]
196
+#[serde(transparent)]
197
+pub struct XcodeToken(String);
198
+
199
+impl From<String> for XcodeToken {
200
+    fn from(token: String) -> Self {
201
+        Self(token)
202
+    }
203
+}
204
+
205
+impl AsRef<str> for XcodeToken {
206
+    fn as_ref(&self) -> &str {
207
+        &self.0
208
+    }
209
+}
210
+
211
+impl AppToken for XcodeToken {
212
+    const APP_TOKEN_IDENTIFIER: &'static str = "com.apple.gs.xcode.auth";
213
+}
214
+
198 215
 pub struct XcodeSession<'a, 'b> {
199
-    pub http_session: AuthenticatedHTTPSession<'a, 'b>,
200
-    pub token: Token,
216
+    pub http_session: AppHTTPSession<'a, 'b, XcodeToken>,
201 217
 }
202 218
 
203 219
 impl<'a, 'b> XcodeSession<'a, 'b> {
204
-    pub fn new(http_session: AuthenticatedHTTPSession<'a, 'b>, token: Token) -> Self {
220
+    pub fn new(http_session: AccountHTTPSession<'a, 'b>, token: Token<XcodeToken>) -> Self {
205 221
         Self {
206
-            http_session,
207
-            token,
222
+            http_session: AppHTTPSession::new(http_session, token),
223
+        }
208 224
     }
225
+
226
+    pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder {
227
+        self.http_session.simple_request_builder(method, url)
228
+    }
229
+
230
+    pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
231
+        self.http_session.anisette_request_builder(method, url)
232
+    }
233
+
234
+    pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
235
+        self.http_session.account_request_builder(method, url)
236
+    }
237
+
238
+    pub fn app_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> {
239
+        self.http_session.app_request_builder(method, url)
209 240
     }
210 241
 
211 242
     pub async fn perform_developer_action_base<T: DeveloperActionBase>(
... ...
@@ -232,12 +263,7 @@ impl<'a, 'b> XcodeSession<'a, 'b> {
232 263
         base_request.extend(developer_action.request());
233 264
 
234 265
         let response = self
235
-            .http_session
236
-            .authenticated_request_builder(Method::POST, url.as_str())?
237
-            .header("Content-Type", "text/x-xml-plist")
238
-            .header("Accept", "text/x-xml-plist")
239
-            .header("X-Apple-App-Info", XCODE_TOKEN_IDENTIFIER.0)
240
-            .header("X-Apple-GS-Token", &self.token)
266
+            .app_request_builder(Method::POST, url.as_str())?
241 267
             .header("X-Xcode-Version", "16.4 (16F6)")
242 268
             .query(&[("clientId", CLIENT_ID)])
243 269
             .body(plist_to_body(base_request.into()))
244 270