Add support for returning refresh token for mobile clients
Some checks failed
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
Run Component Tests / Run Component Tests (push) Has been cancelled
Verify all i18n files are alphabetized / update_translations (push) Has been cancelled

This commit is contained in:
advplyr 2025-06-30 17:31:31 -05:00
parent 4d32a22de9
commit 8b995a179d
4 changed files with 35 additions and 14 deletions

View file

@ -152,8 +152,11 @@ export const mutations = {
setUser(state, user) { setUser(state, user) {
state.user = user state.user = user
if (user) { if (user) {
// Use accessToken from user if included in response (for login)
if (user.accessToken) localStorage.setItem('token', user.accessToken) 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) console.error('No access token found for user', user)
} }
} else { } else {

View file

@ -466,14 +466,29 @@ class Auth {
// return the user login response json if the login was successfull // return the user login response json if the login was successfull
const userResponse = await this.getUserLoginResponsePayload(req.user) 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) res.json(userResponse)
}) })
// Refresh token route // Refresh token route
router.post('/auth/refresh', async (req, res) => { 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) { if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token provided' }) 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' }) 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) const userResponse = await this.getUserLoginResponsePayload(user)
userResponse.user.accessToken = newTokens.accessToken
userResponse.user.refreshToken = shouldReturnRefreshToken ? newTokens.refreshToken : null
res.json(userResponse) res.json(userResponse)
} catch (error) { } catch (error) {
if (error.name === 'TokenExpiredError') { if (error.name === 'TokenExpiredError') {
@ -961,7 +978,7 @@ class Auth {
* @param {import('./models/User')} user * @param {import('./models/User')} user
* @param {Request} req * @param {Request} req
* @param {Response} res * @param {Response} res
* @returns {Promise<string>} newAccessToken * @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/ */
async rotateTokensForSession(session, user, req, res) { async rotateTokensForSession(session, user, req, res) {
// Generate new tokens // Generate new tokens
@ -978,7 +995,10 @@ class Auth {
// Set new refresh token cookie // Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken) this.setRefreshTokenCookie(req, res, newRefreshToken)
return newAccessToken return {
accessToken: newAccessToken,
refreshToken: newRefreshToken
}
} }
/** /**
@ -996,7 +1016,7 @@ class Auth {
// So rotate token for current session // So rotate token for current session
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
if (currentSession) { 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 // Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({ await Database.sessionModel.destroy({
@ -1008,7 +1028,7 @@ class Auth {
} }
}) })
return newAccessToken return newTokens.accessToken
} else { } else {
Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`) Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
} }

View file

@ -336,6 +336,9 @@ class UserController {
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res) const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
if (newAccessToken) { if (newAccessToken) {
user.accessToken = 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`) Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
} else { } else {
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`) Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)

View file

@ -112,10 +112,6 @@ class User extends Model {
this.updatedAt this.updatedAt
/** @type {import('./MediaProgress')[]?} - Only included when extended */ /** @type {import('./MediaProgress')[]?} - Only included when extended */
this.mediaProgresses this.mediaProgresses
// Temporary accessToken, not stored in database
/** @type {string} */
this.accessToken
} }
// Excludes "root" since their can only be 1 root user // Excludes "root" since their can only be 1 root user
@ -526,7 +522,6 @@ class User extends Model {
type: this.type, type: this.type,
// TODO: Old non-expiring token // TODO: Old non-expiring token
token: this.type === 'root' && hideRootToken ? '' : this.token, token: this.type === 'root' && hideRootToken ? '' : this.token,
accessToken: this.accessToken || null,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening], seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],