Sandol Auth Relay는 Keycloak 기반 인증 플로우를 챗봇 환경에 맞게 중계하는 FastAPI 애플리케이션입니다. 사용자 브라우저는 Relay를 통해 Keycloak과 상호작용하고, Relay는 발급받은 Access Token과 Offline Refresh Token을 챗봇 서버에 전달한 뒤 사용자를 지정된 경로로 리다이렉트합니다.
이 리포지토리는 내부 서비스 연동을 위한 참고 구현이며, 다양한 OIDC Relay 시나리오에도 응용할 수 있도록 설계되어 있습니다.
Auth-Relay 연동 이후의 MSA 인증 전파/JWKS 정책 문서는 tuk_sandol_team 레포의 docs/를 기준으로 관리합니다.
docs/README.mddocs/auth-chatbot-auth-relay.mddocs/auth-msa-communication.mddocs/jwks-common-module-guideline.md
사용자 브라우저 ──┐
│ (1) 로그인 링크 발급
Sandol Auth Relay ──┐
│ (2) Keycloak 인가 코드 획득
Keycloak ───┤
│ (3) Offline Refresh Token 발급
챗봇 서버 (Refresh Flow) ◀─┘
- Relay는 Authorization Code Flow를 대리 수행하여, Keycloak으로부터 Access Token과 Offline Refresh Token을 발급받습니다.
- Relay는 두 토큰을 챗봇 서버로 POST 전달합니다.
- 챗봇 서버는 Offline Token을 안전하게 저장하고, 이후 자체적으로 Access Token을 갱신합니다.
- 사용자는 Relay가 지정한
redirect_after주소로 리다이렉트됩니다.
-
로그인 링크 요청 (
POST /issue_login_link)- 챗봇 서버가 사용자 ID, 콜백 URL, 클라이언트 키 등을 담아 Relay에 요청합니다.
callback_url은callback_url_allowlist와 absolute URL exact match여야 하며,redirect_after는 safe relative path +redirect_after_allowlist정책을 따릅니다.- Relay는 JWT 기반 LIT(Login Initiation Token)을 생성하고 로그인 URL을 반환합니다.
-
사용자 로그인 (
GET /login/{lit})- 사용자는 챗봇이 전달한 LIT 링크를 열어 Relay에 접근합니다.
- Relay는 LIT을 검증하고, PKCE 파라미터(state/nonce/code_verifier)를 생성한 뒤 Keycloak 인가 엔드포인트로 리다이렉트합니다.
-
Keycloak 콜백 (
GET /oidc/callback)- Keycloak이 Authorization Code와 state를 Relay로 콜백합니다.
- Relay는 state 검증 후
scope=openid offline_access로 Authorization Code를 교환합니다. - Keycloak은 Access Token과 Offline Refresh Token을 모두 반환합니다.
-
챗봇 서버 알림
- Relay는 아래와 같은 payload를 챗봇 서버 콜백 URL로 POST합니다.
- 챗봇은 Offline Token을 안전하게 저장하고, 필요할 때마다
/token에grant_type=refresh_token요청을 보내 Access Token을 재발급받습니다.
-
사용자 최종 리다이렉트
- Relay는 사용자의 브라우저를
redirect_after경로(허용 목록 내)로 리다이렉트합니다. - 허용되지 않은
redirect_after는/로 fallback됩니다.
- Relay는 사용자의 브라우저를
app/
├─ config/
│ ├─ config.py # 전역 설정, 클라이언트 로더, 로그 설정
│ └─ clients.json # 클라이언트별 Keycloak 설정
├─ routers/
│ └─ auth.py # 인증 관련 FastAPI 라우터
├─ schemas/
│ └─ auth.py # Pydantic 스키마 정의
├─ utils/
│ ├─ __init__.py # PKCE, LIT, 클라이언트 유틸 함수
│ ├─ kc_client.py # KeycloakOpenID 헬퍼
│ ├─ security.py # HMAC 서명 및 타임스탬프 검증
│ └─ storage.py # diskcache 기반 세션 스토리지
main.py # FastAPI 엔트리포인트
pyproject.toml # uv/PEP 621 기반 의존성 관리
docker-compose.yml # 로컬 테스트용
-
Python 3.11 이상
-
Keycloak Realm 및 클라이언트 설정
-
루트 compose에서는
.env의SERVICE_DOMAIN이 KeycloakKC_HOSTNAME으로 주입됩니다. -
변수명을
KC_HOSTNAME으로 두지 않은 이유는 같은 도메인 값을 Keycloak 외 다른 서비스 설정에서도 재사용할 수 있도록 공통 이름으로 관리하기 위해서입니다. -
Standard Flow Enabled
-
Redirect URI에 Relay 콜백 등록
-
Client Scopes에
offline_access추가
-
-
챗봇 서버 (토큰 저장 및 갱신 담당)
| 이름 | 설명 | 기본값 |
|---|---|---|
BASE_URL |
Relay의 외부 접근 URL | https://relay.example.com |
JWT_SECRET |
LIT 서명용 HS256 키 | dev-secret-please-change |
RELAY_TO_CHATBOT_HMAC_SECRET |
챗봇 서버로 전달 시 HMAC 서명용 시크릿 | dev-hmac-secret-please-change |
KAKAO_BOT_APP_ID |
카카오 연결 해제 웹훅 app_id 검증 값 | "" |
KAKAO_WEBHOOK_PRIMARY_ADMIN_KEY |
카카오 연결 해제 웹훅 대표 어드민 키 | "" |
KAKAO_WEBHOOK_ALLOWED_ADMIN_KEYS |
허용할 카카오 어드민 키 목록(콤마 구분) | "" |
STATE_TTL_SECONDS |
state/nonce/code_verifier TTL (초) | 600 |
DEBUG |
true일 경우 DEBUG 로그 출력 |
false |
SESSION_CACHE_DIR |
diskcache 저장 위치 | .cache/sessions |
{
"kakao-bot": {
"server_url": "https://auth.example.com/",
"realm": "example",
"client_id": "kakao-bot",
"redirect_uri": "{BASE_URL}/oidc/callback",
"issuer": "https://auth.example.com/realms/example",
"callback_url_allowlist": [
"https://chatbot.example.com/users/callback"
],
"redirect_after_allowlist": ["/", "/login"],
"scope": "openid offline_access"
}
}-
clients.json에client_secret이 없으면 환경 변수에서 자동 주입합니다.CLIENT_KEY를 대문자로 변환 +__SECRETS접미사 (예:KAKAO_BOT__SECRETS)- 소문자 원형 +
__secrets(예:kakao-bot__secrets)
uv sync
uv run uvicorn main:app --reload --host 0.0.0.0 --port 5600.env 파일을 사용할 경우 dotenv를 통해 자동 로드하도록 설정할 수 있습니다.
docker compose up --build- 챗봇 서버가 Relay에 로그인 링크를 요청합니다.
- 응답에는 로그인용 LIT 링크가 포함됩니다.
- Relay가 LIT을 검증하고 Keycloak 인가 URL로 리다이렉트합니다.
- Keycloak에서 Authorization Code와 state를 전달받습니다.
- Relay는 Code를 교환하여 Access Token + Offline Refresh Token을 발급받고, 챗봇 서버에 POST합니다.
- 챗봇 서버는 Offline Token을 저장하고 Refresh Flow로 Access Token을 갱신합니다.
- 카카오 OAuth2.0 Provider의 연결 해제 웹훅을 수신하며
GET,POST모두 처리합니다. - 인증 헤더는
Authorization: KakaoAK {PRIMARY_ADMIN_KEY}형식이어야 하며, 대표 어드민 키와 검증합니다. - 필수 파라미터는
app_id,user_id,referrer_type이며group_user_token은 선택입니다. - 모든 경우(사용자 없음/오류 포함) 3초 이내
200 OK로 응답합니다. 응답 본문은 사용하지 않습니다. - 유효한 요청이면 백그라운드로 사용자 연결 해제 후속처리를 수행하며, 현재 구현은 해당
user_id의 relay 세션을 정리합니다.
-
Relay로부터 Offline Token을 수신
{ "offline_refresh_token": "<refresh_token>" }→ 안전하게 저장 (암호화 및 Vault/KMS 사용)
-
Access Token 갱신
POST https://auth.example.com/realms/example/protocol/openid-connect/token grant_type=refresh_token client_id=kakao-bot client_secret=<secret> refresh_token=<offline_refresh_token>
-
갱신 시 주의사항
- 응답에 새
refresh_token이 오면 반드시 교체 저장. - 401 응답 시 재로그인 필요.
- Realm 설정의 Idle/Max Lifespan을 초과하면 만료됨.
- 응답에 새
-
갱신 주기
- Access Token 만료 1분 전 혹은 401 응답 시 즉시 갱신.
- 최소 20~25일 간격으로 한 번 이상 refresh 요청 수행 (Idle Timeout 초기화용).
- LIT: HS256(JWT_SECRET)
- 챗봇 콜백 서명: canonical JSON 후 HMAC-SHA256(base64url)
- Timestamp 검증:
verify_timestamps에서 허용 오차(skew) 체크
diskcache.FanoutCache기반 state/nonce/code_verifier 저장소- TTL 자동 만료
- Uvicorn 다중 워커 환경에서도 안전하게 공유 가능
-
코드 스타일
- Ruff 사용
ruff format,ruff check
- Ruff 사용
-
Docstring
- Google Style + 한국어 설명
-
로깅
- 환경 변수
DEBUG=true시 DEBUG 로그 출력
- 환경 변수
-
새 클라이언트 추가
clients.json에 등록 후offline_access스코프를 포함
| 증상 | 원인 / 해결 |
|---|---|
unknown_client_key |
clients.json에 정의된 키인지 확인 |
invalid_or_expired_state |
TTL 만료 혹은 중복 state 사용 |
callback_failed |
챗봇 서버 콜백 URL 및 HMAC 검증 확인 |
no_offline_refresh_token |
Keycloak 클라이언트의 offline_access 설정 누락 |
401 invalid_grant |
offline 토큰 만료, 재로그인 필요 |
내부 서비스용 예제 코드이며 별도 라이선스 지정 없음. 외부 배포 시 적절한 라이선스를 추가하십시오.
{ "relay_access_token": "<access_token>", "offline_refresh_token": "<refresh_token>", "issuer": "https://auth.example.com/realms/example", "aud": "kakao-bot", "chatbot_user_id": "user-123", "client_key": "kakao-bot", "ts": 1700000000, "nonce": "<random>" }