Compare commits

...
9 Commits
Author SHA1 Message Date
advplyr 1d89e3a58c Merge branch 'master' into v3 2026-11-12 17:11:21 -06:00
advplyr 41cbcf551d Merge branch 'master' into v3 2026-11-11 19:09:07 -06:00
advplyr 1e60d7af64 Merge branch 'master' into v3 2026-11-09 17:10:29 -06:00
advplyr 92bae242f1 Merge branch 'master' into v3 2026-11-08 18:15:39 -06:00
advplyr 34b037087b Merge branch 'master' into v3 2026-11-05 17:50:37 -06:00
advplyr 844b04c6ae Merge branch 'master' into v3 2026-11-04 17:52:01 -06:00
advplyr c38fe60360 Remove User legacy API token 2026-11-03 17:00:53 -06:00
advplyr 8c87ae8bad Merge branch 'master' into v3 2026-11-01 17:27:04 -06:00
advplyr f85d03ce26 Set 3.0.0-beta package.json version 2026-10-31 12:08:29 -05:00
14 changed files with 30 additions and 129 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.37.2",
"version": "3.0.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.37.2",
"version": "3.0.0-beta",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.37.2",
"version": "3.0.0-beta",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
-11
View File
@@ -13,11 +13,6 @@
<widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div v-if="legacyToken" class="text-xs space-y-2 mt-4">
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
<p class="text-warning" v-html="$strings.MessageAuthenticationLegacyTokenWarning" />
</div>
<div class="w-full h-px bg-white/10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningStats }}</h1>
@@ -102,12 +97,6 @@ export default {
}
},
computed: {
legacyToken() {
return this.user.token
},
userToken() {
return this.user.accessToken
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
+1 -24
View File
@@ -40,15 +40,6 @@
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
<div v-if="showNewAuthSystemMessage" class="mb-4">
<widgets-alert type="warning">
<div>
<p>{{ $strings.MessageAuthenticationSecurityMessage }}</p>
<a v-if="showNewAuthSystemAdminMessage" href="https://github.com/advplyr/audiobookshelf/discussions/4460" target="_blank" class="underline">{{ $strings.LabelMoreInfo }}</a>
</div>
</widgets-alert>
</div>
<form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model.trim="username" autocomplete="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
@@ -94,10 +85,7 @@ export default {
MetadataPath: '',
login_local: true,
login_openid: false,
authFormData: null,
// New JWT auth system re-login flags
showNewAuthSystemMessage: false,
showNewAuthSystemAdminMessage: false
authFormData: null
}
},
watch: {
@@ -201,8 +189,6 @@ export default {
},
async submitForm() {
this.error = null
this.showNewAuthSystemMessage = false
this.showNewAuthSystemAdminMessage = false
this.processing = true
const payload = {
@@ -238,15 +224,6 @@ export default {
}
})
.then((res) => {
// Force re-login if user is using an old token with no expiration
if (res.user.isOldToken) {
this.username = res.user.username
this.showNewAuthSystemMessage = true
// Admin user sees link to github discussion
this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root'
return false
}
this.setUser(res)
return true
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.37.2",
"version": "3.0.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.37.2",
"version": "3.0.0-beta",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.37.2",
"version": "3.0.0-beta",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "dist-server/index.js",
+2 -16
View File
@@ -62,18 +62,6 @@ class Auth {
return passport.authenticate('jwt', { session: false })(req, res, next)
}
/**
* Function to generate a jwt token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateAccessToken(user) {
return this.tokenManager.generateAccessToken(user)
}
/**
* Invalidate all JWT sessions for a given user
* If user is current user and refresh token is valid, rotate tokens for the current session
@@ -269,10 +257,8 @@ class Auth {
} else {
// UI request -> check if we have a callback url
if (req.cookies.auth_cb) {
let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
// UI request -> redirect to auth_cb url and send the jwt token as parameter
// TODO: Temporarily continue sending the old token as setToken
res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)
const stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
res.redirect(302, `${req.cookies.auth_cb}?accessToken=${userResponse.user.accessToken}${stateQuery}`)
} else {
Logger.error('[Auth] No callback or already expired')
res.redirect(`/login?error=${encodeURIComponent('No callback or already expired')}&autoLaunch=0`)
+2 -3
View File
@@ -415,10 +415,9 @@ class Database {
* Create root user
* @param {string} username
* @param {string} pash
* @param {Auth} auth
* @returns {Promise<boolean>} true if created
*/
async createRootUser(username, pash, auth) {
async createRootUser(username, pash) {
if (!this.sequelize) return false
const transaction = await this.sequelize.transaction()
try {
@@ -427,7 +426,7 @@ class Database {
await transaction.rollback()
return false
}
await this.userModel.createRootUser(username, pash, auth, transaction)
await this.userModel.createRootUser(username, pash, transaction)
await transaction.commit()
this.hasRootUser = true
return true
+1 -1
View File
@@ -479,7 +479,7 @@ class Server {
const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
const created = await Database.createRootUser(rootUsername, rootPash, this.auth)
const created = await Database.createRootUser(rootUsername, rootPash)
if (!created) {
Logger.error(`[Server] attempt to init server when server already has a root user`)
return res.sendStatus(500)
+3 -34
View File
@@ -80,7 +80,9 @@ class TokenManager {
*/
static isBearerAccessTokenPayload(decoded) {
if (!decoded?.userId) return false
if (decoded.type === 'refresh') return false
// Legacy API tokens were signed with only userId and username, and never expired.
if (decoded.type !== 'access') return false
if (!decoded.exp) return false
return true
}
@@ -100,30 +102,6 @@ class TokenManager {
}
}
/**
* Generate a JWT token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
static generateAccessToken(user) {
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
}
/**
* Function to generate a jwt token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateAccessToken(user) {
return TokenManager.generateAccessToken(user)
}
/**
* Generate access token for a given user
*
@@ -325,15 +303,6 @@ class TokenManager {
return
}
// TODO: Temporary flag to report old tokens to users
// May be a better place for this but here means we dont have to decode the token again
if (!jwt_payload.exp && !user.isOldToken) {
Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
user.isOldToken = true
} else if (jwt_payload.exp && user.isOldToken !== undefined) {
delete user.isOldToken
}
// approve login
done(null, user)
}
+2 -13
View File
@@ -28,13 +28,12 @@ class UserController {
*/
async findAll(req, res) {
if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot
const includes = (req.query.include || '').split(',').map((i) => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
const allUsers = await Database.userModel.findAll()
const users = allUsers.map((u) => u.toOldJSONForBrowser(hideRootToken, true))
const users = allUsers.map((u) => u.toOldJSONForBrowser(true))
if (includes.includes('latestSession')) {
for (const user of users) {
@@ -97,7 +96,7 @@ class UserController {
return oldMediaProgress
})
const userJson = req.reqUser.toOldJSONForBrowser(!req.user.isRoot)
const userJson = req.reqUser.toOldJSONForBrowser()
userJson.mediaProgress = oldMediaProgresses
@@ -128,7 +127,6 @@ class UserController {
const userId = uuidv4()
const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
const userType = req.body.type || 'user'
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
@@ -173,7 +171,6 @@ class UserController {
username: req.body.username,
email: typeof req.body.email === 'string' ? req.body.email : null,
pash,
token,
isActive: !!req.body.isActive,
permissions,
bookmarks: [],
@@ -236,16 +233,13 @@ class UserController {
}
let hasUpdates = false
let shouldUpdateToken = false
let shouldInvalidateJwtSessions = false
// When changing username create a new API token
if (updatePayload.username && updatePayload.username !== user.username) {
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
if (usernameExists) {
return res.status(400).send('Username already taken')
}
user.username = updatePayload.username
shouldUpdateToken = true
shouldInvalidateJwtSessions = true
hasUpdates = true
}
@@ -327,11 +321,6 @@ class UserController {
}
if (hasUpdates) {
if (shouldUpdateToken) {
user.token = this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
}
// Handle JWT session invalidation for username/password changes
if (shouldInvalidateJwtSessions) {
const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res)
+3 -18
View File
@@ -5,7 +5,6 @@ const { LRUCache } = require('lru-cache')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { isNullOrNaN } = require('../utils')
const TokenManager = require('../auth/TokenManager')
class UserCache {
constructor() {
@@ -94,8 +93,6 @@ class User extends Model {
this.pash
/** @type {string} */
this.type
/** @type {string} */
this.token
/** @type {boolean} */
this.isActive
/** @type {boolean} */
@@ -186,20 +183,16 @@ class User extends Model {
* Create root user
* @param {string} username
* @param {string} pash
* @param {import('../Auth')} auth
* @returns {Promise<User>}
*/
static async createRootUser(username, pash, auth, transaction = null) {
static async createRootUser(username, pash, transaction = null) {
const userId = uuidv4()
const token = auth.generateAccessToken({ id: userId, username })
const newUser = {
id: userId,
type: 'root',
username,
pash,
token,
isActive: true,
permissions: this.getDefaultPermissionsForUserType('root'),
bookmarks: [],
@@ -314,15 +307,12 @@ class User extends Model {
const username = userinfo.preferred_username || userinfo.name || userinfo.sub
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
const token = TokenManager.generateAccessToken({ id: userId, username })
const newUser = {
id: userId,
type: 'user',
username,
email,
pash: null,
token,
isActive: true,
permissions: this.getDefaultPermissionsForUserType('user'),
bookmarks: [],
@@ -514,6 +504,7 @@ class User extends Model {
email: DataTypes.STRING,
pash: DataTypes.STRING,
type: DataTypes.STRING,
// Column remains so existing databases do not need a migration. New users leave it null.
token: DataTypes.STRING,
isActive: {
type: DataTypes.BOOLEAN,
@@ -596,11 +587,10 @@ class User extends Model {
/**
* User data for browser using old model
*
* @param {boolean} [hideRootToken=false]
* @param {boolean} [minimal=false]
* @returns
*/
toOldJSONForBrowser(hideRootToken = false, minimal = false) {
toOldJSONForBrowser(minimal = false) {
const seriesHideFromContinueListening = this.extraData?.seriesHideFromContinueListening || []
const librariesAccessible = this.permissions?.librariesAccessible || []
const itemTagsSelected = this.permissions?.itemTagsSelected || []
@@ -613,11 +603,6 @@ class User extends Model {
username: this.username,
email: this.email,
type: this.type,
// TODO: Old non-expiring token
token: this.type === 'root' && hideRootToken ? '' : this.token,
// TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck
// Necessary to detect apps using old tokens that no longer match the old token stored on the user
isOldToken: this.isOldToken,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
-3
View File
@@ -75,9 +75,6 @@ class Stream extends EventEmitter {
get codecsToForceAAC() {
return ['alac', 'ac3', 'eac3', 'opus']
}
get userToken() {
return this.user.token
}
// Fmp4 does not work on iOS devices: https://github.com/advplyr/audiobookshelf-app/issues/85
// Workaround: Force AAC transcode for FLAC
get hlsSegmentType() {
+10
View File
@@ -39,6 +39,16 @@ describe('TokenManager', () => {
describe('jwtAuthCheck', () => {
const user = { id: userId, username: 'testuser', isActive: true }
it('rejects legacy API tokens that have no type or expiration', async () => {
const legacyToken = jwt.sign({ userId, username: 'testuser' }, secret)
const decoded = jwt.verify(legacyToken, secret)
const done = sinon.spy()
await tokenManager.jwtAuthCheck(decoded, done)
expect(done.calledWith(null, null)).to.be.true
})
it('rejects refresh tokens for API auth', async () => {
const refreshToken = tokenManager.generateRefreshToken(user)
const decoded = jwt.verify(refreshToken, secret)