Skip to content

Nuxt 4 SPA + Keycloak client-only + LDAP backend ​

Ce guide est le modèle simple recommandé pour NFZ 6.8.0 lorsqu'une application Nuxt 4 doit utiliser Keycloak SSO et un utilisateur LDAP/Active Directory enrichi.

Le principe validé est volontairement strict :

txt
Keycloak = uniquement côté client Nuxt
NFZ = client Feathers remote direct
LDAP/AD = uniquement côté backend Feathers

NFZ ne pilote pas le flux OIDC Keycloak et ne crée pas de proxy Nitro /api/keycloak-ldap. L'application initialise Keycloak dans un plugin client, puis appelle directement le service Feathers distant authentication avec le token Keycloak.

Flux cible ​

txt
1. Nuxt démarre en SPA avec ssr: false.
2. app/plugins/keycloak.client.ts initialise keycloak-js.
3. keycloak.init() finalise le callback OIDC.
4. L'application stocke token + tokenParsed dans un store Pinia SSO.
5. L'URL est nettoyée après keycloak.init() pour retirer #state=...&session_state=...&code=...
6. L'application appelle NFZ remote : api.service('authentication').create(...).
7. Le backend Feathers vérifie ou accepte le token selon sa stratégie, interroge LDAP/AD et retourne user + accessToken.
8. L'application stocke l'utilisateur LDAP enrichi dans un store Pinia dédié.

Configuration Nuxt minimale ​

ts
// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2025-12-01',
  ssr: false,

  modules: [
    '@pinia/nuxt',
    'pinia-plugin-persistedstate/nuxt',
    '@unocss/nuxt',
    'nuxt-quasar-ui',
    'nuxt-feathers-zod',
  ],

  runtimeConfig: {
    public: {
      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',
        // Vide si le backend expose /authentication à la racine.
        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'] },
        ],
      },
      pinia: true,
    },

    // Important : Keycloak n'est pas configuré dans NFZ.
    // NFZ reste uniquement le client Feathers remote.
    keycloak: false,
    auth: false,
    server: {
      enabled: false,
    },
  },
})

Variables d'environnement ​

txt
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=true

Plugin Keycloak client-only ​

ts
// 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,
    },
  }
})

Store SSO ​

ts
// app/stores/sso-session.ts
import { defineStore } from 'pinia'

export const useSsoSessionStore = defineStore('sso-session', {
  state: () => ({
    authenticated: false,
    token: null as string | null,
    tokenParsed: null as Record<string, unknown> | null,
    error: null as string | null,
  }),

  getters: {
    username: state => String(
      state.tokenParsed?.preferred_username
      || state.tokenParsed?.userid
      || state.tokenParsed?.username
      || state.tokenParsed?.email
      || '',
    ),
  },

  actions: {
    setSession(payload: {
      authenticated: boolean
      token: string | null
      tokenParsed: Record<string, unknown> | null
    }): void {
      this.authenticated = payload.authenticated
      this.token = payload.token
      this.tokenParsed = payload.tokenParsed
      this.error = null
    },

    setError(error: unknown): void {
      this.authenticated = false
      this.token = null
      this.tokenParsed = null
      this.error = error instanceof Error ? error.message : String(error)
    },

    clear(): void {
      this.authenticated = false
      this.token = null
      this.tokenParsed = null
      this.error = null
    },
  },
})

Synchronisation LDAP via NFZ remote direct ​

ts
// 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 sso = useSsoSessionStore()
  const ldap = useLdapSessionStore()
  const nuxtApp = useNuxtApp() as unknown as { $api?: FeathersLikeApi }

  async function synchronize(reason = 'manual'): Promise<KeycloakLdapAuthResult | null> {
    if (!sso.authenticated || !sso.token) {
      ldap.setError('Token Keycloak indisponible')
      return null
    }

    if (!nuxtApp.$api) {
      ldap.setError('Client NFZ remote indisponible')
      return null
    }

    const result = await nuxtApp.$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
  }

  return {
    synchronize,
  }
}

Auto-sync après Keycloak ​

ts
// 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 : ''

  return `nfz:keycloak-ldap:auto-sync:${sid || sub || sso.username || 'anonymous'}`
}

export default defineNuxtPlugin({
  name: 'keycloak-ldap-auto-sync',
  enforce: 'post',
  setup(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 || ldap.synchronized) {
        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)
    })
  },
})

Store LDAP minimal ​

ts
// app/stores/ldap-session.ts
import { defineStore } from 'pinia'

interface KeycloakLdapAuthResult {
  accessToken?: string
  authentication?: Record<string, unknown>
  user?: Record<string, unknown>
}

export const useLdapSessionStore = defineStore('ldap-session', {
  state: () => ({
    synchronized: false,
    accessToken: null as string | null,
    authentication: null as Record<string, unknown> | null,
    currentUser: null as Record<string, unknown> | null,
    error: null as string | null,
    lastSyncAt: null as string | null,
  }),

  actions: {
    setAuthResult(result: KeycloakLdapAuthResult): void {
      this.synchronized = true
      this.accessToken = result.accessToken || null
      this.authentication = result.authentication || null
      this.currentUser = result.user || null
      this.error = null
      this.lastSyncAt = new Date().toISOString()
    },

    setError(error: unknown): void {
      this.synchronized = false
      this.error = error instanceof Error ? error.message : String(error)
    },
  },
})

Contrat backend attendu ​

Le backend Feathers doit enregistrer une stratégie dédiée :

js
authentication.register('keycloak-ldap', new SsoLdapStrategy())

Le frontend appelle :

ts
await api.service('authentication').create({
  strategy: 'keycloak-ldap',
  username: 'jdupont',
  authenticated: true,
  access_token: '<keycloak-token>',
  tokenParsed: {},
  ssoUser: {},
})

La réponse attendue :

json
{
  "accessToken": "jwt-feathers",
  "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, le backend doit accepter OPTIONS /authentication avant Feathers REST :

js
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())

Test de validation ​

powershell
curl.exe -i -X OPTIONS "https://api.example.local/authentication" `
  -H "Origin: http://localhost:3000" `
  -H "Access-Control-Request-Method: POST" `
  -H "Access-Control-Request-Headers: content-type,authorization"

La réponse doit être 204 No Content. Si le backend affiche encore Method OPTIONS not allowed, l'appel NFZ remote direct sera bloqué par le navigateur.

Exemple inclus ​

L'archive du module contient un exemple complet dans :

txt
examples/nuxt4-keycloak-ldap-spa-ref/

Cet exemple reprend le modèle validé : Nuxt 4 SPA, Quasar, UnoCSS, Pinia, Keycloak client-only, NFZ 6.8.0 remote direct et synchronisation LDAP automatique après keycloak.init().

Règles à retenir ​

  • Ne configure pas feathers.keycloak dans ce scénario : utilise keycloak: false.
  • Ne crée pas de proxy Nitro /api/keycloak-ldap si le backend CORS est correctement configuré.
  • Nettoie l'URL seulement après keycloak.init().
  • Sépare toujours sso-session et ldap-session.
  • Garde un bouton manuel pour relancer la synchronisation LDAP.

Variante SSR Nuxt 4 ​

Une variante SSR est disponible pour les applications qui souhaitent garder ssr: true tout en conservant Keycloak strictement côté client : Nuxt 4 SSR + Keycloak client-only + LDAP backend.

Documentation utilisateur de nuxt-feathers-zod