@neodaten/auth-native 0.0.0-stage → 0.0.1-reserva.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 +200 -2
- package/package.json +71 -4
package/README.md
CHANGED
|
@@ -1,3 +1,201 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @neodaten/auth-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cliente de identidad para apps React Native del ecosistema. La app entra por el login hospedado del
|
|
4
|
+
emisor del proyecto (`…/p/<slug>`) con `authorization_code` + PKCE S256 y renueva con el refresh
|
|
5
|
+
rotativo. Sin dependencias: usa `fetch`, `crypto.getRandomValues` y un almacén seguro que inyecta la
|
|
6
|
+
app.
|
|
7
|
+
|
|
8
|
+
## Uso
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { crearClienteDeIdentidadNativa, type AlmacenSeguro } from '@neodaten/auth-native';
|
|
12
|
+
|
|
13
|
+
const identidad = crearClienteDeIdentidadNativa({
|
|
14
|
+
emisor: 'https://auth.neodaten.com/p/luup',
|
|
15
|
+
coreApiUrl: 'https://auth.neodaten.com/api',
|
|
16
|
+
clienteId: 'luup-ios',
|
|
17
|
+
recurso: 'urn:neodaten:api:luup',
|
|
18
|
+
redireccion: 'com.crashapp.neodaten:/oauth/callback',
|
|
19
|
+
almacen,
|
|
20
|
+
proveedorDeAtestacion,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const login = await identidad.abrirLoginHospedado();
|
|
24
|
+
login.url;
|
|
25
|
+
login.decidir({ url, esMarcoPrincipal });
|
|
26
|
+
await login.completar(urlDelRegreso);
|
|
27
|
+
const token = await identidad.tokenDeAcceso();
|
|
28
|
+
await identidad.cerrarSesion();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `abrirLoginHospedado()` lee el discovery (el `issuer` tiene que ser el emisor configurado), genera
|
|
32
|
+
PKCE, `state` y `nonce`, y con `proveedorDeAtestacion` pide el ticket de atestación
|
|
33
|
+
(`/auth/atestacion/reto` y `/auth/atestacion/ticket`) y lo manda como `atestacion`. Sin proveedor
|
|
34
|
+
abre sin ticket.
|
|
35
|
+
- `origenDelLogin` es el origen de login-web cuando no comparte host con el emisor (en local,
|
|
36
|
+
`http://localhost:3305`). Por omisión es el del emisor; tiene que ser https salvo localhost o
|
|
37
|
+
loopback con un emisor también local, y nunca se toma del regreso.
|
|
38
|
+
- `decidir()` solo carga los orígenes del emisor, del `authorization_endpoint` y del login; Turnstile
|
|
39
|
+
(`challenges.cloudflare.com`) solo dentro de un iframe. El regreso a `redireccion` desde el marco
|
|
40
|
+
principal es el `callback`; lo demás se descarta.
|
|
41
|
+
- `completar()` se usa una sola vez: valida `state` e `iss`, canjea el código con el
|
|
42
|
+
`code_verifier`, valida el id_token (firma ES256 con el JWKS del emisor, `iss`, `aud`/`azp`,
|
|
43
|
+
`nonce`, `exp`, `iat`, `sub`) y guarda la sesión en el almacén.
|
|
44
|
+
- `cerrarSesion()` revoca el refresh y borra el almacén.
|
|
45
|
+
- Firma: con `crypto.subtle` se verifica ES256 contra el JWKS del emisor. Sin él (Hermes) no se
|
|
46
|
+
verifica la firma y se confía en el TLS del canje (OIDC Core 3.1.3.7, punto 6): el id_token solo
|
|
47
|
+
se toma de la respuesta del endpoint de token (que tiene que ser https, salvo localhost con un
|
|
48
|
+
emisor también local), nunca de la URL, y se siguen exigiendo `iss`,
|
|
49
|
+
`aud`, `azp`, `nonce`, `exp` e `iat`. PKCE usa un SHA-256 propio. Solo hace falta
|
|
50
|
+
`crypto.getRandomValues`; sin él, `abrirLoginHospedado()` da `sin_criptografia`.
|
|
51
|
+
- La aplicación tiene que ser NATIVE en la consola, con `<bundle>:/oauth/callback` como URL de
|
|
52
|
+
retorno y de cierre de sesión.
|
|
53
|
+
|
|
54
|
+
## Pantalla
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { PantallaDeLoginHospedado } from '@neodaten/auth-native/pantalla';
|
|
58
|
+
|
|
59
|
+
<PantallaDeLoginHospedado
|
|
60
|
+
login={login}
|
|
61
|
+
alEntrar={() => navegar('inicio')}
|
|
62
|
+
alFallar={(error) => mostrar(error)}
|
|
63
|
+
alDescartarEnlace={(url) => registrar(url)}
|
|
64
|
+
/>;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
WebView a pantalla completa, incógnita, sin cache ni cookies compartidas, sin puente JS, sin
|
|
68
|
+
ventanas nuevas ni archivos locales. Toda navegación pasa por `login.decidir()`
|
|
69
|
+
(`originWhitelist: ['*']` para que react-native-webview no mande las URLs ajenas a
|
|
70
|
+
`Linking.openURL`). Requiere `react` y `react-native-webview` >=13.15.0 <15 en la app.
|
|
71
|
+
|
|
72
|
+
## Desbloqueo con Face ID, Touch ID o huella
|
|
73
|
+
|
|
74
|
+
`AlmacenConDesbloqueo` extiende `AlmacenSeguro`: guarda la sesión en el llavero normal y, con el
|
|
75
|
+
desbloqueo activo, mueve el refresh a un elemento del llavero que solo se abre con la biometría del
|
|
76
|
+
set actual del dispositivo (`BIOMETRY_CURRENT_SET`, `WHEN_PASSCODE_SET_THIS_DEVICE_ONLY`). La app
|
|
77
|
+
solo crea el almacén con el adaptador de su librería, se lo pasa al cliente y conecta un interruptor
|
|
78
|
+
en Mi cuenta.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
almacen.desbloqueoDisponible(); // Promise<boolean>: si es false, no se muestra el interruptor
|
|
82
|
+
almacen.desbloqueoActivo(); // Promise<boolean>
|
|
83
|
+
almacen.activarDesbloqueo(); // pide el biométrico y protege el refresh
|
|
84
|
+
almacen.desactivarDesbloqueo(); // pide el biométrico y regresa el refresh al llavero normal
|
|
85
|
+
almacen.bloquear(); // olvida el desbloqueo en memoria; la siguiente lectura vuelve a pedirlo
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- Con el desbloqueo activo, la primera lectura de la sesión (`tokenDeAcceso()`, `renovar()`) pide
|
|
89
|
+
el biométrico una vez; mientras la app siga viva usa el refresh desbloqueado en memoria. Para
|
|
90
|
+
volver a pedirlo al regresar a primer plano, llama `almacen.bloquear()` desde `AppState`.
|
|
91
|
+
- Si cancelan o el sistema rechaza el aviso, sale `ErrorDeIdentidad` con `desbloqueo_cancelado`: la
|
|
92
|
+
sesión se conserva y la app ofrece `TEXTOS_DEL_DESBLOQUEO.entrarConCodigo`, que abre el login
|
|
93
|
+
hospedado.
|
|
94
|
+
- Si el sistema invalidó la llave (cambiaron los rostros o las huellas), sale
|
|
95
|
+
`desbloqueo_invalidado`: el paquete ya borró la sesión local y la app manda al login.
|
|
96
|
+
- `cerrarSesion()` borra todo y apaga el desbloqueo. Si no confirman el biométrico, igual borra la
|
|
97
|
+
sesión local, solo que no puede revocar el refresh.
|
|
98
|
+
- Errores al activar: `desbloqueo_no_disponible` (sin biometría), `sin_sesion` y
|
|
99
|
+
`desbloqueo_cancelado`. `esErrorDeDesbloqueo(error)` reconoce los dos de la lectura.
|
|
100
|
+
- Con expo-secure-store en Android, guardar en el elemento protegido también pide el biométrico, así
|
|
101
|
+
que cada rotación del refresh con el desbloqueo activo lo vuelve a pedir. En iOS no: el elemento se
|
|
102
|
+
borra y se crea de nuevo, y crear no pide autenticación.
|
|
103
|
+
- Con react-native-keychain en Android, una llave invalidada se reporta igual que una cancelación:
|
|
104
|
+
llega como `desbloqueo_cancelado` y se resuelve al entrar con código, que reemplaza la llave.
|
|
105
|
+
|
|
106
|
+
Los textos (`TEXTOS_DEL_DESBLOQUEO`) están en español de México y cada adaptador acepta los propios
|
|
107
|
+
como segundo argumento:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { TEXTOS_DEL_DESBLOQUEO } from '@neodaten/auth-native';
|
|
111
|
+
|
|
112
|
+
const textos = { ...TEXTOS_DEL_DESBLOQUEO, motivo: 'Confirma que eres tú para ver tus pedidos.' };
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### App Expo (COMA, conductor de OneChain, Baki, PROTEQA)
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import * as SecureStore from 'expo-secure-store';
|
|
119
|
+
import { crearAlmacenExpo } from '@neodaten/auth-native/expo';
|
|
120
|
+
|
|
121
|
+
const almacen = crearAlmacenExpo(SecureStore);
|
|
122
|
+
const identidad = crearClienteDeIdentidadNativa({ ...opciones, almacen });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Usa `requireAuthentication: true`, `keychainAccessible: WHEN_PASSCODE_SET_THIS_DEVICE_ONLY` y
|
|
126
|
+
`authenticationPrompt` con el motivo; la disponibilidad sale de `canUseBiometricAuthentication()`.
|
|
127
|
+
En `app.json`, el plugin declara el texto de Face ID:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
[
|
|
131
|
+
"expo-secure-store",
|
|
132
|
+
{ "faceIDPermission": "Usamos Face ID para que entres a tu cuenta sin escribir un código." }
|
|
133
|
+
]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### App RN CLI (Luup)
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import * as Keychain from 'react-native-keychain';
|
|
140
|
+
import { crearAlmacenKeychain } from '@neodaten/auth-native/keychain';
|
|
141
|
+
|
|
142
|
+
const almacen = crearAlmacenKeychain(Keychain);
|
|
143
|
+
const identidad = crearClienteDeIdentidadNativa({ ...opciones, almacen });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Usa `accessControl: BIOMETRY_CURRENT_SET`, `accessible: WHEN_PASSCODE_SET_THIS_DEVICE_ONLY` y, en
|
|
147
|
+
Android, `storage: STORAGE_TYPE.RSA`: se cifra con la llave pública sin pedir el biométrico y solo
|
|
148
|
+
descifrar lo pide, así la rotación del refresh no interrumpe. El valor cabe en un bloque RSA de 2048
|
|
149
|
+
bits (hasta 245 bytes). La llave de Android que crea la librería acepta también el código del
|
|
150
|
+
dispositivo y no se invalida al cambiar las huellas. La disponibilidad sale de `getSupportedBiometryType()`. En `ios/<App>/Info.plist`:
|
|
151
|
+
|
|
152
|
+
```xml
|
|
153
|
+
<key>NSFaceIDUsageDescription</key>
|
|
154
|
+
<string>Usamos Face ID para que entres a tu cuenta sin escribir un código.</string>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Ninguna de las dos entradas suma dependencias: la app inyecta el módulo y el paquete lo tipa con una
|
|
158
|
+
interfaz que coincide con su API pública.
|
|
159
|
+
|
|
160
|
+
## Bienvenida con la última cuenta y passkey
|
|
161
|
+
|
|
162
|
+
Después de «Salir», la bienvenida saluda a la última cuenta y entra con la passkey del sistema (Face ID, Touch ID o huella) sin abrir el login hospedado. La passkey se sigue creando en el login hospedado, con la oferta que aparece tras el SMS.
|
|
163
|
+
|
|
164
|
+
**Requisitos en el core:**
|
|
165
|
+
|
|
166
|
+
- la aplicación nativa de primera parte tiene `settings.passkeyEnLaBienvenida`;
|
|
167
|
+
- el proyecto admite passkey como primer factor.
|
|
168
|
+
|
|
169
|
+
**El cliente recibe un `ProveedorDePasskey`:**
|
|
170
|
+
|
|
171
|
+
- `biometria()` devuelve `'rostro' | 'huella' | 'otra'`, o `null` si el sistema no admite passkeys;
|
|
172
|
+
- `firmar(opcionesJson)` recibe `PublicKeyCredentialRequestOptionsJSON` y devuelve el `AuthenticationResponseJSON` como texto. Si la persona cancela, rechaza con `ErrorDeIdentidad('acceso_cancelado')`. Si no hay credencial en el dispositivo, rechaza con `ErrorDeIdentidad('sin_passkey')`.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const cliente = crearClienteDeIdentidadNativa({ ...opciones, proveedorDePasskey });
|
|
176
|
+
const { estado, entrando, error, entrarConPasskey, usarOtraCuenta } = useBienvenida(cliente);
|
|
177
|
+
const resultado = await entrarConPasskey();
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Qué significa cada `resultado`:
|
|
181
|
+
|
|
182
|
+
- `'dentro'`: la sesión ya está guardada.
|
|
183
|
+
- `'cancelado'`: no se hace nada.
|
|
184
|
+
- `'abrir_login'`: se abre el login hospedado.
|
|
185
|
+
- `'reintentar'`: hay que mostrar `error`.
|
|
186
|
+
|
|
187
|
+
**Qué se guarda y cuándo se borra:**
|
|
188
|
+
|
|
189
|
+
- La última cuenta (`neodaten.ultima-cuenta`) guarda solo `sub`, nombre, el teléfono enmascarado a cuatro dígitos y `tienePasskey`.
|
|
190
|
+
- Se escribe en cada login y en cada renovación.
|
|
191
|
+
- `cerrarSesion()` la conserva y `usarOtraCuenta()` la borra.
|
|
192
|
+
- Los alcances por omisión incluyen `profile phone` para que el `id_token` traiga el nombre, el teléfono y `tiene_passkey`.
|
|
193
|
+
|
|
194
|
+
El contrato completo del reto y del grant `urn:neodaten:grant-type:passkey` está en la descripción OpenAPI de `POST /api/oidc/{proyecto}/nativa/passkey/reto`.
|
|
195
|
+
|
|
196
|
+
## Publicación
|
|
197
|
+
|
|
198
|
+
`pnpm --filter @neodaten/auth-native empaquetar` compila a `dist/` con `tsc`. El workflow
|
|
199
|
+
`publicar-auth-native.yml` publica en npm con procedencia al empujar el tag `auth-native-v<versión>`
|
|
200
|
+
sobre un commit de `main`; la versión del tag tiene que coincidir con la de `package.json`. En npm
|
|
201
|
+
hay que dar de alta la publicación de confianza para este paquete y este workflow.
|
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neodaten/auth-native",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-reserva.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Cliente de identidad para apps React Native: login hospedado con código y PKCE, refresh rotativo y almacén seguro inyectado.",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./pantalla": "./src/pantalla/index.ts",
|
|
12
|
+
"./expo": "./src/expo/index.ts",
|
|
13
|
+
"./keychain": "./src/keychain/index.ts",
|
|
14
|
+
"./bienvenida": "./src/bienvenida/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/Neodaten/n360.git",
|
|
23
|
+
"directory": "packages/auth-native"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"registry": "https://registry.npmjs.org",
|
|
27
|
+
"access": "public",
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./pantalla": {
|
|
36
|
+
"types": "./dist/pantalla/index.d.ts",
|
|
37
|
+
"import": "./dist/pantalla/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./expo": {
|
|
40
|
+
"types": "./dist/expo/index.d.ts",
|
|
41
|
+
"import": "./dist/expo/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./keychain": {
|
|
44
|
+
"types": "./dist/keychain/index.d.ts",
|
|
45
|
+
"import": "./dist/keychain/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./bienvenida": {
|
|
48
|
+
"types": "./dist/bienvenida/index.d.ts",
|
|
49
|
+
"import": "./dist/bienvenida/index.js"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"empaquetar": "tsc -p tsconfig.empaquetado.json",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:watch": "vitest"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": "catalog:",
|
|
60
|
+
"react-native-webview": ">=13.15.0 <15",
|
|
61
|
+
"react-native": ">=0.80.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@neodaten/eslint-config": "workspace:*",
|
|
65
|
+
"@neodaten/tsconfig": "workspace:*",
|
|
66
|
+
"@types/react": "catalog:",
|
|
67
|
+
"react": "catalog:",
|
|
68
|
+
"react-native": "0.86.3",
|
|
69
|
+
"react-native-webview": "14.0.1",
|
|
70
|
+
"typescript": "catalog:",
|
|
71
|
+
"vitest": "catalog:"
|
|
72
|
+
}
|
|
73
|
+
}
|