@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.
- package/Package.swift +24 -0
- package/README.md +44 -0
- package/UnisimSdk.podspec +28 -0
- package/dist/UniversalAppsNavBar.d.ts +7 -1
- package/dist/UniversalAppsNavBar.d.ts.map +1 -1
- package/dist/UniversalAppsNavBar.js +36 -2
- package/dist/UniversalAppsNavBar.js.map +1 -1
- package/dist/UserProfile.js +11 -3
- package/dist/UserProfile.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +22 -69
- package/dist/provider.js.map +1 -1
- package/dist/sessionStorage.d.ts +91 -0
- package/dist/sessionStorage.d.ts.map +1 -0
- package/dist/sessionStorage.js +239 -0
- package/dist/sessionStorage.js.map +1 -0
- package/ios/Sources/UnisimSuiteAuthPlugin/UnisimSuiteAuthPlugin.swift +241 -0
- package/package.json +12 -2
|
@@ -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.
|
|
4
|
-
"description": "Shared React SDK for the Universal Suite
|
|
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"
|