diff --git a/client/store/user.js b/client/store/user.js index 787d67db..e37568f1 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -152,8 +152,11 @@ export const mutations = { setUser(state, user) { state.user = user if (user) { + // Use accessToken from user if included in response (for login) if (user.accessToken) localStorage.setItem('token', user.accessToken) - else { + else if (localStorage.getItem('token')) { + user.accessToken = localStorage.getItem('token') + } else { console.error('No access token found for user', user) } } else { diff --git a/server/Auth.js b/server/Auth.js index b1d94d41..b811a5db 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -466,14 +466,29 @@ class Auth { // return the user login response json if the login was successfull const userResponse = await this.getUserLoginResponsePayload(req.user) - this.setRefreshTokenCookie(req, res, req.user.refreshToken) + // Check if mobile app wants refresh token in response + const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' + + userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null + userResponse.user.accessToken = req.user.accessToken + + if (!returnTokens) { + this.setRefreshTokenCookie(req, res, req.user.refreshToken) + } res.json(userResponse) }) // Refresh token route router.post('/auth/refresh', async (req, res) => { - const refreshToken = req.cookies.refresh_token + let refreshToken = req.cookies.refresh_token + + // For mobile clients, the refresh token is sent in the authorization header + let shouldReturnRefreshToken = false + if (!refreshToken && req.headers.authorization?.startsWith('Bearer ')) { + refreshToken = req.headers.authorization.split(' ')[1] + shouldReturnRefreshToken = true + } if (!refreshToken) { return res.status(401).json({ error: 'No refresh token provided' }) @@ -507,10 +522,12 @@ class Auth { return res.status(401).json({ error: 'User not found or inactive' }) } - const newAccessToken = await this.rotateTokensForSession(session, user, req, res) + const newTokens = await this.rotateTokensForSession(session, user, req, res) - user.accessToken = newAccessToken const userResponse = await this.getUserLoginResponsePayload(user) + + userResponse.user.accessToken = newTokens.accessToken + userResponse.user.refreshToken = shouldReturnRefreshToken ? newTokens.refreshToken : null res.json(userResponse) } catch (error) { if (error.name === 'TokenExpiredError') { @@ -961,7 +978,7 @@ class Auth { * @param {import('./models/User')} user * @param {Request} req * @param {Response} res - * @returns {Promise} newAccessToken + * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ async rotateTokensForSession(session, user, req, res) { // Generate new tokens @@ -978,7 +995,10 @@ class Auth { // Set new refresh token cookie this.setRefreshTokenCookie(req, res, newRefreshToken) - return newAccessToken + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken + } } /** @@ -996,7 +1016,7 @@ class Auth { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newAccessToken = await this.rotateTokensForSession(currentSession, user, req, res) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ @@ -1008,7 +1028,7 @@ class Auth { } }) - return newAccessToken + return newTokens.accessToken } else { Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`) } diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 0a99b84e..48c98150 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -336,6 +336,9 @@ class UserController { const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res) if (newAccessToken) { user.accessToken = newAccessToken + // Refresh tokens are only returned for mobile clients + // Mobile apps currently do not use this API endpoint so always set to null + user.refreshToken = null Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`) } else { Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`) diff --git a/server/models/User.js b/server/models/User.js index 9b26b0ff..588b53bb 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -112,10 +112,6 @@ class User extends Model { this.updatedAt /** @type {import('./MediaProgress')[]?} - Only included when extended */ this.mediaProgresses - - // Temporary accessToken, not stored in database - /** @type {string} */ - this.accessToken } // Excludes "root" since their can only be 1 root user @@ -526,7 +522,6 @@ class User extends Model { type: this.type, // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, - accessToken: this.accessToken || null, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],