Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add session revocation #257

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ export default defineNuxtConfig({
},
auth: {
webAuthn: true,
sessionRevocationStorage: 'revoked-sessions',
},
},
})
8 changes: 8 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export interface ModuleOptions {
* @default false
*/
webAuthn?: boolean
/**
* Storage location for revocation data
*/
sessionRevocationStorage?: string
/**
* Hash options used for password hashing
*/
Expand Down Expand Up @@ -49,6 +53,7 @@ export default defineNuxtModule<ModuleOptions>({
// Default configuration options of the Nuxt module
defaults: {
webAuthn: false,
sessionRevocationStorage: 'revoked-sessions',
hash: {
scrypt: {},
},
Expand Down Expand Up @@ -143,6 +148,9 @@ export default defineNuxtModule<ModuleOptions>({
authenticate: {},
})

// Session revocation settings
runtimeConfig.sessionRevocationStorage = options.sessionRevocationStorage

// OAuth settings
runtimeConfig.oauth = defu(runtimeConfig.oauth, {})
// GitHub OAuth
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/server/api/session.delete.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
10 changes: 9 additions & 1 deletion src/runtime/server/api/session.get.ts
Original file line number Diff line number Diff line change
@@ -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)
}

Expand Down
92 changes: 91 additions & 1 deletion src/runtime/server/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -66,6 +66,7 @@ export async function clearUserSession(event: H3Event, config?: Partial<SessionC

await sessionHooks.callHookParallel('clear', session.data, event)
await session.clear()
await revokeSession(event, config)

return true
}
Expand All @@ -91,6 +92,95 @@ export async function requireUserSession(event: H3Event, opts: { statusCode?: nu
return userSession as UserSessionRequired
}

/**
* Checks if a session has been revoked
* @param event The Request (h3) event
* @param config Optional partial session configuration
* @returns A boolean indicating whether the session is revoked
*/
export async function isSessionRevoked(event: H3Event, config: Partial<SessionConfig> = {}) {
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<SessionConfig> = {}) {
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<number>(key)
if (revokedAt && ((Date.now() - revokedAt) > revokedMaxAge)) {
await store.removeItem(key)
}
}
}

let sessionConfig: SessionConfig

function _useSession(event: H3Event, config: Partial<SessionConfig> = {}) {
Expand Down