/
githubmirror
/
rclone
Обзор
Документация
Войти
/
githubmirror
/
rclone
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
backend/iclouddrive/api/session.go
943 строки
28 KB
Punya Jain
iclouddrive: fix 2FA failing with 409 even when the code is valid
29 июл 2026, 22:25
Не верифицирован
29 июл 2026, 22:25
731f2a6
Код
Авторство
О чём код?
package api import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "maps" "net/http" "net/url" "slices" "strings" "sync" "time" "github.com/google/uuid" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/fshttp" "github.com/rclone/rclone/lib/rest" ) const iCloudUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15" // Session represents an iCloud session type Session struct { SessionToken string `json:"session_token"` Scnt string `json:"scnt"` SessionID string `json:"session_id"` AccountCountry string `json:"account_country"` TrustToken string `json:"trust_token"` ClientID string `json:"client_id"` AuthAttributes string `json:"auth_attributes"` FrameID string `json:"frame_id"` Cookies []*http.Cookie `json:"cookies"` AccountInfo AccountInfo `json:"account_info"` mu sync.Mutex `json:"-"` // protects session fields during concurrent Request calls srv *rest.Client `json:"-"` needs2FA bool `json:"-"` // set when SRP signin returns 409 } // srpInitResponse is the server response from /auth/signin/init type srpInitResponse struct { Iteration int `json:"iteration"` Salt string `json:"salt"` Protocol string `json:"protocol"` B string `json:"b"` C string `json:"c"` } func cookieValueFingerprint(value string) string { if value == "" { return "" } sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:6]) } func authStateBodySummary(body []byte) string { summary := []string{fmt.Sprintf("bytes=%d", len(body)), "hash=" + cookieValueFingerprint(string(body))} var top map[string]json.RawMessage if err := json.Unmarshal(body, &top); err != nil { return strings.Join(summary, " ") } keys := make([]string, 0, len(top)) for key := range top { keys = append(keys, key) } slices.Sort(keys) summary = append(summary, fmt.Sprintf("keys=%v", keys)) if _, ok := top["phoneNumberVerification"]; ok { summary = append(summary, "wrapped=true") } return strings.Join(summary, " ") } func cookieDebugSummary(c *http.Cookie) string { parts := []string{c.Name} if c.Value == "" { parts = append(parts, "empty", "len=0") } else { parts = append(parts, "set", fmt.Sprintf("len=%d", len(c.Value)), "hash="+cookieValueFingerprint(c.Value)) } if c.Path != "" { parts = append(parts, "path="+c.Path) } if c.Domain != "" { parts = append(parts, "domain="+c.Domain) } if c.MaxAge != 0 { parts = append(parts, fmt.Sprintf("maxAge=%d", c.MaxAge)) } if !c.Expires.IsZero() { parts = append(parts, "expires") } return strings.Join(parts, ",") } func cookieDebugSummaries(cookies []*http.Cookie) []string { if len(cookies) == 0 { return nil } out := make([]string, 0, len(cookies)) for _, c := range cookies { out = append(out, cookieDebugSummary(c)) } return out } func cookieJarDebugSummaries(cookies []*http.Cookie) []string { if len(cookies) == 0 { return nil } out := make([]string, 0, len(cookies)) for _, c := range cookies { summary := c.Name if c.Value == "" { summary += ",empty,len=0" } else { summary += fmt.Sprintf(",len=%d,hash=%s", len(c.Value), cookieValueFingerprint(c.Value)) } out = append(out, summary) } return out } func (s *Session) mergeCookies(cookies []*http.Cookie) { if len(cookies) == 0 { return } existing := make(map[string]int, len(s.Cookies)) for i, c := range s.Cookies { existing[c.Name] = i } for _, c := range cookies { if c.Value == "" { if idx, ok := existing[c.Name]; ok { fs.Debugf(nil, "iclouddrive: deleting empty auth cookie: %s", cookieDebugSummary(c)) s.Cookies = append(s.Cookies[:idx], s.Cookies[idx+1:]...) delete(existing, c.Name) for j := idx; j < len(s.Cookies); j++ { existing[s.Cookies[j].Name] = j } } else { fs.Debugf(nil, "iclouddrive: ignoring empty auth cookie tombstone for missing cookie: %s", cookieDebugSummary(c)) } continue } if idx, ok := existing[c.Name]; ok { s.Cookies[idx] = c } else { s.Cookies = append(s.Cookies, c) existing[c.Name] = len(s.Cookies) - 1 } } } // extractHeaders reads Apple session headers from an HTTP response into the session // Does NOT acquire s.mu - caller is responsible for locking if needed func (s *Session) extractHeaders(resp *http.Response) { s.mergeCookies(resp.Cookies()) if val := resp.Header.Get("X-Apple-ID-Account-Country"); val != "" { s.AccountCountry = val } if val := resp.Header.Get("X-Apple-ID-Session-Id"); val != "" { s.SessionID = val } if val := resp.Header.Get("X-Apple-Session-Token"); val != "" { s.SessionToken = val } if val := resp.Header.Get("X-Apple-TwoSV-Trust-Token"); val != "" { s.TrustToken = val } if val := resp.Header.Get("scnt"); val != "" { s.Scnt = val } if val := resp.Header.Get("X-Apple-Auth-Attributes"); val != "" { s.AuthAttributes = val } } // Request makes a JSON API request and extracts session headers func (s *Session) Request(ctx context.Context, opts rest.Opts, request any, response any) (*http.Response, error) { resp, err := s.srv.CallJSON(ctx, &opts, &request, &response) if err != nil { return resp, err } s.mu.Lock() s.extractHeaders(resp) s.mu.Unlock() return resp, nil } // acceptedDespiteConflict returns true when Apple signals a successful code // validation with HTTP 409. Since ~mid-2026, idmsa returns 409 on the // securitycode endpoints even when the code is accepted (body carries // securityCode.valid=true), but it still issues X-Apple-Session-Token on // success. Treat token issuance as ground truth and absorb the headers so // TrustSession can proceed. func (s *Session) acceptedDespiteConflict(resp *http.Response) bool { if resp == nil || resp.StatusCode != 409 || resp.Header.Get("X-Apple-Session-Token") == "" { return false } s.mu.Lock() s.extractHeaders(resp) s.mu.Unlock() return true } // Requires2FA returns true if the session requires 2FA func (s *Session) Requires2FA() bool { if s.needs2FA { return true } return s.AccountInfo.DsInfo != nil && s.AccountInfo.DsInfo.HsaVersion == 2 && s.AccountInfo.HsaChallengeRequired } // SignIn performs SRP-based authentication against Apple's idmsa endpoint func (s *Session) SignIn(ctx context.Context, appleID, password string) error { // Step 1: Initialize the auth session if err := s.authStart(ctx); err != nil { return fmt.Errorf("authStart: %w", err) } // Step 2: Federate (submit account name) if err := s.authFederate(ctx, appleID); err != nil { return fmt.Errorf("authFederate: %w", err) } // Step 3: SRP init - send client public value A, get salt + B client, err := newSRPClient() if err != nil { return fmt.Errorf("newSRPClient: %w", err) } aBase64 := base64.StdEncoding.EncodeToString(client.getABytes()) initResp, err := s.authSRPInit(ctx, aBase64, appleID) if err != nil { return fmt.Errorf("authSRPInit: %w", err) } // Decode server values serverB, err := base64.StdEncoding.DecodeString(initResp.B) if err != nil { return fmt.Errorf("decode B: %w", err) } salt, err := base64.StdEncoding.DecodeString(initResp.Salt) if err != nil { return fmt.Errorf("decode salt: %w", err) } // Step 4: Derive password key and process the SRP challenge derivedKey, err := derivePassword(password, salt, initResp.Iteration, initResp.Protocol) if err != nil { return fmt.Errorf("derivePassword: %w", err) } if err := client.processChallenge([]byte(appleID), derivedKey, salt, serverB); err != nil { return fmt.Errorf("processChallenge: %w", err) } // Step 5: Complete - send M1, M2 proofs m1Base64 := base64.StdEncoding.EncodeToString(client.M1) m2Base64 := base64.StdEncoding.EncodeToString(client.M2) if err := s.authSRPComplete(ctx, appleID, m1Base64, m2Base64, initResp.C); err != nil { return fmt.Errorf("authSRPComplete: %w", err) } return nil } // authStart initializes the SRP auth session by hitting the authorize/signin endpoint func (s *Session) authStart(ctx context.Context) error { if s.FrameID == "" { s.FrameID = strings.ToLower(uuid.New().String()) } frameTag := "auth-" + s.FrameID params := url.Values{} params.Set("frame_id", frameTag) params.Set("language", "en_US") params.Set("skVersion", "7") params.Set("iframeId", frameTag) params.Set("client_id", s.ClientID) params.Set("redirect_uri", "https://www.icloud.com") params.Set("response_type", "code") params.Set("response_mode", "web_message") params.Set("state", frameTag) params.Set("authVersion", "latest") opts := rest.Opts{ Method: "GET", Path: "/authorize/signin", Parameters: params, ExtraHeaders: map[string]string{ "Accept": "*/*", "User-Agent": iCloudUserAgent, }, RootURL: authEndpoint, NoResponse: true, } resp, err := s.srv.Call(ctx, &opts) if err != nil { return err } _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("authStart: unexpected status %s", resp.Status) } s.extractHeaders(resp) return nil } // authFederate submits the account name to Apple's federate endpoint func (s *Session) authFederate(ctx context.Context, accountName string) error { values := map[string]any{ "accountName": accountName, "rememberMe": true, } body, err := IntoReader(values) if err != nil { return err } opts := rest.Opts{ Method: "POST", Path: "/federate", Parameters: url.Values{"isRememberMeEnabled": {"true"}}, ExtraHeaders: s.getSRPAuthHeaders(), RootURL: authEndpoint, Body: body, NoResponse: true, } resp, err := s.srv.Call(ctx, &opts) if err != nil { return err } _ = resp.Body.Close() s.extractHeaders(resp) if resp.StatusCode != http.StatusOK { return fmt.Errorf("authFederate: unexpected status %s", resp.Status) } return nil } // authSRPInit sends the client's public value A to the server and retrieves // the salt, server public value B, iteration count, protocol, and challenge func (s *Session) authSRPInit(ctx context.Context, aBase64, accountName string) (*srpInitResponse, error) { values := map[string]any{ "a": aBase64, "accountName": accountName, "protocols": []string{"s2k", "s2k_fo"}, } body, err := IntoReader(values) if err != nil { return nil, err } opts := rest.Opts{ Method: "POST", Path: "/signin/init", ExtraHeaders: s.getSRPAuthHeaders(), RootURL: authEndpoint, Body: body, } var initResp srpInitResponse resp, err := s.srv.CallJSON(ctx, &opts, nil, &initResp) if err != nil { return nil, err } s.extractHeaders(resp) return &initResp, nil } // authSRPComplete sends the SRP proofs M1 and M2 to complete authentication // Returns nil on success (200 or 409/2FA needed) func (s *Session) authSRPComplete(ctx context.Context, accountName, m1Base64, m2Base64, c string) error { trustTokens := []string{} if s.TrustToken != "" { trustTokens = []string{s.TrustToken} } values := map[string]any{ "accountName": accountName, "m1": m1Base64, "m2": m2Base64, "c": c, "rememberMe": true, "trustTokens": trustTokens, } body, err := IntoReader(values) if err != nil { return err } opts := rest.Opts{ Method: "POST", Path: "/signin/complete", Parameters: url.Values{"isRememberMeEnabled": {"true"}}, ExtraHeaders: s.getSRPAuthHeaders(), RootURL: authEndpoint, IgnoreStatus: true, Body: body, } resp, err := s.srv.Call(ctx, &opts) if err != nil { return err } respBody, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() s.extractHeaders(resp) switch resp.StatusCode { case http.StatusOK: fs.Debugf(nil, "iclouddrive: SRP sign in successful") return nil case http.StatusConflict: // 409 = 2FA required fs.Debugf(nil, "iclouddrive: SRP sign in requires 2FA, response: %s", respBody) s.needs2FA = true return nil case http.StatusPreconditionFailed: // 412 = non-2FA account needs repair/complete step fs.Debugf(nil, "iclouddrive: SRP sign in returned 412, attempting repair/complete") return s.authRepairComplete(ctx) case http.StatusForbidden: return fmt.Errorf("sign in failed: incorrect username or password") default: return fmt.Errorf("sign in failed: %s: %s", resp.Status, respBody) } } // authRepairComplete handles the repair flow for non-2FA accounts that return 412 func (s *Session) authRepairComplete(ctx context.Context) error { body, err := IntoReader(map[string]any{}) if err != nil { return err } opts := rest.Opts{ Method: "POST", Path: "/repair/complete", ExtraHeaders: s.getSRPAuthHeaders(), RootURL: authEndpoint, IgnoreStatus: true, NoResponse: true, Body: body, } resp, err := s.srv.Call(ctx, &opts) if err != nil { return fmt.Errorf("repair/complete failed: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("repair/complete returned %s", resp.Status) } s.extractHeaders(resp) fs.Debugf(nil, "iclouddrive: repair/complete successful") return nil } // getAuthOrigin returns the origin URL for auth requests // Supports both global (idmsa.apple.com) and China (idmsa.apple.com.cn) endpoints func getAuthOrigin() string { return strings.TrimSuffix(authEndpoint, "/appleauth/auth") } // getSRPAuthHeaders returns headers needed for SRP auth requests func (s *Session) getSRPAuthHeaders() map[string]string { frameTag := "auth-" + s.FrameID authOrigin := getAuthOrigin() headers := map[string]string{ "Accept": "application/json", "Content-Type": "application/json", "User-Agent": iCloudUserAgent, "Origin": authOrigin, "Referer": authOrigin + "/", "X-Apple-Widget-Key": s.ClientID, "X-Apple-OAuth-Client-Id": s.ClientID, "X-Apple-OAuth-Client-Type": "firstPartyAuth", "X-Apple-OAuth-Redirect-URI": "https://www.icloud.com", "X-Apple-OAuth-Require-Grant-Code": "true", "X-Apple-OAuth-Response-Mode": "web_message", "X-Apple-OAuth-Response-Type": "code", "X-Apple-OAuth-State": frameTag, "X-Apple-Frame-Id": frameTag, "X-Requested-With": "XMLHttpRequest", "X-Apple-Mandate-Security-Upgrade": "0", "X-Apple-I-Require-UE": "true", "X-Apple-I-FD-Client-Info": `{"U":"` + iCloudUserAgent + `","L":"en-US","Z":"GMT-05:00","V":"1.1","F":""}`, } if s.AuthAttributes != "" { headers["X-Apple-Auth-Attributes"] = s.AuthAttributes } if s.Scnt != "" { headers["scnt"] = s.Scnt } if s.SessionID != "" { headers["X-Apple-ID-Session-Id"] = s.SessionID } return headers } // AuthWithToken authenticates the session with the account login endpoint func (s *Session) AuthWithToken(ctx context.Context) error { values := map[string]any{ "accountCountryCode": s.AccountCountry, "dsWebAuthToken": s.SessionToken, "extended_login": true, "trustToken": s.TrustToken, } body, err := IntoReader(values) if err != nil { return err } opts := rest.Opts{ Method: "POST", Path: "/accountLogin", ExtraHeaders: GetCommonHeaders(map[string]string{}), RootURL: setupEndpoint, Body: body, } resp, err := s.Request(ctx, opts, nil, &s.AccountInfo) if err != nil { return err } fs.Debugf(nil, "iclouddrive: accountLogin response cookies: %v", cookieDebugSummaries(resp.Cookies())) fs.Debugf(nil, "iclouddrive: session cookie jar after accountLogin: %v", cookieJarDebugSummaries(s.Cookies)) return nil } type pcsService struct { wsKey string appName string cookies []string } // pcsServices lists all services that need PCS cookies // appName values from icloud.com JS (Build 2616/19) var pcsServices = []pcsService{ {WsPhotos, "photos", []string{"X-APPLE-WEBAUTH-PCS-Photos", "X-APPLE-WEBAUTH-PCS-Sharing"}}, {WsDrive, "iclouddrive", []string{"X-APPLE-WEBAUTH-PCS-Documents"}}, } func (s *Session) hasPCSCookiesFor(names []string) bool { for _, name := range names { if !slices.ContainsFunc(s.Cookies, func(c *http.Cookie) bool { return c.Name == name }) { return false } } return true } // ensurePCSCookies checks whether the session needs PCS cookies for the given // webservice and acquires them if missing, returning true if new cookies were acquired func (s *Session) ensurePCSCookies(ctx context.Context, pcsWSKey string) (bool, error) { if pcsWSKey == "" { return false, nil } for _, pcs := range pcsServices { if pcs.wsKey != pcsWSKey { continue } ws := s.AccountInfo.Webservices[pcs.wsKey] if ws == nil || !ws.PcsRequired { return false, nil } if s.hasPCSCookiesFor(pcs.cookies) { return false, nil } fs.Debugf(nil, "iclouddrive: ADP detected, acquiring PCS cookies for %s", pcs.appName) if err := s.acquirePCSCookiesFor(ctx, pcs.appName, pcs.cookies); err != nil { return false, err } return true, nil } return false, nil } // acquirePCSCookiesFor requests PCS cookies for a specific service on ADP-enabled accounts // May require user approval on a trusted device (polls every 10s, max 5 min) func (s *Session) acquirePCSCookiesFor(ctx context.Context, appName string, cookies []string) error { fs.Logf(nil, "iclouddrive: Advanced Data Protection enabled, requesting PCS cookies for %s", appName) const maxAttempts = 30 for range maxAttempts { fs.Debugf(nil, "iclouddrive: requestPCS(%s) outgoing cookies: %v", appName, cookieJarDebugSummaries(s.Cookies)) values := map[string]any{ "appName": appName, "derivedFromUserAction": true, } body, err := IntoReader(values) if err != nil { return fmt.Errorf("requestPCS(%s): %w", appName, err) } opts := rest.Opts{ Method: "POST", Path: "/requestPCS", ExtraHeaders: s.GetHeaders(map[string]string{}), RootURL: setupEndpoint, Body: body, } var pcsResp struct { Status string `json:"status"` Message string `json:"message"` } resp, err := s.Request(ctx, opts, nil, &pcsResp) if err != nil { return fmt.Errorf("requestPCS(%s): %w", appName, err) } fs.Debugf(nil, "iclouddrive: requestPCS(%s) response: status=%q message=%q cookies=%d %v", appName, pcsResp.Status, pcsResp.Message, len(resp.Cookies()), cookieDebugSummaries(resp.Cookies())) if pcsResp.Status == "success" { if !s.hasPCSCookiesFor(cookies) { var missing []string for _, name := range cookies { if !slices.ContainsFunc(s.Cookies, func(c *http.Cookie) bool { return c.Name == name }) { missing = append(missing, name) } } return fmt.Errorf("requestPCS(%s): server returned success but cookies still missing: %v", appName, missing) } fs.Logf(nil, "iclouddrive: PCS cookies acquired for %s", appName) return nil } fs.Logf(nil, "iclouddrive: waiting for device approval for PCS/%s (%s)", appName, pcsResp.Message) select { case <-ctx.Done(): return ctx.Err() case <-time.After(10 * time.Second): } } return fmt.Errorf("requestPCS(%s): timed out waiting for device approval after 5 minutes", appName) } // RequestPushNotification explicitly requests a push notification to trusted devices // Required for iOS 26.4+ where the SRP 409 response no longer auto-pushes func (s *Session) RequestPushNotification(ctx context.Context) error { opts := rest.Opts{ Method: "PUT", Path: "/verify/trusteddevice/securitycode", ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, NoResponse: true, } _, err := s.Request(ctx, opts, nil, nil) return err } // Validate2FACode validates the 2FA code from a trusted device push notification func (s *Session) Validate2FACode(ctx context.Context, code string) error { values := map[string]any{"securityCode": map[string]string{"code": code}} body, err := IntoReader(values) if err != nil { return err } opts := rest.Opts{ Method: "POST", Path: "/verify/trusteddevice/securitycode", ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, Body: body, NoResponse: true, } resp, err := s.Request(ctx, opts, nil, nil) if err != nil && s.acceptedDespiteConflict(resp) { err = nil } if err == nil { if err := s.TrustSession(ctx); err != nil { return err } return nil } return fmt.Errorf("validate2FACode failed: %w", err) } // TrustedPhoneNumber represents a phone number that can receive SMS verification codes type TrustedPhoneNumber struct { ID int `json:"id"` NumberWithDialCode string `json:"numberWithDialCode"` ObfuscatedNumber string `json:"obfuscatedNumber"` PushMode string `json:"pushMode"` NonFTEU bool `json:"nonFTEU"` } // AuthStateResponse is the response from GET /appleauth/auth after sign-in // Some accounts return fields at top level, others nest them under phoneNumberVerification type AuthStateResponse struct { TrustedPhoneNumbers []TrustedPhoneNumber `json:"trustedPhoneNumbers"` TrustedPhoneNumber *TrustedPhoneNumber `json:"trustedPhoneNumber"` NoTrustedDevices bool `json:"noTrustedDevices"` AuthenticationType string `json:"authenticationType"` Hsa2Account bool `json:"hsa2Account"` PhoneNumberVerification *AuthStateResponse `json:"phoneNumberVerification"` } // GetAuthState retrieves the current auth state including trusted phone numbers for SMS 2FA func (s *Session) GetAuthState(ctx context.Context) (*AuthStateResponse, error) { opts := rest.Opts{ Method: "GET", Path: "", ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, ContentLength: int64Ptr(0), } // Use srv.Call directly to capture the raw response body for debugging resp, err := s.srv.Call(ctx, &opts) if err != nil { return nil, fmt.Errorf("getAuthState: %w", err) } s.mu.Lock() s.extractHeaders(resp) s.mu.Unlock() body, readErr := io.ReadAll(resp.Body) _ = resp.Body.Close() if readErr != nil { return nil, fmt.Errorf("getAuthState read: %w", readErr) } fs.Debugf(nil, "iclouddrive: auth state response summary: %s", authStateBodySummary(body)) var state AuthStateResponse if err := json.Unmarshal(body, &state); err != nil { fs.Debugf(nil, "iclouddrive: auth state parse failed: %s", authStateBodySummary(body)) return nil, fmt.Errorf("getAuthState unmarshal: %w", err) } // Some accounts nest auth data under phoneNumberVerification if state.PhoneNumberVerification != nil && state.AuthenticationType == "" { fs.Debugf(nil, "iclouddrive: unwrapping phoneNumberVerification envelope") n := state.PhoneNumberVerification state.TrustedPhoneNumbers = n.TrustedPhoneNumbers state.TrustedPhoneNumber = n.TrustedPhoneNumber state.NoTrustedDevices = n.NoTrustedDevices state.AuthenticationType = n.AuthenticationType state.Hsa2Account = n.Hsa2Account state.PhoneNumberVerification = nil } fs.Debugf(nil, "iclouddrive: auth state: type=%s hsa2=%v noTrustedDevices=%v phones=%d phoneSingular=%v", state.AuthenticationType, state.Hsa2Account, state.NoTrustedDevices, len(state.TrustedPhoneNumbers), state.TrustedPhoneNumber != nil) // Fall back to singular trustedPhoneNumber when plural array is empty // Some accounts return only the singular form (SMS-only, no trusted devices) if len(state.TrustedPhoneNumbers) == 0 && state.TrustedPhoneNumber != nil { fs.Debugf(nil, "iclouddrive: using singular trustedPhoneNumber (id=%d) as fallback", state.TrustedPhoneNumber.ID) state.TrustedPhoneNumbers = []TrustedPhoneNumber{*state.TrustedPhoneNumber} } return &state, nil } // RequestSMSCode triggers SMS code delivery to a trusted phone number func (s *Session) RequestSMSCode(ctx context.Context, phoneID int, mode string) error { values := map[string]any{ "phoneNumber": map[string]any{"id": phoneID}, "mode": mode, } body, err := IntoReader(values) if err != nil { return err } opts := rest.Opts{ Method: "PUT", Path: "/verify/phone", ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, Body: body, NoResponse: true, } _, err = s.Request(ctx, opts, nil, nil) if err != nil { return fmt.Errorf("requestSMSCode: %w", err) } return nil } // ValidateSMSCode validates a 2FA code received via SMS func (s *Session) ValidateSMSCode(ctx context.Context, code string, phoneID int, mode string) error { values := map[string]any{ "securityCode": map[string]string{"code": code}, "phoneNumber": map[string]any{"id": phoneID}, "mode": mode, } body, err := IntoReader(values) if err != nil { return err } opts := rest.Opts{ Method: "POST", Path: "/verify/phone/securitycode", ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, Body: body, NoResponse: true, } resp, err := s.Request(ctx, opts, nil, nil) if err != nil && s.acceptedDespiteConflict(resp) { err = nil } if err == nil { if err := s.TrustSession(ctx); err != nil { return err } return nil } return fmt.Errorf("validateSMSCode: %w", err) } // TrustSession trusts the session func (s *Session) TrustSession(ctx context.Context) error { opts := rest.Opts{ Method: "GET", Path: "/2sv/trust", ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, NoResponse: true, ContentLength: int64Ptr(0), } _, err := s.Request(ctx, opts, nil, nil) if err != nil { return fmt.Errorf("trustSession failed: %w", err) } return s.AuthWithToken(ctx) } // ValidateSession validates the session func (s *Session) ValidateSession(ctx context.Context) error { opts := rest.Opts{ Method: "POST", Path: "/validate", ExtraHeaders: s.GetHeaders(map[string]string{}), RootURL: setupEndpoint, ContentLength: int64Ptr(0), } _, err := s.Request(ctx, opts, nil, &s.AccountInfo) if err != nil { return fmt.Errorf("validateSession failed: %w", err) } s.needs2FA = false return nil } // GetAuthHeaders returns the authentication headers for the session // Used for 2FA validation and trust requests to idmsa.apple.com func (s *Session) GetAuthHeaders(overwrite map[string]string) map[string]string { headers := s.getSRPAuthHeaders() maps.Copy(headers, overwrite) return headers } // GetHeaders returns the authentication headers required for a request func (s *Session) GetHeaders(overwrite map[string]string) map[string]string { headers := GetCommonHeaders(map[string]string{}) headers["Cookie"] = s.GetCookieString() maps.Copy(headers, overwrite) return headers } // GetCookieString returns the cookie header string for the session func (s *Session) GetCookieString() string { var b strings.Builder first := true for _, cookie := range s.Cookies { if cookie.Value == "" { continue } if !first { b.WriteString("; ") } first = false b.WriteString(cookie.Name) b.WriteByte('=') b.WriteString(cookie.Value) } return b.String() } // GetCommonHeaders generates common HTTP headers with optional overwrite func GetCommonHeaders(overwrite map[string]string) map[string]string { headers := map[string]string{ "Content-Type": "application/json", "Origin": baseEndpoint, "Referer": fmt.Sprintf("%s/", baseEndpoint), "User-Agent": iCloudUserAgent, } maps.Copy(headers, overwrite) return headers } func int64Ptr(v int64) *int64 { return &v } // NewSession creates a new Session instance with default values func NewSession() *Session { session := &Session{ FrameID: strings.ToLower(uuid.New().String()), } httpClient := fshttp.NewClient(context.Background()) if tr, ok := httpClient.Transport.(*fshttp.Transport); ok { tr.SetRequestFilter(func(req *http.Request) { req.Header.Set("User-Agent", iCloudUserAgent) }) } session.srv = rest.NewClient(httpClient).SetRoot(baseEndpoint) return session } // AccountInfo represents the subset of Apple's account response we actually use // json.Unmarshal silently ignores extra fields in the response type AccountInfo struct { DsInfo *dsInfo `json:"dsInfo"` Webservices map[string]*webService `json:"webservices"` HsaChallengeRequired bool `json:"hsaChallengeRequired"` } // dsInfo holds the account metadata fields we read type dsInfo struct { HsaVersion int `json:"hsaVersion"` } // webService represents a web service type webService struct { PcsRequired bool `json:"pcsRequired"` URL string `json:"url"` UploadURL string `json:"uploadUrl"` Status string `json:"status"` }