Keycloak으로 OIDC SSO 구현하기(with ArgoCD, Grafana, Airflow)

동관·2025년 11월 9일
post-thumbnail

배경 상황

  • 서비스를 확장 개발하면서 도입되는 OSS가 다양해지면서 아이디/비밀번호 기반의 인증 정보 관리에 다소 어려움을 겪고 있음.
    - ArgoCD 3, Grafana 3, Airflow * 3, ...
    - Dev/Stage/Live 총 3개의 환경이다보니 관리해야 되는 계정 정보가 3배로 증가
    - 추후 ES, Kibana 등 더 도입해야 할 OSS도 남아있는 상황
    - 모든 환경의 아이디/비밀번호를 통일하거나, 공유 계정을 통해 접속하는 것은 장기적으로 지양해야 할 부분이라고 생각됨.
    f

해결 방안

  • Keycloak 도입으로 OIDC 기반의 SSO 구현

SSO(Single Sign On) 란?

  • SSO란 사용자가 한번의 로그인으로 여러 서비스에 접근할 수 있는 인증 체계

Authentication(인증), Authorization(인가)

  • Authentication(인증)
    • 신원을 증명하는 행위
  • Authorization(인가)
    • 권한을 부여하거나 권한을 받는 행위나 사실

OIDC(OpenID Connect) 란?

  • OAuth 2.0 기반의 ID 인증 프로토콜
    • OAuth(Open Authorization)이란 제3자가 HTTP 서비스에 액세스를 얻기 위한 권한 위임 개방형 표준 프로토콜
    • OAuth 1.0의 복잡성과 인증 방식을 개선시켜 더 보편화시킨 것이 OAuth 2.0
    • OAuth 2.0이 Access Token 기반의 권한 위임(인가) 프로토콜이었다면, OIDC는 ID Token 기반의 인증 프로토콜
  • OAuth 2.0의 시퀀스 다이어그램(Authorization Code 방식)

  1. Resource Owner가 Client에 Resource 요청 & Resource로의 접근 권한 인가
  2. Authorization Server는 사용자 인증 및 Client에게 접근 권한을 위임할 것인지 확인
  3. Authorization Server가 Client로 Authorization Code 발급
  4. Client는 발급받은 Authorization Code로 Authorization Server에게 Access Token 요청
  5. Authorization Server는 Client에게 Access Token 발급
  6. Client는 Access Token을 통해 Resource Server에 접근
  7. Resource Server은 Access Token을 확인 후 Client에게 Resource를 전달
  8. Client는 Resource를 Resource Owner에게 전달
  • Resource Owner
    • 보호된 리소스에 접근 권한을 부여할 수 있는 주체
  • Resource Server
    • 보호된 리소스를 호스팅 중인 서버
      • Access Token을 사용하여 리소스 요청을 허용하거나 응답할 수 있음.
  • Client
    • Resource Owner를 대신하여 보호된 리소스 요청을 보내는 애플리케이션
  • Authorization Server
    • Resource Owner의 신원이 검증되면, Client로 Access Token을 발급해주는 서버
    • Authorization Server는 Resource Server와 같을 수도 있고 다를 수도 있음.
  • OIDC에서는 위 다이어그램에서 Access Token과 함께 사용자 신원 정보가 담긴 ID Token을 함께 발급함.

Keycloak 이란?

  • Keycloak은 CNCF에서 관리하는 오픈소스 IAM(Identity and Access Management) 솔루션
  • 애플리케이션은 Keyclaok에게 사용자의 인증/인가 정보를 관리를 위임할 수 있음.
  • OIDC, SAML 기반의 Single Sign On(SSO) 구현
  • GitHub, Facebook, Google 등 소셜 로그인 지원
  • Keycloak DB 외 LDAP, Active Directory 등 외부 ID 저장소와 통합 가능

Realm

애플리케이션, 사용자, 역할 등이 묶여 격리된 환경을 제공하는 논리적 단위

Client

사용자를 대신하여 Keycloak으로 인증/인가를 요청하는 애플리케이션

Client Scopes

인증/인가에 필요한 정보를 토큰의 각 항목(클레임)에 매핑시키는 규칙들의 그룹

Keycloak으로 SSO 구현하기(with ArgoCD, Grafana, Airflow)

1. Keycloak 설치

https://www.keycloak.org/guides

  • 가이드 문서를 참고하여 설치
  • Raw Yaml 그대로 설치해도 되고, Bitnami Helm Chart를 이용해도 무방
  • Ingress, Gateway API 등 이용해 외부 오픈
  • 설치 이후에는 권장 사항에 따라 새로운 관리자 계정을 만들고 admin 계정을 폐기

2. Realm 생성


  • 좌측 Manage realms > Create realm 이후 원하는 Realm Name을 기입하고 Create
  • Relam Name : development

3. ArgoCD, Grafana, Airflow Client 생성

  • Keycloak을 통해 인증/인가 과정을 수행하려는 애플리케이션을 Client로 등록해줘야 한다.

  • Clients > Create client

  • ArgoCD

    • Client Type : OpenID Connect
    • Client ID : argocd
    • Name : argocd
    • Client authentication : On
    • Authentication flow : Standard flow
    • Root URL : [ArgoCD_Web_URL]
    • Home URL : /application
    • Valid redirect URIs : ****[ArgoCD_Web_URL]/auth/callback
    • Web origins : [ArgoCD_Web_URL]
  • Grafana

    • Client Type : OpenID Connect
    • Client ID : grafana
    • Name : grafana
    • Client authentication : On
    • Authentication flow : Standard flow
    • Root URL : [Grafana_Web_URL]
    • Home URL : [Grafana_Web_URL]
    • Valid redirect URIs : ****[Grafana_Web_URL]/login/generic_oauth
    • Web origins : [Grafana_Web_URL]
  • Airflow

    • Client Type : OpenID Connect
    • Client ID : airflow
    • Name : airflow
    • Client authentication : On
    • Authentication flow : Standard flow
    • Root URL : [Airflow_Web_URL]
    • Home URL : [Airflow_Web_URL]
    • Valid redirect URIs : ****[Airflow_Web_URL]/oauth-authorized/keycloak
    • Web origins : [Airflow_Web_URL]

4. Admins Group 생성

  • ArgoCD, Grafana, Airflow 공식 문서에서 권한을 할당하기 위한 매핑에 필요한 토큰 클레임이 각기 다르게 정의되어있기 때문에, 여기서는 Admins 라는 Group에 속해있는 사용자에게 각 서비스의 관리자 권한을 부여하도록 한다.
  • Groups > Create group
  • Name : Admins

5. Client Scopes 생성

  • Client scopes > Create client scope
  • Name : groups
  • Type : Default
    • 신규 Client 생성 시에 해당 scopeDefault로 설정할지, Optional로 설정할지 결정한다

6. Mapper 설정



  • Mapper란 토큰의 클레임과 정보를 매핑시켜주는 것
  • Mappers > Configure a new mapper
  • Mapper Type : Group Membership
    • Group Membership 이란 사용자가 소속된 Group을 클레임으로 매핑시키는 것
  • Name : groups
  • Token Claim Name : groups
  • Full group path : Off
  • Add to ID token : On
  • Add to access token : On
  • Add to userinfo : On
  • Add to token introspection : On
  • Token Claim Name은 사용자의 그룹이 실제로 토큰에 담길 클레임명을 정하는 것이다.

7. ArgoCD Secret, ConfigMap 수정

  • Client Secret 을 복사한 뒤 Base64로 인코딩하여 argocd-secretoidc.keycloak.clientSecret 값으로 기입
  • argocd-cm 에 아래와 같이 oidc.config 추가
  • argocd-rbac-cm에 아래와 같이 policy.csv 추가
  • 수정 후 argocd-server Restart
    [dgyoon@kube-master ~]# echo -n '<Client Secret>' | base64
    **<base64-encoded-value>**
    
    [dgyoon@kube-master ~]# kubectl edit -n argocd secret argocd-secret
    ===
    apiVersion: v1
    kind: Secret
    metadata:
      labels:
        app.kubernetes.io/name: argocd-secret
        app.kubernetes.io/part-of: argocd
      name: argocd-secret
      namespace: argocd
    type: Opaque
    data:
    	...
      oidc.keycloak.clientSecret: **<base64-encoded-value>**
    	...
    ===
    
    [dgyoon@kube-master ~]# kubectl edit -n argocd cm argocd-cm
    ===
    apiVersion: v1
    kind: ConfigMap
    metadata:
      labels:
        app.kubernetes.io/name: argocd-cm
        app.kubernetes.io/part-of: argocd
      name: argocd-cm
      namespace: argocd
    data:
      ...
      oidc.config: |
        name: keycloak
        issuer: https://<KEYCLOAK WEB URL>/realms/<REALM NAME>
        clientID: argocd
        clientSecret: $oidc.keycloak.clientSecret
        requestedScopes: ["openid","profile","email","groups"]
      ...
    ===
    
    [dgyoon@kube-master ~]# kubectl edit -n argocd cm argocd-rbac-cm
    ===
    apiVersion: v1
    kind: ConfigMap
    metadata:
      labels:
        app.kubernetes.io/name: argocd-rbac-cm
        app.kubernetes.io/part-of: argocd
      name: argocd-rbac-cm
      namespace: argocd
    data:
      policy.csv: |
        g, Admins, role:admin
    ===
    
    [dgyoon@kube-master ~]# kubectl rollout restart deployment argocd-server -n argocd

8. Grafana grafana.ini 수정

grafana.ini:
  auth.generic_oauth:
    enabled: true
    name: Keycloak-OAuth
    allow_sign_up: true
    client_id: grafana
    client_secret: <GRAFANA_CLIENT_SECRET>
    scopes: openid email profile groups
    email_attribute_path: email
    login_attribute_path: username
    name_attribute_path: full_name
    auth_url: https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/protocol/openid-connect/auth
    token_url: https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/protocol/openid-connect/token
    api_url: https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/protocol/openid-connect/userinfo
    role_attribute_path: contains(groups[*], 'Admins') && 'Admin' || contains(groups[*], 'editor') && 'Editor' || 'Viewer'

9. Keycloak webserverconfig 수정

web:
  webserverConfig:
    enabled: true
    stringOverride: |
        import logging
        from base64 import b64decode

        import jwt
        import requests
        from cryptography.hazmat.primitives import serialization
        from flask_appbuilder.security.manager import AUTH_OAUTH

        from airflow.www.security import AirflowSecurityManager

        log = logging.getLogger(__name__)

        AUTH_TYPE = AUTH_OAUTH
        AUTH_USER_REGISTRATION = True
        AUTH_ROLES_SYNC_AT_LOGIN = True
        AUTH_USER_REGISTRATION_ROLE = "Viewer"
        OIDC_ISSUER = "https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>"

        # Make sure you create these role on Keycloak
        AUTH_ROLES_MAPPING = {
            "Viewer": ["Viewer"],
            "Admins": ["Admin"],
            "User": ["User"],
            "Public": ["Public"],
            "Op": ["Op"],
        }

        OAUTH_PROVIDERS = [
            {
                "name": "keycloak",
                "icon": "fa-key",
                "token_key": "access_token",
                "remote_app": {
                    "client_id": "airflow",
                    "client_secret": "<AIRFLOW_CLIENT_SECRET>",
                    "server_metadata_url": "https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/.well-known/openid-configuration",
                    "api_base_url": "https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/protocol/openid-connect",
                    "client_kwargs": {"scope": "email profile groups"},
                    "access_token_url": "https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/protocol/openid-connect/token",
                    "authorize_url": "https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/protocol/openid-connect/auth",
                    "request_token_url": None,
                },
            }
        ]

        # Fetch public key
        req = requests.get(OIDC_ISSUER)
        key_der_base64 = req.json()["public_key"]
        key_der = b64decode(key_der_base64.encode())
        public_key = serialization.load_der_public_key(key_der)

        class CustomSecurityManager(AirflowSecurityManager):
            def get_oauth_user_info(self, provider, response):
                if provider == "keycloak":
                    token = response["access_token"]
                    me = jwt.decode(token, public_key, algorithms=["HS256", "RS256"], audience="account")

                    groups = me.get("groups", [])

                    log.info("groups: {0}".format(groups))

                    if not groups:
                        groups = ["Viewer"]

                    userinfo = {
                        "username": me.get("preferred_username"),
                        "email": me.get("email"),
                        "first_name": me.get("given_name"),
                        "last_name": me.get("family_name"),
                        "role_keys": groups,
                    }

                    log.info("user info: {0}".format(userinfo))

                    return userinfo
                else:
                    return {}

        # Make sure to replace this with your own implementation of AirflowSecurityManager class
        SECURITY_MANAGER_CLASS = CustomSecurityManager

10. ArgoCD, Grafana, Airflow SSO 구현

  • Sign in via Keycloak 버튼을 통해 아이디/비밀번호를 입력해 로그인하면 다른 애플리케이션에서는 버튼 클릭만으로 로그인 가능



인증/인가 Flow(ArgoCD)

  1. 사용자가 Login Via Keycloak 버튼을 클릭하면, Keycloak Login 화면으로 리디렉션시키며, URL 상의 rediect_uri 에 클라이언트의 주소를 남김

    1. Keycloak에 등록된 Client ID와 요청할 Scope 포함
    https://<KEYCLOAK_WEB_URL>/realms/<REALM_NAME>/protocol/openid-connect/auth?**client_id=argocd-dev**&**redirect_uri=https%3A%2F%<**ARGOCD_WEB_URL**>%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email+groups&state=rNsquawYZwluTEsfkbNemuld**
  2. 사용자가 Keycloak Login 화면에서 로그인에 성공한다면, redirect_uriAuthorization Code 를 담아 다시 리디렉션시킴.

    https://<ARGOCD_WEB_URL>/auth/callback?state=rNsquawYZwluTEsfkbNemuld&session_state=ef7339c1-5084-413b-9df3-c40e462853e2&iss=https%3A%2F%2Fkeycloak.testworks.dev%2Frealms%2Fddock-ddock&**code=4c1d60fa-9af3-4941-8346-828ccb754fdf.ef7339c1-5084-413b-9df3-c40e462853e2.5d7e0d08-1dcc-4735-aa88-1461f93e18d2**
  3. /auth/callback API가 호출되어 Authorization Code를 Keycloak에서 Access Token과 ID Token으로 Exchange

    1. POST /realms/ddock-ddock/protocol/openid-connect/token
    2. Access Token은 인가, ID Token은 인증
    3. Access Token, ID Token을 바로 발급받지 않고, 굳이 Authorization Code를 통해 발급받는 이유?
      1. redirect_uri로 전달하기 때문에 Access Token이 탈취될 위험이 있음
      2. 토큰이 사용자의 브라우저에 저장되지 않고, 백엔드 서버 간 API 통신으로 더 안전한 통신 가능
      3. 토큰 요청 시 Keycloak에 등록된 Client Secret이 필요하기 때문에 Authorization Code 가 탈취당하더라도 토큰을 발급받을 수 없음(백엔드 서버만 소유)
  4. ArgoCD는 ID Token을 기반으로 argocd.token 세션 토큰을 생성하여 사용자 브라우저에 쿠키로 저장

    1. ArgoCD는 자체적인 권한 제어 방식(RBAC)이 있기 때문에, Access Token을 인가에 사용하지는 않고, Keycloak UserInfo 엔드포인트를 호출할 때 사용함.

참조

profile
안녕하세요. 방문해주셔서 감사합니다.

0개의 댓글