@ollaid/native-sso 1.0.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 ADDED
@@ -0,0 +1,1682 @@
1
+ # @ollaid/native-sso
2
+
3
+ Package NPM Frontend-First pour l'authentification Native SSO Ollaid.
4
+ **Un `npm install` et une route — c'est tout.**
5
+
6
+ ---
7
+
8
+ ## Table des matières
9
+
10
+ 1. [Installation](#installation)
11
+ 2. [Intégration rapide (3 étapes)](#intégration-rapide-3-étapes)
12
+ 3. [Props de NativeSSOPage](#props-de-nativessopage)
13
+ 4. [Usage avancé (composants individuels)](#usage-avancé-composants-individuels)
14
+ 5. [Backend SaaS — Endpoints requis](#backend-saas--endpoints-requis)
15
+ 6. [APIs IAM Account (Server-to-Server)](#apis-iam-account-server-to-server)
16
+ 7. [Avatar par application](#avatar-par-application)
17
+ 8. [Réponses d'erreur](#réponses-derreur)
18
+ 9. [Configuration .env Laravel](#configuration-env-laravel)
19
+ 10. [Migration Laravel](#migration-laravel)
20
+ 11. [Flux d'authentification](#flux-dauthentification)
21
+ 12. [Session & localStorage](#session--localstorage)
22
+ 13. [OnboardingModal](#onboardingmodal)
23
+ 14. [useTokenHealthCheck](#usetokenhealthcheck)
24
+ 15. [Sécurité](#sécurité)
25
+ 16. [Exports](#exports)
26
+ 17. [Publication & Installation npm](#publication--installation-npm)
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install @ollaid/native-sso
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Intégration rapide (3 étapes)
39
+
40
+ ### 1. Installer le package
41
+
42
+ ```bash
43
+ npm install @ollaid/native-sso
44
+ ```
45
+
46
+ ### 2. Ajouter la route dans `App.tsx`
47
+
48
+ ```tsx
49
+ import { NativeSSOPage } from '@ollaid/native-sso';
50
+ import { Route, Routes, useNavigate } from 'react-router-dom';
51
+
52
+ function App() {
53
+ const navigate = useNavigate();
54
+
55
+ return (
56
+ <Routes>
57
+ <Route
58
+ path="/auth/sso"
59
+ element={
60
+ <NativeSSOPage
61
+ saasApiUrl="https://mon-saas.com/api"
62
+ iamApiUrl="https://identityam.ollaid.com/api"
63
+ onLoginSuccess={(token, user) => {
64
+ console.log('Connecté !', user.name);
65
+ navigate('/dashboard');
66
+ }}
67
+ onLogout={() => navigate('/auth/sso')}
68
+ />
69
+ }
70
+ />
71
+ {/* ... autres routes */}
72
+ </Routes>
73
+ );
74
+ }
75
+ ```
76
+
77
+ ### 3. C'est tout ✅
78
+
79
+ La page `/auth/sso` gère automatiquement :
80
+ - ✅ Connexion par email (mot de passe + OTP)
81
+ - ✅ Connexion par téléphone (SMS OTP)
82
+ - ✅ Connexion par code d'accès
83
+ - ✅ Inscription complète (email ou téléphone uniquement 🇸🇳)
84
+ - ✅ Récupération de mot de passe
85
+ - ✅ Grant access (inscription auto à une nouvelle app)
86
+ - ✅ 2FA (TOTP)
87
+ - ✅ Session persistée en localStorage
88
+ - ✅ Branding Ollaid SSO
89
+
90
+ ---
91
+
92
+ ## Props de `NativeSSOPage`
93
+
94
+ | Prop | Type | Requis | Description |
95
+ |------|------|--------|-------------|
96
+ | `saasApiUrl` | `string` | ✅ | URL du backend SaaS (ex: `https://mon-saas.com/api`) |
97
+ | `iamApiUrl` | `string` | ✅ | URL du backend IAM (ex: `https://identityam.ollaid.com/api`) |
98
+ | `onLoginSuccess` | `(token: string, user: UserInfos) => void` | ❌ | Callback après connexion réussie |
99
+ | `onLogout` | `() => void` | ❌ | Callback après déconnexion |
100
+ | `debug` | `boolean` | ❌ | Active les logs console |
101
+ | `title` | `string` | ❌ | Titre personnalisé (défaut: "Un compte, plusieurs accès") |
102
+ | `description` | `string` | ❌ | Description personnalisée |
103
+ | `logoUrl` | `string` | ❌ | URL du logo (remplace le slider) |
104
+ | `hideFooter` | `boolean` | ❌ | Masquer "Propulsé par iam.ollaid.com" |
105
+ | `onOnboardingComplete` | `(data: { image_url?: string; ccphone?: string; phone?: string }) => void` | ❌ | Callback après complétion de l'onboarding |
106
+
107
+ ---
108
+
109
+ ## Usage avancé (composants individuels)
110
+
111
+ Pour ceux qui veulent plus de contrôle :
112
+
113
+ ```tsx
114
+ import {
115
+ NativeSSOProvider,
116
+ LoginModal,
117
+ SignupModal,
118
+ useNativeAuth,
119
+ } from '@ollaid/native-sso';
120
+
121
+ function MyCustomAuth() {
122
+ const [showLogin, setShowLogin] = useState(false);
123
+
124
+ return (
125
+ <>
126
+ <button onClick={() => setShowLogin(true)}>Se connecter</button>
127
+
128
+ <LoginModal
129
+ open={showLogin}
130
+ onOpenChange={setShowLogin}
131
+ onSwitchToSignup={() => {}}
132
+ onLoginSuccess={(token, user) => console.log('OK', user)}
133
+ saasApiUrl="https://mon-saas.com/api"
134
+ iamApiUrl="https://identityam.ollaid.com/api"
135
+ />
136
+ </>
137
+ );
138
+ }
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Backend SaaS — Endpoints requis
144
+
145
+ Le backend SaaS (Laravel) doit exposer **4 endpoints**. Voici les spécifications exactes :
146
+
147
+ ### `GET /api/native/config`
148
+
149
+ Retourne un **token opaque chiffré** (`encrypted_credentials`) contenant les credentials IAM.
150
+ ⚠️ **Les clés `app_key` et `secret_key` ne sont JAMAIS exposées au frontend.**
151
+
152
+ **Headers requis :** aucun
153
+
154
+ **Réponse succès (200) :**
155
+ ```json
156
+ {
157
+ "success": true,
158
+ "app_key": "oiam_ak_xxxxxxxxxxxxxxxxxxxxxxxx",
159
+ "encrypted_credentials": "base64_encoded_iv_plus_ciphertext...",
160
+ "debug": true // optionnel — active le DebugPanel et les logs console côté frontend
161
+ }
162
+ ```
163
+
164
+ **Principe :**
165
+ Le SaaS chiffre `app_key + secret_key + timestamp` en AES-256-CBC en utilisant la `secret_key` elle-même comme clé de chiffrement (SHA-256 → 32 bytes).
166
+ Le frontend transporte le blob opaque + l'`app_key` en clair (non sensible) vers l'IAM.
167
+ L'IAM retrouve la `secret_key` via l'`app_key` dans sa table `applications`, puis déchiffre le blob.
168
+
169
+ **Implémentation Laravel :**
170
+ ```php
171
+ // routes/api.php
172
+ Route::get('/native/config', function () {
173
+ $appKey = config('services.iam.app_key');
174
+ $secretKey = config('services.iam.secret_key');
175
+
176
+ // Clé AES = SHA-256 de la secret_key (32 bytes binaires)
177
+ $aesKey = hash('sha256', $secretKey, true);
178
+ $iv = random_bytes(16);
179
+
180
+ $payload = json_encode([
181
+ 'app_key' => $appKey,
182
+ 'secret_key' => $secretKey,
183
+ 'ts' => time(),
184
+ ]);
185
+
186
+ $encrypted = openssl_encrypt($payload, 'aes-256-cbc', $aesKey, 0, $iv);
187
+
188
+ return response()->json([
189
+ 'success' => true,
190
+ 'app_key' => $appKey, // En clair (non sensible, sert d'identifiant)
191
+ 'encrypted_credentials' => base64_encode($iv . '::' . $encrypted),
192
+ 'debug' => (bool) config('services.iam.debug'),
193
+ ]);
194
+ });
195
+ ```
196
+
197
+ **Décryptage côté IAM :**
198
+ ```php
199
+ // Dans le endpoint /iam/native/encrypt
200
+ // 1. Retrouver la secret_key via l'app_key
201
+ $app = Application::where('app_key', $request->app_key)->first();
202
+ if (!$app) {
203
+ return response()->json(['success' => false, 'message' => 'Application inconnue'], 401);
204
+ }
205
+
206
+ // 2. Déchiffrer avec la secret_key de l'application
207
+ $aesKey = hash('sha256', $app->secret_key, true); // secret_key = colonne DB
208
+ $decoded = base64_decode($request->encrypted_credentials);
209
+ [$iv, $ciphertext] = explode('::', $decoded, 2);
210
+
211
+ $payload = json_decode(
212
+ openssl_decrypt($ciphertext, 'aes-256-cbc', $aesKey, 0, $iv),
213
+ true
214
+ );
215
+
216
+ // 3. Vérifier le timestamp (anti-replay, max 5 min)
217
+ if (!$payload || time() - $payload['ts'] > 300) {
218
+ return response()->json(['success' => false, 'message' => 'Credentials expirés ou invalides'], 401);
219
+ }
220
+
221
+ $appKey = $payload['app_key'];
222
+ $secretKey = $payload['secret_key'];
223
+ // ... valider et continuer le flux
224
+ ```
225
+
226
+ ---
227
+
228
+ ### `POST /api/native/exchange`
229
+
230
+ Échange le `callback_token` reçu de l'IAM contre un token Sanctum local.
231
+
232
+ **Headers requis :** `Content-Type: application/json`
233
+
234
+ **Body de la requête :**
235
+ ```json
236
+ {
237
+ "callback_token": "eyJhbGciOiJIUzI1NiIs..."
238
+ }
239
+ ```
240
+
241
+ **Logique backend :**
242
+ 1. Recevoir le `callback_token`
243
+ 2. Appeler l'IAM `POST /api/iam/auth/decrypt` avec `app_key`, `secret_key` et le `callback_token` (remplacer les espaces par `+`)
244
+ 3. L'IAM retourne les `user_infos` (9 champs standardisés)
245
+ 4. Créer ou mettre à jour l'utilisateur local
246
+ 5. Générer un token Sanctum
247
+ 6. Retourner le token + user
248
+
249
+ **Réponse succès (200) :**
250
+ ```json
251
+ {
252
+ "success": true,
253
+ "token": "1|abc123def456ghi789...",
254
+ "expires_at": "2026-04-23T03:12:32.000000Z",
255
+ "user": {
256
+ "id": 1,
257
+ "reference": "USR-XXXXXXXX",
258
+ "alias_reference": "ALI-XXXXXXXX",
259
+ "name": "John Doe",
260
+ "email": "john@example.com",
261
+ "phone": "+221771234567",
262
+ "ccphone": "+221",
263
+ "image_url": "https://identityam.ollaid.com/storage/avatars/xxx.jpg",
264
+ "email_verified": true,
265
+ "phone_verified": true
266
+ }
267
+ }
268
+ ```
269
+
270
+ **Implémentation Laravel :**
271
+ ```php
272
+ // routes/api.php
273
+ Route::post('/native/exchange', function (Request $request) {
274
+ $callbackToken = $request->input('callback_token');
275
+
276
+ // ⚠️ IMPORTANT : Remplacer les espaces par '+' avant décryptage
277
+ $callbackToken = str_replace(' ', '+', $callbackToken);
278
+
279
+ // Appel IAM pour décrypter
280
+ $response = Http::post(config('services.iam.base_url') . '/iam/auth/decrypt', [
281
+ 'app_key' => config('services.iam.app_key'),
282
+ 'secret_key' => config('services.iam.secret_key'),
283
+ 'callback_token' => $callbackToken,
284
+ ]);
285
+
286
+ if (!$response->successful() || !$response->json('success')) {
287
+ return response()->json([
288
+ 'success' => false,
289
+ 'error' => 'Token invalide ou expiré',
290
+ 'error_type' => 'invalid_token',
291
+ ], 401);
292
+ }
293
+
294
+ $userInfos = $response->json('user_infos');
295
+
296
+ // Créer ou mettre à jour l'utilisateur local
297
+ $user = User::updateOrCreate(
298
+ ['reference' => $userInfos['reference']],
299
+ [
300
+ 'name' => $userInfos['name'],
301
+ 'email' => $userInfos['email'],
302
+ 'phone' => $userInfos['phone'] ?? null,
303
+ 'ccphone' => $userInfos['ccphone'] ?? null,
304
+ 'image' => $userInfos['image_url'] ?? null,
305
+ 'alias_reference' => $userInfos['alias_reference'],
306
+ 'user_infos' => json_encode($userInfos),
307
+ 'password' => bcrypt(Str::random(32)), // Mot de passe aléatoire
308
+ ]
309
+ );
310
+
311
+ // Générer un token Sanctum
312
+ $token = $user->createToken('native-sso')->plainTextToken;
313
+
314
+ return response()->json([
315
+ 'success' => true,
316
+ 'token' => $token,
317
+ 'expires_at' => now()->addDays(30)->toISOString(),
318
+ 'user' => [
319
+ 'id' => $user->id,
320
+ 'reference' => $user->reference,
321
+ 'alias_reference' => $user->alias_reference,
322
+ 'name' => $user->name,
323
+ 'email' => $user->email,
324
+ 'phone' => $user->phone,
325
+ 'ccphone' => $user->ccphone,
326
+ 'image_url' => $user->image,
327
+ 'email_verified' => $userInfos['email_verification'] === 'verified',
328
+ 'phone_verified' => $userInfos['phone_verification'] === 'verified',
329
+ ],
330
+ ]);
331
+ });
332
+ ```
333
+
334
+ ---
335
+
336
+ ### `POST /api/native/check-token`
337
+
338
+ Vérifie la validité du token Sanctum et retourne les `user_infos` fraîches. Appelé périodiquement par le package (2 min après login, puis toutes les 5 min).
339
+
340
+ **Headers requis :** `Authorization: Bearer {token}`
341
+
342
+ **Réponse succès (200) :**
343
+ ```json
344
+ {
345
+ "success": true,
346
+ "valid": true,
347
+ "user_infos": {
348
+ "name": "John Doe",
349
+ "email": "john@example.com",
350
+ "ccphone": "+221",
351
+ "phone": "771234567",
352
+ "image_url": "https://...",
353
+ "town": "Dakar",
354
+ "country": "SN",
355
+ "auth_2fa": false
356
+ }
357
+ }
358
+ ```
359
+
360
+ **Si token invalide/expiré :** Sanctum retourne automatiquement 401.
361
+
362
+ **Implémentation Laravel :**
363
+ ```php
364
+ // routes/api.php
365
+ Route::post('/native/check-token', function (Request $request) {
366
+ $user = $request->user();
367
+
368
+ return response()->json([
369
+ 'success' => true,
370
+ 'valid' => true,
371
+ 'user_infos' => [
372
+ 'name' => $user->name,
373
+ 'email' => $user->email,
374
+ 'ccphone' => $user->ccphone,
375
+ 'phone' => $user->phone,
376
+ 'image_url' => $user->image,
377
+ 'town' => $user->town ?? null,
378
+ 'country' => $user->country ?? null,
379
+ 'auth_2fa' => (bool) ($user->auth_2fa ?? false),
380
+ ],
381
+ ]);
382
+ })->middleware('auth:sanctum');
383
+ ```
384
+
385
+ > **Comportement réseau :** Le package ne déconnecte l'utilisateur que si le backend retourne explicitement **401**. En cas d'erreur réseau, timeout ou serveur inaccessible, la session est conservée.
386
+
387
+ ---
388
+
389
+ ### `POST /api/native/logout` (single-session)
390
+
391
+ Invalide **uniquement** le token Sanctum courant (`currentAccessToken()->delete()`). Les autres sessions actives de l'utilisateur ne sont pas affectées.
392
+
393
+ **Headers requis :** `Authorization: Bearer {token}`
394
+
395
+ **Réponse succès (200) :**
396
+ ```json
397
+ {
398
+ "success": true,
399
+ "message": "Déconnexion réussie"
400
+ }
401
+ ```
402
+
403
+ **Implémentation Laravel :**
404
+ ```php
405
+ // routes/api.php
406
+ Route::post('/native/logout', function (Request $request) {
407
+ // Supprime UNIQUEMENT le token courant (pas les autres sessions)
408
+ $request->user()->currentAccessToken()->delete();
409
+
410
+ return response()->json([
411
+ 'success' => true,
412
+ 'message' => 'Déconnexion réussie',
413
+ ]);
414
+ })->middleware('auth:sanctum');
415
+ ```
416
+
417
+ ---
418
+
419
+ ## Controller Laravel Complet (copier-coller)
420
+
421
+ Voici un `NativeAuthController.php` complet regroupant les 4 endpoints. Copiez-le dans `app/Http/Controllers/Api/NativeAuthController.php` :
422
+
423
+ ```php
424
+ <?php
425
+
426
+ namespace App\Http\Controllers\Api;
427
+
428
+ use App\Http\Controllers\Controller;
429
+ use App\Models\User;
430
+ use Illuminate\Http\JsonResponse;
431
+ use Illuminate\Http\Request;
432
+ use Illuminate\Support\Facades\Http;
433
+ use Illuminate\Support\Facades\Log;
434
+ use Illuminate\Support\Str;
435
+
436
+ class NativeAuthController extends Controller
437
+ {
438
+ // ════════════════════════════════════════
439
+ // GET /api/native/config
440
+ // ════════════════════════════════════════
441
+
442
+ /**
443
+ * Retourne les credentials IAM chiffrés (opaque token).
444
+ * Le frontend ne voit JAMAIS app_key ni secret_key en clair.
445
+ */
446
+ public function config(): JsonResponse
447
+ {
448
+ $appKey = config('services.iam.app_key');
449
+ $secretKey = config('services.iam.secret_key');
450
+
451
+ if (empty($appKey) || empty($secretKey)) {
452
+ Log::error('[NativeSSO] Config manquante : IAM_APP_KEY ou IAM_SECRET_KEY');
453
+ return response()->json([
454
+ 'success' => false,
455
+ 'error' => 'Configuration SSO incomplète',
456
+ 'error_type' => 'config_missing',
457
+ ], 500);
458
+ }
459
+
460
+ // Clé AES = SHA-256 de la secret_key (32 bytes binaires)
461
+ $aesKey = hash('sha256', $secretKey, true);
462
+ $iv = random_bytes(16);
463
+ $payload = json_encode([
464
+ 'app_key' => $appKey,
465
+ 'secret_key' => $secretKey,
466
+ 'ts' => time(),
467
+ ]);
468
+
469
+ $encrypted = openssl_encrypt($payload, 'aes-256-cbc', $aesKey, 0, $iv);
470
+
471
+ return response()->json([
472
+ 'success' => true,
473
+ 'app_key' => $appKey, // En clair (non sensible, sert d'identifiant pour l'IAM)
474
+ 'encrypted_credentials' => base64_encode($iv . '::' . $encrypted),
475
+ 'debug' => (bool) config('services.iam.debug'), // Contrôlé par IAM_DEBUG dans .env
476
+ ]);
477
+ }
478
+
479
+ // ════════════════════════════════════════
480
+ // POST /api/native/exchange
481
+ // ════════════════════════════════════════
482
+
483
+ /**
484
+ * Échange le callback_token IAM contre un token Sanctum local.
485
+ *
486
+ * 1. Reçoit callback_token du frontend
487
+ * 2. Appelle IAM /iam/auth/decrypt pour décrypter
488
+ * 3. Crée ou met à jour l'utilisateur local
489
+ * 4. Génère un token Sanctum
490
+ */
491
+ public function exchange(Request $request): JsonResponse
492
+ {
493
+ $callbackToken = $request->input('callback_token');
494
+
495
+ if (empty($callbackToken)) {
496
+ return response()->json([
497
+ 'success' => false,
498
+ 'error' => 'callback_token est requis',
499
+ 'error_type' => 'missing_token',
500
+ ], 400);
501
+ }
502
+
503
+ // ⚠️ IMPORTANT : Remplacer les espaces par '+' (encodage URL)
504
+ $callbackToken = str_replace(' ', '+', $callbackToken);
505
+
506
+ try {
507
+ $response = Http::timeout(30)->post(
508
+ config('services.iam.base_url') . '/iam/auth/decrypt',
509
+ [
510
+ 'app_key' => config('services.iam.app_key'),
511
+ 'secret_key' => config('services.iam.secret_key'),
512
+ 'callback_token' => $callbackToken,
513
+ ]
514
+ );
515
+
516
+ if (!$response->successful() || !$response->json('success')) {
517
+ $error = $response->json();
518
+ Log::warning('[NativeSSO] Échec décryptage callback_token', [
519
+ 'status' => $response->status(),
520
+ 'error' => $error['message'] ?? 'Inconnu',
521
+ ]);
522
+
523
+ return response()->json([
524
+ 'success' => false,
525
+ 'error' => $error['message'] ?? 'Token invalide ou expiré',
526
+ 'error_type' => 'invalid_token',
527
+ ], 401);
528
+ }
529
+
530
+ $data = $response->json('data', $response->json());
531
+ $iamReference = $data['iam_reference'] ?? null;
532
+ $aliasReference = $data['alias_reference'] ?? null;
533
+ $userInfos = $data['user_infos'] ?? [];
534
+
535
+ if (!$iamReference || empty($userInfos)) {
536
+ return response()->json([
537
+ 'success' => false,
538
+ 'error' => 'Données utilisateur incomplètes depuis l\'IAM',
539
+ 'error_type' => 'decrypt_failed',
540
+ ], 422);
541
+ }
542
+
543
+ // Créer ou mettre à jour l'utilisateur local
544
+ $user = User::where('reference', $iamReference)->first();
545
+ if (!$user && $aliasReference) {
546
+ $user = User::where('alias_reference', $aliasReference)->first();
547
+ }
548
+
549
+ $userData = [
550
+ 'name' => $userInfos['name'] ?? null,
551
+ 'email' => $userInfos['email'] ?? null,
552
+ 'phone' => $userInfos['phone'] ?? null,
553
+ 'ccphone' => $userInfos['ccphone'] ?? null,
554
+ 'image' => $userInfos['image_url'] ?? null,
555
+ 'alias_reference' => $aliasReference,
556
+ 'user_infos' => json_encode($userInfos),
557
+ ];
558
+
559
+ if ($user) {
560
+ $user->update($userData);
561
+ } else {
562
+ $user = User::create(array_merge($userData, [
563
+ 'reference' => $iamReference,
564
+ 'password' => bcrypt(Str::random(32)),
565
+ ]));
566
+ }
567
+
568
+ // Générer un token Sanctum (expiration 30 jours)
569
+ $expiresAt = now()->addDays(30);
570
+ $token = $user->createToken('native-sso', ['*'], $expiresAt);
571
+
572
+ return response()->json([
573
+ 'success' => true,
574
+ 'token' => $token->plainTextToken,
575
+ 'expires_at' => $expiresAt->toIso8601String(),
576
+ 'user' => [
577
+ 'id' => $user->id,
578
+ 'reference' => $iamReference,
579
+ 'alias_reference' => $aliasReference,
580
+ 'name' => $userInfos['name'] ?? null,
581
+ 'email' => $userInfos['email'] ?? null,
582
+ 'phone' => isset($userInfos['phone'])
583
+ ? ($userInfos['ccphone'] ?? '') . $userInfos['phone']
584
+ : null,
585
+ 'ccphone' => $userInfos['ccphone'] ?? null,
586
+ 'image_url' => $userInfos['image_url'] ?? null,
587
+ 'email_verified' => ($userInfos['email_verification'] ?? '') === 'verified',
588
+ 'phone_verified' => ($userInfos['phone_verification'] ?? '') === 'verified',
589
+ ],
590
+ ]);
591
+
592
+ } catch (\Illuminate\Http\Client\ConnectionException $e) {
593
+ Log::error('[NativeSSO] Timeout connexion IAM', ['error' => $e->getMessage()]);
594
+ return response()->json([
595
+ 'success' => false,
596
+ 'error' => 'Impossible de contacter le serveur d\'authentification',
597
+ 'error_type' => 'exchange_error',
598
+ ], 503);
599
+
600
+ } catch (\Exception $e) {
601
+ Log::error('[NativeSSO] Erreur exchange', ['error' => $e->getMessage()]);
602
+ $details = [];
603
+ if (config('services.iam.debug')) {
604
+ $details = [
605
+ 'debug_error' => $e->getMessage(),
606
+ 'debug_file' => basename($e->getFile()) . ':' . $e->getLine(),
607
+ ];
608
+ }
609
+ return response()->json([
610
+ 'success' => false,
611
+ 'error' => 'Erreur lors de l\'authentification',
612
+ 'error_type' => 'exchange_error',
613
+ ...$details,
614
+ ], 500);
615
+ }
616
+ }
617
+
618
+ // ════════════════════════════════════════
619
+ // POST /api/native/check-token
620
+ // ════════════════════════════════════════
621
+
622
+ /**
623
+ * Vérifie la validité du token Sanctum et retourne les user_infos fraîches.
624
+ * Route protégée par auth:sanctum.
625
+ */
626
+ public function checkToken(Request $request): JsonResponse
627
+ {
628
+ $user = $request->user();
629
+
630
+ return response()->json([
631
+ 'success' => true,
632
+ 'valid' => true,
633
+ 'user_infos' => [
634
+ 'name' => $user->name,
635
+ 'email' => $user->email,
636
+ 'ccphone' => $user->ccphone,
637
+ 'phone' => $user->phone,
638
+ 'image_url' => $user->image,
639
+ 'town' => $user->town ?? null,
640
+ 'country' => $user->country ?? null,
641
+ 'auth_2fa' => (bool) ($user->auth_2fa ?? false),
642
+ ],
643
+ ]);
644
+ }
645
+
646
+ // ════════════════════════════════════════
647
+ // POST /api/native/logout
648
+ // ════════════════════════════════════════
649
+
650
+ /**
651
+ * Révoque UNIQUEMENT le token Sanctum courant (single-session).
652
+ * Route protégée par auth:sanctum.
653
+ */
654
+ public function logout(Request $request): JsonResponse
655
+ {
656
+ $request->user()->currentAccessToken()->delete();
657
+
658
+ return response()->json([
659
+ 'success' => true,
660
+ 'message' => 'Déconnexion réussie',
661
+ ]);
662
+ }
663
+ }
664
+ ```
665
+
666
+ ### Routes `routes/api.php`
667
+
668
+ ```php
669
+ use App\Http\Controllers\Api\NativeAuthController;
670
+
671
+ // Routes SSO Native (pas d'auth requise)
672
+ Route::get('/native/config', [NativeAuthController::class, 'config']);
673
+ Route::post('/native/exchange', [NativeAuthController::class, 'exchange']);
674
+
675
+ // Routes SSO Native (auth Sanctum requise)
676
+ Route::middleware('auth:sanctum')->group(function () {
677
+ Route::post('/native/check-token', [NativeAuthController::class, 'checkToken']);
678
+ Route::post('/native/logout', [NativeAuthController::class, 'logout']);
679
+ });
680
+ ```
681
+
682
+ ### Middleware `ForceJsonResponse` (recommandé)
683
+
684
+ Pour éviter que Laravel retourne du HTML au lieu de JSON en cas d'erreur :
685
+
686
+ ```php
687
+ // app/Http/Middleware/ForceJsonResponse.php
688
+ namespace App\Http\Middleware;
689
+
690
+ use Closure;
691
+ use Illuminate\Http\Request;
692
+
693
+ class ForceJsonResponse
694
+ {
695
+ public function handle(Request $request, Closure $next)
696
+ {
697
+ $request->headers->set('Accept', 'application/json');
698
+ return $next($request);
699
+ }
700
+ }
701
+ ```
702
+
703
+ Appliquez-le sur les routes API dans `bootstrap/app.php` (Laravel 11+) :
704
+ ```php
705
+ ->withMiddleware(function (Middleware $middleware) {
706
+ $middleware->api(prepend: [
707
+ \App\Http\Middleware\ForceJsonResponse::class,
708
+ ]);
709
+ })
710
+ ```
711
+
712
+ Ou dans `app/Http/Kernel.php` (Laravel 10 et avant) :
713
+ ```php
714
+ 'api' => [
715
+ \App\Http\Middleware\ForceJsonResponse::class,
716
+ // ... autres middlewares
717
+ ],
718
+ ```
719
+
720
+ ---
721
+
722
+ ## Réponses d'erreur
723
+
724
+ Tous les endpoints retournent le même format d'erreur :
725
+
726
+ ```json
727
+ {
728
+ "success": false,
729
+ "error": "Description lisible de l'erreur",
730
+ "error_type": "code_erreur"
731
+ }
732
+ ```
733
+
734
+ ### Codes d'erreur par endpoint
735
+
736
+ #### `/api/native/config`
737
+
738
+ | Code HTTP | `error_type` | Description |
739
+ |-----------|-------------|-------------|
740
+ | 500 | `config_missing` | `IAM_APP_KEY` ou `IAM_SECRET_KEY` non configuré dans le `.env` |
741
+
742
+ #### `/api/native/exchange`
743
+
744
+ | Code HTTP | `error_type` | Description |
745
+ |-----------|-------------|-------------|
746
+ | 400 | `missing_token` | `callback_token` absent du body |
747
+ | 401 | `invalid_token` | Token expiré, invalide ou déjà utilisé |
748
+ | 422 | `decrypt_failed` | Erreur lors de l'appel à l'IAM `/iam/auth/decrypt` |
749
+ | 500 | `exchange_error` | Erreur interne lors de la création du user/token |
750
+
751
+ #### `/api/native/logout`
752
+
753
+ | Code HTTP | `error_type` | Description |
754
+ |-----------|-------------|-------------|
755
+ | 401 | `unauthenticated` | Token Bearer manquant ou invalide |
756
+
757
+ ---
758
+
759
+ ## Configuration .env Laravel
760
+
761
+ Ajoutez ces variables dans le fichier `.env` du backend SaaS :
762
+
763
+ ```env
764
+ # Credentials IAM (récupérés depuis le dashboard iam.ollaid.com)
765
+ IAM_APP_KEY=oiam_ak_xxxxxxxxxxxxxxxxxxxxxxxx
766
+ IAM_SECRET_KEY=oiam_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
767
+ IAM_BASE_URL=https://identityam.ollaid.com/api
768
+
769
+ # Mode debug — contrôle le DebugPanel et les logs côté frontend
770
+ # true : active le DebugPanel + logs console + détails d'erreur dans les réponses JSON
771
+ # false : mode production (aucun détail d'erreur exposé, pas de DebugPanel)
772
+ # ⚠️ Si absent ou null → considéré comme false (mode production)
773
+ IAM_DEBUG=false
774
+ ```
775
+
776
+ > **Note :** Pas de clé partagée supplémentaire. Le chiffrement AES-256-CBC utilise directement un hash SHA-256 de la `secret_key` comme clé de chiffrement. L'IAM retrouve la `secret_key` via l'`app_key` dans sa table `applications`.
777
+
778
+ Et dans `config/services.php` :
779
+
780
+ ```php
781
+ 'iam' => [
782
+ 'app_key' => env('IAM_APP_KEY'),
783
+ 'secret_key' => env('IAM_SECRET_KEY'),
784
+ 'base_url' => env('IAM_BASE_URL', 'https://identityam.ollaid.com/api'),
785
+ 'debug' => env('IAM_DEBUG', false), // Active le debug côté frontend (false si absent)
786
+ ],
787
+ ```
788
+
789
+ ---
790
+
791
+ ## Migration Laravel
792
+
793
+ ### Colonnes requises sur la table `users`
794
+
795
+ Si votre table `users` n'a pas encore ces colonnes, ajoutez-les :
796
+
797
+ ```bash
798
+ php artisan make:migration add_iam_columns_to_users_table
799
+ ```
800
+
801
+ ```php
802
+ public function up(): void
803
+ {
804
+ Schema::table('users', function (Blueprint $table) {
805
+ $table->string('reference')->unique()->nullable()->after('id');
806
+ $table->string('alias_reference')->nullable()->after('reference');
807
+ $table->string('ccphone')->nullable();
808
+ $table->string('phone')->nullable();
809
+ $table->string('image')->nullable();
810
+ $table->json('user_infos')->nullable();
811
+ $table->string('phone_verification')->default('pending')->nullable();
812
+ $table->string('email_verification')->default('pending')->nullable();
813
+ });
814
+ }
815
+ ```
816
+
817
+ > **Note :** Le champ `reference` est l'identifiant unique IAM de l'utilisateur. Le champ `alias_reference` identifie l'utilisateur dans le contexte d'une application spécifique.
818
+
819
+ ---
820
+
821
+ ## Flux d'authentification
822
+
823
+ ```
824
+ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
825
+ │ @ollaid/native │ │ IAM API │ │ SaaS API │
826
+ │ -sso (Frontend) │ │ (Ollaid) │ │ (Laravel) │
827
+ └────────┬─────────┘ └──────┬───────┘ └──────┬───────┘
828
+ │ │ │
829
+ │ 1. GET /api/native/config │
830
+ │───────────────────────────────────────────►│
831
+ │◄──────────────── encrypted_credentials ────│
832
+ │ │ │
833
+ │ 2. POST /iam/native/encrypt │
834
+ │─────────────────────►│ │
835
+ │◄── encrypted_credentials │
836
+ │ │ │
837
+ │ 3. POST /iam/native/init │
838
+ │─────────────────────►│ │
839
+ │◄── status + session │ │
840
+ │ │ │
841
+ │ 4. POST /iam/native/validate │
842
+ │─────────────────────►│ │
843
+ │◄── callback_token │ │
844
+ │ │ │
845
+ │ 5. POST /api/native/exchange │
846
+ │───────────────────────────────────────────►│
847
+ │ │ decrypt via IAM │
848
+ │ │◄────────────────────│
849
+ │ │────────────────────►│
850
+ │◄──────────────── sanctum token + user ─────│
851
+ │ │ │
852
+ │ ✅ Connecté ! │ │
853
+ ```
854
+
855
+ ### Détail des étapes :
856
+
857
+ 1. **Config** — Le frontend récupère `encrypted_credentials` (blob opaque chiffré AES-256-CBC) depuis le backend SaaS. Les `app_key` et `secret_key` ne sont **jamais** exposées au frontend.
858
+ 2. **Encrypt** — Le frontend envoie le blob `encrypted_credentials` à l'IAM, qui le déchiffre côté serveur pour valider les credentials
859
+ 3. **Init** — L'IAM vérifie le compte et retourne le statut (`pending_password`, `pending_otp`, `needs_access`, etc.)
860
+ 4. **Validate** — Le frontend envoie le mot de passe/OTP, l'IAM retourne un `callback_token`
861
+ 5. **Exchange** — Le frontend envoie le `callback_token` au backend SaaS, qui le décrypte via l'IAM et crée une session Sanctum
862
+
863
+ > **Important :** Le package gère les étapes 1-5 automatiquement. Le backend SaaS doit implémenter **4 endpoints** (`config`, `exchange`, `check-token`, `logout`).
864
+
865
+ ---
866
+
867
+ ## APIs IAM Account (Server-to-Server)
868
+
869
+ > ⚠️ **ATTENTION** : Ces APIs utilisent la `secret_key` de votre application IAM. Elles doivent être appelées **exclusivement depuis votre backend** (server-to-server). Ne jamais exposer la `secret_key` côté frontend.
870
+
871
+ Le package expose un service `iamAccountService` pour interagir avec les APIs IAM de gestion de compte :
872
+
873
+ ```ts
874
+ import { iamAccountService } from '@ollaid/native-sso';
875
+ ```
876
+
877
+ ---
878
+
879
+ ### `POST /api/iam/link-phone`
880
+
881
+ Associe un numéro de téléphone à un compte utilisateur existant dans l'IAM.
882
+
883
+ **Cas d'usage :** Un utilisateur inscrit par email souhaite ajouter son numéro de téléphone, ou le SaaS collecte le numéro après inscription.
884
+
885
+ **Paramètres requis :**
886
+
887
+ ```json
888
+ {
889
+ "app_key": "oiam_ak_xxxxxxxxxxxxxxxxxxxxxxxx",
890
+ "secret_key": "oiam_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
891
+ "iam_reference": "USR-XXXXXXXX",
892
+ "ccphone": "+221",
893
+ "phone": "771234567"
894
+ }
895
+ ```
896
+
897
+ | Champ | Type | Description |
898
+ |-------|------|-------------|
899
+ | `app_key` | `string` | Clé publique de l'application IAM |
900
+ | `secret_key` | `string` | Clé secrète de l'application IAM |
901
+ | `iam_reference` | `string` | Référence unique de l'utilisateur dans l'IAM (`USR-XXXXXXXX`) |
902
+ | `ccphone` | `string` | Indicatif téléphonique (ex: `+221`) |
903
+ | `phone` | `string` | Numéro de téléphone sans indicatif (ex: `771234567`) |
904
+
905
+ **Réponse succès (200) :**
906
+
907
+ ```json
908
+ {
909
+ "success": true,
910
+ "message": "Numéro de téléphone lié avec succès",
911
+ "user_reference": "USR-XXXXXXXX",
912
+ "user_infos": {
913
+ "name": "John Doe",
914
+ "email": "john@example.com",
915
+ "ccphone": "+221",
916
+ "phone": "771234567",
917
+ "address": null,
918
+ "town": "Dakar",
919
+ "country": "SN",
920
+ "image_url": "https://identityam.ollaid.com/storage/avatars/xxx.jpg",
921
+ "auth_2fa": false
922
+ }
923
+ }
924
+ ```
925
+
926
+ **Codes d'erreur :**
927
+
928
+ | Code HTTP | `error_type` | Description |
929
+ |-----------|-------------|-------------|
930
+ | 401 | `invalid_credentials` | `app_key` ou `secret_key` invalide |
931
+ | 404 | `user_not_found` | `iam_reference` introuvable |
932
+ | 409 | `phone_already_linked` | Ce numéro est déjà associé à un autre compte |
933
+ | 422 | `validation_error` | Champs manquants ou format invalide |
934
+
935
+ **Exemple Node.js (backend) :**
936
+
937
+ ```js
938
+ import { iamAccountService } from '@ollaid/native-sso';
939
+
940
+ const result = await iamAccountService.linkPhone({
941
+ app_key: process.env.IAM_APP_KEY,
942
+ secret_key: process.env.IAM_SECRET_KEY,
943
+ iam_reference: 'USR-XXXXXXXX',
944
+ ccphone: '+221',
945
+ phone: '771234567',
946
+ });
947
+
948
+ if (result.success) {
949
+ // Mettre à jour l'utilisateur local avec result.user_infos
950
+ console.log('Téléphone lié :', result.user_infos.phone);
951
+ }
952
+ ```
953
+
954
+ ---
955
+
956
+ ### `POST /api/iam/link-email`
957
+
958
+ Associe une adresse email à un compte utilisateur existant dans l'IAM.
959
+
960
+ **Cas d'usage :** Un utilisateur inscrit par téléphone (phone-only) souhaite ajouter son email.
961
+
962
+ **Paramètres requis :**
963
+
964
+ ```json
965
+ {
966
+ "app_key": "oiam_ak_xxxxxxxxxxxxxxxxxxxxxxxx",
967
+ "secret_key": "oiam_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
968
+ "iam_reference": "USR-XXXXXXXX",
969
+ "email": "john@example.com"
970
+ }
971
+ ```
972
+
973
+ | Champ | Type | Description |
974
+ |-------|------|-------------|
975
+ | `app_key` | `string` | Clé publique de l'application IAM |
976
+ | `secret_key` | `string` | Clé secrète de l'application IAM |
977
+ | `iam_reference` | `string` | Référence unique de l'utilisateur dans l'IAM |
978
+ | `email` | `string` | Adresse email à associer |
979
+
980
+ **Réponse succès (200) :**
981
+
982
+ ```json
983
+ {
984
+ "success": true,
985
+ "message": "Adresse email liée avec succès",
986
+ "user_reference": "USR-XXXXXXXX",
987
+ "user_infos": {
988
+ "name": "John Doe",
989
+ "email": "john@example.com",
990
+ "ccphone": "+221",
991
+ "phone": "771234567",
992
+ "address": null,
993
+ "town": "Dakar",
994
+ "country": "SN",
995
+ "image_url": "https://identityam.ollaid.com/storage/avatars/xxx.jpg",
996
+ "auth_2fa": false
997
+ }
998
+ }
999
+ ```
1000
+
1001
+ **Codes d'erreur :**
1002
+
1003
+ | Code HTTP | `error_type` | Description |
1004
+ |-----------|-------------|-------------|
1005
+ | 401 | `invalid_credentials` | `app_key` ou `secret_key` invalide |
1006
+ | 404 | `user_not_found` | `iam_reference` introuvable |
1007
+ | 409 | `email_already_linked` | Cet email est déjà associé à un autre compte |
1008
+ | 422 | `validation_error` | Champs manquants ou format invalide |
1009
+
1010
+ **Exemple Node.js (backend) :**
1011
+
1012
+ ```js
1013
+ import { iamAccountService } from '@ollaid/native-sso';
1014
+
1015
+ const result = await iamAccountService.linkEmail({
1016
+ app_key: process.env.IAM_APP_KEY,
1017
+ secret_key: process.env.IAM_SECRET_KEY,
1018
+ iam_reference: 'USR-XXXXXXXX',
1019
+ email: 'john@example.com',
1020
+ });
1021
+
1022
+ if (result.success) {
1023
+ console.log('Email lié :', result.user_infos.email);
1024
+ }
1025
+ ```
1026
+
1027
+ ---
1028
+
1029
+ ### `POST /api/iam/refresh-user-info` (Single)
1030
+
1031
+ Récupère les informations à jour d'un utilisateur depuis l'IAM.
1032
+
1033
+ **Cas d'usage :** Synchroniser les données utilisateur (nom, avatar, etc.) après une modification côté IAM, ou récupérer les infos complètes pour un utilisateur inscrit par téléphone.
1034
+
1035
+ **Paramètres requis :**
1036
+
1037
+ ```json
1038
+ {
1039
+ "secret_key": "oiam_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
1040
+ "alias_reference": "ALI-XXXXXXXX"
1041
+ }
1042
+ ```
1043
+
1044
+ | Champ | Type | Description |
1045
+ |-------|------|-------------|
1046
+ | `secret_key` | `string` | Clé secrète de l'application IAM |
1047
+ | `alias_reference` | `string` | Référence de l'alias utilisateur dans le contexte de votre app (`ALI-XXXXXXXX`) |
1048
+
1049
+ **Réponse succès (200) :**
1050
+
1051
+ ```json
1052
+ {
1053
+ "success": true,
1054
+ "message": "Informations utilisateur récupérées",
1055
+ "user_reference": "USR-XXXXXXXX",
1056
+ "alias_reference": "ALI-XXXXXXXX",
1057
+ "login_type": "email",
1058
+ "user_infos": {
1059
+ "name": "John Doe",
1060
+ "email": "john@example.com",
1061
+ "ccphone": "+221",
1062
+ "phone": "771234567",
1063
+ "address": null,
1064
+ "town": "Dakar",
1065
+ "country": "SN",
1066
+ "image_url": "https://identityam.ollaid.com/storage/avatars/xxx.jpg",
1067
+ "auth_2fa": false
1068
+ }
1069
+ }
1070
+ ```
1071
+
1072
+ **Codes d'erreur :**
1073
+
1074
+ | Code HTTP | `error_type` | Description |
1075
+ |-----------|-------------|-------------|
1076
+ | 401 | `invalid_credentials` | `secret_key` invalide |
1077
+ | 404 | `alias_not_found` | `alias_reference` introuvable |
1078
+
1079
+ **Exemple Node.js :**
1080
+
1081
+ ```js
1082
+ import { iamAccountService } from '@ollaid/native-sso';
1083
+
1084
+ const result = await iamAccountService.refreshUserInfo({
1085
+ secret_key: process.env.IAM_SECRET_KEY,
1086
+ alias_reference: 'ALI-XXXXXXXX',
1087
+ });
1088
+
1089
+ if (result.success) {
1090
+ // Mettre à jour le profil local
1091
+ await updateLocalUser(result.alias_reference, result.user_infos);
1092
+ }
1093
+ ```
1094
+
1095
+ ---
1096
+
1097
+ ### `POST /api/iam/refresh-user-info` (Bulk)
1098
+
1099
+ Synchronise les informations de **plusieurs utilisateurs** en un seul appel (maximum **100 références** par requête).
1100
+
1101
+ **Cas d'usage :** Synchronisation batch quotidienne, ou mise à jour groupée après un import.
1102
+
1103
+ **Paramètres requis :**
1104
+
1105
+ ```json
1106
+ {
1107
+ "secret_key": "oiam_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
1108
+ "alias_references": [
1109
+ "ALI-AAAAAAAA",
1110
+ "ALI-BBBBBBBB",
1111
+ "ALI-CCCCCCCC"
1112
+ ]
1113
+ }
1114
+ ```
1115
+
1116
+ | Champ | Type | Description |
1117
+ |-------|------|-------------|
1118
+ | `secret_key` | `string` | Clé secrète de l'application IAM |
1119
+ | `alias_references` | `string[]` | Liste des alias à synchroniser (max 100) |
1120
+
1121
+ **Réponse succès (200) :**
1122
+
1123
+ ```json
1124
+ {
1125
+ "success": true,
1126
+ "message": "Synchronisation terminée",
1127
+ "total_requested": 3,
1128
+ "total_found": 2,
1129
+ "total_errors": 1,
1130
+ "data": [
1131
+ {
1132
+ "user_reference": "USR-AAAAAAAA",
1133
+ "alias_reference": "ALI-AAAAAAAA",
1134
+ "login_type": "email",
1135
+ "user_infos": { "name": "Alice", "email": "alice@example.com", "..." : "..." }
1136
+ },
1137
+ {
1138
+ "user_reference": "USR-BBBBBBBB",
1139
+ "alias_reference": "ALI-BBBBBBBB",
1140
+ "login_type": "phone",
1141
+ "user_infos": { "name": "Bob", "phone": "771234567", "..." : "..." }
1142
+ }
1143
+ ],
1144
+ "errors": [
1145
+ {
1146
+ "alias_reference": "ALI-CCCCCCCC",
1147
+ "error": "Alias introuvable"
1148
+ }
1149
+ ]
1150
+ }
1151
+ ```
1152
+
1153
+ **Codes d'erreur :**
1154
+
1155
+ | Code HTTP | `error_type` | Description |
1156
+ |-----------|-------------|-------------|
1157
+ | 401 | `invalid_credentials` | `secret_key` invalide |
1158
+ | 422 | `too_many_references` | Plus de 100 références envoyées |
1159
+ | 422 | `validation_error` | `alias_references` manquant ou invalide |
1160
+
1161
+ **Exemple Node.js :**
1162
+
1163
+ ```js
1164
+ import { iamAccountService } from '@ollaid/native-sso';
1165
+
1166
+ const result = await iamAccountService.refreshUserInfoBulk({
1167
+ secret_key: process.env.IAM_SECRET_KEY,
1168
+ alias_references: ['ALI-AAAAAAAA', 'ALI-BBBBBBBB', 'ALI-CCCCCCCC'],
1169
+ });
1170
+
1171
+ console.log(`Synchronisés: ${result.total_found}/${result.total_requested}`);
1172
+
1173
+ // Traiter les succès
1174
+ for (const item of result.data ?? []) {
1175
+ await updateLocalUser(item.alias_reference, item.user_infos);
1176
+ }
1177
+
1178
+ // Logger les erreurs
1179
+ for (const err of result.errors ?? []) {
1180
+ console.warn(`Erreur pour ${err.alias_reference}: ${err.error}`);
1181
+ }
1182
+ ```
1183
+
1184
+ ---
1185
+
1186
+ ## Avatar par application
1187
+
1188
+ Le système d'avatar permet à chaque utilisateur d'avoir une **image de profil différente par application**. Un champ `avatar` est ajouté sur la table `app_accesses`.
1189
+
1190
+ ### Cascade de résolution `image_url`
1191
+
1192
+ Dans toutes les réponses `user_infos`, le champ `image_url` est résolu selon cette priorité :
1193
+
1194
+ 1. **`app_access.avatar`** — Avatar spécifique à l'application (si défini et valide)
1195
+ 2. **`user.image`** — Image globale du profil IAM (si définie et valide)
1196
+ 3. **`no-image.png`** — Fallback par défaut
1197
+
1198
+ ### Migration requise
1199
+
1200
+ ```sql
1201
+ ALTER TABLE app_accesses ADD COLUMN avatar VARCHAR(255) NULL AFTER status;
1202
+ ```
1203
+
1204
+ Ou via Laravel :
1205
+
1206
+ ```php
1207
+ Schema::table('app_accesses', function (Blueprint $table) {
1208
+ $table->string('avatar')->nullable()->after('status');
1209
+ });
1210
+ ```
1211
+
1212
+ ### Pré-remplissage
1213
+
1214
+ Lorsqu'un `AppAccess` est créé (inscription, grant-access), le champ `avatar` est automatiquement pré-rempli avec l'image globale du user (`user.image`).
1215
+
1216
+ ### `POST /api/iam/update-avatar`
1217
+
1218
+ Met à jour l'avatar d'un utilisateur **pour une application spécifique**.
1219
+
1220
+ > ⚠️ **Server-to-Server uniquement** — Requiert `app_key` + `secret_key`.
1221
+
1222
+ **Paramètres requis :**
1223
+
1224
+ ```json
1225
+ {
1226
+ "app_key": "oiam_ak_xxxxxxxxxxxxxxxxxxxxxxxx",
1227
+ "secret_key": "oiam_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
1228
+ "alias_reference": "ALI-XXXXXXXX",
1229
+ "avatar_url": "https://example.com/avatars/user123.jpg"
1230
+ }
1231
+ ```
1232
+
1233
+ | Champ | Type | Description |
1234
+ |-------|------|-------------|
1235
+ | `app_key` | `string` | Clé publique de l'application IAM |
1236
+ | `secret_key` | `string` | Clé secrète de l'application IAM |
1237
+ | `alias_reference` | `string` | Référence de l'alias utilisateur |
1238
+ | `avatar_url` | `string` | URL de la nouvelle image avatar |
1239
+
1240
+ **Réponse succès (200) :**
1241
+
1242
+ ```json
1243
+ {
1244
+ "success": true,
1245
+ "message": "Avatar mis à jour",
1246
+ "user_reference": "USR-XXXXXXXX",
1247
+ "alias_reference": "ALI-XXXXXXXX",
1248
+ "user_infos": {
1249
+ "name": "John Doe",
1250
+ "email": "john@example.com",
1251
+ "ccphone": "+221",
1252
+ "phone": "771234567",
1253
+ "address": null,
1254
+ "town": "Dakar",
1255
+ "country": "SN",
1256
+ "image_url": "https://example.com/avatars/user123.jpg",
1257
+ "auth_2fa": false
1258
+ }
1259
+ }
1260
+ ```
1261
+
1262
+ **Codes d'erreur :**
1263
+
1264
+ | Code HTTP | Description |
1265
+ |-----------|-------------|
1266
+ | 403 | `app_key` ou `secret_key` invalide |
1267
+ | 404 | `alias_reference` introuvable ou pas d'accès à cette app |
1268
+ | 422 | Champs manquants ou format invalide |
1269
+
1270
+ **Exemple Node.js (backend) :**
1271
+
1272
+ ```js
1273
+ import { iamAccountService } from '@ollaid/native-sso';
1274
+
1275
+ const result = await iamAccountService.updateAvatar({
1276
+ app_key: process.env.IAM_APP_KEY,
1277
+ secret_key: process.env.IAM_SECRET_KEY,
1278
+ alias_reference: 'ALI-XXXXXXXX',
1279
+ avatar_url: 'https://example.com/avatars/user123.jpg',
1280
+ });
1281
+
1282
+ if (result.success) {
1283
+ console.log('Avatar mis à jour :', result.user_infos.image_url);
1284
+ }
1285
+ ```
1286
+
1287
+ ### `POST /api/iam/reset-avatar`
1288
+
1289
+ Réinitialise l'avatar d'un utilisateur pour une application, le remettant à `null`. La cascade retombera sur `user.image` ou `no-image.png`.
1290
+
1291
+ > ⚠️ **Server-to-Server uniquement** — Requiert `app_key` + `secret_key`.
1292
+
1293
+ **Paramètres requis :**
1294
+
1295
+ ```json
1296
+ {
1297
+ "app_key": "oiam_ak_xxxxxxxxxxxxxxxxxxxxxxxx",
1298
+ "secret_key": "oiam_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
1299
+ "alias_reference": "ALI-XXXXXXXX"
1300
+ }
1301
+ ```
1302
+
1303
+ | Champ | Type | Description |
1304
+ |-------|------|-------------|
1305
+ | `app_key` | `string` | Clé publique de l'application IAM |
1306
+ | `secret_key` | `string` | Clé secrète de l'application IAM |
1307
+ | `alias_reference` | `string` | Référence de l'alias utilisateur |
1308
+
1309
+ **Réponse succès (200) :**
1310
+
1311
+ ```json
1312
+ {
1313
+ "success": true,
1314
+ "message": "Avatar réinitialisé",
1315
+ "user_reference": "USR-XXXXXXXX",
1316
+ "alias_reference": "ALI-XXXXXXXX",
1317
+ "user_infos": {
1318
+ "name": "John Doe",
1319
+ "email": "john@example.com",
1320
+ "image_url": "https://iam.example.com/storage/users/image.jpg"
1321
+ }
1322
+ }
1323
+ ```
1324
+
1325
+ **Codes d'erreur :**
1326
+
1327
+ | Code HTTP | Description |
1328
+ |-----------|-------------|
1329
+ | 403 | `app_key` ou `secret_key` invalide |
1330
+ | 404 | `alias_reference` introuvable ou pas d'accès à cette app |
1331
+ | 422 | Champs manquants |
1332
+
1333
+ **Exemple Node.js (backend) :**
1334
+
1335
+ ```js
1336
+ import { iamAccountService } from '@ollaid/native-sso';
1337
+
1338
+ const result = await iamAccountService.resetAvatar({
1339
+ app_key: process.env.IAM_APP_KEY,
1340
+ secret_key: process.env.IAM_SECRET_KEY,
1341
+ alias_reference: 'ALI-XXXXXXXX',
1342
+ });
1343
+
1344
+ if (result.success) {
1345
+ console.log('Avatar réinitialisé, image actuelle :', result.user_infos.image_url);
1346
+ }
1347
+ ```
1348
+
1349
+ ---
1350
+
1351
+ ### Format `user_infos` (9 champs standardisés)
1352
+
1353
+ Toutes les APIs IAM retournent le même objet `user_infos` avec exactement **9 champs** :
1354
+
1355
+ | Champ | Type | Description |
1356
+ |-------|------|-------------|
1357
+ | `name` | `string` | Nom complet de l'utilisateur |
1358
+ | `email` | `string \| null` | Adresse email (null si compte phone-only) |
1359
+ | `ccphone` | `string \| null` | Indicatif téléphonique (ex: `+221`) |
1360
+ | `phone` | `string \| null` | Numéro de téléphone sans indicatif |
1361
+ | `address` | `string \| null` | Adresse postale |
1362
+ | `town` | `string \| null` | Ville |
1363
+ | `country` | `string \| null` | Code pays ISO (ex: `SN`, `FR`) |
1364
+ | `image_url` | `string \| null` | URL de l'avatar (résolu via la cascade app_access → user → fallback) |
1365
+ | `auth_2fa` | `boolean` | Indique si la 2FA est activée |
1366
+
1367
+ > **Note :** Le champ `image_url` utilise désormais la cascade d'avatar. Il reflète l'avatar spécifique à l'application si défini, sinon l'image globale du user, sinon le fallback `no-image.png`.
1368
+
1369
+ ---
1370
+
1371
+ ## Session & localStorage
1372
+
1373
+ Le package utilise **4 clés** dans `localStorage` pour persister la session :
1374
+
1375
+ | Clé | Contenu | Source | Valeurs possibles |
1376
+ |-----|---------|--------|-------------------|
1377
+ | `token` | Token Sanctum (bearer) | Réponse de `/api/native/exchange` | Chaîne `"1\|abc123..."` |
1378
+ | `auth_token` | Copie du token (compatibilité) | Même source que `token` | Idem |
1379
+ | `user` | Objet `user_infos` sérialisé en JSON | Réponse de `/api/native/exchange` ou mise à jour via health check | `{"name":"...","email":"...",...}` |
1380
+ | `account_type` | Type de compte | Déterminé lors du `exchange` selon le mode d'inscription | `"user"` (défaut) ou `"client"` (inscription phone-only) |
1381
+
1382
+ > **Note :** `account_type` est stocké **séparément** de l'objet `user` — il n'est pas inclus dans le JSON de la clé `user`.
1383
+
1384
+ ### Nettoyage
1385
+
1386
+ Lors du `logout()`, les 4 clés sont supprimées via `clearAuthToken()`.
1387
+
1388
+ ### Accès programmatique
1389
+
1390
+ ```ts
1391
+ import { getAuthToken, getAuthUser, getAccountType } from '@ollaid/native-sso';
1392
+
1393
+ const token = getAuthToken(); // string | null
1394
+ const user = getAuthUser<UserInfos>(); // UserInfos | null
1395
+ const type = getAccountType(); // string | null
1396
+ ```
1397
+
1398
+ ---
1399
+
1400
+ ## OnboardingModal
1401
+
1402
+ Modal post-connexion qui invite l'utilisateur à compléter les informations manquantes de son profil.
1403
+
1404
+ ### Props
1405
+
1406
+ | Prop | Type | Requis | Description |
1407
+ |------|------|--------|-------------|
1408
+ | `open` | `boolean` | ✅ | Contrôle l'ouverture de la modal |
1409
+ | `onOpenChange` | `(open: boolean) => void` | ✅ | Callback changement d'état |
1410
+ | `user` | `NativeUser` | ✅ | Objet utilisateur courant (pour détecter les champs manquants) |
1411
+ | `onComplete` | `(data) => void` | ✅ | Callback avec les données saisies |
1412
+ | `onSkip` | `() => void` | ✅ | Callback si l'utilisateur passe l'étape |
1413
+
1414
+ ### Champs affichés
1415
+
1416
+ La modal affiche **uniquement** les champs manquants :
1417
+ - **Photo de profil** — si `user.image_url` est vide (max 2 Mo, JPG/PNG)
1418
+ - **Numéro de téléphone** — si `user.phone` est vide
1419
+ - **Adresse email** — si `user.email` est vide (**optionnel**, ne bloque pas la validation)
1420
+
1421
+ ### Callback `onComplete`
1422
+
1423
+ ```ts
1424
+ onComplete: (data: {
1425
+ image_url?: string; // Base64 de la photo (si ajoutée)
1426
+ ccphone?: string; // Indicatif (si téléphone ajouté)
1427
+ phone?: string; // Numéro (si téléphone ajouté)
1428
+ email?: string; // Email (si renseigné — optionnel)
1429
+ }) => void;
1430
+ ```
1431
+
1432
+ ### Condition de soumission
1433
+
1434
+ L'utilisateur **doit** :
1435
+ 1. Cocher la case de confirmation
1436
+ 2. Fournir une photo (si manquante)
1437
+ 3. Fournir un téléphone valide (si manquant)
1438
+
1439
+ L'email est **toujours optionnel** — il n'est pas requis pour valider.
1440
+
1441
+ ### Exemple
1442
+
1443
+ ```tsx
1444
+ import { OnboardingModal } from '@ollaid/native-sso';
1445
+
1446
+ <OnboardingModal
1447
+ open={showOnboarding}
1448
+ onOpenChange={setShowOnboarding}
1449
+ user={currentUser}
1450
+ onComplete={async (data) => {
1451
+ // Envoyer les données au backend pour mise à jour
1452
+ await api.updateProfile(data);
1453
+ setShowOnboarding(false);
1454
+ }}
1455
+ onSkip={() => setShowOnboarding(false)}
1456
+ />
1457
+ ```
1458
+
1459
+ ---
1460
+
1461
+ ## useTokenHealthCheck
1462
+
1463
+ Hook qui vérifie périodiquement la validité du token Sanctum via `POST /api/native/check-token`.
1464
+
1465
+ ### Timing
1466
+
1467
+ | Événement | Délai |
1468
+ |-----------|-------|
1469
+ | Premier check après login | **2 minutes** |
1470
+ | Checks suivants | **toutes les 5 minutes** |
1471
+ | Requêtes par heure | ~12 |
1472
+
1473
+ ### Comportement réseau
1474
+
1475
+ | Situation | Action |
1476
+ |-----------|--------|
1477
+ | ✅ 200 OK | Met à jour `user_infos` en localStorage |
1478
+ | ❌ 401 Unauthorized | Déconnecte l'utilisateur (token expiré/révoqué) |
1479
+ | ⚠️ Erreur réseau / timeout / 500 | **Aucune action** — la session est conservée |
1480
+ | 📴 Appareil hors ligne | Le check échoue silencieusement, session conservée |
1481
+
1482
+ > **Philosophie** : Ne déconnecter que sur un rejet explicite du serveur (401). Jamais sur un problème réseau.
1483
+
1484
+ ### Callbacks
1485
+
1486
+ ```ts
1487
+ useTokenHealthCheck({
1488
+ enabled: true, // Activer/désactiver
1489
+ onUserUpdate: (user) => {
1490
+ // Appelé quand user_infos sont rafraîchies
1491
+ },
1492
+ onSessionExpired: () => {
1493
+ // Appelé sur 401 — rediriger vers login
1494
+ navigate('/auth/sso');
1495
+ },
1496
+ });
1497
+ ```
1498
+
1499
+ ---
1500
+
1501
+ ## Sécurité
1502
+
1503
+ ### Credentials protégés par chiffrement — Opaque Token
1504
+
1505
+ L'architecture utilise un **token opaque** (`encrypted_credentials`) pour protéger les clés API :
1506
+
1507
+ 1. Le backend SaaS chiffre `app_key + secret_key + timestamp` en AES-256-CBC avec `SHA-256(secret_key)` comme clé
1508
+ 2. Le frontend reçoit un **blob opaque** + l'`app_key` en clair (non sensible)
1509
+ 3. Le frontend transporte le blob + `app_key` vers l'IAM
1510
+ 4. L'IAM retrouve la `secret_key` via l'`app_key` dans sa table `applications`, déchiffre le blob et valide
1511
+
1512
+ **Les `secret_key` ne sont JAMAIS visibles** dans les DevTools (onglet Network). Seuls le blob chiffré et l'`app_key` apparaissent. Le blob est inutilisable sans la `secret_key` correspondante.
1513
+
1514
+ ### APIs S2S — Backend uniquement
1515
+
1516
+ Le service `iamAccountService` (link-phone, link-email, refresh-user-info, update-avatar, reset-avatar) utilise la `secret_key` pour des opérations de gestion de compte.
1517
+
1518
+ > ⚠️ **Ces APIs ne doivent JAMAIS être appelées depuis le frontend.** Utilisez-les uniquement depuis votre backend Node.js, PHP (Laravel), ou tout autre serveur.
1519
+
1520
+ ```ts
1521
+ // ✅ CORRECT — dans un contrôleur Node.js / route API backend
1522
+ import { iamAccountService } from '@ollaid/native-sso';
1523
+ await iamAccountService.linkPhone({ ... });
1524
+
1525
+ // ❌ INTERDIT — dans un composant React ou du code frontend
1526
+ // La secret_key serait visible dans le navigateur
1527
+ ```
1528
+
1529
+ ### Champs de debug en production
1530
+
1531
+ Les types `NativeExchangeResponse` incluent `debug_error` et `debug_file` optionnels. Ces champs sont utiles en développement mais **ne doivent jamais être retournés en production** par le backend SaaS, car ils exposent des chemins serveur et des messages d'erreur internes.
1532
+
1533
+ ```php
1534
+ // ✅ Laravel — ne retourner les champs debug que si IAM_DEBUG=true
1535
+ if (config('services.iam.debug')) {
1536
+ $response['debug_error'] = $exception->getMessage();
1537
+ $response['debug_file'] = $exception->getFile();
1538
+ }
1539
+ // ⚠️ Si IAM_DEBUG est absent/null → considéré comme false (production)
1540
+ ```
1541
+
1542
+ ### Bonnes pratiques
1543
+
1544
+ - ✅ Le token Sanctum est stocké en `localStorage` (standard pour SPA)
1545
+ - ✅ Les credentials IAM sont gardés **en mémoire uniquement** (jamais persistés)
1546
+ - ✅ Le device ID est un identifiant aléatoire (pas de fingerprinting invasif)
1547
+ - ✅ Le logout est single-session (ne déconnecte pas les autres appareils)
1548
+ - ✅ Le health check ne déconnecte que sur 401 explicite
1549
+ - ✅ Les photos sont validées à 2 Mo max côté client
1550
+ - ✅ Les lectures `localStorage` sont protégées par try/catch
1551
+
1552
+ ---
1553
+
1554
+ ## Exports
1555
+
1556
+ ### Composants
1557
+ - `NativeSSOPage` — Page complète autonome (recommandé)
1558
+ - `LoginModal` — Modal de connexion
1559
+ - `SignupModal` — Modal d'inscription
1560
+ - `PasswordRecoveryModal` — Modal de récupération
1561
+ - `OTPInput` — Input OTP 6 chiffres
1562
+ - `PhoneInput` — Input téléphone avec indicatif
1563
+ - `AppsLogoSlider` — Slider logos applications
1564
+
1565
+ ### Hooks
1566
+ - `useNativeAuth` — Hook principal d'authentification
1567
+ - `useMobilePassword` — Hook récupération mot de passe
1568
+ - `useMobileRegistration` — Hook inscription
1569
+ - `useTokenHealthCheck` — Hook vérification périodique du token
1570
+
1571
+ ### Provider
1572
+ - `NativeSSOProvider` — Provider React pour configuration centralisée
1573
+ - `useNativeSSOConfig` — Hook pour accéder à la config
1574
+
1575
+ ### Services
1576
+ - `nativeAuthService` — Service d'authentification
1577
+ - `mobilePasswordService` — Service mot de passe
1578
+ - `setNativeAuthConfig` — Configuration manuelle des URLs
1579
+ - `iamAccountService` — Service APIs IAM Account (link-phone, link-email, refresh-user-info, update-avatar, reset-avatar)
1580
+ - `getAuthToken` — Récupérer le token depuis localStorage
1581
+ - `getAuthUser` — Récupérer l'utilisateur depuis localStorage
1582
+ - `getAccountType` — Récupérer le type de compte depuis localStorage
1583
+
1584
+ ### Types
1585
+ - `UserInfos`, `NativeAuthState`, `NativeAuthStatus`, `NativeCredentials`, etc.
1586
+ - `LinkPhoneRequest`, `LinkPhoneResponse` — Types pour l'API link-phone
1587
+ - `LinkEmailRequest`, `LinkEmailResponse` — Types pour l'API link-email
1588
+ - `RefreshUserInfoSingleRequest`, `RefreshUserInfoSingleResponse` — Types pour refresh single
1589
+ - `RefreshUserInfoBulkRequest`, `RefreshUserInfoBulkResponse` — Types pour refresh bulk
1590
+ - `UpdateAvatarRequest`, `UpdateAvatarResponse` — Types pour l'API update-avatar
1591
+ - `ResetAvatarRequest`, `ResetAvatarResponse` — Types pour l'API reset-avatar
1592
+
1593
+ ---
1594
+
1595
+ ## Publication & Installation npm
1596
+
1597
+ ### Prérequis
1598
+
1599
+ - **Node.js** ≥ 18 et **npm** ≥ 9
1600
+ - Un compte [npmjs.com](https://www.npmjs.com/) connecté : `npm login`
1601
+ - Être membre de l'organisation **@ollaid** sur npm
1602
+
1603
+ ### Build
1604
+
1605
+ ```bash
1606
+ cd packages/ollaid-native-sso
1607
+ npm run build
1608
+ ```
1609
+
1610
+ ### Versioning
1611
+
1612
+ Avant chaque publication, incrémenter la version selon [semver](https://semver.org/) :
1613
+
1614
+ ```bash
1615
+ # Correction de bug (1.0.0 → 1.0.1)
1616
+ npm version patch
1617
+
1618
+ # Nouvelle fonctionnalité rétro-compatible (1.0.1 → 1.1.0)
1619
+ npm version minor
1620
+
1621
+ # Breaking change (1.1.0 → 2.0.0)
1622
+ npm version major
1623
+ ```
1624
+
1625
+ ### Publication
1626
+
1627
+ Les scoped packages (`@ollaid/*`) sont **privés par défaut** sur npm.
1628
+ Le flag `--access public` est **obligatoire** pour publier en accès libre :
1629
+
1630
+ ```bash
1631
+ npm publish --access public
1632
+ ```
1633
+
1634
+ > **Astuce** : Pour ne plus avoir à passer le flag à chaque fois, ajoutez dans le `package.json` du package :
1635
+ > ```json
1636
+ > "publishConfig": {
1637
+ > "access": "public"
1638
+ > }
1639
+ > ```
1640
+
1641
+ ### Installation côté client
1642
+
1643
+ Dans le projet qui consomme le package :
1644
+
1645
+ ```bash
1646
+ npm install @ollaid/native-sso
1647
+ ```
1648
+
1649
+ ### Vérification
1650
+
1651
+ ```bash
1652
+ # Voir les infos du package publié
1653
+ npm info @ollaid/native-sso
1654
+
1655
+ # Voir toutes les versions publiées
1656
+ npm info @ollaid/native-sso versions
1657
+ ```
1658
+
1659
+ ### Mise à jour
1660
+
1661
+ ```bash
1662
+ # Mettre à jour vers la dernière version
1663
+ npm update @ollaid/native-sso
1664
+
1665
+ # Ou forcer une version spécifique
1666
+ npm install @ollaid/native-sso@1.0.0
1667
+ ```
1668
+
1669
+ ### Workflow complet de publication
1670
+
1671
+ ```bash
1672
+ cd packages/ollaid-native-sso
1673
+ npm run build # 1. Build
1674
+ npm version patch # 2. Incrémenter la version
1675
+ npm publish --access public # 3. Publier sur npm
1676
+ ```
1677
+
1678
+ ---
1679
+
1680
+ ## Licence
1681
+
1682
+ Propriétaire — Ollaid © 2026