@protobase/mobile 0.94.32 → 0.94.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,11 +3,12 @@ import Foundation
3
3
 
4
4
  /**
5
5
  * One sign-in request of ProtobaseAuth, checked in the order of Android's ProtobaseAuth.java and sent through
6
- * Capacitor's own request type, which refuses redirects, with the device's cookie jar. The call's options are never
7
- * handed to Capacitor code, so page script cannot add params, a file body, timeouts or redirects.
6
+ * Capacitor's own request type, which refuses redirects, with the session's cookies from SessionStore, which stay on
7
+ * this device. The call's options are never handed to Capacitor code, so page script cannot add params, a file body,
8
+ * timeouts or redirects.
8
9
  */
9
10
  enum AuthRequest {
10
- static func send(_ call: CAPPluginCall, config: PluginConfig, capConfig: InstanceConfiguration?) {
11
+ static func send(_ call: CAPPluginCall, config: PluginConfig) {
11
12
  guard let apiOrigin = config.getString("apiOrigin"), let origin = config.getString("origin") else {
12
13
  call.reject("ProtobaseAuth has no apiOrigin or origin in its config")
13
14
  return
@@ -39,28 +40,23 @@ enum AuthRequest {
39
40
  let request = CapacitorUrlRequest(url, method: method)
40
41
  request.setRequestHeaders(headers)
41
42
  if let body { request.request.httpBody = Data(body.utf8) }
42
- // The session holds its delegate, the request that refuses redirects, until it is invalidated.
43
- let session = URLSession(configuration: .default, delegate: request, delegateQueue: nil)
44
- let task = session.dataTask(with: request.getUrlRequest()) { data, response, error in
45
- session.invalidateAndCancel()
46
- if let error {
43
+ // The request refuses redirects as the session's delegate; the cookies come from and go to SessionStore alone.
44
+ SessionRequest.send(request.getUrlRequest(), store: SessionStore(keychain: DeviceKeychain()), delegate: request) { result in
45
+ switch result {
46
+ case .failure(let error as KeychainError):
47
+ call.reject("ProtobaseAuth cannot reach the session: \(error)")
48
+ case .failure(let error):
47
49
  call.reject(error.localizedDescription, (error as NSError).domain, error, nil)
48
- return
50
+ case .success(let (data, response)):
51
+ var output = HttpRequestHandler.buildResponse(data, response, responseType: .text)
52
+ // Set-Cookie stays native; buildResponse lower-cases the names.
53
+ if var answerHeaders = output["headers"] as? [String: Any] {
54
+ answerHeaders.removeValue(forKey: "set-cookie")
55
+ answerHeaders.removeValue(forKey: "set-cookie2")
56
+ output["headers"] = answerHeaders
57
+ }
58
+ call.resolve(output)
49
59
  }
50
- guard let response = response as? HTTPURLResponse else {
51
- call.reject("ProtobaseAuth got no HTTP answer")
52
- return
53
- }
54
- HttpRequestHandler.setCookiesFromResponse(response, capConfig)
55
- var output = HttpRequestHandler.buildResponse(data, response, responseType: .text)
56
- // Set-Cookie stays in the jar; buildResponse lower-cases the names.
57
- if var answerHeaders = output["headers"] as? [String: Any] {
58
- answerHeaders.removeValue(forKey: "set-cookie")
59
- answerHeaders.removeValue(forKey: "set-cookie2")
60
- output["headers"] = answerHeaders
61
- }
62
- call.resolve(output)
63
60
  }
64
- task.resume()
65
61
  }
66
62
  }
@@ -0,0 +1,28 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import WebKit
4
+
5
+ /**
6
+ * Removes everything a fresh install lacks, through the APIs that own each part rather than by WebKit's folders: the
7
+ * device's cookie jar, the session, the app's user defaults, the live update Capacitor would load and the bundles it
8
+ * downloaded, at once, so the bridge reads none of it; then the WebView's data (drafts, cookies, caches), which WebKit
9
+ * removes in its own time, calling `done` once it has. Main thread only.
10
+ */
11
+ enum DevicePurge {
12
+ static func run(done: @escaping () -> Void) throws {
13
+ HTTPCookieStorage.shared.removeCookies(since: .distantPast)
14
+ try SessionStore(keychain: DeviceKeychain()).save(.none)
15
+ if let id = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: id) }
16
+ try removeMissingOk { try KeyValueStore.standard.delete("serverBasePath") }
17
+ try removeMissingOk { try FileManager.default.removeItem(atPath: BundlesFolder.path) }
18
+ WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: .distantPast, completionHandler: done)
19
+ }
20
+
21
+ private static func removeMissingOk(_ remove: () throws -> Void) throws {
22
+ do {
23
+ try remove()
24
+ } catch let error as CocoaError where error.code == .fileNoSuchFile {
25
+ return
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,108 @@
1
+ import Foundation
2
+ import Security
3
+ import UIKit
4
+
5
+ /**
6
+ * What the app's AppDelegate calls first thing at launch, before the bridge reads what a live update left: an app a
7
+ * backup restored, even on the same iPhone, or one moved to another iPhone, starts as a fresh install, signed out with
8
+ * no drafts and no live update.
9
+ */
10
+ public enum ProtobaseFreshInstall {
11
+ public static func prepare() {
12
+ FreshInstall.device().prepare { error in
13
+ fatalError("Protobase cannot tell whether this install is new: \(error)")
14
+ }
15
+ }
16
+ }
17
+
18
+ /**
19
+ * An install knows itself by a random mark kept twice: in a keychain item that stays on this device, and in a file that
20
+ * no backup holds. A restore lacks the file and another iPhone lacks the item too, so either missing, or the two
21
+ * differing, purges everything the app kept, then sets the mark again. The page waits at PageGate until that is done.
22
+ * While the device is locked, as in a launch in the background before the first unlock, both are unreadable: it reads,
23
+ * purges and writes nothing until the device is unlocked.
24
+ */
25
+ struct FreshInstall {
26
+ static let service = "net.protobase.mobile.install"
27
+
28
+ let keychain: Keychain
29
+ let markerFile: URL
30
+ let gate: PageGate
31
+ /** Runs its closure on the main thread once protected data is available: now, or at the unlock. */
32
+ let whenUnlocked: (@escaping () -> Void) -> Void
33
+ /** Purges, throwing on what it removes at once, and calls its closure once the rest is removed too. */
34
+ let purge: (@escaping () -> Void) throws -> Void
35
+
36
+ static func device() -> FreshInstall {
37
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
38
+ return FreshInstall(
39
+ keychain: DeviceKeychain(),
40
+ markerFile: support.appendingPathComponent("net.protobase.mobile/install"),
41
+ gate: .shared,
42
+ whenUnlocked: whenProtectedDataAvailable,
43
+ purge: DevicePurge.run)
44
+ }
45
+
46
+ func prepare(fail: @escaping (Error) -> Void) {
47
+ gate.close()
48
+ whenUnlocked {
49
+ do {
50
+ try check(fail: fail)
51
+ } catch {
52
+ fail(error)
53
+ }
54
+ }
55
+ }
56
+
57
+ private func check(fail: @escaping (Error) -> Void) throws {
58
+ if try isKnown() {
59
+ gate.open()
60
+ return
61
+ }
62
+ try purge {
63
+ do {
64
+ try mark()
65
+ gate.open()
66
+ } catch {
67
+ fail(error)
68
+ }
69
+ }
70
+ }
71
+
72
+ private func isKnown() throws -> Bool {
73
+ guard let kept = try keychain.read(Self.service), FileManager.default.fileExists(atPath: markerFile.path) else { return false }
74
+ return try Data(contentsOf: markerFile) == kept
75
+ }
76
+
77
+ private func mark() throws {
78
+ var bytes = [UInt8](repeating: 0, count: 32)
79
+ let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
80
+ guard status == errSecSuccess else { throw KeychainError(status: status) }
81
+ try keychain.write(Self.service, Data(bytes))
82
+ try writeMarker(Data(bytes))
83
+ }
84
+
85
+ /** Writes the file aside, excludes it from backup, then moves it in place, so the file never stands unexcluded. */
86
+ private func writeMarker(_ mark: Data) throws {
87
+ let folder = markerFile.deletingLastPathComponent()
88
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
89
+ var aside = folder.appendingPathComponent(markerFile.lastPathComponent + ".new")
90
+ try mark.write(to: aside)
91
+ var values = URLResourceValues()
92
+ values.isExcludedFromBackup = true
93
+ try aside.setResourceValues(values)
94
+ _ = try FileManager.default.replaceItemAt(markerFile, withItemAt: aside)
95
+ }
96
+
97
+ private static func whenProtectedDataAvailable(_ run: @escaping () -> Void) {
98
+ if UIApplication.shared.isProtectedDataAvailable {
99
+ run()
100
+ return
101
+ }
102
+ var observer: NSObjectProtocol?
103
+ observer = NotificationCenter.default.addObserver(forName: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil, queue: .main) { _ in
104
+ if let observer { NotificationCenter.default.removeObserver(observer) }
105
+ run()
106
+ }
107
+ }
108
+ }
@@ -6,7 +6,7 @@ import WebKit
6
6
  * Capacitor's asset handler with LocalFileGuard in front. WebKit holds the handler from the WebView's creation, before
7
7
  * any plugin loads, so `install` switches the live instance to this class, which adds no stored state; the guard is an
8
8
  * associated object. One lock covers the check and the serving, and `setAssetPath`, so the base path cannot change
9
- * between them.
9
+ * between them. Every request waits at PageGate while a fresh install's purge runs.
10
10
  */
11
11
  final class GuardedAssetHandler: WebViewAssetHandler {
12
12
  private static var guardKey: UInt8 = 0
@@ -30,6 +30,16 @@ final class GuardedAssetHandler: WebViewAssetHandler {
30
30
  }
31
31
 
32
32
  override func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
33
+ PageGate.shared.whenOpen(ObjectIdentifier(urlSchemeTask)) { [self] in serve(webView, urlSchemeTask) }
34
+ }
35
+
36
+ // A request WebKit stops while it waits at the gate is never started.
37
+ override func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
38
+ if PageGate.shared.cancel(ObjectIdentifier(urlSchemeTask)) { return }
39
+ super.webView(webView, stop: urlSchemeTask)
40
+ }
41
+
42
+ private func serve(_ webView: WKWebView, _ urlSchemeTask: WKURLSchemeTask) {
33
43
  let fileGuard = fileGuard
34
44
  fileGuard.withLock {
35
45
  if fileGuard.refuses(path: urlSchemeTask.request.url?.path ?? "") {
@@ -0,0 +1,58 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ /** A keychain status other than found or not found; it names the status alone, never what the item holds. */
5
+ struct KeychainError: Error, CustomStringConvertible {
6
+ let status: OSStatus
7
+ var description: String { "the keychain answered OSStatus \(status)" }
8
+ }
9
+
10
+ /** Generic-password items by service: `read` is nil when there is none. */
11
+ protocol Keychain {
12
+ func read(_ service: String) throws -> Data?
13
+ func write(_ service: String, _ data: Data) throws
14
+ func delete(_ service: String) throws
15
+ }
16
+
17
+ /**
18
+ * The app's own keychain, whose items stay on this device: readable after the first unlock since it started, never
19
+ * synchronized to iCloud Keychain, and never restored to another device from a backup or a transfer.
20
+ */
21
+ struct DeviceKeychain: Keychain {
22
+ static func query(_ service: String) -> [String: Any] {
23
+ [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrSynchronizable as String: kCFBooleanFalse as Any]
24
+ }
25
+
26
+ static func attributes(_ data: Data) -> [String: Any] {
27
+ [kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecValueData as String: data]
28
+ }
29
+
30
+ static func addQuery(_ service: String, _ data: Data) -> [String: Any] {
31
+ query(service).merging(attributes(data)) { $1 }
32
+ }
33
+
34
+ func read(_ service: String) throws -> Data? {
35
+ var result: AnyObject?
36
+ let status = SecItemCopyMatching(Self.query(service).merging([kSecReturnData as String: true]) { $1 } as CFDictionary, &result)
37
+ if status == errSecItemNotFound { return nil }
38
+ guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) }
39
+ return data
40
+ }
41
+
42
+ /**
43
+ * Updates the item in place, its accessibility included, so a failed write keeps the item it had; adds it only when
44
+ * there is none.
45
+ */
46
+ func write(_ service: String, _ data: Data) throws {
47
+ let updated = SecItemUpdate(Self.query(service) as CFDictionary, Self.attributes(data) as CFDictionary)
48
+ if updated == errSecSuccess { return }
49
+ guard updated == errSecItemNotFound else { throw KeychainError(status: updated) }
50
+ let added = SecItemAdd(Self.addQuery(service, data) as CFDictionary, nil)
51
+ guard added == errSecSuccess else { throw KeychainError(status: added) }
52
+ }
53
+
54
+ func delete(_ service: String) throws {
55
+ let status = SecItemDelete(Self.query(service) as CFDictionary)
56
+ guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError(status: status) }
57
+ }
58
+ }
@@ -0,0 +1,40 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Holds the requests for the app's own files, its page among them, while a fresh install's purge runs, so no page script
5
+ * reads what it removes. Closed only by FreshInstall; main thread only, where WebKit and the purge call it.
6
+ */
7
+ final class PageGate {
8
+ static let shared = PageGate()
9
+
10
+ private(set) var isClosed = false
11
+ private var waiting: [(id: ObjectIdentifier, run: () -> Void)] = []
12
+
13
+ func close() {
14
+ isClosed = true
15
+ }
16
+
17
+ /** Opens the gate, running what waits in the order it came. */
18
+ func open() {
19
+ isClosed = false
20
+ let runs = waiting
21
+ waiting = []
22
+ for item in runs { item.run() }
23
+ }
24
+
25
+ /** Runs `run` now when the gate is open, else once it opens, unless `cancel(id)` comes first. */
26
+ func whenOpen(_ id: ObjectIdentifier, _ run: @escaping () -> Void) {
27
+ guard isClosed else {
28
+ run()
29
+ return
30
+ }
31
+ waiting.append((id, run))
32
+ }
33
+
34
+ /** Whether `id` was waiting; it no longer is. */
35
+ func cancel(_ id: ObjectIdentifier) -> Bool {
36
+ guard let index = waiting.firstIndex(where: { $0.id == id }) else { return false }
37
+ waiting.remove(at: index)
38
+ return true
39
+ }
40
+ }
@@ -23,6 +23,6 @@ public class ProtobaseAuthPlugin: CAPPlugin, CAPBridgedPlugin {
23
23
  }
24
24
 
25
25
  @objc func request(_ call: CAPPluginCall) {
26
- AuthRequest.send(call, config: getConfig(), capConfig: bridge?.config)
26
+ AuthRequest.send(call, config: getConfig())
27
27
  }
28
28
  }
@@ -0,0 +1,68 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The cookies ProtobaseAuth keeps for the API, the session among them, as a value: what an answer sets and what a request
5
+ * sends. It takes the place of the device's cookie jar and the WebView's, which iOS saves where a backup carries them;
6
+ * SessionStore keeps this one on the device alone.
7
+ */
8
+ struct SessionCookies: Codable, Equatable {
9
+ struct Cookie: Codable, Equatable {
10
+ var name: String
11
+ var value: String
12
+ var domain: String
13
+ var path: String
14
+ var expires: Date?
15
+ var secure: Bool
16
+ }
17
+
18
+ static let none = SessionCookies(cookies: [])
19
+
20
+ private(set) var cookies: [Cookie]
21
+
22
+ var isEmpty: Bool { cookies.isEmpty }
23
+
24
+ /**
25
+ * The cookies once `response` to a request for `url` arrived: each Set-Cookie replaces the cookie of its name, domain
26
+ * and path, and one that has expired, such as sign-out's `Max-Age=0`, removes it. `now` defaults to the time after
27
+ * parsing, as `Max-Age=0` expires the moment it is parsed.
28
+ */
29
+ func received(_ response: HTTPURLResponse, for url: URL, now: Date? = nil) -> SessionCookies {
30
+ var fields: [String: String] = [:]
31
+ for case let (name as String, value as String) in response.allHeaderFields { fields[name] = value }
32
+ let parsed = HTTPCookie.cookies(withResponseHeaderFields: fields, for: url)
33
+ let now = now ?? Date()
34
+ var kept = cookies
35
+ for cookie in parsed {
36
+ kept.removeAll { $0.name == cookie.name && $0.domain == cookie.domain && $0.path == cookie.path }
37
+ kept.append(Cookie(name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, expires: cookie.expiresDate, secure: cookie.isSecure))
38
+ }
39
+ return SessionCookies(cookies: kept.filter { !$0.hasExpired(now) })
40
+ }
41
+
42
+ /** The `Cookie` header for a request to `url`, or nil when no cookie goes there. */
43
+ func header(for url: URL, now: Date = Date()) -> String? {
44
+ let sent = cookies.filter { !$0.hasExpired(now) && $0.matches(url) }
45
+ guard !sent.isEmpty else { return nil }
46
+ return sent.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
47
+ }
48
+ }
49
+
50
+ extension SessionCookies.Cookie {
51
+ func hasExpired(_ now: Date) -> Bool {
52
+ guard let expires else { return false }
53
+ return expires <= now
54
+ }
55
+
56
+ /** RFC 6265's domain, path and secure match. */
57
+ func matches(_ url: URL) -> Bool {
58
+ guard let host = url.host?.lowercased() else { return false }
59
+ if secure && url.scheme?.lowercased() != "https" { return false }
60
+ let domain = domain.lowercased()
61
+ let domainMatch = domain.hasPrefix(".") ? host == String(domain.dropFirst()) || host.hasSuffix(domain) : host == domain
62
+ guard domainMatch else { return false }
63
+ let requestPath = url.path.isEmpty ? "/" : url.path
64
+ if requestPath == path { return true }
65
+ guard requestPath.hasPrefix(path) else { return false }
66
+ return path.hasSuffix("/") || requestPath.dropFirst(path.count).hasPrefix("/")
67
+ }
68
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Sends one sign-in request with the session's cookies from SessionStore and keeps what its answer sets there, never in
5
+ * the device's cookie jar or the WebView's, which a backup carries. Requests go one at a time, so two answers cannot
6
+ * each keep their own copy of the cookies and lose the other's.
7
+ */
8
+ enum SessionRequest {
9
+ private static let queue = DispatchQueue(label: "net.protobase.mobile.session")
10
+
11
+ /** A session that neither reads nor saves any cookie jar. */
12
+ static func configuration() -> URLSessionConfiguration {
13
+ let configuration = URLSessionConfiguration.ephemeral
14
+ configuration.httpCookieStorage = nil
15
+ configuration.httpShouldSetCookies = false
16
+ configuration.httpCookieAcceptPolicy = .never
17
+ return configuration
18
+ }
19
+
20
+ static func send(
21
+ _ request: URLRequest,
22
+ store: SessionStore,
23
+ delegate: URLSessionDelegate?,
24
+ configuration: URLSessionConfiguration = configuration(),
25
+ completion: @escaping (Result<(Data?, HTTPURLResponse), Error>) -> Void
26
+ ) {
27
+ queue.async {
28
+ completion(Result { try exchange(request, store: store, delegate: delegate, configuration: configuration) })
29
+ }
30
+ }
31
+
32
+ private struct NoHttpAnswer: LocalizedError {
33
+ var errorDescription: String? { "ProtobaseAuth got no HTTP answer" }
34
+ }
35
+
36
+ private static func exchange(_ given: URLRequest, store: SessionStore, delegate: URLSessionDelegate?, configuration: URLSessionConfiguration) throws -> (Data?, HTTPURLResponse) {
37
+ guard let url = given.url else { throw URLError(.badURL) }
38
+ let cookies = try store.load()
39
+ var request = given
40
+ request.httpShouldHandleCookies = false
41
+ request.setValue(cookies.header(for: url), forHTTPHeaderField: "Cookie")
42
+
43
+ var outcome: (Data?, URLResponse?, Error?) = (nil, nil, nil)
44
+ let done = DispatchSemaphore(value: 0)
45
+ // The session holds its delegate until it is invalidated.
46
+ let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
47
+ session.dataTask(with: request) { data, response, error in
48
+ outcome = (data, response, error)
49
+ done.signal()
50
+ }.resume()
51
+ done.wait()
52
+ session.invalidateAndCancel()
53
+
54
+ if let error = outcome.2 { throw error }
55
+ guard let response = outcome.1 as? HTTPURLResponse else { throw NoHttpAnswer() }
56
+ try store.save(cookies.received(response, for: url))
57
+ return (outcome.0, response)
58
+ }
59
+ }
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Where ProtobaseAuth keeps its cookies between launches: one keychain item that stays on this device, so no backup
5
+ * and no new iPhone holds the session. A keychain that cannot be read throws, and the sign-in call fails as signed out.
6
+ */
7
+ struct SessionStore {
8
+ static let service = "net.protobase.mobile.session"
9
+
10
+ let keychain: Keychain
11
+
12
+ /** The kept cookies; none when there is no item, and an item that does not decode is deleted and counts as none. */
13
+ func load() throws -> SessionCookies {
14
+ guard let data = try keychain.read(Self.service) else { return .none }
15
+ guard let cookies = try? JSONDecoder().decode(SessionCookies.self, from: data) else {
16
+ try keychain.delete(Self.service)
17
+ return .none
18
+ }
19
+ return cookies
20
+ }
21
+
22
+ /** Keeps `cookies`, deleting the item once none are left, as after signing out. */
23
+ func save(_ cookies: SessionCookies) throws {
24
+ guard !cookies.isEmpty else {
25
+ try keychain.delete(Self.service)
26
+ return
27
+ }
28
+ try keychain.write(Self.service, try JSONEncoder().encode(cookies))
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/mobile",
3
- "version": "0.94.32",
3
+ "version": "0.94.34",
4
4
  "description": "The Capacitor that protobase mobile builds a Protobase app's iOS and Android projects with, at fixed versions, and the native plugins that keep page script away from the session and give the iOS app its bounce; installed by the CLI on first use.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "android/build.gradle",
23
23
  "icon"
24
24
  ],
25
- "nativeContract": 11,
25
+ "nativeContract": 12,
26
26
  "capacitor": {
27
27
  "ios": {
28
28
  "src": "ios"