OAuth2 Support

Using CapacitorHttp and in-app link
This commit is contained in:
Denis Arnst 2023-10-08 18:26:29 +02:00
parent 6b164bdb27
commit e521ddfab6
6 changed files with 177 additions and 74 deletions

View file

@ -78,9 +78,32 @@
</template> </template>
<script> <script>
import { App } from '@capacitor/app';
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { CapacitorHttp } from '@capacitor/core' import { CapacitorHttp } from '@capacitor/core'
import { OAuth2Client } from '@byteowls/capacitor-oauth2' import { Browser } from '@capacitor/browser';
// Variable which is set to an instance of ServerConnectForm.vue used below of the listener
let serverConnectForm = null;
App.addListener('appUrlOpen', async (data) => {
// Handle the OAuth callback
const url = new URL(data.url)
// audiobookshelf://oauth?code...
if (url.host === 'oauth') {
// Extract oauth2 code to be exchanged for a token
const authCode = url.searchParams.get('code')
// Extract the state variable
const state = url.searchParams.get('state')
if (authCode) {
await serverConnectForm.oauthExchangeCodeForToken(authCode, state)
}
} else {
console.warn(`[appUrlOpen] Unknown url: ${data.url}`)
}
});
export default { export default {
data() { data() {
@ -130,40 +153,103 @@ export default {
}, },
methods: { methods: {
async clickLoginWithOpenId() { async clickLoginWithOpenId() {
this.error = '' serverConnectForm = this
const options = {
authorizationBaseUrl: `${this.serverConfig.address}/auth/openid`, // First request that we want to do oauth/openid and get the URL which a browser window should open
logsEnabled: true, const redirectUrl = await this.oauthRequest(this.serverConfig.address)
web: {
appId: 'com.audiobookshelf.web', // Actually we should be able to use the redirectUrl directly for Browser.open below
responseType: 'token', // However it seems that when directly using it there is a malformation and leads to the error
redirectUrl: location.origin // Unhandled Promise Rejection: DataCloneError: The object can not be cloned.
}, // (On calling Browser.open)
android: { // Which is hard to debug
appId: 'com.audiobookshelf.app', // So we simply extract the important elements and build the required URL ourselves
responseType: 'code', // which also has the advantage that we can replace the callbackurl with the app url
redirectUrl: 'com.audiobookshelf.app:/'
} const client_id = redirectUrl.searchParams.get('client_id')
const scope = redirectUrl.searchParams.get('scope')
const state = redirectUrl.searchParams.get('state')
if (!client_id || !scope || !state) {
console.warn(`[SSO] Invalid OpenID URL - client_id scope or state missing: ${redirectUrl}`)
this.$toast.error(`SSO: Invalid answer`)
return
} }
OAuth2Client.authenticate(options)
.then(async (response) => { const host = `${redirectUrl.protocol}//${redirectUrl.host}${redirectUrl.port ? ':' + redirectUrl.port : ''}`
const token = response.authorization_response?.additional_parameters?.setToken || response.authorization_response?.setToken const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` +
if (token) { `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` +
this.serverConfig.token = token `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}`
const payload = await this.authenticateToken()
if (payload) { // example url for authentik
this.setUserAndConnection(payload) // 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...";
} else {
this.showAuth = true // Open the browser. The browser/identity provider in turn will redirect to an in-app link supplementing a code
} try {
} else { await Browser.open({ url: buildUrl });
this.error = 'Invalid response: No token' } catch (error) {
} console.error("Error opening browser", error);
}) }
.catch((error) => { },
console.error('OAuth rejected', error) async oauthRequest(url) {
this.error = error.toString?.() || error.message // 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`
try {
const response = await CapacitorHttp.get({
url: backendEndpoint,
disableRedirects: true,
webFetchExtra: {
redirect: "manual"
},
}) })
const locationHeader = response.headers["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
}
} catch (error) {
console.log('[SSO] Error in oauthRequest: ' + error)
this.$toast.error(`SSO error: ${error}`)
return null
}
},
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)}`;
try {
// We can close the browser at this point
await Browser.close()
const response = await CapacitorHttp.get({
url: backendEndpoint
});
serverConnectForm.serverConfig.token = response.data.user.token
const payload = await serverConnectForm.authenticateToken()
if (!payload) {
console.log('[SSO] Failed getting token: ' + this.error);
this.$toast.error(`SSO error: ${this.error}`)
return
}
serverConnectForm.setUserAndConnection(payload)
} catch (error) {
console.log('[SSO] Error in exchangeCodeForToken: ' + error);
this.$toast.error(`SSO error: ${error}`)
return null;
}
}, },
addCustomHeaders() { addCustomHeaders() {
this.showAddCustomHeaders = true this.showAddCustomHeaders = true

View file

@ -2,6 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.audiobookshelf.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>audiobookshelf</string>
</array>
</dict>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>

View file

@ -11,8 +11,8 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'ByteowlsCapacitorOauth2', :path => '../../node_modules/@byteowls/capacitor-oauth2'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard' pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog' pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'

View file

@ -1,23 +1,25 @@
PODS: PODS:
- Alamofire (5.6.4) - Alamofire (5.6.4)
- Capacitor (4.8.0): - Capacitor (5.4.0):
- CapacitorCordova - CapacitorCordova
- CapacitorApp (4.1.1): - CapacitorApp (5.0.6):
- Capacitor - Capacitor
- CapacitorClipboard (4.1.0): - CapacitorBrowser (5.1.0):
- Capacitor - Capacitor
- CapacitorCordova (4.8.0) - CapacitorClipboard (5.0.6):
- CapacitorDialog (4.1.0):
- Capacitor - Capacitor
- CapacitorHaptics (4.1.0): - CapacitorCordova (5.4.0)
- CapacitorDialog (5.0.6):
- Capacitor - Capacitor
- CapacitorNetwork (4.1.0): - CapacitorHaptics (5.0.6):
- Capacitor - Capacitor
- CapacitorPreferences (4.0.2): - CapacitorNetwork (5.0.6):
- Capacitor - Capacitor
- CapacitorStatusBar (4.1.1): - CapacitorPreferences (5.0.6):
- Capacitor - Capacitor
- CordovaPlugins (4.8.0): - CapacitorStatusBar (5.0.6):
- Capacitor
- CordovaPlugins (5.4.0):
- CapacitorCordova - CapacitorCordova
- Realm (10.36.0): - Realm (10.36.0):
- Realm/Headers (= 10.36.0) - Realm/Headers (= 10.36.0)
@ -29,6 +31,7 @@ DEPENDENCIES:
- Alamofire (~> 5.5) - Alamofire (~> 5.5)
- "Capacitor (from `../../node_modules/@capacitor/ios`)" - "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
- "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)" - "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)" - "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)"
@ -50,6 +53,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios" :path: "../../node_modules/@capacitor/ios"
CapacitorApp: CapacitorApp:
:path: "../../node_modules/@capacitor/app" :path: "../../node_modules/@capacitor/app"
CapacitorBrowser:
:path: "../../node_modules/@capacitor/browser"
CapacitorClipboard: CapacitorClipboard:
:path: "../../node_modules/@capacitor/clipboard" :path: "../../node_modules/@capacitor/clipboard"
CapacitorCordova: CapacitorCordova:
@ -69,19 +74,20 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
Capacitor: 6002aadd64492438e5242325025045235dcb7e84 Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
CapacitorApp: acd42fe8561fe751ad5b5f459aa85e6acd7bee24 CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
CapacitorClipboard: 4c092a2608520afb799429e63820256c3a59f1e5 CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
CapacitorCordova: c6249dcb2cf04dd835c0e99df1df4b9c8ad997e2 CapacitorClipboard: 77edf49827ea21da2a9c05c690a4a6a4d07199c4
CapacitorDialog: c8a6558d29767e76a32a056bb5e0fc9104b985b0 CapacitorCordova: 66ce22f9976de30fd816f746e9e92e07d6befafd
CapacitorHaptics: 213b3a1f3efd6dbf6e6b76a1b2bb0399cf43b213 CapacitorDialog: 0f3c15dfe9414b83bc64aef4078f1b92bcfead26
CapacitorNetwork: 7126b3d2d23ca60d5ac0d8d2ecccfab0b1f305c6 CapacitorHaptics: 1fffc1217c7e64a472d7845be50fb0c2f7d4204c
CapacitorPreferences: 1d66dc32299f55ed632c5611f312878979275ea5 CapacitorNetwork: d80b3e79bef6ec37640ee2806c19771f07ff2d0c
CapacitorStatusBar: 65933e554bb5d65b361deaa936a93616086a2608 CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
CordovaPlugins: b7ac282a1681fad663e14dcbe719249f738b88ce CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5 Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716 RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
PODFILE CHECKSUM: 05c80969578f3260e71d903c6ddb969847bcceb2 PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
COCOAPODS: 1.12.1 COCOAPODS: 1.12.0

32
package-lock.json generated
View file

@ -8,9 +8,9 @@
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.66-beta", "version": "0.9.66-beta",
"dependencies": { "dependencies": {
"@byteowls/capacitor-oauth2": "^5.0.0",
"@capacitor/android": "^5.0.0", "@capacitor/android": "^5.0.0",
"@capacitor/app": "^5.0.0", "@capacitor/app": "^5.0.6",
"@capacitor/browser": "^5.1.0",
"@capacitor/clipboard": "^5.0.0", "@capacitor/clipboard": "^5.0.0",
"@capacitor/core": "^5.0.0", "@capacitor/core": "^5.0.0",
"@capacitor/dialog": "^5.0.0", "@capacitor/dialog": "^5.0.0",
@ -1939,14 +1939,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@byteowls/capacitor-oauth2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@byteowls/capacitor-oauth2/-/capacitor-oauth2-5.0.0.tgz",
"integrity": "sha512-yW50GypmyPJcH/95NwR2jJcgT78vBN3FYKL2w6A3vrT04bRLQyw2K0fLqfj8Zws6DJy43Ck1wPs0Bcdvbsub7A==",
"peerDependencies": {
"@capacitor/core": ">=5"
}
},
"node_modules/@capacitor/android": { "node_modules/@capacitor/android": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
@ -1963,6 +1955,14 @@
"@capacitor/core": "^5.0.0" "@capacitor/core": "^5.0.0"
} }
}, },
"node_modules/@capacitor/browser": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-5.1.0.tgz",
"integrity": "sha512-7togqchk2Tvq4SmLaWhcrd4x48ES/GEZsceM+29aun7WhxQEVcDU0cJsVdSU2LNFwNhWgPV2GW90etVd1B3OdQ==",
"peerDependencies": {
"@capacitor/core": "^5.0.0"
}
},
"node_modules/@capacitor/cli": { "node_modules/@capacitor/cli": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
@ -21078,12 +21078,6 @@
"to-fast-properties": "^2.0.0" "to-fast-properties": "^2.0.0"
} }
}, },
"@byteowls/capacitor-oauth2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@byteowls/capacitor-oauth2/-/capacitor-oauth2-5.0.0.tgz",
"integrity": "sha512-yW50GypmyPJcH/95NwR2jJcgT78vBN3FYKL2w6A3vrT04bRLQyw2K0fLqfj8Zws6DJy43Ck1wPs0Bcdvbsub7A==",
"requires": {}
},
"@capacitor/android": { "@capacitor/android": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
@ -21096,6 +21090,12 @@
"integrity": "sha512-6ZXVdnNmaYILasC/RjQw+yfTmq2ZO7Q3v5lFcDVfq3PFGnybyYQh+RstBrYri+376OmXOXxBD7E6UxBhrMzXGA==", "integrity": "sha512-6ZXVdnNmaYILasC/RjQw+yfTmq2ZO7Q3v5lFcDVfq3PFGnybyYQh+RstBrYri+376OmXOXxBD7E6UxBhrMzXGA==",
"requires": {} "requires": {}
}, },
"@capacitor/browser": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-5.1.0.tgz",
"integrity": "sha512-7togqchk2Tvq4SmLaWhcrd4x48ES/GEZsceM+29aun7WhxQEVcDU0cJsVdSU2LNFwNhWgPV2GW90etVd1B3OdQ==",
"requires": {}
},
"@capacitor/cli": { "@capacitor/cli": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",

View file

@ -13,9 +13,9 @@
"ionic:serve": "npm run start" "ionic:serve": "npm run start"
}, },
"dependencies": { "dependencies": {
"@byteowls/capacitor-oauth2": "^5.0.0",
"@capacitor/android": "^5.0.0", "@capacitor/android": "^5.0.0",
"@capacitor/app": "^5.0.0", "@capacitor/app": "^5.0.6",
"@capacitor/browser": "^5.1.0",
"@capacitor/clipboard": "^5.0.0", "@capacitor/clipboard": "^5.0.0",
"@capacitor/core": "^5.0.0", "@capacitor/core": "^5.0.0",
"@capacitor/dialog": "^5.0.0", "@capacitor/dialog": "^5.0.0",