# All files / src/auth protocolLaunch.ts

3.37% Statements 3/89
0% Branches 0/29
0% Functions 0/19
3.61% Lines 3/83

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    1721x
    1x

    1x

     import { getGlobalObjects, BLOCKSTACK_HANDLER } from '../utils'
    import { Logger } from '../logger'

    /**
     * Detects if the native auth-browser is installed and is successfully
     * launched via a custom protocol URI.
     * @param {String} authRequest
     * The encoded authRequest to be used as a query param in the custom URI.
     * @param {String} successCallback
     * The callback that is invoked when the protocol handler was detected.
     * @param {String} failCallback
     * The callback that is invoked when the protocol handler was not detected.
     * @return {void}
     */
    export function launchCustomProtocol(
      authRequest: string,
      successCallback: () => void,
      failCallback: () => void) {
      // Create a unique ID used for this protocol detection attempt.
      const echoReplyID = Math.random().toString(36).substr(2, 9)
      const echoReplyKeyPrefix = 'echo-reply-'
      const echoReplyKey = `${echoReplyKeyPrefix}${echoReplyID}`

      const {
        localStorage, document,
        setTimeout, clearTimeout,
        addEventListener, removeEventListener
      } = getGlobalObjects(
        ['localStorage', 'document', 'setTimeout', 'clearTimeout', 'addEventListener', 'removeEventListener'],
        { throwIfUnavailable: true, usageDesc: 'detectProtocolLaunch' }
      )

      // Use localStorage as a reliable cross-window communication method.
      // Create the storage entry to signal a protocol detection attempt for the
      // next browser window to check.
      localStorage.setItem(echoReplyKey, Date.now().toString())
      const cleanUpLocalStorage = () => {
        try {
          localStorage.removeItem(echoReplyKey)
          // Also clear out any stale echo-reply keys older than 1 hour.
          for (let i = 0; i < localStorage.length; i++) {
            const storageKey = localStorage.key(i)
            if (storageKey && storageKey.startsWith(echoReplyKeyPrefix)) {
              const storageValue = localStorage.getItem(storageKey)
              if (storageValue === 'success' || (Date.now() - parseInt(storageValue, 10)) > 3600000) {
                localStorage.removeItem(storageKey)
              }
            }
          }
        } catch (err) {
          Logger.error('Exception cleaning up echo-reply entries in localStorage')
          Logger.error(err)
        }
      }

      const detectionTimeout = 1000
      let redirectToWebAuthTimer = 0
      const cancelWebAuthRedirectTimer = () => {
        if (redirectToWebAuthTimer) {
          clearTimeout(redirectToWebAuthTimer)
          redirectToWebAuthTimer = 0
        }
      }
      const startWebAuthRedirectTimer = (timeout = detectionTimeout) => {
        cancelWebAuthRedirectTimer()
        redirectToWebAuthTimer = setTimeout(() => {
          if (redirectToWebAuthTimer) {
            cancelWebAuthRedirectTimer()
            let nextFunc: () => void
            if (localStorage.getItem(echoReplyKey) === 'success') {
              Logger.info('Protocol echo reply detected.')
              nextFunc = successCallback
            } else {
              Logger.info('Protocol handler not detected.')
              nextFunc = failCallback
            }
            failCallback = () => {}
            successCallback = () => {}
            cleanUpLocalStorage()
            // Briefly wait since localStorage changes can
            // sometimes be ignored when immediately redirected.
            setTimeout(() => nextFunc(), 100)
          }
        }, timeout)
      }

      startWebAuthRedirectTimer()

      const inputPromptTracker = document.createElement('input')
      inputPromptTracker.type = 'text'

      // Setting display:none on an element prevents them from being focused/blurred.
      // So we hide using 0 width/height/opacity, and set position:fixed so that the
      // page does not scroll when the element is focused.
      const hiddenCssStyle = 'all: initial; position: fixed; top: 0; height: 0; width: 0; opacity: 0;'
      inputPromptTracker.style.cssText = hiddenCssStyle

      // If the the focus of a page element is immediately changed then this likely indicates
      // the protocol handler is installed, and the browser is prompting the user if they want
      // to open the application.
      const inputBlurredFunc = () => {
        // Use a timeout of 100ms to ignore instant toggles between blur and focus.
        // Browsers often perform an instant blur & focus when the protocol handler is working
        // but not showing any browser prompts, so we want to ignore those instances.
        let isRefocused = false
        inputPromptTracker.addEventListener('focus', () => { isRefocused = true }, { once: true, capture: true })
        setTimeout(() => {
          if (redirectToWebAuthTimer && !isRefocused) {
            Logger.info('Detected possible browser prompt for opening the protocol handler app.')
            clearTimeout(redirectToWebAuthTimer)
            inputPromptTracker.addEventListener('focus', () => {
              if (redirectToWebAuthTimer) {
                Logger.info('Possible browser prompt closed, restarting auth redirect timeout.')
                startWebAuthRedirectTimer()
              }
            }, { once: true, capture: true })
          }
        }, 100)
      }
      inputPromptTracker.addEventListener('blur', inputBlurredFunc, { once: true, capture: true })
      setTimeout(() => inputPromptTracker.removeEventListener('blur', inputBlurredFunc), 200)
      document.body.appendChild(inputPromptTracker)
      inputPromptTracker.focus()

      // Detect if document.visibility is immediately changed which is a strong
      // indication that the protocol handler is working. We don't know for sure and
      // can't predict future browser changes, so only increase the redirect timeout.
      // This reduces the probability of a false-negative (where local auth works, but
      // the original page was redirect to web auth because something took too long),
      const pageVisibilityChanged = () => {
        if (document.hidden && redirectToWebAuthTimer) {
          Logger.info('Detected immediate page visibility change (protocol handler probably working).')
          startWebAuthRedirectTimer(3000)
        }
      }
      document.addEventListener('visibilitychange', pageVisibilityChanged, { once: true, capture: true })
      setTimeout(() => document.removeEventListener('visibilitychange', pageVisibilityChanged), 500)

      // Listen for the custom protocol echo reply via localStorage update event.
      addEventListener('storage', function replyEventListener(event) {
        if (event.key === echoReplyKey && localStorage.getItem(echoReplyKey) === 'success') {
          // Custom protocol worked, cancel the web auth redirect timer.
          cancelWebAuthRedirectTimer()
          inputPromptTracker.removeEventListener('blur', inputBlurredFunc)
          Logger.info('Protocol echo reply detected from localStorage event.')
          // Clean up event listener and localStorage.
          removeEventListener('storage', replyEventListener)
          const nextFunc = successCallback
          successCallback = () => {}
          failCallback = () => {}
          cleanUpLocalStorage()
          // Briefly wait since localStorage changes can sometimes
          // be ignored when immediately redirected.
          setTimeout(() => nextFunc(), 100)
        }
      }, false)

      // Use iframe technique for launching the protocol URI rather than setting `window.location`.
      // This method prevents browsers like Safari, Opera, Firefox from showing error prompts
      // about unknown protocol handler when app is not installed, and avoids an empty
      // browser tab when the app is installed.
      Logger.info('Attempting protocol launch via iframe injection.')
      const locationSrc = `${BLOCKSTACK_HANDLER}:${authRequest}&echo=${echoReplyID}`
      const iframe = document.createElement('iframe')

      const iframeStyle = 'all: initial; display: none; position: fixed; top: 0; height: 0; width: 0; opacity: 0;'
      iframe.style.cssText = iframeStyle
      iframe.src = locationSrc
      document.body.appendChild(iframe)
    }