nebulab-dropbox
Version:
Client library for the Dropbox API
495 lines (448 loc) • 19 kB
text/coffeescript
# Base class for OAuth drivers that run in the browser.
#
# Inheriting from this class makes a driver use HTML5 localStorage to preserve
# OAuth tokens across page reloads.
class Dropbox.AuthDriver.BrowserBase
# Sets up the OAuth driver.
#
# Subclasses should pass the options object they receive to the superclass
# constructor.
#
# @param {Object} options (optional) one or more of the options below
# @option options {String} scope embedded in the localStorage key that holds
# the authentication data; useful for having multiple OAuth tokens in a
# single application
# @option options {Boolean} rememberUser if false, the user's OAuth tokens
# are not saved in localStorage; true by default
constructor: (options) ->
if options
@rememberUser = if 'rememberUser' of options
options.rememberUser
else
true
@scope = options.scope or 'default'
else
@rememberUser = true
@scope = 'default'
@storageKey = null
@storage = Dropbox.AuthDriver.BrowserBase.localStorage()
@stateRe = /^[^#]+\#(.*&)?state=([^&]+)(&|$)/
# Browser-side authentication should always use OAuth 2 Implicit Grant.
#
# @see Dropbox.AuthDriver#authType
authType: ->
'token'
# Persists tokens.
#
# @see Dropbox.AuthDriver#onAuthStepChange
onAuthStepChange: (client, callback) ->
@setStorageKey client
switch client.authStep
when Dropbox.Client.RESET
@loadCredentials (credentials) =>
return callback() unless credentials
client.setCredentials credentials
if client.authStep isnt Dropbox.Client.DONE
return callback()
# There is an old access token. Only use it if the app supports
# logout.
unless @rememberUser
return @forgetCredentials(callback)
client.setCredentials credentials
callback()
when Dropbox.Client.DONE
if @rememberUser
return @storeCredentials(client.credentials(), callback)
@forgetCredentials callback
when Dropbox.Client.SIGNED_OUT
@forgetCredentials callback
when Dropbox.Client.ERROR
@forgetCredentials callback
else
callback()
@
# Computes the @storageKey used by loadCredentials and forgetCredentials.
#
# @private
# This is called by onAuthStepChange.
#
# @param {Dropbox.Client} client the client instance that is running the
# authorization process
# @return {Dropbox.AuthDriver} this, for easy call chaining
setStorageKey: (client) ->
# NOTE: the storage key is dependent on the app hash so that multiple apps
# hosted off the same server don't step on eachother's toes
@storageKey = "dropbox-auth:#{@scope}:#{client.appHash()}"
@
# Stores a Dropbox.Client's credentials in localStorage.
#
# @private
# onAuthStepChange calls this method during the authentication flow.
#
# @param {Object} credentials the result of a Drobpox.Client#credentials call
# @param {function()} callback called when the storing operation is complete
# @return {Dropbox.AuthDriver.BrowserBase} this, for easy call chaining
storeCredentials: (credentials, callback) ->
jsonString = JSON.stringify credentials
try
@storage.setItem @storageKey, jsonString
catch storageError
# Safari disables localStorage in Private Browsing mode.
name = encodeURIComponent @storageKey
value = encodeURIComponent jsonString
document.cookie = "#{name}=#{value}; path=/"
callback()
@
# Retrieves a token and secret from localStorage.
#
# @private
# onAuthStepChange calls this method during the authentication flow.
#
# @param {function(Object)} callback supplied with the credentials object
# stored by a previous call to
# {Dropbox.AuthDriver.BrowserBase#storeCredentials}; the argument is null
# if no credentials were stored, or if the previously stored credentials
# were deleted
# @return {Dropbox.AuthDriver.BrowserBase} this, for easy call chaining
loadCredentials: (callback) ->
try
jsonString = @storage.getItem @storageKey
catch storageError
# Safari disables localStorage in Private Browsing mode, but
# localStorage.getItem exists and returns null. This is here to mimic
# that behavior in environments that have localStorage disabled for some
# reason.
jsonString = null
if jsonString is null
# Safari disables localStorage in Private Browsing mode. We need to check
# for cookies as well.
name = encodeURIComponent @storageKey
# Characters unescaped by encodeURIComponent:
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
# Characters that must be escaped in regular expressions:
# http://stackoverflow.com/a/3561711/537046
nameRegexp = name.replace(/[.*+()]/g, '\\$&')
cookieRegexp = new RegExp "(^|(;\\s*))#{name}=([^;]*)(;|$)"
if match = cookieRegexp.exec(document.cookie)
jsonString = decodeURIComponent match[3]
unless jsonString
callback null
return @
try
callback JSON.parse(jsonString)
catch jsonError
# Parse errors.
callback null
@
# Deletes information previously stored by a call to storeCredentials.
#
# @private
# onAuthStepChange calls this method during the authentication flow.
#
# @param {function()} callback called after the credentials are deleted
# @return {Dropbox.AuthDriver.BrowserBase} this, for easy call chaining
forgetCredentials: (callback) ->
try
@storage.removeItem @storageKey
catch storageError
# Safari disables localStorage in Private Browsing mode.
name = encodeURIComponent @storageKey
expires = (new Date(0)).toGMTString()
document.cookie = "#{name}={}; expires=#{expires}; path=/"
callback()
@
# Figures out if a URL is an OAuth 2.0 /authorize redirect URL.
#
# @param {String} the URL to check; if null, the current location's URL is
# checked
# @return {String} the state parameter value received from the /authorize
# redirect, or null if the URL is not the result of an /authorize redirect
locationStateParam: (url) ->
location = url or Dropbox.AuthDriver.BrowserBase.currentLocation()
# Extract the state.
match = @stateRe.exec location
return decodeURIComponent(match[2]) if match
null
# Replaces the filename (basename) in an URL.
#
# @private
# This is used by subclasses to compute the redirect_uri value passed to
# /authorize.
#
# @param {String} url the URL whose basename will be replaced
# @param {String} basename the file name in the returned URL
# @return {String} an URL whose basename has been replaced; the URL will not
# have a query or fragment
replaceUrlBasename: (url, basename) ->
hashIndex = url.indexOf '#'
url = url.substring 0, hashIndex if hashIndex isnt -1
queryIndex = url.indexOf '?'
url = url.substring 0, queryIndex if queryIndex isnt -1
fragments = url.split '/'
fragments[fragments.length - 1] = basename
fragments.join '/'
# Wrapper for window.localStorage.
#
# Drivers should call this method instead of using localStorage directly, to
# simplify stubbing.
#
# @return {Storage} the browser's implementation of the WindowLocalStorage
# interface in the Web Storage specification
@localStorage: ->
if typeof window isnt 'undefined'
try
window.localStorage
catch deprecationError
# Simply accessing window.localStorage in Chrome apps is deprecated.
null
else
null
# Wrapper for window.location.
#
# Drivers should call this method instead of using browser APIs directly, to
# simplify stubbing.
#
# @return {String} the current page's URL
@currentLocation: ->
window.location.href
# Removes the OAuth 2 access token from the current page's URL.
#
# This hopefully also removes the access token from the browser history.
#
# @return {void}
@cleanupLocation: ->
if window.history and window.history.replaceState
pageUrl = @currentLocation()
hashIndex = pageUrl.indexOf '#'
window.history.replaceState {}, document.title,
pageUrl.substring(0, hashIndex)
else
window.location.hash = ''
return
# OAuth driver that uses a redirect and localStorage to complete the flow.
class Dropbox.AuthDriver.Redirect extends Dropbox.AuthDriver.BrowserBase
# Sets up the redirect-based OAuth driver.
#
# @param {Object} options (optional) one more of the options below
# @option options {String} redirectUrl URL to the page that receives the
# /authorize redirect
# @option options {String} redirectFile the URL to the receiver page will be
# computed by replacing the file name (everything after the last /) of
# the current location with this parameter's value
# @option options {String} scope embedded in the localStorage key that holds
# the authentication data; useful for having multiple OAuth tokens in a
# single application
# @option options {Boolean} rememberUser if false, the user's OAuth tokens
# are not saved in localStorage; true by default
constructor: (options) ->
super options
@receiverUrl = @baseUrl options
# The URL of the page that will receive the callback.
#
# @private
# This should only be called by the constructor.
#
# @param {Object} options (optional) the options passed to the constructor
# @option options {String} redirectUrl URL to the page that receives the
# /authorize redirect
# @option options {String} redirectFile the URL to the receiver page will be
# computed by replacing the file name (everything after the last /) of
# the current location with this parameter's value
# @return {String} the current URL, minus any fragment it might have
baseUrl: (options) ->
url = Dropbox.AuthDriver.BrowserBase.currentLocation()
if options
if options.redirectUrl
return options.redirectUrl
if options.redirectFile
return @replaceUrlBasename(url, options.redirectFile)
hashIndex = url.indexOf '#'
url = url.substring 0, hashIndex if hashIndex isnt -1
url
# URL of the page that the user will be redirected to.
#
# @return {String} the URL of the app page that the user will be redirected
# to after /authorize; if no constructor option is set, this will be the
# current page
# @see Dropbox.AuthDriver#url
url: ->
@receiverUrl
# Saves the OAuth 2 credentials, and redirects to the authorize page.
#
# @see Dropbox.AuthDriver#doAuthorize
doAuthorize: (authUrl, stateParam, client) ->
@storeCredentials client.credentials(), ->
window.location.assign authUrl
# Finishes the OAuth 2 process after the user has been redirected.
#
# @see Dropbox.AuthDriver#resumeAuthorize
resumeAuthorize: (stateParam, client, callback) ->
if @locationStateParam() is stateParam
pageUrl = Dropbox.AuthDriver.BrowserBase.currentLocation()
Dropbox.AuthDriver.BrowserBase.cleanupLocation()
callback Dropbox.Util.Oauth.queryParamsFromUrl pageUrl
else
@forgetCredentials ->
callback error: 'Authorization error'
# OAuth driver that uses a popup window and postMessage to complete the flow.
class Dropbox.AuthDriver.Popup extends Dropbox.AuthDriver.BrowserBase
# Sets up a popup-based OAuth driver.
#
# @param {Object} options (optional) one or more of the options below
# @option options {String} receiverUrl URL to the page that receives the
# /authorize redirect and performs the postMessage
# @option options {String} receiverFile the URL to the receiver page will be
# computed by replacing the file name (everything after the last /) of
# the current location with this parameter's value
# @option options {String} scope embedded in the localStorage key that holds
# the authentication data; useful for having multiple OAuth tokens in a
# single application
# @option options {Boolean} rememberUser if false, the user's OAuth tokens
# are not saved in localStorage; true by default
constructor: (options) ->
super options
@receiverUrl = @baseUrl options
# URL of the redirect receiver page, which posts a message back to this page.
#
# @return {String} receiver page URL
# @see Dropbox.AuthDriver#url
url: ->
@receiverUrl
# Shows the authorization URL in a pop-up, waits for it to send a message.
#
# @see Dropbox.AuthDriver#doAuthorize
doAuthorize: (authUrl, stateParam, client, callback) ->
@listenForMessage stateParam, callback
@openWindow authUrl
# The URL of the page that will receive the OAuth callback.
#
# @private
# This should only be called by the constructor.
#
# @param {Object} options the options passed to the constructor
# @option options {String} receiverUrl URL to the page that receives the
# /authorize redirect and performs the postMessage
# @option options {String} receiverFile the URL to the receiver page will be
# computed by replacing the file name (everything after the last /) of
# the current location with this parameter's value
# @return {String} absolute URL of the receiver page
baseUrl: (options) ->
url = Dropbox.AuthDriver.BrowserBase.currentLocation()
if options
if options.receiverUrl
return options.receiverUrl
else if options.receiverFile
return @replaceUrlBasename(url, options.receiverFile)
url
# Creates a popup window.
#
# @private
# This should only be called by {Dropbox.AuthDriver.Popup#doAuthorize}.
#
# @param {String} url the URL that will be loaded in the popup window
# @return {DOMRef} reference to the opened window, or null if the call failed
openWindow: (url) ->
window.open url, '_dropboxOauthSigninWindow', @popupWindowSpec(980, 700)
# Spec string for window.open to create a nice popup.
#
# @private
# This should only be called by {Dropbox.AuthDriver.Popup#openWindow}.
#
# @param {Number} popupWidth the desired width of the popup window
# @param {Number} popupHeight the desired height of the popup window
# @return {String} spec string for the popup window
popupWindowSpec: (popupWidth, popupHeight) ->
# Metrics for the current browser window.
x0 = window.screenX ? window.screenLeft
y0 = window.screenY ? window.screenTop
width = window.outerWidth ? document.documentElement.clientWidth
height = window.outerHeight ? document.documentElement.clientHeight
# Computed popup window metrics.
popupLeft = Math.round x0 + (width - popupWidth) / 2
popupTop = Math.round y0 + (height - popupHeight) / 2.5
popupLeft = x0 if popupLeft < x0
popupTop = y0 if popupTop < y0
# The specification string.
"width=#{popupWidth},height=#{popupHeight}," +
"left=#{popupLeft},top=#{popupTop}" +
'dialog=yes,dependent=yes,scrollbars=yes,location=yes'
# Listens for a postMessage from a previously opened popup window.
#
# @private
# This should only be called by {Dropbox.AuthDriver.Popup#doAuthorize}.
#
# @param {String} stateParam the state parameter passed to the OAuth 2
# /authorize endpoint
# @param {function()} called when the received message matches stateParam
listenForMessage: (stateParam, callback) ->
listener = (event) =>
if event.data
# Message coming from postMessage.
data = event.data
else
# Message coming from Dropbox.Util.EventSource.
data = event
try
oauthInfo = JSON.parse(data)._dropboxjs_oauth_info
catch jsonError
return
return unless oauthInfo
if @locationStateParam(oauthInfo) is stateParam
stateParam = false # Avoid having this matched in the future.
window.removeEventListener 'message', listener
Dropbox.AuthDriver.Popup.onMessage.removeListener listener
callback Dropbox.Util.Oauth.queryParamsFromUrl(data)
window.addEventListener 'message', listener, false
Dropbox.AuthDriver.Popup.onMessage.addListener listener
# The origin of a location, in the context of the same-origin policy.
#
# @param {String} location the URL whose origin is computed
# @return {String} the location's origin
@locationOrigin: (location) ->
# file:// URLs -- the origin is the whole path
match = /^(file:\/\/[^\?\#]*)(\?|\#|$)/.exec location
return match[1] if match
# xxx:// URLs -- the origin is the scheme and the first path segment
# e.g. http://, https://
match = /^([^\:]+\:\/\/[^\/\?\#]*)(\/|\?|\#|$)/.exec location
return match[1] if match
# e.g., data: URLs -- the origin is everything
location
# Communicates with the driver from the OAuth receiver page.
#
# The easiest way for an application to keep up to date with dropbox.js is to
# set up a popup receiver page that loads dropbox.js and calls this method.
# This guarantees that the code used to communicate between the popup
# receiver page and {Dropbox.AuthDriver.Popup#doAuthorize} stays up to date
# as dropbox.js is updated.
#
# @return {void}
@oauthReceiver: ->
window.addEventListener 'load', ->
pageUrl = window.location.href
message = JSON.stringify _dropboxjs_oauth_info: pageUrl
Dropbox.AuthDriver.BrowserBase.cleanupLocation()
opener = window.opener
if window.parent isnt window.top
opener or= window.parent
if opener
try
pageOrigin = window.location.origin or locationOrigin(pageUrl)
opener.postMessage message, pageOrigin
window.close()
catch ieError
# IE <= 9 doesn't support opener.postMessage for popup windows.
try
# postMessage doesn't work in IE, but direct object access does.
opener.Dropbox.AuthDriver.Popup.onMessage.dispatch message
window.close()
catch frameError
# Got nothing left to do.
# Leave the window opened so it can be debugged.
return
# Works around postMessage failures on Internet Explorer.
#
# @private
# This should only be used by {Dropbox.AuthDriver.Popup#doAuthorize} and
# {Dropbox.AuthDriver.Popup.oauthReceiver}.
@onMessage = new Dropbox.Util.EventSource