From e56c1099e6da03482fd9b69a7886a6d928c30d7f Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 24 Jul 2026 17:44:03 -0500 Subject: [PATCH] Add logout all devices button and extend auth logout endpoint --- client/pages/account.vue | 15 +++++++++++---- client/strings/en-us.json | 1 + server/Auth.js | 11 ++++++++--- server/auth/TokenManager.js | 19 +++++++++++++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/client/pages/account.vue b/client/pages/account.vue index e9b5da3cb..456f001cc 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -69,8 +69,9 @@ -
- logout{{ $strings.ButtonLogout }} +
+ devices{{ $strings.ButtonLogoutAllDevices }} + logout{{ $strings.ButtonLogout }}
@@ -87,6 +88,7 @@ export default { newPassword: null, confirmPassword: null, changingPassword: false, + loggingOut: false, selectedLanguage: '', newEReaderDevice: { name: '', @@ -135,7 +137,7 @@ export default { updateLocalLanguage(lang) { this.$setLanguageCode(lang) }, - logout() { + logout(allDevices = false) { // Disconnect from socket if (this.$root.socket) { console.log('Disconnecting from socket', this.$root.socket.id) @@ -149,8 +151,10 @@ export default { this.$store.commit('libraries/setUserPlaylists', []) this.$store.commit('libraries/setCollections', []) + this.loggingOut = true + const url = allDevices ? '/logout?allDevices=1' : '/logout' this.$axios - .$post('/logout') + .$post(url) .then((logoutPayload) => { const redirect_url = logoutPayload.redirect_url @@ -163,6 +167,9 @@ export default { .catch((error) => { console.error(error) }) + .finally(() => { + this.loggingOut = false + }) }, resetForm() { this.password = null diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..7d9fca6b8 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -46,6 +46,7 @@ "ButtonLatest": "Latest", "ButtonLibrary": "Library", "ButtonLogout": "Logout", + "ButtonLogoutAllDevices": "Logout All Devices", "ButtonLookup": "Lookup", "ButtonManageTracks": "Manage Tracks", "ButtonMapChapterTitles": "Map Chapter Titles", diff --git a/server/Auth.js b/server/Auth.js index 65532897d..17bd1160f 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -471,18 +471,23 @@ class Auth { res.json(openIdIssuerConfig) }) - // Logout route + /** + * Logout route + * Use ?allDevices=1 to destroy every session for this user instead of just the current one + */ router.post('/logout', async (req, res) => { // Refresh token be alternatively be sent in the header const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token'] + const allDevices = req.query.allDevices === '1' // Clear refresh token cookie res.clearCookie('refresh_token', { path: '/' }) - // Invalidate the session in database using refresh token - if (refreshToken) { + if (allDevices) { + await this.tokenManager.invalidateAllSessionsForRefreshToken(refreshToken) + } else if (refreshToken) { await this.tokenManager.invalidateRefreshToken(refreshToken) } else { Logger.info(`[Auth] logout: No refresh token on request`) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5ebc6530b..1f0417622 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -505,6 +505,25 @@ class TokenManager { return null } + /** + * Destroy all JWT sessions for the user that owns this refresh token + * + * @param {string} refreshToken + */ + async invalidateAllSessionsForRefreshToken(refreshToken) { + if (!refreshToken) return + + const session = await Database.sessionModel.findOne({ + where: { + [Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }] + } + }) + if (!session) return + + const numDeleted = await Database.sessionModel.destroy({ where: { userId: session.userId } }) + Logger.info(`[TokenManager] Invalidated all JWT sessions for user ${session.userId}, ${numDeleted} deleted`) + } + /** * Invalidate a refresh token - used for logout *