Show when authentication is bad (cookie header only)

This commit is contained in:
49lf
2024-04-14 14:09:26 -04:00
parent 32f2411394
commit db08e67215
4 changed files with 316 additions and 260 deletions

View File

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
EngineConnectionStateType,
DisconnectingType,
EngineCommandManagerEvents, EngineCommandManagerEvents,
EngineConnectionEvents, EngineConnectionEvents,
ConnectionError, ConnectionError,
@ -13,8 +15,12 @@ const Loading = ({ children }: React.PropsWithChildren) => {
const [error, setError] = useState<ConnectionError>(ConnectionError.Unset) const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
useEffect(() => { useEffect(() => {
const onConnectionStateChange = (state: EngineConnectionState) => { const onConnectionStateChange = ({ detail: state }: CustomEvent) => {
console.log("<Loading/>", state) if (
(state.type !== EngineConnectionStateType.Disconnected
|| state.type !== EngineConnectionStateType.Disconnecting)
&& state.value?.type !== DisconnectingType.Error) return
setError(state.value.value.error)
} }
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => { const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {

View File

@ -56,11 +56,11 @@ export function useNetworkStatus() {
useEffect(() => { useEffect(() => {
const onlineCallback = () => { const onlineCallback = () => {
setSteps(initialConnectingTypeGroupState)
setInternetConnected(true) setInternetConnected(true)
} }
const offlineCallback = () => { const offlineCallback = () => {
setInternetConnected(false) setInternetConnected(false)
setSteps(structuredClone(initialConnectingTypeGroupState))
} }
window.addEventListener('online', onlineCallback) window.addEventListener('online', onlineCallback)
window.addEventListener('offline', offlineCallback) window.addEventListener('offline', offlineCallback)
@ -70,10 +70,6 @@ export function useNetworkStatus() {
} }
}, []) }, [])
useEffect(() => {
console.log("pingPongHealth", pingPongHealth)
}, [pingPongHealth])
useEffect(() => { useEffect(() => {
const issues = { const issues = {
[ConnectingTypeGroup.WebSocket]: steps[ [ConnectingTypeGroup.WebSocket]: steps[

View File

@ -43,7 +43,7 @@ export function useSetupEngineManager(
engineCommandManager.pool = settings.pool engineCommandManager.pool = settings.pool
} }
useLayoutEffect(() => { const startEngineInstance = () => {
// Load the engine command manager once with the initial width and height, // Load the engine command manager once with the initial width and height,
// then we do not want to reload it. // then we do not want to reload it.
const { width: quadWidth, height: quadHeight } = getDimensions( const { width: quadWidth, height: quadHeight } = getDimensions(
@ -73,7 +73,9 @@ export function useSetupEngineManager(
}) })
hasSetNonZeroDimensions.current = true hasSetNonZeroDimensions.current = true
} }
}, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight]) }
useLayoutEffect(startEngineInstance, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight])
useEffect(() => { useEffect(() => {
const handleResize = deferExecution(() => { const handleResize = deferExecution(() => {
@ -96,8 +98,20 @@ export function useSetupEngineManager(
} }
}, 500) }, 500)
const onOnline = () => {
startEngineInstance()
}
const onOffline = () => {
engineCommandManager.tearDown()
}
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
return () => { return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
} }
}, []) }, [])

View File

@ -1,8 +1,11 @@
import { PathToNode, Program, SourceRange } from 'lang/wasm' import { PathToNode, Program, SourceRange } from 'lang/wasm'
import { VITE_KC_API_WS_MODELING_URL } from 'env' import { invoke } from '@tauri-apps/api/core'
import { VITE_KC_API_WS_MODELING_URL, VITE_KC_API_BASE_URL } from 'env'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { exportSave } from 'lib/exportSave' import { exportSave } from 'lib/exportSave'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import withBaseURL from 'lib/withBaseURL'
import { isTauri } from 'lib/isTauri'
import { getNodePathFromSourceRange } from 'lang/queryAst' import { getNodePathFromSourceRange } from 'lang/queryAst'
import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme' import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme'
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
@ -140,7 +143,7 @@ export const CONNECTION_ERROR_TEXT: Record<ConnectionError, string> = {
[ConnectionError.DataChannelError]: "The data channel signaled an error.", [ConnectionError.DataChannelError]: "The data channel signaled an error.",
[ConnectionError.WebSocketError]: "The websocket signaled an error.", [ConnectionError.WebSocketError]: "The websocket signaled an error.",
[ConnectionError.LocalDescriptionInvalid]: "The local description is invalid.", [ConnectionError.LocalDescriptionInvalid]: "The local description is invalid.",
[ConnectionError.BadAuthToken]: "Your authorization token is not valid; please login again.", [ConnectionError.BadAuthToken]: "Your authorization token is invalid; please login again.",
[ConnectionError.TooManyConnections]: "There are too many connections.", [ConnectionError.TooManyConnections]: "There are too many connections.",
[ConnectionError.Unknown]: "An unexpected error occurred. Please report this to us.", [ConnectionError.Unknown]: "An unexpected error occurred. Please report this to us.",
} }
@ -150,7 +153,7 @@ export interface ErrorType {
error: ConnectionError, error: ConnectionError,
// Additional context. // Additional context.
context?: string, context?: any,
// We assign this in the state setter because we may have not failed at // We assign this in the state setter because we may have not failed at
// a Connecting state, which we check for there. // a Connecting state, which we check for there.
@ -276,10 +279,11 @@ class EngineConnection extends EventTarget {
if (next.type === EngineConnectionStateType.Disconnecting) { if (next.type === EngineConnectionStateType.Disconnecting) {
const sub = next.value const sub = next.value
if (sub.type === DisconnectingType.Error) { if (sub.type === DisconnectingType.Error) {
console.log(sub)
// Record the last step we failed at. // Record the last step we failed at.
// (Check the current state that we're about to override that // (Check the current state that we're about to override that
// it was a Connecting state.) // it was a Connecting state.)
console.log(sub)
if (this._state.type === EngineConnectionStateType.Connecting) { if (this._state.type === EngineConnectionStateType.Connecting) {
if (!sub.value) sub.value = {} if (!sub.value) sub.value = {}
sub.value.lastConnectingValue = this._state.value sub.value.lastConnectingValue = this._state.value
@ -366,13 +370,18 @@ class EngineConnection extends EventTarget {
return return
} }
// Information on the connect transaction
const createPeerConnection = () => { const createPeerConnection = () => {
this.pc = new RTCPeerConnection({ this.pc = new RTCPeerConnection({
bundlePolicy: 'max-bundle', bundlePolicy: 'max-bundle',
}) })
// Other parts of the application expect pc to be initialized when firing.
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.ConnectionStarted, {
detail: this,
})
)
// Data channels MUST BE specified before SDP offers because requesting // Data channels MUST BE specified before SDP offers because requesting
// them affects what our needs are! // them affects what our needs are!
const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds' const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds'
@ -440,7 +449,7 @@ class EngineConnection extends EventTarget {
type: DisconnectingType.Error, type: DisconnectingType.Error,
value: { value: {
error: ConnectionError.ICENegotiate, error: ConnectionError.ICENegotiate,
context: event.toString(), context: event,
}, },
}, },
} }
@ -566,7 +575,6 @@ class EngineConnection extends EventTarget {
}) })
this.unreliableDataChannel.addEventListener('close', (event) => { this.unreliableDataChannel.addEventListener('close', (event) => {
console.log("data channel close")
this.disconnectAll() this.disconnectAll()
this.finalizeIfAllConnectionsClosed() this.finalizeIfAllConnectionsClosed()
@ -581,7 +589,7 @@ class EngineConnection extends EventTarget {
type: DisconnectingType.Error, type: DisconnectingType.Error,
value: { value: {
error: ConnectionError.DataChannelError, error: ConnectionError.DataChannelError,
context: event.toString(), context: event,
}, },
}, },
} }
@ -619,278 +627,310 @@ class EngineConnection extends EventTarget {
}, },
} }
this.websocket = new WebSocket(this.url, []) const createWebSocketConnection = () => {
this.websocket.binaryType = 'arraybuffer' this.websocket = new WebSocket(this.url, [])
this.websocket.binaryType = 'arraybuffer'
this.websocket.addEventListener('open', (event) => { this.websocket.addEventListener('open', (event) => {
this.state = { this.state = {
type: EngineConnectionStateType.Connecting, type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.WebSocketOpen,
},
}
// Send an initial ping
this.send({ type: 'ping' })
this.pingPongSpan.ping = new Date()
// This is required for when KCMA is running stand-alone / within Tauri.
// Otherwise when run in a browser, the token is sent implicitly via
// the Cookie header.
if (this.token) {
this.send({
type: 'headers',
headers: { Authorization: `Bearer ${this.token}` },
})
}
})
this.websocket.addEventListener('close', (event) => {
console.log("websocket close")
this.disconnectAll()
this.finalizeIfAllConnectionsClosed()
})
this.websocket.addEventListener('error', (event) => {
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Error,
value: { value: {
error: ConnectionError.WebSocketError, type: ConnectingType.WebSocketOpen,
context: event.toString(),
}, },
},
}
})
this.websocket.addEventListener('message', (event) => {
// In the EngineConnection, we're looking for messages to/from
// the server that relate to the ICE handshake, or WebRTC
// negotiation. There may be other messages (including ArrayBuffer
// messages) that are intended for the GUI itself, so be careful
// when assuming we're the only consumer or that all messages will
// be carefully formatted here.
console.log("websocket message", event)
if (typeof event.data !== 'string') {
return
}
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
if (!message.success) {
const errorsString = message?.errors
?.map((error) => {
return ` - ${error.error_code}: ${error.message}`
})
.join('\n')
if (message.request_id) {
const artifactThatFailed =
this.engineCommandManager.artifactMap[message.request_id] ||
this.engineCommandManager.lastArtifactMap[message.request_id]
console.error(
`Error in response to request ${message.request_id}:\n${errorsString}
failed cmd type was ${artifactThatFailed?.commandType}`
)
} else {
console.error(`Error from server:\n${errorsString}`)
} }
return
}
let resp = message.resp // Send an initial ping
this.send({ type: 'ping' })
this.pingPongSpan.ping = new Date()
// If there's no body to the response, we can bail here. // This is required for when KCMA is running stand-alone / within Tauri.
if (!resp || !resp.type) { // Otherwise when run in a browser, the token is sent implicitly via
return // the Cookie header.
} if (this.token) {
this.send({ headers: { Authorization: `Bearer ${this.token}` } })
}
})
switch (resp.type) { this.websocket.addEventListener('close', (event) => {
case 'pong': this.disconnectAll()
this.pingPongSpan.pong = new Date() this.finalizeIfAllConnectionsClosed()
if (this.pingPongSpan.ping && this.pingPongSpan.pong) { })
if (
Math.abs(
this.pingPongSpan.pong.valueOf() -
this.pingPongSpan.ping.valueOf()
) >= pingIntervalMs
) {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'TIMEOUT',
})
)
} else {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'OK',
})
)
}
}
break
case 'ice_server_info':
let ice_servers = resp.data?.ice_servers
// Now that we have some ICE servers it makes sense this.websocket.addEventListener('error', (event) => {
// to start initializing the RTCPeerConnection. RTCPeerConnection this.disconnectAll()
// will begin the ICE process.
createPeerConnection()
this.state = { this.state = {
type: EngineConnectionStateType.Connecting, type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Error,
value: { value: {
type: ConnectingType.PeerConnectionCreated, error: ConnectionError.WebSocketError,
context: event,
}, },
} },
}
})
// No ICE servers can be valid in a local dev. env. this.websocket.addEventListener('message', (event) => {
if (ice_servers?.length === 0) { // In the EngineConnection, we're looking for messages to/from
console.warn('No ICE servers') // the server that relate to the ICE handshake, or WebRTC
this.pc?.setConfiguration({ // negotiation. There may be other messages (including ArrayBuffer
bundlePolicy: 'max-bundle', // messages) that are intended for the GUI itself, so be careful
// when assuming we're the only consumer or that all messages will
// be carefully formatted here.
if (typeof event.data !== 'string') {
return
}
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
if (!message.success) {
const errorsString = message?.errors
?.map((error) => {
return ` - ${error.error_code}: ${error.message}`
}) })
.join('\n')
if (message.request_id) {
const artifactThatFailed =
this.engineCommandManager.artifactMap[message.request_id] ||
this.engineCommandManager.lastArtifactMap[message.request_id]
console.error(
`Error in response to request ${message.request_id}:\n${errorsString}
failed cmd type was ${artifactThatFailed?.commandType}`
)
console.log(artifactThatFailed)
} else { } else {
// When we set the Configuration, we want to always force console.error(`Error from server:\n${errorsString}`)
// iceTransportPolicy to 'relay', since we know the topology
// of the ICE/STUN/TUN server and the engine. We don't wish to
// talk to the engine in any configuration /other/ than relay
// from a infra POV.
this.pc?.setConfiguration({
bundlePolicy: 'max-bundle',
iceServers: ice_servers,
iceTransportPolicy: 'relay',
})
} }
return
}
this.state = { let resp = message.resp
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.ICEServersSet,
},
}
// We have an ICE Servers set now. We just setConfiguration, so let's // If there's no body to the response, we can bail here.
// start adding things we care about to the PeerConnection and let if (!resp || !resp.type) {
// ICE negotiation happen in the background. Everything from here return
// until the end of this function is setup of our end of the }
// PeerConnection and waiting for events to fire our callbacks.
// Add a transceiver to our SDP offer switch (resp.type) {
this.pc?.addTransceiver('video', { case 'pong':
direction: 'recvonly', this.pingPongSpan.pong = new Date()
}) if (this.pingPongSpan.ping && this.pingPongSpan.pong) {
if (
// Create a session description offer based on our local environment Math.abs(
// that we will send to the remote end. The remote will send back this.pingPongSpan.pong.valueOf() -
// what it supports via sdp_answer. this.pingPongSpan.ping.valueOf()
this.pc ) >= pingIntervalMs
?.createOffer() ) {
.then((offer: RTCSessionDescriptionInit) => { this.dispatchEvent(
console.log(offer) new CustomEvent(EngineConnectionEvents.PingPongChanged, {
this.state = { detail: 'TIMEOUT',
type: EngineConnectionStateType.Connecting, })
value: { )
type: ConnectingType.SetLocalDescription, } else {
}, this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'OK',
})
)
} }
return this.pc?.setLocalDescription(offer).then(() => { }
this.send({ break
type: 'sdp_offer', case 'ice_server_info':
offer: { let ice_servers = resp.data?.ice_servers
sdp: offer.sdp || '',
type: offer.type, // Now that we have some ICE servers it makes sense
}, // to start initializing the RTCPeerConnection. RTCPeerConnection
}) // will begin the ICE process.
createPeerConnection()
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.PeerConnectionCreated,
},
}
// No ICE servers can be valid in a local dev. env.
if (ice_servers?.length === 0) {
console.warn('No ICE servers')
this.pc?.setConfiguration({
bundlePolicy: 'max-bundle',
})
} else {
// When we set the Configuration, we want to always force
// iceTransportPolicy to 'relay', since we know the topology
// of the ICE/STUN/TUN server and the engine. We don't wish to
// talk to the engine in any configuration /other/ than relay
// from a infra POV.
this.pc?.setConfiguration({
bundlePolicy: 'max-bundle',
iceServers: ice_servers,
iceTransportPolicy: 'relay',
})
}
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.ICEServersSet,
},
}
// We have an ICE Servers set now. We just setConfiguration, so let's
// start adding things we care about to the PeerConnection and let
// ICE negotiation happen in the background. Everything from here
// until the end of this function is setup of our end of the
// PeerConnection and waiting for events to fire our callbacks.
// Add a transceiver to our SDP offer
this.pc?.addTransceiver('video', {
direction: 'recvonly',
})
// Create a session description offer based on our local environment
// that we will send to the remote end. The remote will send back
// what it supports via sdp_answer.
this.pc
?.createOffer()
.then((offer: RTCSessionDescriptionInit) => {
console.log(offer)
this.state = { this.state = {
type: EngineConnectionStateType.Connecting, type: EngineConnectionStateType.Connecting,
value: { value: {
type: ConnectingType.OfferedSdp, type: ConnectingType.SetLocalDescription,
},
}
return this.pc?.setLocalDescription(offer).then(() => {
this.send({
type: 'sdp_offer',
offer,
})
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.OfferedSdp,
},
}
})
})
.catch((err: Error) => {
// The local description is invalid, so there's no point continuing.
this.disconnectAll()
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Error,
value: {
error: ConnectionError.LocalDescriptionInvalid,
context: err,
},
}, },
} }
}) })
break
case 'sdp_answer':
let answer = resp.data?.answer
if (!answer || answer.type === 'unspecified') {
return
}
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.ReceivedSdp,
},
}
// As soon as this is set, RTCPeerConnection tries to
// establish a connection.
// @ts-ignore
// Have to ignore because dom.ts doesn't have the right type
void this.pc?.setRemoteDescription(answer)
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.SetRemoteDescription,
},
}
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.WebRTCConnecting,
},
}
break
case 'trickle_ice':
let candidate = resp.data?.candidate
console.log('trickle_ice: using this candidate: ', candidate)
void this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
break
case 'metrics_request':
if (this.webrtcStatsCollector === undefined) {
// TODO: Error message here?
return
}
void this.webrtcStatsCollector().then((client_metrics) => {
this.send({
type: 'metrics_response',
metrics: client_metrics,
})
}) })
.catch((err: Error) => { break
// The local description is invalid, so there's no point continuing. }
this.disconnectAll() })
this.state = { }
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Error,
value: {
error: ConnectionError.LocalDescriptionInvalid,
context: err.toString(),
},
},
}
})
break
case 'sdp_answer': // api-deux currently doesn't report if an auth token is invalid on the
let answer = resp.data?.answer // websocket. As a workaround we can invoke two endpoints: /user and
if (!answer || answer.type === 'unspecified') { // /user/session/{token} . Former for regular operations, latter for
return // development.
} // Resolver: https://github.com/KittyCAD/api-deux/issues/1628
const promiseIsAuthed = this.token
this.state = { // We can't check tokens in localStorage, at least not yet.
type: EngineConnectionStateType.Connecting, // Resolver: https://github.com/KittyCAD/api-deux/issues/1629
value: { ? Promise.resolve({ status: 200 })
type: ConnectingType.ReceivedSdp, : !isTauri()
}, ? fetch(withBaseURL('/user'))
} : invoke<Models['User_type'] | Record<'error_code', unknown>>('get_user', {
token: this.token,
// As soon as this is set, RTCPeerConnection tries to hostname: VITE_KC_API_BASE_URL,
// establish a connection.
// @ts-ignore
// Have to ignore because dom.ts doesn't have the right type
void this.pc?.setRemoteDescription(answer)
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.SetRemoteDescription,
},
}
this.state = {
type: EngineConnectionStateType.Connecting,
value: {
type: ConnectingType.WebRTCConnecting,
},
}
break
case 'trickle_ice':
let candidate = resp.data?.candidate
console.log('trickle_ice: using this candidate: ', candidate)
void this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
break
case 'metrics_request':
if (this.webrtcStatsCollector === undefined) {
// TODO: Error message here?
return
}
void this.webrtcStatsCollector().then((client_metrics) => {
this.send({
type: 'metrics_response',
metrics: client_metrics,
})
}) })
break
promiseIsAuthed
.then((e) => {
if (e.status >= 200 && e.status < 400) {
createWebSocketConnection()
} else if (e.status === 401) {
this.state = {
type: EngineConnectionStateType.Disconnected,
value: {
type: DisconnectingType.Error,
value: {
error: ConnectionError.BadAuthToken,
context: e,
},
},
}
} else {
this.state = {
type: EngineConnectionStateType.Disconnected,
value: {
type: DisconnectingType.Error,
value: {
error: ConnectionError.Unknown,
context: e,
},
},
}
} }
}) })
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.ConnectionStarted, {
detail: this,
})
)
} }
// Do not change this back to an object or any, we should only be sending the // Do not change this back to an object or any, we should only be sending the
// WebSocketRequest type! // WebSocketRequest type!