feat(tauri): offload OAuth login flow to a separate window

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
This commit is contained in:
Kasper Seweryn 2024-02-15 21:30:41 +01:00
parent 419da80e37
commit 0095fc566e
9 changed files with 160 additions and 50 deletions

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { BackendError } from '~/types'
import type { RouteLocationRaw } from 'vue-router'
import { onBeforeRouteLeave, type RouteLocationRaw, useRouter } from 'vue-router'
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useEventListener } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
@ -26,6 +26,15 @@ const { t } = useI18n()
const store = useStore()
const router = useRouter()
// TODO (wvffle): Move to store logic when migrated to pinia
useEventListener(window, 'beforeunload', () => {
store.dispatch('auth/tryFinishOAuthFlow')
})
onBeforeRouteLeave(() => {
store.dispatch('auth/tryFinishOAuthFlow')
})
const credentials = reactive({
username: '',
password: ''
@ -53,13 +62,13 @@ const submit = async () => {
} else {
await store.dispatch('auth/oauthLogin', props.next)
}
} catch (error) {
} catch (error: any) {
const backendError = error as BackendError
if (backendError.response?.status === 400) {
errors.value = ['invalid_credentials']
} else {
errors.value = backendError.backendErrors
errors.value = backendError.backendErrors ?? error.message ?? error
}
}
@ -68,20 +77,14 @@ const submit = async () => {
</script>
<template>
<form
class="ui form"
@submit.prevent="submit()"
>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<form class="ui form" @submit.prevent="submit()">
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header">
{{ $t('components.auth.LoginForm.header.loginFailure') }}
</h4>
<ul class="list">
<li v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value">
<li
v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value">
{{ $t('components.auth.LoginForm.help.approvalRequired') }}
</li>
<li v-else-if="errors[0] == 'invalid_credentials'">
@ -98,38 +101,23 @@ const submit = async () => {
{{ $t('components.auth.LoginForm.label.username') }}
<template v-if="showSignup">
<span class="middle pipe symbol" />
<router-link :to="{path: '/signup'}">
<router-link :to="{ path: '/signup' }">
{{ $t('components.auth.LoginForm.link.createAccount') }}
</router-link>
</template>
</label>
<input
id="username-field"
ref="username"
v-model="credentials.username"
required
name="username"
type="text"
autofocus
:placeholder="labels.usernamePlaceholder"
>
<input id="username-field" ref="username" v-model="credentials.username" required name="username" type="text"
autofocus :placeholder="labels.usernamePlaceholder">
</div>
<div class="field">
<label for="password-field">
{{ $t('components.auth.LoginForm.label.password') }}
<span class="middle pipe symbol" />
<router-link
tabindex="1"
:to="{name: 'auth.password-reset', query: {email: credentials.username}}"
>
<router-link tabindex="1" :to="{ name: 'auth.password-reset', query: { email: credentials.username } }">
{{ $t('components.auth.LoginForm.link.resetPassword') }}
</router-link>
</label>
<password-input
v-model="credentials.password"
field-id="password-field"
required
/>
<password-input v-model="credentials.password" field-id="password-field" required />
</div>
</template>
<template v-else>
@ -137,10 +125,7 @@ const submit = async () => {
{{ $t('components.auth.LoginForm.message.redirect', { domain: $store.getters['instance/domain'] }) }}
</p>
</template>
<button
:class="['ui', {'loading': isLoading}, 'right', 'floated', buttonClasses, 'button']"
type="submit"
>
<button :class="['ui', { 'loading': isLoading }, 'right', 'floated', buttonClasses, 'button']" type="submit">
{{ $t('components.auth.LoginForm.button.login') }}
</button>
</form>

View File

@ -2,6 +2,7 @@ import type { BackendError, User } from '~/types'
import type { Module } from 'vuex'
import type { RootState } from '~/store/index'
import type { RouteLocationRaw } from 'vue-router'
import type { WebviewWindow } from '@tauri-apps/api/webview'
import axios from 'axios'
import useLogger from '~/composables/useLogger'
@ -9,6 +10,7 @@ import useFormData from '~/composables/useFormData'
import { clear as clearIDB } from 'idb-keyval'
import { useQueue } from '~/composables/audio/queue'
import { isTauri } from '~/composables/tauri'
export type Permission = 'settings' | 'library' | 'moderation'
export interface State {
@ -21,6 +23,8 @@ export interface State {
scopedTokens: ScopedTokens
applicationSecret: string | undefined
oauthWindow: WebviewWindow | undefined
}
interface ScopedTokens {
@ -55,7 +59,7 @@ function getDefaultOauth (): OAuthTokens {
async function createOauthApp () {
const payload = {
name: `Funkwhale web client at ${window.location.hostname}`,
name: `Funkwhale web client at ${location.hostname}`,
website: location.origin,
scopes: NEEDED_SCOPES,
redirect_uris: `${location.origin}/auth/callback`
@ -78,7 +82,9 @@ const store: Module<State, RootState> = {
oauth: getDefaultOauth(),
scopedTokens: getDefaultScopedTokens(),
applicationSecret: undefined
applicationSecret: undefined,
oauthWindow: undefined
},
getters: {
header: state => {
@ -243,14 +249,41 @@ const store: Module<State, RootState> = {
commit('permission', { key: permission, status: hasPermission })
}
},
async oauthLogin ({ state, rootState, commit }, next: RouteLocationRaw) {
async tryFinishOAuthFlow ({ state }) {
if (isTauri()) {
return state.oauthWindow?.close().catch(() => {
// Ignore the error in case of window being already closed
})
}
},
async oauthLogin ({ state, rootState, commit, dispatch }, next: RouteLocationRaw) {
const app = await createOauthApp()
commit('oauthApp', app)
const redirectUri = encodeURIComponent(`${location.origin}/auth/callback`)
const params = `response_type=code&scope=${encodeURIComponent(NEEDED_SCOPES)}&redirect_uri=${redirectUri}&state=${next}&client_id=${state.oauth.clientId}`
const redirectUrl = encodeURIComponent(`${location.origin}/auth/callback`)
const params = `response_type=code&scope=${encodeURIComponent(NEEDED_SCOPES)}&redirect_uri=${redirectUrl}&state=${next}&client_id=${state.oauth.clientId}`
const authorizeUrl = `${rootState.instance.instanceUrl}authorize?${params}`
if (isTauri()) {
const { WebviewWindow } = await import('@tauri-apps/api/webview')
state.oauthWindow = new WebviewWindow('oauth', {
title: `Login to ${rootState.instance.settings.instance.name}`,
parent: 'main',
url: authorizeUrl
})
const token = await new Promise((resolve, reject) => {
state.oauthWindow?.once('tauri://error', reject)
state.oauthWindow?.once('tauri://destroyed', () => reject(new Error('Aborted by user')))
state.oauthWindow?.once('oauthToken', async (event) => resolve(event.payload))
}).finally(() => dispatch('tryFinishOAuthFlow'))
commit('oauthToken', token)
await dispatch('fetchUser')
} else {
logger.log('Redirecting user...', authorizeUrl)
window.location.href = authorizeUrl
location.href = authorizeUrl
}
},
async handleOauthCallback ({ state, commit, dispatch }, authorizationCode) {
logger.log('Fetching token...')
@ -266,6 +299,18 @@ const store: Module<State, RootState> = {
useFormData(payload),
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
if (isTauri()) {
const { getCurrent } = await import('@tauri-apps/api/window')
const currentWindow = getCurrent()
// If the current window is the oauth window, pass the event to the main window
if (currentWindow.label === 'oauth') {
await currentWindow.emit('oauthToken', response.data)
return
}
}
commit('oauthToken', response.data)
await dispatch('fetchUser')
},

60
front/tauri/Cargo.lock generated
View File

@ -567,6 +567,33 @@ dependencies = [
"objc",
]
[[package]]
name = "color-eyre"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@ -1009,6 +1036,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "fastrand"
version = "1.9.0"
@ -1099,6 +1136,7 @@ dependencies = [
name = "funkwhale"
version = "0.1.0"
dependencies = [
"color-eyre",
"serde",
"serde_json",
"tauri",
@ -1714,6 +1752,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "indenter"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]]
name = "indexmap"
version = "1.9.3"
@ -2288,6 +2332,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "owo-colors"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
[[package]]
name = "pango"
version = "0.18.3"
@ -3908,6 +3958,16 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-log"
version = "0.2.0"

View File

@ -22,9 +22,10 @@ tauri-build = { version = "2.0.0-beta", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.0.0-beta", features = [] }
color-eyre = "0.6.2"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
# DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]
custom-protocol = ["tauri/custom-protocol"]

View File

@ -2,7 +2,7 @@
"identifier": "migrated",
"description": "permissions that were migrated from v1",
"context": "local",
"windows": ["main"],
"windows": ["main", "oauth"],
"permissions": [
"path:default",
"event:default",

View File

@ -0,0 +1,8 @@
{
"identifier": "oauth2",
"description": "permissions that required for OAuth2 login window",
"context": "local",
"windows": ["main"],
"permissions": ["webview:allow-create-webview-window", "window:allow-close"],
"platforms": ["linux", "macOS", "windows", "android", "iOS"]
}

View File

@ -0,0 +1,4 @@
[toolchain]
profile = "minimal"
channel = "1.71.0"
components = ["rust-src", "rust-analyzer", "clippy"]

View File

@ -1,6 +1,12 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
use color_eyre::Result;
fn main() -> Result<()> {
color_eyre::install()?;
funkwhale_lib::run();
Ok(())
}

View File

@ -10,11 +10,12 @@
},
"windows": [
{
"label": "main",
"fullscreen": false,
"height": 600,
"width": 800,
"resizable": true,
"title": "Funkwhale",
"width": 800
"title": "Funkwhale"
}
]
},