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.
- package/LICENSE +21 -0
- package/NitroPasskeys.podspec +37 -0
- package/README.md +314 -0
- package/android/CMakeLists.txt +31 -0
- package/android/build.gradle +155 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +9 -0
- package/android/src/main/java/com/margelo/nitro/nitropasskeys/HybridNitroPasskeys.kt +65 -0
- package/android/src/main/java/com/margelo/nitro/nitropasskeys/NitroPasskeysPackage.kt +20 -0
- package/android/src/main/java/com/margelo/nitro/nitropasskeys/PasskeyErrorToken.kt +64 -0
- package/android/src/test/java/com/margelo/nitro/nitropasskeys/PasskeyErrorTokenTest.kt +128 -0
- package/ios/Base64URL.swift +37 -0
- package/ios/Bridge.h +1 -0
- package/ios/HybridNitroPasskeys.swift +290 -0
- package/ios/PasskeyErrorToken.swift +32 -0
- package/ios/PasskeyJSON.swift +195 -0
- package/lib/commonjs/PasskeyError.js +39 -0
- package/lib/commonjs/PasskeyError.js.map +1 -0
- package/lib/commonjs/index.js +52 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/response.js +30 -0
- package/lib/commonjs/response.js.map +1 -0
- package/lib/commonjs/specs/nitro-passkeys.nitro.js +6 -0
- package/lib/commonjs/specs/nitro-passkeys.nitro.js.map +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/validate.js +118 -0
- package/lib/commonjs/validate.js.map +1 -0
- package/lib/module/PasskeyError.js +34 -0
- package/lib/module/PasskeyError.js.map +1 -0
- package/lib/module/index.js +42 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/response.js +25 -0
- package/lib/module/response.js.map +1 -0
- package/lib/module/specs/nitro-passkeys.nitro.js +4 -0
- package/lib/module/specs/nitro-passkeys.nitro.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/validate.js +109 -0
- package/lib/module/validate.js.map +1 -0
- package/lib/typescript/example/server/index.d.ts +2 -0
- package/lib/typescript/example/server/index.d.ts.map +1 -0
- package/lib/typescript/src/PasskeyError.d.ts +8 -0
- package/lib/typescript/src/PasskeyError.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +8 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/response.d.ts +4 -0
- package/lib/typescript/src/response.d.ts.map +1 -0
- package/lib/typescript/src/specs/nitro-passkeys.nitro.d.ts +12 -0
- package/lib/typescript/src/specs/nitro-passkeys.nitro.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +104 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/validate.d.ts +14 -0
- package/lib/typescript/src/validate.d.ts.map +1 -0
- package/nitro.json +24 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/NitroPasskeys+autolinking.cmake +81 -0
- package/nitrogen/generated/android/NitroPasskeys+autolinking.gradle +27 -0
- package/nitrogen/generated/android/NitroPasskeysOnLoad.cpp +54 -0
- package/nitrogen/generated/android/NitroPasskeysOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JHybridNitroPasskeysSpec.cpp +86 -0
- package/nitrogen/generated/android/c++/JHybridNitroPasskeysSpec.hpp +64 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitropasskeys/HybridNitroPasskeysSpec.kt +63 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitropasskeys/NitroPasskeysOnLoad.kt +35 -0
- package/nitrogen/generated/ios/NitroPasskeys+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Bridge.cpp +49 -0
- package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Bridge.hpp +111 -0
- package/nitrogen/generated/ios/NitroPasskeys-Swift-Cxx-Umbrella.hpp +45 -0
- package/nitrogen/generated/ios/NitroPasskeysAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NitroPasskeysAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridNitroPasskeysSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNitroPasskeysSpecSwift.hpp +93 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridNitroPasskeysSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridNitroPasskeysSpec_cxx.swift +169 -0
- package/nitrogen/generated/shared/c++/HybridNitroPasskeysSpec.cpp +23 -0
- package/nitrogen/generated/shared/c++/HybridNitroPasskeysSpec.hpp +64 -0
- package/package.json +106 -0
- package/src/PasskeyError.ts +58 -0
- package/src/index.ts +66 -0
- package/src/response.ts +49 -0
- package/src/specs/nitro-passkeys.nitro.ts +12 -0
- package/src/types.ts +124 -0
- 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
|
+
}
|