@innertia-solutions/innertia-nuxt 0.1.59 → 0.1.60
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/composables/useApi.js +8 -0
- package/composables/useAuth.js +14 -13
- package/composables/useContext.ts +6 -1
- package/nuxt.config.ts +4 -0
- package/package.json +5 -1
- package/plugins/api-auth.ts +4 -7
- package/plugins/auth-init.ts +17 -28
- package/server/middleware/auth-session.ts +13 -0
- package/server/routes/_auth/[provider]/callback.get.ts +25 -0
- package/server/routes/_auth/context.put.ts +11 -0
- package/server/routes/_auth/login.post.ts +25 -0
- package/server/routes/_auth/logout.post.ts +18 -0
- package/server/routes/_auth/session.get.ts +8 -0
- package/server/routes/api/[...path].ts +15 -0
- package/server/utils/__tests__/bff.test.ts +22 -0
- package/server/utils/__tests__/login.test.ts +16 -0
- package/server/utils/__tests__/session.test.ts +17 -0
- package/server/utils/__tests__/smoke.test.ts +5 -0
- package/server/utils/bff.ts +16 -0
- package/server/utils/login.ts +21 -0
- package/server/utils/session.ts +53 -0
- package/stores/auth.js +7 -47
- package/vitest.config.ts +8 -0
package/composables/useApi.js
CHANGED
|
@@ -42,6 +42,14 @@ export function useApi() {
|
|
|
42
42
|
}
|
|
43
43
|
run(headers, options)
|
|
44
44
|
|
|
45
|
+
// SSR: adjuntar el Bearer desde la sesión sellada del request activo
|
|
46
|
+
// (el BFF no interviene en llamadas server-side directas a Laravel).
|
|
47
|
+
if (import.meta.server) {
|
|
48
|
+
const event = useRequestEvent()
|
|
49
|
+
const token = event?.context?.authToken
|
|
50
|
+
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
|
46
54
|
let url = `${baseUrl}/${cleanPath}`
|
|
47
55
|
|
package/composables/useAuth.js
CHANGED
|
@@ -14,9 +14,13 @@ export function useAuth() {
|
|
|
14
14
|
*/
|
|
15
15
|
async function performLogin(context, email, password, remember = false) {
|
|
16
16
|
authStore.rememberUser = remember
|
|
17
|
-
//
|
|
18
|
-
const data = await
|
|
19
|
-
|
|
17
|
+
// Sella la sesión en el server (Nitro). Devuelve user+contexts, SIN token.
|
|
18
|
+
const data = await $fetch('/_auth/login', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
body: { context, email, password, remember },
|
|
21
|
+
})
|
|
22
|
+
authStore.setAuthenticated(true)
|
|
23
|
+
if (data.user) authStore.saveUser(data.user)
|
|
20
24
|
authStore.setCurrentContext(context)
|
|
21
25
|
queryClient.clear()
|
|
22
26
|
await fetchMe()
|
|
@@ -69,13 +73,9 @@ export function useAuth() {
|
|
|
69
73
|
* Logout: best-effort POST to backend, then clear local state and redirect.
|
|
70
74
|
*/
|
|
71
75
|
async function logout() {
|
|
72
|
-
try {
|
|
73
|
-
await api.post('auth/logout', {})
|
|
74
|
-
} catch {
|
|
75
|
-
// best-effort — ignore network failures
|
|
76
|
-
}
|
|
76
|
+
try { await $fetch('/_auth/logout', { method: 'POST' }) } catch { /* best-effort */ }
|
|
77
77
|
queryClient.clear()
|
|
78
|
-
authStore.logout()
|
|
78
|
+
authStore.logout() // limpia el state en memoria
|
|
79
79
|
organizationStore.reset()
|
|
80
80
|
// En open, limpiar también el tenant seleccionado
|
|
81
81
|
if (useInnertiaMode().isOpen()) useActiveTenantStore().reset()
|
|
@@ -87,10 +87,11 @@ export function useAuth() {
|
|
|
87
87
|
* Returns the URL string from the backend.
|
|
88
88
|
*/
|
|
89
89
|
async function getOauthRedirectUrl(context, provider) {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
const
|
|
93
|
-
|
|
90
|
+
// Laravel arma la URL de Google, pero el redirect_uri debe volver a la ruta
|
|
91
|
+
// Nitro que sella la sesión: /_auth/{provider}/callback
|
|
92
|
+
const redirectUri = `${window.location.origin}/_auth/${provider}/callback?context=${context}`
|
|
93
|
+
const data = await api.get(`auth/${provider}/redirect`, { params: { context, redirect_uri: redirectUri } })
|
|
94
|
+
return data.url ?? data
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
return { performLogin, fetchMe, logout, getOauthRedirectUrl }
|
|
@@ -63,7 +63,12 @@ export function useContext() {
|
|
|
63
63
|
* Updates store and reloads permissions via fetchMe.
|
|
64
64
|
*/
|
|
65
65
|
async function confirmSwitch(targetContext: string) {
|
|
66
|
-
|
|
66
|
+
const context = targetContext
|
|
67
|
+
authStore.setCurrentContext(context)
|
|
68
|
+
// Sincronizar el contexto activo en la sesión sellada (para SSR).
|
|
69
|
+
if (import.meta.client) {
|
|
70
|
+
try { await $fetch('/_auth/context', { method: 'PUT', body: { context } }) } catch { /* best-effort */ }
|
|
71
|
+
}
|
|
67
72
|
await fetchMe()
|
|
68
73
|
return { success: true }
|
|
69
74
|
}
|
package/nuxt.config.ts
CHANGED
|
@@ -22,6 +22,10 @@ export default defineNuxtConfig({
|
|
|
22
22
|
],
|
|
23
23
|
},
|
|
24
24
|
runtimeConfig: {
|
|
25
|
+
// Privado (solo server). Llenado desde NUXT_SESSION_PASSWORD (≥32 chars).
|
|
26
|
+
session: {
|
|
27
|
+
password: '',
|
|
28
|
+
},
|
|
25
29
|
public: {
|
|
26
30
|
loginPath: '/backoffice/login',
|
|
27
31
|
homePath: '/backoffice',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@innertia-solutions/innertia-nuxt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
4
4
|
"description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|
|
@@ -56,11 +56,15 @@
|
|
|
56
56
|
"vanilla-calendar-pro": "^3.1.0",
|
|
57
57
|
"vue3-apexcharts": "^1.11.1"
|
|
58
58
|
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"test": "vitest run"
|
|
61
|
+
},
|
|
59
62
|
"devDependencies": {
|
|
60
63
|
"@pinia/nuxt": "^0.11.3",
|
|
61
64
|
"nuxt": "^4.4.2",
|
|
62
65
|
"pinia": "^3.0.4",
|
|
63
66
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
67
|
+
"vitest": "^2",
|
|
64
68
|
"vue": "^3.5.0"
|
|
65
69
|
}
|
|
66
70
|
}
|
package/plugins/api-auth.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
// Registers the Authorization header interceptor into the shared registry.
|
|
2
|
-
// Universal: useCookie reads request cookies in SSR and document.cookie on client.
|
|
3
1
|
export default defineNuxtPlugin(() => {
|
|
4
2
|
const { add } = useRequestInterceptors()
|
|
5
|
-
|
|
6
3
|
add((headers: Record<string, string>, options: Record<string, unknown>) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// Authorization lo inyecta el BFF (server) desde la sesión sellada.
|
|
5
|
+
// Aquí quedan solo headers no-sensibles (p. ej. multitenancy) si aplica.
|
|
6
|
+
void options
|
|
7
|
+
void headers
|
|
11
8
|
})
|
|
12
9
|
})
|
package/plugins/auth-init.ts
CHANGED
|
@@ -1,34 +1,23 @@
|
|
|
1
|
-
// Universal
|
|
2
|
-
//
|
|
3
|
-
// into the Nuxt payload and transferred to the client → no hydration mismatch.
|
|
4
|
-
// Avoids useQueryClient() because vue-query.ts runs after this plugin alphabetically.
|
|
5
|
-
// Universal plugin: runs on both server (SSR) and client.
|
|
6
|
-
// On SSR: token is read from cookie → fetches user from API → pinia state is serialized
|
|
7
|
-
// into the Nuxt payload and transferred to the client → no hydration mismatch.
|
|
8
|
-
// Avoids useQueryClient() because vue-query.ts runs after this plugin alphabetically.
|
|
1
|
+
// Universal (server+client). Rehidrata la sesión sin tocar el token (vive en la
|
|
2
|
+
// cookie sellada, solo accesible por el BFF).
|
|
9
3
|
export default defineNuxtPlugin(async () => {
|
|
10
4
|
const authStore = useAuthStore()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
authStore.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const data = await api.get('auth/me')
|
|
25
|
-
if (data) {
|
|
26
|
-
authStore.saveUser(data.user ?? data)
|
|
27
|
-
authStore.savePermissions(data.permissions ?? [])
|
|
28
|
-
authStore.availableContexts = data.availableContexts ?? []
|
|
5
|
+
const api = useApi()
|
|
6
|
+
const requestFetch = useRequestFetch()
|
|
7
|
+
try {
|
|
8
|
+
const session = await requestFetch('/_auth/session')
|
|
9
|
+
if (!session?.authenticated) { authStore.setAuthenticated(false); return }
|
|
10
|
+
authStore.setAuthenticated(true)
|
|
11
|
+
if (session.currentContext) authStore.currentContext = session.currentContext
|
|
12
|
+
if (!authStore.user) {
|
|
13
|
+
const me = await api.get('auth/me')
|
|
14
|
+
if (me) {
|
|
15
|
+
authStore.saveUser(me.user ?? me)
|
|
16
|
+
authStore.savePermissions(me.permissions ?? [])
|
|
17
|
+
authStore.availableContexts = me.availableContexts ?? me.contexts ?? []
|
|
29
18
|
}
|
|
30
|
-
} catch {
|
|
31
|
-
if (import.meta.client) authStore.logout()
|
|
32
19
|
}
|
|
20
|
+
} catch {
|
|
21
|
+
authStore.setAuthenticated(false)
|
|
33
22
|
}
|
|
34
23
|
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readSession } from '../utils/session'
|
|
2
|
+
|
|
3
|
+
// Lee la sesión sellada una vez por request y deja el token en event.context
|
|
4
|
+
// para que useApi (SSR) adjunte el Bearer en llamadas directas a Laravel.
|
|
5
|
+
// Defensivo: si no hay secret/sesión, sigue sin token (no revienta el request).
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
try {
|
|
8
|
+
const { token } = await readSession(event)
|
|
9
|
+
if (token) event.context.authToken = token
|
|
10
|
+
} catch {
|
|
11
|
+
// sin NUXT_SESSION_PASSWORD o sin sesión válida → seguir sin token
|
|
12
|
+
}
|
|
13
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineEventHandler, getRouterParam, getQuery, sendRedirect, createError } from 'h3'
|
|
2
|
+
import { writeSession } from '../../../utils/session'
|
|
3
|
+
import { normalizeLoginResponse } from '../../../utils/login'
|
|
4
|
+
|
|
5
|
+
// Recibe el callback de OAuth, intercambia contra Laravel, sella la sesión y
|
|
6
|
+
// redirige a la app. El token nunca llega al browser.
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
const provider = getRouterParam(event, 'provider')
|
|
9
|
+
const query = getQuery(event)
|
|
10
|
+
const config = useRuntimeConfig(event)
|
|
11
|
+
const context = (query.context as string) || 'backoffice'
|
|
12
|
+
let data: any
|
|
13
|
+
try {
|
|
14
|
+
data = await $fetch(`${config.apiInternalUrl}/api/auth/${provider}/callback`, {
|
|
15
|
+
method: 'GET', query, headers: { Accept: 'application/json' },
|
|
16
|
+
})
|
|
17
|
+
} catch (err: any) {
|
|
18
|
+
const msg = err?.data?.message ?? 'oauth_failed'
|
|
19
|
+
return sendRedirect(event, `${config.public.loginPath}?social_error=${encodeURIComponent(msg)}`)
|
|
20
|
+
}
|
|
21
|
+
const { token } = normalizeLoginResponse(data)
|
|
22
|
+
if (!token) throw createError({ statusCode: 502, statusMessage: 'oauth_no_token' })
|
|
23
|
+
await writeSession(event, { token, currentContext: context })
|
|
24
|
+
return sendRedirect(event, config.public.homePath)
|
|
25
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineEventHandler, readBody, createError } from 'h3'
|
|
2
|
+
import { readSession, writeSession } from '../../utils/session'
|
|
3
|
+
|
|
4
|
+
// Cambia el contexto activo en la sesión (el modal "¿seguro?" es UX de front).
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const { context } = await readBody(event)
|
|
7
|
+
const current = await readSession(event)
|
|
8
|
+
if (!current.token) throw createError({ statusCode: 401, statusMessage: 'no_session' })
|
|
9
|
+
await writeSession(event, { ...current, currentContext: context })
|
|
10
|
+
return { ok: true, currentContext: context }
|
|
11
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineEventHandler, readBody, createError } from 'h3'
|
|
2
|
+
import { writeSession } from '../../utils/session'
|
|
3
|
+
import { normalizeLoginResponse } from '../../utils/login'
|
|
4
|
+
|
|
5
|
+
// Llama a Laravel /auth/login server-to-server; en éxito sella el JWT en la
|
|
6
|
+
// sesión y devuelve el body SIN token. Propaga el status de Laravel (403 = el
|
|
7
|
+
// usuario no posee ese contexto → el probing del cliente prueba el siguiente).
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
const { context, email, password, remember } = await readBody(event)
|
|
10
|
+
const config = useRuntimeConfig(event)
|
|
11
|
+
let data: any
|
|
12
|
+
try {
|
|
13
|
+
data = await $fetch(`${config.apiInternalUrl}/api/auth/login`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
body: { context, email, password, remember },
|
|
16
|
+
headers: { Accept: 'application/json' },
|
|
17
|
+
})
|
|
18
|
+
} catch (err: any) {
|
|
19
|
+
const status = err?.response?.status ?? err?.statusCode ?? 500
|
|
20
|
+
throw createError({ statusCode: status, data: err?.data ?? null, statusMessage: 'login_failed' })
|
|
21
|
+
}
|
|
22
|
+
const { token, body } = normalizeLoginResponse(data)
|
|
23
|
+
await writeSession(event, { token, currentContext: context })
|
|
24
|
+
return body
|
|
25
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineEventHandler } from 'h3'
|
|
2
|
+
import { readSession, clearSession } from '../../utils/session'
|
|
3
|
+
import { buildAuthHeaders } from '../../utils/bff'
|
|
4
|
+
|
|
5
|
+
// Best-effort: avisa a Laravel con el Bearer de la sesión, luego limpia la sesión.
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const config = useRuntimeConfig(event)
|
|
8
|
+
const { token } = await readSession(event)
|
|
9
|
+
if (token) {
|
|
10
|
+
try {
|
|
11
|
+
await $fetch(`${config.apiInternalUrl}/api/auth/logout`, {
|
|
12
|
+
method: 'POST', headers: { ...buildAuthHeaders(token), Accept: 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
} catch { /* best-effort */ }
|
|
15
|
+
}
|
|
16
|
+
await clearSession(event)
|
|
17
|
+
return { ok: true }
|
|
18
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineEventHandler } from 'h3'
|
|
2
|
+
import { readSession } from '../../utils/session'
|
|
3
|
+
|
|
4
|
+
// Para SSR/middlewares: ¿hay sesión? ¿cuál es el contexto activo? Sin exponer el token.
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const { token, currentContext } = await readSession(event)
|
|
7
|
+
return { authenticated: !!token, currentContext: currentContext ?? null }
|
|
8
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineEventHandler, proxyRequest, getRequestURL } from 'h3'
|
|
2
|
+
import { readSession } from '../../utils/session'
|
|
3
|
+
import { resolveApiTarget, buildAuthHeaders } from '../../utils/bff'
|
|
4
|
+
|
|
5
|
+
// BFF: reenvía /api/** a Laravel inyectando el Bearer desde la sesión sellada.
|
|
6
|
+
// proxyRequest hace passthrough de método/body/headers y STREAMEA la respuesta
|
|
7
|
+
// (crítico para el SSE del asistente). No se reenvía la cookie de sesión.
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
const config = useRuntimeConfig(event)
|
|
10
|
+
const { token } = await readSession(event)
|
|
11
|
+
const path = (event.context.params?.path ?? '') as string
|
|
12
|
+
const search = getRequestURL(event).search
|
|
13
|
+
const target = resolveApiTarget(config.apiInternalUrl, path, search)
|
|
14
|
+
return proxyRequest(event, target, { headers: buildAuthHeaders(token) })
|
|
15
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { resolveApiTarget, buildAuthHeaders } from '../bff'
|
|
3
|
+
|
|
4
|
+
describe('resolveApiTarget', () => {
|
|
5
|
+
it('arma base + /api/ + path + querystring', () => {
|
|
6
|
+
expect(resolveApiTarget('http://api:80', 'auth/me', '')).toBe('http://api:80/api/auth/me')
|
|
7
|
+
expect(resolveApiTarget('http://api:80', 'question-bank', '?perPage=1'))
|
|
8
|
+
.toBe('http://api:80/api/question-bank?perPage=1')
|
|
9
|
+
})
|
|
10
|
+
it('normaliza slashes duplicados en la base', () => {
|
|
11
|
+
expect(resolveApiTarget('http://api:80/', 'auth/me', '')).toBe('http://api:80/api/auth/me')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('buildAuthHeaders', () => {
|
|
16
|
+
it('con token inyecta Authorization y vacía la cookie', () => {
|
|
17
|
+
expect(buildAuthHeaders('jwt123')).toEqual({ authorization: 'Bearer jwt123', cookie: '' })
|
|
18
|
+
})
|
|
19
|
+
it('sin token solo vacía la cookie (no manda Authorization)', () => {
|
|
20
|
+
expect(buildAuthHeaders(undefined)).toEqual({ cookie: '' })
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { normalizeLoginResponse } from '../login'
|
|
3
|
+
|
|
4
|
+
describe('normalizeLoginResponse', () => {
|
|
5
|
+
it('separa el token del resto', () => {
|
|
6
|
+
const r = normalizeLoginResponse({
|
|
7
|
+
token: 'jwt', user: { id: '1' }, contexts: ['backoffice'], requires_password_change: false,
|
|
8
|
+
})
|
|
9
|
+
expect(r.token).toBe('jwt')
|
|
10
|
+
expect(r.body).toEqual({ user: { id: '1' }, contexts: ['backoffice'], requires_password_change: false })
|
|
11
|
+
expect((r.body as any).token).toBeUndefined()
|
|
12
|
+
})
|
|
13
|
+
it('acepta access_token como alias', () => {
|
|
14
|
+
expect(normalizeLoginResponse({ access_token: 'jwt2' }).token).toBe('jwt2')
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { sessionOptions, SESSION_COOKIE } from '../session'
|
|
3
|
+
|
|
4
|
+
describe('sessionOptions', () => {
|
|
5
|
+
it('sella con el password dado y cookie httpOnly/lax/secure/maxAge', () => {
|
|
6
|
+
const o = sessionOptions('x'.repeat(32))
|
|
7
|
+
expect(o.password).toBe('x'.repeat(32))
|
|
8
|
+
expect(o.name).toBe(SESSION_COOKIE)
|
|
9
|
+
expect(o.cookie).toMatchObject({ httpOnly: true, sameSite: 'lax', secure: true, path: '/', maxAge: 60 * 60 * 24 * 7 })
|
|
10
|
+
})
|
|
11
|
+
it('permite secure:false (dev http)', () => {
|
|
12
|
+
expect(sessionOptions('x'.repeat(32), { secure: false }).cookie.secure).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
it('lanza si el password es muy corto (<32)', () => {
|
|
15
|
+
expect(() => sessionOptions('corto')).toThrow(/32/)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Lógica pura del BFF. Sin dependencias de h3 para poder testear aislado.
|
|
2
|
+
|
|
3
|
+
/** Construye la URL destino en Laravel: base + /api/ + path + query. */
|
|
4
|
+
export function resolveApiTarget(base: string, path: string, search: string): string {
|
|
5
|
+
const cleanBase = base.replace(/\/+$/, '')
|
|
6
|
+
return `${cleanBase}/api/${path}${search}`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Headers a inyectar al reenviar a Laravel:
|
|
11
|
+
* - Authorization Bearer si hay token de sesión.
|
|
12
|
+
* - cookie vacía: NO reenviar la cookie de sesión de Nitro al backend.
|
|
13
|
+
*/
|
|
14
|
+
export function buildAuthHeaders(token?: string): Record<string, string> {
|
|
15
|
+
return token ? { authorization: `Bearer ${token}`, cookie: '' } : { cookie: '' }
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface LaravelLoginResponse {
|
|
2
|
+
token?: string
|
|
3
|
+
access_token?: string
|
|
4
|
+
user?: unknown
|
|
5
|
+
contexts?: string[]
|
|
6
|
+
requires_password_change?: boolean
|
|
7
|
+
[k: string]: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Separa el token (va a la sesión) del body que vuelve al cliente (sin token). */
|
|
11
|
+
export function normalizeLoginResponse(data: LaravelLoginResponse) {
|
|
12
|
+
const token = data.token ?? data.access_token
|
|
13
|
+
return {
|
|
14
|
+
token,
|
|
15
|
+
body: {
|
|
16
|
+
user: data.user ?? null,
|
|
17
|
+
contexts: data.contexts ?? [],
|
|
18
|
+
requires_password_change: data.requires_password_change ?? false,
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useSession } from 'h3'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
3
|
+
|
|
4
|
+
export const SESSION_COOKIE = 'innertia_session'
|
|
5
|
+
|
|
6
|
+
export interface SessionData {
|
|
7
|
+
token?: string
|
|
8
|
+
currentContext?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Opciones de la sesión sellada. Pura y testeable. */
|
|
12
|
+
export function sessionOptions(password: string, opts: { secure?: boolean } = {}) {
|
|
13
|
+
if (!password || password.length < 32) {
|
|
14
|
+
throw new Error('[innertia-auth] NUXT_SESSION_PASSWORD debe tener ≥32 caracteres')
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
password,
|
|
18
|
+
name: SESSION_COOKIE,
|
|
19
|
+
cookie: {
|
|
20
|
+
httpOnly: true,
|
|
21
|
+
sameSite: 'lax' as const,
|
|
22
|
+
secure: opts.secure ?? true,
|
|
23
|
+
path: '/',
|
|
24
|
+
maxAge: 60 * 60 * 24 * 7, // 7 días
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Secure cookie en qa/prod (HTTPS); en dev local (http) debe ir sin Secure o el
|
|
30
|
+
// browser la descarta silenciosamente → logout loop.
|
|
31
|
+
function secureCookie(): boolean {
|
|
32
|
+
return process.env.NODE_ENV !== 'development'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function password(event: H3Event): string {
|
|
36
|
+
// runtimeConfig.session.password (privado), llenado desde NUXT_SESSION_PASSWORD.
|
|
37
|
+
return useRuntimeConfig(event).session.password
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function readSession(event: H3Event): Promise<SessionData> {
|
|
41
|
+
const s = await useSession<SessionData>(event, sessionOptions(password(event), { secure: secureCookie() }))
|
|
42
|
+
return s.data
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function writeSession(event: H3Event, data: SessionData): Promise<void> {
|
|
46
|
+
const s = await useSession<SessionData>(event, sessionOptions(password(event), { secure: secureCookie() }))
|
|
47
|
+
await s.update(data)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function clearSession(event: H3Event): Promise<void> {
|
|
51
|
+
const s = await useSession<SessionData>(event, sessionOptions(password(event), { secure: secureCookie() }))
|
|
52
|
+
await s.clear()
|
|
53
|
+
}
|
package/stores/auth.js
CHANGED
|
@@ -18,19 +18,6 @@ function _setCookie(name, value, maxAgeSeconds) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function _getCookie(name) {
|
|
22
|
-
try {
|
|
23
|
-
const cookie = useCookie(name)
|
|
24
|
-
return cookie.value ?? null
|
|
25
|
-
} catch {
|
|
26
|
-
if (import.meta.client) {
|
|
27
|
-
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
|
|
28
|
-
return match ? decodeURIComponent(match[1]) : null
|
|
29
|
-
}
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
21
|
function _deleteCookie(name) {
|
|
35
22
|
try {
|
|
36
23
|
const cookie = useCookie(name)
|
|
@@ -42,20 +29,10 @@ function _deleteCookie(name) {
|
|
|
42
29
|
}
|
|
43
30
|
}
|
|
44
31
|
|
|
45
|
-
// ─── JWT decode (no lib) ───────────────────────────────────────────────────
|
|
46
|
-
function _decodeJwtExpiry(token) {
|
|
47
|
-
try {
|
|
48
|
-
const payload = JSON.parse(atob(token.split('.')[1]))
|
|
49
|
-
return payload.exp ?? null
|
|
50
|
-
} catch {
|
|
51
|
-
return null
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
32
|
// ─── store ─────────────────────────────────────────────────────────────────
|
|
56
33
|
export const useAuthStore = defineStore('auth', {
|
|
57
34
|
state: () => ({
|
|
58
|
-
|
|
35
|
+
authenticated: false,
|
|
59
36
|
user: null,
|
|
60
37
|
currentContext: null,
|
|
61
38
|
availableContexts: [],
|
|
@@ -64,13 +41,6 @@ export const useAuthStore = defineStore('auth', {
|
|
|
64
41
|
}),
|
|
65
42
|
|
|
66
43
|
persist: [
|
|
67
|
-
{
|
|
68
|
-
// Token en cookie con key propio para que SSR lo lea en middlewares (guest/auth).
|
|
69
|
-
// Key separado para evitar que la segunda config sobreescriba el token.
|
|
70
|
-
key: 'auth_token',
|
|
71
|
-
pick: ['token'],
|
|
72
|
-
storage: piniaPluginPersistedstate.cookies,
|
|
73
|
-
},
|
|
74
44
|
{
|
|
75
45
|
// currentContext y availableContexts en localStorage.
|
|
76
46
|
// user NO se persiste: auth-init.client.ts lo recarga desde API en cada boot,
|
|
@@ -82,22 +52,13 @@ export const useAuthStore = defineStore('auth', {
|
|
|
82
52
|
],
|
|
83
53
|
|
|
84
54
|
actions: {
|
|
85
|
-
// ──
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
},
|
|
90
|
-
|
|
91
|
-
getToken() {
|
|
92
|
-
return this.token ?? _getCookie('auth_token')
|
|
55
|
+
// ── auth flag ──────────────────────────────────────────────────────────
|
|
56
|
+
// El token ya no vive en el cliente. isAuthenticated se resuelve por /_auth/session.
|
|
57
|
+
setAuthenticated(value) {
|
|
58
|
+
this.authenticated = !!value
|
|
93
59
|
},
|
|
94
|
-
|
|
95
60
|
isAuthenticated() {
|
|
96
|
-
|
|
97
|
-
if (!token) return false
|
|
98
|
-
const exp = _decodeJwtExpiry(token)
|
|
99
|
-
if (exp === null) return true // non-JWT or no expiry claim → treat as valid
|
|
100
|
-
return Date.now() / 1000 < exp
|
|
61
|
+
return this.authenticated === true
|
|
101
62
|
},
|
|
102
63
|
|
|
103
64
|
// ── user ───────────────────────────────────────────────────────────────
|
|
@@ -118,12 +79,11 @@ export const useAuthStore = defineStore('auth', {
|
|
|
118
79
|
|
|
119
80
|
// ── logout ─────────────────────────────────────────────────────────────
|
|
120
81
|
logout() {
|
|
121
|
-
this.
|
|
82
|
+
this.authenticated = false
|
|
122
83
|
this.user = null
|
|
123
84
|
this.currentContext = null
|
|
124
85
|
this.availableContexts = []
|
|
125
86
|
this.permissions = []
|
|
126
|
-
_deleteCookie('auth_token')
|
|
127
87
|
_deleteCookie('auth_user')
|
|
128
88
|
},
|
|
129
89
|
},
|