diff --git a/packages/preset-nuxt4/src/features/auth.ts b/packages/preset-nuxt4/src/features/auth.ts index 558d75f3..4bddc157 100644 --- a/packages/preset-nuxt4/src/features/auth.ts +++ b/packages/preset-nuxt4/src/features/auth.ts @@ -30,7 +30,7 @@ function titleCase(slug: string): string { /** Session auth on `nuxt-auth-utils`. The sealed cookie carries an opaque sessionId. */ export const authFeature: Feature = { id: 'nuxt4:auth', - version: '1.10.2', + version: '1.10.3', label: 'Session-based auth (argon2id)', frameworks: ['nuxt4'], stage: STAGE.AUTH, @@ -59,9 +59,9 @@ export const authFeature: Feature = { '- Composable: `useAuth()` (`login`, `logout`, `loggedIn`, `user`)', '- Global middleware redirects unauthenticated visitors to `/login`', '', - '**Sessions are DB-backed.** The cookie is just a sealed pointer to a `sessions` row (id, userId, expiresAt, lastSeenAt, userAgent, ip). The Nitro plugin at `server/plugins/session.ts` validates the row on every request and refreshes `lastSeenAt`. Revoke any session by `DELETE FROM sessions WHERE id = ...`; the cookie becomes immediately invalid.', + '**Sessions are DB-backed.** The cookie is just a sealed pointer to a `sessions` row (id, userId, expiresAt, lastSeenAt, userAgent, ip). `server/middleware/02.session-revocation.ts` checks the row on every request that carries a session cookie and clears the session when the row is gone or expired; the Nitro plugin at `server/plugins/session.ts` refreshes the user and `lastSeenAt` on the client\'s session fetch. Revoke any session by `DELETE FROM sessions WHERE id = ...`; the cookie becomes immediately invalid.', '', - 'Other login flows (passkey, oauth, 2fa challenge) all call `createDbSession(userId, event)` from `server/utils/auth.ts` and pass the returned id as `secure.sessionId` to `setUserSession`. Skipping that step results in a cookie-only session that the fetch hook ignores.', + 'Other login flows (passkey, oauth, 2fa challenge) all call `createDbSession(userId, event)` from `server/utils/auth.ts` and pass the returned id as `secure.sessionId` to `setUserSession`. Skipping that step results in a cookie-only session that `02.session-revocation.ts` clears on the next request.', '', '### Dev login shortcut', '', diff --git a/packages/preset-nuxt4/src/features/docs.ts b/packages/preset-nuxt4/src/features/docs.ts index 2766e093..e69252d8 100644 --- a/packages/preset-nuxt4/src/features/docs.ts +++ b/packages/preset-nuxt4/src/features/docs.ts @@ -22,7 +22,7 @@ import { */ export const docsFeature: Feature = { id: 'nuxt4:docs', - version: '1.1.1', + version: '1.1.2', label: 'Generate AGENTS.md + CLAUDE.md + README.md', frameworks: ['nuxt4'], stage: STAGE.DOCS, diff --git a/packages/preset-nuxt4/src/features/vitest.ts b/packages/preset-nuxt4/src/features/vitest.ts index 7b89dff1..93dea3f2 100644 --- a/packages/preset-nuxt4/src/features/vitest.ts +++ b/packages/preset-nuxt4/src/features/vitest.ts @@ -19,7 +19,7 @@ import { ui } from '@battlestack/tui' /** Vitest config, a sample unit test and `test` scripts. Three projects: unit, nuxt, e2e. */ export const vitestFeature: Feature = { id: 'nuxt4:vitest', - version: '1.0.8', + version: '1.0.9', label: 'Vitest config + scripts', frameworks: ['nuxt4'], stage: STAGE.NAMING, // run after naming so package.json exists diff --git a/packages/preset-nuxt4/templates/auth/server/middleware/02.session-revocation.ts b/packages/preset-nuxt4/templates/auth/server/middleware/02.session-revocation.ts new file mode 100644 index 00000000..b4403d16 --- /dev/null +++ b/packages/preset-nuxt4/templates/auth/server/middleware/02.session-revocation.ts @@ -0,0 +1,37 @@ +import { and, eq, gt } from 'drizzle-orm' +import { db } from '#server/database/client' +import { sessions } from '#server/database/schema/sessions' + +/** + * Logout, revoking a device and deleting a user only remove the `sessions` row; the sealed cookie stays valid. + * nuxt-auth-utils runs the fetch hook (`server/plugins/session.ts`) on its own session fetch only, never in + * `requireUserSession`, so the row is checked here on every request that carries a session cookie. + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(event).session as { name?: string, cookie?: Record } + const cookieName = config.name ?? 'nuxt-session' + if (!getCookie(event, cookieName)) return + + const session = await getUserSession(event) + const userId = session.user?.id + if (!userId) return + const sessionId = session.secure?.sessionId + if (sessionId && await isLive(sessionId, userId)) return + + // getUserSession returns a copy, and clearUserSession would let the next call re-read the still-valid + // cookie, so strip h3's per-request cache in place. Fail closed if it isn't there. + const cached = (event.context.sessions as Record }> | undefined)?.[cookieName] + if (!cached) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + delete cached.data.user + delete cached.data.secure + deleteCookie(event, cookieName, { ...config.cookie, path: '/' }) +}) + +async function isLive(sessionId: string, userId: string): Promise { + const [row] = await db + .select({ id: sessions.id }) + .from(sessions) + .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId), gt(sessions.expiresAt, new Date()))) + .limit(1) + return row !== undefined +} diff --git a/packages/preset-nuxt4/templates/vitest/test/e2e/api/auth.test.ts b/packages/preset-nuxt4/templates/vitest/test/e2e/api/auth.test.ts index 2b814b12..f44f9f5d 100644 --- a/packages/preset-nuxt4/templates/vitest/test/e2e/api/auth.test.ts +++ b/packages/preset-nuxt4/templates/vitest/test/e2e/api/auth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { apiGet, apiPost, isServerUp, loginAsAdmin, BASE_URL } from '~~/test/helpers/setup' +import { apiDelete, apiGet, apiPost, isServerUp, loginAsAdmin, BASE_URL } from '~~/test/helpers/setup' // Headless / no-server-up runs skip the suite entirely. Run `pnpm dev` in // another terminal to exercise these locally. @@ -55,6 +55,19 @@ describe('e2e: auth flow', () => { const cookie = await loginAsAdmin() const logout = await apiPost('/api/auth/logout', {}, cookie) expect(logout.status).toBeLessThan(400) + // The sealed cookie outlives its `sessions` row, so the server must refuse it, not just the client forget it. + expect((await apiGet('/api/auth/sessions', cookie)).status).toBe(401) + }) + + it.skipIf(!serverUp)('a session revoked from another device stops authenticating', async () => { + const revoked = await loginAsAdmin() + const other = await loginAsAdmin() + const list = await apiGet<{ rows: { id: string, current: boolean }[] }>('/api/auth/sessions', revoked) + const current = list.data?.rows.find((r) => r.current) + expect(current).toBeTruthy() + expect((await apiDelete(`/api/auth/sessions/${current!.id}`, other)).status).toBeLessThan(400) + expect((await apiGet('/api/auth/sessions', revoked)).status).toBe(401) + expect((await apiGet('/api/auth/sessions', other)).status).toBe(200) }) if (!serverUp) {