diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 71058b26..53e3ef50 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -1089,7 +1089,21 @@ public record XcodeDownloadProgress( public record AppleAuthSession( string AppleId, Dictionary Cookies, - DateTime ExpiresAt + DateTime ExpiresAt, + IReadOnlyList? CookieDetails = null +); + +/// +/// Persisted cookie metadata for an Apple Developer download session. +/// +public record AppleAuthCookie( + string Name, + string Value, + string Domain, + string Path, + DateTime? Expires, + bool Secure, + bool HttpOnly ); /// diff --git a/src/MauiSherpa.Core/Services/AppleDownloadAuthService.cs b/src/MauiSherpa.Core/Services/AppleDownloadAuthService.cs index cbe197bf..7871a0a8 100644 --- a/src/MauiSherpa.Core/Services/AppleDownloadAuthService.cs +++ b/src/MauiSherpa.Core/Services/AppleDownloadAuthService.cs @@ -25,6 +25,8 @@ public class AppleDownloadAuthService : IAppleDownloadAuthService private string? _pendingSessionId; private string? _pendingScnt; private string? _pendingServiceKey; + internal static readonly TimeSpan PersistedSessionFallbackLifetime = TimeSpan.FromDays(30); + internal static readonly TimeSpan SessionRenewalThreshold = TimeSpan.FromDays(7); // Apple auth endpoints // Olympus only returns authServiceKey for the iTunes Connect hostname variant. @@ -315,17 +317,22 @@ public async Task RequestSmsCodeAsync(TwoFactorMethod phone) public async Task ValidateSessionAsync() { if (_session == null) return false; - if (_session.ExpiresAt <= DateTime.UtcNow) return false; + if (_session.ExpiresAt <= DateTime.UtcNow) + { + await ClearPersistedSessionAsync(); + _session = null; + AuthStateChanged?.Invoke(); + return false; + } try { - // Try a lightweight request to see if session cookies are still valid - var request = new HttpRequestMessage(HttpMethod.Get, OlympusSessionUrl); - var response = await _httpClient.SendAsync(request); - return response.IsSuccessStatusCode; + var renewIfValid = ShouldRenewSession(_session, DateTime.UtcNow); + return await ValidateSessionWithServerAsync(renewIfValid); } - catch + catch (Exception ex) { + _logger.LogWarning($"Apple Developer session validation failed: {ex.Message}"); return false; } } @@ -358,8 +365,7 @@ public async Task SignOutAsync() _pendingScnt = null; _pendingServiceKey = null; - await _secureStorage.RemoveAsync(SecureStorageSessionKey); - await _secureStorage.RemoveAsync(SecureStorageAppleIdKey); + await ClearPersistedSessionAsync(); AuthStateChanged?.Invoke(); _logger.LogInformation("Signed out of Apple Developer"); @@ -656,23 +662,7 @@ private static byte[] CombineArrays(params byte[][] arrays) var response = await _httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) return null; - // Extract cookies — store domain info for proper download auth - var cookies = new Dictionary(); - var allCookies = _cookieContainer.GetAllCookies(); - foreach (Cookie cookie in allCookies) - { - // Store as domain|name=value so we can restore to correct domain - var key = $"{cookie.Domain}|{cookie.Name}"; - cookies[key] = cookie.Value; - } - _logger.LogInformation($"Session cookies: {string.Join(", ", allCookies.Select(c => $"{c.Name}@{c.Domain}"))}"); - - - return new AppleAuthSession( - AppleId: appleId, - Cookies: cookies, - ExpiresAt: DateTime.UtcNow.AddHours(12) - ); + return CreateSessionFromCurrentCookies(appleId, DateTime.UtcNow); } catch (Exception ex) { @@ -706,25 +696,23 @@ private async Task TryRestoreSessionAsync() if (session != null && session.ExpiresAt > DateTime.UtcNow) { _session = session; - // Restore cookies with proper domains - foreach (var (key, value) in session.Cookies) + RestoreCookies(session); + _logger.LogInformation($"Restored Apple session for {session.AppleId} until {session.ExpiresAt:u}"); + AuthStateChanged?.Invoke(); + + if (ShouldRenewSession(session, DateTime.UtcNow)) + await ValidateSessionWithServerAsync(renewIfValid: true); + } + else if (session != null) + { + _session = session; + RestoreCookies(session); + + if (!await ValidateSessionWithServerAsync(renewIfValid: true)) { - string domain, name; - if (key.Contains('|')) - { - var parts = key.Split('|', 2); - domain = parts[0]; - name = parts[1]; - } - else - { - domain = ".apple.com"; - name = key; - } - _cookieContainer.Add(new Cookie(name, value, "/", domain)); + _session = null; + _logger.LogInformation("Apple Developer session is locally expired and could not be refreshed"); } - _logger.LogInformation($"Restored Apple session for {session.AppleId}"); - AuthStateChanged?.Invoke(); } } catch (Exception ex) @@ -732,4 +720,192 @@ private async Task TryRestoreSessionAsync() _logger.LogWarning($"Failed to restore session: {ex.Message}"); } } + + private async Task ValidateSessionWithServerAsync(bool renewIfValid) + { + if (_session == null) return false; + + var request = new HttpRequestMessage(HttpMethod.Get, OlympusSessionUrl); + var response = await _httpClient.SendAsync(request); + if (response.IsSuccessStatusCode) + { + if (renewIfValid) + await RenewSessionFromCurrentCookiesAsync(); + + return true; + } + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + await ClearPersistedSessionAsync(); + _session = null; + AuthStateChanged?.Invoke(); + _logger.LogInformation($"Apple Developer session validation failed with {(int)response.StatusCode}; cleared saved session"); + return false; + } + + _logger.LogWarning($"Apple Developer session validation returned {(int)response.StatusCode}; preserving saved session"); + return false; + } + + private async Task RenewSessionFromCurrentCookiesAsync() + { + if (_session == null) return; + + var refreshedSession = CreateSessionFromCurrentCookies(_session.AppleId, DateTime.UtcNow); + _session = refreshedSession; + await PersistSessionAsync(refreshedSession); + AuthStateChanged?.Invoke(); + _logger.LogInformation($"Renewed Apple Developer session for {refreshedSession.AppleId} until {refreshedSession.ExpiresAt:u}"); + } + + private async Task ClearPersistedSessionAsync() + { + await _secureStorage.RemoveAsync(SecureStorageSessionKey); + await _secureStorage.RemoveAsync(SecureStorageAppleIdKey); + } + + private AppleAuthSession CreateSessionFromCurrentCookies(string appleId, DateTime nowUtc) + { + var allCookies = _cookieContainer.GetAllCookies(); + var cookieDetails = CaptureCookieDetails(allCookies); + var cookies = CreateLegacyCookieMap(cookieDetails); + var expiresAt = CalculateSessionExpiresAt(cookieDetails, nowUtc); + _logger.LogInformation($"Session cookies: {string.Join(", ", allCookies.Select(c => $"{c.Name}@{c.Domain}"))}"); + + return new AppleAuthSession( + AppleId: appleId, + Cookies: cookies, + ExpiresAt: expiresAt, + CookieDetails: cookieDetails + ); + } + + internal static IReadOnlyList CaptureCookieDetails(CookieCollection cookies) + { + var result = new List(); + foreach (Cookie cookie in cookies) + { + result.Add(new AppleAuthCookie( + Name: cookie.Name, + Value: cookie.Value, + Domain: cookie.Domain, + Path: string.IsNullOrWhiteSpace(cookie.Path) ? "/" : cookie.Path, + Expires: NormalizeCookieExpiration(cookie.Expires), + Secure: cookie.Secure, + HttpOnly: cookie.HttpOnly)); + } + + return result; + } + + internal static DateTime CalculateSessionExpiresAt(IReadOnlyList cookies, DateTime nowUtc) + { + var latestCookieExpiration = cookies + .Select(cookie => cookie.Expires) + .Where(expires => expires.HasValue && expires.Value > nowUtc) + .Select(expires => expires!.Value) + .DefaultIfEmpty() + .Max(); + + return latestCookieExpiration == default + ? nowUtc.Add(PersistedSessionFallbackLifetime) + : latestCookieExpiration; + } + + internal static bool ShouldRenewSession(AppleAuthSession session, DateTime nowUtc) => + session.ExpiresAt > nowUtc && + session.ExpiresAt <= nowUtc.Add(SessionRenewalThreshold); + + private static DateTime? NormalizeCookieExpiration(DateTime expires) + { + if (expires == DateTime.MinValue) + return null; + + return expires.Kind == DateTimeKind.Utc + ? expires + : expires.ToUniversalTime(); + } + + private static Dictionary CreateLegacyCookieMap(IEnumerable cookies) + { + var legacyCookies = new Dictionary(); + foreach (var cookie in cookies) + { + legacyCookies[$"{cookie.Domain}|{cookie.Name}"] = cookie.Value; + } + + return legacyCookies; + } + + private void RestoreCookies(AppleAuthSession session) + { + var cookieDetails = session.CookieDetails is { Count: > 0 } + ? session.CookieDetails + : CreateCookieDetailsFromLegacyMap(session.Cookies); + + foreach (var cookieDetail in cookieDetails) + { + if (cookieDetail.Expires is { } expires && expires <= DateTime.UtcNow) + continue; + + try + { + _cookieContainer.Add(CreateCookie(cookieDetail)); + } + catch (CookieException ex) + { + _logger.LogWarning($"Failed to restore Apple session cookie {cookieDetail.Name}@{cookieDetail.Domain}: {ex.Message}"); + } + } + } + + private static IReadOnlyList CreateCookieDetailsFromLegacyMap(Dictionary cookies) + { + var cookieDetails = new List(); + foreach (var (key, value) in cookies) + { + string domain, name; + if (key.Contains('|')) + { + var parts = key.Split('|', 2); + domain = parts[0]; + name = parts[1]; + } + else + { + domain = ".apple.com"; + name = key; + } + + cookieDetails.Add(new AppleAuthCookie( + Name: name, + Value: value, + Domain: domain, + Path: "/", + Expires: null, + Secure: false, + HttpOnly: false)); + } + + return cookieDetails; + } + + private static Cookie CreateCookie(AppleAuthCookie cookieDetail) + { + var cookie = new Cookie( + cookieDetail.Name, + cookieDetail.Value, + string.IsNullOrWhiteSpace(cookieDetail.Path) ? "/" : cookieDetail.Path, + string.IsNullOrWhiteSpace(cookieDetail.Domain) ? ".apple.com" : cookieDetail.Domain) + { + Secure = cookieDetail.Secure, + HttpOnly = cookieDetail.HttpOnly + }; + + if (cookieDetail.Expires is { } expires) + cookie.Expires = expires; + + return cookie; + } } diff --git a/src/MauiSherpa.Core/Services/XcodeService.cs b/src/MauiSherpa.Core/Services/XcodeService.cs index c1c7767c..1ba7a5b8 100644 --- a/src/MauiSherpa.Core/Services/XcodeService.cs +++ b/src/MauiSherpa.Core/Services/XcodeService.cs @@ -301,6 +301,12 @@ public async Task DownloadXcodeAsync( { _logger.LogInformation($"Downloading Xcode {release.Version} from {release.DownloadUrl}..."); + if (!await _authService.ValidateSessionAsync()) + { + _logger.LogError("Apple Developer session is not valid. Sign in again to download Xcode."); + return false; + } + // Use the auth service's shared cookie jar — cookies from SRP auth + Olympus session // are already there, and listDownloads.action will add ADCDownloadAuth using var downloadClient = _authService.CreateAuthenticatedHttpClient(); diff --git a/tests/MauiSherpa.Core.Tests/Services/AppleDownloadAuthServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/AppleDownloadAuthServiceTests.cs new file mode 100644 index 00000000..3f3b5ce3 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/AppleDownloadAuthServiceTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class AppleDownloadAuthServiceTests +{ + [Fact] + public void CaptureCookieDetails_PreservesCookieMetadata() + { + var expires = DateTime.UtcNow.AddDays(14); + var cookies = new CookieCollection + { + new Cookie("myacinfo", "session-value", "/account", ".apple.com") + { + Expires = expires, + Secure = true, + HttpOnly = true + } + }; + + var result = AppleDownloadAuthService.CaptureCookieDetails(cookies); + + result.Should().ContainSingle().Which.Should().BeEquivalentTo(new AppleAuthCookie( + Name: "myacinfo", + Value: "session-value", + Domain: ".apple.com", + Path: "/account", + Expires: expires, + Secure: true, + HttpOnly: true)); + } + + [Fact] + public void CalculateSessionExpiresAt_UsesLatestCookieExpiration() + { + var now = new DateTime(2026, 5, 25, 18, 0, 0, DateTimeKind.Utc); + var latestExpiration = now.AddDays(21); + var cookies = new List + { + new("short", "value", ".apple.com", "/", now.AddDays(2), false, false), + new("long", "value", ".apple.com", "/", latestExpiration, true, true) + }; + + var expiresAt = AppleDownloadAuthService.CalculateSessionExpiresAt(cookies, now); + + expiresAt.Should().Be(latestExpiration); + } + + [Fact] + public void CalculateSessionExpiresAt_WhenCookiesAreSessionCookies_UsesFallbackLifetime() + { + var now = new DateTime(2026, 5, 25, 18, 0, 0, DateTimeKind.Utc); + var cookies = new List + { + new("session", "value", ".apple.com", "/", null, true, true) + }; + + var expiresAt = AppleDownloadAuthService.CalculateSessionExpiresAt(cookies, now); + + expiresAt.Should().Be(now.Add(AppleDownloadAuthService.PersistedSessionFallbackLifetime)); + } + + [Fact] + public void ShouldRenewSession_WhenWithinRenewalThreshold_ReturnsTrue() + { + var now = new DateTime(2026, 5, 25, 18, 0, 0, DateTimeKind.Utc); + var session = new AppleAuthSession("user@example.com", [], now.AddDays(2)); + + var shouldRenew = AppleDownloadAuthService.ShouldRenewSession(session, now); + + shouldRenew.Should().BeTrue(); + } + + [Fact] + public void ShouldRenewSession_WhenOutsideRenewalThreshold_ReturnsFalse() + { + var now = new DateTime(2026, 5, 25, 18, 0, 0, DateTimeKind.Utc); + var session = new AppleAuthSession("user@example.com", [], now.AddDays(14)); + + var shouldRenew = AppleDownloadAuthService.ShouldRenewSession(session, now); + + shouldRenew.Should().BeFalse(); + } + + [Fact] + public void ShouldRenewSession_WhenExpired_ReturnsFalse() + { + var now = new DateTime(2026, 5, 25, 18, 0, 0, DateTimeKind.Utc); + var session = new AppleAuthSession("user@example.com", [], now.AddMinutes(-1)); + + var shouldRenew = AppleDownloadAuthService.ShouldRenewSession(session, now); + + shouldRenew.Should().BeFalse(); + } +}