Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// `BrandedSwitchToggleStyle` at the hosting seams instead.
UISwitch.appearance().onTintColor = .haPrimary

// `lifecycleManager.didFinishLaunching()` schedules native requests, so the mirror starts first.
WebViewCookieMirror.shared.start()
lifecycleManager.didFinishLaunching()
setupDebugSwift()
FlightGreetingManager.shared.start()
Expand Down
2 changes: 1 addition & 1 deletion Sources/App/Assist/Audio/AudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ final class AudioPlayer: NSObject, AudioPlayerProtocol {
}

private func playStreaming(url: URL) {
let playerItem = AVPlayerItem(url: url)
let playerItem = AVPlayerItem(asset: AVURLAsset.withMirroredCookies(url: url))
player.replaceCurrentItem(with: playerItem)
player.play()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ final class CameraHLSAssetLoader: NSObject {
/// so the caller keeps the plain asset and this loader is not needed.
static func asset(for url: URL, api: HomeAssistantAPI) -> (asset: AVURLAsset, loader: CameraHLSAssetLoader?) {
guard needsCustomLoading(url: url, connection: api.server.info.connection) else {
return (AVURLAsset(url: url), nil)
return (AVURLAsset.withMirroredCookies(url: url), nil)
}

let asset = AVURLAsset(url: url, options: customLoadingOptions)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Network
import os
import Shared

@MainActor
class ConnectivityChecker {
Expand Down Expand Up @@ -227,6 +228,7 @@ class ConnectivityChecker {
request.httpMethod = "HEAD"

let config = URLSessionConfiguration.ephemeral
config.httpCookieStorage = HANetworkingEnvironment.current.cookieStorage
let session = URLSession(configuration: config)

let (_, response) = try await session.data(for: request)
Expand Down
32 changes: 32 additions & 0 deletions Sources/App/Frontend/WebView/WebViewCookieMirror.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Shared
import WebKit

/// Copies the frontend WebView's cookies into the cookie storage used by native requests.
///
/// WebKit keeps its cookies in its own store, and `URLSession` cannot read it. Without this, a
/// cookie issued to the frontend by an authentication proxy never reaches native requests. The
/// Android app's HTTP client reads its WebView's cookie manager for the same reason.
///
/// The copy only adds cookies, never deletes them. The native storage persists on disk, and the
/// first read at launch can come back empty before WebKit has loaded its store, so deleting
/// whatever that read lacks would wipe the native cookies at every launch.
///
/// A cookie that the WebView deletes early, on logout for instance, therefore stays with native
/// requests until it expires.
Comment thread
RReverser marked this conversation as resolved.
final class WebViewCookieMirror: NSObject, WKHTTPCookieStoreObserver {
static let shared = WebViewCookieMirror()

@MainActor
func start() {
let store = WKWebsiteDataStore.default().httpCookieStore
store.add(self)
cookiesDidChange(in: store)
}

func cookiesDidChange(in cookieStore: WKHTTPCookieStore) {
cookieStore.getAllCookies { cookies in
cookies.forEach(HANetworkingEnvironment.current.cookieStorage.setCookie)
Current.Log.verbose("Mirrored \(cookies.count) WebView cookies to the native cookie storage")
Comment thread
RReverser marked this conversation as resolved.
Comment thread
RReverser marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ final class WebhookDetailViewModel: ObservableObject {
let configuration = URLSessionConfiguration.ephemeral
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
configuration.httpCookieStorage = HANetworkingEnvironment.current.cookieStorage

let session = URLSession(
configuration: configuration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ final class CarPlayAssistSession: NSObject {
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil)
clearTTSPlayerObservers()

let playerItem = AVPlayerItem(url: url)
let playerItem = AVPlayerItem(asset: AVURLAsset.withMirroredCookies(url: url))
ttsPlayer.automaticallyWaitsToMinimizeStalling = Current.settingsStore
.carPlayAssistDebugSettings
.avPlayerAutomaticallyWaitsToMinimizeStalling
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
"AVURLAssetRequiresCustomURLLoadingKey": true,
])
} else {
asset = .init(url: url)
asset = .withMirroredCookies(url: url)
}

asset.resourceLoader.setDelegate(self, queue: .main)
Expand Down
17 changes: 17 additions & 0 deletions Sources/HANetworking/Sources/AVURLAsset+MirroredCookies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import AVFoundation

public extension AVURLAsset {
/// An asset for `url` that presents the cookies in `HANetworkingEnvironment.current.cookieStorage`.
///
/// AVFoundation loads media over its own connection, which reads no `URLSessionConfiguration`, so
/// without this a server behind cookie-based authentication rejects every media request.
static func withMirroredCookies(url: URL) -> AVURLAsset {
#if os(watchOS)
// The watch has no WebView to mirror, so its storage never holds these cookies.
return AVURLAsset(url: url)
#else
let cookies = HANetworkingEnvironment.current.cookieStorage.cookies(for: url) ?? []
return AVURLAsset(url: url, options: [AVURLAssetHTTPCookiesKey: cookies])
#endif
}
}
21 changes: 18 additions & 3 deletions Sources/HANetworking/Sources/AuthenticationAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,21 @@ public class AuthenticationAPI {

public init(server: Server) {
self.server = server
let configuration = URLSessionConfiguration.af.default
configuration.httpCookieStorage = HANetworkingEnvironment.current.cookieStorage
Comment thread
RReverser marked this conversation as resolved.
// Use custom delegate that supports client certificates (mTLS). Required on watchOS too:
// token refresh is a REST call, and an mTLS server rejects it without the client cert.
if server.info.connection.clientCertificate != nil {
self.session = Session(
configuration: configuration,
delegate: ClientCertificateSessionDelegate(server: server),
serverTrustManager: CustomServerTrustManager(server: server)
)
} else {
self.session = Session(serverTrustManager: CustomServerTrustManager(server: server))
self.session = Session(
configuration: configuration,
serverTrustManager: CustomServerTrustManager(server: server)
)
}
}

Expand Down Expand Up @@ -108,20 +114,29 @@ public class AuthenticationAPI {
clientCertificate: ClientCertificate? = nil
) -> Promise<TokenInfo> {
let session: Session
let configuration = URLSessionConfiguration.af.default
configuration.httpCookieStorage = HANetworkingEnvironment.current.cookieStorage

#if !os(watchOS)
if let clientCert = clientCertificate {
// Create a session delegate that handles client certificate challenges
let delegate = OnboardingClientCertificateDelegate(certificate: clientCert)
session = Session(
configuration: configuration,
delegate: delegate,
serverTrustManager: CustomServerTrustManager(exceptions: exceptions)
)
} else {
session = Session(serverTrustManager: CustomServerTrustManager(exceptions: exceptions))
session = Session(
configuration: configuration,
serverTrustManager: CustomServerTrustManager(exceptions: exceptions)
)
}
#else
session = Session(serverTrustManager: CustomServerTrustManager(exceptions: exceptions))
session = Session(
configuration: configuration,
serverTrustManager: CustomServerTrustManager(exceptions: exceptions)
)
#endif

return Promise { seal in
Expand Down
4 changes: 4 additions & 0 deletions Sources/HANetworking/Sources/HANetworkingEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public struct HANetworkingEnvironment {
/// HACore wires this to `Current.appConfiguration == .debug`.
public var isDebug: Bool = false

/// Cookie storage used by every native request. HACore wires this to the app-group storage,
/// which `WebViewCookieMirror` fills with the frontend WebView's cookies.
public var cookieStorage: HTTPCookieStorage = .shared

/// Invoked when the server permanently rejects the refresh token (reauthentication needed). HACore
/// wires this to log a client event, unsubscribe the model manager, disconnect the API connection,
/// and mark onboarding as needed (`.unauthenticated`). No-op by default.
Expand Down
2 changes: 2 additions & 0 deletions Sources/Shared/API/HAAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ public class HomeAssistantAPI {
) -> URLSession {
let certificateProvider = HomeAssistantCertificateProvider(server: server, onStep: onStep)
let delegate = HAURLSessionDelegate(certificateProvider: certificateProvider)
configuration.httpCookieStorage = HANetworkingEnvironment.current.cookieStorage
#if os(watchOS)
let delegateQueue: OperationQueue? = .main
#else
Expand Down Expand Up @@ -239,6 +240,7 @@ public class HomeAssistantAPI {

configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.httpCookieStorage = HANetworkingEnvironment.current.cookieStorage

return Alamofire.Session(
configuration: configuration,
Expand Down
3 changes: 1 addition & 2 deletions Sources/Shared/API/Webhook/Networking/WebhookManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,8 @@ class WebhookSessionInfo: CustomStringConvertible, Hashable {

return with(configuration) {
$0.sharedContainerIdentifier = AppConstants.AppGroupID
$0.httpCookieStorage = nil
$0.httpCookieStorage = HANetworkingEnvironment.current.cookieStorage
$0.httpCookieAcceptPolicy = .never
$0.httpShouldSetCookies = false
$0.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData

$0.httpAdditionalHeaders = [
Expand Down
5 changes: 5 additions & 0 deletions Sources/Shared/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ public class AppEnvironment {
HANetworkingEnvironment.current.bundleID = AppConstants.BundleID
HANetworkingEnvironment.current.defaultServerName = ServerInfo.defaultName
HANetworkingEnvironment.current.isDebug = Current.appConfiguration == .debug
// `.shared` is per process, and the extensions and the background webhook session are
// separate processes.
HANetworkingEnvironment.current.cookieStorage = .sharedCookieStorage(
forGroupContainerIdentifier: AppConstants.AppGroupID
)
HANetworkingEnvironment.current.handleReauthenticationRequired = { server, statusCode, errorDescription in
Current.clientEventStore.addEvent(ClientEvent(
text: "Refresh token is invalid, notifying user",
Expand Down
16 changes: 16 additions & 0 deletions Tests/App/Assist/AudioPlayerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@testable import HomeAssistant
import Testing

struct AudioPlayerTests {
/// AVPlayer streams the reply over its own connection, so the cookies used by native requests
/// only reach the server if the player's asset carries them.
@Test("Streaming a reply sends the mirrored cookies")
func streamingSendsMirroredCookies() async throws {
let player = AudioPlayer()
defer { player.pause() }

try await CookieRecordingServer.expectMirroredCookie(path: "api/tts_proxy/reply.mp3") { url in
player.play(url: url, server: nil)
}
}
}
12 changes: 12 additions & 0 deletions Tests/App/Cameras/CameraHLSAssetLoaderTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AVFoundation
import HAKit_Mocks
@testable import HomeAssistant
@testable import Shared
Expand Down Expand Up @@ -80,6 +81,17 @@ final class CameraHLSAssetLoaderTests: XCTestCase {
XCTAssertNotNil(loader)
}

/// AVFoundation loads the stream over its own connection, so the cookies used by native requests
/// only reach the server if the asset carries them.
func testAPlainServerIsSentTheMirroredCookies() async throws {
let (api, cleanUp) = makeAPI()
defer { cleanUp() }

try await CookieRecordingServer.expectMirroredCookie(path: "api/hls/index.m3u8") { url in
_ = try await CameraHLSAssetLoader.asset(for: url, api: api).asset.load(.duration)
}
}

/// Builds an API against a throwaway server, optionally with its connection adjusted, and hands
/// back the teardown that puts the environment's servers back.
private func makeAPI(
Expand Down
11 changes: 11 additions & 0 deletions Tests/App/CarPlay/CarPlayAssistSession.test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ final class CarPlayAssistSessionTests: XCTestCase {
XCTAssertEqual(sut.currentState, .processing)
}

/// AVPlayer streams the spoken reply over its own connection, so the cookies used by native
/// requests only reach the server if the player's asset carries them.
func testServerTTSStreamIsSentTheMirroredCookies() async throws {
let sut = makeSut()
sut.start()

try await CookieRecordingServer.expectMirroredCookie(path: "api/tts_proxy/reply.mp3") { url in
await MainActor.run { sut.didReceiveTtsMediaUrl(url) }
}
}

func testIntentEndTransitionsToResponding() {
let sut = makeSut()
sut.start()
Expand Down
113 changes: 113 additions & 0 deletions Tests/App/Utilities/CookieRecordingServer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Foundation
import Network
import PromiseKit
@testable import Shared

/// A loopback HTTP server that answers every request with an empty `200` and records the `Cookie`
/// header each request carried, so a test can check what actually went out on the wire.
///
/// A real listener is needed here, not an OHHTTPStubs stub. AVFoundation loads media outside the
/// app's process, where no `URLProtocol` sees the request, and URLSession adds the cookies from
/// `httpCookieStorage` in its own HTTP handler, which a `URLProtocol` stub replaces.
final class CookieRecordingServer {
private let listener: NWListener
/// Every piece of state below is touched on this queue only.
private let queue = DispatchQueue(label: "CookieRecordingServer")
private var recorded: [String] = []
private var waiters: [(cookie: String, resolver: Resolver<Void>)] = []

/// Puts a cookie for a new server in `HANetworkingEnvironment.current.cookieStorage`, starts `send`
/// with the server's URL, and throws unless a request that `send` makes carries that cookie.
///
/// The cookie arrives with the request, before any response, so `send` runs in a task of its own
/// and a client that never finishes with the empty response cannot hang the test.
static func expectMirroredCookie(
path: String = "",
send: @escaping (URL) async throws -> Void
) async throws {
let server = try CookieRecordingServer()
let url = try await server.start().appendingPathComponent(path)

// Tests run in parallel against the one shared storage, so each cookie needs its own name.
let name = "test_\(UUID().uuidString.prefix(8))"
guard let cookie = HTTPCookie(properties: [
.domain: "127.0.0.1", .path: "/", .name: name, .value: "1",
]) else {
throw URLError(.badURL)
}
HANetworkingEnvironment.current.cookieStorage.setCookie(cookie)
defer { HANetworkingEnvironment.current.cookieStorage.deleteCookie(cookie) }

let sending = Task { try await send(url) }
defer { sending.cancel() }
try await server.waitForCookie("\(name)=1")
}

private init() throws {
self.listener = try NWListener(using: .tcp, on: .any)
}

deinit {
listener.cancel()
}

private func start() async throws -> URL {
let (ready, resolver) = Promise<Void>.pending()
listener.stateUpdateHandler = { state in
switch state {
case .ready: resolver.fulfill(())
case let .failed(error): resolver.reject(error)
default: break
}
}
listener.newConnectionHandler = { [weak self] connection in
self?.answer(connection)
}
listener.start(queue: queue)

try await ready.asyncValue(timeout: 5)
return URL(string: "http://127.0.0.1:\(listener.port!.rawValue)")!
}

/// Waits for a request whose `Cookie` header contains `cookie`, and throws if none arrives.
private func waitForCookie(_ cookie: String) async throws {
let (arrived, resolver) = Promise<Void>.pending()
queue.async { [self] in
if recorded.contains(where: { $0.contains(cookie) }) {
resolver.fulfill(())
} else {
waiters.append((cookie, resolver))
}
}
try await arrived.asyncValue(timeout: 5)
}

private func answer(_ connection: NWConnection) {
connection.start(queue: queue)
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, _, _ in
// A connection closed without a request, such as a port probe, has nothing to record.
guard let data, let request = String(data: data, encoding: .utf8) else {
connection.cancel()
return
}
self?.record(request)

let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in
connection.cancel()
})
}
}

private func record(_ request: String) {
guard let line = request.components(separatedBy: "\r\n").first(where: {
$0.lowercased().hasPrefix("cookie:")
}) else { return }

let header = line.dropFirst("cookie:".count).trimmingCharacters(in: .whitespaces)
recorded.append(header)
for waiter in waiters where header.contains(waiter.cookie) {
waiter.resolver.fulfill(())
}
}
}
Loading
Loading