iclouddrive: fix ADP/PCS cookie acquisition for iCloud Drive

This commit is contained in:
Yakov Till
2026-05-21 21:48:47 +02:00
committed by Nick Craig-Wood
parent 74436281ed
commit 2dbad62a11
7 changed files with 112 additions and 57 deletions

View File

@@ -25,6 +25,13 @@ const (
authEndpoint = "https://idmsa.apple.com/appleauth/auth"
)
// Webservice keys in AccountInfo.Webservices map
const (
WsDrive = "drivews"
WsDocs = "docws"
WsPhotos = "ckdatabasews"
)
type sessionSave func(*Session)
// Client defines the client configuration
@@ -32,6 +39,7 @@ type Client struct {
appleID string
password string
remoteName string // rclone remote name, used for cache namespacing
pcsWSKey string // webservice key for PCS cookie scoping (e.g. WsDrive, WsPhotos)
srv *rest.Client
Session *Session
sessionSaveCallback sessionSave
@@ -41,11 +49,14 @@ type Client struct {
}
// New creates a new iCloud API client and initializes its HTTP session
func New(appleID, password, trustToken string, clientID string, cookies []*http.Cookie, sessionSaveCallback sessionSave, remoteName string) (*Client, error) {
// pcsWSKey scopes PCS cookie acquisition to the caller's webservice (WsDrive, WsPhotos);
// empty string skips PCS entirely
func New(appleID, password, trustToken string, clientID string, cookies []*http.Cookie, sessionSaveCallback sessionSave, remoteName string, pcsWSKey string) (*Client, error) {
icloud := &Client{
appleID: strings.ToLower(appleID), // Apple SRP requires lowercase in client-side proof
password: password,
remoteName: filepath.Base(remoteName),
pcsWSKey: pcsWSKey,
srv: rest.NewClient(fshttp.NewClient(context.Background())),
Session: NewSession(),
sessionSaveCallback: sessionSaveCallback,
@@ -75,8 +86,8 @@ func (c *Client) DriveService() (*DriveService, error) {
func (c *Client) Request(ctx context.Context, opts rest.Opts, request any, response any) (resp *http.Response, err error) {
resp, err = c.Session.Request(ctx, opts, request, response)
if err != nil && resp != nil {
// try to reauth
if resp.StatusCode == 401 || resp.StatusCode == 421 {
// 401/421 = session expired, 423 = missing PCS cookies (ADP)
if resp.StatusCode == 401 || resp.StatusCode == 421 || resp.StatusCode == 423 {
err = c.Authenticate(ctx)
if err != nil {
return nil, err
@@ -95,6 +106,24 @@ func (c *Client) Request(ctx context.Context, opts rest.Opts, request any, respo
func (c *Client) Authenticate(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.authenticateSession(ctx); err != nil {
return err
}
// Ensure PCS cookies after any successful auth path (ADP accounts)
acquired, err := c.Session.ensurePCSCookies(ctx, c.pcsWSKey)
if err != nil {
return err
}
if acquired && c.sessionSaveCallback != nil {
c.sessionSaveCallback(c.Session)
}
return nil
}
// authenticateSession establishes a valid session via the cheapest available path
func (c *Client) authenticateSession(ctx context.Context) error {
// Skip /validate round-trip when saved session has cookies + service endpoints
// Native client behavior: use cached session, reauth lazily on 401/421
if c.Session.Cookies != nil && len(c.Session.AccountInfo.Webservices) > 0 {

View File

@@ -33,7 +33,7 @@ type DriveService struct {
// NewDriveService creates a new DriveService instance.
func NewDriveService(icloud *Client) (*DriveService, error) {
return &DriveService{icloud: icloud, RootID: "FOLDER::com.apple.CloudDocs::root", endpoint: icloud.Session.AccountInfo.Webservices["drivews"].URL, docsEndpoint: icloud.Session.AccountInfo.Webservices["docws"].URL}, nil
return &DriveService{icloud: icloud, RootID: "FOLDER::com.apple.CloudDocs::root", endpoint: icloud.Session.AccountInfo.Webservices[WsDrive].URL, docsEndpoint: icloud.Session.AccountInfo.Webservices[WsDocs].URL}, nil
}
// GetItemByDriveID retrieves a DriveItem by its Drive ID.

View File

@@ -511,7 +511,7 @@ func (album *Album) SetTestPhotoCache(cache map[string]*Photo) {
// NewPhotosService creates a new PhotosService instance
func NewPhotosService(ctx context.Context, client *Client, pacer *fs.Pacer, shouldRetry ShouldRetryFunc) (*PhotosService, error) {
service, exists := client.Session.AccountInfo.Webservices["ckdatabasews"]
service, exists := client.Session.AccountInfo.Webservices[WsPhotos]
if !exists || service.Status != "active" {
return nil, fmt.Errorf("ckdatabasews service not available")
}
@@ -2311,7 +2311,7 @@ func (ps *PhotosService) requestWithReauth(ctx context.Context, makeOpts func()
reauthDone := false
return ps.pacer.Call(func() (bool, error) {
resp, err := ps.client.Session.Request(ctx, makeOpts(), data, response)
if !reauthDone && err != nil && resp != nil && (resp.StatusCode == 401 || resp.StatusCode == 421) {
if !reauthDone && err != nil && resp != nil && (resp.StatusCode == 401 || resp.StatusCode == 421 || resp.StatusCode == 423) {
reauthDone = true
if authErr := ps.client.Authenticate(ctx); authErr != nil {
return false, authErr

View File

@@ -495,7 +495,7 @@ func (s *Session) getSRPAuthHeaders() map[string]string {
return headers
}
// AuthWithToken authenticates the session
// AuthWithToken authenticates the session with the account login endpoint
func (s *Session) AuthWithToken(ctx context.Context) error {
values := map[string]any{
"accountCountryCode": s.AccountCountry,
@@ -522,86 +522,110 @@ func (s *Session) AuthWithToken(ctx context.Context) error {
fs.Debugf(nil, "iclouddrive: accountLogin response cookies: %v", cookieDebugSummaries(resp.Cookies()))
fs.Debugf(nil, "iclouddrive: session cookie jar after accountLogin: %v", cookieJarDebugSummaries(s.Cookies))
// Acquire PCS cookies if Advanced Data Protection is enabled
if ws := s.AccountInfo.Webservices["ckdatabasews"]; ws != nil && ws.PcsRequired {
fs.Debugf(nil, "iclouddrive: ADP detected (pcsRequired=true)")
if s.hasPCSCookies() {
fs.Debugf(nil, "iclouddrive: PCS cookies already present, skipping acquisition")
} else {
if err := s.acquirePCSCookies(ctx); err != nil {
return err
}
}
} else {
fs.Debugf(nil, "iclouddrive: no ADP (pcsRequired=false)")
}
return nil
}
// hasPCSCookies checks if the required PCS cookies for Photos are already present
func (s *Session) hasPCSCookies() bool {
var hasPhotos, hasSharing bool
for _, c := range s.Cookies {
switch c.Name {
case "X-APPLE-WEBAUTH-PCS-Photos":
hasPhotos = true
case "X-APPLE-WEBAUTH-PCS-Sharing":
hasSharing = true
}
}
return hasPhotos && hasSharing
type pcsService struct {
wsKey string
appName string
cookies []string
}
// acquirePCSCookies requests PCS cookies for ADP-enabled accounts
// 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) acquirePCSCookies(ctx context.Context) error {
fs.Logf(nil, "iclouddrive: Advanced Data Protection enabled, requesting PCS cookies")
const maxAttempts = 30 // 30 * 10s = 5 minutes max
for attempt := 0; attempt < maxAttempts; attempt++ {
fs.Debugf(nil, "iclouddrive: requestPCS outgoing cookies: %v", cookieJarDebugSummaries(s.Cookies))
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": "photos",
"appName": appName,
"derivedFromUserAction": true,
}
body, err := IntoReader(values)
if err != nil {
return fmt.Errorf("requestPCS: %w", err)
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,
}
opts.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: %w", err)
return fmt.Errorf("requestPCS(%s): %w", appName, err)
}
fs.Debugf(nil, "iclouddrive: requestPCS response cookies: %v", cookieDebugSummaries(resp.Cookies()))
fs.Debugf(nil, "iclouddrive: requestPCS response: status=%q message=%q cookies=%d",
pcsResp.Status, pcsResp.Message, len(resp.Cookies()))
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.hasPCSCookies() {
return fmt.Errorf("requestPCS: server returned success but PCS cookies missing")
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")
fs.Logf(nil, "iclouddrive: PCS cookies acquired for %s", appName)
return nil
}
// Device consent required - poll until approved
fs.Logf(nil, "iclouddrive: waiting for device approval for PCS (%s)", pcsResp.Message)
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: timed out waiting for device approval after 5 minutes")
return fmt.Errorf("requestPCS(%s): timed out waiting for device approval after 5 minutes", appName)
}
// RequestPushNotification explicitly requests a push notification to trusted devices

View File

@@ -96,7 +96,7 @@ func resumeConfigClient(m configmap.Mapper, appleid, password, trustToken, clien
if err != nil {
return nil, nil, err
}
icloud, err := api.New(appleid, password, trustToken, clientID, cookies, nil, "_config")
icloud, err := api.New(appleid, password, trustToken, clientID, cookies, nil, "_config", "")
if err != nil {
return nil, nil, err
}
@@ -280,7 +280,7 @@ func Config(ctx context.Context, name string, m configmap.Mapper, config fs.Conf
// Force fresh SRP authentication - ignore stale trust token and cookies
// so that reconnect always prompts for 2FA
m.Set(configAuthSession, "")
icloud, err := api.New(appleid, password, "", clientID, nil, nil, "_config")
icloud, err := api.New(appleid, password, "", clientID, nil, nil, "_config", "")
if err != nil {
return nil, err
}
@@ -410,7 +410,8 @@ func Config(ctx context.Context, name string, m configmap.Mapper, config fs.Conf
// newICloudClient parses options, authenticates, and returns a ready client
// Shared between NewFs (Drive) and NewFsPhotos (Photos) to avoid duplication
func newICloudClient(ctx context.Context, name string, m configmap.Mapper) (*api.Client, *Options, error) {
// pcsWSKey scopes PCS cookie acquisition to the caller's service
func newICloudClient(ctx context.Context, name string, m configmap.Mapper, pcsWSKey string) (*api.Client, *Options, error) {
opt := new(Options)
err := configstruct.Set(m, opt)
if err != nil {
@@ -442,6 +443,7 @@ func newICloudClient(ctx context.Context, name string, m configmap.Mapper) (*api
cookies,
callback,
name,
pcsWSKey,
)
if err != nil {
return nil, nil, err

View File

@@ -677,7 +677,7 @@ func retryResultUnknown(ctx context.Context, resp *http.Response, err error) (bo
// NewFs constructs an Fs from the path, container:path
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
icloud, opt, err := newICloudClient(ctx, name, m)
icloud, opt, err := newICloudClient(ctx, name, m, api.WsDrive)
if err != nil {
return nil, err
}

View File

@@ -59,7 +59,7 @@ type PhotosObject struct {
// NewFsPhotos constructs an Fs for Photos from the path, container:path
func NewFsPhotos(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
icloud, opt, err := newICloudClient(ctx, name, m)
icloud, opt, err := newICloudClient(ctx, name, m, api.WsPhotos)
if err != nil {
return nil, err
}