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)
}