Azure AD SSO — 로그인 토큰이 어떻게 흐르는가
JWT, RS256, MSAL — 사내 계정으로 로그인하는 원리
Azure AD SSO 이해
로그인 버튼을 누르면 토큰이 어디서 와서 어떻게 검증되는지 — 원리부터 코드까지.
JWT란 무엇인가
JWT는 점(.)으로 구분된 세 조각으로 이루어져 있습니다. Base64로 인코딩되어 있어서 URL에 포함할 수 있습니다.
{"alg":"RS256","typ":"JWT"}
{"sub":"user@gsr.com","name":"홍길동"…}
Azure의 비밀키로 생성
| 조각 | 내용 | 우리가 쓰는 이유 |
|---|---|---|
| Header | 서명 알고리즘 (RS256), 토큰 타입 | 어떤 방법으로 검증할지 결정 |
| Payload | username, name, email, 만료시각(exp), 발급자(iss)… | 사용자 정보 추출 — 별도 DB 조회 불필요 |
| Signature | Header + Payload를 Azure의 비밀키로 서명한 값 | 위조 여부 검증 — 비밀키 없이 서명 불가 |
여권에 비유하면: Header는 여권 앞표지, Payload는 신상정보 페이지, Signature는 정부 도장입니다. 도장 없이 만든 여권은 위조입니다.
RS256 서명 검증 — 공개키로 어떻게 위조를 막는가
RS256은 비대칭 키 방식입니다. Azure AD가 비밀키(Private Key)로 서명하고, 우리 Flask 서버는 공개키(Public Key)로 검증합니다. 공개키는 인터넷에 공개되어 있어도 됩니다 — 검증에만 쓸 수 있고 새 서명을 만들 수 없으니까요.
검증 과정 단계별
Flask에서 이 과정을 담당하는 핵심 코드는 service/auth/token_manager.py의 AuthManager.verify()입니다.
📋 핵심 검증 코드 (token_manager.py)
class AuthManager:
def __init__(self, tenant_id: str, client_id: str) -> None:
self.audience = f"api://{client_id}" # 이 앱 전용 토큰인지 확인
self.issuer = f"https://sts.windows.net/{tenant_id}/" # Azure AD 발급인지 확인
jwks_uri = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
self._jwk_client = PyJWKClient(jwks_uri, cache_jwk_set=True, lifespan=600)
def verify(self) -> dict:
header = request.headers.get("Authorization", "")
token = self._sanitize_token(header.split(" ", 1)[1].strip())
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
return jwt.decode(
token, signing_key.key, algorithms=["RS256"],
audience=self.audience, issuer=self.issuer,
options={"verify_exp": True},
)
aud=api://cba0f2a9…는 "이 토큰이 우리 앱을 위해 발급됐는가", iss=https://sts.windows.net/…는 "이 토큰이 우리 회사 Azure AD가 발급했는가"를 확인합니다.MSAL 인증 흐름 — 브라우저에서 토큰을 얻는 방법
MSAL(Microsoft Authentication Library)은 Azure AD 로그인을 브라우저에서 처리하는 공식 라이브러리입니다. 우리 프로젝트는 loginRedirect 방식을 사용합니다 — 팝업이 아니라 페이지 전체를 Azure AD로 이동했다가 돌아오는 방식입니다.
| 단계 | MSAL 함수 | 언제 호출하는가 |
|---|---|---|
| 1. 로그인 시작 | instance.loginRedirect(loginRequest) | 로그인 버튼 클릭 시 |
| 2. 리다이렉트 복귀 처리 | instance.handleRedirectPromise() | Azure에서 돌아온 직후 (토큰 수신) |
| 3. 세션 자동 복원 | instance.acquireTokenSilent() | 이미 로그인된 사용자가 새로고침 시 |
전체 흐름 도식
📋 auth-provider.tsx 인증 흐름
브라우저 로드
│
▼ handleRedirectPromise() 실행
│ ├─ Azure 리다이렉트 응답 있음? → accessToken 수신 → Flask /acl 검증
│ └─ 응답 없음 → 아래로 진행
│
▼ getAllAccounts() — 기존 로그인 세션 확인
│ ├─ 계정 없음 → setLoading(false) — 로그인 화면 표시
│ └─ 계정 있음 → acquireTokenSilent() 시도
│ ├─ 성공 → Flask /acl 검증
│ └─ InteractionRequiredAuthError → clearCache() → 로그인 화면
handleRedirectPromise()를 반드시 먼저 실행해야 합니다. Azure에서 돌아올 때 토큰이 URL에 담겨오는데, 이 함수가 그것을 처리합니다. 먼저 실행하지 않으면 토큰이 버려집니다.단일 직렬 effect 패턴 — 왜 effect를 하나로 합쳐야 하는가
가장 흔한 실수는 인증 로직을 두 개의 useEffect로 분리하는 것입니다. 레이스 컨디션(race condition)이 발생해 로그인이 완료됐는데도 로그인 화면이 다시 나타나는 버그가 생깁니다.
// Effect 1: redirect 처리
useEffect(() => {
handleRedirectPromise()...
}, [])
// Effect 2: 세션 복원
useEffect(() => {
acquireTokenSilent()...
}, [])
// → 두 effect가 동시에 실행됨
// 하나의 effect로 순서 보장
useEffect(() => {
if (inProgress !== None) return // 대기
if (initDone.current) return // 중복 방지
initDone.current = true
;(async () => {
const r = await handleRedirectPromise()
if (r?.accessToken) { ... return }
// 응답 없으면 세션 복원 시도
await acquireTokenSilent()
})()
}, [inProgress, instance])
| 패턴 | 역할 | 없으면? |
|---|---|---|
inProgress !== None 체크 | MSAL이 처리 중일 때 대기 | 중복 호출로 충돌 발생 |
initDone.current ref | 중복 실행 방지 | StrictMode에서 2회 실행됨 |
handleRedirectPromise() 먼저 | 리다이렉트 복귀 토큰 처리 | 토큰이 버려져 로그인 화면 재표시 |
sessionStorage에 stale 상태가 남습니다. 다음 로그인 시도 시 이 에러가 발생하면, k.includes('interaction.status')인 sessionStorage 키를 모두 제거 후 재시도합니다.5-part JWT 이상 현상 — Azure의 특이한 토큰 형태
Azure AD는 가끔 점이 5개인 JWT를 발급합니다 (정상은 2개). 이것은 access_token과 id_token이 이어붙여진 형태입니다. PyJWT는 이 형식을 모르기 때문에 그냥 전달하면 파싱 오류가 납니다.
| 형태 | 점(.) 개수 | 상황 |
|---|---|---|
| 정상 JWT | 2개 (3조각) | 일반적인 access_token |
| 5-part JWT | 4개 (5조각) | Azure가 access+id 토큰 이어붙인 경우 |
📋 _sanitize_token() — 5-part JWT 처리 (token_manager.py)
def _sanitize_token(self, token: str) -> str:
parts = token.split(".")
if len(parts) != 5:
return token # 정상 3조각이면 그대로 반환
# 5조각 → 앞 3조각만 추출 (access_token 부분)
try:
signing_key = self._jwk_client.get_signing_key_from_jwt(".".join(parts[:3]))
sig_len = self._rsa_sig_b64_len(signing_key.key.key_size // 8)
except Exception:
sig_len = 342 # RSA-2048 기본값
# 서명 길이만큼만 잘라내기
if sig_len < len(parts[2]):
return f"{parts[0]}.{parts[1]}.{parts[2][:sig_len]}"
return ".".join(parts[:3])
JWKS 공개키 캐싱 — 매 요청마다 Azure에 묻지 않는다
Azure AD의 공개키(JWKS)는 자주 바뀌지 않습니다. 매 API 요청마다 Azure에 공개키를 물어보면 지연이 발생하고 Azure에 불필요한 트래픽이 생깁니다. PyJWKClient는 기본적으로 공개키를 캐싱합니다.
| 설정 | 값 | 의미 |
|---|---|---|
cache_jwk_set=True | True | 공개키 캐싱 활성화 |
lifespan=600 | 600초 (10분) | 10분마다 Azure에서 공개키 재조회 |
Lambda는 요청마다 새로운 실행 환경이 아닙니다 — 같은 컨테이너가 warm하게 유지됩니다. 따라서 AuthManager를 모듈 레벨에서 한 번만 생성하면 캐시가 컨테이너 lifetime 동안 유지됩니다.
📋 싱글턴 패턴 (decorators.py)
_manager = None
def _get_manager():
global _manager
if _manager is None:
_manager = get_auth_manager() # 최초 1회만 생성
return _manager