From 1a607aba78ca289b0fd9ef7820db5becb5c3945f Mon Sep 17 00:00:00 2001 From: krolakj Date: Thu, 7 May 2026 16:08:45 +0200 Subject: [PATCH] feat: add captive portal (zero-trust proxy) authentication support Detect when the server is behind a zero-trust proxy (Cloudflare Access, Authelia, etc.) and present an embedded browser for authentication. Cookies are persisted and forwarded to all request paths including audio streaming. Session is cleared on explicit logout but preserved on token expiry for seamless re-authentication. --- Amperfy.xcodeproj/project.pbxproj | 28 +- Amperfy/AppDelegate.swift | 4 + Amperfy/CaptivePortalAuthenticator.swift | 239 ++++++++++++++++++ Amperfy/Screens/ViewController/LoginVC.swift | 27 +- .../Account/AccountSettingsView.swift | 3 + .../Api/Ampache/AmpacheXmlServerApi.swift | 43 +++- AmperfyKit/Api/BackendProxy.swift | 43 +++- AmperfyKit/Api/CaptivePortalDetector.swift | 67 +++++ AmperfyKit/Api/CaptivePortalSession.swift | 118 +++++++++ .../Api/Subsonic/SubsonicServerApi.swift | 69 +++-- .../Download/PlayableDownloadDelegate.swift | 8 + AmperfyKit/Player/BackendAudioPlayer.swift | 21 +- .../Cases/API/CaptivePortalDetectorTest.swift | 238 +++++++++++++++++ .../Cases/API/CaptivePortalSessionTest.swift | 192 ++++++++++++++ 14 files changed, 1058 insertions(+), 42 deletions(-) create mode 100644 Amperfy/CaptivePortalAuthenticator.swift create mode 100644 AmperfyKit/Api/CaptivePortalDetector.swift create mode 100644 AmperfyKit/Api/CaptivePortalSession.swift create mode 100644 AmperfyKitTests/Cases/API/CaptivePortalDetectorTest.swift create mode 100644 AmperfyKitTests/Cases/API/CaptivePortalSessionTest.swift diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index 5f71d962..4be42aaf 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -204,8 +204,9 @@ 50BE5D522850F4D700156FC6 /* HelperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B798B323B8664700551E62 /* HelperTest.swift */; }; 50BE5D532850F4E700156FC6 /* MusicPlayerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5095F98B23C8389E008B0805 /* MusicPlayerTest.swift */; }; 50BE5D542850F4E700156FC6 /* SubsonicVersionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E964AE25E8E25E00E3210F /* SubsonicVersionTest.swift */; }; + B5C7DE010000000000000001 /* CaptivePortalDetectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C7DE010000000000000002 /* CaptivePortalDetectorTest.swift */; }; + B5C7DE010000000000000003 /* CaptivePortalSessionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C7DE010000000000000004 /* CaptivePortalSessionTest.swift */; }; 50BE5D552850F4E700156FC6 /* UtilitiesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5090963726496A9500DD9826 /* UtilitiesTest.swift */; }; - B5A5CE010000000000000001 /* ShareSongActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5CE010000000000000002 /* ShareSongActionTest.swift */; }; 50BE5D562850F4E700156FC6 /* PlayQueueHandlerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5011712D27453D1300B7C08D /* PlayQueueHandlerTest.swift */; }; 50BE5D572850F4F600156FC6 /* album_missing_artistId.xml in Resources */ = {isa = PBXBuildFile; fileRef = 50ED2B9227BB932700331BF7 /* album_missing_artistId.xml */; }; 50BE5D582850F4F600156FC6 /* artist_example_1.xml in Resources */ = {isa = PBXBuildFile; fileRef = 50AB92C526661F5800DCE45C /* artist_example_1.xml */; }; @@ -312,6 +313,9 @@ 50C9D644284FA9C7007F18D0 /* BackendProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500B2B302249607B00D14EF4 /* BackendProxy.swift */; }; 50C9D645284FA9C7007F18D0 /* BackendApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086F87A22397B8400546F5A /* BackendApi.swift */; }; 50C9D646284FA9C7007F18D0 /* LoginCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BA92EE21CBF45D00E5901D /* LoginCredentials.swift */; }; + 7146AB03077769531B516A68 /* CaptivePortalDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61D4B85223AB23B952FE958 /* CaptivePortalDetector.swift */; }; + A5F13F6A02387E878E94F964 /* CaptivePortalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE16295A61BFA531345994AE /* CaptivePortalSession.swift */; }; + 6036AB4E5C3D8AA06B7D3AC5 /* CaptivePortalAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857989FA4AA8B31088A135BF /* CaptivePortalAuthenticator.swift */; }; 50C9D647284FAA02007F18D0 /* SsMusicFolderParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DEEDFF265D46CB0073FF20 /* SsMusicFolderParserDelegate.swift */; }; 50C9D648284FAA02007F18D0 /* SsPingParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086F884223A1E4600546F5A /* SsPingParserDelegate.swift */; }; 50C9D649284FAA02007F18D0 /* SubsonicVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E964A925E8E20E00E3210F /* SubsonicVersion.swift */; }; @@ -530,10 +534,11 @@ 632F51262C83A8400032860D /* LyricsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632F51252C83A8400032860D /* LyricsVC.swift */; }; 6333C8E12C6FDB5200CCA50A /* SecondaryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6333C8E02C6FDB5200CCA50A /* SecondaryText.swift */; }; 637A28B52C79409C0082FACC /* MiniPlayerSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */; }; - C0A1B0032EEE000000000003 /* MacWindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B0022EEE000000000002 /* MacWindowHelper.swift */; }; 638920F02C8C8E9000932EE8 /* SettingsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638920EF2C8C8E9000932EE8 /* SettingsList.swift */; }; 63DCDB7A2C674B5D00522F68 /* SettingsSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */; }; 641708592C13BF5100BA2619 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641708582C13BF5100BA2619 /* Haptics.swift */; }; + B5A5CE010000000000000001 /* ShareSongActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5CE010000000000000002 /* ShareSongActionTest.swift */; }; + C0A1B0032EEE000000000003 /* MacWindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B0022EEE000000000002 /* MacWindowHelper.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -840,7 +845,6 @@ 509001C027182A1300A8056D /* CatalogParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogParserTest.swift; sourceTree = ""; }; 509001C227182A4700A8056D /* CatalogParserDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogParserDelegate.swift; sourceTree = ""; }; 5090963726496A9500DD9826 /* UtilitiesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesTest.swift; sourceTree = ""; }; - B5A5CE010000000000000002 /* ShareSongActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSongActionTest.swift; sourceTree = ""; }; 509357512E3BF47800CD4075 /* MiniPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerView.swift; sourceTree = ""; }; 509362EC28E041FF005C2AAC /* Amperfy v28.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v28.xcdatamodel"; sourceTree = ""; }; 50947E0227DA011F00C368D7 /* ScrobbleEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleEntry.swift; sourceTree = ""; }; @@ -956,6 +960,9 @@ 50BA92EC21CBF45D00E5901D /* AlbumParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumParserDelegate.swift; sourceTree = ""; }; 50BA92ED21CBF45D00E5901D /* ArtistParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArtistParserDelegate.swift; sourceTree = ""; }; 50BA92EE21CBF45D00E5901D /* LoginCredentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginCredentials.swift; sourceTree = ""; }; + F61D4B85223AB23B952FE958 /* CaptivePortalDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalDetector.swift; sourceTree = ""; }; + AE16295A61BFA531345994AE /* CaptivePortalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalSession.swift; sourceTree = ""; }; + 857989FA4AA8B31088A135BF /* CaptivePortalAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalAuthenticator.swift; sourceTree = ""; }; 50BA92F721CC318D00E5901D /* PlayableTableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayableTableCell.swift; sourceTree = ""; }; 50BA92F921CC37F800E5901D /* SongsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongsVC.swift; sourceTree = ""; }; 50BA92FD21CED4AF00E5901D /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; @@ -1103,6 +1110,8 @@ 50E936DA2F100C7800C1C504 /* RatingAppEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingAppEnum.swift; sourceTree = ""; }; 50E964A925E8E20E00E3210F /* SubsonicVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubsonicVersion.swift; sourceTree = ""; }; 50E964AE25E8E25E00E3210F /* SubsonicVersionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubsonicVersionTest.swift; sourceTree = ""; }; + B5C7DE010000000000000002 /* CaptivePortalDetectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalDetectorTest.swift; sourceTree = ""; }; + B5C7DE010000000000000004 /* CaptivePortalSessionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalSessionTest.swift; sourceTree = ""; }; 50ED030C2E2788200092E6DC /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreAudio.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/CoreAudio.framework; sourceTree = DEVELOPER_DIR; }; 50ED2B9027B7AE8500331BF7 /* EventNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventNotificationHandler.swift; sourceTree = ""; }; 50ED2B9227BB932700331BF7 /* album_missing_artistId.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = album_missing_artistId.xml; sourceTree = ""; }; @@ -1148,12 +1157,13 @@ 632F51252C83A8400032860D /* LyricsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsVC.swift; sourceTree = ""; }; 6333C8E02C6FDB5200CCA50A /* SecondaryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryText.swift; sourceTree = ""; }; 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerSceneDelegate.swift; sourceTree = ""; }; - C0A1B0022EEE000000000002 /* MacWindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindowHelper.swift; sourceTree = ""; }; 638920EF2C8C8E9000932EE8 /* SettingsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsList.swift; sourceTree = ""; }; 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSceneDelegate.swift; sourceTree = ""; }; 641708582C13BF5100BA2619 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 643A04712CD29AB20012DEA3 /* Amperfy v39.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v39.xcdatamodel"; sourceTree = ""; }; 64C433822E1391FA0082A165 /* Amperfy v46.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v46.xcdatamodel"; sourceTree = ""; }; + B5A5CE010000000000000002 /* ShareSongActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSongActionTest.swift; sourceTree = ""; }; + C0A1B0022EEE000000000002 /* MacWindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindowHelper.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1463,6 +1473,7 @@ isa = PBXGroup; children = ( 508385E721C5965B00C4BB32 /* AppDelegate.swift */, + 857989FA4AA8B31088A135BF /* CaptivePortalAuthenticator.swift */, 5059A31B28A9ED680068E4D2 /* SceneDelegate.swift */, 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */, 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */, @@ -1490,6 +1501,8 @@ children = ( 5086F87A22397B8400546F5A /* BackendApi.swift */, 500B2B302249607B00D14EF4 /* BackendProxy.swift */, + F61D4B85223AB23B952FE958 /* CaptivePortalDetector.swift */, + AE16295A61BFA531345994AE /* CaptivePortalSession.swift */, 50CDF201264BB1C000F83EF6 /* EventLogger.swift */, 5077F954222C58A10099CA91 /* GenericXmlParser.swift */, 50BA92EE21CBF45D00E5901D /* LoginCredentials.swift */, @@ -2142,6 +2155,8 @@ 50DB11A8266536190033BFFA /* Subsonic */, 50DB1180266501EC0033BFFA /* Ampache */, 50E964AE25E8E25E00E3210F /* SubsonicVersionTest.swift */, + B5C7DE010000000000000002 /* CaptivePortalDetectorTest.swift */, + B5C7DE010000000000000004 /* CaptivePortalSessionTest.swift */, ); path = API; sourceTree = ""; @@ -2551,6 +2566,7 @@ 50C171522D10777400C0C53A /* PlaylistAddPlaylistsVC.swift in Sources */, 500B7D4921EDD6250083ED6F /* ViewCreator.swift in Sources */, 508385EA21C5965B00C4BB32 /* LoginVC.swift in Sources */, + 6036AB4E5C3D8AA06B7D3AC5 /* CaptivePortalAuthenticator.swift in Sources */, 507BA5CB2F01CFA800B4EA8B /* OnlineOfflineModeAppEnum.swift in Sources */, 50B137022EFC85D700738475 /* AccountAppEntity.swift in Sources */, 50C9D7152850759B007F18D0 /* AnimatedGradientLayer.swift in Sources */, @@ -2815,6 +2831,8 @@ 50C9D6A5284FAA7F007F18D0 /* CoreDataMigrationStep.swift in Sources */, 50C9D6B9284FAA87007F18D0 /* Artist.swift in Sources */, 50C9D644284FA9C7007F18D0 /* BackendProxy.swift in Sources */, + 7146AB03077769531B516A68 /* CaptivePortalDetector.swift in Sources */, + A5F13F6A02387E878E94F964 /* CaptivePortalSession.swift in Sources */, 50C9D69A284FAA6C007F18D0 /* AbstractLibraryEntityMO+CoreDataClass.swift in Sources */, 50C9D678284FAA6C007F18D0 /* SongMO+CoreDataProperties.swift in Sources */, 50C9D687284FAA6C007F18D0 /* AlbumMO+CoreDataProperties.swift in Sources */, @@ -2850,6 +2868,8 @@ 50BE5D522850F4D700156FC6 /* HelperTest.swift in Sources */, 50BE5D6B2850F4FB00156FC6 /* SsGenreParserTest.swift in Sources */, 50BE5D542850F4E700156FC6 /* SubsonicVersionTest.swift in Sources */, + B5C7DE010000000000000001 /* CaptivePortalDetectorTest.swift in Sources */, + B5C7DE010000000000000003 /* CaptivePortalSessionTest.swift in Sources */, 50BE5D742850F4FB00156FC6 /* SsAlbumMultidiscExample1ParserTest.swift in Sources */, 50BE5D872850F50F00156FC6 /* AbstractAmpacheTest.swift in Sources */, 50BE5D4F2850F4C900156FC6 /* AlbumTest.swift in Sources */, diff --git a/Amperfy/AppDelegate.swift b/Amperfy/AppDelegate.swift index 527863bc..32027506 100644 --- a/Amperfy/AppDelegate.swift +++ b/Amperfy/AppDelegate.swift @@ -87,6 +87,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AmperKit.shared.localNotificationManager }() + private let captivePortalAuthenticator = CaptivePortalAuthenticator() + public func getMeta(_ accountInfo: AccountInfo) -> MetaManager { AmperKit.shared.getMeta(accountInfo) } @@ -263,6 +265,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { os_log("application launch", log: self.log, type: .info) } + CaptivePortalSession.shared.authHandler = captivePortalAuthenticator + storage.applyMultiAccountSettingsUpdateIfNeeded() libraryUpdater.performAccountCleanUpIfNeccessaryInBackground() diff --git a/Amperfy/CaptivePortalAuthenticator.swift b/Amperfy/CaptivePortalAuthenticator.swift new file mode 100644 index 00000000..eacd15ca --- /dev/null +++ b/Amperfy/CaptivePortalAuthenticator.swift @@ -0,0 +1,239 @@ +// +// CaptivePortalAuthenticator.swift +// Amperfy +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import AmperfyKit +import UIKit +import WebKit + +// MARK: - CaptivePortalAuthenticator + +final class CaptivePortalAuthenticator: NSObject, CaptivePortalAuthHandler, @unchecked Sendable { + @MainActor + func performCaptivePortalAuth(serverURL: URL, clearSession: Bool) async throws { + if clearSession { + let dataStore = WKWebsiteDataStore.default() + let allTypes = WKWebsiteDataStore.allWebsiteDataTypes() + let records = await dataStore.dataRecords(ofTypes: allTypes) + await dataStore.removeData(ofTypes: allTypes, for: records) + } + + try await showAlertAndAuthenticate(serverURL: serverURL) + } + + @MainActor + private func showAlertAndAuthenticate(serverURL: URL) async throws { + guard let window = AppDelegate.mainSceneDelegate?.window, + let rootVC = window.rootViewController + else { + throw CaptivePortalError.authenticationFailed + } + + let topVC = Self.topViewController(from: rootVC) + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(), Error>) in + let alert = UIAlertController( + title: "Network Authentication Required", + message: + "Your server requires network authentication. You'll be redirected to log in.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Continue", style: .default) { _ in + self.presentWebView(serverURL: serverURL, from: topVC, continuation: continuation) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + continuation.resume(throwing: CaptivePortalError.userCancelled) + }) + topVC.present(alert, animated: true) + } + } + + @MainActor + private func presentWebView( + serverURL: URL, + from presenter: UIViewController, + continuation: CheckedContinuation<(), Error> + ) { + let webVC = CaptivePortalWebViewController( + serverURL: serverURL, + continuation: continuation + ) + let navVC = UINavigationController(rootViewController: webVC) + navVC.modalPresentationStyle = .fullScreen + presenter.present(navVC, animated: true) + } + + @MainActor + private static func topViewController(from vc: UIViewController) -> UIViewController { + if let presented = vc.presentedViewController { + return topViewController(from: presented) + } + if let nav = vc as? UINavigationController, let visible = nav.visibleViewController { + return topViewController(from: visible) + } + if let tab = vc as? UITabBarController, let selected = tab.selectedViewController { + return topViewController(from: selected) + } + return vc + } +} + +// MARK: - CaptivePortalWebViewController + +final class CaptivePortalWebViewController: UIViewController, WKNavigationDelegate, + WKUIDelegate { + private let serverURL: URL + private let serverHost: String + private var continuation: CheckedContinuation<(), Error>? + private var webView: WKWebView! + private var didComplete = false + + init(serverURL: URL, continuation: CheckedContinuation<(), Error>) { + self.serverURL = serverURL + self.serverHost = serverURL.host?.lowercased() ?? "" + self.continuation = continuation + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sign In" + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped) + ) + + let config = WKWebViewConfiguration() + config.websiteDataStore = .default() + webView = WKWebView(frame: view.bounds, configuration: config) + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.navigationDelegate = self + webView.uiDelegate = self + view.addSubview(webView) + + webView.load(URLRequest(url: serverURL)) + } + + @objc + private func cancelTapped() { + finishWith(error: CaptivePortalError.userCancelled) + } + + private func finishWith(error: Error?) { + guard !didComplete else { return } + didComplete = true + dismiss(animated: true) { + if let error { + self.continuation?.resume(throwing: error) + self.continuation = nil + } else { + self.copyCookiesToSharedStorage() + } + } + } + + private func copyCookiesToSharedStorage() { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + for cookie in cookies { + HTTPCookieStorage.shared.setCookie(cookie) + } + self.continuation?.resume() + self.continuation = nil + } + } + + // MARK: - WKNavigationDelegate + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> () + ) { + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let currentHost = webView.url?.host?.lowercased() else { return } + if currentHost == serverHost { + finishWith(error: nil) + } + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return } + finishWith(error: CaptivePortalError.authenticationFailed) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return } + finishWith(error: CaptivePortalError.authenticationFailed) + } + + // MARK: - WKUIDelegate + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) + -> WKWebView? { + if navigationAction.targetFrame == nil || !navigationAction.targetFrame!.isMainFrame { + webView.load(navigationAction.request) + } + return nil + } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> () + ) { + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler() }) + present(alert, animated: true) + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> () + ) { + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) }) + alert + .addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) }) + present(alert, animated: true) + } +} diff --git a/Amperfy/Screens/ViewController/LoginVC.swift b/Amperfy/Screens/ViewController/LoginVC.swift index 3cc774e0..bd2a8f8b 100644 --- a/Amperfy/Screens/ViewController/LoginVC.swift +++ b/Amperfy/Screens/ViewController/LoginVC.swift @@ -399,10 +399,25 @@ class LoginVC: UIViewController { Task { @MainActor in do { let meta = self.appDelegate.getMeta(accountInfo) - let authenticatedApiType = try await meta.backendApi.login( - apiType: selectedApiType, - credentials: credentials - ) + let authenticatedApiType: BackenApiType + do { + authenticatedApiType = try await meta.backendApi.login( + apiType: selectedApiType, + credentials: credentials + ) + } catch let error as CaptivePortalError where error == .captivePortalDetected { + guard let serverURL = URL(string: credentials.activeBackendServerUrl) else { + self.showErrorMsg(message: "Invalid server URL") + self.appDelegate.resetMeta(accountInfo) + return + } + try await CaptivePortalSession.shared.authenticate(serverURL: serverURL) + authenticatedApiType = try await meta.backendApi.login( + apiType: selectedApiType, + credentials: credentials + ) + } + credentials.backendApi = authenticatedApiType accountInfo = Account.createInfo(credentials: credentials) meta.backendApi.selectedApi = authenticatedApiType @@ -430,9 +445,13 @@ class LoginVC: UIViewController { mainScene .replaceMainRootViewController(vc: syncVC) } + } catch let error as CaptivePortalError where error == .userCancelled { + self.appDelegate.resetMeta(accountInfo) } catch { if error is AuthenticationError { self.showErrorMsg(message: error.localizedDescription) + } else if error is CaptivePortalError { + self.showErrorMsg(message: error.localizedDescription) } else { self.showErrorMsg(message: "Not able to login!") } diff --git a/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift b/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift index bb82ce4f..bc3207ff 100644 --- a/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift +++ b/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift @@ -72,6 +72,9 @@ struct AccountSettingsView: View { } let meta = appDelegate.getMeta(accountInfo) + + CaptivePortalSession.shared.logout() + meta.stopManager() appDelegate.resetMeta(accountInfo) diff --git a/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift b/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift index f1be1578..7d9c734b 100644 --- a/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift +++ b/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift @@ -741,11 +741,32 @@ final class AmpacheXmlServerApi: URLCleanser, Sendable { } private func request(url: URL) async throws -> APIDataResponse { - try await withUnsafeThrowingContinuation { continuation in - AF.request(url, method: .get).validate().responseData { response in + let (data, httpResponse) = try await performAFRequest(url: url) + + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: httpResponse, data: data + ) { + guard let serverURLString = credentials.wrappedValue?.activeBackendServerUrl, + let serverURL = URL(string: serverURLString) + else { throw CaptivePortalError.authenticationFailed } + try await CaptivePortalSession.shared.authenticate(serverURL: serverURL) + let (retryData, retryResponse) = try await performAFRequest(url: url) + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: retryResponse, data: retryData + ) { + throw CaptivePortalError.authenticationFailed + } + return try processAFResponse(url: url, data: retryData, httpResponse: retryResponse) + } + return try processAFResponse(url: url, data: data, httpResponse: httpResponse) + } + + private func performAFRequest(url: URL) async throws -> (Data, HTTPURLResponse?) { + try await withUnsafeThrowingContinuation { continuation in + AF.request(url, method: .get).responseData { response in if let data = response.data { - continuation.resume(returning: APIDataResponse(data: data, url: url)) + continuation.resume(returning: (data, response.response)) return } if let err = response.error { @@ -757,6 +778,22 @@ final class AmpacheXmlServerApi: URLCleanser, Sendable { } } + private func processAFResponse( + url: URL, data: Data, httpResponse: HTTPURLResponse? + ) throws + -> APIDataResponse { + if let statusCode = httpResponse?.statusCode, statusCode >= 400 { + throw ResponseError( + type: .api, + statusCode: statusCode, + message: "HTTP Error: \(statusCode)", + cleansedURL: cleanse(url: url), + data: data + ) + } + return APIDataResponse(data: data, url: url) + } + func requesetLibraryMetaData() async throws -> AuthentificationHandshake { try await reauthenticate() } diff --git a/AmperfyKit/Api/BackendProxy.swift b/AmperfyKit/Api/BackendProxy.swift index 2c7d10ed..20bda212 100644 --- a/AmperfyKit/Api/BackendProxy.swift +++ b/AmperfyKit/Api/BackendProxy.swift @@ -321,26 +321,43 @@ public final class BackendProxy: Sendable { let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig) let request = URLRequest(url: activeBackendServerUrl) - let task = session.downloadTask(with: request) { tempLocalUrl, response, error in + let task = session.dataTask(with: request) { data, response, error in if let error = error { continuation .resume( throwing: AuthenticationError .downloadError(message: error.localizedDescription) ) - } else { - if let statusCode = (response as? HTTPURLResponse)?.statusCode { - if statusCode >= 400, - // ignore 401 Unauthorized (RFC 7235) status code - // -> Can occur if root website requires http basic authentication, - // but the REST API endpoints are reachable without http basic authentication - statusCode != 401 { - continuation - .resume(throwing: AuthenticationError.requestStatusError(message: "\(statusCode)")) - } else { - continuation.resume() - } + return + } + + let httpResponse = response as? HTTPURLResponse + + // For the root URL reachability check, only detect captive portal + // via domain redirect (the server redirected us to a different host). + // Content-Type checks are not appropriate here because the server's + // root page legitimately returns HTML. + if CaptivePortalDetector.isDomainRedirect( + requestURL: activeBackendServerUrl, + response: httpResponse + ) { + continuation.resume(throwing: CaptivePortalError.captivePortalDetected) + return + } + + if let statusCode = httpResponse?.statusCode { + if statusCode >= 400, + // ignore 401 Unauthorized (RFC 7235) status code + // -> Can occur if root website requires http basic authentication, + // but the REST API endpoints are reachable without http basic authentication + statusCode != 401 { + continuation + .resume(throwing: AuthenticationError.requestStatusError(message: "\(statusCode)")) + } else { + continuation.resume() } + } else { + continuation.resume() } } task.resume() diff --git a/AmperfyKit/Api/CaptivePortalDetector.swift b/AmperfyKit/Api/CaptivePortalDetector.swift new file mode 100644 index 00000000..16a0bbca --- /dev/null +++ b/AmperfyKit/Api/CaptivePortalDetector.swift @@ -0,0 +1,67 @@ +// +// CaptivePortalDetector.swift +// AmperfyKit +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import Foundation + +public enum CaptivePortalDetector { + public static func isCaptivePortalResponse( + requestURL: URL, + response: HTTPURLResponse?, + data: Data? + ) + -> Bool { + guard let response else { return false } + + if isDomainRedirect(requestURL: requestURL, response: response) { + return true + } + + if isHTMLContentType(response: response) { + return true + } + + if response.statusCode == 403, let data, isHTMLContent(data: data) { + return true + } + + return false + } + + public static func isHTMLContent(data: Data) -> Bool { + let prefix = String(data: data.prefix(500), encoding: .utf8)?.lowercased() ?? "" + return prefix.contains(" Bool { + guard let response else { return false } + guard let responseURL = response.url else { return false } + guard let requestHost = requestURL.host?.lowercased(), + let responseHost = responseURL.host?.lowercased() + else { return false } + return requestHost != responseHost + } + + private static func isHTMLContentType(response: HTTPURLResponse) -> Bool { + guard let contentType = response.value(forHTTPHeaderField: "Content-Type")?.lowercased() + else { return false } + return contentType.contains("text/html") + } +} diff --git a/AmperfyKit/Api/CaptivePortalSession.swift b/AmperfyKit/Api/CaptivePortalSession.swift new file mode 100644 index 00000000..4b46f16b --- /dev/null +++ b/AmperfyKit/Api/CaptivePortalSession.swift @@ -0,0 +1,118 @@ +// +// CaptivePortalSession.swift +// AmperfyKit +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import Foundation + +// MARK: - CaptivePortalAuthHandler + +public protocol CaptivePortalAuthHandler: AnyObject, Sendable { + @MainActor + func performCaptivePortalAuth(serverURL: URL, clearSession: Bool) async throws +} + +// MARK: - CaptivePortalError + +public enum CaptivePortalError: LocalizedError, Equatable { + case captivePortalDetected + case noAuthHandler + case authenticationFailed + case userCancelled + + public var errorDescription: String? { + switch self { + case .captivePortalDetected: + return "Server is behind a network authentication portal." + case .noAuthHandler: + return "Captive portal auth handler not configured." + case .authenticationFailed: + return "Network authentication failed. Please try again." + case .userCancelled: + return "Authentication was cancelled." + } + } +} + +// MARK: - CaptivePortalSession + +public final class CaptivePortalSession: @unchecked Sendable { + public static let shared = CaptivePortalSession() + + private let lock = NSLock() + private var _authHandler: (any CaptivePortalAuthHandler)? + private var _needsSessionClear = false + private var activeAuthTask: Task<(), Error>? + + public var authHandler: (any CaptivePortalAuthHandler)? { + get { lock.withLock { _authHandler } } + set { lock.withLock { _authHandler = newValue } } + } + + private init() {} + + @MainActor + public func authenticate(serverURL: URL) async throws { + let existingTask: Task<(), Error>? = lock.withLock { activeAuthTask } + if let existingTask { + try await existingTask.value + return + } + + let clearSession = lock.withLock { + let val = _needsSessionClear + _needsSessionClear = false + return val + } + + let task = Task { @MainActor in + guard let handler = self.authHandler else { + throw CaptivePortalError.noAuthHandler + } + try await handler.performCaptivePortalAuth(serverURL: serverURL, clearSession: clearSession) + } + lock.withLock { activeAuthTask = task } + + do { + try await task.value + lock.withLock { activeAuthTask = nil } + } catch { + lock.withLock { activeAuthTask = nil } + throw error + } + } + + /// Call on explicit logout. Clears HTTPCookieStorage immediately and marks + /// WKWebView data for clearing on the next auth attempt. + public func logout() { + HTTPCookieStorage.shared.cookies?.forEach { cookie in + HTTPCookieStorage.shared.deleteCookie(cookie) + } + lock.withLock { _needsSessionClear = true } + } + + #if DEBUG + public func resetForTesting() { + lock.withLock { + _needsSessionClear = false + activeAuthTask = nil + } + } + #endif +} diff --git a/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift b/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift index 47fd6ea9..452d785f 100644 --- a/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift +++ b/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift @@ -957,24 +957,34 @@ final class SubsonicServerApi: URLCleanser, Sendable { } private func request(url: URL) async throws -> APIDataResponse { + let (data, httpResponse) = try await performAFRequest(url: url) + + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: httpResponse, data: data + ) { + guard let serverURLString = credentials.wrappedValue?.activeBackendServerUrl, + let serverURL = URL(string: serverURLString) + else { throw CaptivePortalError.authenticationFailed } + try await CaptivePortalSession.shared.authenticate(serverURL: serverURL) + let (retryData, retryResponse) = try await performAFRequest(url: url) + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: retryResponse, data: retryData + ) { + throw CaptivePortalError.authenticationFailed + } + return try processAFResponse(url: url, data: retryData, httpResponse: retryResponse) + } + + return try processAFResponse(url: url, data: data, httpResponse: httpResponse) + } + + private func performAFRequest(url: URL) async throws -> (Data, HTTPURLResponse?) { try await withUnsafeThrowingContinuation { continuation in - let afRequest = AF.request(url, method: .get) - afRequest.validate().responseData { response in - if response.response?.statusCode == 404 { - let cleanedURL = self.cleanse(url: response.request?.url) - os_log("API 404: Not Found: %s", log: self.log, type: .info, cleanedURL.description) - let notFoundError = ResponseError( - type: .api, - statusCode: SubsonicError.requestedDataNotFound.rawValue, - message: "404: Not Found", - cleansedURL: cleanedURL, - data: response.data - ) - continuation.resume(throwing: notFoundError) - return - } + AF.request(url, method: .get).responseData { response in if let data = response.data { - continuation.resume(returning: APIDataResponse(data: data, url: url)) + continuation.resume( + returning: (data, response.response) + ) return } if let err = response.error { @@ -985,4 +995,31 @@ final class SubsonicServerApi: URLCleanser, Sendable { } } } + + private func processAFResponse( + url: URL, data: Data, httpResponse: HTTPURLResponse? + ) throws + -> APIDataResponse { + if httpResponse?.statusCode == 404 { + let cleanedURL = cleanse(url: url) + os_log("API 404: Not Found: %s", log: self.log, type: .info, cleanedURL.description) + throw ResponseError( + type: .api, + statusCode: SubsonicError.requestedDataNotFound.rawValue, + message: "404: Not Found", + cleansedURL: cleanedURL, + data: data + ) + } + if let statusCode = httpResponse?.statusCode, statusCode >= 400 { + throw ResponseError( + type: .api, + statusCode: statusCode, + message: "HTTP Error: \(statusCode)", + cleansedURL: cleanse(url: url), + data: data + ) + } + return APIDataResponse(data: data, url: url) + } } diff --git a/AmperfyKit/Download/PlayableDownloadDelegate.swift b/AmperfyKit/Download/PlayableDownloadDelegate.swift index aa791075..120d3099 100644 --- a/AmperfyKit/Download/PlayableDownloadDelegate.swift +++ b/AmperfyKit/Download/PlayableDownloadDelegate.swift @@ -84,6 +84,14 @@ final class PlayableDownloadDelegate: DownloadManagerDelegate { url: fileURL, maxFileSize: Self.maxFileSizeOfErrorResponse ) else { return nil } + if CaptivePortalDetector.isHTMLContent(data: data) { + return ResponseError( + type: .api, + message: "Network session expired. Please open the app to re-authenticate.", + cleansedURL: downloadURL?.asCleansedURL(cleanser: backendApi), + data: data + ) + } return backendApi.checkForErrorResponse(response: APIDataResponse( data: data, url: downloadURL diff --git a/AmperfyKit/Player/BackendAudioPlayer.swift b/AmperfyKit/Player/BackendAudioPlayer.swift index 7893bbca..b31751d4 100644 --- a/AmperfyKit/Player/BackendAudioPlayer.swift +++ b/AmperfyKit/Player/BackendAudioPlayer.swift @@ -639,14 +639,31 @@ class BackendAudioPlayer: NSObject { return } + let headers = Self.cookieHeaders(for: asset.url) + switch queueType { case .play: currentPreparedUrl = asset.url.absoluteString - player?.play(url: asset.url) + if headers.isEmpty { + player?.play(url: asset.url) + } else { + player?.play(url: asset.url, headers: headers) + } case .queue: nextPreloadedUrl = asset.url.absoluteString - player?.queue(url: asset.url) + if headers.isEmpty { + player?.queue(url: asset.url) + } else { + player?.queue(url: asset.url, headers: headers) + } + } + } + + private static func cookieHeaders(for url: URL) -> [String: String] { + guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { + return [:] } + return HTTPCookie.requestHeaderFields(with: cookies) } // MARK: - EQ Implementation diff --git a/AmperfyKitTests/Cases/API/CaptivePortalDetectorTest.swift b/AmperfyKitTests/Cases/API/CaptivePortalDetectorTest.swift new file mode 100644 index 00000000..b1980fdc --- /dev/null +++ b/AmperfyKitTests/Cases/API/CaptivePortalDetectorTest.swift @@ -0,0 +1,238 @@ +// +// CaptivePortalDetectorTest.swift +// AmperfyKitTests +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +@testable import AmperfyKit +import XCTest + +class CaptivePortalDetectorTest: XCTestCase { + let serverURL = URL(string: "https://music.example.com/api/ping")! + + override func setUp() {} + + override func tearDown() {} + + // MARK: - isCaptivePortalResponse + + func testNilResponse() { + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: nil, data: nil + ) + XCTAssertFalse(result) + } + + func testNormalJsonResponse() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let data = "{\"status\":\"ok\"}".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + func testNormalXmlResponse() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "application/xml"] + ) + let data = "".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + func testHtmlContentTypeDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] + ) + let data = "Login".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertTrue(result) + } + + func testHtmlContentTypeUppercaseDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "TEXT/HTML"] + ) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: nil + ) + XCTAssertTrue(result) + } + + func testDomainRedirectDetected() { + let redirectURL = URL(string: "https://auth.cloudflare.com/login")! + let response = HTTPURLResponse( + url: redirectURL, statusCode: 302, + httpVersion: nil, headerFields: nil + ) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: nil + ) + XCTAssertTrue(result) + } + + func test403WithHtmlBodyDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 403, + httpVersion: nil, headerFields: ["Content-Type": "application/octet-stream"] + ) + let data = "Access Denied".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertTrue(result) + } + + func test403WithNonHtmlBodyNotDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 403, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let data = "{\"error\":\"forbidden\"}".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + func test403WithNilDataNotDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 403, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: nil + ) + XCTAssertFalse(result) + } + + func testNon403ErrorWithHtmlBodyNotDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 500, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let data = "Server Error".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + // MARK: - isHTMLContent + + func testIsHTMLContentDoctype() { + let data = "test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentDoctypeUppercase() { + let data = "test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentHtmlTag() { + let data = "test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentWithLeadingWhitespace() { + let data = " \n test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentXml() { + let data = "".data(using: .utf8)! + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentJson() { + let data = "{\"status\":\"ok\",\"version\":\"1.16.1\"}".data(using: .utf8)! + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentEmptyData() { + let data = Data() + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentOnlyChecksPrefix() { + var content = String(repeating: "x", count: 600) + content += "" + let data = content.data(using: .utf8)! + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + // MARK: - isDomainRedirect + + func testDomainRedirectSameHost() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, httpVersion: nil, headerFields: nil + ) + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: response) + ) + } + + func testDomainRedirectDifferentHost() { + let redirectURL = URL(string: "https://idp.example.com/authorize")! + let response = HTTPURLResponse( + url: redirectURL, statusCode: 302, httpVersion: nil, headerFields: nil + ) + XCTAssertTrue( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: response) + ) + } + + func testDomainRedirectNilResponse() { + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: nil) + ) + } + + func testDomainRedirectCaseInsensitive() { + let requestURL = URL(string: "https://Music.Example.COM/api")! + let responseURL = URL(string: "https://music.example.com/api")! + let response = HTTPURLResponse( + url: responseURL, statusCode: 200, httpVersion: nil, headerFields: nil + ) + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: requestURL, response: response) + ) + } + + func testDomainRedirectDifferentPath() { + let responseURL = URL(string: "https://music.example.com/different/path")! + let response = HTTPURLResponse( + url: responseURL, statusCode: 200, httpVersion: nil, headerFields: nil + ) + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: response) + ) + } +} diff --git a/AmperfyKitTests/Cases/API/CaptivePortalSessionTest.swift b/AmperfyKitTests/Cases/API/CaptivePortalSessionTest.swift new file mode 100644 index 00000000..5f842384 --- /dev/null +++ b/AmperfyKitTests/Cases/API/CaptivePortalSessionTest.swift @@ -0,0 +1,192 @@ +// +// CaptivePortalSessionTest.swift +// AmperfyKitTests +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +@testable import AmperfyKit +import XCTest + +// MARK: - MOCK_CaptivePortalAuthHandler + +final class MOCK_CaptivePortalAuthHandler: CaptivePortalAuthHandler, @unchecked Sendable { + var authCallCount = 0 + var lastServerURL: URL? + var lastClearSession = false + var shouldThrow: CaptivePortalError? + + @MainActor + func performCaptivePortalAuth(serverURL: URL, clearSession: Bool) async throws { + authCallCount += 1 + lastServerURL = serverURL + lastClearSession = clearSession + if let error = shouldThrow { + throw error + } + } +} + +// MARK: - CaptivePortalSessionTest + +@MainActor +class CaptivePortalSessionTest: XCTestCase { + var session: CaptivePortalSession! + var mockHandler: MOCK_CaptivePortalAuthHandler! + + override func setUp() { + session = CaptivePortalSession.shared + session.resetForTesting() + mockHandler = MOCK_CaptivePortalAuthHandler() + session.authHandler = mockHandler + } + + override func tearDown() { + session.authHandler = nil + } + + // MARK: - authenticate + + func testAuthenticateCallsHandler() async throws { + let url = URL(string: "https://music.example.com")! + try await session.authenticate(serverURL: url) + XCTAssertEqual(mockHandler.authCallCount, 1) + XCTAssertEqual(mockHandler.lastServerURL, url) + } + + func testAuthenticateNoHandlerThrows() async { + session.authHandler = nil + let url = URL(string: "https://music.example.com")! + do { + try await session.authenticate(serverURL: url) + XCTFail("Expected error") + } catch let error as CaptivePortalError { + XCTAssertEqual(error, .noAuthHandler) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + func testAuthenticateForwardsHandlerError() async { + mockHandler.shouldThrow = .authenticationFailed + let url = URL(string: "https://music.example.com")! + do { + try await session.authenticate(serverURL: url) + XCTFail("Expected error") + } catch let error as CaptivePortalError { + XCTAssertEqual(error, .authenticationFailed) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + // MARK: - logout + + func testLogoutClearsCookies() { + let cookie = HTTPCookie(properties: [ + .name: "CF_Authorization", + .value: "test-token", + .domain: "music.example.com", + .path: "/", + ])! + HTTPCookieStorage.shared.setCookie(cookie) + + let beforeCount = HTTPCookieStorage.shared.cookies?.filter { + $0.name == "CF_Authorization" && $0.domain == "music.example.com" + }.count ?? 0 + XCTAssertEqual(beforeCount, 1) + + session.logout() + + let afterCount = HTTPCookieStorage.shared.cookies?.filter { + $0.name == "CF_Authorization" && $0.domain == "music.example.com" + }.count ?? 0 + XCTAssertEqual(afterCount, 0) + } + + func testLogoutClearsAllCookies() { + let serverCookie = HTTPCookie(properties: [ + .name: "CF_Authorization", + .value: "test-token", + .domain: "music.example.com", + .path: "/", + ])! + let auth0Cookie = HTTPCookie(properties: [ + .name: "auth0_session", + .value: "session-value", + .domain: "login.auth0.com", + .path: "/", + ])! + HTTPCookieStorage.shared.setCookie(serverCookie) + HTTPCookieStorage.shared.setCookie(auth0Cookie) + + session.logout() + + let remaining = HTTPCookieStorage.shared.cookies?.filter { + $0.name == "CF_Authorization" || $0.name == "auth0_session" + } ?? [] + XCTAssertEqual(remaining.count, 0) + } + + // MARK: - clearSession flag + + func testNoClearSessionByDefault() async throws { + let url = URL(string: "https://music.example.com")! + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + } + + func testClearSessionAfterLogout() async throws { + let url = URL(string: "https://music.example.com")! + session.logout() + try await session.authenticate(serverURL: url) + XCTAssertTrue(mockHandler.lastClearSession) + } + + func testClearSessionFlagConsumedAfterAuth() async throws { + let url = URL(string: "https://music.example.com")! + session.logout() + try await session.authenticate(serverURL: url) + XCTAssertTrue(mockHandler.lastClearSession) + + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + } + + func testClearSessionFlagNotSetOnSessionExpiry() async throws { + let url = URL(string: "https://music.example.com")! + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + } + + // MARK: - CaptivePortalError + + func testErrorDescriptions() { + XCTAssertNotNil(CaptivePortalError.captivePortalDetected.errorDescription) + XCTAssertNotNil(CaptivePortalError.noAuthHandler.errorDescription) + XCTAssertNotNil(CaptivePortalError.authenticationFailed.errorDescription) + XCTAssertNotNil(CaptivePortalError.userCancelled.errorDescription) + } + + func testErrorEquality() { + XCTAssertEqual(CaptivePortalError.captivePortalDetected, .captivePortalDetected) + XCTAssertNotEqual(CaptivePortalError.captivePortalDetected, .userCancelled) + } +}