@habityzer/nuxt-symfony-kinde-layer 2.1.4 → 2.2.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/.env.example ADDED
@@ -0,0 +1,17 @@
1
+ # Kinde Authentication Configuration
2
+ # These are required for the @habityzer/nuxt-kinde-auth module
3
+ KINDE_AUTH_DOMAIN=https://your-domain.kinde.com
4
+ KINDE_CLIENT_ID=your_client_id
5
+ KINDE_CLIENT_SECRET=your_client_secret
6
+ KINDE_REDIRECT_URL=http://localhost:3000/api/auth/callback
7
+ KINDE_LOGOUT_REDIRECT_URL=http://localhost:3000
8
+
9
+ # Auth Cookie and Middleware Configuration
10
+ NUXT_PUBLIC_AUTH_COOKIE_PREFIX=auth_
11
+ NUXT_PUBLIC_AUTH_LOGIN_PATH=/login
12
+ NUXT_PUBLIC_AUTH_CLOCK_SKEW_SECONDS=300
13
+ NUXT_PUBLIC_AUTH_APP_TOKEN_PREFIX=Bearer
14
+ NUXT_PUBLIC_AUTH_E2E_TOKEN_COOKIE_NAME=e2e_token
15
+ NUXT_PUBLIC_AUTH_ID_TOKEN_NAME=id_token
16
+ NUXT_PUBLIC_AUTH_ACCESS_TOKEN_NAME=access_token
17
+ NUXT_PUBLIC_AUTH_REFRESH_TOKEN_NAME=refresh_token
@@ -38,6 +38,20 @@ jobs:
38
38
  run: pnpm install --frozen-lockfile
39
39
 
40
40
  - name: Prepare Nuxt
41
+ env:
42
+ KINDE_AUTH_DOMAIN: https://placeholder.kinde.com
43
+ KINDE_CLIENT_ID: placeholder_client_id
44
+ KINDE_CLIENT_SECRET: placeholder_client_secret
45
+ KINDE_REDIRECT_URL: http://localhost:3000/api/auth/callback
46
+ KINDE_LOGOUT_REDIRECT_URL: http://localhost:3000
47
+ NUXT_PUBLIC_AUTH_COOKIE_PREFIX: auth_
48
+ NUXT_PUBLIC_AUTH_LOGIN_PATH: /login
49
+ NUXT_PUBLIC_AUTH_CLOCK_SKEW_SECONDS: 300
50
+ NUXT_PUBLIC_AUTH_APP_TOKEN_PREFIX: Bearer
51
+ NUXT_PUBLIC_AUTH_E2E_TOKEN_COOKIE_NAME: e2e_token
52
+ NUXT_PUBLIC_AUTH_ID_TOKEN_NAME: id_token
53
+ NUXT_PUBLIC_AUTH_ACCESS_TOKEN_NAME: access_token
54
+ NUXT_PUBLIC_AUTH_REFRESH_TOKEN_NAME: refresh_token
41
55
  run: pnpm nuxt prepare
42
56
 
43
57
  - name: Run linter
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env sh
2
+ . "$(dirname -- "$0")/_/husky.sh"
3
+
4
+ # Validate commit message format
5
+ pnpm exec commitlint --edit "$1"
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Pre-commit hook to ensure code quality
4
+ set -e
5
+
6
+ echo "🔍 Running pre-commit checks..."
7
+
8
+ # Check if .nuxt directory exists, if not, prepare it
9
+ if [ ! -d ".nuxt" ]; then
10
+ echo "📦 Preparing Nuxt (first time)..."
11
+ export KINDE_AUTH_DOMAIN="https://placeholder.kinde.com"
12
+ export KINDE_CLIENT_ID="placeholder_client_id"
13
+ export KINDE_CLIENT_SECRET="placeholder_client_secret"
14
+ export KINDE_REDIRECT_URL="http://localhost:3000/api/auth/callback"
15
+ export KINDE_LOGOUT_REDIRECT_URL="http://localhost:3000"
16
+ export NUXT_PUBLIC_AUTH_COOKIE_PREFIX="auth_"
17
+ export NUXT_PUBLIC_AUTH_LOGIN_PATH="/login"
18
+ export NUXT_PUBLIC_AUTH_CLOCK_SKEW_SECONDS="300"
19
+ export NUXT_PUBLIC_AUTH_APP_TOKEN_PREFIX="Bearer"
20
+ export NUXT_PUBLIC_AUTH_E2E_TOKEN_COOKIE_NAME="e2e_token"
21
+ export NUXT_PUBLIC_AUTH_ID_TOKEN_NAME="id_token"
22
+ export NUXT_PUBLIC_AUTH_ACCESS_TOKEN_NAME="access_token"
23
+ export NUXT_PUBLIC_AUTH_REFRESH_TOKEN_NAME="refresh_token"
24
+
25
+ pnpm nuxt prepare > /dev/null 2>&1
26
+ fi
27
+
28
+ # Run linter
29
+ echo "✨ Running ESLint..."
30
+ pnpm lint
31
+
32
+ echo "✅ Pre-commit checks passed!"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ # [2.2.0](https://github.com/Habityzer/nuxt-symfony-kinde-layer/compare/v2.1.4...v2.2.0) (2026-02-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * Enhance Kinde authentication configuration and middleware handling ([d8b722a](https://github.com/Habityzer/nuxt-symfony-kinde-layer/commit/d8b722a8ba5a126f02c31aea0347fa3e21bb29b9))
7
+ * Refactor Kinde authentication configuration and enhance middleware setup ([c577a2a](https://github.com/Habityzer/nuxt-symfony-kinde-layer/commit/c577a2a047299f30cb745a91aac82b50c2bfedb1))
8
+
1
9
  ## [2.1.4](https://github.com/Habityzer/nuxt-symfony-kinde-layer/compare/v2.1.3...v2.1.4) (2026-01-01)
2
10
 
3
11
 
package/README.md CHANGED
@@ -257,6 +257,83 @@ Add these scripts to your project's `package.json`:
257
257
  }
258
258
  ```
259
259
 
260
+ ## Development
261
+
262
+ ### Local Development Setup
263
+
264
+ 1. **Install dependencies:**
265
+ ```bash
266
+ pnpm install
267
+ ```
268
+
269
+ 2. **The project uses Husky for git hooks:**
270
+ - Pre-commit: Automatically runs `pnpm lint` before each commit
271
+ - Commit-msg: Validates commit message format (conventional commits)
272
+
273
+ 3. **Run linter manually:**
274
+ ```bash
275
+ pnpm lint # Check for issues
276
+ pnpm lint:fix # Auto-fix issues
277
+ ```
278
+
279
+ 4. **First time setup:**
280
+ The pre-commit hook will automatically run `nuxt prepare` if needed (with placeholder environment variables).
281
+
282
+ ### Commit Message Format
283
+
284
+ This project uses [Conventional Commits](https://www.conventionalcommits.org/). Your commits must follow this format:
285
+
286
+ ```
287
+ type(scope): subject
288
+
289
+ body (optional)
290
+ ```
291
+
292
+ **Types:**
293
+ - `feat`: New feature
294
+ - `fix`: Bug fix
295
+ - `docs`: Documentation changes
296
+ - `style`: Code style changes (formatting, etc.)
297
+ - `refactor`: Code refactoring
298
+ - `test`: Adding or updating tests
299
+ - `chore`: Maintenance tasks
300
+
301
+ **Examples:**
302
+ ```bash
303
+ git commit -m "feat: add authentication middleware"
304
+ git commit -m "fix: resolve cookie prefix conflict"
305
+ git commit -m "docs: update README with CI setup"
306
+ ```
307
+
308
+ ## CI/CD Setup
309
+
310
+ ### Required Environment Variables for GitHub Actions
311
+
312
+ When building or publishing this layer in CI/CD (e.g., GitHub Actions), you need to provide placeholder environment variables for the `nuxt prepare` step. The layer's `nuxt.config.ts` validates these at build time.
313
+
314
+ Add these to your workflow:
315
+
316
+ ```yaml
317
+ - name: Prepare Nuxt
318
+ env:
319
+ KINDE_AUTH_DOMAIN: https://placeholder.kinde.com
320
+ KINDE_CLIENT_ID: placeholder_client_id
321
+ KINDE_CLIENT_SECRET: placeholder_client_secret
322
+ KINDE_REDIRECT_URL: http://localhost:3000/api/auth/callback
323
+ KINDE_LOGOUT_REDIRECT_URL: http://localhost:3000
324
+ NUXT_PUBLIC_AUTH_COOKIE_PREFIX: auth_
325
+ NUXT_PUBLIC_AUTH_LOGIN_PATH: /login
326
+ NUXT_PUBLIC_AUTH_CLOCK_SKEW_SECONDS: 300
327
+ NUXT_PUBLIC_AUTH_APP_TOKEN_PREFIX: Bearer
328
+ NUXT_PUBLIC_AUTH_E2E_TOKEN_COOKIE_NAME: e2e_token
329
+ NUXT_PUBLIC_AUTH_ID_TOKEN_NAME: id_token
330
+ NUXT_PUBLIC_AUTH_ACCESS_TOKEN_NAME: access_token
331
+ NUXT_PUBLIC_AUTH_REFRESH_TOKEN_NAME: refresh_token
332
+ run: pnpm nuxt prepare
333
+ ```
334
+
335
+ **Note**: These are placeholder values only used for type generation and validation. Projects consuming this layer will provide their own real credentials at runtime.
336
+
260
337
  ## Troubleshooting
261
338
 
262
339
  ### Cookie Name Conflicts
@@ -1,6 +1,8 @@
1
1
  import { computed, ref, readonly } from 'vue'
2
2
  import { E2E_TOKEN_COOKIE_NAME } from '../constants/auth'
3
3
 
4
+ const LEGACY_E2E_STORAGE_KEY = 'e2e_app_token'
5
+
4
6
  interface SymfonyUser {
5
7
  id: number
6
8
  email: string
@@ -105,9 +107,30 @@ export const useAuth = () => {
105
107
  // Clear local Symfony state
106
108
  userProfile.value = null
107
109
 
108
- // Clear E2E test token if exists
110
+ // Clear auth cookies first so route middleware blocks protected pages immediately.
109
111
  if (import.meta.client) {
110
- localStorage.removeItem('e2e_app_token')
112
+ const config = useRuntimeConfig()
113
+ const kindeConfig = config.public.kindeAuth || {}
114
+ const cookieConfig = kindeConfig.cookie || {}
115
+ const middlewareConfig = kindeConfig.middleware || {}
116
+ const cookiePrefix = requireString(cookieConfig.prefix, 'kindeAuth.cookie.prefix')
117
+ const idTokenName = requireString(cookieConfig.idTokenName, 'kindeAuth.cookie.idTokenName')
118
+ const accessTokenName = requireString(cookieConfig.accessTokenName, 'kindeAuth.cookie.accessTokenName')
119
+ const refreshTokenName = requireString(cookieConfig.refreshTokenName, 'kindeAuth.cookie.refreshTokenName')
120
+ const e2eTokenCookieName = requireString(middlewareConfig.e2eTokenCookieName, 'kindeAuth.middleware.e2eTokenCookieName')
121
+
122
+ const authCookies = [idTokenName, accessTokenName, refreshTokenName]
123
+ const scopedE2eCookieName = `${cookiePrefix}${e2eTokenCookieName}`
124
+ const scopedE2eStorageKey = `${cookiePrefix}e2e_app_token`
125
+
126
+ authCookies.forEach((cookieName) => {
127
+ document.cookie = `${cookiePrefix}${cookieName}=; path=/; max-age=0`
128
+ })
129
+
130
+ // Remove both scoped and legacy keys for backward compatibility.
131
+ localStorage.removeItem(scopedE2eStorageKey)
132
+ localStorage.removeItem(LEGACY_E2E_STORAGE_KEY)
133
+ document.cookie = `${scopedE2eCookieName}=; path=/; max-age=0`
111
134
  document.cookie = `${E2E_TOKEN_COOKIE_NAME}=; path=/; max-age=0`
112
135
  }
113
136
 
@@ -137,3 +160,11 @@ export const useAuth = () => {
137
160
  fetchUserProfile
138
161
  }
139
162
  }
163
+
164
+ function requireString(value: unknown, key: string): string {
165
+ if (typeof value === 'string' && value.trim().length > 0) {
166
+ return value
167
+ }
168
+
169
+ throw new Error(`[useAuth] Missing required config: ${key}`)
170
+ }
@@ -1,26 +1,9 @@
1
- /**
2
- * Authentication constants shared across the application
3
- */
4
-
5
- /**
6
- * Cookie name for E2E test authentication token
7
- * Used by automated tests to bypass Kinde OAuth flow
8
- */
9
- export const E2E_TOKEN_COOKIE_NAME = 'kinde_token'
10
-
11
- /**
12
- * Prefix for Symfony app tokens (used in E2E tests)
13
- * These are long-lived tokens generated by `php bin/console app:token:manage create`
14
- */
15
- export const APP_TOKEN_PREFIX = 'app_'
16
-
17
- /**
18
- * Kinde authentication cookie names
19
- * These cookies are managed by the @habityzer/nuxt-kinde-auth module
20
- * The prefix is configured per-project in nuxt.config.ts
21
- *
22
- * Note: These constants use placeholder names. The actual cookie names
23
- * will have the project-specific prefix (e.g., 'ew-id_token', 'habityzer_id_token')
24
- */
25
- export const KINDE_ID_TOKEN_COOKIE_NAME = 'id_token'
26
- export const KINDE_ACCESS_TOKEN_COOKIE_NAME = 'access_token'
1
+ export {
2
+ APP_TOKEN_PREFIX,
3
+ CLOCK_SKEW_SECONDS,
4
+ DEFAULT_LOGIN_PATH,
5
+ E2E_TOKEN_COOKIE_NAME,
6
+ KINDE_ACCESS_TOKEN_COOKIE_NAME,
7
+ KINDE_ID_TOKEN_COOKIE_NAME,
8
+ KINDE_REFRESH_TOKEN_COOKIE_NAME
9
+ } from '../../shared/auth-constants'
@@ -0,0 +1,134 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const router = useRouter()
3
+ const config = useRuntimeConfig()
4
+ const kindeConfig = config.public.kindeAuth || {}
5
+ const middlewareConfig = kindeConfig.middleware || {}
6
+ const cookieConfig = kindeConfig.cookie || {}
7
+ // @ts-expect-error - cookie property exists in runtime config but not in module types
8
+ const cookiePrefix = requireString(cookieConfig.prefix, 'kindeAuth.cookie.prefix')
9
+ const idTokenBaseName = requireString(cookieConfig.idTokenName, 'kindeAuth.cookie.idTokenName')
10
+ const accessTokenBaseName = requireString(cookieConfig.accessTokenName, 'kindeAuth.cookie.accessTokenName')
11
+ const e2eTokenCookieName = requireString(middlewareConfig.e2eTokenCookieName, 'kindeAuth.middleware.e2eTokenCookieName')
12
+ const appTokenPrefix = requireString(middlewareConfig.appTokenPrefix, 'kindeAuth.middleware.appTokenPrefix')
13
+ const clockSkewSeconds = requireNonNegativeNumber(middlewareConfig.clockSkewSeconds, 'kindeAuth.middleware.clockSkewSeconds')
14
+ const idToken = useCookie<string | null>(`${cookiePrefix}${idTokenBaseName}`)
15
+ const accessToken = useCookie<string | null>(`${cookiePrefix}${accessTokenBaseName}`)
16
+ const e2eToken = useCookie<string | null>(`${cookiePrefix}${e2eTokenCookieName}`)
17
+ const publicRoutes: string[] = middlewareConfig.publicRoutes || ['/']
18
+ const loginPath = requireString(middlewareConfig.loginPath, 'kindeAuth.middleware.loginPath')
19
+
20
+ router.beforeEach((to) => {
21
+ if (to.path.startsWith('/api') || to.path.startsWith('/_nuxt')) {
22
+ return true
23
+ }
24
+
25
+ const isPublicRoute = publicRoutes.some(route => to.path === route || to.path.startsWith(`${route}/`))
26
+ logClient('route-check', { path: to.path, isPublicRoute })
27
+ if (isPublicRoute) {
28
+ return true
29
+ }
30
+
31
+ const hasIdToken = !!idToken.value
32
+ const hasAccessToken = !!accessToken.value
33
+ const e2eTokenValue = e2eToken.value
34
+
35
+ logClient('cookie-state', {
36
+ path: to.path,
37
+ cookiePrefix,
38
+ hasIdToken,
39
+ hasAccessToken,
40
+ hasScopedE2eToken: !!e2eTokenValue
41
+ })
42
+
43
+ if (e2eTokenValue && e2eTokenValue.startsWith(appTokenPrefix)) {
44
+ logClient('allow-e2e-app-token', { path: to.path })
45
+ return true
46
+ }
47
+
48
+ if (!hasIdToken && !hasAccessToken) {
49
+ logClient('redirect-missing-auth-cookies', { path: to.path })
50
+ window.location.href = loginPath
51
+ return false
52
+ }
53
+
54
+ const idTokenUsable = hasIdToken ? isUsableToken(idToken.value as string, appTokenPrefix, clockSkewSeconds) : false
55
+ const accessTokenUsable = hasAccessToken ? isUsableToken(accessToken.value as string, appTokenPrefix, clockSkewSeconds) : false
56
+ const isUnauthorized = !idTokenUsable && !accessTokenUsable
57
+
58
+ logClient('token-evaluation', {
59
+ path: to.path,
60
+ idTokenUsable,
61
+ accessTokenUsable
62
+ })
63
+
64
+ if (isUnauthorized) {
65
+ idToken.value = null
66
+ accessToken.value = null
67
+ logClient('redirect-all-auth-tokens-invalid-or-expired', { path: to.path })
68
+ window.location.href = loginPath
69
+ return false
70
+ }
71
+
72
+ logClient('allow-protected-route', { path: to.path })
73
+ return true
74
+ })
75
+ })
76
+
77
+ function isUsableToken(token: string, appTokenPrefix: string, clockSkewSeconds: number): boolean {
78
+ if (token.startsWith(appTokenPrefix)) {
79
+ return true
80
+ }
81
+
82
+ const payload = decodeJwtPayload(token)
83
+ if (!payload || typeof payload.exp !== 'number') {
84
+ return false
85
+ }
86
+
87
+ const nowSeconds = Math.floor(Date.now() / 1000)
88
+ return payload.exp > nowSeconds + clockSkewSeconds
89
+ }
90
+
91
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
92
+ const parts = token.split('.')
93
+ if (parts.length !== 3) {
94
+ return null
95
+ }
96
+
97
+ const payloadPart = parts[1]
98
+ if (!payloadPart) {
99
+ return null
100
+ }
101
+
102
+ try {
103
+ const padded = payloadPart.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payloadPart.length / 4) * 4, '=')
104
+ const decoded = atob(padded)
105
+ const parsed = JSON.parse(decoded)
106
+ return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ function logClient(event: string, details: Record<string, unknown>) {
113
+ if (!import.meta.dev) {
114
+ return
115
+ }
116
+
117
+ console.warn(`[AUTH GUARD CLIENT] ${event}`, details)
118
+ }
119
+
120
+ function requireString(value: unknown, key: string): string {
121
+ if (typeof value === 'string' && value.trim().length > 0) {
122
+ return value
123
+ }
124
+
125
+ throw new Error(`[AUTH GUARD CLIENT] Missing required config: ${key}`)
126
+ }
127
+
128
+ function requireNonNegativeNumber(value: unknown, key: string): number {
129
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
130
+ return value
131
+ }
132
+
133
+ throw new Error(`[AUTH GUARD CLIENT] Invalid required numeric config: ${key}`)
134
+ }
package/nuxt.config.ts CHANGED
@@ -1,6 +1,26 @@
1
1
  // https://nuxt.com/docs/guide/going-further/layers
2
- export default defineNuxtConfig({
2
+ import {
3
+ APP_TOKEN_PREFIX,
4
+ CLOCK_SKEW_SECONDS,
5
+ DEFAULT_LOGIN_PATH,
6
+ E2E_TOKEN_COOKIE_NAME,
7
+ KINDE_ACCESS_TOKEN_COOKIE_NAME,
8
+ KINDE_ID_TOKEN_COOKIE_NAME,
9
+ KINDE_REFRESH_TOKEN_COOKIE_NAME
10
+ } from './shared/auth-constants'
11
+
12
+ const AUTH_COOKIE_PREFIX = process.env.NUXT_PUBLIC_AUTH_COOKIE_PREFIX
13
+ const AUTH_LOGIN_PATH = process.env.NUXT_PUBLIC_AUTH_LOGIN_PATH || DEFAULT_LOGIN_PATH
14
+ const AUTH_CLOCK_SKEW_SECONDS = process.env.NUXT_PUBLIC_AUTH_CLOCK_SKEW_SECONDS
15
+ ? Number(process.env.NUXT_PUBLIC_AUTH_CLOCK_SKEW_SECONDS)
16
+ : CLOCK_SKEW_SECONDS
17
+ const AUTH_APP_TOKEN_PREFIX = process.env.NUXT_PUBLIC_AUTH_APP_TOKEN_PREFIX || APP_TOKEN_PREFIX
18
+ const AUTH_E2E_TOKEN_COOKIE_NAME = process.env.NUXT_PUBLIC_AUTH_E2E_TOKEN_COOKIE_NAME || E2E_TOKEN_COOKIE_NAME
19
+ const AUTH_ID_TOKEN_NAME = process.env.NUXT_PUBLIC_AUTH_ID_TOKEN_NAME || KINDE_ID_TOKEN_COOKIE_NAME
20
+ const AUTH_ACCESS_TOKEN_NAME = process.env.NUXT_PUBLIC_AUTH_ACCESS_TOKEN_NAME || KINDE_ACCESS_TOKEN_COOKIE_NAME
21
+ const AUTH_REFRESH_TOKEN_NAME = process.env.NUXT_PUBLIC_AUTH_REFRESH_TOKEN_NAME || KINDE_REFRESH_TOKEN_COOKIE_NAME
3
22
 
23
+ export default defineNuxtConfig({
4
24
  // Pre-configure shared modules that all projects will use
5
25
  modules: [
6
26
  '@nuxt/eslint',
@@ -24,17 +44,57 @@ export default defineNuxtConfig({
24
44
 
25
45
  // Expose kindeAuth config for middleware (will be merged with project config)
26
46
  kindeAuth: {
27
- // @ts-expect-error - cookie property exists in runtime but not in Kinde module types
28
47
  cookie: {
29
- prefix: 'app_' // Default, projects override this
48
+ prefix: AUTH_COOKIE_PREFIX,
49
+ idTokenName: AUTH_ID_TOKEN_NAME,
50
+ accessTokenName: AUTH_ACCESS_TOKEN_NAME,
51
+ refreshTokenName: AUTH_REFRESH_TOKEN_NAME
30
52
  },
31
53
  middleware: {
32
- publicRoutes: [] // Default, projects override this
54
+ publicRoutes: [], // Default, projects override this
55
+ loginPath: AUTH_LOGIN_PATH,
56
+ clockSkewSeconds: AUTH_CLOCK_SKEW_SECONDS,
57
+ appTokenPrefix: AUTH_APP_TOKEN_PREFIX,
58
+ e2eTokenCookieName: AUTH_E2E_TOKEN_COOKIE_NAME
33
59
  }
34
- }
60
+ } as Record<string, unknown>
35
61
  }
36
62
  },
37
63
  compatibilityDate: '2025-01-17',
64
+ hooks: {
65
+ ready(nuxt) {
66
+ const publicConfig = nuxt.options.runtimeConfig.public as Record<string, unknown>
67
+ const runtimeKindeAuth = (publicConfig.kindeAuth || {}) as Record<string, unknown>
68
+ const runtimeCookie = (runtimeKindeAuth.cookie || {}) as Record<string, unknown>
69
+ const runtimeMiddleware = (runtimeKindeAuth.middleware || {}) as Record<string, unknown>
70
+ const kindeModuleConfig = (nuxt.options.kindeAuth || {}) as Record<string, unknown>
71
+ const kindeModuleCookie = (kindeModuleConfig.cookie || {}) as Record<string, unknown>
72
+ const kindeModuleMiddleware = (kindeModuleConfig.middleware || {}) as Record<string, unknown>
73
+
74
+ // Keep layer reusable: if app defines values only in kindeAuth module config,
75
+ // mirror them into runtime config used by guards/composables.
76
+ if (!isNonEmptyString(runtimeCookie.prefix) && isNonEmptyString(kindeModuleCookie.prefix)) {
77
+ runtimeCookie.prefix = kindeModuleCookie.prefix
78
+ }
79
+ if (!Array.isArray(runtimeMiddleware.publicRoutes) && Array.isArray(kindeModuleMiddleware.publicRoutes)) {
80
+ runtimeMiddleware.publicRoutes = kindeModuleMiddleware.publicRoutes
81
+ }
82
+
83
+ runtimeKindeAuth.cookie = runtimeCookie
84
+ runtimeKindeAuth.middleware = runtimeMiddleware
85
+ publicConfig.kindeAuth = runtimeKindeAuth
86
+
87
+ assertRequiredString(runtimeCookie.prefix, 'runtimeConfig.public.kindeAuth.cookie.prefix')
88
+ // Optional values are defaulted in the layer; only validate final resolved values.
89
+ assertRequiredNumber(runtimeMiddleware.clockSkewSeconds, 'runtimeConfig.public.kindeAuth.middleware.clockSkewSeconds')
90
+ assertRequiredString(kindeModuleCookie.prefix, 'kindeAuth.cookie.prefix')
91
+ assertRequiredString(kindeModuleConfig.authDomain, 'kindeAuth.authDomain')
92
+ assertRequiredString(kindeModuleConfig.clientId, 'kindeAuth.clientId')
93
+ assertRequiredString(kindeModuleConfig.clientSecret, 'kindeAuth.clientSecret')
94
+ assertRequiredString(kindeModuleConfig.redirectURL, 'kindeAuth.redirectURL')
95
+ assertRequiredString(kindeModuleConfig.logoutRedirectURL, 'kindeAuth.logoutRedirectURL')
96
+ }
97
+ },
38
98
 
39
99
  // ESLint configuration
40
100
  eslint: {
@@ -48,14 +108,14 @@ export default defineNuxtConfig({
48
108
 
49
109
  // Default Kinde configuration (projects MUST override with their credentials)
50
110
  kindeAuth: {
51
- authDomain: process.env.KINDE_AUTH_DOMAIN || 'https://dummy.kinde.com', // Project must provide, dummy for build
52
- clientId: process.env.KINDE_CLIENT_ID || 'dummy-client-id', // Project must provide, dummy for build
53
- clientSecret: process.env.KINDE_CLIENT_SECRET || 'dummy-secret', // Project must provide, dummy for build
54
- redirectURL: process.env.KINDE_REDIRECT_URL || 'http://localhost:3000/api/auth/kinde_callback', // Project must provide, dummy for build
55
- logoutRedirectURL: process.env.KINDE_LOGOUT_REDIRECT_URL || 'http://localhost:3000', // Project must provide, dummy for build
111
+ authDomain: process.env.KINDE_AUTH_DOMAIN,
112
+ clientId: process.env.KINDE_CLIENT_ID,
113
+ clientSecret: process.env.KINDE_CLIENT_SECRET,
114
+ redirectURL: process.env.KINDE_REDIRECT_URL,
115
+ logoutRedirectURL: process.env.KINDE_LOGOUT_REDIRECT_URL,
56
116
  postLoginRedirectURL: '/dashboard', // Default, can be overridden
57
117
  cookie: {
58
- prefix: 'app_', // Projects MUST override this
118
+ prefix: AUTH_COOKIE_PREFIX,
59
119
  httpOnly: false, // Allow client-side deletion for logout
60
120
  secure: process.env.NODE_ENV === 'production',
61
121
  sameSite: 'lax' as const,
@@ -75,3 +135,19 @@ export default defineNuxtConfig({
75
135
  // Pinia configuration
76
136
  pinia: {}
77
137
  })
138
+
139
+ function assertRequiredString(value: unknown, key: string) {
140
+ if (typeof value !== 'string' || value.trim().length === 0) {
141
+ throw new Error(`[nuxt-symfony-kinde-layer] Missing required config: ${key}`)
142
+ }
143
+ }
144
+
145
+ function assertRequiredNumber(value: unknown, key: string) {
146
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
147
+ throw new Error(`[nuxt-symfony-kinde-layer] Missing or invalid required numeric config: ${key}`)
148
+ }
149
+ }
150
+
151
+ function isNonEmptyString(value: unknown): value is string {
152
+ return typeof value === 'string' && value.trim().length > 0
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@habityzer/nuxt-symfony-kinde-layer",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "Shared Nuxt layer for Symfony + Kinde authentication integration",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -9,7 +9,9 @@
9
9
  "build": "nuxt build",
10
10
  "lint": "eslint .",
11
11
  "lint:fix": "eslint . --fix",
12
- "release": " HUSKY=0 semantic-release"
12
+ "prepare": "husky || true",
13
+ "prepare:ci": "pnpm install && KINDE_AUTH_DOMAIN=https://placeholder.kinde.com KINDE_CLIENT_ID=placeholder KINDE_CLIENT_SECRET=placeholder KINDE_REDIRECT_URL=http://localhost:3000/api/auth/callback KINDE_LOGOUT_REDIRECT_URL=http://localhost:3000 NUXT_PUBLIC_AUTH_COOKIE_PREFIX=auth_ NUXT_PUBLIC_AUTH_LOGIN_PATH=/login NUXT_PUBLIC_AUTH_CLOCK_SKEW_SECONDS=300 NUXT_PUBLIC_AUTH_APP_TOKEN_PREFIX=Bearer NUXT_PUBLIC_AUTH_E2E_TOKEN_COOKIE_NAME=e2e_token NUXT_PUBLIC_AUTH_ID_TOKEN_NAME=id_token NUXT_PUBLIC_AUTH_ACCESS_TOKEN_NAME=access_token NUXT_PUBLIC_AUTH_REFRESH_TOKEN_NAME=refresh_token pnpm nuxt prepare",
14
+ "release": "HUSKY=0 semantic-release"
13
15
  },
14
16
  "keywords": [
15
17
  "nuxt",
@@ -45,7 +47,8 @@
45
47
  "openapi-typescript": "^7.10.0",
46
48
  "typescript": "^5.9.3",
47
49
  "eslint": "^9.37.0",
48
- "semantic-release": "^24.2.9"
50
+ "semantic-release": "^24.2.9",
51
+ "husky": "^9.0.0"
49
52
  },
50
53
  "peerDependencies": {
51
54
  "nuxt": "^3.0.0 || ^4.0.0"
@@ -12,13 +12,14 @@
12
12
  * @see .cursorrules for proxy best practices
13
13
  */
14
14
 
15
- // Auth constants (defined inline to avoid import issues during Nitro bundling)
16
- const E2E_TOKEN_COOKIE_NAME = 'kinde_token'
17
- const APP_TOKEN_PREFIX = 'app_'
18
- const KINDE_ID_TOKEN_COOKIE_NAME = 'id_token'
19
-
20
15
  export default defineEventHandler(async (event) => {
21
16
  const config = useRuntimeConfig()
17
+ const kindeConfig = config.public.kindeAuth || {}
18
+ const middlewareConfig = kindeConfig.middleware || {}
19
+ const cookieConfig = kindeConfig.cookie || {}
20
+ const appTokenPrefix = requireString(middlewareConfig.appTokenPrefix, 'kindeAuth.middleware.appTokenPrefix')
21
+ const e2eTokenCookieName = requireString(middlewareConfig.e2eTokenCookieName, 'kindeAuth.middleware.e2eTokenCookieName')
22
+ const idTokenBaseName = requireString(cookieConfig.idTokenName, 'kindeAuth.cookie.idTokenName')
22
23
 
23
24
  // Get the path (remove /api/symfony prefix)
24
25
  let path
@@ -50,8 +51,11 @@ export default defineEventHandler(async (event) => {
50
51
  } else {
51
52
  // Check for E2E test token first (from cookie)
52
53
  // Only use E2E token if it's a valid app token (starts with APP_TOKEN_PREFIX)
53
- const e2eToken = getCookie(event, E2E_TOKEN_COOKIE_NAME)
54
- if (e2eToken && e2eToken.startsWith(APP_TOKEN_PREFIX)) {
54
+ // Prefer scoped cookie name to avoid collisions between projects on localhost.
55
+ const cookiePrefix = requireString(cookieConfig.prefix, 'kindeAuth.cookie.prefix')
56
+ const scopedE2eCookieName = `${cookiePrefix}${e2eTokenCookieName}`
57
+ const e2eToken = getCookie(event, scopedE2eCookieName)
58
+ if (e2eToken && e2eToken.startsWith(appTokenPrefix)) {
55
59
  token = e2eToken
56
60
  } else {
57
61
  // Use Kinde authentication from the module
@@ -79,7 +83,7 @@ export default defineEventHandler(async (event) => {
79
83
  // If access token is not available, try id_token as fallback
80
84
  if (!accessToken || accessToken.trim() === '') {
81
85
  const idToken = (await sessionManager.getSessionItem(
82
- KINDE_ID_TOKEN_COOKIE_NAME
86
+ idTokenBaseName
83
87
  )) as string | undefined
84
88
 
85
89
  if (idToken) {
@@ -220,3 +224,11 @@ export default defineEventHandler(async (event) => {
220
224
  })
221
225
  }
222
226
  })
227
+
228
+ function requireString(value: unknown, key: string): string {
229
+ if (typeof value === 'string' && value.trim().length > 0) {
230
+ return value
231
+ }
232
+
233
+ throw new Error(`[SYMFONY PROXY] Missing required config: ${key}`)
234
+ }
@@ -0,0 +1,144 @@
1
+ export default defineEventHandler((event) => {
2
+ if (!isHtmlNavigationRequest(event)) {
3
+ return
4
+ }
5
+
6
+ const path = getRequestURL(event).pathname
7
+ if (!path || path.startsWith('/api') || path.startsWith('/_nuxt') || path.startsWith('/__nuxt') || path.startsWith('/_ipx')) {
8
+ return
9
+ }
10
+
11
+ const config = useRuntimeConfig(event)
12
+ const kindeConfig = config.public.kindeAuth || {}
13
+ const middlewareConfig = kindeConfig.middleware || {}
14
+ const cookieConfig = kindeConfig.cookie || {}
15
+ const publicRoutes: string[] = middlewareConfig.publicRoutes || ['/']
16
+ const loginPath = requireString(middlewareConfig.loginPath, 'kindeAuth.middleware.loginPath')
17
+ const appTokenPrefix = requireString(middlewareConfig.appTokenPrefix, 'kindeAuth.middleware.appTokenPrefix')
18
+ const e2eTokenCookieName = requireString(middlewareConfig.e2eTokenCookieName, 'kindeAuth.middleware.e2eTokenCookieName')
19
+ const clockSkewSeconds = requireNonNegativeNumber(middlewareConfig.clockSkewSeconds, 'kindeAuth.middleware.clockSkewSeconds')
20
+ const idTokenBaseName = requireString(cookieConfig.idTokenName, 'kindeAuth.cookie.idTokenName')
21
+ const accessTokenBaseName = requireString(cookieConfig.accessTokenName, 'kindeAuth.cookie.accessTokenName')
22
+ const isPublicRoute = publicRoutes.some(route => path === route || path.startsWith(`${route}/`))
23
+
24
+ logServer('route-check', { path, isPublicRoute })
25
+ if (isPublicRoute) {
26
+ return
27
+ }
28
+
29
+ const cookiePrefix = requireString(cookieConfig.prefix, 'kindeAuth.cookie.prefix')
30
+ const idTokenName = `${cookiePrefix}${idTokenBaseName}`
31
+ const accessTokenName = `${cookiePrefix}${accessTokenBaseName}`
32
+ const e2eTokenName = `${cookiePrefix}${e2eTokenCookieName}`
33
+
34
+ const idToken = getCookie(event, idTokenName)
35
+ const accessToken = getCookie(event, accessTokenName)
36
+ const e2eToken = getCookie(event, e2eTokenName)
37
+
38
+ const hasIdToken = !!idToken
39
+ const hasAccessToken = !!accessToken
40
+ logServer('cookie-state', {
41
+ path,
42
+ cookiePrefix,
43
+ hasIdToken,
44
+ hasAccessToken,
45
+ hasScopedE2eToken: !!e2eToken
46
+ })
47
+
48
+ if (e2eToken && e2eToken.startsWith(appTokenPrefix)) {
49
+ logServer('allow-e2e-app-token', { path })
50
+ return
51
+ }
52
+
53
+ if (!hasIdToken && !hasAccessToken) {
54
+ logServer('redirect-missing-auth-cookies', { path })
55
+ return sendRedirect(event, loginPath, 302)
56
+ }
57
+
58
+ const idTokenUsable = hasIdToken ? isUsableToken(idToken as string, appTokenPrefix, clockSkewSeconds) : false
59
+ const accessTokenUsable = hasAccessToken ? isUsableToken(accessToken as string, appTokenPrefix, clockSkewSeconds) : false
60
+ const isUnauthorized = !idTokenUsable && !accessTokenUsable
61
+
62
+ logServer('token-evaluation', {
63
+ path,
64
+ idTokenUsable,
65
+ accessTokenUsable
66
+ })
67
+
68
+ if (isUnauthorized) {
69
+ setCookie(event, idTokenName, '', { path: '/', maxAge: 0 })
70
+ setCookie(event, accessTokenName, '', { path: '/', maxAge: 0 })
71
+ logServer('redirect-all-auth-tokens-invalid-or-expired', { path })
72
+ return sendRedirect(event, loginPath, 302)
73
+ }
74
+
75
+ logServer('allow-protected-route', { path })
76
+ })
77
+
78
+ function isHtmlNavigationRequest(event: Parameters<typeof defineEventHandler>[0]): boolean {
79
+ if (event.method !== 'GET' && event.method !== 'HEAD') {
80
+ return false
81
+ }
82
+
83
+ const accept = getHeader(event, 'accept') || ''
84
+ return accept.includes('text/html') || accept.includes('*/*')
85
+ }
86
+
87
+ function isUsableToken(token: string, appTokenPrefix: string, clockSkewSeconds: number): boolean {
88
+ if (token.startsWith(appTokenPrefix)) {
89
+ return true
90
+ }
91
+
92
+ const payload = decodeJwtPayload(token)
93
+ if (!payload || typeof payload.exp !== 'number') {
94
+ return false
95
+ }
96
+
97
+ const nowSeconds = Math.floor(Date.now() / 1000)
98
+ return payload.exp > nowSeconds + clockSkewSeconds
99
+ }
100
+
101
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
102
+ const parts = token.split('.')
103
+ if (parts.length !== 3) {
104
+ return null
105
+ }
106
+
107
+ const payloadPart = parts[1]
108
+ if (!payloadPart) {
109
+ return null
110
+ }
111
+
112
+ try {
113
+ const padded = payloadPart.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payloadPart.length / 4) * 4, '=')
114
+ const decoded = Buffer.from(padded, 'base64').toString('utf8')
115
+ const parsed = JSON.parse(decoded)
116
+ return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ function logServer(event: string, details: Record<string, unknown>) {
123
+ if (process.env.NODE_ENV === 'production') {
124
+ return
125
+ }
126
+
127
+ console.warn(`[AUTH GUARD SERVER] ${event}`, details)
128
+ }
129
+
130
+ function requireString(value: unknown, key: string): string {
131
+ if (typeof value === 'string' && value.trim().length > 0) {
132
+ return value
133
+ }
134
+
135
+ throw new Error(`[AUTH GUARD SERVER] Missing required config: ${key}`)
136
+ }
137
+
138
+ function requireNonNegativeNumber(value: unknown, key: string): number {
139
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
140
+ return value
141
+ }
142
+
143
+ throw new Error(`[AUTH GUARD SERVER] Invalid required numeric config: ${key}`)
144
+ }
@@ -1,23 +1,9 @@
1
- /**
2
- * Authentication constants for server-side code
3
- * These are duplicated from app/constants/auth.ts to avoid import issues in Nitro bundling
4
- */
5
-
6
- /**
7
- * Cookie name for E2E test authentication token
8
- * Used by automated tests to bypass Kinde OAuth flow
9
- */
10
- export const E2E_TOKEN_COOKIE_NAME = 'kinde_token'
11
-
12
- /**
13
- * Prefix for Symfony app tokens (used in E2E tests)
14
- * These are long-lived tokens generated by `php bin/console app:token:manage create`
15
- */
16
- export const APP_TOKEN_PREFIX = 'app_'
17
-
18
- /**
19
- * Kinde authentication cookie names (base names without prefix)
20
- * The prefix is configured per-project in nuxt.config.ts and applied dynamically
21
- */
22
- export const KINDE_ID_TOKEN_COOKIE_NAME = 'id_token'
23
- export const KINDE_ACCESS_TOKEN_COOKIE_NAME = 'access_token'
1
+ export {
2
+ APP_TOKEN_PREFIX,
3
+ CLOCK_SKEW_SECONDS,
4
+ DEFAULT_LOGIN_PATH,
5
+ E2E_TOKEN_COOKIE_NAME,
6
+ KINDE_ACCESS_TOKEN_COOKIE_NAME,
7
+ KINDE_ID_TOKEN_COOKIE_NAME,
8
+ KINDE_REFRESH_TOKEN_COOKIE_NAME
9
+ } from '../../shared/auth-constants'
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared authentication constants for both app and server code.
3
+ */
4
+ export const DEFAULT_LOGIN_PATH = '/api/kinde/login'
5
+ export const CLOCK_SKEW_SECONDS = 30
6
+
7
+ export const E2E_TOKEN_COOKIE_NAME = 'kinde_token'
8
+ export const APP_TOKEN_PREFIX = 'app_'
9
+
10
+ export const KINDE_ID_TOKEN_COOKIE_NAME = 'id_token'
11
+ export const KINDE_ACCESS_TOKEN_COOKIE_NAME = 'access_token'
12
+ export const KINDE_REFRESH_TOKEN_COOKIE_NAME = 'refresh_token'
@@ -1,82 +0,0 @@
1
- import { E2E_TOKEN_COOKIE_NAME, KINDE_ID_TOKEN_COOKIE_NAME, KINDE_ACCESS_TOKEN_COOKIE_NAME } from '../constants/auth'
2
-
3
- /**
4
- * Global auth middleware - checks authentication on route navigation
5
- * Redirects to login if accessing protected routes without authentication
6
- *
7
- * Note: This middleware works alongside the nuxt-kinde-auth module.
8
- * It handles E2E testing tokens and uses the module's login endpoints.
9
- *
10
- * Projects should configure publicRoutes in their nuxt.config.ts:
11
- * kindeAuth: {
12
- * middleware: {
13
- * publicRoutes: ['/', '/blog', '/help']
14
- * }
15
- * }
16
- */
17
- export default defineNuxtRouteMiddleware(async (to) => {
18
- // Get public routes from runtime config (configured per-project)
19
- const config = useRuntimeConfig()
20
- const kindeConfig = config.public.kindeAuth || {}
21
- const publicRoutes: string[] = kindeConfig.middleware?.publicRoutes || ['/']
22
-
23
- // Check if the route is public or a child of public routes
24
- const isPublicRoute = publicRoutes.some(route =>
25
- to.path === route || to.path.startsWith(`${route}/`)
26
- )
27
-
28
- // If it's a public route, allow access
29
- if (isPublicRoute) {
30
- return
31
- }
32
-
33
- // For protected routes, check authentication
34
- if (import.meta.server) {
35
- // Server-side: Check for auth cookies using Nuxt's useCookie
36
- // Note: Cookie names include the project-specific prefix
37
- const config = useRuntimeConfig()
38
- // @ts-expect-error - cookie property exists in runtime config but not in Kinde module types
39
- const cookiePrefix = config.public.kindeAuth?.cookie?.prefix || 'app_'
40
-
41
- const idTokenName = `${cookiePrefix}${KINDE_ID_TOKEN_COOKIE_NAME}`
42
- const accessTokenName = `${cookiePrefix}${KINDE_ACCESS_TOKEN_COOKIE_NAME}`
43
-
44
- const idToken = useCookie(idTokenName)
45
- const accessToken = useCookie(accessTokenName)
46
- const e2eToken = useCookie(E2E_TOKEN_COOKIE_NAME) // E2E test token
47
-
48
- // Allow access if any valid auth token exists
49
- if (!idToken.value && !accessToken.value && !e2eToken.value) {
50
- // Redirect to module's login endpoint
51
- return navigateTo('/api/kinde/login', { external: true })
52
- }
53
- } else {
54
- // Client-side: Check for E2E token first (for tests)
55
- const e2eToken = useCookie(E2E_TOKEN_COOKIE_NAME)
56
-
57
- // If E2E token exists, allow access (for automated tests)
58
- if (e2eToken.value) {
59
- return
60
- }
61
-
62
- // Check for auth cookies directly (more reliable than reactive state)
63
- const config = useRuntimeConfig()
64
- // @ts-expect-error - cookie property exists in runtime config but not in Kinde module types
65
- const cookiePrefix = config.public.kindeAuth?.cookie?.prefix || 'app_'
66
-
67
- const idTokenName = `${cookiePrefix}${KINDE_ID_TOKEN_COOKIE_NAME}`
68
- const accessTokenName = `${cookiePrefix}${KINDE_ACCESS_TOKEN_COOKIE_NAME}`
69
-
70
- const idToken = useCookie(idTokenName)
71
- const accessToken = useCookie(accessTokenName)
72
-
73
- // Allow access if any valid auth token cookie exists
74
- if (!idToken.value && !accessToken.value) {
75
- // Redirect to module's login endpoint
76
- if (import.meta.client) {
77
- window.location.href = '/api/kinde/login'
78
- }
79
- return
80
- }
81
- }
82
- })