본문으로 바로가기

프로젝트 개요

다수의 사내 서비스에 공통으로 사용할 수 있는 다중 계정 전환 컴포넌트를 10일 이내에 개발해야 했습니다.
Keycloak에 대한 사전 지식이 없는 상태였으며, 관리자 권한도 없이 백엔드 인증 담당자와의 협업만으로 진행해야 했습니다.

이 프로젝트의 주요 목표는 다음 두 가지였습니다.

  1. 호스트 서비스로부터 완전히 독립된 형태로 제작 (스타일 및 스크립트 충돌 방지)
  2. SSO 또는 인증(Keycloak)에 대한 이해가 없는 개발자도 쉽게 사용할 수 있도록 구현

 

컴포넌트 요구사항

개발 과정에서 기능은 점진적으로 확장되었으며, 최종 요구사항은 다음과 같습니다.

  • 프로필 이미지 표시
  • hover 시 유저 정보 표시
  • click 시 유저 정보 + 계정 리스트 + 계정 전환 모달
  • 전체 로그아웃 기능
  • 계정 전환/로그아웃 시 다른 탭에 알림 Dialog 노출
  • display: none 상태에서도 모달 동작 유지
  • client_id, redirect_uri 등의 파라미터 동적 주입 가능
  • 계정 전환 시 prompt, select_account query parameter 포함

 

프로젝트 디자인

  • lit-element-starter-ts 기반으로 프로젝트 구성
  • 주요 컴포넌트: modal, popover, sso-profile, sso-userinfo-popover, sso-userinfo-modal
  • <script>를 통해 import한 뒤 <sso-profile />로 사용 가능
  • Typescript/React 사용자 지원을 위한 .d.ts 제공
  • 환경변수는 <script id="...">{...}</script> 태그를 통해 전달, 내부적으로 기본값과 병합하여 사용
  • Popover 위치 계산은 Floating UI 활용
  • 모달은 Shadow DOM 외부(document.body)에 렌더링하여 독립성 확보
  • Web Component 스타일링 문제 해결을 위해 모든 요소에 part 제공
  • Rollup을 통해 UMD, ESM 빌드 및 단일 파일 번들 제공

이슈

1. keycloak에 대해 익숙하지 않음

keyCloack을 init할 때 login-required, check-sso를 선택할 수 있는데 모든 서비스는 로그인이 됐다고 가정하고 Sso만 검증할 수 있는 check-sso를 사용해야 했습니다. check-sso의 기본 동작을 이해하고 있지 않아 동작 방식을 이해해야 했고 시간이 소요됐습니다.

await keycloak.init({
    onLoad: 'check-sso',
    silentCheckSsoRedirectUri: `${location.origin}/silent-check-sso.html`
});


check-sso는 iframe을 만들어 계정 전환, 로그아웃이 됐을 때 이벤트를 받아야 하는데 silent-check-sso.html을 만들고 아래 내용과 같은 형태를 담으라고 적혀있었습니다 (keycloak adapter 링크). 인증이 됐다면, keycloak 서버에서 받은 token을 전달해주는 역할이라고 하지만 전체적인 흐름이 없었기에 이해하는데 어려움이 있었습니다.

<html><body><script>parent.postMessage(location.href, location.origin)</script></body></html>

로그아웃이 발생했을 때 onAuthLogout으로 이벤트를 받을 수 있는데, 이 때 로그아웃이 아닌 계정전환을 하더라도 1번 계정은 로그아웃되고 2번 계정이 로그인 됐다면 이벤트가 발생하여 어떤 Alert를 띄울지 판단하는데 해당 기능이 필요했습니다.
KeycloakJs에서는 별도의 checkSso를 제공하지 않았고, 이를 해결하기 위해 customAdapter를 만들게 되었습니다.

await keycloak.init({
    ...,
    adapter: MyKeycloakAdapter,
});

결과론적으로 onAuthLogout시에 필요한 this.MyKecloakAdapter.checkSso()를 호출했고 그 내부 로직에는 CheckSso에 사용되는 KeycloakJs코드를 복붙하여 수정하여 사용했습니다. 생각보다 JS파일 자체는 작았고, 아래 부분이 필요하여 조건문 중 필요한 부분만 사용했습니다. (ResponseMode에 fragment와 query가 있었고 fragment 부분만 남겨두거나 등등)

   var checkSsoSilently = function() {
                var ifrm = document.createElement("iframe");
                var src = kc.createLoginUrl({prompt: 'none', redirectUri: kc.silentCheckSsoRedirectUri});
                ifrm.setAttribute("src", src);
                ifrm.setAttribute("title", "keycloak-silent-check-sso");
                ifrm.style.display = "none";
                document.body.appendChild(ifrm);

                var messageCallback = function(event) {
                    if (event.origin !== window.location.origin || ifrm.contentWindow !== event.source) {
                        return;
                    }

                    var oauth = parseCallback(event.data);
                    processCallback(oauth, initPromise);

                    document.body.removeChild(ifrm);
                    window.removeEventListener("message", messageCallback);
                };

                window.addEventListener("message", messageCallback);
            };

    function parseCallback(url) {
        var oauth = parseCallbackUrl(url);
        if (!oauth) {
            return;
        }

        var oauthState = callbackStorage.get(oauth.state);

        if (oauthState) {
            oauth.valid = true;
            oauth.redirectUri = oauthState.redirectUri;
            oauth.storedNonce = oauthState.nonce;
            oauth.prompt = oauthState.prompt;
            oauth.pkceCodeVerifier = oauthState.pkceCodeVerifier;
        }

        return oauth;
    }

    function parseCallbackUrl(url) {
        var supportedParams;
        switch (kc.flow) {
            case 'standard':
                supportedParams = ['code', 'state', 'session_state', 'kc_action_status'];
                break;
            case 'implicit':
                supportedParams = ['access_token', 'token_type', 'id_token', 'state', 'session_state', 'expires_in', 'kc_action_status'];
                break;
            case 'hybrid':
                supportedParams = ['access_token', 'token_type', 'id_token', 'code', 'state', 'session_state', 'expires_in', 'kc_action_status'];
                break;
        }

        supportedParams.push('error');
        supportedParams.push('error_description');
        supportedParams.push('error_uri');

        var queryIndex = url.indexOf('?');
        var fragmentIndex = url.indexOf('#');

        var newUrl;
        var parsed;

        if (kc.responseMode === 'query' && queryIndex !== -1) {
            newUrl = url.substring(0, queryIndex);
            parsed = parseCallbackParams(url.substring(queryIndex + 1, fragmentIndex !== -1 ? fragmentIndex : url.length), supportedParams);
            if (parsed.paramsString !== '') {
                newUrl += '?' + parsed.paramsString;
            }
            if (fragmentIndex !== -1) {
                newUrl += url.substring(fragmentIndex);
            }
        } else if (kc.responseMode === 'fragment' && fragmentIndex !== -1) {
            newUrl = url.substring(0, fragmentIndex);
            parsed = parseCallbackParams(url.substring(fragmentIndex + 1), supportedParams);
            if (parsed.paramsString !== '') {
                newUrl += '#' + parsed.paramsString;
            }
        }

        if (parsed && parsed.oauthParams) {
            if (kc.flow === 'standard' || kc.flow === 'hybrid') {
                if ((parsed.oauthParams.code || parsed.oauthParams.error) && parsed.oauthParams.state) {
                    parsed.oauthParams.newUrl = newUrl;
                    return parsed.oauthParams;
                }
            } else if (kc.flow === 'implicit') {
                if ((parsed.oauthParams.access_token || parsed.oauthParams.error) && parsed.oauthParams.state) {
                    parsed.oauthParams.newUrl = newUrl;
                    return parsed.oauthParams;
                }
            }
        }
    }

    function parseCallbackParams(paramsString, supportedParams) {
        var p = paramsString.split('&');
        var result = {
            paramsString: '',
            oauthParams: {}
        };
        for (var i = 0; i < p.length; i++) {
            var split = p[i].indexOf("=");
            var key = p[i].slice(0, split);
            if (supportedParams.indexOf(key) !== -1) {
                result.oauthParams[key] = p[i].slice(split + 1);
            } else {
                if (result.paramsString !== '') {
                    result.paramsString += '&';
                }
                result.paramsString += p[i];
            }
        }
        return result;
    }

2. Cross origin 문제

개발 중에는 silent-check-sso.html의 orgin이 기존 localhost에 동일하게 serving하고 있었지만 배포 이후 특정 URL에서 서빙하다보니 메시지가 정상적으로 받아지지 않았습니다. 디버깅을 위해 브라우저 디버기을 했고 이벤트 리스너에서 받은 message의 event.data가 token이 아닌 알 수 없는 내용이 왔고 postMessage가 정상적으로 되지 않고 있다고 생각했습니다.
그리고 해결을 위해 아래와 같이 targetOrigin을 *로 변경하였습니다.

<html><body><script>parent.postMessage(location.href, '*')</script></body></html>

둘째로 keyCloak.init({onLoad: 'check-sso'})를 할 때 checkSsoSliently()를 호출했는데,

  var checkSsoSilently = function() {
                var ifrm = document.createElement("iframe");
                var src = kc.createLoginUrl({prompt: 'none', redirectUri: kc.silentCheckSsoRedirectUri});
                ifrm.setAttribute("src", src);
                ifrm.setAttribute("title", "keycloak-silent-check-sso");
                ifrm.style.display = "none";
                document.body.appendChild(ifrm);

                var messageCallback = function(event) {

                    if (event.origin !== window.location.origin || ifrm.contentWindow !== event.source) {
                       return;
                    }

		// logic

이벤트를 받았을 때 event.origin과 window.location.origin이 동일하지 않아 init을 할 수 없었습니다. pnpm patch를 통해 'event.origin !== window.location.origin' 부분만 'kc.silentCheckSsoRedirectUri.includes(event.origin)'으로 변경하여 checkSilentSsoRedirectUri로부터 event.origin이 왔는지 검증하는 로직으로 변경하였습니다.

3. End of Thrid-party cookies

몇몇 브라우저에서 SameSite 정책으로 인해 CheckSso를 확인할 수 없었습니다. 크롬에서 작업을 끝난 후에 다른 브라우저를 확인했을 때 아래와 같이 modern browser에서 iframe과 cookie에 강하게 의존하여 동작하지 않았습니다. SameSite가 아닌 경우에도 이용할 수 있는 방법을 찾았지만 Chrome 또한 2025년 상반기에 해당 기능을 block할 계획을 갖고 있다고 발표했고 만들고 나서 아래와 같은 정보를 알게되었습니다.

일부 브라우저의 최신 버전에서는 Chrome의 SameSite나 완전히 차단된 타사 쿠키와 같이 타사가 사용자를 추적하지 못하도록 다양한 쿠키 정책이 적용됩니다. 이러한 정책은 시간이 지남에 따라 더 제한적이 될 가능성이 높으며 다른 브라우저에서도 채택될 것입니다. 결국 타사 컨텍스트의 쿠키는 브라우저에서 완전히 지원되지 않고 차단될 수 있습니다. 결과적으로 영향을 받는 어댑터 기능은 궁극적으로 더 이상 지원되지 않을 수 있습니다."SameSite=Lax by Default" 정책이 있는 브라우저SSL/TLS 연결이 Keycloak 측과 애플리케이션 측에 구성된 경우 모든 기능이 지원됩니다. 예를 들어, Chrome은 버전 84부터 영향을 받습니다.타사 쿠키가 차단된 브라우저Silent는 check-sso 지원되지 않으며 기본적으로 일반(비침묵)으로 돌아갑니다 . 이 동작은 메서드 에 전달된 옵션을 check-sso설정하여 변경할 수 있습니다 . 이 경우 제한적인 브라우저 동작이 감지되면 완전히 비활성화됩니다.Regular check-sso도 영향을 받습니다. Session Status iframe이 지원되지 않으므로 어댑터가 초기화될 때 Keycloak으로 추가 리디렉션을 수행하여 사용자의 로그인 상태를 확인해야 합니다. 이 확인은 iframe을 사용하여 사용자가 로그인했는지 여부를 알려주고 리디렉션은 사용자가 로그아웃한 경우에만 수행되는 표준 동작과 다릅니다.
 
Google은 2024년에 모든 Chrome 사용자를 대상으로 타사 쿠키를 종료할 예정이며, Safari에서는 이미 기본적으로 비활성화되어 있습니다. 이것이 당신에게 어떤 영향을 미치는지 살펴보겠습니다.
우선, ID 서버와 앱이 동일한 루트 도메인을 공유하는 경우 영향을 받지 않습니다.
예를 들어, 다음과 같은 경우: 귀하의 앱은 www.example.com 또는 dashboard.example.com에 호스팅됩니다.예를 들어 Keycloak와 같은 귀하의 ID 서버는 auth.example.com에 호스팅됩니다.
당신은 영향을 받지 않습니다 ✅. 실제로 www.example.com, dashboard.example.com 및 auth.example.com은 모두 동일한 루트 도메인인 example.com을 공유합니다. 반면에, 당신이 다음의 경우에 있다면:
귀하의 앱은 www.examples.com 또는 dashboard.example.com에 호스팅됩니다.귀하의 ID 서버는 auth.sowhere-else.com에 호스팅됩니다.

 

참조

https://unpkg.com/browse/keycloak-js@19.0.1/dist/keycloak.mjs

 

UNPKG - keycloak-js

 

unpkg.com

https://www.keycloak.org/securing-apps/javascript-adapter#_modern_browsers

 

Keycloak JavaScript adapter - Keycloak

In some situations, you may need to run the adapter in environments that are not supported by default, such as Capacitor. To use the JavasScript client in these environments, you can pass a custom adapter. For example, a third-party library could provide s

www.keycloak.org

https://docs.oidc-spa.dev/resources/end-of-third-party-cookies

 

End of third-party cookies | OIDC SPA

Resources🍪End of third-party cookies TL;DR; It's mostly inconsequential. Google is ending third-party cookies for all Chrome users in 2024 and are already disabled by default in Safari. Let's see how it might affect you. First of all, if your identity s

docs.oidc-spa.dev