Auth/login.js

import { addEvent, emptyFunc, extend, getElement, getRandomString, isFunction, removeEvent } from '../util'
import { emptyCleanups, getAppKey, KakaoError, KAKAO_AGENT, origin, processRules, UA, URL, validate } from '../common'
import { isAndroidKakaoTalkWebView, isIOSKakaoTalkWebView, isNewerAndroidKakaoTalkWebView } from '../webviewchecker'
import eventObserver from '../eventObserver'
import Poller from '../poller'
import { request } from '../API/request'

import rules from './rules'
import * as authCommon from './common'
import { getProxy } from './proxy'
import { setAccessToken } from './secret'
import kakaotalk from './kakaotalk'

const poller = new Poller(1000, 600) // 10 min timeout
const RESERVED_REDIRECT_URI = 'kakaojs'

/**
 * @alias AuthSuccessCallback
 * @callback AuthSuccessCallback
 * @param {Object} authObj
 * @param {String} authObj.access_token
 * @param {String} authObj.refresh_token
 * @param {String} authObj.token_type 고정값 "bearer"
 * @param {Number} authObj.expires_in
 * @param {String} authObj.scope
 * @memberof Kakao.Auth
 */
/**
 * @alias AuthFailCallback
 * @callback AuthFailCallback
 * @param {Object} errorObj
 * @param {String} errorObj.error 고정값 "access_denied"
 * @param {String} errorObj.error_description
 * @memberof Kakao.Auth
 */

/**
 * 카카오 로그인 버튼을 생성합니다.
 * @function createLoginButton
 * @param {Object} settings 로그인 버튼과 관련된 설정을 key/value로 전달합니다.
 * @param {String|HTMLElement} settings.container DOM Element 또는 Element의 ID Selector를 넘기면, 해당 Element 내부에 로그인 버튼이 생성됩니다.
 * @param {String} [settings.lang="kr"] 로그인 버튼에 표시할 언어, "kr"|"en"
 * @param {String} [settings.size="medium"] 로그인 버튼의 사이즈, "small"|"medium"|"large"
 * @param {AuthSuccessCallback} [settings.success] 로그인이 성공할 경우 토큰을 받을 콜백 함수
 * @param {AuthFailCallback} [settings.fail] 로그인이 실패할 경우 에러를 받을 콜백 함수
 * @param {AuthSuccessCallback|AuthFailCallback} [settings.always] 로그인 성공 여부에 관계 없이 항상 호출되는 함수
 * @param {String} [settings.scope] 추가 동의 받을 항목의 키 ex) "account_email,gender"
 * @param {Boolean} [settings.persistAccessToken=true] 세션이 종료된 뒤에도 액세스 토큰을 사용할 수 있도록 로컬 스토리지 저장 여부
 * @param {Boolean} [settings.throughTalk=true] 간편 로그인 사용 여부
 * @see {@link Kakao.Auth.login} 직접 로그인 버튼을 제작하여 사용할 때 이용하세요.
 * @memberof Kakao.Auth
 */
export function createLoginButton(settings) {
  settings = processRules(settings, rules.createLoginButton, 'Auth.createLoginButton')

  const container$ = getElement(settings.container)
  if (!container$) {
    throw new KakaoError('container is required for Kakao login button: pass in element or id')
  }

  const buttonSize = settings.size === 'medium' ? '02' : settings.size === 'small' ? '03' : '01'
  const buttonUrl = `${URL.authDomain}/public/widget/login/${settings.lang}/${settings.lang}_${buttonSize}_medium`

  const buttonImage = `${buttonUrl}.png`
  const hoverButtonImage = `${buttonUrl}_press.png`

  container$.innerHTML = `<img
    id="kakao-login-btn"
    src=${buttonImage}
    onmouseover=this.src='${hoverButtonImage}'
    onmouseout=this.src='${buttonImage}'
    style="cursor: pointer"
  />`

  const clickHandler = () => {
    doLogin(settings)
  }

  addEvent(container$, 'click', clickHandler)
  cleanups.push(() => {
    removeEvent(container$, 'click', clickHandler)
  })
}

/**
 * 사용자가 앱에 로그인할 수 있도록 로그인 팝업창을 띄우는 함수입니다. 사용자의 클릭 이벤트 이후에 호출되어야 브라우저에 의해 팝업이 차단되지 않습니다.
 * @function login
 * @param {Object} settings 로그인과 관련된 설정을 key/value로 전달합니다.
 * @param {AuthSuccessCallback} [settings.success] 로그인이 성공할 경우 토큰을 받을 콜백 함수
 * @param {AuthFailCallback} [settings.fail] 로그인이 실패할 경우 에러를 받을 콜백 함수
 * @param {AuthSuccessCallback|AuthFailCallback} [settings.always] 로그인 성공 여부에 관계 없이 항상 호출되는 함수
 * @param {String} [settings.scope] 추가 동의 받을 항목의 키 ex) "account_email,gender"
 * @param {Boolean} [settings.persistAccessToken=true] 세션이 종료된 뒤에도 액세스 토큰을 사용할 수 있도록 로컬 스토리지 저장 여부
 * @param {Boolean} [settings.throughTalk=true] 간편 로그인 사용 여부
 * @see {@link Kakao.Auth.createLoginButton} 직접 로그인 버튼을 제작하여 사용할 필요가 없는 경우 유용합니다.
 * @see {@link https://developers.kakao.com/tool/demo/login/login?method=dynamic|데모 보러가기}
 * @memberof Kakao.Auth
 */
export function login(settings) {
  settings = processRules(settings, rules.login, 'Auth.login')
  doLogin(settings)
}

function doLogin(settings) {
  const stateToken = getRandomString() + getRandomString()

  if (kakaotalk.isSupport() && settings.throughTalk) {
    loginThroughTalk(settings, stateToken)
  } else if (settings.redirectUri) {
    location.href = redirectLoginThroughWeb(settings)
  } else if (isNewerAndroidKakaoTalkWebView()) {
    const params = extend({},
      authCommon.makeAuthParams(settings),
      authCommon.makeAuthExtraParams(settings),
      {
        redirect_uri: URL.talkLoginRedirectUri,
        response_type: 'code',
        state: stateToken,
        ka: KAKAO_AGENT,
        origin,
      },
    )

    const loginUrl = authCommon.makeAuthUrl(params)
    loginThroughTalk(settings, stateToken, loginUrl)
  } else {
    if (!(UA.browser.msie && parseInt(UA.browser.version.major) <= 9)) {
      addLoginEvent(settings, stateToken)
    }

    const loginUrl = loginThroughWeb(settings, stateToken)
    authCommon.openLoginPopup(loginUrl)
  }

  eventObserver.dispatch('LOGIN_START')
}

function addLoginEvent(settings) {
  const messageHandler = ({ origin, data }) => {
    if (/\.kakao\.com$/.test(origin) && data && typeof data === 'string') {
      const arr = data.split(' ')
      if (arr[1] === 'postResponse') {
        const resp = JSON.parse(decodeURIComponent(arr[2]))
        handleAuthResponse(settings, resp)

        removeEvent(window, 'message', messageHandler)
      }
    }
  }

  addEvent(window, 'message', messageHandler)
  cleanups.push(() => {
    removeEvent(window, 'message', messageHandler)
  })
}

/**
 * 다른 계정으로 로그인할 수 있도록 로그인 팝업창을 띄우는 함수입니다. 사용자의 클릭 이벤트 이후에 호출되어야 브라우저에 의해 팝업이 차단되지 않습니다.
 * @function loginForm
 * @param {Object} settings 로그인과 관련된 설정을 key/value로 전달합니다.
 * @param {AuthSuccessCallback} [settings.success] 로그인이 성공할 경우 토큰을 받을 콜백 함수
 * @param {AuthFailCallback} [settings.fail] 로그인이 실패할 경우 에러를 받을 콜백 함수
 * @param {AuthSuccessCallback|AuthFailCallback} [settings.always] 로그인 성공 여부에 관계 없이 항상 호출되는 함수
 * @param {String} [settings.scope] 추가 동의 받을 항목의 키 ex) "account_email,gender"
 * @param {Boolean} [settings.persistAccessToken=true] 세션이 종료된 뒤에도 액세스 토큰을 사용할 수 있도록 로컬 스토리지 저장 여부
 * @see {@link https://developers.kakao.com/tool/demo/login/loginForm?method=reauthenticate-popup|데모 보러가기}
 * @memberof Kakao.Auth
 */
export function loginForm(settings) {
  settings = processRules(settings, rules.login, 'Auth.loginForm')

  const stateToken = getRandomString() + getRandomString()
  const reauthQueryString = '&prompt=login'

  if (settings.redirectUri) {
    location.href = `${redirectLoginThroughWeb(settings)}${reauthQueryString}`
  } else {
    const loginUrl = `${loginThroughWeb(settings, stateToken)}${reauthQueryString}`
    authCommon.openLoginPopup(loginUrl)
  }
}

export function autoLogin(settings) {
  settings = processRules(settings, rules.autoLogin, 'Auth.autoLogin')

  if (isIOSKakaoTalkWebView() || isAndroidKakaoTalkWebView()) {
    const stateToken = getRandomString() + getRandomString()
    const params = extend({},
      authCommon.makeAuthParams(settings),
      {
        redirect_uri: URL.talkLoginRedirectUri,
        response_type: 'code',
        state: stateToken,
        ka: KAKAO_AGENT,
        origin,
        prompt: 'none',
      },
    )

    const loginUrl = authCommon.makeAuthUrl(params)
    loginThroughTalk(settings, stateToken, loginUrl)
  } else {
    authCommon.runAuthCallback(settings, {
      error: 'auto_login',
      error_description: 'Auto-login is only supported by KakaoTalk InAppBrowser',
      error_code: '400',
      status: 'error',
    })
  }

  eventObserver.dispatch('LOGIN_START')
}

let popupForTalk = null
const closePopup = () => {
  popupForTalk && popupForTalk.close && popupForTalk.close()
  popupForTalk = null
}

let proxyForTalk = null
let prevCode = null
function loginThroughTalk(settings, stateToken, talkLoginUrl) {
  if (!proxyForTalk) {
    proxyForTalk = getProxy({}, response => {
      if (response.status === 'error' && (response.error_code === '500' || response.error_code === '600' || response.error_code === '700')) {
        poller.stop()

        if (response.error_code === '700') {
          location.href = `${URL.authDomain}/error/network`
        }

        handleAuthResponse(settings, {
          error: response.error,
          error_description: response.error_description,
        })
      }

      if (response.status) { // WHEN RECEIVE authorization code
        if (response.status === 'ok') {
          poller.stop()

          if (prevCode === response.code) {
            return
          } else {
            prevCode = response.code
          }

          proxyForTalk.getAccessToken(
            response.code,
            getAppKey(),
            (UA.os.ios && !talkLoginUrl) ? URL.redirectUri : URL.talkLoginRedirectUri,
            settings.approvalType,
          )

          closePopup()
        } else {
          // iOS Chrome, Webview(Daum, Naver)에서 동의/취소 시 유니버셜링크를 요청했던 빈 팝업창이 남는 현상에 대한 대응
          if (UA.os.ios && popupForTalk.location.href === 'about:blank') {
            closePopup()
          }
        }
      } else { // WHEN RECEIVE access token
        handleAuthResponse(settings, response)
      }
    })

    cleanups.push(() => {
      proxyForTalk.destroy()
      proxyForTalk = null
    })
  }

  let fallbackUrl = ''
  if (talkLoginUrl) {
    if (settings.redirectUri) {
      location.href = talkLoginUrl
    } else {
      authCommon.openLoginPopup(talkLoginUrl)
    }
  } else {
    fallbackUrl = settings.redirectUri ?
      redirectLoginThroughWeb(settings) :
      loginThroughWeb(settings, stateToken, UA.os.ios ? URL.redirectUri : URL.talkLoginRedirectUri)

    const params = extend({},
      authCommon.makeAuthParams(settings),
      authCommon.makeAuthExtraParams(settings),
    )

    // Instagram Webview 대응
    setTimeout(() => {
      popupForTalk = kakaotalk.login(stateToken, fallbackUrl, params, settings.redirectUri)
    }, 500)
  }

  poller.start(
    () => {
      if (stateToken) {
        proxyForTalk.getCode(stateToken, getAppKey(), KAKAO_AGENT)
      }
    },
    () => {
      handleAuthResponse(settings, {
        error: 'timeout',
        description: 'Account login timed out. Please login again.',
        error_description: 'Account login timed out. Please login again.',
      })

      if (settings.redirectUri) {
        location.href = fallbackUrl
      } else {
        authCommon.openLoginPopup(fallbackUrl)
      }
    },
  )
}

let proxyForWeb = null
const savedSettingsForWeb = {}
function loginThroughWeb(settings, stateToken, fallbackUrl) {
  if (!proxyForWeb) {
    proxyForWeb = getProxy({}, response => {
      const savedSettings = getSavedSettingsWithResponseState(response, savedSettingsForWeb)
      handleAuthResponse(savedSettings, response)
    })

    cleanups.push(() => {
      proxyForWeb.destroy()
      proxyForWeb = null
    })
  }

  savedSettingsForWeb[stateToken] = settings

  const redirectUri = settings.redirectUri ? settings.redirectUri :
    fallbackUrl ? fallbackUrl : RESERVED_REDIRECT_URI
  const params = extend({},
    authCommon.makeAuthParams(settings),
    authCommon.makeAuthExtraParams(settings),
    {
      redirect_uri: redirectUri,
      response_type: 'code',
      state: stateToken,
      proxy: `easyXDM_Kakao_${proxyForWeb.channel}_provider`,
      ka: KAKAO_AGENT,
      origin,
    },
  )

  return authCommon.makeAuthUrl(params)
}

function redirectLoginThroughWeb(settings) {
  const params = extend({},
    authCommon.makeAuthParams(settings),
    authCommon.makeAuthExtraParams(settings),
    {
      redirect_uri: settings.redirectUri,
      response_type: 'code',
      ka: KAKAO_AGENT,
      origin,
    },
  )

  return authCommon.makeAuthUrl(params)
}

function getSavedSettingsWithResponseState(response, settings) {
  if (!settings[response.stateToken]) {
    throw new KakaoError('security error: #CST2')
  }

  const savedSettings = settings[response.stateToken]
  delete settings[response.stateToken]
  delete response.stateToken

  return savedSettings
}

function handleAuthResponse(settings, resp) {
  if (resp.error) {
    if (resp.error !== 'access_denied') {
      setAccessToken(null)
    }
  } else {
    setAccessToken(resp.access_token, settings.persistAccessToken)
    eventObserver.dispatch('LOGIN')
  }

  authCommon.runAuthCallback(settings, resp)
}

/**
 * 현재 로그인되어 있는 사용자를 로그아웃시키고, Access Token을 삭제합니다.
 * @function logout
 * @param {Function} [callback] 로그아웃 후 호출할 콜백 함수
 * @see {@link https://developers.kakao.com/tool/demo/login/logout|데모 보러가기}
 * @memberof Kakao.Auth
 */
export function logout(callback = emptyFunc) {
  validate(callback, isFunction, 'Auth.logout')

  request({
    url: '/v1/user/logout',
    always() {
      setAccessToken(null)
      eventObserver.dispatch('LOGOUT')
      callback(true)
    },
  })
}


let proxyForAccessToken = null
export function issueAccessToken(settings) { // FIXME:
  settings = processRules(settings, rules.issueAccessToken, 'Auth.issueAccessToken')

  if (!proxyForAccessToken) {
    proxyForAccessToken = getProxy({}, response => {
      handleAuthResponse(settings, response)
    })

    cleanups.push(() => {
      proxyForAccessToken.destroy()
      proxyForAccessToken = null
    })
  }

  proxyForAccessToken.getAccessToken(settings.code, getAppKey(), settings.redirectUri)
}

const cleanups = []
export function cleanup() {
  emptyCleanups(cleanups)
}