diff --git a/ios/.gitignore b/ios/.gitignore index 76a4e67..15ca8d8 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -1,3 +1,4 @@ *.xcodeproj/ build/ DerivedData/ +Resources/Info.plist diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 2787637..89985a4 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -7,3 +7,4 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1. ### Added - iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418). +- Player tokens (shake, then Player Tokens): an ipx API token and a Cloudflare Access service token, kept in the keychain, which the player sends instead of the page's sign-in cookie, so playback does not stop when that cookie expires (#419). diff --git a/ios/README.md b/ios/README.md index de98870..2aed1b5 100644 --- a/ios/README.md +++ b/ios/README.md @@ -29,6 +29,16 @@ Shake the phone to change it. Plain `http://` only on your own network that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the player uses (`CookieBridge`). `-ipx.server ` as a launch argument overrides it for a run. +## Player tokens + +The player normally rides on the web view's cookie, which ends with Cloudflare Access's session. +To make it outlast that, shake the phone, choose Player Tokens and enter three things, all kept +in the keychain (`Credentials.swift`): an ipx API token (the page's Settings, API tokens), and the +Client ID and Client Secret of an Access service token (Zero Trust, Access, Service credentials), +which the `ipodderx` application needs a *Service Auth* policy for. Without all three the player +sends only the cookie, as before. Playback's own requests use them (the audio, positions, the +artwork); the page still signs in as it did. + ## How it hangs together | | | @@ -73,7 +83,7 @@ its own clock, so both happen while the page is frozen. Video stays in the page. * Episodes kept on the device (#412) are the page's service worker's, which a `WKWebView` does not run without App-Bound Domains; the app plays them from the server. -* The cookie is the only credential: when Cloudflare Access's expires mid-drive, playback stops - with "Sign in again", and opening the app signs in. A token in the keychain would end that. +* Without the player tokens the cookie is the only credential: when Cloudflare Access's expires + mid-drive, playback stops with "Sign in again", and opening the app signs in. * CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same `Playback`. diff --git a/ios/Sources/Credentials.swift b/ios/Sources/Credentials.swift new file mode 100644 index 0000000..4f20e9d --- /dev/null +++ b/ios/Sources/Credentials.swift @@ -0,0 +1,60 @@ +import Foundation +import Security + +/// What the player and CarPlay send instead of the web view's cookie (#419). +/// +/// The cookie Cloudflare Access leaves in the web view lasts as long as its session, and when it +/// ends mid-drive the player has nothing. Two things that do not end by themselves replace it: +/// an ipx API token (Settings, API tokens), which ipx reads as `Authorization: Bearer`, and an +/// Access service token (Zero Trust, Service Auth), which Cloudflare reads as +/// `CF-Access-Client-Id` and `CF-Access-Client-Secret` before the request reaches ipx. Either +/// half alone is refused somewhere, so `headers` is empty unless it has all three, and the player +/// then falls back to the cookie as before. +enum Credentials { + enum Field: String, CaseIterable { + case apiToken = "ipx.api-token" + case accessId = "ipx.access-client-id" + case accessSecret = "ipx.access-client-secret" + } + + private static let service = "net.sdf1.ipx" + + static subscript(field: Field) -> String? { + get { + var query = base(field) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var out: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, + let data = out as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + set { + SecItemDelete(base(field) as CFDictionary) + guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return } + var item = base(field) + item[kSecValueData as String] = Data(value.utf8) + // After first unlock, not when unlocked: CarPlay and a locked phone both play. + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(item as CFDictionary, nil) + } + } + + private static func base(_ field: Field) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: field.rawValue] + } + + /// The headers for a request to the server, or none when any of the three is missing. + static var headers: [String: String] { + guard let token = self[.apiToken], let id = self[.accessId], + let secret = self[.accessSecret] else { return [:] } + return ["Authorization": "Bearer \(token)", + "CF-Access-Client-Id": id, + "CF-Access-Client-Secret": secret] + } + + static var isSet: Bool { !headers.isEmpty } +} diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift index 91a70d9..6176669 100644 --- a/ios/Sources/Playback.swift +++ b/ios/Sources/Playback.swift @@ -69,10 +69,13 @@ final class Playback: NSObject { lastSaved = 0 // An AVURLAsset does not read the shared cookie store by itself, and the cookie is how - // ipx knows whose file this is. - let asset = AVURLAsset(url: item.url, options: [ + // ipx knows whose file this is, unless the credentials are set, which do not expire. + // The header option has no constant in the SDK; AVFoundation reads it by this name. + var options: [String: Any] = [ AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [], - ]) + ] + if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers } + let asset = AVURLAsset(url: item.url, options: options) let playerItem = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: playerItem) player.volume = volume @@ -92,7 +95,8 @@ final class Playback: NSObject { // second has something to do about it. let msg = pi.error?.localizedDescription ?? "the file could not be played" let status = await self.request("GET", "/api/me") - self.onError?(status == 401 ? "Sign in again" : msg) + let expired = Credentials.isSet ? "Check the tokens in Settings" : "Sign in again" + self.onError?(status == 401 || status == 403 ? expired : msg) default: break } } @@ -254,7 +258,9 @@ final class Playback: NSObject { guard let art = item.artwork, let url = Server.resolve(art) else { return } Task { - guard let (data, _) = try? await session.data(from: url), + var artReq = URLRequest(url: url) + Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) } + guard let (data, _) = try? await session.data(for: artReq), let image = UIImage(data: data), self.item?.guid == item.guid else { return } // Read back, not `info`: by the time the picture arrives that elapsed time is stale, // and writing it would jerk the progress bar back. @@ -285,13 +291,14 @@ final class Playback: NSObject { return "/api/entries/\(esc(item.feedId))/\(esc(item.guid))/\(what)" } - /// One call to ipx, signed in by the web view's cookies; its status, or nil if it never + /// One call to ipx, signed in by the credentials or else the web view's cookies; its status, or nil if it never /// arrived. A position that does not save is saved again ten seconds on. @discardableResult private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? { guard let url = Server.resolve(path) else { return nil } var req = URLRequest(url: url) req.httpMethod = method + Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) } if let json { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: json) diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift index eb5c900..22ca204 100644 --- a/ios/Sources/WebViewController.swift +++ b/ios/Sources/WebViewController.swift @@ -63,6 +63,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg f.clearButtonMode = .whileEditing } if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) } + alert.addAction(UIAlertAction(title: "Player Tokens…", style: .default) { [weak self] _ in + self?.askForCredentials() + }) alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in guard let self else { return } let text = alert?.textFields?.first?.text ?? "" @@ -78,6 +81,40 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg present(alert, animated: true) } + /// The tokens the player and CarPlay use in place of the page's cookie (#419). Left as they + /// are when a field is empty; all three emptied clears them. + private func askForCredentials() { + guard presentedViewController == nil else { return } + let alert = UIAlertController( + title: "Player tokens", + message: "An ipx API token (Settings, API tokens) and a Cloudflare Access service token, " + + "so playback and CarPlay keep working when the page's sign-in ends. " + + (Credentials.isSet ? "Set now; leave a field empty to keep it." : "Not set."), + preferredStyle: .alert) + let fields: [(String, Credentials.Field)] = [ + ("ipx API token", .apiToken), ("Access client ID", .accessId), + ("Access client secret", .accessSecret), + ] + for (placeholder, _) in fields { + alert.addTextField { f in + f.placeholder = placeholder + f.isSecureTextEntry = true + f.autocapitalizationType = .none + f.autocorrectionType = .no + } + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in + Credentials.Field.allCases.forEach { Credentials[$0] = nil } + }) + alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak alert] _ in + for (i, (_, field)) in fields.enumerated() { + if let v = alert?.textFields?[i].text, !v.isEmpty { Credentials[field] = v } + } + }) + present(alert, animated: true) + } + // MARK: - the page /// Sign-in leaves the server's address (Cloudflare Access, its identity provider) and must diff --git a/ios/project.yml b/ios/project.yml index 390466b..249c7f7 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -29,6 +29,8 @@ targets: # Playing on with the screen locked. A plist key, not a signed entitlement, so it works on # a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant. UIBackgroundModes: [audio] + # HTTPS only, so TestFlight need not ask the export-compliance question on every build. + ITSAppUsesNonExemptEncryption: false UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -38,6 +40,7 @@ targets: UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight # Plain HTTP only for a server on your own network (192.168.1.130:8099); the tunnel is HTTPS. @@ -46,7 +49,8 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx - MARKETING_VERSION: "0.1.0-dev" + MARKETING_VERSION: "0.1.0" + DEVELOPMENT_TEAM: 8743L8D729 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon iPXTests: