feat: refactor auth flow and enable exchange token flow (#95)
* feat: refactor auth flow and enable exchange token flow * fix: do not cache to local storage for exchange token * chore: remove logging * chore: lint * feat: pkce alignment with oauth endpoint * feat: default log in via accountBinding.authenticateAccount if available * feat: do not show legacy sign in if connectors has accountBinding.authenticateAccount flow * fix: base64url safe
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div v-if="!hidden" class="flex flex-col space-y-2">
|
||||
<!-- idle: server URL + sign in button -->
|
||||
<template v-if="state === 'idle'">
|
||||
<div class="flex space-x-2">
|
||||
<FormButton
|
||||
v-if="canAddAccount"
|
||||
full-width
|
||||
color="outline"
|
||||
@click="openBrowserAuth()"
|
||||
>
|
||||
Log in with OAuth token
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- waiting: instructions + code input -->
|
||||
<template v-if="state === 'waiting' || state === 'submitting'">
|
||||
<div class="text-foreground-2 space-y-2 border rounded-lg p-2">
|
||||
<div class="text-sm text-center">
|
||||
Check your browser: authorize the app, then copy the exchange code and paste
|
||||
it below.
|
||||
</div>
|
||||
<div class="py-2"><CommonLoadingBar :loading="state === 'waiting'" /></div>
|
||||
<FormTextInput
|
||||
v-model="exchangeCode"
|
||||
name="exchangeCode"
|
||||
:show-label="false"
|
||||
placeholder="Paste exchange code here"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
:disabled="state === 'submitting'"
|
||||
/>
|
||||
<FormButton
|
||||
full-width
|
||||
:disabled="!exchangeCode?.trim() || state === 'submitting'"
|
||||
@click="submitCode()"
|
||||
>
|
||||
{{ state === 'submitting' ? 'Signing in...' : 'Submit' }}
|
||||
</FormButton>
|
||||
|
||||
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
|
||||
<div class="text-sm text-center">Having trouble?</div>
|
||||
<div class="flex justify-center">
|
||||
<span>
|
||||
<FormButton size="sm" text @click="retryFlow()">Retry</FormButton>
|
||||
or
|
||||
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- error -->
|
||||
<template v-if="state === 'error'">
|
||||
<div class="text-foreground-2 space-y-2">
|
||||
<div class="text-sm text-center text-red-500">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<FormButton full-width @click="retryFlow()">Try again</FormButton>
|
||||
<FormButton text size="sm" full-width @click="emit('backToSignIn')">
|
||||
Back
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import { useTokenExchange, supportsOAuthToken } from '~/lib/authn/useTokenExchange'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'backToSignIn'): void
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
const { generateLocalChallenge } = useAuthManager()
|
||||
const { exchangeAccessCode } = useTokenExchange()
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const state = ref<'idle' | 'waiting' | 'submitting' | 'error'>('idle')
|
||||
const exchangeCode = ref<string | undefined>()
|
||||
const errorMessage = ref('')
|
||||
const showHelp = ref(false)
|
||||
const hidden = ref(false)
|
||||
|
||||
const checkServerSupport = async (url: string) => {
|
||||
const serverUrl = url ? new URL(url).origin : 'https://app.speckle.systems'
|
||||
hidden.value = !(await supportsOAuthToken(serverUrl))
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => checkServerSupport(props.serverUrl))
|
||||
watch(
|
||||
() => props.serverUrl,
|
||||
(url) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => checkServerSupport(url), 500)
|
||||
}
|
||||
)
|
||||
|
||||
let currentCodeVerifier = ''
|
||||
let currentCodeChallenge = ''
|
||||
let currentServerUrl = ''
|
||||
|
||||
const openBrowserAuth = async () => {
|
||||
currentServerUrl = props.serverUrl
|
||||
? new URL(props.serverUrl).origin
|
||||
: 'https://app.speckle.systems'
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generateLocalChallenge()
|
||||
currentCodeVerifier = codeVerifier
|
||||
currentCodeChallenge = codeChallenge
|
||||
const authUrl = `${currentServerUrl}/authn/verify/sdui/${codeChallenge}?returnExchangeToken=true&code_challenge_method=S256`
|
||||
app.$openUrl(authUrl)
|
||||
|
||||
state.value = 'waiting'
|
||||
exchangeCode.value = undefined
|
||||
showHelp.value = false
|
||||
|
||||
setTimeout(() => {
|
||||
if (state.value === 'waiting') {
|
||||
showHelp.value = true
|
||||
}
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
const submitCode = async () => {
|
||||
const code = exchangeCode.value?.trim()
|
||||
if (!code || !currentCodeChallenge || !currentServerUrl) return
|
||||
|
||||
state.value = 'submitting'
|
||||
try {
|
||||
await exchangeAccessCode(
|
||||
currentServerUrl,
|
||||
code,
|
||||
currentCodeChallenge,
|
||||
currentCodeVerifier
|
||||
)
|
||||
void trackEvent('DUI Account Added')
|
||||
// Refresh accounts so the watcher in Menu.vue detects the new account and closes the dialog
|
||||
await accountStore.refreshAccounts()
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : 'Failed to sign in. Please try again.'
|
||||
state.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
const retryFlow = () => {
|
||||
state.value = 'idle'
|
||||
exchangeCode.value = undefined
|
||||
errorMessage.value = ''
|
||||
showHelp.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -2,56 +2,30 @@
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div v-if="isDesktopServiceAvailable">
|
||||
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
|
||||
<FormButton
|
||||
text
|
||||
size="sm"
|
||||
full-width
|
||||
@click="showCustomServerInput = !showCustomServerInput"
|
||||
>
|
||||
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
|
||||
</FormButton>
|
||||
<div v-if="showCustomServerInput">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="name"
|
||||
:show-label="false"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
@clear="showCustomServerInput = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<FormButton
|
||||
color="outline"
|
||||
class="px-1"
|
||||
:icon-left="ArrowLeftIcon"
|
||||
hide-text
|
||||
@click="emit('backToSignIn')"
|
||||
/>
|
||||
|
||||
<FormButton full-width @click="startAccountAddFlow()">
|
||||
Sign in (Legacy)
|
||||
<FormButton full-width color="outline" @click="startAccountAddFlow()">
|
||||
Log in (Legacy)
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
|
||||
<div
|
||||
v-show="isAddingAccount"
|
||||
class="text-foreground-2 mt-2 mb-4 space-y-2 border rounded-lg p-2"
|
||||
>
|
||||
<div class="text-sm text-center">
|
||||
Please check your browser: waiting for authorization to complete.
|
||||
</div>
|
||||
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
|
||||
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
|
||||
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
|
||||
<div class="text-sm text-center">Having trouble?</div>
|
||||
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
|
||||
<FormButton
|
||||
text
|
||||
size="sm"
|
||||
full-width
|
||||
@click="$openUrl('https://speckle.community')"
|
||||
>
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
<div class="flex justify-center">
|
||||
<span>
|
||||
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,6 +65,10 @@ const hostApp = useHostAppStore()
|
||||
const app = useNuxtApp()
|
||||
const { trackEvent } = useMixpanel()
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'backToSignIn'): void
|
||||
}>()
|
||||
@@ -98,7 +76,6 @@ const emit = defineEmits<{
|
||||
const showCustomServerInput = ref(false)
|
||||
const isAddingAccount = ref(false)
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
|
||||
const showHelp = ref(false)
|
||||
|
||||
const accountCheckerIntervalFn = useIntervalFn(
|
||||
@@ -123,9 +100,9 @@ const startAccountAddFlow = () => {
|
||||
setTimeout(() => {
|
||||
showHelp.value = true
|
||||
}, 10_000)
|
||||
const url = customServerUrl.value
|
||||
const url = props.serverUrl
|
||||
? `http://localhost:29364/auth/add-account?serverUrl=${
|
||||
new URL(customServerUrl.value).origin
|
||||
new URL(props.serverUrl).origin
|
||||
}`
|
||||
: `http://localhost:29364/auth/add-account`
|
||||
|
||||
@@ -149,11 +126,6 @@ const startAccountAddFlow = () => {
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
const restartFlow = () => {
|
||||
isAddingAccount.value = false
|
||||
showHelp.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isDesktopServiceAvailable.value = await pingDesktopService()
|
||||
})
|
||||
|
||||
@@ -39,20 +39,24 @@
|
||||
title="Add a new account"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<AccountsSignInFlow v-if="!showLegacy" />
|
||||
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
|
||||
|
||||
<FormButton
|
||||
v-if="!showLegacy"
|
||||
text
|
||||
full-width
|
||||
size="sm"
|
||||
class="text-xs"
|
||||
@click="showLegacy = true"
|
||||
>
|
||||
Legacy Sign in
|
||||
</FormButton>
|
||||
<div class="flex flex-col space-y-4 p-2">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="Server to sign in"
|
||||
show-label
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsLegacySignInFlow
|
||||
v-if="!canStartAuthAccount"
|
||||
:server-url="customServerUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
@@ -68,10 +72,18 @@ import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const app = useNuxtApp()
|
||||
const { pingDesktopService } = useDesktopService()
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
|
||||
(name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const customServerUrl = ref<string>('https://app.speckle.systems')
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -88,7 +100,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const showAddNewAccount = ref(false)
|
||||
const showLegacy = ref(false)
|
||||
const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
|
||||
|
||||
const showAccountsDialog = defineModel<boolean>('open', {
|
||||
required: false,
|
||||
@@ -110,8 +122,8 @@ watch(showAccountsDialog, (newVal) => {
|
||||
|
||||
watch(showAddNewAccount, (newVal) => {
|
||||
if (newVal) {
|
||||
// reset the current/legacy state on every add account sub-dialog
|
||||
showLegacy.value = false
|
||||
// reset the sign-in mode on every add account sub-dialog
|
||||
signInMode.value = 'default'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormButton
|
||||
text
|
||||
size="sm"
|
||||
full-width
|
||||
@click="showCustomServerInput = !showCustomServerInput"
|
||||
>
|
||||
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
|
||||
</FormButton>
|
||||
<div v-if="showCustomServerInput">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="name"
|
||||
:show-label="false"
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
@clear="showCustomServerInput = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormButton v-if="canAddAccount" full-width @click="logIn()">Sign in</FormButton>
|
||||
<FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
|
||||
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
|
||||
const showCustomServerInput = ref(false)
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
|
||||
(name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const { generateChallenge } = useAuthManager()
|
||||
|
||||
const logIn = () => {
|
||||
const serverUrl = customServerUrl.value
|
||||
? new URL(customServerUrl.value).origin
|
||||
const logIn = async () => {
|
||||
const serverUrl = props.serverUrl
|
||||
? new URL(props.serverUrl).origin
|
||||
: 'https://app.speckle.systems'
|
||||
const challenge = generateChallenge(serverUrl)
|
||||
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}`
|
||||
window.location.href = authUrl
|
||||
if (canStartAuthAccount) {
|
||||
const acc = await $accountBinding.authenticateAccount(serverUrl)
|
||||
if (acc.token) {
|
||||
await accountStore.refreshAccounts()
|
||||
} else {
|
||||
hostAppStore.setNotification({
|
||||
title: 'Log In',
|
||||
type: ToastNotificationType.Info,
|
||||
description:
|
||||
"Log in could not completed. Make sure you have logged in successfully, otherwise try 'Log in with OAuth token'"
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const { codeChallenge } = await generateChallenge(serverUrl)
|
||||
const authUrl = `${serverUrl}/authn/verify/sdui/${codeChallenge}?code_challenge_method=S256`
|
||||
window.location.href = authUrl
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
Welcome to Speckle
|
||||
</h1>
|
||||
<div v-if="isDesktopServiceAvailable || canAddAccount">
|
||||
<AccountsSignInFlow v-if="!showLegacy" />
|
||||
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
|
||||
<FormButton
|
||||
v-if="!showLegacy"
|
||||
text
|
||||
full-width
|
||||
size="sm"
|
||||
class="text-xs"
|
||||
@click="showLegacy = true"
|
||||
>
|
||||
Legacy Sign in
|
||||
</FormButton>
|
||||
<div class="flex flex-col space-y-4 p-2">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="Server to sign in"
|
||||
:show-label="false"
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsLegacySignInFlow :server-url="customServerUrl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-foreground-2 mt-2 mb-4">
|
||||
@@ -53,13 +57,13 @@ import type { BaseBridge } from '~/lib/bridge/base'
|
||||
const accountStore = useAccountStore()
|
||||
const { pingDesktopService } = useDesktopService()
|
||||
|
||||
const customServerUrl = ref<string>('https://app.speckle.systems')
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const showLegacy = ref(false)
|
||||
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
Reference in New Issue
Block a user