Nuxt 4 SPA + client-only Keycloak + LDAP backend
This guide is the recommended simple model for NFZ 6.5.30 when a Nuxt 4 application needs Keycloak SSO and an LDAP/Active Directory enriched user.
The validated rule is intentionally strict:
Keycloak = Nuxt client only
NFZ = direct remote Feathers client
LDAP/AD = Feathers backend onlyNFZ does not drive the Keycloak OIDC flow and does not create a Nitro /api/keycloak-ldap proxy. The application initializes Keycloak in a client plugin, then directly calls the remote Feathers authentication service with the Keycloak token.
Target flow
1. Nuxt starts as an SPA with ssr: false.
2. app/plugins/keycloak.client.ts initializes keycloak-js.
3. keycloak.init() completes the OIDC callback.
4. The app stores token + tokenParsed in a Pinia SSO store.
5. The URL is cleaned after keycloak.init() to remove #state=...&session_state=...&code=...
6. The app calls NFZ remote: api.service('authentication').create(...).
7. The Feathers backend verifies or accepts the token according to its strategy, resolves LDAP/AD and returns user + accessToken.
8. The app stores the enriched LDAP user in a dedicated Pinia store.Minimal Nuxt configuration
// 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',
// Empty when the backend exposes /authentication at the root.
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 is not configured in NFZ here.
// NFZ only remains the remote Feathers client.
keycloak: false,
auth: false,
server: {
enabled: false,
},
},
})Environment variables
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=trueClient-only Keycloak plugin
// 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,
},
}
})SSO store
// 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
},
},
})LDAP synchronization through direct NFZ remote
// 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('Keycloak token is unavailable')
return null
}
if (!nuxtApp.$api) {
ldap.setError('NFZ remote client is unavailable')
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 after Keycloak
// 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)
})
},
})Minimal LDAP store
// 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)
},
},
})Expected backend contract
The Feathers backend must register a dedicated strategy:
authentication.register('keycloak-ldap', new SsoLdapStrategy())The frontend calls:
await api.service('authentication').create({
strategy: 'keycloak-ldap',
username: 'jdupont',
authenticated: true,
access_token: '<keycloak-token>',
tokenParsed: {},
ssoUser: {},
})Expected response:
{
"accessToken": "feathers-jwt",
"authentication": {
"strategy": "keycloak-ldap"
},
"user": {
"username": "jdupont",
"email": "jdupont@example.local",
"displayName": "Jean Dupont",
"ldap": {},
"sso": {}
}
}Required backend CORS
Because the call is direct from the browser, the backend must accept OPTIONS /authentication before 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())Validation test
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"The response must be 204 No Content. If the backend still logs Method OPTIONS not allowed, the direct NFZ remote call will be blocked by the browser.
Included example
The module archive contains a complete example in:
examples/nuxt4-keycloak-ldap-spa-ref/This example implements the validated model: Nuxt 4 SPA, Quasar, UnoCSS, Pinia, client-only Keycloak, direct NFZ 6.5.30 remote mode and automatic LDAP synchronization after keycloak.init().
Rules to keep
- Do not configure
feathers.keycloakin this scenario: usekeycloak: false. - Do not create a Nitro
/api/keycloak-ldapproxy when backend CORS is properly configured. - Clean the URL only after
keycloak.init(). - Always separate
sso-sessionandldap-session. - Keep a manual button to re-run LDAP synchronization.
Nuxt 4 SSR variant
An SSR variant is available for applications that need ssr: true while keeping Keycloak strictly client-side: Nuxt 4 SSR + client-only Keycloak + LDAP backend.
