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:
Oğuzhan Koral
2026-03-25 17:21:07 +03:00
committed by GitHub
parent d2b0d35119
commit a69de13f16
11 changed files with 619 additions and 187 deletions
+3 -1
View File
@@ -15,4 +15,6 @@ dist
!.yarn/plugins !.yarn/plugins
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
.claude
@@ -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>
+20 -48
View File
@@ -2,56 +2,30 @@
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<div v-if="isDesktopServiceAvailable"> <div v-if="isDesktopServiceAvailable">
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2"> <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"> <div class="flex space-x-2">
<FormButton <FormButton full-width color="outline" @click="startAccountAddFlow()">
color="outline" Log in (Legacy)
class="px-1"
:icon-left="ArrowLeftIcon"
hide-text
@click="emit('backToSignIn')"
/>
<FormButton full-width @click="startAccountAddFlow()">
Sign in (Legacy)
</FormButton> </FormButton>
</div> </div>
</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"> <div class="text-sm text-center">
Please check your browser: waiting for authorization to complete. Please check your browser: waiting for authorization to complete.
</div> </div>
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></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> <div class="text-sm text-center">Having trouble?</div>
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton> <div class="flex justify-center">
<FormButton <span>
text <FormButton text size="sm" @click="$openUrl('https://speckle.community')">
size="sm" Get in touch with us
full-width </FormButton>
@click="$openUrl('https://speckle.community')" </span>
> </div>
Get in touch with us
</FormButton>
</div> </div>
</div> </div>
</div> </div>
@@ -91,6 +65,10 @@ const hostApp = useHostAppStore()
const app = useNuxtApp() const app = useNuxtApp()
const { trackEvent } = useMixpanel() const { trackEvent } = useMixpanel()
const props = defineProps<{
serverUrl: string
}>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'backToSignIn'): void (e: 'backToSignIn'): void
}>() }>()
@@ -98,7 +76,6 @@ const emit = defineEmits<{
const showCustomServerInput = ref(false) const showCustomServerInput = ref(false)
const isAddingAccount = 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 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 showHelp = ref(false)
const accountCheckerIntervalFn = useIntervalFn( const accountCheckerIntervalFn = useIntervalFn(
@@ -123,9 +100,9 @@ const startAccountAddFlow = () => {
setTimeout(() => { setTimeout(() => {
showHelp.value = true showHelp.value = true
}, 10_000) }, 10_000)
const url = customServerUrl.value const url = props.serverUrl
? `http://localhost:29364/auth/add-account?serverUrl=${ ? `http://localhost:29364/auth/add-account?serverUrl=${
new URL(customServerUrl.value).origin new URL(props.serverUrl).origin
}` }`
: `http://localhost:29364/auth/add-account` : `http://localhost:29364/auth/add-account`
@@ -149,11 +126,6 @@ const startAccountAddFlow = () => {
}, 30_000) }, 30_000)
} }
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
}
onMounted(async () => { onMounted(async () => {
isDesktopServiceAvailable.value = await pingDesktopService() isDesktopServiceAvailable.value = await pingDesktopService()
}) })
+29 -17
View File
@@ -39,20 +39,24 @@
title="Add a new account" title="Add a new account"
fullscreen="none" fullscreen="none"
> >
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-4 p-2">
<AccountsSignInFlow v-if="!showLegacy" /> <FormTextInput
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" /> v-model="customServerUrl"
name="Server to sign in"
<FormButton show-label
v-if="!showLegacy" placeholder="https://app.speckle.systems"
text color="foundation"
full-width autocomplete="off"
size="sm" show-clear
class="text-xs" />
@click="showLegacy = true" <div class="space-y-2">
> <AccountsSignInFlow :server-url="customServerUrl" />
Legacy Sign in <AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
</FormButton> <AccountsLegacySignInFlow
v-if="!canStartAuthAccount"
:server-url="customServerUrl"
/>
</div>
</div> </div>
</CommonDialog> </CommonDialog>
</div> </div>
@@ -68,10 +72,18 @@ import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts' import { useAccountStore } from '~/store/accounts'
import { useMixpanel } from '~/lib/core/composables/mixpanel' import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useDesktopService } from '~/lib/core/composables/desktopService' import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { BaseBridge } from '~/lib/bridge/base'
const { trackEvent } = useMixpanel() const { trackEvent } = useMixpanel()
const app = useNuxtApp() const app = useNuxtApp()
const { pingDesktopService } = useDesktopService() 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( const props = withDefaults(
defineProps<{ defineProps<{
@@ -88,7 +100,7 @@ defineEmits<{
}>() }>()
const showAddNewAccount = ref(false) const showAddNewAccount = ref(false)
const showLegacy = ref(false) const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
const showAccountsDialog = defineModel<boolean>('open', { const showAccountsDialog = defineModel<boolean>('open', {
required: false, required: false,
@@ -110,8 +122,8 @@ watch(showAccountsDialog, (newVal) => {
watch(showAddNewAccount, (newVal) => { watch(showAddNewAccount, (newVal) => {
if (newVal) { if (newVal) {
// reset the current/legacy state on every add account sub-dialog // reset the sign-in mode on every add account sub-dialog
showLegacy.value = false signInMode.value = 'default'
} }
}) })
+33 -31
View File
@@ -1,51 +1,53 @@
<template> <template>
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<FormButton <FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { useAuthManager } from '~/lib/authn/useAuthManager' import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { BaseBridge } from '~/lib/bridge/base' 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 props = defineProps<{
const showCustomServerInput = ref(false) serverUrl: string
}>()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { $accountBinding } = useNuxtApp() const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) => const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(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 { generateChallenge } = useAuthManager()
const logIn = () => { const logIn = async () => {
const serverUrl = customServerUrl.value const serverUrl = props.serverUrl
? new URL(customServerUrl.value).origin ? new URL(props.serverUrl).origin
: 'https://app.speckle.systems' : 'https://app.speckle.systems'
const challenge = generateChallenge(serverUrl) if (canStartAuthAccount) {
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}` const acc = await $accountBinding.authenticateAccount(serverUrl)
window.location.href = authUrl 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> </script>
+18 -14
View File
@@ -6,18 +6,22 @@
Welcome to Speckle Welcome to Speckle
</h1> </h1>
<div v-if="isDesktopServiceAvailable || canAddAccount"> <div v-if="isDesktopServiceAvailable || canAddAccount">
<AccountsSignInFlow v-if="!showLegacy" /> <div class="flex flex-col space-y-4 p-2">
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" /> <FormTextInput
<FormButton v-model="customServerUrl"
v-if="!showLegacy" name="Server to sign in"
text :show-label="false"
full-width placeholder="https://app.speckle.systems"
size="sm" color="foundation"
class="text-xs" autocomplete="off"
@click="showLegacy = true" show-clear
> />
Legacy Sign in <div class="space-y-2">
</FormButton> <AccountsSignInFlow :server-url="customServerUrl" />
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
<AccountsLegacySignInFlow :server-url="customServerUrl" />
</div>
</div>
</div> </div>
<div v-else> <div v-else>
<div class="text-foreground-2 mt-2 mb-4"> <div class="text-foreground-2 mt-2 mb-4">
@@ -53,13 +57,13 @@ import type { BaseBridge } from '~/lib/bridge/base'
const accountStore = useAccountStore() const accountStore = useAccountStore()
const { pingDesktopService } = useDesktopService() const { pingDesktopService } = useDesktopService()
const customServerUrl = ref<string>('https://app.speckle.systems')
const { $accountBinding } = useNuxtApp() const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) => const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(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. const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
onMounted(async () => { onMounted(async () => {
+61 -9
View File
@@ -1,29 +1,81 @@
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const CHALLENGE_KEY = 'speckle_challenge' const CHALLENGE_KEY = 'speckle_challenge'
const CHALLENGE_URL_KEY = 'speckle_url_challenge' const CHALLENGE_URL_KEY = 'speckle_url_challenge'
const CODE_VERIFIER_KEY = 'speckle_code_verifier'
function toBase64Url(buffer: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...buffer))
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Generates a cryptographically random base64url-encoded string.
* 32 bytes → 43 characters after base64url encoding (within the RFC 7636 range of 43-128).
*/
function createCodeVerifier(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return toBase64Url(bytes)
}
/**
* Computes SHA-256 hash of a string and returns it as base64url.
* This is the PKCE code_challenge derivation from a code_verifier.
*/
async function computeS256Challenge(codeVerifier: string): Promise<string> {
const data = new TextEncoder().encode(codeVerifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return toBase64Url(new Uint8Array(digest))
}
export interface ChallengeData {
/** The raw random string (code_verifier in PKCE terms) */
codeVerifier: string
/** SHA-256 base64url hash of codeVerifier (code_challenge for S256 method) */
codeChallenge: string
}
export function useAuthManager() { export function useAuthManager() {
const generateChallenge = (url: string): string => { /**
let result = '' * Generates a PKCE code_verifier + code_challenge pair and persists to localStorage.
for (let i = 0; i < 12; i++) { * Used by redirect-based sign-in flows (SignInFlow) that need to
result += chars.charAt(Math.floor(Math.random() * chars.length)) * recover the values after the browser navigates away and back.
} */
localStorage.setItem(CHALLENGE_KEY, result) // <-- persist it const generateChallenge = async (url: string): Promise<ChallengeData> => {
const codeVerifier = createCodeVerifier()
const codeChallenge = await computeS256Challenge(codeVerifier)
localStorage.setItem(CHALLENGE_KEY, codeChallenge)
localStorage.setItem(CODE_VERIFIER_KEY, codeVerifier)
localStorage.setItem(CHALLENGE_URL_KEY, url) localStorage.setItem(CHALLENGE_URL_KEY, url)
return result return { codeVerifier, codeChallenge }
}
/**
* Generates a PKCE code_verifier + code_challenge pair without persisting to localStorage.
* Used by flows that keep values in memory (ExchangeTokenSignInFlow)
* so they don't overwrite the redirect flow's stored data.
*/
const generateLocalChallenge = async (): Promise<ChallengeData> => {
const codeVerifier = createCodeVerifier()
const codeChallenge = await computeS256Challenge(codeVerifier)
return { codeVerifier, codeChallenge }
} }
const getChallenge = (): string | null => { const getChallenge = (): string | null => {
return localStorage.getItem(CHALLENGE_KEY) return localStorage.getItem(CHALLENGE_KEY)
} }
const getCodeVerifier = (): string | null => {
return localStorage.getItem(CODE_VERIFIER_KEY)
}
const getChallengeUrl = (): string | null => { const getChallengeUrl = (): string | null => {
return localStorage.getItem(CHALLENGE_URL_KEY) return localStorage.getItem(CHALLENGE_URL_KEY)
} }
return { return {
getChallenge, getChallenge,
getCodeVerifier,
getChallengeUrl, getChallengeUrl,
generateChallenge generateChallenge,
generateLocalChallenge
} }
} }
+114
View File
@@ -0,0 +1,114 @@
import { md5 } from '@speckle/shared'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
/**
* Checks if the server supports the new /oauth/token endpoint.
* The server exposes GET /oauth/token returning 'supported' when available.
*/
export async function supportsOAuthToken(serverUrl: string): Promise<boolean> {
try {
const res = await fetch(new URL('/oauth/token', serverUrl), { method: 'GET' })
return res.ok
} catch {
return false
}
}
export function useTokenExchange() {
const { $accountBinding } = useNuxtApp()
const exchangeAccessCode = async (
rawServerUrl: string,
accessCode: string,
challenge: string,
codeVerifier?: string
): Promise<void> => {
// Normalize to origin (strips trailing slash, path, etc.)
// so account IDs stay consistent with connectors
const serverUrl = new URL(rawServerUrl).origin
const tokenHeaders = { 'Content-Type': 'application/json' }
let tokenResponse: Response
// If we have a codeVerifier, try the new PKCE-based /oauth/token endpoint first
if (codeVerifier && (await supportsOAuthToken(serverUrl))) {
tokenResponse = await fetch(new URL('/oauth/token', serverUrl), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({
appId: 'sdui',
appSecret: 'sdui',
accessCode,
codeVerifier
})
})
} else {
// Fall back to legacy /auth/token with plain challenge
tokenResponse = await fetch(new URL('/auth/token', serverUrl), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({
appId: 'sdui',
appSecret: 'sdui',
accessCode,
challenge
})
})
}
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
throw new Error(
`Token exchange failed with status ${tokenResponse.status}: ${errorText}`
)
}
const { token, refreshToken } = (await tokenResponse.json()) as {
token: string
refreshToken: string
}
// Query user and server info
const graphqlQuery = {
query:
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
}
const userAndServerInfoResponse = await fetch(new URL('/graphql', serverUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(graphqlQuery)
})
if (!userAndServerInfoResponse.ok) {
throw new Error(
`Failed to fetch user info with status ${userAndServerInfoResponse.status}`
)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userAndServerInfo = await userAndServerInfoResponse.json()
const accountId = md5(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
userAndServerInfo.data.activeUser.email + serverUrl
).toUpperCase()
const account: Account = {
id: accountId,
token,
refreshToken,
isDefault: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
serverInfo: { url: serverUrl, ...userAndServerInfo.data.serverInfo },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
userInfo: userAndServerInfo.data.activeUser
}
await $accountBinding.addAccount(accountId, account)
}
return { exchangeAccessCode }
}
@@ -9,6 +9,7 @@ export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
getAccounts: () => Promise<Account[]> getAccounts: () => Promise<Account[]>
addAccount: (accountId: string, account: Account) => Promise<void> addAccount: (accountId: string, account: Account) => Promise<void>
removeAccount: (accountId: string) => Promise<void> removeAccount: (accountId: string) => Promise<void>
authenticateAccount: (serverUrl: string) => Promise<Account>
} }
// An almost 1-1 mapping of what we need from the Core accounts class. // An almost 1-1 mapping of what we need from the Core accounts class.
@@ -60,6 +61,25 @@ export class MockedAccountBinding implements IAccountBinding {
return await console.log('no way dude', accountId, account) return await console.log('no way dude', accountId, account)
} }
public async authenticateAccount(serverUrl: string) {
const config = useRuntimeConfig()
return (await {
id: 'whatever',
isDefault: true,
token: config.public.speckleToken,
serverInfo: {
name: 'test',
url: serverUrl,
frontend2: true
},
userInfo: {
id: 'whatever',
avatar: 'whatever',
email: ''
}
}) as Account
}
public async removeAccount(accountId: string) { public async removeAccount(accountId: string) {
return await console.log('no way dude', accountId) return await console.log('no way dude', accountId)
} }
+138
View File
@@ -574,6 +574,8 @@ export type AutomateAuthCodePayloadTest = {
/** Additional resources to validate user access to. */ /** Additional resources to validate user access to. */
export type AutomateAuthCodeResources = { export type AutomateAuthCodeResources = {
automationId?: InputMaybe<Scalars['String']['input']>;
projectId?: InputMaybe<Scalars['String']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>; workspaceId?: InputMaybe<Scalars['String']['input']>;
}; };
@@ -1249,6 +1251,18 @@ export type CreateDashboardTokenReturn = {
tokenMetadata: DashboardToken; tokenMetadata: DashboardToken;
}; };
export type CreateEmbedShareTokenInput = {
expiresAt?: InputMaybe<Scalars['DateTime']['input']>;
label?: InputMaybe<Scalars['String']['input']>;
password?: InputMaybe<Scalars['String']['input']>;
projectId: Scalars['String']['input'];
/**
* The model(s) and version(s) string used in the embed url.
* Format: 'modelId1,modelId2@versionId'
*/
resourceIdString: Scalars['String']['input'];
};
export type CreateEmbedTokenReturn = { export type CreateEmbedTokenReturn = {
__typename?: 'CreateEmbedTokenReturn'; __typename?: 'CreateEmbedTokenReturn';
token: Scalars['String']['output']; token: Scalars['String']['output'];
@@ -2680,6 +2694,7 @@ export type Mutation = {
serverInviteBatchCreate: Scalars['Boolean']['output']; serverInviteBatchCreate: Scalars['Boolean']['output'];
/** Invite a new user to the speckle server and return the invite ID */ /** Invite a new user to the speckle server and return the invite ID */
serverInviteCreate: Scalars['Boolean']['output']; serverInviteCreate: Scalars['Boolean']['output'];
sharingMutations: SharingMutations;
/** /**
* Request access to a specific stream * Request access to a specific stream
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.create instead. * @deprecated Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.create instead.
@@ -3235,6 +3250,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>; description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */ /** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions; embedOptions: ProjectEmbedOptions;
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
embedTokens: EmbedTokenCollection; embedTokens: EmbedTokenCollection;
/** @deprecated Use specific auth policies instead */ /** @deprecated Use specific auth policies instead */
hasAccessToFeature: Scalars['Boolean']['output']; hasAccessToFeature: Scalars['Boolean']['output'];
@@ -3283,6 +3299,7 @@ export type Project = {
/** Same as savedView(), but won't throw if view isn't found */ /** Same as savedView(), but won't throw if view isn't found */
savedViewIfExists?: Maybe<SavedView>; savedViewIfExists?: Maybe<SavedView>;
savedViews: SavedViewCollection; savedViews: SavedViewCollection;
shareTokens: ShareTokenCollection;
/** Source apps used in any models of this project */ /** Source apps used in any models of this project */
sourceApps: Array<Scalars['String']['output']>; sourceApps: Array<Scalars['String']['output']>;
team: Array<ProjectCollaborator>; team: Array<ProjectCollaborator>;
@@ -3480,6 +3497,13 @@ export type ProjectSavedViewsArgs = {
}; };
export type ProjectShareTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<ProjectShareTokensFilter>;
limit?: Scalars['Int']['input'];
};
export type ProjectUngroupedViewGroupArgs = { export type ProjectUngroupedViewGroupArgs = {
input: GetUngroupedViewGroupInput; input: GetUngroupedViewGroupInput;
}; };
@@ -4032,6 +4056,7 @@ export type ProjectMutations = {
batchDelete: Scalars['Boolean']['output']; batchDelete: Scalars['Boolean']['output'];
/** Create new project */ /** Create new project */
create: Project; create: Project;
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
createEmbedToken: CreateEmbedTokenReturn; createEmbedToken: CreateEmbedTokenReturn;
/** Delete an existing project */ /** Delete an existing project */
delete: Scalars['Boolean']['output']; delete: Scalars['Boolean']['output'];
@@ -4041,7 +4066,9 @@ export type ProjectMutations = {
/** Leave a project. Only possible if you're not the last remaining owner. */ /** Leave a project. Only possible if you're not the last remaining owner. */
leave: Scalars['Boolean']['output']; leave: Scalars['Boolean']['output'];
modelIngestionMutations: ProjectModelIngestionMutations; modelIngestionMutations: ProjectModelIngestionMutations;
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
revokeEmbedToken: Scalars['Boolean']['output']; revokeEmbedToken: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
revokeEmbedTokens: Scalars['Boolean']['output']; revokeEmbedTokens: Scalars['Boolean']['output'];
savedViewMutations: SavedViewMutations; savedViewMutations: SavedViewMutations;
/** Updates an existing project */ /** Updates an existing project */
@@ -4147,16 +4174,19 @@ export type ProjectPermissionChecks = {
canLeave: PermissionCheckResult; canLeave: PermissionCheckResult;
canListAutomations: PermissionCheckResult; canListAutomations: PermissionCheckResult;
canListIssues: PermissionCheckResult; canListIssues: PermissionCheckResult;
canListShareTokens: PermissionCheckResult;
canListUsers: PermissionCheckResult; canListUsers: PermissionCheckResult;
canLoad: PermissionCheckResult; canLoad: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult; canMoveToWorkspace: PermissionCheckResult;
canPublish: PermissionCheckResult; canPublish: PermissionCheckResult;
canRead: PermissionCheckResult; canRead: PermissionCheckResult;
canReadAccIntegrationSettings: PermissionCheckResult; canReadAccIntegrationSettings: PermissionCheckResult;
/** @deprecated Part of the old API surface and will be removed in the future. Use canListShareTokens. Field will be deleted on October 1st, 2026. */
canReadEmbedTokens: PermissionCheckResult; canReadEmbedTokens: PermissionCheckResult;
canReadSettings: PermissionCheckResult; canReadSettings: PermissionCheckResult;
canReadWebhooks: PermissionCheckResult; canReadWebhooks: PermissionCheckResult;
canRequestRender: PermissionCheckResult; canRequestRender: PermissionCheckResult;
/** @deprecated Part of the old API surface and will be removed in the future. Use canRevoke on ShareToken. Field will be deleted on October 1st, 2026. */
canRevokeEmbedTokens: PermissionCheckResult; canRevokeEmbedTokens: PermissionCheckResult;
canUpdate: PermissionCheckResult; canUpdate: PermissionCheckResult;
canUpdateAllowPublicComments: PermissionCheckResult; canUpdateAllowPublicComments: PermissionCheckResult;
@@ -4237,6 +4267,11 @@ export enum ProjectSavedViewsUpdatedMessageType {
Updated = 'UPDATED' Updated = 'UPDATED'
} }
export type ProjectShareTokensFilter = {
createdByUserId?: InputMaybe<Scalars['String']['input']>;
sourceType?: InputMaybe<ShareSourceType>;
};
export type ProjectTestAutomationCreateInput = { export type ProjectTestAutomationCreateInput = {
modelId: Scalars['String']['input']; modelId: Scalars['String']['input'];
name: Scalars['String']['input']; name: Scalars['String']['input'];
@@ -4615,6 +4650,22 @@ export type RequestWorkspaceSupportAccessInput = {
workspaceId: Scalars['ID']['input']; workspaceId: Scalars['ID']['input'];
}; };
export type ResourceAccessRule = {
__typename?: 'ResourceAccessRule';
modelId?: Maybe<Scalars['String']['output']>;
projectId?: Maybe<Scalars['String']['output']>;
type: ResourceAccessRuleType;
versionId?: Maybe<Scalars['String']['output']>;
workspaceId?: Maybe<Scalars['String']['output']>;
};
export enum ResourceAccessRuleType {
Model = 'model',
Project = 'project',
Version = 'version',
Workspace = 'workspace'
}
export type ResourceIdentifier = { export type ResourceIdentifier = {
__typename?: 'ResourceIdentifier'; __typename?: 'ResourceIdentifier';
resourceId: Scalars['String']['output']; resourceId: Scalars['String']['output'];
@@ -5141,6 +5192,72 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input']; id: Scalars['ID']['input'];
}; };
export enum ShareSourceType {
Dashboard = 'dashboard',
Embed = 'embed',
SavedViewGroup = 'savedViewGroup'
}
export type ShareToken = {
__typename?: 'ShareToken';
createdAt: Scalars['DateTime']['output'];
createdBy: LimitedUser;
expiresAt?: Maybe<Scalars['DateTime']['output']>;
hasPassword: Scalars['Boolean']['output'];
id: Scalars['String']['output'];
label?: Maybe<Scalars['String']['output']>;
lastUsed?: Maybe<Scalars['DateTime']['output']>;
permissions: ShareTokenPermissionChecks;
resourceAccessRules: Array<ResourceAccessRule>;
sourceId: Scalars['String']['output'];
sourceType: ShareSourceType;
/** The full token string. Only returned on creation. */
token?: Maybe<Scalars['String']['output']>;
};
export type ShareTokenCollection = {
__typename?: 'ShareTokenCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<ShareToken>;
totalCount: Scalars['Int']['output'];
};
export type ShareTokenPermissionChecks = {
__typename?: 'ShareTokenPermissionChecks';
canRevoke: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type SharingMutations = {
__typename?: 'SharingMutations';
createEmbedShareToken: ShareToken;
revokeProjectShareTokens: Scalars['Boolean']['output'];
revokeShareToken: Scalars['Boolean']['output'];
updateShareToken: ShareToken;
};
export type SharingMutationsCreateEmbedShareTokenArgs = {
input: CreateEmbedShareTokenInput;
};
export type SharingMutationsRevokeProjectShareTokensArgs = {
projectId: Scalars['String']['input'];
sourceType: ShareSourceType;
};
export type SharingMutationsRevokeShareTokenArgs = {
tokenId: Scalars['String']['input'];
};
export type SharingMutationsUpdateShareTokenArgs = {
input: UpdateShareTokenInput;
tokenId: Scalars['String']['input'];
};
export type SmartTextEditorValue = { export type SmartTextEditorValue = {
__typename?: 'SmartTextEditorValue'; __typename?: 'SmartTextEditorValue';
/** File attachments, if any */ /** File attachments, if any */
@@ -5823,6 +5940,12 @@ export type UpdateServerRegionInput = {
name?: InputMaybe<Scalars['String']['input']>; name?: InputMaybe<Scalars['String']['input']>;
}; };
export type UpdateShareTokenInput = {
expiresAt?: InputMaybe<Scalars['DateTime']['input']>;
label?: InputMaybe<Scalars['String']['input']>;
password?: InputMaybe<Scalars['String']['input']>;
};
/** Only non-null values will be updated */ /** Only non-null values will be updated */
export type UpdateVersionInput = { export type UpdateVersionInput = {
message?: InputMaybe<Scalars['String']['input']>; message?: InputMaybe<Scalars['String']['input']>;
@@ -6591,6 +6714,7 @@ export type Workspace = {
/** Active user's seat type for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ /** Active user's seat type for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
seatType?: Maybe<WorkspaceSeatType>; seatType?: Maybe<WorkspaceSeatType>;
seats?: Maybe<WorkspaceSubscriptionSeats>; seats?: Maybe<WorkspaceSubscriptionSeats>;
shareTokens: ShareTokenCollection;
slug: Scalars['String']['output']; slug: Scalars['String']['output'];
/** Information about the workspace's SSO configuration and the current user's SSO session, if present */ /** Information about the workspace's SSO configuration and the current user's SSO session, if present */
sso?: Maybe<WorkspaceSso>; sso?: Maybe<WorkspaceSso>;
@@ -6650,6 +6774,13 @@ export type WorkspaceProjectsArgs = {
}; };
export type WorkspaceShareTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceShareTokensFilter>;
limit?: Scalars['Int']['input'];
};
export type WorkspaceSupportSessionsArgs = { export type WorkspaceSupportSessionsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>; cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceSupportSessionFilter>; filter?: InputMaybe<WorkspaceSupportSessionFilter>;
@@ -7146,6 +7277,7 @@ export type WorkspacePermissionChecks = {
canInvite: PermissionCheckResult; canInvite: PermissionCheckResult;
canLeave: PermissionCheckResult; canLeave: PermissionCheckResult;
canListDashboards: PermissionCheckResult; canListDashboards: PermissionCheckResult;
canListShareTokens: PermissionCheckResult;
canMakeWorkspaceExclusive: PermissionCheckResult; canMakeWorkspaceExclusive: PermissionCheckResult;
canManageDomainBasedSecurityPolicies: PermissionCheckResult; canManageDomainBasedSecurityPolicies: PermissionCheckResult;
canManageInvites: PermissionCheckResult; canManageInvites: PermissionCheckResult;
@@ -7398,6 +7530,12 @@ export type WorkspaceSeatsByType = {
viewers?: Maybe<WorkspaceSeatCollection>; viewers?: Maybe<WorkspaceSeatCollection>;
}; };
export type WorkspaceShareTokensFilter = {
createdByUserId?: InputMaybe<Scalars['String']['input']>;
projectId?: InputMaybe<Scalars['String']['input']>;
sourceType?: InputMaybe<ShareSourceType>;
};
export type WorkspaceSso = { export type WorkspaceSso = {
__typename?: 'WorkspaceSso'; __typename?: 'WorkspaceSso';
/** If null, the workspace does not have SSO configured */ /** If null, the workspace does not have SSO configured */
+8 -67
View File
@@ -3,17 +3,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { md5 } from '@speckle/shared'
import { ToastNotificationType } from '@speckle/ui-components' import { ToastNotificationType } from '@speckle/ui-components'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthManager } from '~/lib/authn/useAuthManager' import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding' import { useTokenExchange } from '~/lib/authn/useTokenExchange'
import { useHostAppStore } from '~/store/hostApp' import { useHostAppStore } from '~/store/hostApp'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { getChallenge, getChallengeUrl } = useAuthManager() const { getChallenge, getCodeVerifier, getChallengeUrl } = useAuthManager()
const { $accountBinding } = useNuxtApp() const { exchangeAccessCode } = useTokenExchange()
const hostApp = useHostAppStore() const hostApp = useHostAppStore()
onMounted(async () => { onMounted(async () => {
@@ -22,69 +21,11 @@ onMounted(async () => {
const accessCode = route.query.access_code as string | undefined const accessCode = route.query.access_code as string | undefined
if (accessCode && origin) { if (accessCode && origin) {
const challenge = getChallenge() const challenge = getChallenge()
const body = { if (!challenge) {
appId: 'sdui', throw new Error('No challenge found in storage.')
appSecret: 'sdui',
accessCode,
challenge
} }
const codeVerifier = getCodeVerifier() ?? undefined
// Exchange the access code for a real token (optional) await exchangeAccessCode(origin, accessCode, challenge, codeVerifier)
const response = await fetch(new URL('/auth/token', origin), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
hostApp.setNotification({
title: 'Log In',
type: ToastNotificationType.Danger,
description: `Token exchange failed with status ${response.status}: ${errorText}`
})
// Stop processing and redirect immediately on failure
return router.replace('/')
}
const { token, refreshToken } = (await response.json()) as {
token: string
refreshToken: string
}
const graphqlQuery = {
query:
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
}
const userAndServerInfoResponse = await fetch(new URL('/graphql', origin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` // Add the token as a Bearer token
},
body: JSON.stringify(graphqlQuery)
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userAndServerInfo = await userAndServerInfoResponse.json()
const accountId = md5(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
userAndServerInfo.data.activeUser.email + origin
).toUpperCase()
const account: Account = {
id: accountId,
token,
refreshToken,
isDefault: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
serverInfo: { url: origin, ...userAndServerInfo.data.serverInfo },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
userInfo: userAndServerInfo.data.activeUser
}
await $accountBinding.addAccount(accountId, account)
} else { } else {
throw new Error('No access code is found.') throw new Error('No access code is found.')
} }
@@ -92,7 +33,7 @@ onMounted(async () => {
hostApp.setNotification({ hostApp.setNotification({
type: ToastNotificationType.Danger, type: ToastNotificationType.Danger,
title: 'Failed to add your Speckle account.', title: 'Failed to add your Speckle account.',
description: error as string description: error instanceof Error ? error.message : (error as string)
}) })
} finally { } finally {
router.replace('/') router.replace('/')