peterhudec/authomatic

View on GitHub
examples/gae/showcase/static/js/authomatic.coffee

Summary

Maintainability
Test Coverage

###
# CoffeeDoc example documentation #

This is a module-level docstring, and will be displayed at the top of the module documentation.
Documentation generated by [CoffeeDoc](http://github.com/omarkhan/coffeedoc)

npm install -g coffeedoc

###

$ = jQuery

jsonPCallbackCounter = 0

globalOptions =
  # Popup options
  logging: on
  popupWidth: 800
  popupHeight: 600
  popupLinkSelector: 'a.authomatic'
  popupFormSelector: 'form.authomatic'
  popupFormValidator: ($form) -> true

  # Access options
  backend: null
  forceBackend: off
  substitute: {}
  params: {}
  headers: {}
  body: ''
  jsonpCallbackPrefix: 'authomaticJsonpCallback'

  # Callbacks
  onPopupInvalid: null # ($form) ->
  onPopupOpen: null # (url) ->
  onLoginComplete: null # (result) ->
  onBackendStart: null # (data) ->
  onBackendComplete: null # (data, textStatus, jqXHR) ->
  onAccessSuccess: null # (data, textStatus, jqXHR) ->
  onAccessComplete: null # (jqXHR, textStatus) ->


###########################################################
# Internal functions
###########################################################

# Logs to the console if available and if globalOptions.logging is true.
log = (args...) ->
  console?.log('Authomatic:', args...) if (globalOptions.logging and console?.log?.apply?)

# Opens a new centered window with the URL specified.
openWindow = (url) ->
  width = globalOptions.popupWidth
  height = globalOptions.popupHeight

  top = (screen.height / 2) - (height / 2)
  left = (screen.width / 2) - (width / 2)
  settings = "width=#{width},height=#{height},top=#{top},left=#{left}"
  log('Opening popup:', url)
  globalOptions.onPopupOpen?(url)
  window.open(url, '', settings)

# Parses a querystring and returns object.
parseQueryString = (queryString) ->
  result = {}
  for item in queryString.split('&')
    [k, v] = item.split('=')
    v = decodeURIComponent(v)
    if result.hasOwnProperty k
      if Array.isArray result[k]
        result[k].push(v)
      else
        result[k] = [result[k], v]
    else
      result[k] = v
  result

# Parses URL and returns an object with url, query and params properties.
parseUrl = (url) ->
  log('parseUrl', url)
  questionmarkIndex = url.indexOf('?')
  if questionmarkIndex >= 0
    u = url.substring(0, questionmarkIndex)
    qs = url.substring(questionmarkIndex + 1)
  else
    u = url

  url: u
  query: qs
  params: parseQueryString(qs) if qs

# Deserializes credentials and returns an object with id, typeId, type, subtype and rest properties.
deserializeCredentials = (credentials) ->
  sc = decodeURIComponent(credentials).split('\n')

  typeId = sc[1]
  [type, subtype] = typeId.split('-')

  id: parseInt(sc[0])
  typeId: typeId
  type: parseInt(type)
  subtype: parseInt(subtype)
  rest: sc[2..]

# Resolves provider class based on credentials.
getProviderClass = (credentials) ->
  {type, subtype} = deserializeCredentials(credentials)
  
  if type is 1
    if subtype is 2
      # Flickr needs special treatment.
      Flickr  
    else
      # OAuth 1.0a providers.
      Oauth1Provider
  else if type is 2
    # OAuth 2 providers.
    if subtype is 6
      # Foursquare needs special treatment.
      Foursquare
    else if subtype is 9
      # So does LinkedIn.
      LinkedIn
    else if subtype is 14
      # So does WindowsLive.
      WindowsLive
    else if subtype in [12, 15]
      # Viadeo and Yammer support neither CORS nor JSONP.
      BaseProvider
    else
      Oauth2Provider
  else
    # Base provider allways works.
    BaseProvider

# Substitutes {dotted.path.to.object.property} tags in a template string with the value
# found in the path target in the substitute object.
format = (template, substitute) ->
  # Replace all {object.value} tags
  template.replace /{([^}]*)}/g, (match, tag)->
    # Traverse through dotted path in substitute
    target = substitute
    for level in tag.split('.')
      target = target[level]
    # Return value of the last object in the path

    # TODO: URL encode
    target


###########################################################
# Global objects
###########################################################

window.authomatic = new class Authomatic
  
  setup: (options) ->
    $.extend(globalOptions, options)
    log 'Setting up authomatic to:', globalOptions
  
  popupInit: ->
    $(globalOptions.popupLinkSelector).click (e) ->
      e.preventDefault()
      openWindow($(this).attr('href'))
    
    $(globalOptions.popupFormSelector).submit (e) ->
      e.preventDefault()
      $form = $(this);
      url = $form.attr('action') + '?' + $form.serialize()
      if globalOptions.popupFormValidator($form)
        openWindow(url)
      else
        globalOptions.onPopupInvalid?($form)
  
  loginComplete: (result, closer) ->
    # We need to deepcopy the result before closing the popup.
    # Otherwise IE would loose the reference to the result object.
    result_copy = $.extend(true, {}, result)
    log('Login procedure complete', result_copy)
    # Now we can close the popup.
    closer()
    globalOptions.onLoginComplete(result_copy)

  access: (credentials, url, options = {}) ->
    localEvents =
      onBackendStart: null
      onBackendComplete: null
      onAccessSuccess: null
      onAccessComplete: null

    # Merge options with global options and local events
    updatedOptions = {}
    $.extend(updatedOptions, globalOptions, localEvents, options)

    # Format url.
    url = format(url, updatedOptions.substitute)
    
    log 'access options', updatedOptions, globalOptions

    if updatedOptions.forceBackend
      Provider = BaseProvider
    else
      Provider = getProviderClass(credentials)

    provider = new Provider(options.backend, credentials, url, updatedOptions)
    log 'Instantiating provider:', provider
    provider.access()


###########################################################
# Providers
###########################################################

# Makes a request to backend. If request elements return, tries cross-domain request first, then JSONP.
class BaseProvider
  _x_jsonpCallbackParamName: 'callback'

  constructor: (@backend, @credentials, url, @options) ->
    @backendRequestType = 'auto'
    @jsonpRequest = no
    @jsonpCallbackName = "#{globalOptions.jsonpCallbackPrefix}#{jsonPCallbackCounter}"

    # Credentials
    @deserializedCredentials = deserializeCredentials(@credentials)
    @providerID = @deserializedCredentials.id
    @providerType = @deserializedCredentials.type
    @credentialsRest = @deserializedCredentials.rest

    # Request elements
    parsedUrl = parseUrl(url)
    @url = parsedUrl.url
    @params = {}
    $.extend(@params, parsedUrl.params, @options.params)
  
  contactBackend: (callback) =>
    if @jsonpRequest and @options.method is not 'GET'
      @backendRequestType = 'fetch'

    data =
      type: @backendRequestType
      credentials: @credentials
      url: @url
      method: @options.method
      body: @options.body
      params: JSON.stringify(@params)
      headers: JSON.stringify(@options.headers)
    
    globalOptions.onBackendStart?(data)
    @options.onBackendStart?(data)
    log "Contacting backend at #{@options.backend}.", data
    $.get(@options.backend, data, callback)
  
  contactProvider: (requestElements) =>
    {url, method, params, headers, body} = requestElements

    # jQuery.ajax options for cross domain request.
    options =
      type: method
      data: params
      headers: headers
      complete: [
        ((jqXHR, textStatus) -> log 'Request complete.', textStatus, jqXHR)
        globalOptions.onAccessComplete
        @options.onAccessComplete
      ]
      success: [
        ((data) -> log 'Request successfull.', data)
        globalOptions.onAccessSuccess
        @options.onAccessSuccess
      ]
      error: (jqXHR, textStatus, errorThrown) =>
        # If cross domain fails,
        if jqXHR.state() is 'rejected'
          if @options.method is 'GET'
            log 'Cross domain request failed! trying JSONP request.'
            # access again but with JSONP request type.
            @jsonpRequest = yes
          else
            @backendRequestType = 'fetch'
          @access()

    # Additional JSONP options for jQuery.ajax
    if @jsonpRequest
      jsonpOptions =
        jsonpCallback: @jsonpCallbackName
        jsonp: @_x_jsonpCallbackParamName
        cache: true # If false, jQuery would add a nonce to query string which would break signature.
        dataType: 'jsonp'
        error: (jqXHR, textStatus, errorThrown) ->
          # If JSONP fails, there is not much to do.
          log 'JSONP failed! State:', jqXHR.state()

      # Add JSONP arguments to options
      $.extend(options, jsonpOptions)
      log "Contacting provider with JSONP request.", url, options
    else
      log "Contacting provider with cross domain request", url, options
    
    $.ajax(url, options)
  
  access: =>

    callback = (data, textStatus, jqXHR) =>
      # Call backend complete callbacks.
      globalOptions.onBackendComplete?(data, textStatus, jqXHR)
      @options.onBackendComplete?(data, textStatus, jqXHR)

      # Find out whether backend returned fetch result or request elements.
      responseTo = jqXHR?.getResponseHeader('Authomatic-Response-To')

      if responseTo is 'fetch'
        log "Fetch data returned from backend.", jqXHR.getResponseHeader('content-type'), data
        # Call onAccessSuccess and onAccessComplete callbacks manually.
        globalOptions.onAccessSuccess?(data)
        @options.onAccessSuccess?(data)
        globalOptions.onAccessComplete?(jqXHR, textStatus)
        @options.onAccessComplete?(jqXHR, textStatus)

      else if responseTo is 'elements'
        log "Request elements data returned from backend.", data
        @contactProvider(data)

    # Increase JSONP callback counter for next JSONP request.
    jsonPCallbackCounter += 1 if @jsonpRequest
    @contactBackend(callback)


###########################################################
# OAuth 1.0a
###########################################################

# Allways makes a JSONP request.
class Oauth1Provider extends BaseProvider
  access: =>
    @jsonpRequest = yes
    # Add JSONP callback name to params to be included in the OAuth 1.0a signature

    @params[@_x_jsonpCallbackParamName] = @jsonpCallbackName
    super()
    
  contactProvider: (requestElements) =>
    # We must remove the JSONP callback from params, because jQuery will add it too and
    # the OAuth 1.0a signature would not be valid if there are two callback params in the querystring.
    delete requestElements.params.callback
    super(requestElements)

class Flickr extends Oauth1Provider
  _x_jsonpCallbackParamName: 'jsoncallback'


###########################################################
# OAuth 2.0
###########################################################

# Skips the backend. Tries cross-domain, then JSONP if method is GET or fetches through backend. 
class Oauth2Provider extends BaseProvider

  _x_accessToken: 'access_token'
  _x_bearer: 'Bearer'

  constructor: (args...) ->
    super(args...)
    
    # Unpack credentials elements.
    [@accessToken, @refreshToken, @expirationTime, @tokenType] = @credentialsRest

    @handleTokenType()

  handleTokenType: =>
    # Handle token type.
    if @tokenType is '1'
      # If token type is "1" which means "Bearer", pass access token in authorisation header
      @options.headers['Authorization'] = "#{@_x_bearer} #{@accessToken}"
    else
      # Else pass it as querystring parameter
      @params[@_x_accessToken] = @accessToken

  access: () =>
    if @backendRequestType is 'fetch'
      super()
    else
      # Skip backend and go directly to provider.
      requestElements =
        url: @url
        method: @options.method
        params: @params
        headers: @options.headers
        body: @options.body

      @contactProvider(requestElements)

class Foursquare extends Oauth2Provider
  _x_accessToken: 'oauth_token'

class Google extends Oauth2Provider
  _x_bearer: 'OAuth'

class LinkedIn extends Oauth2Provider
  _x_accessToken: 'oauth2_access_token'

class WindowsLive extends Oauth2Provider
  handleTokenType: =>
    # Handle token type.
    if @tokenType is '1'
      # If token type is "1" which means "Bearer", pass access token in authorisation header
      @options.headers['Authorization'] = "#{@_x_bearer} #{@accessToken}"
    
    # Windows Live allways needs access token in querystring.
    @params[@_x_accessToken] = @accessToken


# @ sourceMappingURL=authomatic.map