@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.
- package/ios/Sources/ProtobaseMobile/AuthRequest.swift +19 -23
- package/ios/Sources/ProtobaseMobile/DevicePurge.swift +28 -0
- package/ios/Sources/ProtobaseMobile/FreshInstall.swift +108 -0
- package/ios/Sources/ProtobaseMobile/GuardedAssetHandler.swift +11 -1
- package/ios/Sources/ProtobaseMobile/Keychain.swift +58 -0
- package/ios/Sources/ProtobaseMobile/PageGate.swift +40 -0
- package/ios/Sources/ProtobaseMobile/ProtobaseAuthPlugin.swift +1 -1
- package/ios/Sources/ProtobaseMobile/SessionCookies.swift +68 -0
- package/ios/Sources/ProtobaseMobile/SessionRequest.swift +59 -0
- package/ios/Sources/ProtobaseMobile/SessionStore.swift +30 -0
- package/package.json +2 -2
|
@@ -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
|
|
7
|
-
* handed to Capacitor code, so page script cannot add params, a file body,
|
|
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
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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.
|
|
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":
|
|
25
|
+
"nativeContract": 12,
|
|
26
26
|
"capacitor": {
|
|
27
27
|
"ios": {
|
|
28
28
|
"src": "ios"
|