diff --git a/lib/tunnelbroker/tunnelbroker-context.js b/lib/tunnelbroker/tunnelbroker-context.js index eb0727945..75f6e1d29 100644 --- a/lib/tunnelbroker/tunnelbroker-context.js +++ b/lib/tunnelbroker/tunnelbroker-context.js @@ -1,293 +1,327 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { tunnnelbrokerURL } from '../facts/tunnelbroker.js'; import { tunnelbrokerHeartbeatTimeout } from '../shared/timeouts.js'; import type { MessageReceiveConfirmation } from '../types/tunnelbroker/message-receive-confirmation-types.js'; import type { MessageSentStatus } from '../types/tunnelbroker/message-to-device-request-status-types.js'; import type { MessageToDeviceRequest } from '../types/tunnelbroker/message-to-device-request-types.js'; import { type TunnelbrokerMessage, tunnelbrokerMessageTypes, tunnelbrokerMessageValidator, } from '../types/tunnelbroker/messages.js'; import { type PeerToPeerMessage, peerToPeerMessageValidator, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; -import type { ConnectionInitializationMessage } from '../types/tunnelbroker/session-types.js'; +import type { + AnonymousInitializationMessage, + TunnelbrokerInitializationMessage, + ConnectionInitializationMessage, +} from '../types/tunnelbroker/session-types.js'; import type { Heartbeat } from '../types/websocket/heartbeat-types.js'; export type ClientMessageToDevice = { +deviceID: string, +payload: string, }; export type TunnelbrokerSocketListener = ( message: TunnelbrokerMessage, ) => mixed; type PromiseCallbacks = { +resolve: () => void, +reject: (error: string) => void, }; type Promises = { [clientMessageID: string]: PromiseCallbacks }; type TunnelbrokerContextType = { +sendMessage: (message: ClientMessageToDevice) => Promise, +addListener: (listener: TunnelbrokerSocketListener) => void, +removeListener: (listener: TunnelbrokerSocketListener) => void, +connected: boolean, + +setUnauthorizedDeviceID: (unauthorizedDeviceID: ?string) => void, }; const TunnelbrokerContext: React.Context = React.createContext(); type Props = { +children: React.Node, +initMessage: ?ConnectionInitializationMessage, +peerToPeerMessageHandler?: (message: PeerToPeerMessage) => mixed, }; +function createAnonymousInitMessage( + deviceID: string, +): AnonymousInitializationMessage { + return ({ + type: 'AnonymousInitializationMessage', + deviceID, + deviceType: 'mobile', + }: AnonymousInitializationMessage); +} + function TunnelbrokerProvider(props: Props): React.Node { - const { children, initMessage, peerToPeerMessageHandler } = props; + const { + children, + initMessage: initMessageProp, + peerToPeerMessageHandler, + } = props; const [connected, setConnected] = React.useState(false); const listeners = React.useRef>(new Set()); const socket = React.useRef(null); const promises = React.useRef({}); const heartbeatTimeoutID = React.useRef(); + const [unauthorizedDeviceID, setUnauthorizedDeviceID] = + React.useState(null); + const isAuthorized = !unauthorizedDeviceID; + + const initMessage = React.useMemo(() => { + if (!unauthorizedDeviceID) { + return initMessageProp; + } + return createAnonymousInitMessage(unauthorizedDeviceID); + }, [unauthorizedDeviceID, initMessageProp]); const previousInitMessage = - React.useRef(initMessage); + React.useRef(initMessage); const initMessageChanged = initMessage !== previousInitMessage.current; previousInitMessage.current = initMessage; const stopHeartbeatTimeout = React.useCallback(() => { if (heartbeatTimeoutID.current) { clearTimeout(heartbeatTimeoutID.current); heartbeatTimeoutID.current = null; } }, []); const resetHeartbeatTimeout = React.useCallback(() => { stopHeartbeatTimeout(); heartbeatTimeoutID.current = setTimeout(() => { socket.current?.close(); setConnected(false); }, tunnelbrokerHeartbeatTimeout); }, [stopHeartbeatTimeout]); // determine if the socket is active (not closed or closing) const isSocketActive = socket.current?.readyState === WebSocket.OPEN || socket.current?.readyState === WebSocket.CONNECTING; // The Tunnelbroker connection can have 4 states: // - DISCONNECTED: isSocketActive = false, connected = false // Should be in this state when initMessage is null // - CONNECTING: isSocketActive = true, connected = false // This lasts until Tunnelbroker sends ConnectionInitializationResponse // - CONNECTED: isSocketActive = true, connected = true // - DISCONNECTING: isSocketActive = false, connected = true // This lasts between socket.close() and socket.onclose() React.useEffect(() => { // when initMessage changes, we need to close the socket and open a new one if ((!initMessage || initMessageChanged) && isSocketActive) { socket.current?.close(); return; } // when we're already connected (or pending disconnection), // or there's no init message to start with, we don't need to do anything if (connected || !initMessage) { return; } const tunnelbrokerSocket = new WebSocket(tunnnelbrokerURL); tunnelbrokerSocket.onopen = () => { tunnelbrokerSocket.send(JSON.stringify(initMessage)); }; tunnelbrokerSocket.onclose = () => { // this triggers the effect hook again and reconnect setConnected(false); console.log('Connection to Tunnelbroker closed'); }; tunnelbrokerSocket.onerror = e => { console.log('Tunnelbroker socket error:', e.message); }; tunnelbrokerSocket.onmessage = (event: MessageEvent) => { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return; } let rawMessage; try { rawMessage = JSON.parse(event.data); } catch (e) { console.log('error while parsing Tunnelbroker message:', e.message); return; } if (!tunnelbrokerMessageValidator.is(rawMessage)) { console.log('invalid TunnelbrokerMessage'); return; } const message: TunnelbrokerMessage = rawMessage; resetHeartbeatTimeout(); for (const listener of listeners.current) { listener(message); } if ( message.type === tunnelbrokerMessageTypes.CONNECTION_INITIALIZATION_RESPONSE ) { if (message.status.type === 'Success' && !connected) { setConnected(true); - console.log('session with Tunnelbroker created'); + console.log( + 'session with Tunnelbroker created. isAuthorized:', + isAuthorized, + ); } else if (message.status.type === 'Success' && connected) { console.log( 'received ConnectionInitializationResponse with status: Success for already connected socket', ); } else { setConnected(false); console.log( 'creating session with Tunnelbroker error:', message.status.data, ); } } else if (message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) { const confirmation: MessageReceiveConfirmation = { type: tunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, messageIDs: [message.messageID], }; socket.current?.send(JSON.stringify(confirmation)); if (!peerToPeerMessageHandler) { return; } let rawPeerToPeerMessage; try { rawPeerToPeerMessage = JSON.parse(message.payload); } catch (e) { console.log( 'error while parsing Tunnelbroker peer-to-peer message:', e.message, ); return; } if (!peerToPeerMessageValidator.is(rawPeerToPeerMessage)) { console.log('invalid Tunnelbroker PeerToPeerMessage'); return; } const peerToPeerMessage: PeerToPeerMessage = rawPeerToPeerMessage; peerToPeerMessageHandler(peerToPeerMessage); } else if ( message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST_STATUS ) { for (const status: MessageSentStatus of message.clientMessageIDs) { if (status.type === 'Success') { promises.current[status.data]?.resolve(); delete promises.current[status.data]; } else if (status.type === 'Error') { promises.current[status.data.id]?.reject(status.data.error); delete promises.current[status.data.id]; } else if (status.type === 'SerializationError') { console.log('SerializationError for message: ', status.data); } else if (status.type === 'InvalidRequest') { console.log('Tunnelbroker recorded InvalidRequest'); } } } else if (message.type === tunnelbrokerMessageTypes.HEARTBEAT) { const heartbeat: Heartbeat = { type: tunnelbrokerMessageTypes.HEARTBEAT, }; socket.current?.send(JSON.stringify(heartbeat)); } }; socket.current = tunnelbrokerSocket; }, [ connected, initMessage, initMessageChanged, isSocketActive, + isAuthorized, resetHeartbeatTimeout, stopHeartbeatTimeout, peerToPeerMessageHandler, ]); const sendMessage: (message: ClientMessageToDevice) => Promise = React.useCallback( (message: ClientMessageToDevice) => { if (!connected || !socket.current) { throw new Error('Tunnelbroker not connected'); } const clientMessageID = uuid.v4(); const messageToDevice: MessageToDeviceRequest = { type: tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST, clientMessageID, deviceID: message.deviceID, payload: message.payload, }; return new Promise((resolve, reject) => { promises.current[clientMessageID] = { resolve, reject, }; socket.current?.send(JSON.stringify(messageToDevice)); }); }, [connected], ); const addListener = React.useCallback( (listener: TunnelbrokerSocketListener) => { listeners.current.add(listener); }, [], ); const removeListener = React.useCallback( (listener: TunnelbrokerSocketListener) => { listeners.current.delete(listener); }, [], ); const value: TunnelbrokerContextType = React.useMemo( () => ({ sendMessage, connected, addListener, removeListener, + setUnauthorizedDeviceID, }), [addListener, connected, removeListener, sendMessage], ); return ( {children} ); } function useTunnelbroker(): TunnelbrokerContextType { const context = React.useContext(TunnelbrokerContext); invariant(context, 'TunnelbrokerContext not found'); return context; } export { TunnelbrokerProvider, useTunnelbroker }; diff --git a/lib/types/tunnelbroker/session-types.js b/lib/types/tunnelbroker/session-types.js index ab15da5ed..19c3d59e4 100644 --- a/lib/types/tunnelbroker/session-types.js +++ b/lib/types/tunnelbroker/session-types.js @@ -1,31 +1,52 @@ // @flow import type { TInterface } from 'tcomb'; import t from 'tcomb'; import { tShape, tString } from '../../utils/validation-utils.js'; -export type DeviceTypes = 'mobile' | 'web' | 'keyserver'; +export type TunnelbrokerDeviceTypes = 'mobile' | 'web' | 'keyserver'; export type ConnectionInitializationMessage = { +type: 'ConnectionInitializationMessage', +deviceID: string, +accessToken: string, +userID: string, +notifyToken?: ?string, - +deviceType: DeviceTypes, + +deviceType: TunnelbrokerDeviceTypes, +deviceAppVersion?: ?string, +deviceOS?: ?string, }; +export type AnonymousInitializationMessage = { + +type: 'AnonymousInitializationMessage', + +deviceID: string, + +deviceType: TunnelbrokerDeviceTypes, + +deviceAppVersion?: ?string, + +deviceOS?: ?string, +}; + +export type TunnelbrokerInitializationMessage = + | ConnectionInitializationMessage + | AnonymousInitializationMessage; + export const connectionInitializationMessageValidator: TInterface = tShape({ type: tString('ConnectionInitializationMessage'), deviceID: t.String, accessToken: t.String, userID: t.String, notifyToken: t.maybe(t.String), - deviceType: t.enums.of(['Mobile', 'Web', 'Keyserver']), + deviceType: t.enums.of(['mobile', 'web', 'keyserver']), + deviceAppVersion: t.maybe(t.String), + deviceOS: t.maybe(t.String), + }); + +export const anonymousInitializationMessageValidator: TInterface = + tShape({ + type: tString('AnonymousInitializationMessage'), + deviceID: t.String, + deviceType: t.enums.of(['mobile', 'web', 'keyserver']), deviceAppVersion: t.maybe(t.String), deviceOS: t.maybe(t.String), });