🌱 Infra/KeyCloak

[keycloak 실습 #2] GoogleWorkSpace + Keycloak+ AWS IdentityCenter(SSO) 연동까지

mini_world 2025. 4. 20. 20:08
목차 접기

 

📌 개요

⚠️ 주의: 이번 실습은 비용이 들어갑니다. (AWS/GoogleWorkSpace/Domain..)

Google Workspace를 그룹웨어로 사용하는 환경에서는 Google 계정을 중심으로 다양한 서비스에 접근할 수 있도록 구성할 수 있다.
이때, Google Workspace를 직접 사용하는 것도 가능하지만, 중앙 인증 및 인가 솔루션인 Keycloak을 중간에 두고 구성하면 다음과 같은 장점이 있다.

  1. 통합 사용자 및 그룹 관리
    • Google Workspace의 사용자 정보를 Role 또는 Group 단위로 다양한 서비스에 대한 접근 제어를 통합 관리할 수 있음
  2. 중앙 권한 제어 및 유연한 확장성
    • “dev팀”, “infra팀”과 같이 그룹을 구성하고, 그룹별로 접근 가능한 AWS 계정이나 ArgoCD 프로젝트를 분리하여 제어 할 수 있음
    • 추후 SAML 또는 OIDC 기반의 외부 서비스가 추가되더라도, Keycloak을 중심으로 쉽게 통합할 수 있음
  3. 유연한 Attribute 및 Claim 매핑
    • Google Workspace에서 전달되는 claim 값을 Keycloak에서 필터링하거나 변환하여 전달할 수 있음
       (예: 이메일 → preferred_username)
    • AWS Identity Center 등에서 Role Mapping 시 필요한 claim 값 (groups, roles 등)도 유연하게 조정 가능함
  4. 일관된 사용자 경험 제공
    • 사용자는 Google Workspace 계정으로 로그인만 하면 되고, Keycloak이 중계자 역할을 하여 다양한 서비스까지 자연스럽게 SSO가 연계됨

즉, 정리하면 Google WorkSpace에 비로 연동되지 않는 경우에도 Keycloak에 연동(SAML/OIDC/Federate)할 수 있고, Group, Role-mapping, Attribute/Claim-mapping으로 일관된 정책으로 솔루션을 관리할 수 있다.

 

📌  GoogleWorkSpace <-> Keycloak 연동

 

1. Keycloak 구성

이번 실습에서는 공인 도메인을 가지고 있는 Keycloak이 필요하다.
Keycloak 구성에 대한 내용은 이전 실습에서도 많이 다루고 있으니 패스 하도록 한다.
실습을 위한 Realm을 새로 생성한다.

 

2. Google WorkSpace 생성 및 구성

Google WorkSpace는 14일동안 비용없이 사용해볼 수 있다.
링크에서 Business Starter (사용자당 $7.56) 로 테스트 해보자.

계정을 생성했다면 Google Cloud 에서 새로운 프로젝트를 생성해야 한다.

새로운 프로젝트를 생성했다면 Google 인증 플랫폼(https://console.cloud.google.com/auth/overview)을 구성 해야한다.

이제 OAuth Client를 생성한다.

승인된 리디렉션 URL에 https://${Keycloak도메인}/realms/${Realm이름}/broker/google/endpoint 을 작성한다.

구성이 완료 후 Client ID & Secret 을 복사한다.

 

3. Keycloak + Google Identity Provider 연동

이제 Keycloak으로 이동해서 IdentityProvider에 Google을 등록한다.

2 단계에서 생성한 Client의 ID/Secret을 입력한다.

 

4. Keycloak 에 Google User로 로그인 해보기

이제 연동이 되었으니, Google 사용자로 로그인 해보자.

사용자 로그인이 성공했다면 Keycloak Realm으로 가서 사용자 목록을 확인해보자.

방금 로그인 한 사용자가 생성되어있다. (사용자는 첫 로그인 후 생성된다.)

 

📌 Keycloak <-> AWS IdentityCenter(AWS SSO) SAML 연동

위 단계에서 Google WorkSpace와 Keycloak을 연동했고,
Keycloak에 google Workspace 계정으로 로그인까지 진행했다.

이번 단계에서는 AWS IdentitiyCenter와 Keycloak연동을 통해
결과적으로는 Google WorkSpace계정으로 AWS 까지 사용할 수 있도록 해보자.

 

1. AWS 계정 구성 (ControlTower+IdentityCenter)

⚠️ 주의
로그인 후 https://us-east-1.console.aws.amazon.com/iam/ 에서 루트사용자 MFA를 등록하기를 강력 권장함

테스트를 위한 AWS계정을 생성한다.
이때, Organization, ControlTower, IdentityCenter를 모두 활성화한 구성으로 테스트를 진행한다. (AWS 계정생성 링크)

이제 control tower 서비스에서 랜딩존을 활성화 한다.
(랜딩존은 편한방식으로 생성하면 된다. 이 실습에서는 IdentityCenter의 기능만 사용한다.)

Control Tower가 활성화 되었다면, IdentityCenter 서비스로 이동하고,
설정 > 작업증명 소스 탭에서 자격증명 소스 변경을 클릭한다.

이때, 외부자격증명 공급자를 선택한다.

메타데이터 파일을 다운로드 한다.

 

2. KeyCloak AWS Client 생성

이제 다시 KeyCloak으로 가서 AWS Client를 생성하자.

import하면 기본적인 부분은 자동으로 설정되며, Name작성 및 Always display in UI 부분만 On으로 변경하고 Save한다.

추가로 Home URL에 /realms/${Realm이름}/protocol/saml/clients/${Client이름} 만 추가한다.

 

 

3. KeyCloak SAML파일 다운로드 및 AWS IdentityCenter 설정 완료하기

이제 Realms Settings로 가서 SAML 2.0 Identity Provider Metadata를 다운받는다.

다시 1번에서 설정중이었던 AWS콘솔로 들어가서 방금 다운받은 SAML 2.0 Identity Provider Metadata 파일을 업로드 한다.

이제 자격증명 소스가 외부Idp(Keycloak)으로 설정되었다.
사용자 및 그룹 설정을 Keycloak과 연동하기 위해서는 SCIM설정이 필요하다. 
따라서 아래 자동프로비저닝 부분에서 활성화를 클릭한다.

자동프로비저닝을 활성화 하면 아래처럼 SCIM 엔드포인트와 토큰이 나오는데,
혹시모르니 따로 저장해둔다.

이제 AWS IdentityCenter 로그인 페이지에 접속해보면, google 로그인이 보일것이다.

그런데 문제는 Google계정으로 로그인 후 IdentityCenter에 접근하지 못한다.

이 이유는 AWS IdentityCenter에 사용자가 생성되지않았기 때문인데, 아래 [알고갈것]SCIM이 뭔지? 에서 내용을 다룬다.

일단 이 동기화 문제를 해결하기 위해 아래 SCIM 설정을 진행해보자.

 

 

📌 Keycloak <-> AWS IdentityCenter(AWS SSO) SCIM 설정

이렇게 Keycloak과 AWS IdentityCenter사이에 SAML설정 후에도 사용자/그룹이 동기화 되지 않는 이슈가 있다.
일단 이번에는 스크립트를 통해 일단 원리를 이해하고,
나중에는 cron등 등록해서 자동으로 프로비저닝 하게 해주어야 한다.

1. 임시로 사용할 AWS AccessKey 생성 및 IdentityCenter ID 확인

스크립트만 사용하고 제거할 IAM 사용자를 만든다. (링크)

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowIdentityStore",
            "Effect": "Allow",
            "Action": [
                "identitystore:*"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

권한 설정 후 AccessKey를 생성하고 AccessKey,SecretKey를 복사해둔다.

IdentityCenterID도 스크립트에서 쓰이니 복사해두자.

 

2. Keycloak admin-cli 설정

SCIM 스크립트는 Keycloak API 접근용 클라이언트가 필요하다.
AWS Identity Center 연결을 위한 SAML 클라이언트와는 별도로 관리 API 접근용 클라이언트(일반적으로 admin-cli)를 사용한다.

Client목록에서 admin-cli를 클릭하고, 아래와 같이 설정을 수정한다. 

이후 해당 Client에서 유저와 그룹을 쿼리할 수 있도록 권한을 추가한다.

마지막으로 이 클라이언트의 Credentials을 복사한다. 스크립트에 사용할 예정이다.

 

3. SCIM 스크립트 실행


스크립트가 너무 길어서 아래 접근글로 넣어놨다.
로컬에서 python scim.py 실행하면 된다.

수정할 부분은 변수 부분이다.

# Keycloak Settings
KEYCLOAK_URL = "your-keycloak-url"
KEYCLOAK_REALM = "your-realm"
KEYCLOAK_CLIENT_ID = "admin-cli"
KEYCLOAK_CLIENT_SECRET_KEY = "your-secret-key"

# AWS Settings
AWS_IDENTITY_STORE_ID = "your-identity-store-id"
AWS_ACCESS_KEY = "your-access-key"
AWS_SECRET_KEY = "your-secret-key"
AWS_REGION = "your-region"

 

👇아래👇 열어서 스크립트 확인

더보기

SCIM 스크립트 

from keycloak import KeycloakAdmin, KeycloakOpenIDConnection
import boto3
import logging
import traceback
from datetime import datetime

# Keycloak Settings
KEYCLOAK_URL = "your-keycloak-url"
KEYCLOAK_REALM = "your-realm"
KEYCLOAK_CLIENT_ID = "admin-cli"
KEYCLOAK_CLIENT_SECRET_KEY = "your-secret-key"

# AWS Settings
AWS_IDENTITY_STORE_ID = "your-identity-store-id"
AWS_ACCESS_KEY = "your-access-key"
AWS_SECRET_KEY = "your-secret-key"
AWS_REGION = "your-region"

class Keycloak:
    def __init__(self):
        connection = KeycloakOpenIDConnection(
            server_url=KEYCLOAK_URL,
            realm_name=KEYCLOAK_REALM,
            client_id=KEYCLOAK_CLIENT_ID,
            client_secret_key=KEYCLOAK_CLIENT_SECRET_KEY,
            verify=True
        )
        self.admin_client = KeycloakAdmin(
            server_url=KEYCLOAK_URL,
            realm_name=KEYCLOAK_REALM,
            client_id=KEYCLOAK_CLIENT_ID,
            client_secret_key=KEYCLOAK_CLIENT_SECRET_KEY,
            connection=connection
        )
        self.logger = logging.getLogger(__name__)

    def list_users(self):
        try:
            return self.admin_client.get_users({})
        except Exception as e:
            self.logger.error(f"Keycloak list_users error: {e}")
            return []

    def list_groups(self):
        try:
            return self.admin_client.get_groups({})
        except Exception as e:
            self.logger.error(f"Keycloak list_groups error: {e}")
            return []

    def get_group_members(self, group_id):
        try:
            return self.admin_client.get_group_members(group_id)
        except Exception as e:
            self.logger.error(f"Keycloak get_group_members error: {e}")
            return []

class KeycloakAWSSync:
    def __init__(self, dry_run=False):
        self.setup_logging()
        self.logger.info("Initializing sync components")

        self.dry_run = dry_run
        self.keycloak = Keycloak()
        self.aws_client = boto3.client(
            'identitystore',
            aws_access_key_id=AWS_ACCESS_KEY,
            aws_secret_access_key=AWS_SECRET_KEY,
            region_name=AWS_REGION
        )
        self.identity_store_id = AWS_IDENTITY_STORE_ID

    def setup_logging(self):
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(levelname)s - %(message)s',
            handlers=[logging.StreamHandler(), logging.FileHandler("sync.log")]
        )
        self.logger = logging.getLogger(__name__)

    def sync_users(self):
        users = self.keycloak.list_users()
        existing_users = self.aws_client.list_users(IdentityStoreId=self.identity_store_id)["Users"]

        aws_usernames = {u["UserName"]: u for u in existing_users if u.get("UserType") == "keycloak"}
        keycloak_usernames = {u["username"]: u for u in users}

        for username, user in keycloak_usernames.items():
            first = user.get("firstName", "")
            last = user.get("lastName", "")
            display = f"{first} {last}"
            if username in aws_usernames:
                self.logger.info(f"Updating user: {username}")
                if not self.dry_run:
                    self.aws_client.update_user(
                        IdentityStoreId=self.identity_store_id,
                        UserId=aws_usernames[username]["UserId"],
                        Operations=[
                            {"AttributePath": "userName", "AttributeValue": username},
                            {"AttributePath": "name.givenName", "AttributeValue": first},
                            {"AttributePath": "name.familyName", "AttributeValue": last},
                            {"AttributePath": "displayName", "AttributeValue": display}
                        ]
                    )
                else:
                    self.logger.info(f"[DRY-RUN] Would update user: {username}")
            else:
                self.logger.info(f"Creating user: {username}")
                if not self.dry_run:
                    self.aws_client.create_user(
                        IdentityStoreId=self.identity_store_id,
                        UserName=username,
                        Name={"GivenName": first, "FamilyName": last},
                        DisplayName=display,
                        UserType="keycloak"
                    )
                else:
                    self.logger.info(f"[DRY-RUN] Would create user: {username}")

        for username, user in aws_usernames.items():
            if username not in keycloak_usernames:
                self.logger.info(f"Deleting user: {username}")
                if not self.dry_run:
                    self.aws_client.delete_user(
                        IdentityStoreId=self.identity_store_id,
                        UserId=user["UserId"]
                    )
                else:
                    self.logger.info(f"[DRY-RUN] Would delete user: {username}")

    def sync_groups(self):
        groups = self.keycloak.list_groups()
        existing_groups = self.aws_client.list_groups(IdentityStoreId=self.identity_store_id)["Groups"]

        aws_groups = {g["DisplayName"].strip().lower(): g for g in existing_groups if g["DisplayName"].startswith("keycloak-")}
        keycloak_groupnames = {f"keycloak-{g['name']}": g for g in groups}
        keycloak_keys = set(k.strip().lower() for k in keycloak_groupnames.keys())

        for display, group in keycloak_groupnames.items():
            norm_display = display.strip().lower()
            if norm_display in aws_groups:
                self.logger.info(f"Updating group: {display}")
                if not self.dry_run:
                    self.aws_client.update_group(
                        IdentityStoreId=self.identity_store_id,
                        GroupId=aws_groups[norm_display]["GroupId"],
                        Operations=[
                            {"AttributePath": "displayName", "AttributeValue": display},
                            {"AttributePath": "description", "AttributeValue": f"Updated at {datetime.now().isoformat()}"}
                        ]
                    )
                else:
                    self.logger.info(f"[DRY-RUN] Would update group: {display}")
            else:
                self.logger.info(f"Creating group: {display}")
                if not self.dry_run:
                    self.aws_client.create_group(
                        IdentityStoreId=self.identity_store_id,
                        DisplayName=display,
                        Description=f"Keycloak synced group. Created at {datetime.now().isoformat()}"
                    )
                else:
                    self.logger.info(f"[DRY-RUN] Would create group: {display}")

        for display, group in aws_groups.items():
            if display not in keycloak_keys:
                self.logger.info(f"Deleting group not found in Keycloak: {display}")
                if not self.dry_run:
                    self.aws_client.delete_group(
                        IdentityStoreId=self.identity_store_id,
                        GroupId=group["GroupId"]
                    )
                else:
                    self.logger.info(f"[DRY-RUN] Would delete group: {display}")

    def sync_group_memberships(self):
        groups = self.keycloak.list_groups()
        for group in groups:
            group_name = group["name"]
            group_id = group["id"]
            prefixed_name = f"keycloak-{group_name}"
            try:
                group_resp = self.aws_client.get_group_id(
                    IdentityStoreId=self.identity_store_id,
                    AlternateIdentifier={
                        'UniqueAttribute': {
                            'AttributePath': 'displayName',
                            'AttributeValue': prefixed_name
                        }
                    }
                )
                aws_group_id = group_resp["GroupId"]
            except Exception:
                self.logger.warning(f"Group {prefixed_name} not found in AWS")
                continue

            # Keycloak members
            kc_members = self.keycloak.get_group_members(group_id)
            kc_usernames = {m["username"] for m in kc_members}

            # AWS members
            aws_members = self.aws_client.list_group_memberships(
                IdentityStoreId=self.identity_store_id,
                GroupId=aws_group_id
            ).get("GroupMemberships", [])

            aws_user_map = {}
            for mem in aws_members:
                mem_id = mem["MemberId"].get("UserId")
                if mem_id:
                    user_detail = self.aws_client.describe_user(
                        IdentityStoreId=self.identity_store_id,
                        UserId=mem_id
                    )
                    aws_user_map[user_detail["UserName"]] = mem["MembershipId"]

            # Add missing users
            for username in kc_usernames:
                if username not in aws_user_map:
                    self.logger.info(f"Adding user {username} to group {prefixed_name}")
                    if not self.dry_run:
                        try:
                            user_resp = self.aws_client.get_user_id(
                                IdentityStoreId=self.identity_store_id,
                                AlternateIdentifier={
                                    'UniqueAttribute': {
                                        'AttributePath': 'userName',
                                        'AttributeValue': username
                                    }
                                }
                            )
                            aws_user_id = user_resp["UserId"]
                            self.aws_client.create_group_membership(
                                IdentityStoreId=self.identity_store_id,
                                GroupId=aws_group_id,
                                MemberId={'UserId': aws_user_id}
                            )
                        except Exception as e:
                            self.logger.warning(f"Failed to add {username} to group {prefixed_name}: {e}")
                    else:
                        self.logger.info(f"[DRY-RUN] Would add user {username} to group {prefixed_name}")

            # Remove stale users
            for username, membership_id in aws_user_map.items():
                if username not in kc_usernames:
                    self.logger.info(f"Removing user {username} from group {prefixed_name}")
                    if not self.dry_run:
                        self.aws_client.delete_group_membership(
                            IdentityStoreId=self.identity_store_id,
                            MembershipId=membership_id
                        )
                    else:
                        self.logger.info(f"[DRY-RUN] Would remove user {username} from group {prefixed_name}")

    def run_sync(self):
        self.sync_users()
        self.sync_groups()
        self.sync_group_memberships()

if __name__ == "__main__":
    syncer = KeycloakAWSSync(dry_run=False)  # Set to False for actual sync
    syncer.run_sync()

 

이제 Keyclock에서 사용자나 그룹을 생성하고 스크립트를 돌리면 아래처럼 싱크가 맞춰진다.

Keycloak콘솔과 AWS IdentityCenter 콘솔에서 sync가 잘 되었는지 확인한다.

 

4. Google Workspace 계정으로 AWS IdentityCenter로그인

이제 다시 Google Workspace계정으로 AWS IdentityCenter에 로그인 해보자.
그 전에 사용자에 매핑된 계정/권한이 잘 반영되는지 확인하기 위해 설정을 추가한다.

먼저 아무 계정이나 선택하고, 그룹과 권한세트를 할당하자.

 

이후 Keycloak 콘솔에서 해당 그룹에 google workspace 사용자를 추가한다.
추가한 후 SCIM 싱크 실행해줘야한다.

이제 다시한번 로그인해보자.

이제 오류없이 제대로 접근이 된것을 확인할 수 있다.

 

 

[알고갈것] SCIM이 뭔지?

  • SAML
    • SAML(Security Assertion Markup Language)은 인증(Authentication) 정보를 교환하는 프로토콜임
    • 목적: 사용자가 한 번 로그인하면 여러 서비스에 접근할 수 있게 하는 SSO(Single Sign-On) 구현
    • 작동 방식:
      • Keycloak(IdP)이 사용자를 인증하고 SAML 어설션(assertion)을 AWS Identity Center(SP)에 전달
      • AWS는 이 어설션을 신뢰하고 사용자에게 접근 권한 부여
    • 한계: 사용자 계정의 프로비저닝(생성/수정/삭제)은 처리하지 않음
  • SCIM
    • SCIM(System for Cross-domain Identity Management)은 사용자 프로비저닝(User Provisioning) 을 자동화하는 프로토콜이다.
    • 목적: IdP와 SP 간 사용자 정보 자동 동기화
    • 작동 방식:
      • Keycloak의 사용자/그룹 정보를 AWS Identity Center로 자동 동기화
      • 사용자 생성, 수정, 삭제, 그룹 멤버십 변경 등을 자동으로 반영
    • 이점:
      • 수동으로 사용자를 관리할 필요 없음
      • 사용자 정보 불일치 방지
      • 사용자 온보딩/오프보딩 자동화

즉, SCIM 스크립트는 Keycloak과 AWS Identity Center 사이에서 아래 작업을 수행한다.

  1. Keycloak에서 사용자/그룹 정보 추출
  2. AWS Identity Center의 SCIM 엔드포인트로 이 정보 전송
  3. 양쪽 시스템의 사용자/그룹 상태를 지속적으로 동기화

일반적으로 SCIM 스크립트는 아래처럼 구성된다.

  • 주기적으로 실행되도록 스케줄링(예: cron 작업)
  • AWS Lambda 함수로 구현
  • 컨테이너화된 애플리케이션으로 실행
  • CI/CD 파이프라인의 일부로 실행 등

 

요약

  • SAML: 로그인(인증) 정보를 교환하여 SSO 구현
  • SCIM: 사용자 계정 정보를 자동으로 동기화하여 계정 관리 자동화

이 두 프로토콜을 함께 사용하면 사용자는 Google Workspace로 로그인하고, 이 인증 정보가 Keycloak을 통해 AWS Identity Center로 전달되며(SAML), 동시에 사용자 계정 정보도 자동으로 동기화(SCIM)되어 완전한 IAM 솔루션을 구현할 수 있다.

 

참고링크

* 참고 블로그: https://blog.beachgeek.co.uk/keycloak-on-aws-part-two/

728x90