Chrome 확장 프로그램에서 Firebase로 인증

이 문서에서는 Firebase Authentication을 사용하여 Manifest V3를 사용하는 Chrome 확장 프로그램에 사용자 로그인하는 방법을 설명합니다.

Firebase Authentication은 Chrome 확장 프로그램에서 사용자를 로그인 처리할 수 있는 여러 가지 인증 방법을 제공하며 일부는 다른 방법보다 개발 노력이 더 많이 필요합니다.

Manifest V3 Chrome 확장 프로그램에서 다음 방법을 사용하려면 firebase/auth/web-extension에서 가져오기만 하면 됩니다.

  • 이메일과 비밀번호로 로그인(createUserWithEmailAndPassword, signInWithEmailAndPassword)
  • 이메일 링크로 로그인(sendSignInLinkToEmail, isSignInWithEmailLink, signInWithEmailLink)
  • 익명으로 로그인(signInAnonymously)
  • 커스텀 인증 시스템으로 로그인(signInWithCustomToken)
  • 제공업체 로그인을 임의로 처리한 후 signInWithCredential 사용

다음 로그인 방법도 지원되지만 몇 가지 추가 작업이 필요합니다.

  • 팝업 창을 사용해 로그인(signInWithPopup, linkWithPopup, reauthenticateWithPopup)
  • 로그인 페이지로 리디렉션해서 로그인(signInWithRedirect, linkWithRedirect, reauthenticateWithRedirect)
  • reCAPTCHA를 사용하여 전화번호로 로그인
  • reCAPTCHA를 사용하여 SMS 다중 인증(MFA)
  • reCAPTCHA Enterprise 보호

Manifest V3 Chrome 확장 프로그램에서 이러한 메서드를 사용하려면 오프스크린 문서를 사용해야 합니다.

firebase/auth/web-extension 진입점 사용

firebase/auth/web-extension에서 가져오면 웹 앱과 유사한 Chrome 확장 프로그램에서 사용자가 로그인할 수 있습니다.

firebase/auth/web-extension은 웹 SDK 버전 v10.8.0 이상에서만 지원됩니다.

import { getAuth, signInWithEmailAndPassword } from 'firebase/auth/web-extension';

const auth = getAuth();
signInWithEmailAndPassword(auth, email, password)
  .then((userCredential) => {
    // Signed in
    const user = userCredential.user;
    // ...
  })
  .catch((error) => {
    const errorCode = error.code;
    const errorMessage = error.message;
  });

오프스크린 문서 사용

signInWithPopup, linkWithPopup, reauthenticateWithPopup과 같은 일부 인증 방법은 확장 프로그램 패키지 외부에서 코드를 로드해야 하므로 Chrome 확장 프로그램과 직접 호환되지 않습니다. Manifest V3부터는 이 작업이 허용되지 않으며 확장 프로그램 플랫폼에 의해 차단됩니다. 이 문제를 해결하려면 오프스크린 문서를 사용하여 iframe 내에서 코드를 로드하면 됩니다. 오프스크린 문서에서 일반 인증 흐름을 구현하고 오프스크린 문서의 결과를 다시 확장 프로그램으로 프록시 처리합니다.

이 가이드에서는 signInWithPopup을 예시로 사용하지만 다른 인증 방법에도 동일한 개념이 적용됩니다.

시작하기 전에

이 기법을 사용하려면 iframe에서 로드할 웹에서 사용할 수 있는 웹페이지를 설정해야 합니다. Firebase 호스팅을 포함한 모든 호스트가 가능합니다. 다음 콘텐츠가 있는 웹사이트를 만듭니다.

<!DOCTYPE html>
<html>
  <head>
    <title>signInWithPopup</title>
    <script src="signInWithPopup.js"></script>
  </head>
  <body><h1>signInWithPopup</h1></body>
</html>

제휴 로그인

Google 계정, Apple, SAML, OIDC로 로그인과 같은 제휴 로그인을 사용하는 경우 승인된 도메인 목록에 Chrome 확장 프로그램 ID를 추가해야 합니다.

  1. Firebase 콘솔에서 프로젝트를 엽니다.
  2. 인증 섹션에서 설정 페이지를 엽니다.
  3. 승인된 도메인 목록에 다음 URI를 추가합니다.
    chrome-extension://CHROME_EXTENSION_ID

Chrome 확장 프로그램의 매니페스트 파일에서 다음 URL이 content_security_policy 허용 목록에 추가되었는지 확인합니다.

  • https://apis.google.com
  • https://www.gstatic.com
  • https://www.googleapis.com
  • https://securetoken.googleapis.com

인증 구현

HTML 문서에서 signInWithPopup.js는 인증을 처리하는 JavaScript 코드입니다. 확장 프로그램에서 직접 지원되는 메서드를 구현하는 방법에는 두 가지가 있습니다.

  • firebase/auth/web-extension 대신 firebase/auth를 사용합니다. web-extension 진입점은 확장 프로그램 내에서 실행되는 코드에 사용됩니다. 이 코드는 최종적으로 확장 프로그램(iframe 내, 오프스크린 문서 내)에서 실행되지만 실행되는 컨텍스트는 표준 웹입니다.
  • 인증 요청 및 응답을 프록시하기 위해 postMessage 리스너에 인증 로직을 래핑합니다.
import { signInWithPopup, GoogleAuthProvider, getAuth } from'firebase/auth';
import { initializeApp } from 'firebase/app';
import firebaseConfig from './firebaseConfig.js'

const app = initializeApp(firebaseConfig);
const auth = getAuth();

// This code runs inside of an iframe in the extension's offscreen document.
// This gives you a reference to the parent frame, i.e. the offscreen document.
// You will need this to assign the targetOrigin for postMessage.
const PARENT_FRAME = document.location.ancestorOrigins[0];

// This demo uses the Google auth provider, but any supported provider works.
// Make sure that you enable any provider you want to use in the Firebase Console.
// https://console.firebase.google.com/project/_/authentication/providers
const PROVIDER = new GoogleAuthProvider();

function sendResponse(result) {
  globalThis.parent.self.postMessage(JSON.stringify(result), PARENT_FRAME);
}

globalThis.addEventListener('message', function({data}) {
  if (data.initAuth) {
    // Opens the Google sign-in page in a popup, inside of an iframe in the
    // extension's offscreen document.
    // To centralize logic, all respones are forwarded to the parent frame,
    // which goes on to forward them to the extension's service worker.
    signInWithPopup(auth, PROVIDER)
      .then(sendResponse)
      .catch(sendResponse)
  }
});

Chrome 확장 프로그램 빌드

웹사이트가 게시되면 Chrome 확장 프로그램에서 사용할 수 있습니다.

  1. manifest.json 파일에 offscreen 권한을 추가합니다.
  2.     {
          "name": "signInWithPopup Demo",
          "manifest_version" 3,
          "background": {
            "service_worker": "background.js"
          },
          "permissions": [
            "offscreen"
          ]
        }
        
  3. 오프스크린 문서를 만듭니다. 이는 확장 프로그램 패키지 내부에 있는 최소 HTML 파일로 오프스크린 문서 JavaScript 로직을 로드합니다.
  4.     <!DOCTYPE html>
        <script src="./offscreen.js"></script>
        
  5. 확장 프로그램 패키지에 offscreen.js를 포함합니다. 이는 1단계에서 설정한 공개 웹사이트와 확장 프로그램 간의 프록시 역할을 합니다.
  6.     // This URL must point to the public site
        const _URL = 'https://example.com/signInWithPopupExample';
        const iframe = document.createElement('iframe');
        iframe.src = _URL;
        document.documentElement.appendChild(iframe);
        chrome.runtime.onMessage.addListener(handleChromeMessages);
    
        function handleChromeMessages(message, sender, sendResponse) {
          // Extensions may have an number of other reasons to send messages, so you
          // should filter out any that are not meant for the offscreen document.
          if (message.target !== 'offscreen') {
            return false;
          }
    
          function handleIframeMessage({data}) {
            try {
              if (data.startsWith('!_{')) {
                // Other parts of the Firebase library send messages using postMessage.
                // You don't care about them in this context, so return early.
                return;
              }
              data = JSON.parse(data);
              self.removeEventListener('message', handleIframeMessage);
    
              sendResponse(data);
            } catch (e) {
              console.log(`json parse failed - ${e.message}`);
            }
          }
    
          globalThis.addEventListener('message', handleIframeMessage, false);
    
          // Initialize the authentication flow in the iframed document. You must set the
          // second argument (targetOrigin) of the message in order for it to be successfully
          // delivered.
          iframe.contentWindow.postMessage({"initAuth": true}, new URL(_URL).origin);
          return true;
        }
        
  7. background.js 서비스 워커에서 오프스크린 문서를 설정합니다.
  8.     const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html';
    
        // A global promise to avoid concurrency issues
        let creatingOffscreenDocument;
    
        // Chrome only allows for a single offscreenDocument. This is a helper function
        // that returns a boolean indicating if a document is already active.
        async function hasDocument() {
          // Check all windows controlled by the service worker to see if one
          // of them is the offscreen document with the given path
          const matchedClients = await clients.matchAll();
          return matchedClients.some(
            (c) => c.url === chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH)
          );
        }
    
        async function setupOffscreenDocument(path) {
          // If we do not have a document, we are already setup and can skip
          if (!(await hasDocument())) {
            // create offscreen document
            if (creating) {
              await creating;
            } else {
              creating = chrome.offscreen.createDocument({
                url: path,
                reasons: [
                    chrome.offscreen.Reason.DOM_SCRAPING
                ],
                justification: 'authentication'
              });
              await creating;
              creating = null;
            }
          }
        }
    
        async function closeOffscreenDocument() {
          if (!(await hasDocument())) {
            return;
          }
          await chrome.offscreen.closeDocument();
        }
    
        function getAuth() {
          return new Promise(async (resolve, reject) => {
            const auth = await chrome.runtime.sendMessage({
              type: 'firebase-auth',
              target: 'offscreen'
            });
            auth?.name !== 'FirebaseError' ? resolve(auth) : reject(auth);
          })
        }
    
        async function firebaseAuth() {
          await setupOffscreenDocument(OFFSCREEN_DOCUMENT_PATH);
    
          const auth = await getAuth()
            .then((auth) => {
              console.log('User Authenticated', auth);
              return auth;
            })
            .catch(err => {
              if (err.code === 'auth/operation-not-allowed') {
                console.error('You must enable an OAuth provider in the Firebase' +
                              ' console in order to use signInWithPopup. This sample' +
                              ' uses Google by default.');
              } else {
                console.error(err);
                return err;
              }
            })
            .finally(closeOffscreenDocument)
    
          return auth;
        }
        

    이제 서비스 워커 내에서 firebaseAuth()를 호출하면 오프스크린 문서를 만들고 사이트를 iframe에 로드합니다. iframe은 백그라운드에서 처리되며 Firebase는 표준 인증 흐름을 거칩니다. 확인 또는 거부되고 나면 인증 객체가 오프스크린 문서를 사용하여 iframe에서 서비스 워커로 프록시됩니다.