iclouddrive: fix ADP/PCS cookie acquisition for iCloud Drive
This commit is contained in:
committed by
Nick Craig-Wood
parent
74436281ed
commit
2dbad62a11
@@ -25,6 +25,13 @@ const (
|
|||||||
authEndpoint = "https://idmsa.apple.com/appleauth/auth"
|
authEndpoint = "https://idmsa.apple.com/appleauth/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Webservice keys in AccountInfo.Webservices map
|
||||||
|
const (
|
||||||
|
WsDrive = "drivews"
|
||||||
|
WsDocs = "docws"
|
||||||
|
WsPhotos = "ckdatabasews"
|
||||||
|
)
|
||||||
|
|
||||||
type sessionSave func(*Session)
|
type sessionSave func(*Session)
|
||||||
|
|
||||||
// Client defines the client configuration
|
// Client defines the client configuration
|
||||||
@@ -32,6 +39,7 @@ type Client struct {
|
|||||||
appleID string
|
appleID string
|
||||||
password string
|
password string
|
||||||
remoteName string // rclone remote name, used for cache namespacing
|
remoteName string // rclone remote name, used for cache namespacing
|
||||||
|
pcsWSKey string // webservice key for PCS cookie scoping (e.g. WsDrive, WsPhotos)
|
||||||
srv *rest.Client
|
srv *rest.Client
|
||||||
Session *Session
|
Session *Session
|
||||||
sessionSaveCallback sessionSave
|
sessionSaveCallback sessionSave
|
||||||
@@ -41,11 +49,14 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new iCloud API client and initializes its HTTP session
|
// 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{
|
icloud := &Client{
|
||||||
appleID: strings.ToLower(appleID), // Apple SRP requires lowercase in client-side proof
|
appleID: strings.ToLower(appleID), // Apple SRP requires lowercase in client-side proof
|
||||||
password: password,
|
password: password,
|
||||||
remoteName: filepath.Base(remoteName),
|
remoteName: filepath.Base(remoteName),
|
||||||
|
pcsWSKey: pcsWSKey,
|
||||||
srv: rest.NewClient(fshttp.NewClient(context.Background())),
|
srv: rest.NewClient(fshttp.NewClient(context.Background())),
|
||||||
Session: NewSession(),
|
Session: NewSession(),
|
||||||
sessionSaveCallback: sessionSaveCallback,
|
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) {
|
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)
|
resp, err = c.Session.Request(ctx, opts, request, response)
|
||||||
if err != nil && resp != nil {
|
if err != nil && resp != nil {
|
||||||
// try to reauth
|
// 401/421 = session expired, 423 = missing PCS cookies (ADP)
|
||||||
if resp.StatusCode == 401 || resp.StatusCode == 421 {
|
if resp.StatusCode == 401 || resp.StatusCode == 421 || resp.StatusCode == 423 {
|
||||||
err = c.Authenticate(ctx)
|
err = c.Authenticate(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
func (c *Client) Authenticate(ctx context.Context) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
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
|
// Skip /validate round-trip when saved session has cookies + service endpoints
|
||||||
// Native client behavior: use cached session, reauth lazily on 401/421
|
// Native client behavior: use cached session, reauth lazily on 401/421
|
||||||
if c.Session.Cookies != nil && len(c.Session.AccountInfo.Webservices) > 0 {
|
if c.Session.Cookies != nil && len(c.Session.AccountInfo.Webservices) > 0 {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type DriveService struct {
|
|||||||
|
|
||||||
// NewDriveService creates a new DriveService instance.
|
// NewDriveService creates a new DriveService instance.
|
||||||
func NewDriveService(icloud *Client) (*DriveService, error) {
|
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.
|
// GetItemByDriveID retrieves a DriveItem by its Drive ID.
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ func (album *Album) SetTestPhotoCache(cache map[string]*Photo) {
|
|||||||
|
|
||||||
// NewPhotosService creates a new PhotosService instance
|
// NewPhotosService creates a new PhotosService instance
|
||||||
func NewPhotosService(ctx context.Context, client *Client, pacer *fs.Pacer, shouldRetry ShouldRetryFunc) (*PhotosService, error) {
|
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" {
|
if !exists || service.Status != "active" {
|
||||||
return nil, fmt.Errorf("ckdatabasews service not available")
|
return nil, fmt.Errorf("ckdatabasews service not available")
|
||||||
}
|
}
|
||||||
@@ -2311,7 +2311,7 @@ func (ps *PhotosService) requestWithReauth(ctx context.Context, makeOpts func()
|
|||||||
reauthDone := false
|
reauthDone := false
|
||||||
return ps.pacer.Call(func() (bool, error) {
|
return ps.pacer.Call(func() (bool, error) {
|
||||||
resp, err := ps.client.Session.Request(ctx, makeOpts(), data, response)
|
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
|
reauthDone = true
|
||||||
if authErr := ps.client.Authenticate(ctx); authErr != nil {
|
if authErr := ps.client.Authenticate(ctx); authErr != nil {
|
||||||
return false, authErr
|
return false, authErr
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ func (s *Session) getSRPAuthHeaders() map[string]string {
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthWithToken authenticates the session
|
// AuthWithToken authenticates the session with the account login endpoint
|
||||||
func (s *Session) AuthWithToken(ctx context.Context) error {
|
func (s *Session) AuthWithToken(ctx context.Context) error {
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
"accountCountryCode": s.AccountCountry,
|
"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: accountLogin response cookies: %v", cookieDebugSummaries(resp.Cookies()))
|
||||||
fs.Debugf(nil, "iclouddrive: session cookie jar after accountLogin: %v", cookieJarDebugSummaries(s.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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasPCSCookies checks if the required PCS cookies for Photos are already present
|
type pcsService struct {
|
||||||
func (s *Session) hasPCSCookies() bool {
|
wsKey string
|
||||||
var hasPhotos, hasSharing bool
|
appName string
|
||||||
for _, c := range s.Cookies {
|
cookies []string
|
||||||
switch c.Name {
|
|
||||||
case "X-APPLE-WEBAUTH-PCS-Photos":
|
|
||||||
hasPhotos = true
|
|
||||||
case "X-APPLE-WEBAUTH-PCS-Sharing":
|
|
||||||
hasSharing = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasPhotos && hasSharing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
// May require user approval on a trusted device (polls every 10s, max 5 min)
|
||||||
func (s *Session) acquirePCSCookies(ctx context.Context) error {
|
func (s *Session) acquirePCSCookiesFor(ctx context.Context, appName string, cookies []string) error {
|
||||||
fs.Logf(nil, "iclouddrive: Advanced Data Protection enabled, requesting PCS cookies")
|
fs.Logf(nil, "iclouddrive: Advanced Data Protection enabled, requesting PCS cookies for %s", appName)
|
||||||
const maxAttempts = 30 // 30 * 10s = 5 minutes max
|
const maxAttempts = 30
|
||||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
for range maxAttempts {
|
||||||
fs.Debugf(nil, "iclouddrive: requestPCS outgoing cookies: %v", cookieJarDebugSummaries(s.Cookies))
|
fs.Debugf(nil, "iclouddrive: requestPCS(%s) outgoing cookies: %v", appName, cookieJarDebugSummaries(s.Cookies))
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
"appName": "photos",
|
"appName": appName,
|
||||||
"derivedFromUserAction": true,
|
"derivedFromUserAction": true,
|
||||||
}
|
}
|
||||||
body, err := IntoReader(values)
|
body, err := IntoReader(values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("requestPCS: %w", err)
|
return fmt.Errorf("requestPCS(%s): %w", appName, err)
|
||||||
}
|
}
|
||||||
opts := rest.Opts{
|
opts := rest.Opts{
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
Path: "/requestPCS",
|
Path: "/requestPCS",
|
||||||
ExtraHeaders: s.GetHeaders(map[string]string{}),
|
ExtraHeaders: s.GetHeaders(map[string]string{}),
|
||||||
RootURL: setupEndpoint,
|
RootURL: setupEndpoint,
|
||||||
|
Body: body,
|
||||||
}
|
}
|
||||||
opts.Body = body
|
|
||||||
var pcsResp struct {
|
var pcsResp struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
resp, err := s.Request(ctx, opts, nil, &pcsResp)
|
resp, err := s.Request(ctx, opts, nil, &pcsResp)
|
||||||
if err != nil {
|
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(%s) response: status=%q message=%q cookies=%d %v",
|
||||||
fs.Debugf(nil, "iclouddrive: requestPCS response: status=%q message=%q cookies=%d",
|
appName, pcsResp.Status, pcsResp.Message, len(resp.Cookies()), cookieDebugSummaries(resp.Cookies()))
|
||||||
pcsResp.Status, pcsResp.Message, len(resp.Cookies()))
|
|
||||||
if pcsResp.Status == "success" {
|
if pcsResp.Status == "success" {
|
||||||
if !s.hasPCSCookies() {
|
if !s.hasPCSCookiesFor(cookies) {
|
||||||
return fmt.Errorf("requestPCS: server returned success but PCS cookies missing")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
// Device consent required - poll until approved
|
fs.Logf(nil, "iclouddrive: waiting for device approval for PCS/%s (%s)", appName, pcsResp.Message)
|
||||||
fs.Logf(nil, "iclouddrive: waiting for device approval for PCS (%s)", pcsResp.Message)
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-time.After(10 * time.Second):
|
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
|
// RequestPushNotification explicitly requests a push notification to trusted devices
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func resumeConfigClient(m configmap.Mapper, appleid, password, trustToken, clien
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
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
|
// Force fresh SRP authentication - ignore stale trust token and cookies
|
||||||
// so that reconnect always prompts for 2FA
|
// so that reconnect always prompts for 2FA
|
||||||
m.Set(configAuthSession, "")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// newICloudClient parses options, authenticates, and returns a ready client
|
||||||
// Shared between NewFs (Drive) and NewFsPhotos (Photos) to avoid duplication
|
// 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)
|
opt := new(Options)
|
||||||
err := configstruct.Set(m, opt)
|
err := configstruct.Set(m, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -442,6 +443,7 @@ func newICloudClient(ctx context.Context, name string, m configmap.Mapper) (*api
|
|||||||
cookies,
|
cookies,
|
||||||
callback,
|
callback,
|
||||||
name,
|
name,
|
||||||
|
pcsWSKey,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ func retryResultUnknown(ctx context.Context, resp *http.Response, err error) (bo
|
|||||||
|
|
||||||
// NewFs constructs an Fs from the path, container:path
|
// NewFs constructs an Fs from the path, container:path
|
||||||
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ type PhotosObject struct {
|
|||||||
|
|
||||||
// NewFsPhotos constructs an Fs for Photos from the path, container:path
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user