Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/preset-nuxt4/src/features/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
'',
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
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<string, { data: Record<string, unknown> }> | 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<boolean> {
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
}
15 changes: 14 additions & 1 deletion packages/preset-nuxt4/templates/vitest/test/e2e/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Loading