diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index 4fa8e394..2009b28d 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -50,7 +50,11 @@ export default { label: String, disabled: Boolean, readonly: Boolean, - showEdit: Boolean + showEdit: Boolean, + menuDisabled: { + type: Boolean, + default: false + }, }, data() { return { @@ -77,7 +81,7 @@ export default { } }, showMenu() { - return this.isFocused + return this.isFocused && !this.menuDisabled }, wrapperClass() { var classes = [] diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index e645569e..ffb1feb7 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -46,6 +46,9 @@ + +

+

@@ -187,6 +190,25 @@ export default { this.$toast.error('Client Secret required') isValid = false } + + function isValidRedirectURI(uri) { + // Check for somestring://someother/string + const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i') + return pattern.test(uri) + } + + const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs + if (uris.includes('*') && uris.length > 1) { + this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used') + isValid = false + } else { + uris.forEach(uri => { + if (uri !== '*' && !isValidRedirectURI(uri)) { + this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`) + isValid = false + } + }) + } return isValid }, async saveSettings() { diff --git a/client/strings/de.json b/client/strings/de.json index 78e64804..eb3d59f4 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -337,6 +337,8 @@ "LabelMinute": "Minute", "LabelMissing": "Fehlend", "LabelMissingParts": "Fehlende Teile", + "LabelMobileRedirectURIs": "Erlaubte Weiterleitungs-URIs für die mobile App", + "LabelMobileRedirectURIsDescription": "Dies ist eine Whitelist gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist audiobookshelf://oauth, den Sie entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen können. Die Verwendung eines Sternchens (*) als alleiniger Eintrag erlaubt jede URI.", "LabelMore": "Mehr", "LabelMoreInfo": "Mehr Info", "LabelName": "Name", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 857627e9..02f9df05 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Missing", "LabelMissingParts": "Missing Parts", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is audiobookshelf://oauth, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (*) as the sole entry permits any URI.", "LabelMore": "More", "LabelMoreInfo": "More Info", "LabelName": "Name", diff --git a/server/Auth.js b/server/Auth.js index 267bbb45..c20d532a 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -8,6 +8,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') +const e = require('express') /** * @class Class for handling all the authentication related functionality. @@ -15,6 +16,8 @@ const Logger = require('./Logger') class Auth { constructor() { + // Map of openId sessions indexed by oauth2 state-variable + this.openIdAuthSession = new Map() } /** @@ -283,7 +286,26 @@ class Auth { // for API or mobile clients const oidcStrategy = passport._strategy('openid-client') const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' - oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + + let redirect_uri = null + + // The client wishes a different redirect_uri + // We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect + // where we will handle the redirect to it + if (req.query.redirect_uri) { + // Check if the redirect_uri is in the whitelist + if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) || + (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() + redirect_uri = req.query.redirect_uri + } else { + Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`) + return res.status(400).send('Invalid redirect_uri') + } + } else { + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + } + Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) const client = oidcStrategy._client const sessionKey = oidcStrategy._key @@ -327,6 +349,10 @@ class Auth { mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later } + // We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API + // for the request to mobile-redirect and as such the session is not shared + this.openIdAuthSession.set(params.state, { redirect_uri: redirect_uri }) + // Now get the URL to direct to const authorizationUrl = client.authorizationUrl({ ...params, @@ -347,6 +373,37 @@ class Auth { } }) + // This will be the oauth2 callback route for mobile clients + // It will redirect to an app-link like audiobookshelf://oauth + router.get('/auth/openid/mobile-redirect', (req, res) => { + try { + // Extract the state parameter from the request + const { state, code } = req.query + + // Check if the state provided is in our list + if (!state || !this.openIdAuthSession.has(state)) { + Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') + return res.status(400).send('State parameter mismatch') + } + + let redirect_uri = this.openIdAuthSession.get(state).redirect_uri + + if (!redirect_uri) { + Logger.error('[Auth] No redirect URI') + return res.status(400).send('No redirect URI') + } + + this.openIdAuthSession.delete(state) + + const redirectUri = `${redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + // Redirect to the overwrite URI saved in the map + res.redirect(redirectUri) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`) + res.status(500).send('Internal Server Error') + } + }) + // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', (req, res, next) => { const oidcStrategy = passport._strategy('openid-client') diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 26a9d77b..e209fac9 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -629,6 +629,23 @@ class MiscController { } else { Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) } + } else if (key === 'authOpenIDMobileRedirectURIs') { + function isValidRedirectURI(uri) { + const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i'); + return pattern.test(uri); + } + + const uris = settingsUpdate[key] + if (!Array.isArray(uris) || + (uris.includes('*') && uris.length > 1) || + uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { + Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`) + continue + } + + // Update the URIs + Database.serverSettings[key] = uris + hasUpdates = true } else { const updatedValueType = typeof settingsUpdate[key] if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index bf3db557..6e9d8456 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -71,6 +71,7 @@ class ServerSettings { this.authOpenIDAutoLaunch = false this.authOpenIDAutoRegister = false this.authOpenIDMatchExistingBy = null + this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] if (settings) { this.construct(settings) @@ -126,6 +127,7 @@ class ServerSettings { this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null + this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -211,7 +213,8 @@ class ServerSettings { authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, - authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client } } @@ -220,6 +223,7 @@ class ServerSettings { delete json.tokenSecret delete json.authOpenIDClientID delete json.authOpenIDClientSecret + delete json.authOpenIDMobileRedirectURIs return json } @@ -254,7 +258,8 @@ class ServerSettings { authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, - authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client } }