Skip to content

Commit

Permalink
Merge pull request #3665 from mikiher/subdirectory-fixes-3
Browse files Browse the repository at this point in the history
Subdirectory support for OIDC and SocketIO
  • Loading branch information
advplyr authored Dec 3, 2024
2 parents 95c80a5 + 33aa4f1 commit 5fa0897
Show file tree
Hide file tree
Showing 10 changed files with 345 additions and 88 deletions.
38 changes: 37 additions & 1 deletion client/pages/config/authentication.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />

<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
</div>
<div class="mt-2 sm:mt-5">
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
<code>{{ webCallbackURL }}</code>
<br />
<code>{{ mobileAppCallbackURL }}</code>
</p>
</div>
</div>

<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />

<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
Expand Down Expand Up @@ -164,6 +178,27 @@ export default {
value: 'username'
}
]
},
subfolderOptions() {
const options = [
{
text: 'None',
value: ''
}
]
if (this.$config.routerBasePath) {
options.push({
text: this.$config.routerBasePath,
value: this.$config.routerBasePath
})
}
return options
},
webCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
},
mobileAppCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
}
},
methods: {
Expand Down Expand Up @@ -325,7 +360,8 @@ export default {
},
init() {
this.newAuthSettings = {
...this.authSettings
...this.authSettings,
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
}
this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid')
Expand Down
2 changes: 2 additions & 0 deletions client/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,8 @@
"LabelViewPlayerSettings": "View player settings",
"LabelViewQueue": "View player queue",
"LabelVolume": "Volume",
"LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
"LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
"LabelWeekdaysToRun": "Weekdays to run",
"LabelXBooks": "{0} books",
"LabelXItems": "{0} items",
Expand Down
8 changes: 4 additions & 4 deletions server/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class Auth {
{
client: openIdClient,
params: {
redirect_uri: '/auth/openid/callback',
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
scope: 'openid profile email'
}
},
Expand Down Expand Up @@ -480,9 +480,9 @@ class Auth {
// for the request to mobile-redirect and as such the session is not shared
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })

redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
} else {
redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()

if (req.query.state) {
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
Expand Down Expand Up @@ -733,7 +733,7 @@ class Auth {
const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
postLogoutRedirectUri = `${protocol}://${host}/login`
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
}
// else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement
Expand Down
18 changes: 5 additions & 13 deletions server/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ class Server {
Logger.logManager = new LogManager()

this.server = null
this.io = null
}

/**
Expand Down Expand Up @@ -441,18 +440,11 @@ class Server {
async stop() {
Logger.info('=== Stopping Server ===')
Watcher.close()
Logger.info('Watcher Closed')

return new Promise((resolve) => {
SocketAuthority.close((err) => {
if (err) {
Logger.error('Failed to close server', err)
} else {
Logger.info('Server successfully closed')
}
resolve()
})
})
Logger.info('[Server] Watcher Closed')
await SocketAuthority.close()
Logger.info('[Server] Closing HTTP Server')
await new Promise((resolve) => this.server.close(resolve))
Logger.info('[Server] HTTP Server Closed')
}
}
module.exports = Server
142 changes: 82 additions & 60 deletions server/SocketAuthority.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const Auth = require('./Auth')
class SocketAuthority {
constructor() {
this.Server = null
this.io = null
this.socketIoServers = []

/** @type {Object.<string, SocketClient>} */
this.clients = {}
Expand Down Expand Up @@ -89,82 +89,104 @@ class SocketAuthority {
*
* @param {Function} callback
*/
close(callback) {
Logger.info('[SocketAuthority] Shutting down')
// This will close all open socket connections, and also close the underlying http server
if (this.io) this.io.close(callback)
else callback()
async close() {
Logger.info('[SocketAuthority] closing...')
const closePromises = this.socketIoServers.map((io) => {
return new Promise((resolve) => {
Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
io.close(() => {
Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
resolve()
})
})
})
await Promise.all(closePromises)
Logger.info('[SocketAuthority] closed')
this.socketIoServers = []
}

initialize(Server) {
this.Server = Server

this.io = new SocketIO.Server(this.Server.server, {
const socketIoOptions = {
cors: {
origin: '*',
methods: ['GET', 'POST']
},
path: `${global.RouterBasePath}/socket.io`
})

this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]
}

Logger.info('[SocketAuthority] Socket Connected', socket.id)
const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
ioServer.path = '/socket.io'
this.socketIoServers.push(ioServer)

// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))
if (global.RouterBasePath) {
// open a separate socket.io server for the router base path, keeping the original server open for legacy clients
const ioBasePath = `${global.RouterBasePath}/socket.io`
const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
ioBasePathServer.path = ioBasePath
this.socketIoServers.push(ioBasePathServer)
}

// Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
this.socketIoServers.forEach((io) => {
io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
socket,
connected_at: Date.now()
}
socket.sheepClient = this.clients[socket.id]

// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)

// Sent automatically from socket.io clients
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))

const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))

const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))

//
// Events for testing
//
socket.on('message_all_users', (payload) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
if (client.user?.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
// Sent automatically from socket.io clients
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)

const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))

const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})

//
// Events for testing
//
socket.on('message_all_users', (payload) => {
// admin user can send a message to all authenticated users
// displays on the web app as a toast
const client = this.clients[socket.id] || {}
if (client.user?.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
})
})
}
Expand Down
4 changes: 2 additions & 2 deletions server/controllers/MiscController.js
Original file line number Diff line number Diff line change
Expand Up @@ -679,9 +679,9 @@ class MiscController {
continue
}
let updatedValue = settingsUpdate[key]
if (updatedValue === '') updatedValue = null
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
let currentValue = currentAuthenticationSettings[key]
if (currentValue === '') currentValue = null
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null

if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
Expand Down
15 changes: 8 additions & 7 deletions server/migrations/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.

| Server Version | Migration Script Name | Description |
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| Server Version | Migration Script Name | Description |
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
Loading

0 comments on commit 5fa0897

Please sign in to comment.