Module 01

Azure AD SSO — 로그인 토큰이 어떻게 흐르는가

JWT, RS256, MSAL — 사내 계정으로 로그인하는 원리

Azure AD SSO 이해

로그인 버튼을 누르면 토큰이 어디서 와서 어떻게 검증되는지 — 원리부터 코드까지.

JWT란 무엇인가

"JWT(JSON Web Token)는 서버가 '이 사람은 누구인지'를 서명으로 보증하는 디지털 신분증입니다."

JWT는 점(.)으로 구분된 세 조각으로 이루어져 있습니다. Base64로 인코딩되어 있어서 URL에 포함할 수 있습니다.

HEADER
알고리즘 정보
{"alg":"RS256","typ":"JWT"}
.
PAYLOAD
사용자 정보 (claims)
{"sub":"user@gsr.com","name":"홍길동"…}
.
SIGNATURE
위조 방지 서명
Azure의 비밀키로 생성
조각내용우리가 쓰는 이유
Header서명 알고리즘 (RS256), 토큰 타입어떤 방법으로 검증할지 결정
Payloadusername, name, email, 만료시각(exp), 발급자(iss)…사용자 정보 추출 — 별도 DB 조회 불필요
SignatureHeader + Payload를 Azure의 비밀키로 서명한 값위조 여부 검증 — 비밀키 없이 서명 불가

여권에 비유하면: Header는 여권 앞표지, Payload는 신상정보 페이지, Signature는 정부 도장입니다. 도장 없이 만든 여권은 위조입니다.

ℹ️ Payload는 암호화되지 않습니다. Base64로 인코딩되어 있을 뿐 — 누구나 디코딩해서 내용을 볼 수 있습니다. 개인정보를 JWT에 담을 때는 주의가 필요합니다. 우리 프로젝트에서는 username, name 정도만 담습니다.

RS256 서명 검증 — 공개키로 어떻게 위조를 막는가

RS256은 비대칭 키 방식입니다. Azure AD가 비밀키(Private Key)로 서명하고, 우리 Flask 서버는 공개키(Public Key)로 검증합니다. 공개키는 인터넷에 공개되어 있어도 됩니다 — 검증에만 쓸 수 있고 새 서명을 만들 수 없으니까요.

😰 HS256 (대칭키)
같은 키로 서명+검증. Flask가 비밀키를 알아야 하므로 비밀키를 공유해야 함. 비밀키 노출 시 누구나 위조 가능.
✅ RS256 (비대칭키)
Azure만 비밀키 보유. Flask는 공개키만 알면 검증 가능. 공개키가 노출돼도 위조 불가.

검증 과정 단계별

1
토큰 수신
Bearer header
2
JWKS 조회
Azure 공개키
3
서명 검증
RS256 알고리즘
4
claims 확인
aud, iss, exp
5
사용자 인가
g.current_user

Flask에서 이 과정을 담당하는 핵심 코드는 service/auth/token_manager.pyAuthManager.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},
        )
ℹ️ audience와 issuer 검증이 필수입니다. 서명만 검증하면 다른 앱에서 발급된 유효한 토큰도 통과됩니다. 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 — 레이스 컨디션
// Effect 1: redirect 처리
useEffect(() => {
  handleRedirectPromise()...
}, [])

// Effect 2: 세션 복원
useEffect(() => {
  acquireTokenSilent()...
}, [])
// → 두 effect가 동시에 실행됨
✅ 단일 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() 먼저리다이렉트 복귀 토큰 처리토큰이 버려져 로그인 화면 재표시
⚠️ interaction_in_progress 에러: MSAL 리다이렉트 도중 브라우저 탭을 닫으면 sessionStorage에 stale 상태가 남습니다. 다음 로그인 시도 시 이 에러가 발생하면, k.includes('interaction.status')인 sessionStorage 키를 모두 제거 후 재시도합니다.

5-part JWT 이상 현상 — Azure의 특이한 토큰 형태

Azure AD는 가끔 점이 5개인 JWT를 발급합니다 (정상은 2개). 이것은 access_tokenid_token이 이어붙여진 형태입니다. PyJWT는 이 형식을 모르기 때문에 그냥 전달하면 파싱 오류가 납니다.

형태점(.) 개수상황
정상 JWT2개 (3조각)일반적인 access_token
5-part JWT4개 (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])
ℹ️ 이 코드를 수정하지 마세요. 이상하게 생긴 로직이지만 Azure의 실제 동작을 처리하기 위한 코드입니다. 프로젝트에 그대로 복사해서 사용하면 됩니다.

JWKS 공개키 캐싱 — 매 요청마다 Azure에 묻지 않는다

Azure AD의 공개키(JWKS)는 자주 바뀌지 않습니다. 매 API 요청마다 Azure에 공개키를 물어보면 지연이 발생하고 Azure에 불필요한 트래픽이 생깁니다. PyJWKClient는 기본적으로 공개키를 캐싱합니다.

설정의미
cache_jwk_set=TrueTrue공개키 캐싱 활성화
lifespan=600600초 (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
이 모듈을 마쳤습니다. JWT 구조, RS256 검증, MSAL 흐름, 직렬 effect 패턴, 5-part JWT, JWKS 캐싱까지 — Azure AD SSO의 핵심을 이해했습니다. 다음은 이 토큰을 검증하는 Flask를 Lambda에 올리는 방법입니다.