@neodaten/passkey-expo 0.0.0-stage → 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/README.md +48 -2
- package/android/build.gradle +18 -0
- package/android/src/main/java/mx/neodaten/passkey/PasskeyNativaModule.kt +69 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/modulo-passkey-nativa.d.ts +7 -0
- package/dist/modulo-passkey-nativa.js +4 -0
- package/dist/modulo-passkey-nativa.js.map +1 -0
- package/dist/proveedor-de-passkey.d.ts +3 -0
- package/dist/proveedor-de-passkey.js +32 -0
- package/dist/proveedor-de-passkey.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Base64Url.swift +18 -0
- package/ios/CeremoniaDeLaPasskey.swift +76 -0
- package/ios/ErrorDeLaPasskey.swift +21 -0
- package/ios/OpcionesDeLaPasskey.swift +14 -0
- package/ios/PasskeyNativa.podspec +20 -0
- package/ios/PasskeyNativaModule.swift +51 -0
- package/ios/RespuestaDeLaPasskey.swift +25 -0
- package/package.json +47 -3
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @neodaten/passkey-expo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Es un módulo Expo que firma con la passkey del sistema (Face ID, Touch ID o huella). Implementa el `ProveedorDePasskey` de `@neodaten/auth-native` para entrar desde la bienvenida sin abrir el login hospedado.
|
|
4
|
+
|
|
5
|
+
- **iOS 16 o superior:** `ASAuthorizationPlatformPublicKeyCredentialProvider`, con verificación del usuario obligatoria. Pide solo credenciales disponibles al momento, así que no muestra la hoja del QR.
|
|
6
|
+
- **Android 9 o superior:** Credential Manager (`androidx.credentials` 1.6.0).
|
|
7
|
+
|
|
8
|
+
Va en un paquete aparte para que las apps React Native sin Expo (Luup) puedan seguir usando `auth-native` sin este módulo nativo.
|
|
9
|
+
|
|
10
|
+
## Uso
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { crearClienteDeIdentidadNativa } from '@neodaten/auth-native';
|
|
14
|
+
import { BienvenidaConUltimaCuenta } from '@neodaten/auth-native/bienvenida';
|
|
15
|
+
import { crearProveedorDePasskeyExpo } from '@neodaten/passkey-expo';
|
|
16
|
+
|
|
17
|
+
const cliente = crearClienteDeIdentidadNativa({
|
|
18
|
+
...opciones,
|
|
19
|
+
proveedorDePasskey: crearProveedorDePasskeyExpo(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
<BienvenidaConUltimaCuenta
|
|
23
|
+
cliente={cliente}
|
|
24
|
+
marca={marcaDeLaApp}
|
|
25
|
+
alEntrar={irAlInicio}
|
|
26
|
+
alAbrirElLogin={abrirLoginHospedado}
|
|
27
|
+
sinUltimaCuenta={<BienvenidaDeSiempre />}
|
|
28
|
+
/>;
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Requisitos de la app
|
|
32
|
+
|
|
33
|
+
- **iOS:** `ios.associatedDomains: ["webcredentials:id.neodaten.com"]` (en dev, `id.dev.neodaten.com`). El App ID debe estar en el `apple-app-site-association` del dominio de identidad.
|
|
34
|
+
- **Android:** la huella SHA-256 del certificado debe estar en `PASSKEYS_ANDROID_APPS` del core, que publica `assetlinks.json`.
|
|
35
|
+
- **Core:** la aplicación tiene `settings.passkeyEnLaBienvenida`.
|
|
36
|
+
|
|
37
|
+
## Errores
|
|
38
|
+
|
|
39
|
+
`firmar` rechaza con estos códigos:
|
|
40
|
+
|
|
41
|
+
- `acceso_cancelado`: la persona canceló.
|
|
42
|
+
- `sin_passkey`: no hay credencial en el dispositivo. En iOS, una cancelación en menos de un segundo, sin interfaz, cuenta como `sin_passkey`.
|
|
43
|
+
- `passkey_fallida`: cualquier otro fallo.
|
|
44
|
+
|
|
45
|
+
El proveedor los convierte en `ErrorDeIdentidad`. `useBienvenida` abre el login hospedado cuando no hay passkey o la passkey falla.
|
|
46
|
+
|
|
47
|
+
## Publicación
|
|
48
|
+
|
|
49
|
+
`pnpm --filter @neodaten/passkey-expo empaquetar` compila a `dist/` con `tsc`. El workflow `publicar-passkey-expo.yml` publica en npm con la publicación de confianza, sin procedencia porque el repositorio es privado, cuando se empuja el tag `passkey-expo-v<versión>` sobre un commit de `main`. La versión del tag tiene que coincidir con la de `package.json`. En npm hay que dar de alta la publicación de confianza para este paquete y este workflow. Primero se publica `@neodaten/auth-native`, que es dependencia par.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'expo-module-gradle-plugin'
|
|
3
|
+
|
|
4
|
+
group = 'mx.neodaten.passkey'
|
|
5
|
+
version = '0.1.0'
|
|
6
|
+
|
|
7
|
+
android {
|
|
8
|
+
namespace 'mx.neodaten.passkey'
|
|
9
|
+
defaultConfig {
|
|
10
|
+
versionCode 1
|
|
11
|
+
versionName '0.1.0'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
dependencies {
|
|
16
|
+
implementation 'androidx.credentials:credentials:1.6.0'
|
|
17
|
+
implementation 'androidx.credentials:credentials-play-services-auth:1.6.0'
|
|
18
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package mx.neodaten.passkey
|
|
2
|
+
|
|
3
|
+
import android.content.pm.PackageManager
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import androidx.credentials.CredentialManager
|
|
6
|
+
import androidx.credentials.GetCredentialRequest
|
|
7
|
+
import androidx.credentials.GetPublicKeyCredentialOption
|
|
8
|
+
import androidx.credentials.PublicKeyCredential
|
|
9
|
+
import androidx.credentials.exceptions.GetCredentialCancellationException
|
|
10
|
+
import androidx.credentials.exceptions.GetCredentialException
|
|
11
|
+
import androidx.credentials.exceptions.NoCredentialException
|
|
12
|
+
import expo.modules.kotlin.exception.CodedException
|
|
13
|
+
import expo.modules.kotlin.functions.Coroutine
|
|
14
|
+
import expo.modules.kotlin.modules.Module
|
|
15
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
16
|
+
|
|
17
|
+
private const val PRIMERA_VERSION_CON_PASSKEYS = Build.VERSION_CODES.P
|
|
18
|
+
|
|
19
|
+
class PasskeyNativaModule : Module() {
|
|
20
|
+
override fun definition() =
|
|
21
|
+
ModuleDefinition {
|
|
22
|
+
Name("PasskeyNativa")
|
|
23
|
+
|
|
24
|
+
AsyncFunction("biometria") { biometriaDelDispositivo() }
|
|
25
|
+
|
|
26
|
+
AsyncFunction("firmar") Coroutine { opcionesJson: String -> firmar(opcionesJson) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private fun biometriaDelDispositivo(): String? {
|
|
30
|
+
if (Build.VERSION.SDK_INT < PRIMERA_VERSION_CON_PASSKEYS) return null
|
|
31
|
+
val paquetes = appContext.reactContext?.packageManager ?: return null
|
|
32
|
+
return when {
|
|
33
|
+
paquetes.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) -> "huella"
|
|
34
|
+
paquetes.hasSystemFeature(PackageManager.FEATURE_FACE) -> "rostro"
|
|
35
|
+
else -> "otra"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private suspend fun firmar(opcionesJson: String): String {
|
|
40
|
+
val actividad = appContext.currentActivity ?: throw ErrorDeLaPasskey.sinActividad()
|
|
41
|
+
val pedido = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(opcionesJson)))
|
|
42
|
+
val credencial =
|
|
43
|
+
try {
|
|
44
|
+
CredentialManager.create(actividad).getCredential(actividad, pedido).credential
|
|
45
|
+
} catch (error: GetCredentialCancellationException) {
|
|
46
|
+
throw ErrorDeLaPasskey.cancelada(error)
|
|
47
|
+
} catch (error: NoCredentialException) {
|
|
48
|
+
throw ErrorDeLaPasskey.sinPasskey(error)
|
|
49
|
+
} catch (error: GetCredentialException) {
|
|
50
|
+
throw ErrorDeLaPasskey.fallida(error)
|
|
51
|
+
}
|
|
52
|
+
if (credencial !is PublicKeyCredential) throw ErrorDeLaPasskey.fallida(null)
|
|
53
|
+
return credencial.authenticationResponseJson
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private object ErrorDeLaPasskey {
|
|
58
|
+
fun cancelada(causa: Throwable): CodedException =
|
|
59
|
+
CodedException("acceso_cancelado", "Se canceló la passkey.", causa)
|
|
60
|
+
|
|
61
|
+
fun sinPasskey(causa: Throwable): CodedException =
|
|
62
|
+
CodedException("sin_passkey", "No hay passkey en este dispositivo.", causa)
|
|
63
|
+
|
|
64
|
+
fun sinActividad(): CodedException =
|
|
65
|
+
CodedException("passkey_fallida", "No hay una pantalla activa para pedir la passkey.", null)
|
|
66
|
+
|
|
67
|
+
fun fallida(causa: Throwable?): CodedException =
|
|
68
|
+
CodedException("passkey_fallida", "No se pudo usar la passkey.", causa)
|
|
69
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ProveedorDePasskey } from '@neodaten/auth-native';
|
|
2
|
+
export { proveedorDePasskeyCon } from './proveedor-de-passkey';
|
|
3
|
+
export type { ModuloPasskeyNativa } from './modulo-passkey-nativa';
|
|
4
|
+
export declare const crearProveedorDePasskeyExpo: () => ProveedorDePasskey;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { requireNativeModule } from 'expo-modules-core';
|
|
2
|
+
import { NOMBRE_DEL_MODULO } from './modulo-passkey-nativa';
|
|
3
|
+
import { proveedorDePasskeyCon } from './proveedor-de-passkey';
|
|
4
|
+
export { proveedorDePasskeyCon } from './proveedor-de-passkey';
|
|
5
|
+
export const crearProveedorDePasskeyExpo = () => proveedorDePasskeyCon(requireNativeModule(NOMBRE_DEL_MODULO));
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAExD,OAAO,EAAE,iBAAiB,EAA4B,MAAM,yBAAyB,CAAC;AACtF,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAG/D,MAAM,CAAC,MAAM,2BAA2B,GAAG,GAAuB,EAAE,CAClE,qBAAqB,CAAC,mBAAmB,CAAsB,iBAAiB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ModuloPasskeyNativa {
|
|
2
|
+
biometria(): Promise<string | null>;
|
|
3
|
+
firmar(opcionesJson: string): Promise<string>;
|
|
4
|
+
}
|
|
5
|
+
export declare const NOMBRE_DEL_MODULO = "PasskeyNativa";
|
|
6
|
+
export declare const CODIGO_DE_CANCELACION = "acceso_cancelado";
|
|
7
|
+
export declare const CODIGO_SIN_PASSKEY = "sin_passkey";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"modulo-passkey-nativa.js","sourceRoot":"","sources":["../src/modulo-passkey-nativa.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,iBAAiB,GAAG,eAAe,CAAC;AAEjD,MAAM,CAAC,MAAM,qBAAqB,GAAG,kBAAkB,CAAC;AACxD,MAAM,CAAC,MAAM,kBAAkB,GAAG,aAAa,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ErrorDeIdentidad, } from '@neodaten/auth-native';
|
|
2
|
+
import { CODIGO_DE_CANCELACION, CODIGO_SIN_PASSKEY, } from './modulo-passkey-nativa';
|
|
3
|
+
const BIOMETRIAS = new Set([
|
|
4
|
+
'rostro',
|
|
5
|
+
'huella',
|
|
6
|
+
'otra',
|
|
7
|
+
]);
|
|
8
|
+
const esBiometria = (valor) => BIOMETRIAS.has(valor);
|
|
9
|
+
const codigoDe = (error) => typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
|
10
|
+
const comoErrorDeIdentidad = (error) => {
|
|
11
|
+
const codigo = codigoDe(error);
|
|
12
|
+
if (codigo === CODIGO_DE_CANCELACION)
|
|
13
|
+
return new ErrorDeIdentidad('acceso_cancelado');
|
|
14
|
+
if (codigo === CODIGO_SIN_PASSKEY)
|
|
15
|
+
return new ErrorDeIdentidad('sin_passkey');
|
|
16
|
+
return new ErrorDeIdentidad('respuesta_invalida', { cause: error });
|
|
17
|
+
};
|
|
18
|
+
export const proveedorDePasskeyCon = (modulo) => ({
|
|
19
|
+
async biometria() {
|
|
20
|
+
const leida = await modulo.biometria();
|
|
21
|
+
return leida !== null && esBiometria(leida) ? leida : null;
|
|
22
|
+
},
|
|
23
|
+
async firmar(opcionesJson) {
|
|
24
|
+
try {
|
|
25
|
+
return await modulo.firmar(opcionesJson);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw comoErrorDeIdentidad(error);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
//# sourceMappingURL=proveedor-de-passkey.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proveedor-de-passkey.js","sourceRoot":"","sources":["../src/proveedor-de-passkey.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,GAGjB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,qBAAqB,EACrB,kBAAkB,GAEnB,MAAM,yBAAyB,CAAC;AAEjC,MAAM,UAAU,GAAwB,IAAI,GAAG,CAA0B;IACvE,QAAQ;IACR,QAAQ;IACR,MAAM;CACP,CAAC,CAAC;AAEH,MAAM,WAAW,GAAG,CAAC,KAAa,EAAoC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;AAE/F,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAW,EAAE,CAC3C,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;AAE1F,MAAM,oBAAoB,GAAG,CAAC,KAAc,EAAoB,EAAE;IAChE,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC/B,IAAI,MAAM,KAAK,qBAAqB;QAAE,OAAO,IAAI,gBAAgB,CAAC,kBAAkB,CAAC,CAAC;IACtF,IAAI,MAAM,KAAK,kBAAkB;QAAE,OAAO,IAAI,gBAAgB,CAAC,aAAa,CAAC,CAAC;IAC9E,OAAO,IAAI,gBAAgB,CAAC,oBAAoB,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;AACtE,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,MAA2B,EAAsB,EAAE,CAAC,CAAC;IACzF,KAAK,CAAC,SAAS;QACb,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,SAAS,EAAE,CAAC;QACvC,OAAO,KAAK,KAAK,IAAI,IAAI,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7D,CAAC;IACD,KAAK,CAAC,MAAM,CAAC,YAAY;QACvB,IAAI,CAAC;YACH,OAAO,MAAM,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;QAC3C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,oBAAoB,CAAC,KAAK,CAAC,CAAC;QACpC,CAAC;IACH,CAAC;CACF,CAAC,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum Base64Url {
|
|
4
|
+
static func codificar(_ datos: Data) -> String {
|
|
5
|
+
datos.base64EncodedString()
|
|
6
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
7
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
8
|
+
.replacingOccurrences(of: "=", with: "")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static func decodificar(_ texto: String) -> Data? {
|
|
12
|
+
let base64 = texto
|
|
13
|
+
.replacingOccurrences(of: "-", with: "+")
|
|
14
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
15
|
+
let relleno = String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
|
16
|
+
return Data(base64Encoded: base64 + relleno)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import AuthenticationServices
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
@available(iOS 16.0, *)
|
|
5
|
+
final class CeremoniaDeLaPasskey: NSObject, ASAuthorizationControllerDelegate,
|
|
6
|
+
ASAuthorizationControllerPresentationContextProviding
|
|
7
|
+
{
|
|
8
|
+
private static let cancelacionSinInterfazEnSegundos: TimeInterval = 1
|
|
9
|
+
|
|
10
|
+
private let alTerminar: (Result<String, ErrorDeLaPasskey>) -> Void
|
|
11
|
+
private var iniciadaEn = Date()
|
|
12
|
+
private var controlador: ASAuthorizationController?
|
|
13
|
+
|
|
14
|
+
init(alTerminar: @escaping (Result<String, ErrorDeLaPasskey>) -> Void) {
|
|
15
|
+
self.alTerminar = alTerminar
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func firmar(desafio: Data, rpId: String) {
|
|
19
|
+
let proveedor = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
|
|
20
|
+
let pedido = proveedor.createCredentialAssertionRequest(challenge: desafio)
|
|
21
|
+
pedido.userVerificationPreference = .required
|
|
22
|
+
let controlador = ASAuthorizationController(authorizationRequests: [pedido])
|
|
23
|
+
controlador.delegate = self
|
|
24
|
+
controlador.presentationContextProvider = self
|
|
25
|
+
self.controlador = controlador
|
|
26
|
+
iniciadaEn = Date()
|
|
27
|
+
controlador.performRequests(options: .preferImmediatelyAvailableCredentials)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func authorizationController(
|
|
31
|
+
controller: ASAuthorizationController,
|
|
32
|
+
didCompleteWithAuthorization authorization: ASAuthorization
|
|
33
|
+
) {
|
|
34
|
+
guard let asercion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {
|
|
35
|
+
terminar(.failure(.fallida("El sistema no devolvió una passkey.")))
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
do {
|
|
39
|
+
terminar(.success(try RespuestaDeLaPasskey.json(asercion)))
|
|
40
|
+
} catch {
|
|
41
|
+
terminar(.failure(.fallida(error.localizedDescription)))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
|
46
|
+
terminar(.failure(errorDe(error)))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
|
50
|
+
let escenas = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
51
|
+
return escenas.flatMap(\.windows).first(where: \.isKeyWindow) ?? ASPresentationAnchor()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private func errorDe(_ error: Error) -> ErrorDeLaPasskey {
|
|
55
|
+
guard let error = error as? ASAuthorizationError else {
|
|
56
|
+
return .fallida(error.localizedDescription)
|
|
57
|
+
}
|
|
58
|
+
switch error.code {
|
|
59
|
+
case .canceled:
|
|
60
|
+
return fueSinInterfaz() ? .sinPasskey : .cancelada
|
|
61
|
+
case .notInteractive:
|
|
62
|
+
return .sinPasskey
|
|
63
|
+
default:
|
|
64
|
+
return .fallida(error.localizedDescription)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func fueSinInterfaz() -> Bool {
|
|
69
|
+
Date().timeIntervalSince(iniciadaEn) < Self.cancelacionSinInterfazEnSegundos
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private func terminar(_ resultado: Result<String, ErrorDeLaPasskey>) {
|
|
73
|
+
controlador = nil
|
|
74
|
+
alTerminar(resultado)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
enum ErrorDeLaPasskey: Error {
|
|
4
|
+
case opcionesInvalidas
|
|
5
|
+
case cancelada
|
|
6
|
+
case sinPasskey
|
|
7
|
+
case fallida(String)
|
|
8
|
+
|
|
9
|
+
var excepcion: Exception {
|
|
10
|
+
switch self {
|
|
11
|
+
case .cancelada:
|
|
12
|
+
return Exception(name: "acceso_cancelado", description: "Se canceló la passkey.", code: "acceso_cancelado")
|
|
13
|
+
case .sinPasskey:
|
|
14
|
+
return Exception(name: "sin_passkey", description: "No hay passkey en este dispositivo.", code: "sin_passkey")
|
|
15
|
+
case .opcionesInvalidas:
|
|
16
|
+
return Exception(name: "passkey_fallida", description: "Las opciones de la passkey no son válidas.", code: "passkey_fallida")
|
|
17
|
+
case .fallida(let detalle):
|
|
18
|
+
return Exception(name: "passkey_fallida", description: detalle, code: "passkey_fallida")
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct OpcionesDeLaPasskey: Decodable {
|
|
4
|
+
let challenge: String
|
|
5
|
+
let rpId: String
|
|
6
|
+
|
|
7
|
+
static func leer(_ json: String) throws -> (desafio: Data, rpId: String) {
|
|
8
|
+
let opciones = try JSONDecoder().decode(OpcionesDeLaPasskey.self, from: Data(json.utf8))
|
|
9
|
+
guard let desafio = Base64Url.decodificar(opciones.challenge) else {
|
|
10
|
+
throw ErrorDeLaPasskey.opcionesInvalidas
|
|
11
|
+
}
|
|
12
|
+
return (desafio, opciones.rpId)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'PasskeyNativa'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.license = 'UNLICENSED'
|
|
10
|
+
s.author = 'Neodaten'
|
|
11
|
+
s.homepage = 'https://github.com/Neodaten/n360'
|
|
12
|
+
s.platforms = { :ios => '16.0' }
|
|
13
|
+
s.swift_version = '5.9'
|
|
14
|
+
s.source = { :git => 'https://github.com/Neodaten/n360.git' }
|
|
15
|
+
s.static_framework = true
|
|
16
|
+
|
|
17
|
+
s.dependency 'ExpoModulesCore'
|
|
18
|
+
|
|
19
|
+
s.source_files = '**/*.swift'
|
|
20
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import LocalAuthentication
|
|
3
|
+
|
|
4
|
+
public class PasskeyNativaModule: Module {
|
|
5
|
+
private var ceremoniaEnCurso: AnyObject?
|
|
6
|
+
|
|
7
|
+
public func definition() -> ModuleDefinition {
|
|
8
|
+
Name("PasskeyNativa")
|
|
9
|
+
|
|
10
|
+
AsyncFunction("biometria") { () -> String? in
|
|
11
|
+
biometriaDelDispositivo()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
AsyncFunction("firmar") { (opcionesJson: String, promesa: Promise) in
|
|
15
|
+
self.firmar(opcionesJson: opcionesJson, promesa: promesa)
|
|
16
|
+
}.runOnQueue(.main)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private func firmar(opcionesJson: String, promesa: Promise) {
|
|
20
|
+
guard #available(iOS 16.0, *) else {
|
|
21
|
+
promesa.reject(ErrorDeLaPasskey.sinPasskey.excepcion)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
do {
|
|
25
|
+
let (desafio, rpId) = try OpcionesDeLaPasskey.leer(opcionesJson)
|
|
26
|
+
let ceremonia = CeremoniaDeLaPasskey { [weak self] resultado in
|
|
27
|
+
self?.ceremoniaEnCurso = nil
|
|
28
|
+
switch resultado {
|
|
29
|
+
case .success(let respuesta): promesa.resolve(respuesta)
|
|
30
|
+
case .failure(let error): promesa.reject(error.excepcion)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
ceremoniaEnCurso = ceremonia
|
|
34
|
+
ceremonia.firmar(desafio: desafio, rpId: rpId)
|
|
35
|
+
} catch {
|
|
36
|
+
promesa.reject(ErrorDeLaPasskey.opcionesInvalidas.excepcion)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private func biometriaDelDispositivo() -> String? {
|
|
42
|
+
guard #available(iOS 16.0, *) else { return nil }
|
|
43
|
+
let contexto = LAContext()
|
|
44
|
+
var error: NSError?
|
|
45
|
+
guard contexto.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return nil }
|
|
46
|
+
switch contexto.biometryType {
|
|
47
|
+
case .faceID: return "rostro"
|
|
48
|
+
case .touchID: return "huella"
|
|
49
|
+
default: return "otra"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import AuthenticationServices
|
|
2
|
+
|
|
3
|
+
enum RespuestaDeLaPasskey {
|
|
4
|
+
static func json(_ asercion: ASAuthorizationPlatformPublicKeyCredentialAssertion) throws -> String {
|
|
5
|
+
let identificador = Base64Url.codificar(asercion.credentialID)
|
|
6
|
+
let respuesta: [String: Any] = [
|
|
7
|
+
"id": identificador,
|
|
8
|
+
"rawId": identificador,
|
|
9
|
+
"type": "public-key",
|
|
10
|
+
"authenticatorAttachment": "platform",
|
|
11
|
+
"clientExtensionResults": [String: Any](),
|
|
12
|
+
"response": [
|
|
13
|
+
"clientDataJSON": Base64Url.codificar(asercion.rawClientDataJSON),
|
|
14
|
+
"authenticatorData": Base64Url.codificar(asercion.rawAuthenticatorData),
|
|
15
|
+
"signature": Base64Url.codificar(asercion.signature),
|
|
16
|
+
"userHandle": Base64Url.codificar(asercion.userID),
|
|
17
|
+
],
|
|
18
|
+
]
|
|
19
|
+
let datos = try JSONSerialization.data(withJSONObject: respuesta)
|
|
20
|
+
guard let texto = String(data: datos, encoding: .utf8) else {
|
|
21
|
+
throw ErrorDeLaPasskey.fallida("No se pudo armar la respuesta de la passkey.")
|
|
22
|
+
}
|
|
23
|
+
return texto
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neodaten/passkey-expo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Módulo Expo que firma con la passkey del sistema (Face ID, Touch ID o huella) para entrar desde la bienvenida con @neodaten/auth-native.",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"ios",
|
|
18
|
+
"android/build.gradle",
|
|
19
|
+
"android/src",
|
|
20
|
+
"expo-module.config.json",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/Neodaten/n360.git",
|
|
26
|
+
"directory": "packages/passkey-expo"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"registry": "https://registry.npmjs.org",
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@neodaten/auth-native": ">=0.1.0",
|
|
34
|
+
"expo-modules-core": ">=57.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"expo-modules-core": "57.0.25",
|
|
38
|
+
"react": "^19.2.0",
|
|
39
|
+
"react-native": "0.86.3",
|
|
40
|
+
"typescript": "^6.0.3",
|
|
41
|
+
"vitest": "^4.1.0",
|
|
42
|
+
"@neodaten/auth-native": "0.1.0",
|
|
43
|
+
"@neodaten/eslint-config": "0.0.0",
|
|
44
|
+
"@neodaten/tsconfig": "0.0.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"empaquetar": "tsc -p tsconfig.empaquetado.json",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
}
|
|
6
50
|
}
|