diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 5bbe13f6..f1915176 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -24,5 +24,7 @@ export default defineNuxtConfig({ }, auth: { webAuthn: true, + sessionRevocationStorage: 'revoked-sessions', + }, }, }) diff --git a/src/module.ts b/src/module.ts index b5103e66..1ec9b08f 100644 --- a/src/module.ts +++ b/src/module.ts @@ -22,6 +22,10 @@ export interface ModuleOptions { * @default false */ webAuthn?: boolean + /** + * Storage location for revocation data + */ + sessionRevocationStorage?: string /** * Hash options used for password hashing */ @@ -49,6 +53,7 @@ export default defineNuxtModule({ // Default configuration options of the Nuxt module defaults: { webAuthn: false, + sessionRevocationStorage: 'revoked-sessions', hash: { scrypt: {}, }, @@ -143,6 +148,9 @@ export default defineNuxtModule({ authenticate: {}, }) + // Session revocation settings + runtimeConfig.sessionRevocationStorage = options.sessionRevocationStorage + // OAuth settings runtimeConfig.oauth = defu(runtimeConfig.oauth, {}) // GitHub OAuth diff --git a/src/runtime/server/api/session.delete.ts b/src/runtime/server/api/session.delete.ts index 20a704e9..18e03387 100644 --- a/src/runtime/server/api/session.delete.ts +++ b/src/runtime/server/api/session.delete.ts @@ -1,8 +1,9 @@ import { eventHandler } from 'h3' -import { clearUserSession } from '../utils/session' +import { clearUserSession, revokeSession } from '../utils/session' export default eventHandler(async (event) => { await clearUserSession(event) + await revokeSession(event) return { loggedOut: true } }) diff --git a/src/runtime/server/api/session.get.ts b/src/runtime/server/api/session.get.ts index de945709..a85a9671 100644 --- a/src/runtime/server/api/session.get.ts +++ b/src/runtime/server/api/session.get.ts @@ -1,11 +1,19 @@ import { eventHandler } from 'h3' -import { getUserSession, sessionHooks } from '../utils/session' +import { getUserSession, isSessionRevoked, clearUserSession, sessionHooks } from '../utils/session' +import { createError } from '#imports' export default eventHandler(async (event) => { const session = await getUserSession(event) // If session is not empty, call fetch hook if (Object.keys(session).length > 0) { + if (await isSessionRevoked(event)) { + await clearUserSession(event) + throw createError({ + statusCode: 401, + statusMessage: 'Session revoked', + }) + } await sessionHooks.callHookParallel('fetch', session, event) } diff --git a/src/runtime/server/utils/session.ts b/src/runtime/server/utils/session.ts index c9f73843..aae8e0c1 100644 --- a/src/runtime/server/utils/session.ts +++ b/src/runtime/server/utils/session.ts @@ -2,7 +2,7 @@ import type { H3Event, SessionConfig } from 'h3' import { useSession, createError } from 'h3' import { defu } from 'defu' import { createHooks } from 'hookable' -import { useRuntimeConfig } from '#imports' +import { useRuntimeConfig, useStorage } from '#imports' import type { UserSession, UserSessionRequired } from '#auth-utils' export interface SessionHooks { @@ -66,6 +66,7 @@ export async function clearUserSession(event: H3Event, config?: Partial = {}) { + const sessionRevocationStorage = useRuntimeConfig().sessionRevocationStorage + if (!sessionRevocationStorage) { + return false + } + const session = await _useSession(event, config) + if (!session || !session.id) { + return false + } + const store = useStorage(sessionRevocationStorage) + return await store.get(session.id) +} + +/** + * Revokes a session + * @param event The Request (h3) event + * @param config Optional partial session configuration + * @returns A boolean indicating whether the session was successfully revoked + */ +export async function revokeSession(event: H3Event, config: Partial = {}) { + const sessionRevocationStorage = useRuntimeConfig().sessionRevocationStorage + if (!sessionRevocationStorage) { + console.log('No session revocation storage configured') + return false + } + const session = await _useSession(event, config) + if (!session || !session.id) { + return false + } + const store = useStorage(sessionRevocationStorage) + await store.set(session.id, Date.now()) + return true +} + +/** + * Lists all revoked sessions + * @returns An array of revoked session keys + */ +export async function listRevokedSessions() { + const sessionRevocationStorage = useRuntimeConfig().sessionRevocationStorage + if (!sessionRevocationStorage) { + return [] + } + const store = useStorage(sessionRevocationStorage) + return await store.getKeys() +} + +/** + * Clears all revoked sessions + */ +export async function clearRevokedSessions() { + const sessionRevocationStorage = useRuntimeConfig().sessionRevocationStorage + if (!sessionRevocationStorage) { + return + } + const store = useStorage(sessionRevocationStorage) + await store.clear() +} + +/** + * Cleans up expired revoked sessions + * @param maxAge Optional maximum age for revoked sessions (in milliseconds) + */ +export async function cleanupRevokedSessions(maxAge?: number) { + const sessionRevocationStorage = useRuntimeConfig().sessionRevocationStorage + if (!sessionRevocationStorage) { + return + } + const sessionMaxAge = useRuntimeConfig().session?.maxAge + const revokedMaxAge = maxAge || sessionMaxAge + if (!revokedMaxAge) { + return + } + const store = useStorage(sessionRevocationStorage) + const keys = await store.getKeys() + for (const key of keys) { + const revokedAt = await store.get(key) + if (revokedAt && ((Date.now() - revokedAt) > revokedMaxAge)) { + await store.removeItem(key) + } + } +} + let sessionConfig: SessionConfig function _useSession(event: H3Event, config: Partial = {}) {