Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { NavigationFailure } from 'vue-router'
export interface AuthServiceInterface {
handleAuthError(route: any, error?: unknown): any
signinSilent(): Promise<unknown>
/**
* Silent renewal that retries network errors. Resolves to whether it succeeded.
* Concurrent `renewToken()` callers share one attempt, direct `signinSilent()` calls
* (e.g. the token timer) don't join it.
*/
renewToken(): Promise<boolean>
logoutUser(): Promise<void | NavigationFailure>
getRefreshToken(): Promise<string>
}
Expand Down
109 changes: 94 additions & 15 deletions packages/web-pkg/src/composables/yjs/useYjsSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HocuspocusProvider } from '@hocuspocus/provider'
import type { Resource } from '@opencloud-eu/web-client'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useConfigStore } from '../piniaStores'
import { useAuthService } from '../authContext/useAuthService'
import type { YjsAdapter } from './types'
import { decodeIdentityMessage } from './useYjsCollaborators'

Expand Down Expand Up @@ -317,6 +318,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {

const { $gettext } = useGettext()
const authStore = useAuthStore()
const authService = useAuthService()
const configStore = useConfigStore()

const sessionNonce = ref(0)
Expand Down Expand Up @@ -981,30 +983,107 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
connectTimer = undefined
}

/**
* Renewal of a refused token: `renewing` while the new token is fetched,
* `verifying` until the server accepts it. A refusal while verifying ends
* the session, so a token the server keeps refusing is renewed only once.
*/
let tokenRenewal: 'idle' | 'renewing' | 'verifying' = 'idle'
/** The socket's own state. `status` reads `connecting` during a renewal. */
let socketStatus: YjsStatus = YjsStatus.Connecting
let renewal: Promise<boolean> | undefined
/** Whether a reconnect is waiting in `getToken` for the pending renewal. */
let isTokenAwaited = false

/**
* A reconnect during a renewal waits for the new token. Sending the old
* one instead would get refused after the renewal finished, and the server
* would drop the renewed token queued behind it along with the refusal.
*/
async function getToken() {
if (renewal) {
isTokenAwaited = true
await renewal
}
return authStore.accessToken
}

function stopOnAuthFailure(reason: string) {
error.value = new Error(deniedMessage(reason))

// Stop retrying: `permissionDeniedHandler` leaves `shouldConnect`
// true, so the socket layer keeps reconnecting - and a later attempt
// that authenticates would merge our locally seeded copy into a room
// already holding the same content.
stopProvider(prov)

// Hydrate and release the loading gate, but only for a failed
// *opening* connect. Mid-session the document is already live and
// populated; re-running the hydration checks there could plant a
// stale flag against a room this client has just left.
void onProviderSynced(doc, null)
}

/**
* The server refused a token that expired while the regular renewal could
* not run (device asleep, tab frozen). Renew it and authenticate again.
* The server keeps the socket open after a refusal, so this happens in
* place; a socket that closed meanwhile sends the new token on reconnect.
*/
async function renewTokenAndReauthenticate() {
tokenRenewal = 'renewing'
isTokenAwaited = false
status.value = YjsStatus.Connecting
// A rejection would leave `renewal` set and every `getToken` throwing.
renewal = authService.renewToken().catch(() => false)
const isRenewed = await renewal
renewal = undefined

if (doc.isDestroyed || unref(ydoc) !== doc) return
if (unref(isConflicted) || unref(isLockedForReload)) return
if (!isRenewed) {
console.error('[yjs] token renewal failed')
stopOnAuthFailure(YjsDeniedReason.TokenInvalid)
return
}

tokenRenewal = 'verifying'
// A reconnect meanwhile sends the renewed token on open by itself.
if (socketStatus !== YjsStatus.Connected || isTokenAwaited) return

await prov.sendToken()
prov.startSync()
}

const prov: HocuspocusProvider = new HocuspocusProvider({
url: serverUrl,
name,
document: doc,
token: () => authStore.accessToken,
token: getToken,
onStatus({ status: s }) {
status.value = s as YjsStatus
socketStatus = s as YjsStatus
const isAuthPending = tokenRenewal !== 'idle' && socketStatus === YjsStatus.Connected
status.value = isAuthPending ? YjsStatus.Connecting : socketStatus
},
onAuthenticated() {
if (tokenRenewal === 'idle') return
tokenRenewal = 'idle'
status.value = socketStatus
},
onAuthenticationFailed({ reason }) {
// Refusals of the old token, still in flight.
if (tokenRenewal === 'renewing') return

console.error('[yjs] auth failed:', reason)
clearConnectTimer()
error.value = new Error(deniedMessage(reason))

// Stop retrying: `permissionDeniedHandler` leaves `shouldConnect`
// true, so the socket layer keeps reconnecting - and a later attempt
// that authenticates would merge our locally seeded copy into a room
// already holding the same content.
stopProvider(prov)

// Hydrate and release the loading gate, but only for a failed
// *opening* connect. Mid-session the document is already live and
// populated; re-running the hydration checks there could plant a
// stale flag against a room this client has just left.
void onProviderSynced(doc, null)

// Only mid-session: an opening connect usually has a fresh token.
if (reason === YjsDeniedReason.TokenInvalid && unref(isReady) && tokenRenewal === 'idle') {
void renewTokenAndReauthenticate()
return
}

stopOnAuthFailure(reason)
},
onStateless({ payload }) {
const identity = decodeIdentityMessage(payload)
Expand Down
Loading
Loading