nuxt-feathers-zod 0.2.3 → 0.2.5
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 +179 -293
- package/dist/module.json +1 -1
- package/dist/module.mjs +8 -2
- package/dist/runtime/composables/pinia.d.ts +1 -1
- package/dist/runtime/composables/pinia.js +1 -1
- package/dist/runtime/composables/useAuth.d.ts +13 -0
- package/dist/runtime/composables/useAuth.js +99 -0
- package/dist/runtime/options/index.d.ts +11 -0
- package/dist/runtime/options/index.js +18 -1
- package/dist/runtime/options/keycloak.d.ts +41 -0
- package/dist/runtime/options/keycloak.js +19 -0
- package/dist/runtime/plugins/feathers-auth.js +1 -1
- package/dist/runtime/plugins/keycloak-sso.d.ts +2 -0
- package/dist/runtime/plugins/keycloak-sso.js +147 -0
- package/dist/runtime/stores/auth.js +1 -1
- package/dist/runtime/templates/server/index.js +8 -0
- package/dist/runtime/templates/server/keycloak.d.ts +2 -0
- package/dist/runtime/templates/server/keycloak.js +96 -0
- package/dist/runtime/templates/server/plugin.js +20 -0
- package/package.json +7 -3
- package/src/cli/index.ts +2 -2
package/README.md
CHANGED
|
@@ -1,357 +1,193 @@
|
|
|
1
|
-
# nuxt-feathers-zod
|
|
1
|
+
# nuxt-feathers-zod
|
|
2
|
+
[guide](https://vevedh.github.io/nuxt-feathers-zod/)
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
### Guide officiel d’initialisation – Nuxt 4 (Bun, Feathers v5, Zod)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Ce guide décrit **la seule procédure valide et supportée** pour initialiser correctement **nuxt-feathers-zod** dans un projet **Nuxt 4**, en se basant **strictement sur le comportement réel du module**.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Il évite volontairement toute “magie implicite” ou création manuelle non supportée.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
---
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
- REST transport (Koa or Express)
|
|
13
|
-
- Optional Socket.io transport (WebSocket)
|
|
14
|
-
- **Zod schemas** for data + query validation (server-side)
|
|
15
|
-
- Typed Feathers client injected into Nuxt (`$api`)
|
|
16
|
-
- Optional Pinia integration via `feathers-pinia`
|
|
17
|
-
- CLI to generate services and middleware
|
|
18
|
-
- **Swagger UI (legacy)** support via `feathers-swagger`
|
|
12
|
+
## 1. Objectif du module
|
|
19
13
|
|
|
20
|
-
|
|
14
|
+
`nuxt-feathers-zod` permet d’embarquer un **backend FeathersJS v5 (Dove)** directement dans **Nitro**, avec :
|
|
21
15
|
|
|
22
|
-
|
|
16
|
+
* API REST (`/feathers/*`)
|
|
17
|
+
* WebSocket (Socket.IO)
|
|
18
|
+
* Validation **Zod-first**
|
|
19
|
+
* Authentification **Local + JWT**
|
|
20
|
+
* Adapters (MongoDB, Memory, etc.)
|
|
21
|
+
* Swagger legacy (optionnel)
|
|
22
|
+
* Composables client (`useService`, `useAuth`, stores Pinia)
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
- Nuxt **4**
|
|
26
|
-
- FeathersJS **v5 (Dove)**
|
|
24
|
+
👉 Il **n’y a pas de backend séparé** : Feathers est monté **dans Nuxt**.
|
|
27
25
|
|
|
28
26
|
---
|
|
29
27
|
|
|
30
|
-
##
|
|
28
|
+
## 2. Pré-requis
|
|
31
29
|
|
|
32
|
-
|
|
30
|
+
* **Bun** (recommandé et supporté)
|
|
31
|
+
* **Node.js ≥ 18**
|
|
32
|
+
* **Nuxt 4**
|
|
33
|
+
* MongoDB (optionnel mais recommandé)
|
|
33
34
|
|
|
34
35
|
---
|
|
35
36
|
|
|
36
|
-
##
|
|
37
|
+
## 3. Création du projet Nuxt 4
|
|
37
38
|
|
|
38
39
|
```bash
|
|
39
|
-
bunx nuxi@latest init my-
|
|
40
|
-
cd my-
|
|
40
|
+
bunx nuxi@latest init my-site
|
|
41
|
+
cd my-site
|
|
41
42
|
bun install
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Run once to verify:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
43
|
bun run dev
|
|
48
44
|
```
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
## 2️⃣ Install nuxt-feathers-zod
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
bun add nuxt-feathers-zod feathers-pinia
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
(Optional – for Swagger legacy support)
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
bun add feathers-swagger
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
---
|
|
65
|
-
|
|
66
|
-
## 3️⃣ Enable the module
|
|
67
|
-
|
|
68
|
-
```ts
|
|
69
|
-
// nuxt.config.ts
|
|
70
|
-
export default defineNuxtConfig({
|
|
71
|
-
modules: ['nuxt-feathers-zod'],
|
|
72
|
-
})
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
At this point, Nuxt will start with an **embedded FeathersJS server inside Nitro**.
|
|
76
|
-
|
|
77
|
-
---
|
|
78
|
-
|
|
79
|
-
Parfait 👍
|
|
80
|
-
Voici un **bloc prêt à intégrer dans ton `README.md`**, qui :
|
|
81
|
-
|
|
82
|
-
1. **explique précisément le rôle de `playground/server/feathers/dummy.ts`**
|
|
83
|
-
2. **explique pourquoi le premier service à créer doit être `users`**
|
|
84
|
-
3. **donne le raisonnement Feathers + Auth + Swagger + DX**
|
|
85
|
-
|
|
86
|
-
Tu peux l’insérer tel quel (par exemple après la section _Minimal Feathers configuration_).
|
|
46
|
+
➡️ Vérifie que Nuxt démarre **avant toute intégration Feathers**.
|
|
87
47
|
|
|
88
48
|
---
|
|
89
49
|
|
|
90
|
-
##
|
|
91
|
-
|
|
92
|
-
Le fichier :
|
|
93
|
-
|
|
94
|
-
```ts
|
|
95
|
-
playground / server / feathers / dummy.ts
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
est **volontairement simple** et joue un rôle fondamental dans le module `nuxt-feathers-zod`.
|
|
99
|
-
|
|
100
|
-
### 🎯 Objectif principal
|
|
101
|
-
|
|
102
|
-
👉 **Fournir un backend Feathers minimal mais fonctionnel**, utilisable immédiatement dans le playground Nuxt, sans dépendre d’un vrai projet métier.
|
|
103
|
-
|
|
104
|
-
Il permet de :
|
|
50
|
+
## 4. Installation des dépendances
|
|
105
51
|
|
|
106
|
-
|
|
107
|
-
- valider le **routing REST** (`/feathers/*`)
|
|
108
|
-
- tester **Swagger UI**
|
|
109
|
-
- servir de **service de test (smoke test)** pour le module
|
|
110
|
-
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
### 🧩 Que fait concrètement `dummy.ts` ?
|
|
114
|
-
|
|
115
|
-
Typiquement, ce fichier :
|
|
116
|
-
|
|
117
|
-
- crée une application Feathers
|
|
118
|
-
- enregistre **au moins un service**
|
|
119
|
-
- expose une route REST simple (ex: `/dummy`)
|
|
120
|
-
- ne dépend pas d’authentification ni de base de données
|
|
121
|
-
|
|
122
|
-
Exemple conceptuel :
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
export function dummy(app: Application) {
|
|
126
|
-
app.use('dummy', {
|
|
127
|
-
async find() {
|
|
128
|
-
return [{ ok: true }]
|
|
129
|
-
},
|
|
130
|
-
})
|
|
131
|
-
}
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Ce service permet de tester immédiatement :
|
|
52
|
+
### 4.1 Module principal
|
|
135
53
|
|
|
136
54
|
```bash
|
|
137
|
-
|
|
55
|
+
bun add nuxt-feathers-zod feathers-pinia
|
|
138
56
|
```
|
|
139
57
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
### 🧠 Pourquoi ce fichier est important ?
|
|
143
|
-
|
|
144
|
-
Sans `dummy.ts` :
|
|
145
|
-
|
|
146
|
-
- Feathers démarre **sans aucun service**
|
|
147
|
-
- Swagger UI peut être vide ou trompeur
|
|
148
|
-
- les tests d’intégration sont plus complexes
|
|
149
|
-
- le playground ne démontre rien visuellement
|
|
58
|
+
### 4.2 (Optionnel) Swagger legacy
|
|
150
59
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
---
|
|
154
|
-
|
|
155
|
-
## 👤 Pourquoi créer immédiatement un service `users` (et pas un autre) ?
|
|
156
|
-
|
|
157
|
-
Dans un projet Feathers **avec authentification**, le **premier vrai service à créer doit toujours être `users`**.
|
|
158
|
-
|
|
159
|
-
Ce n’est **pas un choix arbitraire**.
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
### 🔐 Raison n°1 — Feathers Auth repose sur `users`
|
|
164
|
-
|
|
165
|
-
Le système d’authentification Feathers (v5 Dove) repose sur :
|
|
166
|
-
|
|
167
|
-
- le service **`authentication`**
|
|
168
|
-
- une **stratégie locale ou JWT**
|
|
169
|
-
- un **service utilisateur** (`users`)
|
|
170
|
-
|
|
171
|
-
➡️ **Sans service `users`**, ces endpoints ne peuvent pas fonctionner :
|
|
172
|
-
|
|
173
|
-
```http
|
|
174
|
-
POST /feathers/authentication
|
|
175
|
-
POST /feathers/users
|
|
60
|
+
```bash
|
|
61
|
+
bun add feathers-swagger swagger-ui-dist
|
|
176
62
|
```
|
|
177
63
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
### 🔑 Raison n°2 — `users` est la source de vérité sécurité
|
|
181
|
-
|
|
182
|
-
Le service `users` contient :
|
|
183
|
-
|
|
184
|
-
- les identifiants (email, username, etc.)
|
|
185
|
-
- le mot de passe hashé
|
|
186
|
-
- les rôles (`admin`, `editor`, etc.)
|
|
187
|
-
- les règles d’accès (RBAC)
|
|
188
|
-
|
|
189
|
-
C’est sur `users` que reposent ensuite :
|
|
190
|
-
|
|
191
|
-
- `authenticate('jwt')`
|
|
192
|
-
- `requireRole(...)`
|
|
193
|
-
- les hooks de sécurité
|
|
194
|
-
- Swagger `securitySchemes`
|
|
195
|
-
|
|
196
|
-
---
|
|
197
|
-
|
|
198
|
-
### 🧠 Raison n°3 — Swagger dépend fortement de `users`
|
|
199
|
-
|
|
200
|
-
Si tu actives Swagger (`feathers.swagger = true`) :
|
|
201
|
-
|
|
202
|
-
- le **flux d’authentification JWT** est documenté
|
|
203
|
-
- le bouton **Authorize** apparaît
|
|
204
|
-
- les routes sécurisées sont visibles
|
|
205
|
-
|
|
206
|
-
👉 **Sans `users`, Swagger est incomplet ou trompeur**.
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
|
|
210
|
-
### 🧪 Raison n°4 — DX et tests automatisés
|
|
211
|
-
|
|
212
|
-
Dans `nuxt-feathers-zod`, le service `users` permet :
|
|
213
|
-
|
|
214
|
-
- de tester immédiatement :
|
|
215
|
-
|
|
216
|
-
```bash
|
|
217
|
-
curl -X POST /feathers/users
|
|
218
|
-
curl -X POST /feathers/authentication
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
- de valider :
|
|
222
|
-
- JWT
|
|
223
|
-
- guards
|
|
224
|
-
- hooks
|
|
225
|
-
- swagger.json
|
|
226
|
-
|
|
227
|
-
- d’avoir un **socle stable pour tous les autres services**
|
|
228
|
-
|
|
229
|
-
---
|
|
230
|
-
|
|
231
|
-
## ✅ Ordre recommandé des services dans un projet Nuxt + Feathers
|
|
232
|
-
|
|
233
|
-
Toujours respecter cet ordre :
|
|
234
|
-
|
|
235
|
-
1. **`dummy`** (playground / smoke test)
|
|
236
|
-
2. **`users`** ← indispensable
|
|
237
|
-
3. `authentication` (auto-géré)
|
|
238
|
-
4. services métier (`articles`, `projects`, `tickets`, etc.)
|
|
64
|
+
> ⚠️ `swagger-ui-dist` est requis si `feathers.swagger = true`
|
|
239
65
|
|
|
240
66
|
---
|
|
241
67
|
|
|
242
|
-
##
|
|
243
|
-
|
|
244
|
-
- `dummy.ts` :
|
|
245
|
-
- service minimal
|
|
246
|
-
- sert de **preuve de fonctionnement**
|
|
247
|
-
- facilite debug, Swagger et onboarding
|
|
248
|
-
|
|
249
|
-
- `users` :
|
|
250
|
-
- **service fondamental**
|
|
251
|
-
- requis pour l’authentification
|
|
252
|
-
- point central de la sécurité
|
|
253
|
-
- base de Swagger, JWT et RBAC
|
|
254
|
-
|
|
255
|
-
👉 **Sans `users`, un projet Feathers n’est pas réellement exploitable.**
|
|
68
|
+
## 5. Configuration **obligatoire** (`nuxt.config.ts`)
|
|
256
69
|
|
|
257
|
-
|
|
70
|
+
> ⚠️ **Cette configuration est critique**.
|
|
71
|
+
> Une mauvaise initialisation provoque des erreurs bloquantes au démarrage.
|
|
258
72
|
|
|
259
73
|
```ts
|
|
260
|
-
// nuxt.config.ts
|
|
261
74
|
export default defineNuxtConfig({
|
|
262
75
|
modules: ['nuxt-feathers-zod'],
|
|
263
76
|
|
|
264
77
|
feathers: {
|
|
78
|
+
/**
|
|
79
|
+
* RÈGLE D’OR :
|
|
80
|
+
* Le module scanne UNIQUEMENT ces dossiers
|
|
81
|
+
*/
|
|
265
82
|
servicesDirs: ['services'],
|
|
266
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Transports
|
|
86
|
+
*/
|
|
267
87
|
transports: {
|
|
268
88
|
rest: {
|
|
269
89
|
path: '/feathers',
|
|
270
90
|
framework: 'koa',
|
|
271
91
|
},
|
|
92
|
+
websocket: true,
|
|
272
93
|
},
|
|
273
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Base de données (MongoDB recommandé)
|
|
97
|
+
*/
|
|
274
98
|
database: {
|
|
275
99
|
mongo: {
|
|
276
|
-
url: 'mongodb://127.0.0.1:27017/
|
|
100
|
+
url: 'mongodb://127.0.0.1:27017/my-site',
|
|
277
101
|
},
|
|
278
102
|
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Authentification
|
|
106
|
+
*/
|
|
107
|
+
auth: true,
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Swagger legacy (optionnel)
|
|
111
|
+
*/
|
|
112
|
+
swagger: false,
|
|
279
113
|
},
|
|
280
114
|
})
|
|
281
115
|
```
|
|
282
116
|
|
|
283
|
-
|
|
117
|
+
---
|
|
284
118
|
|
|
285
|
-
|
|
286
|
-
bun run dev
|
|
287
|
-
```
|
|
119
|
+
## 6. RÈGLE FONDAMENTALE – À NE JAMAIS VIOLER
|
|
288
120
|
|
|
289
|
-
|
|
121
|
+
> ❌ **Ne jamais créer un service manuellement**
|
|
122
|
+
> ✅ **Toujours utiliser la CLI officielle**
|
|
290
123
|
|
|
291
|
-
|
|
292
|
-
curl http://localhost:3000/feathers
|
|
293
|
-
```
|
|
124
|
+
Cette règle est **imposée par le code interne du module**.
|
|
294
125
|
|
|
295
126
|
---
|
|
296
127
|
|
|
297
|
-
##
|
|
128
|
+
## 7. Création du premier service : `users` (OBLIGATOIRE)
|
|
298
129
|
|
|
299
130
|
```bash
|
|
300
|
-
bunx nuxt-feathers-zod add service
|
|
131
|
+
bunx nuxt-feathers-zod add service users \
|
|
301
132
|
--adapter mongodb \
|
|
302
133
|
--auth \
|
|
303
134
|
--idField _id \
|
|
304
135
|
--docs
|
|
305
136
|
```
|
|
306
137
|
|
|
307
|
-
|
|
138
|
+
### Structure générée (attendue)
|
|
308
139
|
|
|
309
140
|
```
|
|
310
|
-
services/
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
141
|
+
services/users/
|
|
142
|
+
users.ts
|
|
143
|
+
users.class.ts
|
|
144
|
+
users.schema.ts
|
|
145
|
+
users.shared.ts
|
|
315
146
|
```
|
|
316
147
|
|
|
317
|
-
|
|
148
|
+
### Pourquoi `users` est obligatoire ?
|
|
318
149
|
|
|
319
|
-
|
|
150
|
+
* Le module **résout l’authentification** via une `entityClass` nommée **`User`**
|
|
151
|
+
* Cette classe est **recherchée dynamiquement** dans les exports scannés
|
|
152
|
+
* Sans ce service :
|
|
320
153
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
docs: {
|
|
327
|
-
description: 'Articles API',
|
|
328
|
-
idType: 'string',
|
|
329
|
-
},
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
```
|
|
154
|
+
* `Services typeExports []`
|
|
155
|
+
* `Entity class User not found in services imports`
|
|
156
|
+
* **Boot impossible**
|
|
157
|
+
|
|
158
|
+
👉 Le service `users` est la **clé de voûte** de tout projet `nuxt-feathers-zod`.
|
|
333
159
|
|
|
334
160
|
---
|
|
335
161
|
|
|
336
|
-
##
|
|
162
|
+
## 8. Création d’un service métier (exemple : `articles`)
|
|
337
163
|
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
|
|
164
|
+
```bash
|
|
165
|
+
bunx nuxt-feathers-zod add service articles \
|
|
166
|
+
--adapter mongodb \
|
|
167
|
+
--auth \
|
|
168
|
+
--idField _id \
|
|
169
|
+
--docs
|
|
170
|
+
```
|
|
341
171
|
|
|
342
|
-
|
|
343
|
-
</script>
|
|
172
|
+
Structure :
|
|
344
173
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
174
|
+
```
|
|
175
|
+
services/articles/
|
|
176
|
+
articles.ts
|
|
177
|
+
articles.class.ts
|
|
178
|
+
articles.schema.ts
|
|
179
|
+
articles.shared.ts
|
|
348
180
|
```
|
|
349
181
|
|
|
350
182
|
---
|
|
351
183
|
|
|
352
|
-
##
|
|
184
|
+
## 9. Démarrage et tests REST
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
bun run dev
|
|
188
|
+
```
|
|
353
189
|
|
|
354
|
-
|
|
190
|
+
### 9.1 Création d’un utilisateur
|
|
355
191
|
|
|
356
192
|
```bash
|
|
357
193
|
curl -X POST http://localhost:3000/feathers/users \
|
|
@@ -359,7 +195,7 @@ curl -X POST http://localhost:3000/feathers/users \
|
|
|
359
195
|
-d '{"userId":"demo","password":"demo123"}'
|
|
360
196
|
```
|
|
361
197
|
|
|
362
|
-
|
|
198
|
+
### 9.2 Authentification
|
|
363
199
|
|
|
364
200
|
```bash
|
|
365
201
|
curl -X POST http://localhost:3000/feathers/authentication \
|
|
@@ -367,7 +203,7 @@ curl -X POST http://localhost:3000/feathers/authentication \
|
|
|
367
203
|
-d '{"strategy":"local","userId":"demo","password":"demo123"}'
|
|
368
204
|
```
|
|
369
205
|
|
|
370
|
-
|
|
206
|
+
### 9.3 Accès à un service protégé
|
|
371
207
|
|
|
372
208
|
```bash
|
|
373
209
|
curl http://localhost:3000/feathers/articles \
|
|
@@ -376,51 +212,101 @@ curl http://localhost:3000/feathers/articles \
|
|
|
376
212
|
|
|
377
213
|
---
|
|
378
214
|
|
|
379
|
-
##
|
|
215
|
+
## 10. Swagger legacy (optionnel)
|
|
380
216
|
|
|
381
|
-
|
|
217
|
+
### 10.1 Activer Swagger
|
|
382
218
|
|
|
383
219
|
```ts
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
swagger: true,
|
|
388
|
-
},
|
|
389
|
-
})
|
|
220
|
+
feathers: {
|
|
221
|
+
swagger: true,
|
|
222
|
+
}
|
|
390
223
|
```
|
|
391
224
|
|
|
392
|
-
|
|
225
|
+
### 10.2 Accès
|
|
393
226
|
|
|
394
|
-
|
|
395
|
-
|
|
227
|
+
* UI :
|
|
228
|
+
`http://localhost:3000/feathers/docs/`
|
|
229
|
+
* Spec :
|
|
230
|
+
`http://localhost:3000/feathers/swagger.json`
|
|
231
|
+
|
|
232
|
+
### ⚠️ Important
|
|
233
|
+
|
|
234
|
+
Dans l’UI Swagger, la spec doit être définie manuellement à :
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
../swagger.json
|
|
238
|
+
```
|
|
396
239
|
|
|
397
|
-
|
|
240
|
+
(C’est un comportement connu et assumé du module.)
|
|
398
241
|
|
|
399
242
|
---
|
|
400
243
|
|
|
401
|
-
##
|
|
244
|
+
## 11. Plugins serveur Feathers (seed, hooks globaux)
|
|
402
245
|
|
|
246
|
+
### Exemple : `server/feathers/dummy.ts`
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { defineFeathersServerPlugin } from 'nuxt-feathers-zod/server'
|
|
250
|
+
|
|
251
|
+
export default defineFeathersServerPlugin((app) => {
|
|
252
|
+
app.hooks({
|
|
253
|
+
setup: [
|
|
254
|
+
async (context, next) => {
|
|
255
|
+
await context.app.service('users').create({
|
|
256
|
+
userId: 'admin',
|
|
257
|
+
password: 'admin123',
|
|
258
|
+
})
|
|
259
|
+
await next()
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
})
|
|
263
|
+
})
|
|
403
264
|
```
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
├─ server/
|
|
407
|
-
├─ services/
|
|
408
|
-
│ └─ articles/
|
|
409
|
-
├─ nuxt.config.ts
|
|
410
|
-
└─ package.json
|
|
411
|
-
```
|
|
265
|
+
|
|
266
|
+
➡️ Ces fichiers sont des **plugins Feathers**, pas des services.
|
|
412
267
|
|
|
413
268
|
---
|
|
414
269
|
|
|
415
|
-
##
|
|
270
|
+
## 12. Erreurs courantes et causes réelles
|
|
271
|
+
|
|
272
|
+
### ❌ `Services typeExports []`
|
|
273
|
+
|
|
274
|
+
Causes :
|
|
275
|
+
|
|
276
|
+
* `servicesDirs` incorrect
|
|
277
|
+
* services créés manuellement
|
|
278
|
+
* fichiers mal nommés (`users.ts` manquant)
|
|
279
|
+
|
|
280
|
+
### ❌ `Entity class User not found in services imports`
|
|
281
|
+
|
|
282
|
+
Cause exacte :
|
|
283
|
+
|
|
284
|
+
* le service `users` n’existe pas ou n’a pas été généré via la CLI
|
|
285
|
+
|
|
286
|
+
✅ Solution universelle :
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
bunx nuxt-feathers-zod add service users
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
416
293
|
|
|
417
|
-
|
|
294
|
+
## 13. Bonnes pratiques figées
|
|
418
295
|
|
|
419
|
-
|
|
420
|
-
|
|
296
|
+
* ✅ **Toujours** générer les services avec la CLI
|
|
297
|
+
* ✅ `services/<name>/<name>.ts` obligatoire
|
|
298
|
+
* ✅ Zod-first (`*.schema.ts`)
|
|
299
|
+
* ❌ Pas de création manuelle
|
|
300
|
+
* ❌ Pas de renommage arbitraire de `User`
|
|
301
|
+
* ❌ Pas de déplacement hors `servicesDirs`
|
|
421
302
|
|
|
422
303
|
---
|
|
423
304
|
|
|
424
|
-
##
|
|
305
|
+
## 14. Résumé express (checklist)
|
|
425
306
|
|
|
426
|
-
|
|
307
|
+
1. `bunx nuxi init`
|
|
308
|
+
2. `bun add nuxt-feathers-zod feathers-pinia`
|
|
309
|
+
3. `servicesDirs: ['services']`
|
|
310
|
+
4. `bunx nuxt-feathers-zod add service users`
|
|
311
|
+
5. `bunx nuxt-feathers-zod add service <business>`
|
|
312
|
+
6. `bun run dev`
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -75,7 +75,7 @@ const module$1 = defineNuxtModule({
|
|
|
75
75
|
const require = createRequire(import.meta.url);
|
|
76
76
|
try {
|
|
77
77
|
require.resolve("feathers-swagger", { paths: [nuxt.options.rootDir] });
|
|
78
|
-
} catch {
|
|
78
|
+
} catch (e) {
|
|
79
79
|
consola.warn(
|
|
80
80
|
"feathers.swagger is enabled but 'feathers-swagger' could not be resolved from this Nuxt project. Install it in your app (root) dependencies: bun add feathers-swagger swagger-ui-dist"
|
|
81
81
|
);
|
|
@@ -111,6 +111,12 @@ const module$1 = defineNuxtModule({
|
|
|
111
111
|
addImports({ from: resolver.resolve("./runtime/stores/auth"), name: "useAuthStore" });
|
|
112
112
|
addPlugin({ order: 1, src: resolver.resolve("./runtime/plugins/feathers-auth") });
|
|
113
113
|
}
|
|
114
|
+
if (resolvedOptions.keycloak) {
|
|
115
|
+
addPlugin({ order: 1, src: resolver.resolve("./runtime/plugins/keycloak-sso"), mode: "client" });
|
|
116
|
+
} else if (resolvedOptions.auth) {
|
|
117
|
+
addImports({ from: resolver.resolve("./runtime/stores/auth"), name: "useAuthStore" });
|
|
118
|
+
addPlugin({ order: 1, src: resolver.resolve("./runtime/plugins/feathers-auth") });
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
let clientPluginDst;
|
|
116
122
|
for (const clientTemplate of getClientTemplates(resolvedOptions, resolver)) {
|
|
@@ -118,7 +124,7 @@ const module$1 = defineNuxtModule({
|
|
|
118
124
|
if (clientTemplate.filename?.endsWith("client/plugin.ts") || clientTemplate.filename?.endsWith("client/plugin"))
|
|
119
125
|
clientPluginDst = tpl.dst;
|
|
120
126
|
}
|
|
121
|
-
addPlugin({ order: 0, src: clientPluginDst ?? resolver.resolve(resolvedOptions.templateDir, "client/plugin.ts")
|
|
127
|
+
addPlugin({ order: 0, src: clientPluginDst ?? resolver.resolve(resolvedOptions.templateDir, "client/plugin.ts") });
|
|
122
128
|
}
|
|
123
129
|
nuxt.hook("mcp:setup", ({ mcp }) => {
|
|
124
130
|
mcp.tool("get-feathers-config", "Get the Feathers config", {}, async () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createPiniaClient, defineGetters, defineSetters, defineValues, useAuth, useBackup, useDataStore, useInstanceDefaults, useServiceInstance, } from 'feathers-pinia';
|
|
1
|
+
export { createPiniaClient, defineGetters, defineSetters, defineValues, useAuth as usePiniaAuth, useBackup, useDataStore, useInstanceDefaults, useServiceInstance, } from 'feathers-pinia';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type AuthProvider = 'keycloak' | 'local' | 'none';
|
|
2
|
+
export declare function useAuth(): {
|
|
3
|
+
provider: import("vue").ComputedRef<AuthProvider>;
|
|
4
|
+
ready: import("vue").Ref<boolean, boolean>;
|
|
5
|
+
isAuthenticated: import("vue").ComputedRef<boolean>;
|
|
6
|
+
user: import("vue").ComputedRef<any>;
|
|
7
|
+
permissions: import("vue").ComputedRef<any>;
|
|
8
|
+
token: import("vue").ComputedRef<string | null>;
|
|
9
|
+
init: () => Promise<void>;
|
|
10
|
+
login: (options?: any) => Promise<any>;
|
|
11
|
+
logout: (options?: any) => Promise<any>;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useNuxtApp, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { useAuthStore } from "../stores/auth.js";
|
|
4
|
+
export function useAuth() {
|
|
5
|
+
const nuxtApp = useNuxtApp();
|
|
6
|
+
const rc = useRuntimeConfig();
|
|
7
|
+
const pub = rc.public;
|
|
8
|
+
const provider = computed(() => {
|
|
9
|
+
if (pub?._feathers?.keycloak)
|
|
10
|
+
return "keycloak";
|
|
11
|
+
if (pub?._feathers?.auth)
|
|
12
|
+
return "local";
|
|
13
|
+
return "none";
|
|
14
|
+
});
|
|
15
|
+
const ready = ref(false);
|
|
16
|
+
const keycloak = computed(() => nuxtApp.$keycloak);
|
|
17
|
+
const authStore = computed(() => {
|
|
18
|
+
try {
|
|
19
|
+
return useAuthStore();
|
|
20
|
+
} catch (e) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const isAuthenticated = computed(() => {
|
|
25
|
+
if (provider.value === "keycloak")
|
|
26
|
+
return Boolean(keycloak.value?.authenticated);
|
|
27
|
+
if (provider.value === "local")
|
|
28
|
+
return Boolean(authStore.value?.authenticated || authStore.value?.isAuthenticated);
|
|
29
|
+
return false;
|
|
30
|
+
});
|
|
31
|
+
const user = computed(() => {
|
|
32
|
+
if (provider.value === "keycloak")
|
|
33
|
+
return keycloak.value?.user ?? null;
|
|
34
|
+
if (provider.value === "local")
|
|
35
|
+
return authStore.value?.user ?? null;
|
|
36
|
+
return null;
|
|
37
|
+
});
|
|
38
|
+
const permissions = computed(() => {
|
|
39
|
+
if (provider.value === "keycloak")
|
|
40
|
+
return keycloak.value?.permissions ?? [];
|
|
41
|
+
return [];
|
|
42
|
+
});
|
|
43
|
+
const token = computed(() => {
|
|
44
|
+
if (provider.value === "keycloak")
|
|
45
|
+
return keycloak.value?.token?.() ?? null;
|
|
46
|
+
if (provider.value === "local")
|
|
47
|
+
return authStore.value?.accessToken ?? null;
|
|
48
|
+
return null;
|
|
49
|
+
});
|
|
50
|
+
async function init() {
|
|
51
|
+
if (import.meta.server)
|
|
52
|
+
return;
|
|
53
|
+
if (ready.value) {
|
|
54
|
+
if (provider.value !== "keycloak")
|
|
55
|
+
return;
|
|
56
|
+
if (!isAuthenticated.value)
|
|
57
|
+
return;
|
|
58
|
+
if (user.value)
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (provider.value === "keycloak") {
|
|
62
|
+
const kc = keycloak.value;
|
|
63
|
+
if (kc?.authenticated && typeof kc.whoami === "function") {
|
|
64
|
+
await kc.whoami().catch(() => null);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (provider.value === "local") {
|
|
68
|
+
const s = authStore.value;
|
|
69
|
+
if (s?.restore) {
|
|
70
|
+
await s.restore().catch(() => {
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
ready.value = true;
|
|
75
|
+
}
|
|
76
|
+
async function login(options) {
|
|
77
|
+
if (provider.value === "keycloak")
|
|
78
|
+
return keycloak.value?.login?.(options);
|
|
79
|
+
if (provider.value === "local")
|
|
80
|
+
return authStore.value?.login?.(options);
|
|
81
|
+
}
|
|
82
|
+
async function logout(options) {
|
|
83
|
+
if (provider.value === "keycloak")
|
|
84
|
+
return keycloak.value?.logout?.(options);
|
|
85
|
+
if (provider.value === "local")
|
|
86
|
+
return authStore.value?.logout?.(options);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
provider,
|
|
90
|
+
ready,
|
|
91
|
+
isAuthenticated,
|
|
92
|
+
user,
|
|
93
|
+
permissions,
|
|
94
|
+
token,
|
|
95
|
+
init,
|
|
96
|
+
login,
|
|
97
|
+
logout
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { AuthOptions, PublicAuthOptions, ResolvedAuthOptions, ResolvedAuthO
|
|
|
3
3
|
import type { ClientOptions, ResolvedClientOptionsOrDisabled } from './client/index.js';
|
|
4
4
|
import type { PiniaOptions } from './client/pinia.js';
|
|
5
5
|
import type { DataBaseOptions, ResolvedDataBaseOptions } from './database/index.js';
|
|
6
|
+
import type { KeycloakOptions, ResolvedKeycloakOptions, ResolvedKeycloakOptionsOrDisabled } from './keycloak.js';
|
|
6
7
|
import type { ResolvedServerOptions, ServerOptions } from './server.js';
|
|
7
8
|
import type { ServicesDir, ServicesDirs } from './services.js';
|
|
8
9
|
import type { ResolvedSwaggerOptionsOrDisabled, SwaggerOptionsOrDisabled } from './swagger.js';
|
|
@@ -14,6 +15,7 @@ export interface ModuleOptions {
|
|
|
14
15
|
servicesDirs: ServicesDir | ServicesDirs;
|
|
15
16
|
server: ServerOptions;
|
|
16
17
|
auth: AuthOptions | boolean;
|
|
18
|
+
keycloak?: KeycloakOptions | boolean;
|
|
17
19
|
client: ClientOptions | boolean;
|
|
18
20
|
validator: ValidatorOptions;
|
|
19
21
|
loadFeathersConfig: boolean;
|
|
@@ -26,6 +28,7 @@ export interface ResolvedOptions {
|
|
|
26
28
|
servicesDirs: ServicesDirs;
|
|
27
29
|
server: ResolvedServerOptions;
|
|
28
30
|
auth: ResolvedAuthOptionsOrDisabled;
|
|
31
|
+
keycloak: ResolvedKeycloakOptionsOrDisabled;
|
|
29
32
|
client: ResolvedClientOptionsOrDisabled;
|
|
30
33
|
validator: ResolvedValidatorOptions;
|
|
31
34
|
loadFeathersConfig: boolean;
|
|
@@ -33,11 +36,19 @@ export interface ResolvedOptions {
|
|
|
33
36
|
}
|
|
34
37
|
export interface FeathersRuntimeConfig {
|
|
35
38
|
auth?: ResolvedAuthOptions;
|
|
39
|
+
keycloak?: ResolvedKeycloakOptions;
|
|
36
40
|
}
|
|
37
41
|
export interface FeathersPublicRuntimeConfig {
|
|
38
42
|
transports: ResolvedTransportsOptions;
|
|
39
43
|
auth?: PublicAuthOptions;
|
|
40
44
|
pinia?: PiniaOptions;
|
|
45
|
+
keycloak?: {
|
|
46
|
+
serverUrl: string;
|
|
47
|
+
realm: string;
|
|
48
|
+
clientId: string;
|
|
49
|
+
authServicePath: string;
|
|
50
|
+
onLoad: 'check-sso' | 'login-required';
|
|
51
|
+
};
|
|
41
52
|
}
|
|
42
53
|
export type ModuleConfig = Partial<Omit<ModuleOptions, 'auth'> & {
|
|
43
54
|
auth: Omit<AuthOptions, 'entityImport'> | boolean;
|
|
@@ -3,6 +3,7 @@ import { getServicesImports } from "../services.js";
|
|
|
3
3
|
import { resolveAuthOptions } from "./authentication/index.js";
|
|
4
4
|
import { resolveClientOptions } from "./client/index.js";
|
|
5
5
|
import { resolveDataBaseOptions } from "./database/index.js";
|
|
6
|
+
import { resolveKeycloakOptions } from "./keycloak.js";
|
|
6
7
|
import { resolveServerOptions } from "./server.js";
|
|
7
8
|
import { resolveServicesDirs } from "./services.js";
|
|
8
9
|
import { resolveSwaggerOptions } from "./swagger.js";
|
|
@@ -20,7 +21,8 @@ export async function resolveOptions(options, nuxt) {
|
|
|
20
21
|
const validator = resolveValidatorOptions(options.validator);
|
|
21
22
|
const swagger = resolveSwaggerOptions(options.swagger, transports);
|
|
22
23
|
const servicesImports = await getServicesImports(servicesDirs);
|
|
23
|
-
const
|
|
24
|
+
const keycloak = resolveKeycloakOptions(options.keycloak);
|
|
25
|
+
const auth = keycloak ? false : resolveAuthOptions(options.auth, !!client, servicesImports, appDir);
|
|
24
26
|
const loadFeathersConfig = options.loadFeathersConfig;
|
|
25
27
|
const resolvedOptions = {
|
|
26
28
|
templateDir,
|
|
@@ -31,6 +33,7 @@ export async function resolveOptions(options, nuxt) {
|
|
|
31
33
|
client,
|
|
32
34
|
validator,
|
|
33
35
|
auth,
|
|
36
|
+
keycloak,
|
|
34
37
|
loadFeathersConfig,
|
|
35
38
|
swagger
|
|
36
39
|
};
|
|
@@ -42,6 +45,11 @@ export function resolveRuntimeConfig(options) {
|
|
|
42
45
|
if (options.auth) {
|
|
43
46
|
runtimeConfig.auth = options.auth;
|
|
44
47
|
}
|
|
48
|
+
if (options.keycloak) {
|
|
49
|
+
runtimeConfig.keycloak = {
|
|
50
|
+
authServicePath: options.keycloak.authServicePath
|
|
51
|
+
};
|
|
52
|
+
}
|
|
45
53
|
return runtimeConfig;
|
|
46
54
|
}
|
|
47
55
|
export function resolvePublicRuntimeConfig(options) {
|
|
@@ -62,5 +70,14 @@ export function resolvePublicRuntimeConfig(options) {
|
|
|
62
70
|
if (client?.pinia) {
|
|
63
71
|
publicRuntimeConfig.pinia = client.pinia;
|
|
64
72
|
}
|
|
73
|
+
if (options.keycloak) {
|
|
74
|
+
publicRuntimeConfig.keycloak = {
|
|
75
|
+
serverUrl: options.keycloak.serverUrl,
|
|
76
|
+
realm: options.keycloak.realm,
|
|
77
|
+
clientId: options.keycloak.clientId,
|
|
78
|
+
authServicePath: options.keycloak.authServicePath,
|
|
79
|
+
onLoad: options.keycloak.onLoad
|
|
80
|
+
};
|
|
81
|
+
}
|
|
65
82
|
return publicRuntimeConfig;
|
|
66
83
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface KeycloakOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Keycloak base URL, e.g. https://sso.example.com
|
|
4
|
+
*/
|
|
5
|
+
serverUrl: string;
|
|
6
|
+
realm: string;
|
|
7
|
+
clientId: string;
|
|
8
|
+
/**
|
|
9
|
+
* Keycloak init onLoad mode (client-side).
|
|
10
|
+
* - 'check-sso': do not force redirect, just check existing SSO session (recommended for Option A)
|
|
11
|
+
* - 'login-required': force login redirect if not authenticated
|
|
12
|
+
*/
|
|
13
|
+
onLoad?: 'check-sso' | 'login-required';
|
|
14
|
+
/**
|
|
15
|
+
* Optional: client secret (only needed for UMA permissions flow)
|
|
16
|
+
*/
|
|
17
|
+
secret?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Optional: override issuer/audience checks.
|
|
20
|
+
* If omitted, issuer defaults to `${serverUrl}/realms/${realm}`
|
|
21
|
+
* and audience defaults to clientId.
|
|
22
|
+
*/
|
|
23
|
+
issuer?: string;
|
|
24
|
+
audience?: string | string[];
|
|
25
|
+
/**
|
|
26
|
+
* Map Keycloak subject (`sub`) to a Feathers users service field.
|
|
27
|
+
*/
|
|
28
|
+
userService?: string;
|
|
29
|
+
serviceIdField?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Path for the bridge service (avoid '/auth' collisions).
|
|
32
|
+
*/
|
|
33
|
+
authServicePath?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Enable UMA permissions (requires secret).
|
|
36
|
+
*/
|
|
37
|
+
permissions?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export type ResolvedKeycloakOptions = Required<Pick<KeycloakOptions, 'serverUrl' | 'realm' | 'clientId' | 'userService' | 'serviceIdField' | 'authServicePath' | 'permissions' | 'onLoad'>> & Omit<KeycloakOptions, 'userService' | 'serviceIdField' | 'authServicePath' | 'permissions' | 'onLoad'>;
|
|
40
|
+
export type ResolvedKeycloakOptionsOrDisabled = ResolvedKeycloakOptions | false;
|
|
41
|
+
export declare function resolveKeycloakOptions(keycloak: KeycloakOptions | boolean | undefined): ResolvedKeycloakOptionsOrDisabled;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function resolveKeycloakOptions(keycloak) {
|
|
2
|
+
if (!keycloak)
|
|
3
|
+
return false;
|
|
4
|
+
if (keycloak === true)
|
|
5
|
+
throw new Error("[nuxt-feathers-zod] feathers.keycloak=true is not supported; please provide an object config.");
|
|
6
|
+
return {
|
|
7
|
+
serverUrl: keycloak.serverUrl,
|
|
8
|
+
realm: keycloak.realm,
|
|
9
|
+
clientId: keycloak.clientId,
|
|
10
|
+
issuer: keycloak.issuer,
|
|
11
|
+
audience: keycloak.audience,
|
|
12
|
+
secret: keycloak.secret,
|
|
13
|
+
userService: keycloak.userService || "users",
|
|
14
|
+
serviceIdField: keycloak.serviceIdField || "keycloakId",
|
|
15
|
+
authServicePath: keycloak.authServicePath || "/_keycloak",
|
|
16
|
+
permissions: !!keycloak.permissions,
|
|
17
|
+
onLoad: keycloak.onLoad || "check-sso"
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRuntimeConfig } from "#app";
|
|
2
|
+
import Keycloak from "keycloak-js";
|
|
3
|
+
export default defineNuxtPlugin(async (nuxtApp) => {
|
|
4
|
+
if (import.meta.server)
|
|
5
|
+
return;
|
|
6
|
+
const rc = useRuntimeConfig();
|
|
7
|
+
const pub = rc.public;
|
|
8
|
+
const kcPub = pub?._feathers?.keycloak ?? {};
|
|
9
|
+
const serverUrl = kcPub.serverUrl ?? pub.KC_URL;
|
|
10
|
+
const realm = kcPub.realm ?? pub.KC_REALM;
|
|
11
|
+
const clientId = kcPub.clientId ?? pub.KC_CLIENT_ID;
|
|
12
|
+
if (!serverUrl || !realm || !clientId) {
|
|
13
|
+
console.warn("[nuxt-feathers-zod][keycloak-sso] Missing Keycloak config. Expected feathers.keycloak.{serverUrl,realm,clientId} to be exposed in runtimeConfig.public._feathers.keycloak (or legacy KC_URL/KC_REALM/KC_CLIENT_ID).");
|
|
14
|
+
nuxtApp.provide("keycloak", {
|
|
15
|
+
instance: null,
|
|
16
|
+
authenticated: false,
|
|
17
|
+
token: () => void 0,
|
|
18
|
+
login: async () => {
|
|
19
|
+
},
|
|
20
|
+
logout: async () => {
|
|
21
|
+
},
|
|
22
|
+
updateToken: async () => false,
|
|
23
|
+
whoami: async () => null,
|
|
24
|
+
user: void 0,
|
|
25
|
+
permissions: []
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const keycloak = new Keycloak({
|
|
30
|
+
url: String(serverUrl),
|
|
31
|
+
realm: String(realm),
|
|
32
|
+
clientId: String(clientId)
|
|
33
|
+
});
|
|
34
|
+
const onLoad = pub?._feathers?.keycloak?.onLoad === "login-required" ? "login-required" : "check-sso";
|
|
35
|
+
const initResult = await keycloak.init({
|
|
36
|
+
onLoad,
|
|
37
|
+
checkLoginIframe: false,
|
|
38
|
+
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`
|
|
39
|
+
}).catch(() => ({ authenticated: false }));
|
|
40
|
+
const authenticated = keycloak.authenticated === true && initResult?.authenticated !== false;
|
|
41
|
+
const userid = keycloak?.tokenParsed?.preferred_username;
|
|
42
|
+
const authServicePath = pub?._feathers?.keycloak?.authServicePath || "/_keycloak";
|
|
43
|
+
let refreshTimer;
|
|
44
|
+
let lastWhoamiAt = 0;
|
|
45
|
+
async function safeWhoami(reason) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
if (now - lastWhoamiAt < 1e4)
|
|
48
|
+
return;
|
|
49
|
+
lastWhoamiAt = now;
|
|
50
|
+
try {
|
|
51
|
+
await whoami();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.warn("[nuxt-feathers-zod][keycloak-sso] whoami refresh failed:", reason, e);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function startTokenRefreshLoop() {
|
|
57
|
+
if (refreshTimer)
|
|
58
|
+
window.clearInterval(refreshTimer);
|
|
59
|
+
const minValidity = 60;
|
|
60
|
+
const intervalMs = 3e4;
|
|
61
|
+
refreshTimer = window.setInterval(async () => {
|
|
62
|
+
try {
|
|
63
|
+
const refreshed = await keycloak.updateToken(minValidity);
|
|
64
|
+
if (refreshed)
|
|
65
|
+
await safeWhoami("updateToken(refreshed)");
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.warn("[nuxt-feathers-zod][keycloak-sso] updateToken failed:", e);
|
|
68
|
+
}
|
|
69
|
+
}, intervalMs);
|
|
70
|
+
nuxtApp.hook("app:beforeUnmount", () => {
|
|
71
|
+
if (refreshTimer)
|
|
72
|
+
window.clearInterval(refreshTimer);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const provided = {
|
|
76
|
+
instance: keycloak,
|
|
77
|
+
authenticated,
|
|
78
|
+
userid,
|
|
79
|
+
tokenParsed: keycloak.tokenParsed,
|
|
80
|
+
token: () => keycloak.token,
|
|
81
|
+
login: async (opts) => {
|
|
82
|
+
await keycloak.login(opts);
|
|
83
|
+
},
|
|
84
|
+
logout: async (opts) => {
|
|
85
|
+
await keycloak.logout(opts);
|
|
86
|
+
},
|
|
87
|
+
updateToken: async (minValidity = 30) => {
|
|
88
|
+
const refreshed = await keycloak.updateToken(minValidity);
|
|
89
|
+
if (refreshed)
|
|
90
|
+
await safeWhoami("provided.updateToken(refreshed)");
|
|
91
|
+
return refreshed;
|
|
92
|
+
},
|
|
93
|
+
// Fallback user (will be replaced by whoami() when $api is ready)
|
|
94
|
+
user: keycloak.tokenParsed ? { ...keycloak.tokenParsed, userid } : userid ? { userid } : void 0,
|
|
95
|
+
permissions: [],
|
|
96
|
+
whoami: async () => null
|
|
97
|
+
};
|
|
98
|
+
async function whoami() {
|
|
99
|
+
if (!provided.authenticated)
|
|
100
|
+
return null;
|
|
101
|
+
if (!("$api" in nuxtApp) || !nuxtApp.$api)
|
|
102
|
+
return null;
|
|
103
|
+
const token = keycloak.token;
|
|
104
|
+
if (!token)
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
const res = await nuxtApp.$api.service(authServicePath).create({ access_token: token });
|
|
108
|
+
provided.user = res?.user;
|
|
109
|
+
provided.permissions = res?.permissions || [];
|
|
110
|
+
return res;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if ("$api" in nuxtApp && nuxtApp.$api) {
|
|
116
|
+
nuxtApp.$api.hooks({
|
|
117
|
+
before: [
|
|
118
|
+
async (ctx) => {
|
|
119
|
+
await keycloak.updateToken(30).catch(() => {
|
|
120
|
+
});
|
|
121
|
+
const t = keycloak.token;
|
|
122
|
+
if (!t)
|
|
123
|
+
return ctx;
|
|
124
|
+
ctx.params ||= {};
|
|
125
|
+
ctx.params.headers ||= {};
|
|
126
|
+
ctx.params.headers.Authorization = `Bearer ${t}`;
|
|
127
|
+
return ctx;
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
provided.whoami = whoami;
|
|
133
|
+
nuxtApp.provide("keycloak", provided);
|
|
134
|
+
if (provided.authenticated) {
|
|
135
|
+
await safeWhoami("post-init");
|
|
136
|
+
keycloak.onTokenExpired = async () => {
|
|
137
|
+
try {
|
|
138
|
+
const refreshed = await keycloak.updateToken(60);
|
|
139
|
+
if (refreshed)
|
|
140
|
+
await safeWhoami("onTokenExpired(refreshed)");
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.warn("[nuxt-feathers-zod][keycloak-sso] token expired and refresh failed:", e);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
startTokenRefreshLoop();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getServerAuthContents } from "./authentication.js";
|
|
2
|
+
import { getServerKeycloakContents } from "./keycloak.js";
|
|
2
3
|
import { getServerMongodbContents } from "./mongodb.js";
|
|
3
4
|
import { getServerPluginContents } from "./plugin.js";
|
|
4
5
|
import { getServerContents } from "./server.js";
|
|
@@ -29,5 +30,12 @@ export function getServerTemplates(options) {
|
|
|
29
30
|
write: true
|
|
30
31
|
});
|
|
31
32
|
}
|
|
33
|
+
if (options.keycloak) {
|
|
34
|
+
serverTemplates.push({
|
|
35
|
+
filename: "feathers/server/keycloak.ts",
|
|
36
|
+
getContents: getServerKeycloakContents(options),
|
|
37
|
+
write: true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
32
40
|
return serverTemplates;
|
|
33
41
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export function getServerKeycloakContents(options) {
|
|
2
|
+
return async () => {
|
|
3
|
+
return `// ! Generated by nuxt-feathers-zod - do not change manually
|
|
4
|
+
import type { HookContext, Application } from '@feathersjs/feathers'
|
|
5
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
|
6
|
+
|
|
7
|
+
export type KeycloakConfig = {
|
|
8
|
+
serverUrl: string
|
|
9
|
+
realm: string
|
|
10
|
+
clientId: string
|
|
11
|
+
secret?: string
|
|
12
|
+
userService: string
|
|
13
|
+
serviceIdField: string
|
|
14
|
+
authServicePath: string
|
|
15
|
+
permissions?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getBearer(headers?: Record<string, any>) {
|
|
19
|
+
const auth = headers?.authorization || headers?.Authorization
|
|
20
|
+
if (!auth || typeof auth !== 'string') return null
|
|
21
|
+
if (!auth.startsWith('Bearer ')) return null
|
|
22
|
+
return auth.slice(7)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function safeResolveUser(app: Application, cfg: KeycloakConfig, payload: any) {
|
|
26
|
+
const sub = payload?.sub as string | undefined
|
|
27
|
+
if (!sub) return payload
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const users = app.service(cfg.userService)
|
|
31
|
+
const found = await users.find({ query: { [cfg.serviceIdField]: sub }, paginate: false } as any)
|
|
32
|
+
const first = Array.isArray(found) ? found[0] : (found?.data?.[0] ?? null)
|
|
33
|
+
if (first) return first
|
|
34
|
+
|
|
35
|
+
// Minimal create data to avoid schema conflicts
|
|
36
|
+
const created = await users.create({ [cfg.serviceIdField]: sub } as any)
|
|
37
|
+
return created
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
// Fallback: no DB user service or schema mismatch -> return token payload as user
|
|
41
|
+
return { [cfg.serviceIdField]: sub, ...payload }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function keycloakAuthorizationHook(app: Application, cfg: KeycloakConfig) {
|
|
46
|
+
const issuer = \`\${cfg.serverUrl}/realms/\${cfg.realm}\`
|
|
47
|
+
const jwks = createRemoteJWKSet(new URL(\`\${issuer}/protocol/openid-connect/certs\`))
|
|
48
|
+
|
|
49
|
+
return async (context: HookContext) => {
|
|
50
|
+
// Only external calls
|
|
51
|
+
if (!context.params?.provider) return context
|
|
52
|
+
|
|
53
|
+
const token = getBearer(context.params.headers as any)
|
|
54
|
+
if (!token) return context
|
|
55
|
+
|
|
56
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
57
|
+
issuer,
|
|
58
|
+
audience: cfg.clientId,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
context.params.client = payload
|
|
62
|
+
context.params.user = await safeResolveUser(app, cfg, payload)
|
|
63
|
+
context.params.permissions = []
|
|
64
|
+
|
|
65
|
+
return context
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function keycloakBridgeService(app: Application, cfg: KeycloakConfig) {
|
|
70
|
+
return {
|
|
71
|
+
async create(data: { access_token: string }) {
|
|
72
|
+
const fakeCtx = {
|
|
73
|
+
app,
|
|
74
|
+
params: { headers: { Authorization: \`Bearer \${data.access_token}\` }, provider: 'rest' },
|
|
75
|
+
} as unknown as HookContext
|
|
76
|
+
|
|
77
|
+
await keycloakAuthorizationHook(app, cfg)(fakeCtx)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
user: (fakeCtx.params as any).user,
|
|
81
|
+
permissions: (fakeCtx.params as any).permissions,
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async patch(_: any, data: { access_token: string }) {
|
|
86
|
+
return (this as any).create(data)
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async remove() {
|
|
90
|
+
return { ok: true }
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -23,6 +23,8 @@ export function getServerPluginContents(options) {
|
|
|
23
23
|
const mongo = !!options.database?.mongo;
|
|
24
24
|
const authStrategies = options?.auth?.authStrategies;
|
|
25
25
|
const auth = (authStrategies || []).length > 0;
|
|
26
|
+
const keycloakEnabled = !!options.keycloak;
|
|
27
|
+
const keycloak = options.keycloak;
|
|
26
28
|
const authService = options?.auth?.service;
|
|
27
29
|
const restPath = transports?.rest?.path;
|
|
28
30
|
const websocketPath = transports?.websocket?.path;
|
|
@@ -65,10 +67,27 @@ export function getServerPluginContents(options) {
|
|
|
65
67
|
},
|
|
66
68
|
ui: swagger.swaggerUI({ docsPath: '${docsPath}' }),
|
|
67
69
|
}))
|
|
70
|
+
` : "";
|
|
71
|
+
const keycloakInitBlock = keycloakEnabled ? ` // Init Keycloak-only SSO (global hook + bridge service)
|
|
72
|
+
const keycloakConfig = ${JSON.stringify({
|
|
73
|
+
serverUrl: keycloak.serverUrl,
|
|
74
|
+
realm: keycloak.realm,
|
|
75
|
+
clientId: keycloak.clientId,
|
|
76
|
+
issuer: keycloak.issuer || `${keycloak.serverUrl.replace(/\/$/, "")}/realms/${keycloak.realm}`,
|
|
77
|
+
audience: keycloak.audience || keycloak.clientId,
|
|
78
|
+
secret: keycloak.secret,
|
|
79
|
+
userService: keycloak.userService || "users",
|
|
80
|
+
serviceIdField: keycloak.serviceIdField || "keycloakId",
|
|
81
|
+
authServicePath: keycloak.authServicePath || "/_keycloak",
|
|
82
|
+
permissions: !!keycloak.permissions
|
|
83
|
+
})}
|
|
84
|
+
app.hooks({ before: [keycloakAuthorizationHook(app, keycloakConfig)] })
|
|
85
|
+
app.use(keycloakConfig.authServicePath, keycloakBridgeService(app, keycloakConfig) as any)
|
|
68
86
|
` : "";
|
|
69
87
|
return `// ! Generated by nuxt-feathers-zod - do not change manually
|
|
70
88
|
import type { NitroApp } from 'nitropack'
|
|
71
89
|
import type { Application } from './server.js'
|
|
90
|
+
${put(keycloakEnabled, `import { keycloakAuthorizationHook, keycloakBridgeService } from './keycloak.js'`)}
|
|
72
91
|
${put(options.loadFeathersConfig, `import configuration from '@feathersjs/configuration'`)}
|
|
73
92
|
import { feathers } from '@feathersjs/feathers'
|
|
74
93
|
${puts([
|
|
@@ -94,6 +113,7 @@ ${put(options.loadFeathersConfig, `
|
|
|
94
113
|
app.configure(configuration())
|
|
95
114
|
`)}
|
|
96
115
|
${put(swaggerEnabled, swaggerInitBlock)}
|
|
116
|
+
${put(keycloakEnabled, keycloakInitBlock)}
|
|
97
117
|
// Add nitroApp to feathers app
|
|
98
118
|
app.nitroApp = nitroApp;
|
|
99
119
|
${put(rest, `${put(koa, `
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-feathers-zod",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.5",
|
|
5
5
|
"packageManager": "bun@1.3.6",
|
|
6
6
|
"description": "Feathers API integration for Nuxt",
|
|
7
7
|
"author": "Herve de CHAVIGNY",
|
|
@@ -57,7 +57,10 @@
|
|
|
57
57
|
"release": "bunx release-it",
|
|
58
58
|
"release:patch": "bunx release-it patch",
|
|
59
59
|
"release:minor": "bunx release-it minor",
|
|
60
|
-
"release:major": "bunx release-it major"
|
|
60
|
+
"release:major": "bunx release-it major",
|
|
61
|
+
"docs:dev": "cd docs && bun install && bunx vitepress dev . --host",
|
|
62
|
+
"docs:build": "cd docs && bun install && bunx vitepress build .",
|
|
63
|
+
"docs:preview": "cd docs && bun install && bunx vitepress preview ."
|
|
61
64
|
},
|
|
62
65
|
"peerDependencies": {
|
|
63
66
|
"nuxt": "^4.0.0"
|
|
@@ -85,6 +88,8 @@
|
|
|
85
88
|
"consola": "latest",
|
|
86
89
|
"defu": "latest",
|
|
87
90
|
"feathers-pinia": "latest",
|
|
91
|
+
"jose": "^5.10.0",
|
|
92
|
+
"keycloak-js": "^24.0.5",
|
|
88
93
|
"mongodb": "^6.21.0",
|
|
89
94
|
"pinia": "latest",
|
|
90
95
|
"socket.io-client": "latest",
|
|
@@ -105,7 +110,6 @@
|
|
|
105
110
|
"dotenv-cli": "latest",
|
|
106
111
|
"eslint": "^9.39.2",
|
|
107
112
|
"nuxt": "^4.3.0",
|
|
108
|
-
"nuxt-mcp": "latest",
|
|
109
113
|
"release-it": "latest",
|
|
110
114
|
"typescript": "latest",
|
|
111
115
|
"vitest": "latest",
|
package/src/cli/index.ts
CHANGED
|
@@ -261,7 +261,7 @@ async function ensureFeathersSwaggerSupport(projectRoot: string, io: { dry: bool
|
|
|
261
261
|
)
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
-
catch {
|
|
264
|
+
catch (e) {
|
|
265
265
|
// ignore
|
|
266
266
|
}
|
|
267
267
|
}
|
|
@@ -319,7 +319,7 @@ function relativeToCwd(p: string) {
|
|
|
319
319
|
try {
|
|
320
320
|
return p.replace(`${resolve(process.cwd())}/`, '')
|
|
321
321
|
}
|
|
322
|
-
catch {
|
|
322
|
+
catch (e) {
|
|
323
323
|
return p
|
|
324
324
|
}
|
|
325
325
|
}
|