@unisim/sdk 0.122.3 → 0.123.0

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.
@@ -0,0 +1,241 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import Security
4
+
5
+ /// Stores the Universal Suite session in the iOS Keychain, in a group shared by
6
+ /// every suite app signed by the same team — so signing in on Universal Images
7
+ /// leaves you signed in when you open Universal PDF.
8
+ ///
9
+ /// It exists because a Capacitor app runs at `capacitor://localhost`, where the
10
+ /// suite's cross-subdomain auth cookie cannot be written at all: the browser
11
+ /// rejects `Domain=.unisim.co.uk` from an origin outside that zone. Before this
12
+ /// plugin the native builds could not persist a session even to THEMSELVES —
13
+ /// sign in, force-quit, signed out again, nothing logged. See
14
+ /// `src/sessionStorage.ts` and its browser test.
15
+ ///
16
+ /// ⚠️ **Sharing needs an entitlement the plugin cannot add for you.** The host
17
+ /// app must carry an `.entitlements` file declaring the shared group:
18
+ ///
19
+ /// <key>keychain-access-groups</key>
20
+ /// <array><string>$(AppIdentifierPrefix)co.uk.unisim.suite</string></array>
21
+ ///
22
+ /// and reference it from `CODE_SIGN_ENTITLEMENTS` in the Xcode project. No
23
+ /// `capacitor.config.json` change is needed — the team prefix is resolved at
24
+ /// runtime. An app with NO entitlements file cannot use the Keychain at all:
25
+ /// every call comes back `errSecMissingEntitlement` (-34018), which is also
26
+ /// what an unsigned simulator build gets.
27
+ ///
28
+ /// Without a resolvable group the plugin still stores the session, app-locally:
29
+ /// it persists across launches but does NOT carry to the other suite apps.
30
+ /// `status()` reports which of the two you actually got, because nothing about
31
+ /// the behaviour makes that visible from the outside.
32
+ @objc(UnisimSuiteAuthPlugin)
33
+ public class UnisimSuiteAuthPlugin: CAPPlugin, CAPBridgedPlugin {
34
+ public let identifier = "UnisimSuiteAuthPlugin"
35
+ public let jsName = "UnisimSuiteAuth"
36
+ public let pluginMethods: [CAPPluginMethod] = [
37
+ CAPPluginMethod(name: "get", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "remove", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "status", returnType: CAPPluginReturnPromise)
41
+ ]
42
+
43
+ /// One service for the whole suite. The `key` the JS side passes (Supabase's
44
+ /// `storageKey`, i.e. `universal-suite-auth`) becomes the account, so other
45
+ /// suite keys can share the same group later without colliding.
46
+ private static let service = "co.uk.unisim.suite.auth"
47
+
48
+ /// The group every suite app shares. Prefixed with the team's app-identifier
49
+ /// prefix at runtime — see `appIdentifierPrefix()`.
50
+ private static let defaultGroupSuffix = "co.uk.unisim.suite"
51
+
52
+ private static var cachedPrefix: String??
53
+
54
+ /// The team prefix iOS stamps on this app's keychain items, e.g. the
55
+ /// `ABCDE12345` in `ABCDE12345.co.uk.unisim.pdf`.
56
+ ///
57
+ /// ⚠️ Resolved at RUNTIME rather than hardcoded, and that is deliberate: the
58
+ /// entitlement is written as `$(AppIdentifierPrefix)co.uk.unisim.suite`, and
59
+ /// `kSecAttrAccessGroup` will only accept the expanded form. Baking a team
60
+ /// ID into the config of thirteen apps means thirteen places to be wrong,
61
+ /// and a wrong one fails as `errSecMissingEntitlement` at sign-in — far from
62
+ /// where the mistake was made.
63
+ ///
64
+ /// The trick is the documented one: write an item with NO access group, and
65
+ /// read back the group iOS assigned it, which is always
66
+ /// `<prefix>.<bundle id>`.
67
+ private static func appIdentifierPrefix() -> String? {
68
+ if let cached = cachedPrefix { return cached }
69
+
70
+ let base: [String: Any] = [
71
+ kSecClass as String: kSecClassGenericPassword,
72
+ kSecAttrService as String: service,
73
+ kSecAttrAccount as String: "unisim.suite.prefix-probe"
74
+ ]
75
+
76
+ var query = base
77
+ query[kSecReturnAttributes as String] = true
78
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
79
+
80
+ var item: CFTypeRef?
81
+ var status = SecItemCopyMatching(query as CFDictionary, &item)
82
+ if status == errSecItemNotFound {
83
+ var insert = base
84
+ insert[kSecValueData as String] = Data()
85
+ insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
86
+ insert[kSecReturnAttributes as String] = true
87
+ status = SecItemAdd(insert as CFDictionary, &item)
88
+ }
89
+
90
+ guard status == errSecSuccess,
91
+ let attributes = item as? [String: Any],
92
+ let group = attributes[kSecAttrAccessGroup as String] as? String,
93
+ let dot = group.firstIndex(of: ".") else {
94
+ cachedPrefix = .some(nil)
95
+ return nil
96
+ }
97
+
98
+ let prefix = String(group[group.startIndex..<dot])
99
+ cachedPrefix = .some(prefix)
100
+ return prefix
101
+ }
102
+
103
+ /// The group to store under, or nil to fall back to an app-local item.
104
+ private var accessGroup: String? {
105
+ // An explicit group wins — an escape hatch for an app that needs to sit
106
+ // outside the suite group, and the only way to override the runtime
107
+ // resolution if it ever gets this wrong.
108
+ if let explicit = getConfig().getString("accessGroup"), !explicit.isEmpty {
109
+ return explicit
110
+ }
111
+ let suffix = getConfig().getString("accessGroupSuffix") ?? Self.defaultGroupSuffix
112
+ guard let prefix = Self.appIdentifierPrefix() else { return nil }
113
+ return "\(prefix).\(suffix)"
114
+ }
115
+
116
+ private func baseQuery(for key: String) -> [String: Any] {
117
+ var query: [String: Any] = [
118
+ kSecClass as String: kSecClassGenericPassword,
119
+ kSecAttrService as String: Self.service,
120
+ kSecAttrAccount as String: key
121
+ ]
122
+ if let group = accessGroup {
123
+ query[kSecAttrAccessGroup as String] = group
124
+ }
125
+ return query
126
+ }
127
+
128
+ /// ⚠️ Put the OSStatus in the MESSAGE, not just the code. Every one of these
129
+ /// failures is indistinguishable from the JS side otherwise — "Keychain
130
+ /// update failed" was all that came back from the first simulator run, and
131
+ /// the number was the only thing that would have identified it.
132
+ ///
133
+ /// The one worth recognising is **-34018 `errSecMissingEntitlement`**, which
134
+ /// means the process has no keychain entitlement to work with at all. Two
135
+ /// ways to get there: an app built with code signing disabled (an unsigned
136
+ /// simulator build has no `application-identifier`, so the Keychain refuses
137
+ /// everything), or a configured `accessGroup` that is not listed in the
138
+ /// app's `keychain-access-groups` — including one missing the team prefix.
139
+ private static func describe(_ operation: String, _ status: OSStatus) -> String {
140
+ let detail = SecCopyErrorMessageString(status, nil) as String? ?? "unknown error"
141
+ let hint = status == errSecMissingEntitlement
142
+ ? " — the app has no usable keychain entitlement (unsigned build, or accessGroup not in keychain-access-groups)"
143
+ : ""
144
+ return "Keychain \(operation) failed: OSStatus \(status) (\(detail))\(hint)"
145
+ }
146
+
147
+ @objc func status(_ call: CAPPluginCall) {
148
+ let group = accessGroup
149
+ call.resolve([
150
+ "shared": group != nil,
151
+ "accessGroup": group ?? NSNull(),
152
+ "appIdentifierPrefix": Self.appIdentifierPrefix() ?? NSNull()
153
+ ])
154
+ }
155
+
156
+ @objc func get(_ call: CAPPluginCall) {
157
+ guard let key = call.getString("key") else {
158
+ call.reject("key is required")
159
+ return
160
+ }
161
+
162
+ var query = baseQuery(for: key)
163
+ query[kSecReturnData as String] = true
164
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
165
+
166
+ var item: CFTypeRef?
167
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
168
+
169
+ switch status {
170
+ case errSecSuccess:
171
+ guard let data = item as? Data, let value = String(data: data, encoding: .utf8) else {
172
+ // Present but unreadable — treat as absent rather than throwing,
173
+ // so a corrupt item shows as "signed out" and can be overwritten
174
+ // by the next sign-in instead of wedging the app.
175
+ call.resolve(["value": NSNull()])
176
+ return
177
+ }
178
+ call.resolve(["value": value])
179
+ case errSecItemNotFound:
180
+ // ⚠️ NOT an error, and the JS side must not treat it as one. "No
181
+ // session in the shared store" is exactly what another suite app
182
+ // signing out looks like.
183
+ call.resolve(["value": NSNull()])
184
+ default:
185
+ call.reject(Self.describe("read", status), String(status))
186
+ }
187
+ }
188
+
189
+ @objc func set(_ call: CAPPluginCall) {
190
+ guard let key = call.getString("key") else {
191
+ call.reject("key is required")
192
+ return
193
+ }
194
+ guard let value = call.getString("value"), let data = value.data(using: .utf8) else {
195
+ call.reject("value is required")
196
+ return
197
+ }
198
+
199
+ let query = baseQuery(for: key)
200
+ let attributes: [String: Any] = [
201
+ kSecValueData as String: data,
202
+ // Readable once the device has been unlocked after a reboot, and
203
+ // never synced to iCloud — a bearer token has no business on
204
+ // another device.
205
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
206
+ ]
207
+
208
+ let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
209
+ if updateStatus == errSecSuccess {
210
+ call.resolve()
211
+ return
212
+ }
213
+ if updateStatus != errSecItemNotFound {
214
+ call.reject(Self.describe("update", updateStatus), String(updateStatus))
215
+ return
216
+ }
217
+
218
+ var insert = query
219
+ insert.merge(attributes) { current, _ in current }
220
+ let addStatus = SecItemAdd(insert as CFDictionary, nil)
221
+ if addStatus == errSecSuccess {
222
+ call.resolve()
223
+ } else {
224
+ call.reject(Self.describe("write", addStatus), String(addStatus))
225
+ }
226
+ }
227
+
228
+ @objc func remove(_ call: CAPPluginCall) {
229
+ guard let key = call.getString("key") else {
230
+ call.reject("key is required")
231
+ return
232
+ }
233
+
234
+ let status = SecItemDelete(baseQuery(for: key) as CFDictionary)
235
+ if status == errSecSuccess || status == errSecItemNotFound {
236
+ call.resolve()
237
+ } else {
238
+ call.reject(Self.describe("delete", status), String(status))
239
+ }
240
+ }
241
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unisim/sdk",
3
- "version": "0.122.3",
4
- "description": "Shared React SDK for the Universal Suite auth, entitlements, usage telemetry, changelog, org admin.",
3
+ "version": "0.123.0",
4
+ "description": "Shared React SDK for the Universal Suite \u2014 auth, entitlements, usage telemetry, changelog, org admin.",
5
5
  "license": "MIT",
6
6
  "author": "Universal Simulation Ltd",
7
7
  "keywords": [
@@ -28,6 +28,11 @@
28
28
  "module": "./dist/index.js",
29
29
  "types": "./dist/index.d.ts",
30
30
  "sideEffects": false,
31
+ "capacitor": {
32
+ "ios": {
33
+ "src": "ios"
34
+ }
35
+ },
31
36
  "exports": {
32
37
  ".": {
33
38
  "types": "./dist/index.d.ts",
@@ -40,12 +45,16 @@
40
45
  "files": [
41
46
  "dist",
42
47
  "electron",
48
+ "ios/Sources",
49
+ "Package.swift",
50
+ "UnisimSdk.podspec",
43
51
  "README.md"
44
52
  ],
45
53
  "scripts": {
46
54
  "build": "tsc -p tsconfig.build.json",
47
55
  "dev": "tsc -p tsconfig.build.json --watch",
48
56
  "typecheck": "tsc --noEmit",
57
+ "test:session-storage": "npm run build && node tests/session-storage.browser.mjs",
49
58
  "prepublishOnly": "npm run typecheck && npm run build"
50
59
  },
51
60
  "dependencies": {
@@ -62,6 +71,7 @@
62
71
  "@types/react": "^18.3.0",
63
72
  "@types/react-dom": "^18.3.7",
64
73
  "pdf-lib": "^1.17.1",
74
+ "playwright": "^1.62.1",
65
75
  "react": "^18.3.0",
66
76
  "react-dom": "^18.3.1",
67
77
  "typescript": "^5.7.2"