From 3f08e8fd134a302d80566af22e0e40016ed17cc9 Mon Sep 17 00:00:00 2001 From: Christian Stroet Date: Tue, 6 Oct 2026 17:32:54 +0200 Subject: [PATCH 1/2] fix(auth): refuse revoked sessions on every request Logout, revoking a device and deleting a user only remove the `sessions` row; the sealed cookie stayed valid. nuxt-auth-utils calls the `fetch` hook in server/plugins/session.ts only from GET /api/_auth/session, never from getUserSession or requireUserSession, so every API route kept accepting a revoked cookie. The docs claimed the plugin validated the row on every request. server/middleware/02.session-revocation.ts now checks, on each request that carries a session cookie, that the row exists, belongs to the user and has not expired. If not, it strips h3's per-request session cache in place and deletes the cookie. clearUserSession does not work here: the next getUserSession re-reads the still-valid request cookie. Without that cache it fails closed with a 401. The auth e2e suite now asserts that a logged-out cookie and a session revoked from another device both get a 401. --- packages/preset-nuxt4/src/features/auth.ts | 4 +- .../middleware/02.session-revocation.ts | 37 +++++++++++++++++++ .../vitest/test/e2e/api/auth.test.ts | 15 +++++++- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 packages/preset-nuxt4/templates/auth/server/middleware/02.session-revocation.ts diff --git a/packages/preset-nuxt4/src/features/auth.ts b/packages/preset-nuxt4/src/features/auth.ts index 558d75f3..27dbbf71 100644 --- a/packages/preset-nuxt4/src/features/auth.ts +++ b/packages/preset-nuxt4/src/features/auth.ts @@ -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/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) { From 9881dd748127cd4ee9d067d48a1d515fdb673ea4 Mon Sep 17 00:00:00 2001 From: Joris Neuteboom Date: Thu, 15 Oct 2026 11:09:10 +0200 Subject: [PATCH 2/2] chore: bump auth, vitest and docs past main The bump gate diffs a PR against its base branch, and main already carries nuxt4:auth 1.10.2, nuxt4:vitest 1.0.8 and nuxt4:docs 1.1.1. --- packages/preset-nuxt4/src/features/auth.ts | 2 +- packages/preset-nuxt4/src/features/docs.ts | 2 +- packages/preset-nuxt4/src/features/vitest.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/preset-nuxt4/src/features/auth.ts b/packages/preset-nuxt4/src/features/auth.ts index 27dbbf71..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, 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