@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.
Files changed (2) hide show
  1. package/README.md +200 -2
  2. package/package.json +71 -4
package/README.md CHANGED
@@ -1,3 +1,201 @@
1
- # Temporary Holding Version
1
+ # @neodaten/auth-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }