react google login에서 scope 초기화 되는 현상 해결하기
리액트를 다루다 보면, 구글의 oauth를 이용해서 로그인을 할 필요가 있다.
리액트에서 구글 oauth를 이용하는 제일 간단한 방법은, npm 라이브러리에 있는 react-google-login 라이브러리를 이용하면 된다.
https://www.npmjs.com/package/react-google-login
react-google-login
A Google Login Component for React
www.npmjs.com
보통의 경우 기본 scope인 id,profile,email 정도만 필요하여 문제가 되지 않는다.
하지만 내가 진행하던 과제에서는 다양한 권한이 필요하여 해당 라이브러리를 이용하여 scope 설정하여 액세스 토큰을 받아왔다.
<GoogleLogin
clientId={clientId} // admin oauth 인증키.
render={(renderProps) => (
<StyledGoogleLogin onClick={renderProps.onClick}>로그인</StyledGoogleLogin>
)}
responseType="code" //refresh token을 얻기 위해, response를 데이터가 아닌 요청 코드를 받겠다는 설정값.
accessType="offline" //refresh token을 얻기위한 기본 값
fetchBasicProfile={true} //권한 수동 or 자동 설정
scope={
"https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly profile email openid https://www.googleapis.com/auth/gmail.settings.basic https://www.googleapis.com/auth/script.external_request https://www.googleapis.com/auth/script.scriptapp https://www.googleapis.com/auth/spreadsheets"
} //권한 요청 범위
onSuccess={(response) => {
onLogin(response);
}} // 로그인 성공시 이동할 함수
onFailure={(res) => console.log(res)} //실패시 이동할 함수
prompt="select_account" //항상 권한 요청을 하게 된다(사용 동의)-> consent | 사용자에게 계정선택을 준다 -> select_account | 없을경우(no 일 경우) 캐시에 남아있는걸로 자동 로그인한다.
/>;
[ 얼추 열개가 넘는다. g-suite 자동화 때문에... ]
문제가 된 경우는, 로그인 후 새로고침을 통해 SPA 스토어를 재 구성하고 난 뒤,
로그아웃 컴포넌트를 이용해서 로그아웃을 한 후 다시 로그인 컴포넌트를 이용하여 로그인을 하려고 하니...
기본 설정된 scope로 액세스 토큰을 받아오지 못했다.(분명 위에 보면 적혀있고, 제대로 설정도 되어 있다.)
대체 왜일까?
원인은 로그아웃 컴포넌트 때문이였다.
react-google-login 패키지 내부에 보면, react-google-logout 클래스가 포함되어 있다.
export interface GoogleLogoutProps {
readonly clientId: string,
readonly onLogoutSuccess?: () => void;
readonly onFailure?: () => void;
readonly buttonText?: string;
readonly className?: string;
readonly children?: ReactNode;
readonly jsSrc?: string;
readonly style?: CSSProperties;
readonly disabled?: boolean;
readonly disabledStyle?: CSSProperties;
readonly tag?: string;
readonly render?: (props: { onClick: () => void, disabled?: boolean }) => JSX.Element;
}
export class GoogleLogout extends Component<GoogleLogoutProps, unknown> {
public signOut(): void;
}
[react-google-logout 클래스, 내부에 같이 존재한다.]
해당 컴포넌트는 로그인 후에 로그아웃을 처리해주기 위해 존재하고
보통의 사용의 경우 로그인 한 후에 해당 컴포넌트를 로딩시켜 주었고
해당 컴포넌트를 이용하여 실행하면 스토어에 남아있는 정보들을 끊어주는 방식이다.
const signOut = () => {
if (window.gapi) {
const auth2 = window.gapi.auth2.getAuthInstance()
if (auth2 != null) {
auth2.signOut().then(auth2.disconnect().then(onLogoutSuccess))
}
}
}
useEffect(() => {
loadScript(document, 'script', 'google-login', jsSrc, () => {
const params = {
client_id: clientId,
cookie_policy: cookiePolicy,
login_hint: loginHint,
hosted_domain: hostedDomain,
fetch_basic_profile: fetchBasicProfile,
discoveryDocs,
ux_mode: uxMode,
redirect_uri: redirectUri,
scope,
access_type: accessType
}
window.gapi.load('auth2', () => {
if (!window.gapi.auth2.getAuthInstance()) {
window.gapi.auth2.init(params).then(
() => setLoaded(true),
err => onFailure(err)
)
} else {
setLoaded(true)
}
})
})
return () => {
removeScript(document, 'google-login')
}
}, [])
return { signOut, loaded }
}
[로그아웃 내부 로직 흐름, 프론트 페이지의 auth2 객체가 있는 경우 signOut과 disconnet를 실행시켜 준다]
문제는, 새로고침해서 리액트 스토어가 초기화 된 경우이다. (이미 로그인을 한 상태에서)
저 위에 useEffect가 발동이 되면서 (React hook 개념부분) 핵심적인 부분은 바로 다음 코드이다
window.gapi.load("auth2", () => {
if (!window.gapi.auth2.getAuthInstance()) {
window.gapi.auth2.init(params).then(
() => setLoaded(true),
(err) => onFailure(err)
);
} else {
setLoaded(true);
}
});
구글 oauth2를 쓰기 위해서는, auth2 객체를 로딩해와서 적용하게 되는데
새로고침을 했으니 window.gapi.auth2.getAuthInstance() 해당부분이 있을리 만무하다.
하여, 없기 때문에 새로 init(params)를 해줘서 auth instance를 생성해 주는데
문제는 저 params 이다.
문제는 구글 로그아웃 컴포넌트에서 params를 설정하는 prop의 속성인데..
export interface GoogleLogoutProps {
readonly clientId: string,
readonly onLogoutSuccess?: () => void;
readonly onFailure?: () => void;
readonly buttonText?: string;
readonly className?: string;
readonly children?: ReactNode;
readonly jsSrc?: string;
readonly style?: CSSProperties;
readonly disabled?: boolean;
readonly disabledStyle?: CSSProperties;
readonly tag?: string;
readonly render?: (props: { onClick: () => void, disabled?: boolean }) => JSX.Element;
}
[Google Logout 컴포넌트의 prop. 자세히 보면 scope가 없다!]
logout 컴포넌트에서는 scope가 설정되지 않는다. 즉 params에 scope는 빈 값으로 들어가게 되는데
이런 경우 구글 oauth는 기본적으로 id,profile,email 3개의 항목만 default로 설정되어 값이 작동되게 된다.
이런 상태에서 기본적으로 Logout 컴포넌트는 기본 3가지의 scope만 가진 auth를 프론트 페이지에 로딩시키게 되고, 문제는 로그아웃 후에 로그인 컴포넌트가 로딩이 될 때이다.
로그인 컴포넌트가 로딩될때의 useEffect를 보면..
useEffect(() => {
let unmounted = false
loadScript(document, 'script', 'google-login', jsSrc, () => {
const params = {
client_id: clientId,
cookie_policy: cookiePolicy,
login_hint: loginHint,
hosted_domain: hostedDomain,
fetch_basic_profile: fetchBasicProfile,
discoveryDocs,
ux_mode: uxMode,
redirect_uri: redirectUri,
scope,
access_type: accessType
}
if (responseType === 'code') {
params.access_type = 'offline'
}
window.gapi.load('auth2', () => {
const GoogleAuth = window.gapi.auth2.getAuthInstance()
if (!GoogleAuth) {
window.gapi.auth2.init(params).then(
res => {
if (!unmounted) {
setLoaded(true)
const signedIn = isSignedIn && res.isSignedIn.get()
onAutoLoadFinished(signedIn)
if (signedIn) {
handleSigninSuccess(res.currentUser.get())
}
}
},
err => {
setLoaded(true)
onAutoLoadFinished(false)
onFailure(err)
}
)
} else if (isSignedIn && GoogleAuth.isSignedIn.get()) {
setLoaded(true)
onAutoLoadFinished(true)
handleSigninSuccess(GoogleAuth.currentUser.get())
} else if (!unmounted) {
setLoaded(true)
onAutoLoadFinished(false)
}
})
})
return () => {
unmounted = true
removeScript(document, 'google-login')
}
}, [])
useEffect(() => {
if (autoLoad) {
signIn()
}
}, [loaded])
return { signIn, loaded }
}
꽤나 긴데, 여기서 주목할점은 다음과 같은 코드 블럭이다.
window.gapi.load('auth2', () => {
const GoogleAuth = window.gapi.auth2.getAuthInstance()
if (!GoogleAuth) {
window.gapi.auth2.init(params).then(
res => {
if (!unmounted) {
setLoaded(true)
const signedIn = isSignedIn && res.isSignedIn.get()
onAutoLoadFinished(signedIn)
if (signedIn) {
handleSigninSuccess(res.currentUser.get())
}
}
},
err => {
setLoaded(true)
onAutoLoadFinished(false)
onFailure(err)
}
)
} else if (isSignedIn && GoogleAuth.isSignedIn.get()) {
setLoaded(true)
onAutoLoadFinished(true)
handleSigninSuccess(GoogleAuth.currentUser.get())
} else if (!unmounted) {
setLoaded(true)
onAutoLoadFinished(false)
}
})
})
자세히 보면, google api를 로딩시킨 후에(auth2를) 해당 인스턴스가 있는지 확인한 후, '없는 경우'에 새로 초기화를 해준다는 것이다.
방금의 설명한 경우는, 로그아웃 후에 해당 인스턴스가 그대로 남아있기 때문에 새로 초기화가 되지 않는다. 즉 else if의 가장 마지막으로 빠지면서 로딩이 되고 끝이 나는데..
아까 위에서 말했듯, scope가 기본 할당 범위만 있기 때문에 이상태에서 로그인을 할 경우 액세스 토큰을 내가 원하는 scope가 설정이 된 액세스토큰을 받아오지 못하는 것이다.
이런 문제로 내부에서 메일 발송이나 지슈트 관련 프로세스가 전혀 먹지 않아서 곤란했었다
실제 문제를 찾을때는, 에러도 나지 않고 불특정하게 (새로고침할때만 그러니 불특정 할 수밖에 없었다) 발생하여 원인 찾기가 꽤나 힘들었었다.
해결로는, 프론트에서 로그아웃을 시도하는 경우 백엔드에 api call을 보내고, 백엔드에서 해당 토큰의 유효성을 해제시켜버리는 식으로 안전하게 해결했다.
나와같이 react-google-logout의 특징점때문에 scope로 고통받는 사람이 없길 빈다.