부스트캠프 그룹 프로젝트 진행 중 Github 로그인을 팝업 브라우저에서 수행한 후 원본 브라우저에 적용하는 기능을 개발해야 했다.
- 팝업창을 띄우고 로그인을 성공하는 기능까지 개발할 수 있었다.
- 하지만 로그인이 완료되고 그 정보를 원본 브라우저에 적용하는데 오류가 발생했다.
- 오류 - DOM Exception: Blocked a frame with origin "..." from accessing a cross-origin frame.
이러한 과정들을 기록하려고 포스트를 작성하게 되었다.
전체적으로 How to create an OAuth Popup in React 아티클을 참고해서 작성했다.
Web APIs의 Window.open() 을 사용하여 팝업을 사용해보자.
// App.js
import './App.css';
function App() {
const handleOpenPopup = () => {
const popup = window.open("https://www.naver.com", "네이버", "popup=yes");
}
return (
<div className="App">
<button onClick={handleOpenPopup}>팝업 열기</button>
</div>
);
}
export default App;
window.open() API 는 아래와 같이 사용할 수 있다.
open()
open(url)
open(url, target)
open(url, target, windowFeatures)
url
: 윈도우에서 로딩 할 페이지의 urltarget
: 윈도우 컨텍스트의 이름windowFeatures
: name1=value2,name2=value2,...
형태로 입력할 수 있는 윈도우의 속성 open()
함수의 windowFeature
를 통해 윈도우의 크기, 위치를 지정할 수 있다./callback
으로 이동하도록 구현 할 예정이다.)App.js
상단에 상수로 선언해주었다.// App.js
const CLIENT_ID = "5052479e47d7a36951cd";
const GITHUB_AUTH_SERVER = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}`;
...
window.open()
의 windowFeatures
인자로 팝업의 크기와 위치 등을 지정할 수 있다.(0,0)
이라고 하면 (top = 0, left = 0)
,(window.screenX, window.screenY)
이다.window.outerWidth
, window.outerHeight
로 구할 수 있다.(top, left)
, 팝업의 가로-세로 길이를 width
, height
로 정의했다.팝업의 위치는 자유지만, 팝업을 부모 브라우저의 정 중앙에 생성해보자.
// App.js
...
const handleOpenPopup = () => {
const width = 500; // 팝업의 가로 길이: 500
const height = 400; // 팝업의 세로 길이 : 500
// 팝업을 부모 브라우저의 정 중앙에 위치시킨다.
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
const popup = window.open(
GITHUB_AUTH_SERVER,
"로그인 중...",
`width=${width},height=${height},left=${left},top=${top}`
);
}
(window.screenX + width, window.screenY + height)
위치가 부모 브라우저의 정 중앙이긴 하지만, 해당 지점부터 오른쪽/아래로 팝업을 그리면 우측 하단으로 치우치게 된다.팝업에서 로그인이 완료되면 아래와 같이 (1) 에서 등록했던 Callback URL로 이동된다.
참고했던 래퍼런스 방식대로 여기서 인가 코드를 가져와보자.
window.open()
으로 생성된 팝업 윈도우 객체를 상태로 저장한다.useEffect
훅의 deps 에 popup
을 등록하여 변화가 생기면 effect
함수를 실행한다.effect
함수에서는 팝업이 열려 있는 상태라면 지속적으로 팝업의 URL 을 관찰하면서, Callback URL에 code=[인가코드]
형식으로 반환되는 쿼리스트링을 확인한다.code
쿼리스트링이 존재한다면 팝업을 닫고 code
값을 이용해 필요에 따라 인가가 필요한 API 호출한다.아래는 위 로직을 적용한 코드 전체이다.
// App.js
import './App.css';
import {useEffect, useState} from "react";
const CLIENT_ID = "5052479e47d7a36951cd";
const GITHUB_AUTH_SERVER = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}`;
function App() {
const [popup, setPopup] = useState();
const handleOpenPopup = () => {
const width = 500;
const height = 400;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
const popup = window.open(
GITHUB_AUTH_SERVER,
"로그인 중...",
`width=${width},height=${height},left=${left},top=${top}`
);
setPopup(popup);
}
useEffect(() => {
if (!popup) {
return;
}
const timer = setInterval(() => {
if (!popup) {
timer && clearInterval(timer);
return;
}
const currentUrl = popup.location.href;
if (!currentUrl) {
return;
}
const searchParams = new URL(currentUrl).searchParams;
const code = searchParams.get('code');
if (code) {
popup.close();
console.log(`The popup URL has URL code param = ${code}`);
// 가져온 code 로 다른 정보를 가져오는 API 호출
}}, 500)}, [popup]);
return (
<div className="App">
<button onClick={handleOpenPopup}>팝업 열기</button>
</div>
);
}
export default App;
실제로 실행해보면 아래와 같이 잘 되는 것을 볼 수 있다.
어? 인가 코드는 잘 가져오는데, 저기 뜨는 에러는 무슨 에러지..?
JavaScript APIs like iframe.contentWindow, window.parent, window.open, and window.opener allow documents to directly reference each other.
When two documents do not have the same origin, these references provide very limited access to Window and Location objects, as described in the next two sections.
To communicate between documents from different origins, use window.postMessage.
mdn web docs
mdn의 SOP 문서에 관련 내용이 작성되어 있다.
SOP (Same-Origin-Policy) 란 간단하게 보안상 출처(Origin)이 다른 문서나 스크립트 사이에서 상호작용하는 방식을 제한하는 것을 의미한다.
위의 에러에서도 "cross-origin
에 대해서 접근하는 것을 막았다." 는 내용을 볼 수 있다.
이제 위의 문제를 문서의 마지막에 소개한 window.postMessage()
API를 이용해서 해결해보자.
(사실 위의 예시에서는 Cross-Origin 일 때는 오류가 나도 크게 상관이 없다. 실제로 코드가 반환되는 시점에서는 Callback URL 로 지정된 Same-Origin 상태이기 때문에 그 시점에서 코드를 받아오면 된다. 하지만 개발자도구 콘솔이 더럽혀지는걸 볼 수는 없기 때문에..)
window.postMessage()
API는 아래와 같이 사용할 수 있다.
targetWindow.postMessage(message, targetOrigin, [transfer]);
targetWindow
로는 메세지를 받을 목적지 윈도우를 입력한다.message
에는 전달할 데이터를 입력한다.targetOrigin
에는 targetWindow
의 Origin
을 입력한다.targetWindow
의 Origin
을 명확히 설정하지 않으면 보안상 위험하기 때문에 중요하다.window.postMessage()
로 보낸 데이터는 "message" 이벤트로 수신하여 처리할 수 있다.
아래는 mdn에서 권장하는 수신 방법 예시이다.
window.addEventListener("message", receiveMessage, false);
function receiveMessage(event)
{
if (event.origin !== "http://example.org:8080")
return;
// ...
}
localhost:3000/callback?code=...
) 으로 이동하더라도 App.js가 실행된다.useEffect
훅에서 팝업의 URL에 code
쿼리스트링이 있는지 체크하고, 만약 있다면 팝업을 연 부모 브라우저 (window.opener
) 에게 code
를 전달한다.code
체크를 한다. 모두 만족한다면 code
값을 이용해 API 를 호출하는 등 활용한다.아래는 위 과정이 모두 포함된 전체 코드이다.
// App.js
import './App.css';
import {useEffect, useState} from "react";
const CLIENT_ID = "5052479e47d7a36951cd";
const GITHUB_AUTH_SERVER = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}`;
function App() {
const [popup, setPopup] = useState();
const handleOpenPopup = () => {
const width = 500;
const height = 400;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
const popup = window.open(
GITHUB_AUTH_SERVER,
"로그인 중...",
`width=${width},height=${height},left=${left},top=${top}`
);
setPopup(popup);
}
useEffect(() => {
const currentUrl = window.location.href;
const searchParams = new URL(currentUrl).searchParams;
const code = searchParams.get("code");
if (code) {
window.opener.postMessage({ code }, window.location.origin);
}
}, []);
// 로그인 팝입이 열리면 로그인 로직을 처리합니다.
useEffect(() => {
if (!popup) {
return;
}
const githubOAuthCodeListener = (e) => {
// 동일한 Origin 의 이벤트만 처리하도록 제한
if (e.origin !== window.location.origin) {
return;
}
const { code } = e.data;
if (code) {
console.log(`The popup URL has URL code param = ${code}`);
}
popup?.close();
setPopup(null);
};
window.addEventListener("message", githubOAuthCodeListener, false);
return () => {
window.removeEventListener("message", githubOAuthCodeListener);
popup?.close();
setPopup(null);
};
}, [popup]);
return (
<div className="App">
<button onClick={handleOpenPopup}>팝업 열기</button>
</div>
);
}
export default App;
그 외
App.js
하나의 컴포넌트에서 처리하느라 부모 브라우저에서도 useEffect
훅이 실행되는데, 라우팅을 적용하여 /callback
의 경우 다른 컴포넌트에서 처리하면 더 깔끔한 코드가 될 수 있다.window.addEventListner(...)
와 같이 특정 컴포넌트에 종속된 로직이 아니기 때문에 커스텀 훅으로 관리하면 재사용 하기 더 편하다.좋은 글 감사합니다!
이 글 참고하여 제 문제를 해결할 수 있었습니다.
저는 부모-자식 창을 별도의 컴포넌트로 처리하였습니다.
제 경험도 정리해서 공유해드려요 ㅎㅎ 감사합니다!
https://dd5dd5.tistory.com/17
팀원과의 소통도 좀 부탁드립니다.