@craft-native/ios 0.0.173 → 0.0.174
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/README.md +31 -1
- package/dist/cli.js +139 -79
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +5 -0
- package/templates/CraftNativeActions.swift +174 -0
- package/templates/CraftNativeFlatList.swift +1 -1
- package/templates/CraftNativeScreen.swift +437 -38
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import AuthenticationServices
|
|
2
3
|
import LocalAuthentication
|
|
3
4
|
import SQLite3
|
|
4
5
|
import Security
|
|
@@ -50,6 +51,7 @@ enum CraftNativeActions {
|
|
|
50
51
|
// The in-app browser and the system's web sign-in sheet (CraftBrowser).
|
|
51
52
|
values.append("browser")
|
|
52
53
|
if config.enableSocialAuth { values.append("appleSignIn") }
|
|
54
|
+
if config.enablePasskeys && !CraftNativePasskeys.domains(in: config).isEmpty { values.append("passkeys") }
|
|
53
55
|
if config.enablePushNotifications { values.append("push") }
|
|
54
56
|
if config.enableShare { values.append("share") }
|
|
55
57
|
// The screen's brightness and the status bar's style need nothing
|
|
@@ -247,6 +249,23 @@ enum CraftNativeActions {
|
|
|
247
249
|
}
|
|
248
250
|
}
|
|
249
251
|
return
|
|
252
|
+
case ("Passkeys", "isAvailable"):
|
|
253
|
+
sync = .success(CraftNativePasskeys.isAvailable(config: config))
|
|
254
|
+
case ("Passkeys", "create"), ("Passkeys", "get"):
|
|
255
|
+
guard config.enablePasskeys else {
|
|
256
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Passkeys are disabled"), completion)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
DispatchQueue.main.async {
|
|
260
|
+
CraftNativePasskeys.shared.perform(
|
|
261
|
+
requestToken: requestToken,
|
|
262
|
+
method: method,
|
|
263
|
+
options: args.first as? [String: Any] ?? [:],
|
|
264
|
+
config: config,
|
|
265
|
+
completion: completion
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
return
|
|
250
269
|
case ("Dialog", _):
|
|
251
270
|
// The system's own alert, confirmation and action sheet, the same
|
|
252
271
|
// ones a page gets (CraftNativeUI.swift), over the native screen.
|
|
@@ -490,6 +509,7 @@ enum CraftNativeActions {
|
|
|
490
509
|
cancellationLock.unlock()
|
|
491
510
|
context?.invalidate()
|
|
492
511
|
task?.cancel()
|
|
512
|
+
CraftNativePasskeys.shared.cancel(requestToken: requestToken)
|
|
493
513
|
}
|
|
494
514
|
|
|
495
515
|
/// `fetch` for native screens: one URLSession data task per request, keyed
|
|
@@ -1123,6 +1143,160 @@ enum CraftNativeActions {
|
|
|
1123
1143
|
}
|
|
1124
1144
|
}
|
|
1125
1145
|
|
|
1146
|
+
/// The native WebAuthn half for screens without a browser context. The
|
|
1147
|
+
/// server still owns challenges and verifies the returned client data; this
|
|
1148
|
+
/// class only translates WebAuthn options into Apple's passkey sheet and
|
|
1149
|
+
/// returns the authenticator response as base64url strings.
|
|
1150
|
+
final class CraftNativePasskeys: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
|
|
1151
|
+
static let shared = CraftNativePasskeys()
|
|
1152
|
+
|
|
1153
|
+
private struct Pending {
|
|
1154
|
+
let requestToken: String
|
|
1155
|
+
let completion: (Result<Any, CraftNativeActionError>) -> Void
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private var pending: [ObjectIdentifier: Pending] = [:]
|
|
1159
|
+
private var controllers: [String: ASAuthorizationController] = [:]
|
|
1160
|
+
|
|
1161
|
+
static func domains(in config: CraftConfig) -> Set<String> {
|
|
1162
|
+
Set(config.associatedDomains.compactMap { entry in
|
|
1163
|
+
let value = entry.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1164
|
+
guard value.lowercased().hasPrefix("webcredentials:") else { return nil }
|
|
1165
|
+
let domain = String(value.dropFirst("webcredentials:".count)).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
|
|
1166
|
+
let normalized = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
1167
|
+
return normalized.isEmpty ? nil : normalized
|
|
1168
|
+
})
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
static func isAvailable(config: CraftConfig) -> Bool {
|
|
1172
|
+
if #available(iOS 15.0, *) {
|
|
1173
|
+
return config.enablePasskeys && !domains(in: config).isEmpty
|
|
1174
|
+
}
|
|
1175
|
+
return false
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
func perform(
|
|
1179
|
+
requestToken: String,
|
|
1180
|
+
method: String,
|
|
1181
|
+
options: [String: Any],
|
|
1182
|
+
config: CraftConfig,
|
|
1183
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
1184
|
+
) {
|
|
1185
|
+
guard #available(iOS 15.0, *) else {
|
|
1186
|
+
completion(.failure(CraftNativeActionError(code: "UNAVAILABLE", message: "Passkeys require iOS 15 or later")))
|
|
1187
|
+
return
|
|
1188
|
+
}
|
|
1189
|
+
guard Self.isAvailable(config: config) else {
|
|
1190
|
+
completion(.failure(CraftNativeActionError(code: "UNAVAILABLE", message: "Passkeys need a matching webcredentials associated domain")))
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
guard let relyingParty = relyingPartyID(options: options, config: config),
|
|
1194
|
+
let challenge = data(options["challenge"]) else {
|
|
1195
|
+
completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Passkeys need a base64url challenge and an associated relying-party id")))
|
|
1196
|
+
return
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: relyingParty)
|
|
1200
|
+
let request: ASAuthorizationRequest
|
|
1201
|
+
if method == "create" {
|
|
1202
|
+
guard let user = options["user"] as? [String: Any],
|
|
1203
|
+
let name = user["name"] as? String, !name.isEmpty,
|
|
1204
|
+
let userID = data(user["id"]) else {
|
|
1205
|
+
completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Passkeys.create needs user.name and a base64url user.id")))
|
|
1206
|
+
return
|
|
1207
|
+
}
|
|
1208
|
+
request = provider.createCredentialRegistrationRequest(challenge: challenge, name: name, userID: userID)
|
|
1209
|
+
} else {
|
|
1210
|
+
let assertion = provider.createCredentialAssertionRequest(challenge: challenge)
|
|
1211
|
+
if let allowCredentials = options["allowCredentials"] as? [[String: Any]] {
|
|
1212
|
+
assertion.allowedCredentials = allowCredentials.compactMap { descriptor in
|
|
1213
|
+
guard let credentialID = data(descriptor["id"]) else { return nil }
|
|
1214
|
+
return ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialID)
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
request = assertion
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
let controller = ASAuthorizationController(authorizationRequests: [request])
|
|
1221
|
+
controller.delegate = self
|
|
1222
|
+
controller.presentationContextProvider = self
|
|
1223
|
+
let key = ObjectIdentifier(controller)
|
|
1224
|
+
pending[key] = Pending(requestToken: requestToken, completion: completion)
|
|
1225
|
+
controllers[requestToken] = controller
|
|
1226
|
+
controller.performRequests()
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
func cancel(requestToken: String) {
|
|
1230
|
+
controllers.removeValue(forKey: requestToken)
|
|
1231
|
+
pending = pending.filter { $0.value.requestToken != requestToken }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
|
|
1235
|
+
let key = ObjectIdentifier(controller)
|
|
1236
|
+
guard let request = pending.removeValue(forKey: key) else { return }
|
|
1237
|
+
controllers.removeValue(forKey: request.requestToken)
|
|
1238
|
+
if let registration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
|
|
1239
|
+
request.completion(.success([
|
|
1240
|
+
"id": Self.base64url(registration.credentialID),
|
|
1241
|
+
"rawId": Self.base64url(registration.credentialID),
|
|
1242
|
+
"type": "public-key",
|
|
1243
|
+
"response": [
|
|
1244
|
+
"clientDataJSON": Self.base64url(registration.rawClientDataJSON),
|
|
1245
|
+
"attestationObject": Self.base64url(registration.rawAttestationObject),
|
|
1246
|
+
],
|
|
1247
|
+
]))
|
|
1248
|
+
return
|
|
1249
|
+
}
|
|
1250
|
+
if let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion {
|
|
1251
|
+
var response: [String: Any] = [
|
|
1252
|
+
"clientDataJSON": Self.base64url(assertion.rawClientDataJSON),
|
|
1253
|
+
"authenticatorData": Self.base64url(assertion.rawAuthenticatorData),
|
|
1254
|
+
"signature": Self.base64url(assertion.signature),
|
|
1255
|
+
]
|
|
1256
|
+
if let userID = assertion.userID { response["userHandle"] = Self.base64url(userID) }
|
|
1257
|
+
request.completion(.success([
|
|
1258
|
+
"id": Self.base64url(assertion.credentialID),
|
|
1259
|
+
"rawId": Self.base64url(assertion.credentialID),
|
|
1260
|
+
"type": "public-key",
|
|
1261
|
+
"response": response,
|
|
1262
|
+
]))
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
request.completion(.failure(CraftNativeActionError(code: "PASSKEY_FAILED", message: "Apple returned an unsupported passkey credential")))
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
|
1269
|
+
let key = ObjectIdentifier(controller)
|
|
1270
|
+
guard let request = pending.removeValue(forKey: key) else { return }
|
|
1271
|
+
controllers.removeValue(forKey: request.requestToken)
|
|
1272
|
+
let cancelled = (error as? ASAuthorizationError)?.code == .canceled
|
|
1273
|
+
request.completion(.failure(CraftNativeActionError(code: cancelled ? "CANCELLED" : "PASSKEY_FAILED", message: error.localizedDescription)))
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
|
1277
|
+
CraftPresenter.keyWindow() ?? ASPresentationAnchor()
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
private func relyingPartyID(options: [String: Any], config: CraftConfig) -> String? {
|
|
1281
|
+
let value = (options["rpId"] as? String) ?? (options["rp"] as? [String: Any])?["id"] as? String
|
|
1282
|
+
let domains = Self.domains(in: config)
|
|
1283
|
+
let candidate = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? (domains.count == 1 ? domains.first : nil)
|
|
1284
|
+
guard let candidate, domains.contains(candidate), candidate.range(of: #"^[a-z0-9.-]+$"#, options: .regularExpression) != nil else { return nil }
|
|
1285
|
+
return candidate
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
private func data(_ value: Any?) -> Data? {
|
|
1289
|
+
guard let string = value as? String, !string.isEmpty else { return nil }
|
|
1290
|
+
var encoded = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
1291
|
+
encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4)
|
|
1292
|
+
return Data(base64Encoded: encoded)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private static func base64url(_ data: Data) -> String {
|
|
1296
|
+
data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1126
1300
|
// MARK: - Synchronous reads for a screen's first frame
|
|
1127
1301
|
|
|
1128
1302
|
extension CraftNativeActions {
|
|
@@ -277,7 +277,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
280
|
-
|
|
280
|
+
craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
var visibleItemIdentities: [String] {
|