Nuxt 4 SSR + Keycloak client-only + LDAP backend
Ce guide décrit le modèle SSR recommandé pour une application Nuxt 4 qui utilise Keycloak SSO côté navigateur, un backend Feathers distant exposé via NFZ 6.5.30, et une stratégie backend keycloak-ldap pour résoudre l'utilisateur LDAP/Active Directory.
Le modèle est volontairement séparé en trois responsabilités :
Keycloak = uniquement côté client Nuxt
NFZ 6.5.30 = client Feathers remote direct
LDAP/AD = uniquement côté backend FeathersLa différence avec le modèle SPA tient au rendu Nuxt : ssr: true est activé. Keycloak reste cependant strictement client-only. Le serveur Nuxt rend un shell stable, puis le navigateur finalise l'authentification SSO et lance la synchronisation LDAP.
Quand utiliser ce modèle
Utilise ce modèle lorsque :
- l'application doit conserver le rendu SSR Nuxt pour la structure, les layouts ou les pages publiques ;
- l'authentification réelle est fournie par Keycloak dans le navigateur ;
- le backend Feathers possède déjà une stratégie
keycloak-ldapou équivalente ; - le frontend doit appeler directement l'API Feathers distante, sans proxy Nitro local ;
- les informations applicatives doivent venir du LDAP/AD backend plutôt que du seul
tokenParsedKeycloak.
Ce modèle n'est pas une authentification OIDC serveur avec cookies httpOnly. Pour ce besoin, il faut prévoir une architecture serveur dédiée.
Flux d'exécution
1. Nuxt rend la page côté serveur avec ssr: true.
2. Les blocs dépendants de Keycloak ou LDAP sont encapsulés dans <ClientOnly>.
3. app/plugins/keycloak.client.ts initialise keycloak-js côté navigateur.
4. keycloak.init() finalise le callback OIDC.
5. L'application nettoie l'URL après keycloak.init() pour retirer #state=...&session_state=...&code=...
6. app/plugins/keycloak-ldap-bridge.client.ts lance la synchronisation LDAP automatique.
7. useKeycloakLdapBridge appelle api.service('authentication').create(...).
8. Le backend Feathers exécute la stratégie keycloak-ldap, interroge LDAP/AD et retourne user + accessToken.
9. Le store ldap-session expose l'utilisateur applicatif enrichi.Configuration Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-12-01',
ssr: true,
modules: [
'@pinia/nuxt',
'@unocss/nuxt',
'nuxt-quasar-ui',
'nuxt-feathers-zod',
],
runtimeConfig: {
public: {
authDebug: process.env.NUXT_PUBLIC_AUTH_DEBUG === 'true',
keycloak: {
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://keycloak.example.local',
realm: process.env.KEYCLOAK_REALM || 'EXAMPLE',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'nuxt4app',
onLoad: process.env.KEYCLOAK_ON_LOAD || 'check-sso',
},
ldapBridge: {
autoSync: process.env.NUXT_PUBLIC_LDAP_AUTO_SYNC !== 'false',
},
},
},
feathers: {
client: {
mode: 'remote',
remote: {
url: process.env.NFZ_REMOTE_URL || 'https://api.example.local',
transport: 'rest',
restPath: process.env.NFZ_REMOTE_REST_PATH ?? '',
websocketPath: process.env.NFZ_REMOTE_SOCKET_PATH || '/socket.io',
services: [
{ path: 'authentication', methods: ['create', 'remove'] },
{ path: 'users', methods: ['find', 'get'] },
{ path: 'ldap-users', methods: ['find', 'get'] },
],
},
pinia: true,
},
keycloak: false,
auth: false,
server: {
enabled: false,
},
},
})Points importants :
ssr: trueactive le rendu serveur Nuxt.keycloak: falseévite toute gestion Keycloak dans NFZ.auth: falseévite de mélanger l'auth runtime NFZ avec le SSO client-only.server.enabled: falseconfirme que l'application consomme un backend Feathers distant.restPathreste vide si le backend expose directement/authentication.
Variables d'environnement
NFZ_REMOTE_URL=https://api.example.local
NFZ_REMOTE_REST_PATH=
NFZ_REMOTE_SOCKET_PATH=/socket.io
KEYCLOAK_SERVER_URL=https://keycloak.example.local
KEYCLOAK_REALM=EXAMPLE
KEYCLOAK_CLIENT_ID=nuxt4app
KEYCLOAK_ON_LOAD=check-sso
NUXT_PUBLIC_LDAP_AUTO_SYNC=true
NUXT_PUBLIC_AUTH_DEBUG=falsePlugin Keycloak client-only
Le plugin doit être suffixé .client.ts. Il ne s'exécute jamais pendant le rendu serveur.
// app/plugins/keycloak.client.ts
import Keycloak from 'keycloak-js'
import { useSsoSessionStore } from '~/stores/sso-session'
interface PublicKeycloakConfig {
serverUrl?: string
realm?: string
clientId?: string
onLoad?: 'check-sso' | 'login-required'
}
function cleanupOidcCallbackUrl(): void {
const url = new URL(window.location.href)
const hashValue = url.hash.startsWith('#') ? url.hash.slice(1) : url.hash
const hashParams = new URLSearchParams(hashValue)
const hasKeycloakHash = hashParams.has('state')
&& (hashParams.has('code') || hashParams.has('session_state'))
const hasKeycloakQuery = url.searchParams.has('state')
&& (url.searchParams.has('code') || url.searchParams.has('session_state'))
if (hasKeycloakHash) {
url.hash = ''
}
if (hasKeycloakQuery) {
url.searchParams.delete('state')
url.searchParams.delete('session_state')
url.searchParams.delete('code')
}
if (hasKeycloakHash || hasKeycloakQuery) {
window.history.replaceState(
window.history.state,
document.title,
`${url.pathname}${url.search}${url.hash}`,
)
}
}
export default defineNuxtPlugin(async () => {
const config = useRuntimeConfig()
const keycloakConfig = config.public.keycloak as PublicKeycloakConfig
const sso = useSsoSessionStore()
const keycloak = new Keycloak({
url: keycloakConfig.serverUrl,
realm: keycloakConfig.realm,
clientId: keycloakConfig.clientId,
})
try {
const authenticated = await keycloak.init({
onLoad: keycloakConfig.onLoad || 'check-sso',
pkceMethod: 'S256',
checkLoginIframe: false,
responseMode: 'fragment',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
})
cleanupOidcCallbackUrl()
sso.setSession({
authenticated: authenticated === true && Boolean(keycloak.token),
token: keycloak.token ?? null,
tokenParsed: keycloak.tokenParsed as Record<string, unknown> | null,
})
}
catch (error) {
cleanupOidcCallbackUrl()
sso.setError(error)
}
return {
provide: {
keycloakClient: keycloak,
},
}
})La fonction cleanupOidcCallbackUrl() doit toujours être exécutée après keycloak.init(), jamais avant.
Synchronisation LDAP automatique
Le bridge LDAP est aussi un plugin client. Il attend que l'application soit montée, vérifie la session Keycloak, puis appelle le service Feathers distant.
// app/plugins/keycloak-ldap-bridge.client.ts
import { useKeycloakLdapBridge } from '~/composables/useKeycloakLdapBridge'
import { useLdapSessionStore } from '~/stores/ldap-session'
import { useSsoSessionStore } from '~/stores/sso-session'
function resolveSyncKey(): string {
const sso = useSsoSessionStore()
const user = sso.tokenParsed
const sid = typeof user?.sid === 'string' ? user.sid : ''
const sub = typeof user?.sub === 'string' ? user.sub : ''
const username = sso.username || ''
return `nfz:keycloak-ldap:auto-sync:${sid || sub || username || 'anonymous'}`
}
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:mounted', async () => {
const config = useRuntimeConfig()
if (config.public.ldapBridge?.autoSync === false) {
return
}
const sso = useSsoSessionStore()
const ldap = useLdapSessionStore()
if (!sso.authenticated || !sso.token) {
return
}
if (ldap.synchronized && ldap.currentUser) {
return
}
const syncKey = resolveSyncKey()
if (sessionStorage.getItem(syncKey) === 'done') {
return
}
sessionStorage.setItem(syncKey, 'running')
const bridge = useKeycloakLdapBridge()
const result = await bridge.synchronize('auto-after-keycloak')
if (result?.user) {
sessionStorage.setItem(syncKey, 'done')
return
}
sessionStorage.removeItem(syncKey)
})
})Appel NFZ remote direct
// app/composables/useKeycloakLdapBridge.ts
import { useLdapSessionStore } from '~/stores/ldap-session'
import { useSsoSessionStore } from '~/stores/sso-session'
interface KeycloakLdapAuthResult {
accessToken?: string
authentication?: Record<string, unknown>
user?: Record<string, unknown>
}
interface FeathersLikeApi {
service(path: string): {
create(data: Record<string, unknown>): Promise<KeycloakLdapAuthResult>
}
}
export function useKeycloakLdapBridge() {
const nuxtApp = useNuxtApp()
const sso = useSsoSessionStore()
const ldap = useLdapSessionStore()
async function synchronize(reason = 'manual-refresh'): Promise<KeycloakLdapAuthResult | null> {
if (!sso.token) {
ldap.setError('Keycloak token is not available')
return null
}
const api = nuxtApp.$api as FeathersLikeApi
try {
const result = await api.service('authentication').create({
strategy: 'keycloak-ldap',
username: sso.username,
authenticated: true,
access_token: sso.token,
tokenParsed: sso.tokenParsed,
ssoUser: sso.tokenParsed,
reason,
})
ldap.setAuthResult(result)
return result
}
catch (error) {
ldap.setError(error)
return null
}
}
return {
synchronize,
}
}Rendu SSR-safe
Les informations Keycloak et LDAP ne sont disponibles qu'après montage côté navigateur. Les composants qui les affichent doivent donc utiliser <ClientOnly>.
<template>
<ClientOnly>
<UserSessionPanel />
<template #fallback>
<div class="text-grey-7">
Chargement de la session utilisateur…
</div>
</template>
</ClientOnly>
</template>Pour les pages protégées, les middlewares doivent rester client-only tant que la session n'est pas lisible côté serveur :
// app/middleware/auth-keycloak-ldap.ts
export default defineNuxtRouteMiddleware(() => {
if (import.meta.server) {
return
}
const ldap = useLdapSessionStore()
if (!ldap.synchronized || !ldap.currentUser) {
return navigateTo('/')
}
})Contrat backend attendu
Le backend Feathers doit enregistrer une stratégie dédiée :
authentication.register('keycloak-ldap', new SsoLdapStrategy())Le frontend envoie :
{
"strategy": "keycloak-ldap",
"username": "jdupont",
"authenticated": true,
"access_token": "<keycloak-token>",
"tokenParsed": {},
"ssoUser": {}
}Réponse attendue :
{
"accessToken": "feathers-jwt",
"authentication": {
"strategy": "keycloak-ldap"
},
"user": {
"username": "jdupont",
"email": "jdupont@example.local",
"displayName": "Jean Dupont",
"ldap": {},
"sso": {}
}
}CORS indispensable côté backend
Comme l'appel est direct depuis le navigateur vers le backend Feathers, le serveur doit gérer OPTIONS /authentication avant Feathers REST.
import cors from 'cors'
import express from '@feathersjs/express'
const allowedOrigins = [
'http://localhost:3000',
'https://app.example.local',
]
const corsOptions = {
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
return callback(null, true)
}
return callback(new Error(`CORS origin rejected: ${origin}`))
},
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Origin', 'X-Requested-With'],
credentials: true,
preflightContinue: false,
optionsSuccessStatus: 204,
}
app.use(cors(corsOptions))
app.options(/.*/, cors(corsOptions))
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.configure(express.rest())Exemple inclus
Le dépôt contient un exemple complet :
examples/nuxt4-keycloak-ldap-ssr-ref/Cet exemple reprend la structure Nuxt 4 SSR, Quasar 2, UnoCSS, Pinia, NFZ 6.5.30 remote direct, Keycloak client-only et synchronisation LDAP automatique.
Règles de production
- Ne place pas de secrets Keycloak ou LDAP dans le frontend.
- Garde la vérification et l'enrichissement LDAP côté backend.
- Configure une allowlist CORS stricte.
- Ne nettoie jamais le hash Keycloak avant
keycloak.init(). - Sépare la session SSO (
sso-session) de la session applicative LDAP (ldap-session). - Prévois une évolution distincte si tu veux une vraie session serveur Nuxt avec cookies
httpOnly.
