Repository navigation
Present the WebView's cookies on native requests #5912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
RReverser
wants to merge
1
commit into
home-assistant:main
Choose a base branch
from
RReverser:webview-cookies-on-native-requests
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| 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") | ||
|
RReverser marked this conversation as resolved.
RReverser marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
Sources/HANetworking/Sources/AVURLAsset+MirroredCookies.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(()) | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.