react-native-nitro-passkeys 0.1.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/NitroPasskeys.podspec +37 -0
  3. package/README.md +314 -0
  4. package/android/CMakeLists.txt +31 -0
  5. package/android/build.gradle +155 -0
  6. package/android/fix-prefab.gradle +51 -0
  7. package/android/gradle.properties +5 -0
  8. package/android/src/main/AndroidManifest.xml +2 -0
  9. package/android/src/main/cpp/cpp-adapter.cpp +9 -0
  10. package/android/src/main/java/com/margelo/nitro/nitropasskeys/HybridNitroPasskeys.kt +65 -0
  11. package/android/src/main/java/com/margelo/nitro/nitropasskeys/NitroPasskeysPackage.kt +20 -0
  12. package/android/src/main/java/com/margelo/nitro/nitropasskeys/PasskeyErrorToken.kt +64 -0
  13. package/android/src/test/java/com/margelo/nitro/nitropasskeys/PasskeyErrorTokenTest.kt +128 -0
  14. package/ios/Base64URL.swift +37 -0
  15. package/ios/Bridge.h +1 -0
  16. package/ios/HybridNitroPasskeys.swift +290 -0
  17. package/ios/PasskeyErrorToken.swift +32 -0
  18. package/ios/PasskeyJSON.swift +195 -0
  19. package/lib/commonjs/PasskeyError.js +39 -0
  20. package/lib/commonjs/PasskeyError.js.map +1 -0
  21. package/lib/commonjs/index.js +52 -0
  22. package/lib/commonjs/index.js.map +1 -0
  23. package/lib/commonjs/package.json +1 -0
  24. package/lib/commonjs/response.js +30 -0
  25. package/lib/commonjs/response.js.map +1 -0
  26. package/lib/commonjs/specs/nitro-passkeys.nitro.js +6 -0
  27. package/lib/commonjs/specs/nitro-passkeys.nitro.js.map +1 -0
  28. package/lib/commonjs/types.js +2 -0
  29. package/lib/commonjs/types.js.map +1 -0
  30. package/lib/commonjs/validate.js +118 -0
  31. package/lib/commonjs/validate.js.map +1 -0
  32. package/lib/module/PasskeyError.js +34 -0
  33. package/lib/module/PasskeyError.js.map +1 -0
  34. package/lib/module/index.js +42 -0
  35. package/lib/module/index.js.map +1 -0
  36. package/lib/module/response.js +25 -0
  37. package/lib/module/response.js.map +1 -0
  38. package/lib/module/specs/nitro-passkeys.nitro.js +4 -0
  39. package/lib/module/specs/nitro-passkeys.nitro.js.map +1 -0
  40. package/lib/module/types.js +2 -0
  41. package/lib/module/types.js.map +1 -0
  42. package/lib/module/validate.js +109 -0
  43. package/lib/module/validate.js.map +1 -0
  44. package/lib/typescript/example/server/index.d.ts +2 -0
  45. package/lib/typescript/example/server/index.d.ts.map +1 -0
  46. package/lib/typescript/src/PasskeyError.d.ts +8 -0
  47. package/lib/typescript/src/PasskeyError.d.ts.map +1 -0
  48. package/lib/typescript/src/index.d.ts +8 -0
  49. package/lib/typescript/src/index.d.ts.map +1 -0
  50. package/lib/typescript/src/response.d.ts +4 -0
  51. package/lib/typescript/src/response.d.ts.map +1 -0
  52. package/lib/typescript/src/specs/nitro-passkeys.nitro.d.ts +12 -0
  53. package/lib/typescript/src/specs/nitro-passkeys.nitro.d.ts.map +1 -0
  54. package/lib/typescript/src/types.d.ts +104 -0
  55. package/lib/typescript/src/types.d.ts.map +1 -0
  56. package/lib/typescript/src/validate.d.ts +14 -0
  57. package/lib/typescript/src/validate.d.ts.map +1 -0
  58. package/nitro.json +24 -0
  59. package/nitrogen/generated/.gitattributes +1 -0
  60. package/nitrogen/generated/android/NitroPasskeys+autolinking.cmake +81 -0
  61. package/nitrogen/generated/android/NitroPasskeys+autolinking.gradle +27 -0
  62. package/nitrogen/generated/android/NitroPasskeysOnLoad.cpp +54 -0
  63. package/nitrogen/generated/android/NitroPasskeysOnLoad.hpp +34 -0
  64. package/nitrogen/generated/android/c++/JHybridNitroPasskeysSpec.cpp +86 -0
  65. package/nitrogen/generated/android/c++/JHybridNitroPasskeysSpec.hpp +64 -0
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitropasskeys/HybridNitroPasskeysSpec.kt +63 -0
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitropasskeys/NitroPasskeysOnLoad.kt +35 -0
  68. package/nitrogen/generated/ios/NitroPasskeys+autolinking.rb +62 -0
  69. package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Bridge.cpp +49 -0
  70. package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Bridge.hpp +111 -0
  71. package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Umbrella.hpp +45 -0
  72. package/nitrogen/generated/ios/NitroPasskeysAutolinking.mm +33 -0
  73. package/nitrogen/generated/ios/NitroPasskeysAutolinking.swift +26 -0
  74. package/nitrogen/generated/ios/c++/HybridNitroPasskeysSpecSwift.cpp +11 -0
  75. package/nitrogen/generated/ios/c++/HybridNitroPasskeysSpecSwift.hpp +93 -0
  76. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  77. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  78. package/nitrogen/generated/ios/swift/HybridNitroPasskeysSpec.swift +56 -0
  79. package/nitrogen/generated/ios/swift/HybridNitroPasskeysSpec_cxx.swift +169 -0
  80. package/nitrogen/generated/shared/c++/HybridNitroPasskeysSpec.cpp +23 -0
  81. package/nitrogen/generated/shared/c++/HybridNitroPasskeysSpec.hpp +64 -0
  82. package/package.json +106 -0
  83. package/src/PasskeyError.ts +58 -0
  84. package/src/index.ts +66 -0
  85. package/src/response.ts +49 -0
  86. package/src/specs/nitro-passkeys.nitro.ts +12 -0
  87. package/src/types.ts +124 -0
  88. package/src/validate.ts +132 -0
@@ -0,0 +1,65 @@
1
+ package com.margelo.nitro.nitropasskeys
2
+
3
+ import android.os.Build
4
+ import androidx.credentials.CreatePublicKeyCredentialRequest
5
+ import androidx.credentials.CreatePublicKeyCredentialResponse
6
+ import androidx.credentials.CredentialManager
7
+ import androidx.credentials.GetCredentialRequest
8
+ import androidx.credentials.GetPublicKeyCredentialOption
9
+ import androidx.credentials.PublicKeyCredential
10
+ import com.margelo.nitro.NitroModules
11
+ import com.margelo.nitro.core.Promise
12
+ import java.util.concurrent.atomic.AtomicBoolean
13
+ import kotlinx.coroutines.CoroutineScope
14
+ import kotlinx.coroutines.Dispatchers
15
+ import kotlinx.coroutines.SupervisorJob
16
+
17
+ class HybridNitroPasskeys : HybridNitroPasskeysSpec() {
18
+ override val isSupported: Boolean
19
+ get() = Build.VERSION.SDK_INT >= 28
20
+
21
+ private val inFlight = AtomicBoolean(false)
22
+ private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
23
+
24
+ override fun create(creationOptionsJson: String): Promise<String> = runExclusive {
25
+ val activity = requireActivity()
26
+ // Constructed here because it can throw IllegalArgumentException.
27
+ val request = CreatePublicKeyCredentialRequest(requestJson = creationOptionsJson)
28
+ val response = CredentialManager.create(activity).createCredential(activity, request)
29
+ (response as? CreatePublicKeyCredentialResponse)?.registrationResponseJson
30
+ ?: throw Error("[Passkeys:unknown] Unexpected response type")
31
+ }
32
+
33
+ override fun get(requestOptionsJson: String): Promise<String> = runExclusive {
34
+ val activity = requireActivity()
35
+ val request =
36
+ GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson = requestOptionsJson)))
37
+ val result = CredentialManager.create(activity).getCredential(activity, request)
38
+ (result.credential as? PublicKeyCredential)?.authenticationResponseJson
39
+ ?: throw Error("[Passkeys:unknown] Unexpected credential type")
40
+ }
41
+
42
+ private fun requireActivity() =
43
+ NitroModules.applicationContext?.currentActivity
44
+ ?: throw Error("[Passkeys:unknown] No current Activity")
45
+
46
+ /**
47
+ * Runs [block] on the main scope with a single-flight guard. The guard is released on every path
48
+ * (success, any throw, cancellation). Errors are rethrown with a `[Passkeys:<token>]` prefix.
49
+ */
50
+ private fun runExclusive(block: suspend () -> String): Promise<String> {
51
+ if (!inFlight.compareAndSet(false, true)) {
52
+ return Promise.rejected(Error("[Passkeys:unknown] A passkey request is already in progress"))
53
+ }
54
+ return Promise.async(mainScope) {
55
+ try {
56
+ block()
57
+ } catch (e: Throwable) {
58
+ if (e.message?.contains("[Passkeys:") == true) throw e
59
+ throw Error(PasskeyErrorToken.message(e))
60
+ } finally {
61
+ inFlight.set(false)
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,20 @@
1
+ package com.margelo.nitro.nitropasskeys
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfoProvider
7
+
8
+ public class NitroPasskeysPackage : BaseReactPackage() {
9
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
10
+
11
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
12
+ emptyMap()
13
+ }
14
+
15
+ companion object {
16
+ init {
17
+ NitroPasskeysOnLoad.initializeNative()
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,64 @@
1
+ package com.margelo.nitro.nitropasskeys
2
+
3
+ import androidx.credentials.exceptions.CreateCredentialCancellationException
4
+ import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
5
+ import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException
6
+ import androidx.credentials.exceptions.CreateCredentialUnsupportedException
7
+ import androidx.credentials.exceptions.GetCredentialCancellationException
8
+ import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
9
+ import androidx.credentials.exceptions.GetCredentialUnsupportedException
10
+ import androidx.credentials.exceptions.NoCredentialException
11
+ import androidx.credentials.exceptions.domerrors.AbortError
12
+ import androidx.credentials.exceptions.domerrors.DataError
13
+ import androidx.credentials.exceptions.domerrors.DomError
14
+ import androidx.credentials.exceptions.domerrors.EncodingError
15
+ import androidx.credentials.exceptions.domerrors.InvalidStateError
16
+ import androidx.credentials.exceptions.domerrors.NotAllowedError
17
+ import androidx.credentials.exceptions.domerrors.NotSupportedError
18
+ import androidx.credentials.exceptions.domerrors.SecurityError
19
+ import androidx.credentials.exceptions.domerrors.SyntaxError
20
+ import androidx.credentials.exceptions.domerrors.TimeoutError
21
+ import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
22
+ import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException
23
+ import org.json.JSONException
24
+
25
+ /** Maps Credential Manager failures to the `[Passkeys:<token>]` tokens the JS layer parses. */
26
+ object PasskeyErrorToken {
27
+ fun token(error: Throwable): String =
28
+ when (error) {
29
+ // DOM exceptions first: InvalidStateError means different things on create and on get.
30
+ is CreatePublicKeyCredentialDomException ->
31
+ if (error.domError is InvalidStateError) "alreadyRegistered"
32
+ else domToken(error.domError)
33
+ is GetPublicKeyCredentialDomException ->
34
+ if (error.domError is InvalidStateError) "unknown" else domToken(error.domError)
35
+ is GetCredentialCancellationException,
36
+ is CreateCredentialCancellationException -> "canceled"
37
+ is NoCredentialException -> "noCredentials"
38
+ is CreateCredentialProviderConfigurationException,
39
+ is GetCredentialProviderConfigurationException,
40
+ is CreateCredentialNoCreateOptionException,
41
+ is CreateCredentialUnsupportedException,
42
+ is GetCredentialUnsupportedException -> "unsupported"
43
+ // JSONException is defensive: the request constructors document IllegalArgumentException.
44
+ is IllegalArgumentException,
45
+ is JSONException -> "invalidOptions"
46
+ else -> "unknown"
47
+ }
48
+
49
+ private fun domToken(d: DomError): String =
50
+ when (d) {
51
+ is NotAllowedError,
52
+ is AbortError,
53
+ is TimeoutError -> "canceled"
54
+ is SecurityError -> "domainNotAssociated"
55
+ is NotSupportedError -> "unsupported"
56
+ is DataError,
57
+ is EncodingError,
58
+ is SyntaxError -> "invalidOptions"
59
+ else -> "unknown"
60
+ }
61
+
62
+ fun message(error: Throwable): String =
63
+ "[Passkeys:${token(error)}] ${error.message ?: error.javaClass.simpleName}"
64
+ }
@@ -0,0 +1,128 @@
1
+ package com.margelo.nitro.nitropasskeys
2
+
3
+ import androidx.credentials.exceptions.CreateCredentialCancellationException
4
+ import androidx.credentials.exceptions.CreateCredentialInterruptedException
5
+ import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
6
+ import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException
7
+ import androidx.credentials.exceptions.CreateCredentialUnknownException
8
+ import androidx.credentials.exceptions.CreateCredentialUnsupportedException
9
+ import androidx.credentials.exceptions.GetCredentialCancellationException
10
+ import androidx.credentials.exceptions.GetCredentialInterruptedException
11
+ import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
12
+ import androidx.credentials.exceptions.GetCredentialUnknownException
13
+ import androidx.credentials.exceptions.GetCredentialUnsupportedException
14
+ import androidx.credentials.exceptions.NoCredentialException
15
+ import androidx.credentials.exceptions.domerrors.AbortError
16
+ import androidx.credentials.exceptions.domerrors.DataError
17
+ import androidx.credentials.exceptions.domerrors.DomError
18
+ import androidx.credentials.exceptions.domerrors.EncodingError
19
+ import androidx.credentials.exceptions.domerrors.InvalidStateError
20
+ import androidx.credentials.exceptions.domerrors.NetworkError
21
+ import androidx.credentials.exceptions.domerrors.NotAllowedError
22
+ import androidx.credentials.exceptions.domerrors.NotSupportedError
23
+ import androidx.credentials.exceptions.domerrors.SecurityError
24
+ import androidx.credentials.exceptions.domerrors.SyntaxError
25
+ import androidx.credentials.exceptions.domerrors.TimeoutError
26
+ import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
27
+ import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException
28
+ import org.json.JSONException
29
+ import org.junit.Assert.assertEquals
30
+ import org.junit.Test
31
+
32
+ class PasskeyErrorTokenTest {
33
+ private fun create(d: DomError) = CreatePublicKeyCredentialDomException(d, "x")
34
+
35
+ private fun get(d: DomError) = GetPublicKeyCredentialDomException(d, "x")
36
+
37
+ private fun assertBoth(expected: String, d: () -> DomError) {
38
+ assertEquals(expected, PasskeyErrorToken.token(create(d())))
39
+ assertEquals(expected, PasskeyErrorToken.token(get(d())))
40
+ }
41
+
42
+ @Test
43
+ fun invalidStateIsAlreadyRegisteredOnCreate() {
44
+ assertEquals("alreadyRegistered", PasskeyErrorToken.token(create(InvalidStateError())))
45
+ }
46
+
47
+ @Test
48
+ fun invalidStateIsUnknownOnGet() {
49
+ assertEquals("unknown", PasskeyErrorToken.token(get(InvalidStateError())))
50
+ }
51
+
52
+ @Test fun notAllowedIsCanceled() = assertBoth("canceled") { NotAllowedError() }
53
+
54
+ @Test fun abortIsCanceled() = assertBoth("canceled") { AbortError() }
55
+
56
+ @Test fun timeoutIsCanceled() = assertBoth("canceled") { TimeoutError() }
57
+
58
+ @Test fun securityIsDomainNotAssociated() = assertBoth("domainNotAssociated") { SecurityError() }
59
+
60
+ @Test fun notSupportedIsUnsupported() = assertBoth("unsupported") { NotSupportedError() }
61
+
62
+ @Test fun dataIsInvalidOptions() = assertBoth("invalidOptions") { DataError() }
63
+
64
+ @Test fun encodingIsInvalidOptions() = assertBoth("invalidOptions") { EncodingError() }
65
+
66
+ @Test fun syntaxIsInvalidOptions() = assertBoth("invalidOptions") { SyntaxError() }
67
+
68
+ @Test fun otherDomErrorIsUnknown() = assertBoth("unknown") { NetworkError() }
69
+
70
+ @Test
71
+ fun cancellationsAreCanceled() {
72
+ assertEquals("canceled", PasskeyErrorToken.token(GetCredentialCancellationException("x")))
73
+ assertEquals("canceled", PasskeyErrorToken.token(CreateCredentialCancellationException("x")))
74
+ }
75
+
76
+ @Test
77
+ fun noCredentialIsNoCredentials() {
78
+ assertEquals("noCredentials", PasskeyErrorToken.token(NoCredentialException("x")))
79
+ }
80
+
81
+ @Test
82
+ fun unsupportedClasses() {
83
+ val errors =
84
+ listOf(
85
+ CreateCredentialProviderConfigurationException("x"),
86
+ GetCredentialProviderConfigurationException("x"),
87
+ CreateCredentialNoCreateOptionException("x"),
88
+ CreateCredentialUnsupportedException("x"),
89
+ GetCredentialUnsupportedException("x"),
90
+ )
91
+ for (e in errors) assertEquals(
92
+ e.javaClass.simpleName,
93
+ "unsupported",
94
+ PasskeyErrorToken.token(e),
95
+ )
96
+ }
97
+
98
+ @Test
99
+ fun invalidOptionsExceptions() {
100
+ assertEquals("invalidOptions", PasskeyErrorToken.token(IllegalArgumentException("x")))
101
+ assertEquals("invalidOptions", PasskeyErrorToken.token(JSONException("x")))
102
+ }
103
+
104
+ @Test
105
+ fun unknownFallbacks() {
106
+ val errors =
107
+ listOf(
108
+ CreateCredentialInterruptedException("x"),
109
+ GetCredentialInterruptedException("x"),
110
+ CreateCredentialUnknownException("x"),
111
+ GetCredentialUnknownException("x"),
112
+ RuntimeException("x"),
113
+ )
114
+ for (e in errors) assertEquals(e.javaClass.simpleName, "unknown", PasskeyErrorToken.token(e))
115
+ }
116
+
117
+ @Test
118
+ fun messageFormat() {
119
+ assertEquals(
120
+ "[Passkeys:canceled] boom",
121
+ PasskeyErrorToken.message(GetCredentialCancellationException("boom")),
122
+ )
123
+ assertEquals(
124
+ "[Passkeys:unknown] RuntimeException",
125
+ PasskeyErrorToken.message(RuntimeException()),
126
+ )
127
+ }
128
+ }
@@ -0,0 +1,37 @@
1
+ import Foundation
2
+
3
+ /// Unpadded base64url (RFC 4648 §5), the encoding WebAuthn JSON uses for every binary field.
4
+ enum Base64URL {
5
+ private static let alphabet = Set(
6
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_="
7
+ )
8
+
9
+ /// Encodes `data` as base64url with `+`→`-`, `/`→`_` and no `=` padding.
10
+ static func encode(_ data: Data) -> String {
11
+ data.base64EncodedString()
12
+ .replacingOccurrences(of: "+", with: "-")
13
+ .replacingOccurrences(of: "/", with: "_")
14
+ .replacingOccurrences(of: "=", with: "")
15
+ }
16
+
17
+ /// Decodes padded or unpadded base64url. Returns `nil` for empty input, any character outside the
18
+ /// base64url alphabet (including `+`, `/` and whitespace) or an impossible length.
19
+ static func decode(_ string: String) -> Data? {
20
+ guard !string.isEmpty, string.allSatisfy({ alphabet.contains($0) }) else { return nil }
21
+
22
+ var unpadded = Substring(string)
23
+ while unpadded.last == "=" {
24
+ unpadded = unpadded.dropLast()
25
+ }
26
+ guard unpadded.count % 4 != 1 else { return nil }
27
+
28
+ var base64 = unpadded
29
+ .replacingOccurrences(of: "-", with: "+")
30
+ .replacingOccurrences(of: "_", with: "/")
31
+ let remainder = base64.count % 4
32
+ if remainder != 0 {
33
+ base64 += String(repeating: "=", count: 4 - remainder)
34
+ }
35
+ return Data(base64Encoded: base64)
36
+ }
37
+ }
package/ios/Bridge.h ADDED
@@ -0,0 +1 @@
1
+ #pragma once
@@ -0,0 +1,290 @@
1
+ import AuthenticationServices
2
+ import Foundation
3
+ import NitroModules
4
+ import UIKit
5
+
6
+ /// WebAuthn passkeys on top of AuthenticationServices' platform provider.
7
+ ///
8
+ /// `active` is only read or written under `lock`. That is what makes the `@unchecked Sendable`
9
+ /// sound: JS calls arrive on the JS thread, settlement happens on the main thread.
10
+ final class HybridNitroPasskeys: HybridNitroPasskeysSpec, @unchecked Sendable {
11
+ private let lock = NSLock()
12
+ /// The in-flight request. Also the strong reference that keeps the helper alive, because
13
+ /// `ASAuthorizationController` only holds its delegate weakly.
14
+ private var active: PasskeyRequest?
15
+
16
+ var isSupported: Bool { true }
17
+
18
+ func create(creationOptionsJson: String) throws -> Promise<String> {
19
+ begin { .registration(try RegistrationParameters(json: creationOptionsJson)) }
20
+ }
21
+
22
+ func get(requestOptionsJson: String) throws -> Promise<String> {
23
+ begin { .assertion(try AssertionParameters(json: requestOptionsJson)) }
24
+ }
25
+
26
+ private func begin(_ parse: () throws -> PasskeyCeremony) -> Promise<String> {
27
+ lock.lock()
28
+ defer { lock.unlock() }
29
+
30
+ guard active == nil else {
31
+ return .rejected(withError: passkeyError("unknown", "A passkey request is already in progress"))
32
+ }
33
+
34
+ let ceremony: PasskeyCeremony
35
+ do {
36
+ ceremony = try parse()
37
+ } catch {
38
+ return .rejected(withError: error)
39
+ }
40
+
41
+ let promise = Promise<String>()
42
+ let request = PasskeyRequest(ceremony: ceremony, promise: promise) { [weak self] settled in
43
+ self?.clear(settled)
44
+ }
45
+ active = request
46
+ DispatchQueue.main.async {
47
+ request.start()
48
+ }
49
+ return promise
50
+ }
51
+
52
+ /// Called exactly once per request, by `PasskeyRequest.settle`, before the promise settles so a
53
+ /// follow-up call made from the JS continuation is not rejected as "in progress".
54
+ private func clear(_ request: PasskeyRequest) {
55
+ lock.lock()
56
+ defer { lock.unlock() }
57
+ if active === request {
58
+ active = nil
59
+ }
60
+ }
61
+ }
62
+
63
+ // MARK: - Request helper
64
+
65
+ enum PasskeyCeremony: Sendable {
66
+ case registration(RegistrationParameters)
67
+ case assertion(AssertionParameters)
68
+ }
69
+
70
+ private func passkeyError(_ token: String, _ text: String) -> Error {
71
+ RuntimeError.error(withMessage: PasskeyErrorToken.message(token: token, text))
72
+ }
73
+
74
+ /// One `create` or `get` ceremony. Lives on the main actor (both AuthenticationServices
75
+ /// protocols are UI-actor isolated); only `init` runs on the calling JS thread.
76
+ @MainActor
77
+ private final class PasskeyRequest: NSObject {
78
+ private let ceremony: PasskeyCeremony
79
+ private let promise: Promise<String>
80
+ private let onSettle: @Sendable (PasskeyRequest) -> Void
81
+ private var anchor: ASPresentationAnchor?
82
+ private var controller: ASAuthorizationController?
83
+ private var settled = false
84
+
85
+ nonisolated init(
86
+ ceremony: PasskeyCeremony,
87
+ promise: Promise<String>,
88
+ onSettle: @escaping @Sendable (PasskeyRequest) -> Void
89
+ ) {
90
+ self.ceremony = ceremony
91
+ self.promise = promise
92
+ self.onSettle = onSettle
93
+ super.init()
94
+ }
95
+
96
+ func start() {
97
+ guard let window = Self.presentationWindow() else {
98
+ settle(.failure(passkeyError("unknown", "No window to present on")))
99
+ return
100
+ }
101
+ anchor = window
102
+
103
+ let request: ASAuthorizationRequest
104
+ switch ceremony {
105
+ case .registration(let parameters):
106
+ request = Self.registrationRequest(parameters)
107
+ case .assertion(let parameters):
108
+ request = Self.assertionRequest(parameters)
109
+ }
110
+
111
+ let controller = ASAuthorizationController(authorizationRequests: [request])
112
+ controller.delegate = self
113
+ controller.presentationContextProvider = self
114
+ self.controller = controller
115
+ controller.performRequests()
116
+ }
117
+ }
118
+
119
+ // MARK: ASAuthorizationControllerDelegate
120
+
121
+ extension PasskeyRequest: ASAuthorizationControllerDelegate {
122
+ func authorizationController(
123
+ controller: ASAuthorizationController,
124
+ didCompleteWithAuthorization authorization: ASAuthorization
125
+ ) {
126
+ settle(Result { try responseJSON(for: authorization.credential) })
127
+ }
128
+
129
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
130
+ let token = PasskeyErrorToken.token(for: error)
131
+ settle(.failure(passkeyError(token, error.localizedDescription)))
132
+ }
133
+ }
134
+
135
+ // MARK: ASAuthorizationControllerPresentationContextProviding
136
+
137
+ extension PasskeyRequest: ASAuthorizationControllerPresentationContextProviding {
138
+ func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
139
+ // `start` sets the anchor before `performRequests`, so the fallback is never expected.
140
+ anchor ?? ASPresentationAnchor()
141
+ }
142
+ }
143
+
144
+ // MARK: Settlement, requests and responses
145
+
146
+ extension PasskeyRequest {
147
+ /// Resolves or rejects the promise at most once and releases the in-flight slot.
148
+ private func settle(_ result: Result<String, Error>) {
149
+ guard !settled else { return }
150
+ settled = true
151
+
152
+ // Release the controller after the current delegate callback has returned, not while the
153
+ // controller is still on the stack calling us.
154
+ if let controller {
155
+ controller.delegate = nil
156
+ controller.presentationContextProvider = nil
157
+ DispatchQueue.main.async {
158
+ withExtendedLifetime(controller) {}
159
+ }
160
+ }
161
+ controller = nil
162
+ anchor = nil
163
+
164
+ onSettle(self)
165
+
166
+ switch result {
167
+ case .success(let json):
168
+ promise.resolve(withResult: json)
169
+ case .failure(let error):
170
+ promise.reject(withError: error)
171
+ }
172
+ }
173
+
174
+ // MARK: Building requests
175
+
176
+ private static func registrationRequest(
177
+ _ parameters: RegistrationParameters
178
+ ) -> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest {
179
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: parameters.rpID)
180
+ let request = provider.createCredentialRegistrationRequest(
181
+ challenge: parameters.challenge,
182
+ name: parameters.userName,
183
+ userID: parameters.userID
184
+ )
185
+ if let displayName = parameters.displayName, !displayName.isEmpty {
186
+ request.displayName = displayName
187
+ }
188
+ if let userVerification = parameters.userVerification {
189
+ request.userVerificationPreference = .init(rawValue: userVerification)
190
+ }
191
+ request.attestationPreference = .init(rawValue: parameters.attestation)
192
+ if !parameters.excludedCredentialIDs.isEmpty, #available(iOS 17.4, *) {
193
+ request.excludedCredentials = parameters.excludedCredentialIDs.map {
194
+ ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0)
195
+ }
196
+ }
197
+ return request
198
+ }
199
+
200
+ private static func assertionRequest(
201
+ _ parameters: AssertionParameters
202
+ ) -> ASAuthorizationPlatformPublicKeyCredentialAssertionRequest {
203
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: parameters.rpID)
204
+ let request = provider.createCredentialAssertionRequest(challenge: parameters.challenge)
205
+ if !parameters.allowedCredentialIDs.isEmpty {
206
+ request.allowedCredentials = parameters.allowedCredentialIDs.map {
207
+ ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0)
208
+ }
209
+ }
210
+ if let userVerification = parameters.userVerification {
211
+ request.userVerificationPreference = .init(rawValue: userVerification)
212
+ }
213
+ return request
214
+ }
215
+
216
+ // MARK: Building responses
217
+
218
+ private func responseJSON(for credential: ASAuthorizationCredential) throws -> String {
219
+ switch ceremony {
220
+ case .registration:
221
+ guard let registration = credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else {
222
+ throw passkeyError("unknown", "Unexpected credential type")
223
+ }
224
+ guard let attestationObject = registration.rawAttestationObject, !attestationObject.isEmpty else {
225
+ throw passkeyError("unknown", "Missing attestation object")
226
+ }
227
+ var attachment = "platform"
228
+ if #available(iOS 16.6, *) {
229
+ attachment = Self.attachmentName(registration.attachment)
230
+ }
231
+ return try PasskeyJSON.encode(
232
+ PasskeyJSON.RegistrationResponse(
233
+ credentialID: registration.credentialID,
234
+ clientDataJSON: registration.rawClientDataJSON,
235
+ attestationObject: attestationObject,
236
+ attachment: attachment
237
+ )
238
+ )
239
+
240
+ case .assertion:
241
+ guard let assertion = credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {
242
+ throw passkeyError("unknown", "Unexpected credential type")
243
+ }
244
+ guard !assertion.rawAuthenticatorData.isEmpty else {
245
+ throw passkeyError("unknown", "Missing authenticator data")
246
+ }
247
+ guard !assertion.signature.isEmpty else {
248
+ throw passkeyError("unknown", "Missing signature")
249
+ }
250
+ var attachment = "platform"
251
+ if #available(iOS 16.6, *) {
252
+ attachment = Self.attachmentName(assertion.attachment)
253
+ }
254
+ return try PasskeyJSON.encode(
255
+ PasskeyJSON.AssertionResponse(
256
+ credentialID: assertion.credentialID,
257
+ clientDataJSON: assertion.rawClientDataJSON,
258
+ authenticatorData: assertion.rawAuthenticatorData,
259
+ signature: assertion.signature,
260
+ userID: assertion.userID,
261
+ attachment: attachment
262
+ )
263
+ )
264
+ }
265
+ }
266
+
267
+ @available(iOS 16.6, *)
268
+ private static func attachmentName(_ attachment: ASAuthorizationPublicKeyCredentialAttachment) -> String {
269
+ switch attachment {
270
+ case .platform:
271
+ return "platform"
272
+ case .crossPlatform:
273
+ return "cross-platform"
274
+ @unknown default:
275
+ return "platform"
276
+ }
277
+ }
278
+
279
+ // MARK: Anchor
280
+
281
+ /// Key window of the foreground-active scene, else the first window of any connected scene.
282
+ private static func presentationWindow() -> UIWindow? {
283
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
284
+ let activeScene = scenes.first { $0.activationState == .foregroundActive }
285
+ if let keyWindow = activeScene?.keyWindow {
286
+ return keyWindow
287
+ }
288
+ return scenes.lazy.compactMap { $0.windows.first }.first
289
+ }
290
+ }
@@ -0,0 +1,32 @@
1
+ import AuthenticationServices
2
+ import Foundation
3
+
4
+ /// Maps native errors to the `[Passkeys:<token>]` tokens that `PasskeyError.from` parses in JS.
5
+ enum PasskeyErrorToken {
6
+ private static let notAssociatedMarker = "is not associated with domain"
7
+
8
+ static func token(for error: Error) -> String {
9
+ let nsError = error as NSError
10
+ guard nsError.domain == ASAuthorizationError.errorDomain else { return "unknown" }
11
+ if nsError.code == ASAuthorizationError.Code.failed.rawValue, isDomainNotAssociated(nsError) {
12
+ return "domainNotAssociated"
13
+ }
14
+ return "asauth-\(nsError.code)"
15
+ }
16
+
17
+ /// True when the error (or any underlying error) says the app is not associated with the RP
18
+ /// domain, i.e. the Associated Domains entitlement or the AASA file is missing or wrong.
19
+ static func isDomainNotAssociated(_ error: NSError) -> Bool {
20
+ if error.localizedDescription.contains(notAssociatedMarker) { return true }
21
+ let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String
22
+ if reason?.contains(notAssociatedMarker) == true { return true }
23
+ if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
24
+ return isDomainNotAssociated(underlying)
25
+ }
26
+ return false
27
+ }
28
+
29
+ static func message(token: String, _ text: String) -> String {
30
+ "[Passkeys:\(token)] \(text)"
31
+ }
32
+ }