@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 +17 -0
- package/.github/workflows/publish.yml +14 -0
- package/.husky/commit-msg +5 -0
- package/.husky/pre-commit +32 -0
- package/CHANGELOG.md +8 -0
- package/README.md +77 -0
- package/app/composables/useAuth.ts +33 -2
- package/app/constants/auth.ts +9 -26
- package/app/plugins/auth-guard.client.ts +134 -0
- package/nuxt.config.ts +87 -11
- package/package.json +6 -3
- package/server/api/symfony/[...].ts +20 -8
- package/server/middleware/auth-guard.ts +144 -0
- package/server/utils/auth-constants.ts +9 -23
- package/shared/auth-constants.ts +12 -0
- package/app/middleware/auth.global.ts +0 -82
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,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
|
|
110
|
+
// Clear auth cookies first so route middleware blocks protected pages immediately.
|
|
109
111
|
if (import.meta.client) {
|
|
110
|
-
|
|
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
|
+
}
|
package/app/constants/auth.ts
CHANGED
|
@@ -1,26 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
52
|
-
clientId: process.env.KINDE_CLIENT_ID
|
|
53
|
-
clientSecret: process.env.KINDE_CLIENT_SECRET
|
|
54
|
-
redirectURL: process.env.KINDE_REDIRECT_URL
|
|
55
|
-
logoutRedirectURL: process.env.KINDE_LOGOUT_REDIRECT_URL
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
})
|