콘텐츠로 이동

웹 OIDC(SSO) 로그인 설정

원천 · framework/evomind/web/workflow-project-evo-service기준 · 릴리스 v0.12.0커밋 · 2e32c6cc파일 · docs/operations/web-oidc.md기준선 · 2026-11-12

로그인 화면의 SSO 버튼으로 Keycloak 등 OIDC 제공자에 로그인한다. 로그인에 성공하면 서비스가 사용자·그룹 소속을 동기화하고 자체 토큰을 발급한다. 이후 API 호출은 이 자체 토큰으로 인증한다(IdP 토큰을 API 가 직접 검증하지 않는다). evo-dispatch 웹 연동을 켜면 로그인 때 받은 사용자 본인의 Keycloak 토큰을 메모리에만 보관해 dispatch 호출에 쓴다(dispatch.md). 기본은 꺼짐이다. 꺼진 상태에서는 SSO 버튼이 없고 기존 로컬 로그인만 동작한다.

브라우저 ─ SSO 버튼 ─▶ <evo-api>/oauth2/authorization/oidc ─▶ IdP 로그인
IdP ─▶ <evo-api>/login/oauth2/code/oidc ─▶ 사용자·그룹 동기화 ─▶ <화면>/oidc/callback?code=… ─▶ 자체 토큰 교환

realm(예: evo)에 웹 로그인용 클라이언트를 하나 만든다. 조직용·일반용 인스턴스를 따로 쓰면 인스턴스마다 자기 Keycloak(개발서버는 둘 다 렐름 evo)에서 이 절을 한 번씩 수행하고, 리디렉션 URI·Web origins 는 그 인스턴스의 주소로 넣는다(instances.md). 로그인 과정은 모두 백엔드(evo-api)가 수행하므로 프론트용·백엔드용을 따로 만들지 않는다. 이 절은 Keycloak 관리자에게 그대로 전달해 작업을 맡길 수 있게 썼다.

1.1 클라이언트 생성 (Clients → Create client)

섹션 제목: “1.1 클라이언트 생성 (Clients → Create client)”
항목 값
Client type OpenID Connect
Client ID evo-web (다른 이름이면 그 값을 OIDC_CLIENT_ID에 넣는다)
Client authentication On (기밀 클라이언트)
Authorization Off
Standard flow On
Direct access grants / Implicit flow / Service accounts roles Off
OAuth 2.0 Device Authorization Grant Off (CLI용 클라이언트가 따로 있다: cli-session.md)
Root URL / Home URL https://<evo-web 주소>
Valid redirect URIs https://<evo-api 주소>/login/oauth2/code/oidc — 콜백은 화면이 아니라 API 주소이며, * 없이 정확히 이 한 줄만 넣는다
Web origins https://<evo-web 주소>
PKCE Method 비워 두거나 S256 (앱은 S256을 보낸다)

1.2 토큰 claim (Clients → evo-web → Client scopes → evo-web-dedicated → Add mapper)

섹션 제목: “1.2 토큰 claim (Clients → evo-web → Client scopes → evo-web-dedicated → Add mapper)”

앱은 로그인 직후 ID 토큰과 userinfo의 claim으로 사용자와 그룹을 읽는다. 기본 설정에서는 access token에만 들어가는 항목이 있으므로 이 클라이언트에서 ID 토큰과 userinfo에도 들어가게 한다. 역할(realm_access.roles) claim은 더 이상 읽지 않는다.

claim 설정
groups Mapper type Group Membership, Token Claim Name groups, Full group path Off(전체 경로가 아닌 그룹 이름), Add to ID token / access token / userinfo 모두 On. 경로는 OIDC_GROUPS_CLAIM
preferred_username, name, email 기본 profile·email 스코프를 Default로 둔다(기본값)

로그인 자격은 그룹 소속이다. Keycloak 에 앱의 기본 그룹과 같은 이름의 그룹 user, admin 을 만들고(소문자, 접두 없이 글자 그대로) 사용 대상자를 넣는다. user 또는 admin 그룹에 속하지 않은 사용자는 계정만 있어도 로그인이 거부된다(/login?error=oidc_role, 화면 문구 “접근 권한이 없습니다.”). 이때 앱에는 새 계정이 만들어지지 않는다.

  • 로그인할 때마다 토큰의 groups 중 앱에 같은 이름 그룹이 있는 것만 그 계정의 소속이 되고 나머지 소속은 모두 제거된다(Keycloak 이 기준). 앱에 없는 이름은 무시하고 그룹을 자동으로 만들지 않는다. 앱 그룹은 앱에서 먼저 만든다.
  • 그룹이 없는 사용자의 토큰에는 groups claim 이 빈 배열이 아니라 항목 자체가 없다(Keycloak 26.1.5 확인). 앱은 이를 ’그룹 없음’으로 처리하므로, 매퍼가 빠지면 소속이 사라져 로그인이 거부된다(조용한 오동작이 아니라 눈에 띄는 장애).
  • SSO 계정의 소속은 기본적으로 Keycloak 에서만 바꾼다. 서비스 계정(6절)을 설정하지 않았다면 앱 관리 화면에서 SSO 계정을 그룹에 넣거나 빼려 할 때 “SSO 계정은 Keycloak 에서 관리합니다”로 거부된다(로컬 계정은 앱에서 관리한다). 소속이 바뀌면 그 사람의 다음 로그인(또는 CLI 세션 요청)에 반영된다.
  1. Clients → evo-web → Client scopes → Evaluate에서 시험 사용자(그룹 user 소속)를 고른다.
  2. Generated ID token과 Generated user info 양쪽에 groups 가 그룹 이름만으로(경로 / 없이) 들어 있고 user 가 보이는지 확인한다. 렐름 역할(realm_access.roles)은 앱이 읽지 않으므로 확인하지 않아도 된다.
  3. Credentials 탭에서 Client secret이 발급됐는지만 확인한다.
  • Client ID(evo-web에서 바꿨다면 바꾼 값)와 1.3 확인 결과(통과 / 빠진 claim).
  • Client secret은 채팅·이슈·문서에 적지 않는다. 요청자가 정한 안전한 경로로만 전달하고, 받은 쪽은 .env(개발서버는 CI/CD 변수 DEPLOY_DEV_ORG_ENV_FILE)의 OIDC_CLIENT_SECRET에만 넣는다.
이름 기본값 설명
OIDC_ENABLED false true 로 켠다.
OIDC_ISSUER_URI (비움) https://<keycloak>/realms/<realm>. 기동 시 Discovery 로 확인한다.
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET (비움) 위 기밀 클라이언트 값. 시크릿은 .env 에만 둔다.
OIDC_SCOPES openid,profile,email 요청할 scope.
OIDC_GROUPS_CLAIM groups 그룹 claim 경로(점 표기). 역할 claim 설정(OIDC_ROLES_CLAIM)은 없어졌다.
OIDC_GROUP_PREFIX (비움) 설정하면 이 접두사로 시작하는 그룹만 동기화하고 접두사는 뗀다.
OIDC_SUCCESS_REDIRECT_URI (비움) 로그인 후 돌아갈 화면 주소. 화면과 API 주소가 다르면 반드시 화면 주소를 넣는다. 비우면 같은 주소로 돌아간다.

.env 를 바꾼 뒤 앱을 재기동한다. 켜면 OIDC_ISSUER_URI 에 닿지 못할 때 앱이 기동하지 못할 수 있으니 주소를 먼저 확인한다. HTTPS 발급자만 사용한다. 시크릿·내부 주소를 저장소·이슈·로그에 남기지 않는다.

  • 로그아웃과 계정 전환: 앱의 로그아웃은 자체 refresh 세션을 폐기하고 브라우저의 토큰만 지우며 Keycloak SSO 세션은 끝내지 않는다. 대신 OIDC 인증 요청에 prompt=login 을 붙여, Keycloak 이 SSO 세션이 있어도 로그인 자격 증명을 다시 묻는다. 그래서 로그아웃 뒤 같은 브라우저에서 OIDC 로 다른 계정을 골라 로그인할 수 있다. SSO 세션을 재사용한 자동 로그인은 하지 않는다. Keycloak 세션 자체를 끝내는 RP-initiated logout(end_session)은 지금 범위가 아니다.
  • IdP 의 역할 claim 은 쓰지 않는다. 관리자 여부는 앱이 admin 그룹 소속(유효 역할)으로 판정하며, 사용자 역할 표시값(users.role)은 admin 그룹 소속 여부에 맞춰 둔다.
  • 같은 이름의 로컬 계정과 SSO 계정은 별개 계정이다. 자세한 동작은 4절을 본다.
  • CLI 로그인(evo login)은 별도 설정이다: cli-session.md.

4. 계정 분리와 Keycloak 삭제 시 동작

섹션 제목: “4. 계정 분리와 Keycloak 삭제 시 동작”

같은 이름의 로컬 계정과 SSO 계정

섹션 제목: “같은 이름의 로컬 계정과 SSO 계정”
  • SSO 계정은 사용자 이름이 아니라 발급자(issuer)와 sub 로 식별한다. 이름이 같아도 로컬 계정과 합쳐지지 않고 별개 계정으로 공존한다. 계정 병합 기능은 없다.
  • 처음 SSO 로그인할 때 같은 이름이 이미 있으면 SSO 쪽 이름 뒤에 _oidc(그것도 있으면 _oidc2, _oidc3)가 붙는다. 예: 로컬 hong 이 있으면 SSO hong 은 hong_oidc 가 된다.
  • 두 계정은 대화 기록·설정·CLI 키·API 키가 각각 따로다. 같은 사람이 두 계정을 쓰면 데이터가 나뉜다. 운영에서는 SSO 로그인을 쓰고 로컬 계정은 비상용 관리자 계정만 남기는 것을 권한다.

Keycloak 에서 계정이나 그룹을 삭제해도 앱에는 반영되지 않는다

섹션 제목: “Keycloak 에서 계정이나 그룹을 삭제해도 앱에는 반영되지 않는다”

evo-api 는 Keycloak 의 삭제·변경을 알리는 연동이 없다. 사용자가 로그인하거나 CLI 세션을 요청할 때 받는 토큰만 본다.

Keycloak 에서 한 일 evo-api 에 일어나는 일
계정 삭제 그 사람은 새로 로그인하거나 CLI 세션을 받을 수 없다. 그러나 evo-api 의 계정(대화·설정·그룹 소속)은 활성 상태로 그대로 남는다.
그룹 삭제 evo-api 의 그룹과 그 그룹의 허용 모델·권한 설정은 그대로 남는다. 그 뒤 로그인(또는 CLI 세션 요청)하는 사용자만 소속이 빠지고, 로그인하지 않은 사용자의 소속은 그대로다.

이미 발급된 것은 만료될 때까지 쓸 수 있다.

발급된 것 유효 기간(기본값) Keycloak 삭제만으로는
웹 access 토큰 15분(JWT_ACCESS_TTL) 만료까지 유효
웹 refresh 토큰 7일(JWT_REFRESH_TTL) 앱이 자체 발급한 토큰이라 갱신이 계속 된다(앱 계정이 활성인 동안)
CLI 키(evo-lite) 12시간(CLI_KEY_TTL) 만료까지 유효
API 게이트웨이 키 폐기 전까지 소유 계정이 활성이면 유효

접근을 끊으려면 앱에서 계정을 비활성화한다

섹션 제목: “접근을 끊으려면 앱에서 계정을 비활성화한다”

관리자 설정의 사용자 화면에서 해당 계정의 활성 을 해제한다. 계정과 데이터까지 지우려면 비활성화한 뒤 삭제 한다(, 비활성화와 삭제의 차이는 roles-groups.md 8절).

  • 로그인과 토큰 갱신(refresh)이 거부된다. 이미 발급된 웹 access 토큰도 다음 요청부터 401 로 거부된다(요청마다 계정 상태를 확인한다). refresh 토큰 행은 폐기되지 않고 남지만 갱신 요청에서 거부된다.
  • SSO 로그인과 CLI 세션 요청이 거부되고, 그 사용자의 CLI 키는 evo-lite 에서 삭제된다.
  • 그 사용자의 API 게이트웨이 키는 소유 계정이 비활성이므로 인증에 실패한다.
  • 계정 행·대화 기록은 남는다.
  • 이 순서로 정리한다: Keycloak 에서 계정을 삭제 → 앱에서 같은 사람의 계정을 비활성화(SSO 계정 이름은 _oidc 가 붙었을 수 있으니 사용자 목록의 인증 열로 확인한다) → 데이터도 지울 때만 앱에서 삭제.
  • 앱에서 삭제해도 Keycloak 계정은 지워지지 않는다(evo-api 는 Keycloak 에 쓰지 않는다). Keycloak 계정이 남아 있으면 그 사람이 다시 SSO 로그인할 때 빈 새 계정이 만들어진다.
  1. .env 를 채우고 재기동한다.
  2. GET <evo-api>/api/auth/config 의 oidcEnabled 가 true 인지 확인한다.
  3. 로그인 화면에 SSO 버튼이 보이고, 눌렀을 때 IdP 로그인 화면으로 이동하는지 확인한다. 로그인 후 /oidc/callback 을 거쳐 채팅 화면으로 들어오면 성공이다.
  4. 로그아웃한 뒤 SSO 버튼을 다시 누른다. IdP 가 자격 증명을 다시 묻고, 같은 계정 또는 다른 계정으로 로그인할 수 있어야 한다.

6. 앱에서 한 SSO 계정의 그룹 소속 변경을 Keycloak 에 반영

섹션 제목: “6. 앱에서 한 SSO 계정의 그룹 소속 변경을 Keycloak 에 반영”

기본은 SSO 계정의 그룹 소속을 Keycloak 에서만 바꾼다(앱 화면에서는 “SSO 계정은 Keycloak 에서 관리합니다”로 거부). 서비스 계정을 설정하면 앱 관리 화면에서 SSO 계정을 그룹에 넣거나 빼는 것이 가능해지고, 앱이 Keycloak 의 같은 이름 그룹에도 반영한다. 로컬 계정은 Keycloak 을 거치지 않고 앱에서만 바꾼다. 그룹과 사용자 자체는 Keycloak 에 만들지 않는다(그룹은 앱에서 만들고 Keycloak 담당이 같은 이름으로 따로 만든다).

6.1 Keycloak 준비 (담당자에게 전달)

섹션 제목: “6.1 Keycloak 준비 (담당자에게 전달)”

렐름마다 서비스 계정 클라이언트를 하나 만든다(조직용·일반용은 서로 다른 secret).

항목 값
Client ID evo-api-sync
유형 비공개(client authentication On), Service accounts roles On
로그인 방식 클라이언트 자격 증명만. Standard flow·Direct access·Implicit·Device grant 는 끈다.
서비스 계정 역할 realm-management 의 view-users, manage-users 두 개만
secret 채팅·이슈·문서에 적지 않고 파일로만 전달한다.

manage-users 는 사용자 수정·삭제·비밀번호 재설정까지 허용하므로 그룹 소속만으로 좁힐 수 없다(26.1.5 기준, 그룹 한정 권한은 preview 기능). 그래서 앱은 그룹 소속 추가·제거 호출만 하도록 코드로 제한하고, secret 은 인스턴스별로 .env(개발서버는 CI/CD 변수)에만 둔다. 운영에 쓰기 전에 그룹 한정 권한이나 안정판 도입을 다시 검토한다.

이름 설명
KEYCLOAK_SYNC_CLIENT_ID 서비스 계정 클라이언트 ID(예: evo-api-sync). 비우면 꺼짐.
KEYCLOAK_SYNC_CLIENT_SECRET 그 client secret. 둘 다 있고 발급자(OIDC_ISSUER_URI, 비면 CLI_OIDC_ISSUER_URI)를 알아야 켜진다.
  • 앱에서 SSO 계정을 그룹에 넣거나 빼면 Keycloak 에 먼저 반영하고(사용자 sub 로 같은 이름 그룹에 추가·제거) 성공하면 앱 DB 와 CLI 키를 바꾼다. Keycloak 에 이미 그 상태면 호출 없이 앱만 맞춘다.
  • 실패하면(같은 이름 그룹 없음, 사용자 없음, 서비스 계정 권한·인증 오류, Keycloak 오류·시간 초과, 다른 렐름 계정) 502 “Keycloak 에 반영하지 못해 변경하지 않았습니다. …” 이고 앱 DB 는 바뀌지 않는다. 관리 화면은 오류를 표시하고 화면 값을 변경 전으로 돌린다.
  • Keycloak 에 반영한 뒤 뒤따르는 단계(CLI 키 갱신 등)가 실패해 변경이 롤백되면 반대 호출로 Keycloak 을 원래대로 되돌린다. 반영·실패·복원은 감사 로그(group.keycloak)에 남고 secret·토큰은 남지 않는다.
  • 한계: 이미 발급된 토큰에는 새 소속이 들어 있지 않다. 그 토큰으로 로그인·CLI 세션을 다시 요청하면 동기화가 앱의 변경을 되돌릴 수 있고, 새 토큰이 발급되면(다시 로그인하면) 맞춰진다.