From e521ddfab6933cef5823bc2b8320c28d32c7da67 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Sun, 8 Oct 2023 18:26:29 +0200 Subject: [PATCH 01/14] OAuth2 Support Using CapacitorHttp and in-app link --- components/connection/ServerConnectForm.vue | 152 +++++++++++++++----- ios/App/App/Info.plist | 11 ++ ios/App/Podfile | 2 +- ios/App/Podfile.lock | 50 ++++--- package-lock.json | 32 ++--- package.json | 4 +- 6 files changed, 177 insertions(+), 74 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index c2909281..c7ee4f57 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -78,9 +78,32 @@ diff --git a/plugins/init.client.js b/plugins/init.client.js index 2c9a4cf5..ec2a1c73 100644 --- a/plugins/init.client.js +++ b/plugins/init.client.js @@ -282,6 +282,14 @@ export default ({ store, app }, inject) => { window.history.back() } }) + + /** + * @see https://capacitorjs.com/docs/apis/app#addlistenerappurlopen- + * Listen for url open events for the app. This handles both custom URL scheme links as well as URLs your app handles + */ + App.addListener('appUrlOpen', (data) => { + eventBus.$emit('url-open', data.url) + }) } export { From 6c8833718008958204c24490ec7025b96e9a7a91 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Fri, 3 Nov 2023 21:29:37 +0100 Subject: [PATCH 05/14] oauth2: Force HTTPS, check state Also improve error handling --- components/connection/ServerConnectForm.vue | 82 ++++++++++++++++----- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 678c07c2..253cc453 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -97,7 +97,10 @@ export default { error: null, showForm: false, showAddCustomHeaders: false, - authMethods: [] + authMethods: [], + oauth: { + state: null + } } }, computed: { @@ -138,11 +141,28 @@ export default { // audiobookshelf://oauth?code... // urlObj.hostname for iOS and urlObj.pathname for android if (url.startsWith('audiobookshelf://oauth')) { + // Extract possible errors thrown by the SSO provider + const authError = urlObj.searchParams.get('error') + if (authError) { + console.warn(`[SSO] Received the following error: ${authError}`) + this.$toast.error(`SSO: Received the following error: ${authError}`) + return + } + // Extract oauth2 code to be exchanged for a token const authCode = urlObj.searchParams.get('code') // Extract the state variable const state = urlObj.searchParams.get('state') + if (this.oauth.state !== state) { + console.warn(`[SSO] Wrong state returned by SSO Provider`) + this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`) + return + } + + // Clear the state variable from the component config + this.oauth.state = null + if (authCode) { await this.oauthExchangeCodeForToken(authCode, state) } @@ -151,8 +171,19 @@ export default { } }, async clickLoginWithOpenId() { + // oauth standard requires https explicitly + if (!this.serverConfig.address.startsWith('https')) { + console.warn(`[SSO] Oauth2 requires HTTPS`) + this.$toast.error(`SSO: The URL to the server must be https:// secured`) + return + } + // First request that we want to do oauth/openid and get the URL which a browser window should open const redirectUrl = await this.oauthRequest(this.serverConfig.address) + if (!redirectUrl) { + // error message handled by oauthRequest + return + } // Actually we should be able to use the redirectUrl directly for Browser.open below // However it seems that when directly using it there is a malformation and leads to the error @@ -172,7 +203,16 @@ export default { return } - const host = `${redirectUrl.protocol}//${redirectUrl.host}` + if (redirectUrl.protocol !== 'https:') { + console.warn(`[SSO] Insecure Redirection by SSO provider: ${redirectUrl.protocol} is not allowed. Use HTTPS`) + this.$toast.error(`SSO: The SSO provider must return a HTTPS secured URL`) + return + } + + // We need to verify if the state is the same later + this.oauth.state = state + + const host = `https://${redirectUrl.host}` const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` // example url for authentik @@ -199,20 +239,22 @@ export default { } }) + // Every kind of redirection is allowed [RFC6749 - 1.7] + if (!(response.status >= 300 && response.status < 400)) { + throw new Error(`Unexpected response from server: ${response.status}`) + } + // Depending on iOS or Android, it can be location or Location... const locationHeader = response.headers[Object.keys(response.headers).find((key) => key.toLowerCase() === 'location')] - if (locationHeader) { - const url = new URL(locationHeader) - return url - } else { - console.log('[SSO] No location header in oauthRequest') - this.$toast.error(`SSO: Invalid answer`) - return null + if (!locationHeader) { + throw new Error(`No location header in SSO answer`) } + + const url = new URL(locationHeader) + return url } catch (error) { - console.log('[SSO] Error in oauthRequest: ' + error) - this.$toast.error(`SSO error: ${error}`) - return null + console.error(`[SSO] ${error.message}`) + this.$toast.error(`SSO Error: ${error.message}`) } }, async oauthExchangeCodeForToken(code, state) { @@ -224,26 +266,28 @@ export default { if (this.$platform === 'ios' || this.$platform === 'web') { await Browser.close() } + } catch(error) {} // No Error handling needed + try { const response = await CapacitorHttp.get({ url: backendEndpoint }) + if (!response.data || !response.data.user || !response.data.user.token) { + throw new Error('Token data is missing in the response.') + } + this.serverConfig.token = response.data.user.token const payload = await this.authenticateToken() if (!payload) { - console.log('[SSO] Failed getting token: ' + this.error) - this.$toast.error(`SSO error: ${this.error}`) - - return + throw new Error('Authentication failed with the provided token.') } this.setUserAndConnection(payload) } catch (error) { - console.log('[SSO] Error in exchangeCodeForToken: ' + error) - this.$toast.error(`SSO error: ${error}`) - return null + console.error('[SSO] Error in exchangeCodeForToken: ', error) + this.$toast.error(`SSO error: ${error.message || error}`) } }, addCustomHeaders() { From 29843980512014527970572091be2c5558493a6c Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Sat, 4 Nov 2023 15:25:03 +0100 Subject: [PATCH 06/14] ServerConnect: Improve connect flow * Prefer HTTPS if no protocol given * Retry with HTTP if no protocol given and HTTPS TCP establishment was not successful * Do validation checks, i.e. control for unexpected redirects. Don't allow for protocol downgrade * Provide good user messages on failure * Fix a bug where it would use the wrong protocol after connecting bc. of unchecked redirect * Reworked getRequest(...) to get information about the resulting URL and detailed errors --- components/connection/ServerConnectForm.vue | 213 ++++++++++++++++---- 1 file changed, 176 insertions(+), 37 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 253cc453..9cfb2bba 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -378,10 +378,18 @@ export default { this.error = null this.showAuth = false }, - validateServerUrl(url) { + /** + * Validates a URL and reconstructs it with an optional protocol override. + * If the URL is invalid, null is returned. + * + * @param {string} url - The URL to validate. + * @param {string|null} [protocolOverride=null] - (Optional) Protocol to override the URL's original protocol. + * @returns {string|null} The validated URL with the original or overridden protocol, or null if invalid. + */ + validateServerUrl(url, protocolOverride = null) { try { var urlObject = new URL(url) - var address = `${urlObject.protocol}//${urlObject.hostname}` + var address = `${protocolOverride ? protocolOverride : urlObject.protocol}//${urlObject.hostname}` if (urlObject.port) address += ':' + urlObject.port return address } catch (error) { @@ -389,18 +397,45 @@ export default { return null } }, + /** + * Sends a GET request to the specified URL with the provided headers and timeout. + * If the response is successful (HTTP 200), the response object is returned. + * Otherwise, throws an error object containing code. + * code can be either a number, which is then a HTTP status code or + * a string, which is then a keyword like NSURLErrorBadURL when the TCP connection could not be established. + * When code is a string, error.message contains the human readable error by the OS or + * the http body of the non-200 answer. + * + * @async + * @param {string} url - The URL to which the GET request will be sent. + * @param {Object} headers - HTTP headers to be included in the request. + * @param {number} [connectTimeout=6000] - Timeout for the request in milliseconds. + * @returns {Promise} The HTTP response object if the request is successful. + * @throws {Error} An error with 'code' property set to the HTTP status code if the response is not successful. + * @throws {Error} An error with 'code' property set to the error code if the request fails. + */ async getRequest(url, headers, connectTimeout = 6000) { const options = { url, headers, connectTimeout } - const response = await CapacitorHttp.get(options) - console.log('[ServerConnectForm] GET request response', response) - if (response.status >= 400) { - throw new Error(response.data) - } else { - return response.data + try { + const response = await CapacitorHttp.get(options) + console.log('[ServerConnectForm] GET request response', response) + if (response.status == 200) { + return response + } else { + // Put the HTTP error code inside the cause + let errorObj = new Error(response.data) + errorObj.code = response.status + throw errorObj + } + } catch (error) { + // Put the error name inside the cause (a string) + let errorObj = new Error(error.message) + errorObj.code = error.code + throw errorObj } }, async postRequest(url, data, headers, connectTimeout = 6000) { @@ -426,23 +461,16 @@ export default { * Get request to server /status api endpoint * * @param {string} address - * @returns {Promise<{isInit:boolean, language:string, authMethods:string[]}>} + * @returns {Promise} + * HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}> */ - getServerAddressStatus(address) { - return this.getRequest(`${address}/status`).catch((error) => { - console.error('Failed to get server status', error) - const errorMsg = error.message || error - this.error = 'Failed to ping server' - if (typeof errorMsg === 'string') { - this.error += ` (${errorMsg})` - } - return null - }) + async getServerAddressStatus(address) { + return this.getRequest(`${address}/status`) }, pingServerAddress(address, customHeaders) { return this.getRequest(`${address}/ping`, customHeaders) - .then((data) => { - return data.success + .then((response) => { + return response.data.success }) .catch((error) => { console.error('Server ping failed', error) @@ -478,31 +506,142 @@ export default { async submit() { if (!this.networkConnected) return if (!this.serverConfig.address) return - if (!this.serverConfig.address.startsWith('http')) { - this.serverConfig.address = 'http://' + this.serverConfig.address - } - var validServerAddress = this.validateServerUrl(this.serverConfig.address) - if (!validServerAddress) { - this.error = 'Invalid server address' - return - } - this.serverConfig.address = validServerAddress + const initialAddress = this.serverConfig.address + // Did the user specify a protocol? + const protocolProvided = initialAddress.startsWith('http://') || initialAddress.startsWith('https://') + // Add https:// if not provided + this.serverConfig.address = this.prependProtocolIfNeeded(initialAddress) + this.processing = true this.error = null this.authMethods = [] - const statusData = await this.getServerAddressStatus(this.serverConfig.address) - this.processing = false - if (statusData) { - if (!statusData.isInit) { - this.error = 'Server is not initialized' - } else { + try { + // Try the server URL. If it fails and the protocol was not provided, try with http instead of https + const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided) + if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) { this.showAuth = true - this.authMethods = statusData.authMethods || [] + this.authMethods = statusData.data.authMethods || [] } + } catch (error) { + this.handleLoginFormError(error) + } finally { + this.processing = false } }, + /** Validates the login form response from the server. + * + * Ensure the request has not been redirected to an unexpected hostname and check if it is Audiobookshelf + * + * @param {object} statusData - The data received from the server's response, including data and url. + * @param {string} initialAddressWithProtocol - The initial server address including the protocol used for the request. + * @param {boolean} protocolProvided - Indicates whether the protocol was explicitly provided in the initial address. + * + * @returns {boolean} - Returns `true` if the response is valid, otherwise `false` and sets this.error. + */ + validateLoginFormResponse(statusData, initialAddressWithProtocol, protocolProvided) { + // We have a 200 status code at this point + + // Check if we got redirected to a different hostname, we don't allow this + const initialAddressUrl = new URL(initialAddressWithProtocol) + const currentAddressUrl = new URL(statusData.url) + if (initialAddressUrl.hostname !== currentAddressUrl.hostname) { + this.error = `Server redirected somewhere else (to ${currentAddressUrl.hostname})` + console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`) + return false + } // We don't allow a redirection back from https to http if the user used https:// explicitly + else if (protocolProvided && + initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') { + this.error = `You specified https:// but the Server redirected back to plain http` + console.error(`[ServerConnectForm] User specified https:// but server redirected to http`) + return false + } + + // Check content of response now + if (!statusData || !statusData.data || Object.keys(statusData).length === 0) { + this.error = 'Response from server was empty' // Usually some kind of config error on server side + console.error('[ServerConnectForm] Received empty response') + return false + } else if (!('isInit' in statusData.data) || !('language' in statusData.data)) { // TODO + this.error = 'This does not seem to be a Audiobookshelf server' + console.error('[ServerConnectForm] Received as response from Server:\n', statusData) + return false + } else if (!statusData.data.isInit) { + this.error = 'Server is not initialized' + return false + } + + // If we got redirected from http to https, we allow this + // Also there is the possibility that https was tried (with protocolProvided false) but only http was successfull + // So set the correct protocol for the config + const configUrl = new URL(this.serverConfig.address) + configUrl.protocol = currentAddressUrl.protocol + this.serverConfig.address = configUrl.toString() + + return true + }, + /** + * Handles errors received during the login form process, providing user-friendly error messages. + * + * @param {Object} error - The error object received from a failed login attempt. + */ + handleLoginFormError(error) { + console.error('[ServerConnectForm] Received invalid status', error) + + if (error.code === 404) { + this.error = `This does not seem to be an Audiobookshelf server. (Error: 404)` + } else if (typeof error.code === "number") { // Error with HTTP Code + this.error = `Failed to retrieve status of server: ${error.code}` + } else { // error is usually a meaningful error like "Server timed out" + this.error = `Failed to contact server. (${error})` + } + }, + /** + * Attempts to retrieve the server address status for the given URL. + * If the initial attempt fails, it retries with HTTP if allowed. + * + * @param {string} address - The URL address to validate and check. + * @param {boolean} shouldRetryWithHttp - Flag to indicate if the function should retry with HTTP on failure. + * @returns {Promise} + * HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}> + * @throws Will throw an error if the URL has a wrong format or if both HTTPS and HTTP (if retried) requests fail. + */ + async tryServerUrl(address, shouldRetryWithHttp) { + const validatedUrl = this.validateServerUrl(address) + if (!validatedUrl) { + throw new Error('URL has wrong format') + } + + try { + return await this.getServerAddressStatus(validatedUrl) + } catch (error) { + // We only retry when the user did not specify a protocol + // Also for security reasons, we only retry when the https request did not + // return a http status code (so only retry when the TCP connection could not be established) + if (shouldRetryWithHttp && (typeof error.code !== "number")) { + console.log("[ServerConnectForm] https failed, trying to connect with http...") + const validatedHttpUrl = this.validateServerUrl(address, 'http:') + if (validatedHttpUrl) { + return await this.getServerAddressStatus(validatedHttpUrl) + } + // else if validatedHttpUrl is false return the original error below + } + // rethrow original error + throw error + } + }, + /** + * Ensures that a protocol is prepended to the given address if it does not already start with http:// or https://. + * + * @param {string} address - The server address that may or may not have a protocol. + * @returns {string} The address with a protocol prepended if it was missing. + */ + prependProtocolIfNeeded(address) { + return address.startsWith('http://') || address.startsWith('https://') + ? address + : `https://${address}` + }, async submitAuth() { if (!this.networkConnected) return if (!this.serverConfig.username) { From 8d8782a5a9960a79d3f255642fdec8d057682a18 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Sun, 5 Nov 2023 17:27:04 +0100 Subject: [PATCH 07/14] oauth2: Implement PKCE - Also fixed URLs, the / after ${this.serverConfig.address} had to be removed, because during Connection flow the URL is now (re)set correctly uniformly - Removed now uneccessary callback? parameter from first auth request --- components/connection/ServerConnectForm.vue | 34 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 9cfb2bba..85e55992 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -99,7 +99,8 @@ export default { showAddCustomHeaders: false, authMethods: [], oauth: { - state: null + state: null, + verifier: null } } }, @@ -226,9 +227,32 @@ export default { } }, async oauthRequest(url) { + // Generate oauth2 PKCE challenge + // In accordance to RFC 7636 Section 4 + function base64URLEncode(arrayBuffer) { + let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer))) + return base64String + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + } + async function sha256(buffer) { + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + return new Uint8Array(hashBuffer) + } + + const randomBuffer = new Uint8Array(64) + window.crypto.getRandomValues(randomBuffer) + const verifier = base64URLEncode(randomBuffer) + + const challengeBuffer = await sha256(randomBuffer) + const challenge = base64URLEncode(challengeBuffer) + + this.oauth.verifier = verifier + + // set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken - // We dont need the callback parameter strictly speaking, but we must provide something or passport will error out as it seems to always expect it - const backendEndpoint = `${url}/auth/openid?callback=${encodeURIComponent('/login')}&isRest=true` + const backendEndpoint = `${url}auth/openid?code_challenge=${challenge}&code_challenge_method=S256&isRest=true` try { const response = await CapacitorHttp.get({ @@ -259,7 +283,7 @@ export default { }, async oauthExchangeCodeForToken(code, state) { // We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info - const backendEndpoint = `${this.serverConfig.address}/auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}` + const backendEndpoint = `${this.serverConfig.address}auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}` try { // We can close the browser at this point (does not work on Android) @@ -701,7 +725,7 @@ export default { this.error = null this.processing = true - const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => { + const authRes = await this.postRequest(`${this.serverConfig.address}api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => { console.error('[ServerConnectForm] Server auth failed', error) const errorMsg = error.message || error this.error = 'Failed to authorize' From 945baa24f00db2ae2914ff9e835ee1477decfbb3 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Sun, 5 Nov 2023 20:32:14 +0100 Subject: [PATCH 08/14] Connect: Check if audiobookshelf and check for version --- components/connection/ServerConnectForm.vue | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 13599e9d..32696384 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -82,6 +82,8 @@ import { Browser } from '@capacitor/browser' import { CapacitorHttp } from '@capacitor/core' import { Dialog } from '@capacitor/dialog' +const requiredServerVersion = '2.5.0' + export default { data() { return { @@ -587,10 +589,14 @@ export default { this.error = 'Response from server was empty' // Usually some kind of config error on server side console.error('[ServerConnectForm] Received empty response') return false - } else if (!('isInit' in statusData.data) || !('language' in statusData.data)) { // TODO + } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') { this.error = 'This does not seem to be a Audiobookshelf server' console.error('[ServerConnectForm] Received as response from Server:\n', statusData) return false + } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) { + this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`; + console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion); + return false; } else if (!statusData.data.isInit) { this.error = 'Server is not initialized' return false @@ -666,6 +672,26 @@ export default { ? address : `https://${address}` }, + /** + * Compares two semantic versioning strings to determine if the current version meets + * or exceeds the minimum version requirement. + * + * @param {string} currentVersion - The current version string to compare, e.g., "1.2.3". + * @param {string} minVersion - The minimum version string required, e.g., "1.0.0". + * @returns {boolean} - Returns true if the current version is greater than or equal + * to the minimum version, false otherwise. + */ + isValidVersion(currentVersion, minVersion) { + const currentParts = currentVersion.split('.').map(Number); + const minParts = minVersion.split('.').map(Number); + + for (let i = 0; i < minParts.length; i++) { + if (currentParts[i] > minParts[i]) return true; + if (currentParts[i] < minParts[i]) return false; + } + + return true; + }, async submitAuth() { if (!this.networkConnected) return if (!this.serverConfig.username) { From 1a6b716046517cc8d94007207b21e9cd0f9318dd Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Sun, 5 Nov 2023 21:01:51 +0100 Subject: [PATCH 09/14] oauth2: Add comments - Move appUrlOpen to represent the correct order of flow --- components/connection/ServerConnectForm.vue | 122 ++++++++++++++------ 1 file changed, 84 insertions(+), 38 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 32696384..40a6330e 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -135,44 +135,23 @@ export default { } }, methods: { - async appUrlOpen(url) { - if (!url) return - - // Handle the OAuth callback - const urlObj = new URL(url) - - // audiobookshelf://oauth?code... - // urlObj.hostname for iOS and urlObj.pathname for android - if (url.startsWith('audiobookshelf://oauth')) { - // Extract possible errors thrown by the SSO provider - const authError = urlObj.searchParams.get('error') - if (authError) { - console.warn(`[SSO] Received the following error: ${authError}`) - this.$toast.error(`SSO: Received the following error: ${authError}`) - return - } - - // Extract oauth2 code to be exchanged for a token - const authCode = urlObj.searchParams.get('code') - // Extract the state variable - const state = urlObj.searchParams.get('state') - - if (this.oauth.state !== state) { - console.warn(`[SSO] Wrong state returned by SSO Provider`) - this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`) - return - } - - // Clear the state variable from the component config - this.oauth.state = null - - if (authCode) { - await this.oauthExchangeCodeForToken(authCode, state) - } - } else { - console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`) - } - }, + /** + * Initiates the login process using OpenID via OAuth2.0. + * 1. Verifying the server's address + * 2. Calling oauthRequest() to obtain the special OpenID redirect URL + * including a challenge and specying audiobookshelf://oauth as redirect URL + * 3. Open this redirect URL in browser (which is a website of the SSO provider) + * + * When the browser is open, the following flow is expected: + * a. The user authenticates and the provider redirects back to custom URL audiobookshelf://oauth + * b. The app calls appUrlOpen() when `audiobookshelf://oauth` is called + * b. appUrlOpen() handles the incoming URL and extracts the authorization code from GET parameter + * c. oauthExchangeCodeForToken() exchanges the authorization code for an access token + * + * + * @async + * @throws Will log a console error if the browser fails to open the URL and display errors via this.error to the user. + */ async clickLoginWithOpenId() { // oauth standard requires https explicitly if (!this.serverConfig.address.startsWith('https')) { @@ -228,6 +207,14 @@ export default { console.error('Error opening browser', error) } }, + /** + * Requests the OAuth/OpenID URL from the backend server to open in browser + * + * @async + * @param {string} url - The base URL of the server to append the OAuth request parameters to. + * @return {Promise} OAuth URL which should be opened in a browser + * @throws Logs an error and displays a toast notification if the token exchange fails. + */ async oauthRequest(url) { // Generate oauth2 PKCE challenge // In accordance to RFC 7636 Section 4 @@ -283,6 +270,62 @@ export default { this.$toast.error(`SSO Error: ${error.message}`) } }, + /** + * Handles the callback received from the OAuth/OpenID provider. + * + * @async + * @function appUrlOpen + * @param {string} url - The callback URL received from the OAuth/OpenID provider. + * @throws Logs a warning and displays a toast notification if the URL is invalid or the state doesn't match. + */ + async appUrlOpen(url) { + if (!url) return + + // Handle the OAuth callback + const urlObj = new URL(url) + + // audiobookshelf://oauth?code... + // urlObj.hostname for iOS and urlObj.pathname for android + if (url.startsWith('audiobookshelf://oauth')) { + // Extract possible errors thrown by the SSO provider + const authError = urlObj.searchParams.get('error') + if (authError) { + console.warn(`[SSO] Received the following error: ${authError}`) + this.$toast.error(`SSO: Received the following error: ${authError}`) + return + } + + // Extract oauth2 code to be exchanged for a token + const authCode = urlObj.searchParams.get('code') + // Extract the state variable + const state = urlObj.searchParams.get('state') + + if (this.oauth.state !== state) { + console.warn(`[SSO] Wrong state returned by SSO Provider`) + this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`) + return + } + + // Clear the state variable from the component config + this.oauth.state = null + + if (authCode) { + await this.oauthExchangeCodeForToken(authCode, state) + } + } else { + console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`) + } + }, + /** + * Exchanges an oauth2 authorization code for a JWT token. + * And uses that token to finalise the log in process using authenticateToken() + * + * @async + * @function oauthExchangeCodeForToken + * @param {string} code - The authorization code provided by the OpenID provider. + * @param {string} state - The state value used to associate a client session with an ID token. + * @throws Logs an error and displays a toast notification if the token exchange fails. + */ async oauthExchangeCodeForToken(code, state) { // We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info const backendEndpoint = `${this.serverConfig.address}auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}` @@ -314,6 +357,9 @@ export default { } catch (error) { console.error('[SSO] Error in exchangeCodeForToken: ', error) this.$toast.error(`SSO error: ${error.message || error}`) + } finally { + // We don't need the oauth verifier any more + this.oauth.verifier = null } }, addCustomHeaders() { From 0daa043f1454cb089841156fa82193450e67e198 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Sun, 5 Nov 2023 21:34:28 +0100 Subject: [PATCH 10/14] oauth: Customizable OpenID Buttontext and auto-launch --- components/connection/ServerConnectForm.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 40a6330e..57a8ac93 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -52,7 +52,7 @@
- Login with OpenId + {{ oauth.buttonText }}
@@ -102,7 +102,8 @@ export default { authMethods: [], oauth: { state: null, - verifier: null + verifier: null, + buttonText: 'Login with OpenID' } } }, @@ -595,6 +596,11 @@ export default { if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) { this.showAuth = true this.authMethods = statusData.data.authMethods || [] + this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID' + + if (statusData.data.authFormData?.authOpenIDAutoLaunch) { + this.clickLoginWithOpenId() + } } } catch (error) { this.handleLoginFormError(error) From 5fdd0c667245491174832306c8d394915e8092e4 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Fri, 10 Nov 2023 22:30:55 +0100 Subject: [PATCH 11/14] oauth2: Fix PKCE - Send challenge to the SSO provider itself too - Fix challenge generation - Removed semicolons --- components/connection/ServerConnectForm.vue | 49 +++++++++++++-------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 57a8ac93..f2b32a5b 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -103,6 +103,7 @@ export default { oauth: { state: null, verifier: null, + challenge: null, buttonText: 'Login with OpenID' } } @@ -196,7 +197,10 @@ export default { this.oauth.state = state const host = `https://${redirectUrl.host}` - const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256` // example url for authentik // const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..." @@ -224,21 +228,27 @@ export default { return base64String .replace(/\+/g, '-') .replace(/\//g, '_') - .replace(/=/g, '') - } - async function sha256(buffer) { - const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) - return new Uint8Array(hashBuffer) + .replace(/=+$/g, '') } - const randomBuffer = new Uint8Array(64) - window.crypto.getRandomValues(randomBuffer) - const verifier = base64URLEncode(randomBuffer) + async function sha256(plain) { + const encoder = new TextEncoder() + const data = encoder.encode(plain) + return await window.crypto.subtle.digest('SHA-256', data) + } - const challengeBuffer = await sha256(randomBuffer) - const challenge = base64URLEncode(challengeBuffer) + function generateRandomString() { + var array = new Uint32Array(42) + window.crypto.getRandomValues(array) + return Array.from(array, dec => ('0' + dec.toString(16)).slice(-2)).join('') // hex + } + + const verifier = generateRandomString() + + const challenge = base64URLEncode(await sha256(verifier)) this.oauth.verifier = verifier + this.oauth.challenge = challenge // set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken @@ -361,6 +371,7 @@ export default { } finally { // We don't need the oauth verifier any more this.oauth.verifier = null + this.oauth.challenge = null } }, addCustomHeaders() { @@ -646,9 +657,9 @@ export default { console.error('[ServerConnectForm] Received as response from Server:\n', statusData) return false } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) { - this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`; - console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion); - return false; + this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})` + console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion) + return false } else if (!statusData.data.isInit) { this.error = 'Server is not initialized' return false @@ -734,15 +745,15 @@ export default { * to the minimum version, false otherwise. */ isValidVersion(currentVersion, minVersion) { - const currentParts = currentVersion.split('.').map(Number); - const minParts = minVersion.split('.').map(Number); + const currentParts = currentVersion.split('.').map(Number) + const minParts = minVersion.split('.').map(Number) for (let i = 0; i < minParts.length; i++) { - if (currentParts[i] > minParts[i]) return true; - if (currentParts[i] < minParts[i]) return false; + if (currentParts[i] > minParts[i]) return true + if (currentParts[i] < minParts[i]) return false } - return true; + return true }, async submitAuth() { if (!this.networkConnected) return From 4f994072adf948d2f7823b7ab81496e343ae5a74 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 11 Nov 2023 13:55:32 -0600 Subject: [PATCH 12/14] Check for duplicate config when authenticating with openid --- components/connection/ServerConnectForm.vue | 37 ++++++++++----------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index f2b32a5b..bdbf8790 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -197,10 +197,7 @@ export default { this.oauth.state = state const host = `https://${redirectUrl.host}` - const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + - `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + - `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + - `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256` + const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256` // example url for authentik // const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..." @@ -225,10 +222,7 @@ export default { // In accordance to RFC 7636 Section 4 function base64URLEncode(arrayBuffer) { let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer))) - return base64String - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, '') + return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') } async function sha256(plain) { @@ -240,7 +234,7 @@ export default { function generateRandomString() { var array = new Uint32Array(42) window.crypto.getRandomValues(array) - return Array.from(array, dec => ('0' + dec.toString(16)).slice(-2)).join('') // hex + return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join('') // hex } const verifier = generateRandomString() @@ -250,7 +244,6 @@ export default { this.oauth.verifier = verifier this.oauth.challenge = challenge - // set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken const backendEndpoint = `${url}auth/openid?code_challenge=${challenge}&code_challenge_method=S256&isRest=true` @@ -346,7 +339,7 @@ export default { if (this.$platform === 'ios' || this.$platform === 'web') { await Browser.close() } - } catch(error) {} // No Error handling needed + } catch (error) {} // No Error handling needed try { const response = await CapacitorHttp.get({ @@ -364,6 +357,11 @@ export default { throw new Error('Authentication failed with the provided token.') } + const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username) + if (duplicateConfig) { + throw new Error('Config already exists for this address and username.') + } + this.setUserAndConnection(payload) } catch (error) { console.error('[SSO] Error in exchangeCodeForToken: ', error) @@ -640,8 +638,7 @@ export default { console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`) return false } // We don't allow a redirection back from https to http if the user used https:// explicitly - else if (protocolProvided && - initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') { + else if (protocolProvided && initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') { this.error = `You specified https:// but the Server redirected back to plain http` console.error(`[ServerConnectForm] User specified https:// but server redirected to http`) return false @@ -684,9 +681,11 @@ export default { if (error.code === 404) { this.error = `This does not seem to be an Audiobookshelf server. (Error: 404)` - } else if (typeof error.code === "number") { // Error with HTTP Code + } else if (typeof error.code === 'number') { + // Error with HTTP Code this.error = `Failed to retrieve status of server: ${error.code}` - } else { // error is usually a meaningful error like "Server timed out" + } else { + // error is usually a meaningful error like "Server timed out" this.error = `Failed to contact server. (${error})` } }, @@ -712,8 +711,8 @@ export default { // We only retry when the user did not specify a protocol // Also for security reasons, we only retry when the https request did not // return a http status code (so only retry when the TCP connection could not be established) - if (shouldRetryWithHttp && (typeof error.code !== "number")) { - console.log("[ServerConnectForm] https failed, trying to connect with http...") + if (shouldRetryWithHttp && typeof error.code !== 'number') { + console.log('[ServerConnectForm] https failed, trying to connect with http...') const validatedHttpUrl = this.validateServerUrl(address, 'http:') if (validatedHttpUrl) { return await this.getServerAddressStatus(validatedHttpUrl) @@ -731,9 +730,7 @@ export default { * @returns {string} The address with a protocol prepended if it was missing. */ prependProtocolIfNeeded(address) { - return address.startsWith('http://') || address.startsWith('https://') - ? address - : `https://${address}` + return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}` }, /** * Compares two semantic versioning strings to determine if the current version meets From 6a938b8da1b1b0d7ff710dbeefaf07102de87b2b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 11 Nov 2023 14:00:48 -0600 Subject: [PATCH 13/14] Update button UI to be full width --- components/connection/ServerConnectForm.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index bdbf8790..779bebd3 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -51,8 +51,8 @@ {{ networkConnected ? 'Submit' : 'No Internet' }} -
- {{ oauth.buttonText }} +
+ {{ oauth.buttonText }}
From a42dfa56494275ffb423417b5900c76c6296b65d Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Sat, 11 Nov 2023 21:30:10 +0100 Subject: [PATCH 14/14] Don't check version for now - Be more explicit on error 404, to not confuse users - Error for when there is no authCode --- components/connection/ServerConnectForm.vue | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 779bebd3..0f33f152 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -82,7 +82,8 @@ import { Browser } from '@capacitor/browser' import { CapacitorHttp } from '@capacitor/core' import { Dialog } from '@capacitor/dialog' -const requiredServerVersion = '2.5.0' +// TODO: when backend ready. See validateLoginFormResponse() +//const requiredServerVersion = '2.5.0' export default { data() { @@ -315,6 +316,9 @@ export default { if (authCode) { await this.oauthExchangeCodeForToken(authCode, state) + } else { + console.warn(`[SSO] No code received`) + this.$toast.error(`SSO: The response from the SSO Provider did not include a code (authentication error?)`) } } else { console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`) @@ -649,14 +653,19 @@ export default { this.error = 'Response from server was empty' // Usually some kind of config error on server side console.error('[ServerConnectForm] Received empty response') return false - } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') { + } else if (!('isInit' in statusData.data) || !('language' in statusData.data)) { this.error = 'This does not seem to be a Audiobookshelf server' console.error('[ServerConnectForm] Received as response from Server:\n', statusData) return false - } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) { - this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})` - console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion) - return false +// TODO: delete the if above and comment the ones below out, as soon as the backend is ready to introduce a version check +// } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') { +// this.error = 'This does not seem to be a Audiobookshelf server' +// console.error('[ServerConnectForm] Received as response from Server:\n', statusData) +// return false +// } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) { +// this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})` +// console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion) +// return false } else if (!statusData.data.isInit) { this.error = 'Server is not initialized' return false @@ -680,7 +689,7 @@ export default { console.error('[ServerConnectForm] Received invalid status', error) if (error.code === 404) { - this.error = `This does not seem to be an Audiobookshelf server. (Error: 404)` + this.error = `This does not seem to be an Audiobookshelf server. (Error: 404 querying /status)` } else if (typeof error.code === 'number') { // Error with HTTP Code this.error = `Failed to retrieve status of server: ${error.code}`