5 minute read

들어가며

ADFS로 OpenID Connect를 붙이다 보면 한 가지 애매한 지점을 만난다. discovery 문서에 PKCE 지원이 광고되지 않는다는 것이다.

RFC 8414는 authorization server가 지원하는 PKCE 방식을 code_challenge_methods_supported 필드로 광고하도록 정한다. 그런데 ADFS(및 Entra ID)는 이 필드를 discovery에 넣지 않는다. 그러면 자연스레 의문이 생긴다.

ADFS는 PKCE를 지원하지 않는 걸까? 아니면 광고만 안 할 뿐 실제로는 받는 걸까? 받는다면, 검증까지 하는 걸까?

이 글은 그 질문을 문서가 아니라 실측으로 답한 기록이다. 결론부터 말하면, ADFS는 PKCE를 광고하지 않지만 실제로는 검증하고 강제한다. 그리고 그걸 깨끗하게 증명하려면 흔한 방식으로는 부족하고, negative-first라는 작은 기법이 필요하다.

환경 정보

  • IdP: ADFS (Windows Server 2019+), 페더레이션 서비스명 sts.example-lab.local (예시값)
  • 테스트 클라이언트: Python 3.12 + Flask, Native(public) application
  • 프로토콜: OIDC Authorization Code + PKCE(S256), client_secret 없음

스크린샷은 /assets/images/27-07-14-pkce/ 아래에 두고 ![desc](img) 형태로 삽입함. 민감 값(도메인·계정)은 예시로 치환함.


1. discovery는 PKCE를 광고하지 않는다

먼저 discovery 문서를 그대로 떠본다.

import requests
url = f"{ADFS_BASE}/adfs/.well-known/openid-configuration"
conf = requests.get(url, verify=VERIFY, timeout=30).json()

print(conf.get("code_challenge_methods_supported"))  # -> None

code_challenge_methods_supported없다(None). 대신 다른 필드들은 정상적으로 실려 있다.

필드 값(요약)
response_types_supported code, id_token, code id_token
grant_types_supported authorization_code, refresh_token
token_endpoint_auth_methods_supported client_secret_post, client_secret_basic, private_key_jwt, windows_client_authentication

여기서 확인되는 건 두 가지다.

  • response_types_supportedcode가, grant_types_supportedauthorization_code가 있으니 Authorization Code flow는 사용 가능하다. PKCE는 이 위에 얹는 것이니 전제 조건은 충족된다.
  • 반면 token_endpoint_auth_methods_supportednone없다. public client의 표준 표기가 없다는 뜻인데, 이것도 discovery만으로는 “public client 미지원”이라고 단정할 수 없다. discovery의 침묵과 실제 동작은 별개다.

핵심: code_challenge_methods_supported의 부재는 PKCE 미지원의 근거가 되지 못한다. ADFS는 광고 없이도 PKCE를 받는 것으로 알려져 있다. 최종 판단은 실제 flow를 던져봐야 한다.


2. Native(public) + PKCE 흐름을 던진다

ADFS에 Native client application(public) 을 하나 등록한다. client_secret이 없는 public client다. RedirectUri는 로컬 루프백(https://localhost:5001/callback)으로 잡는다.

테스트 클라이언트 첫 화면 — Native(public)+PKCE, secret 없음. negative-first 로그인 링크 포함

PKCE의 뼈대는 이렇다.

import base64, hashlib, secrets

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

verifier  = b64url(secrets.token_bytes(32))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
# authorize 요청에 code_challenge=challenge, code_challenge_method=S256
# token 교환에 code_verifier=verifier (client_secret 없음)

authorize로 리다이렉트해 로그인하면 code가 돌아오고, 그 code를 올바른 verifier와 함께 token 엔드포인트로 교환한다.

결과는 정상 토큰 발급이었다.

{
  "positive_exchange": {
    "http": 200,
    "access_token_present": true,
    "refresh_token_present": true
  },
  "id_token_claims": {
    "aud": "<native-client-id>",
    "iss": "https://sts.example-lab.local/adfs",
    "upn": "user01@example-lab.local",
    "unique_name": "EXAMPLE\\user01"
  }
}

positive 교환 — client_secret 없이 code_verifier만으로 200·토큰 발급

client_secret 없이 code_verifier만으로 교환이 성공했다. id_token도 서명(RS256, jwks 대조)·audience·issuer·nonce 검증을 모두 통과했다.

1차 실증: ADFS는 discovery에 광고하지 않지만 Native(public)+PKCE 조합을 받아준다. refresh_token까지 발급된다.

하지만 여기엔 함정이 있다. “받아준다”는 것과 “검증한다“는 것은 다르다. PKCE를 받기만 하고 code_verifier를 실제로 대조하지 않는다면, 그건 PKCE가 아니라 장식이다. 이걸 가려내야 한다.


3. 왜 “positive 후 negative”로는 증명이 안 되는가

가장 먼저 떠오르는 검증 방법은 이렇다.

  1. 올바른 verifier로 교환 → 성공(200)
  2. 이어서 틀린 verifier로 교환 → 실패하는지 관찰

그런데 이렇게 하면 2단계에서 반드시 invalid_grant이 나긴 나는데, 그 이유가 애매하다. authorization code는 1회용이라 1단계에서 이미 소진됐기 때문이다. 즉 2단계의 거부가

  • PKCE 검증 실패 때문인지,
  • 아니면 그냥 code 재사용(이미 소진된 code) 때문인지

구분할 수 없다. 실제로 이 경우 ADFS는 다음 에러를 반환한다.

MSIS9612: The authorization code received in 'code' parameter is invalid.

이건 “code가 무효(소진됨)”라는 메시지이지, PKCE에 대한 언급이 아니다. 검증에 대한 결론을 여기서 내릴 수 없다.


4. negative-first — code를 소진하기 전에 틀린 verifier부터

해법은 순서를 뒤집는 것이다. code를 소진하기 전에, 틀린 verifier를 먼저 던진다.

  1. authorize로 code 1개 확보
  2. 틀린 verifier로 먼저 교환 시도 → 이때 code는 아직 미소진 상태
    • 여기서 나는 거부는 code 재사용이 아니라 순수 PKCE 검증 실패
  3. (부가 관찰) 그 다음 올바른 verifier로 재시도 → code가 이미 소진됐는지 확인

핵심 구현은 이렇다. 쿠키 세션 대신 서버측 저장소(state를 키로)를 쓴 이유는 뒤에서 설명한다.

@app.route("/callback")
def callback():
    state = request.args.get("state")
    flow  = FLOW_STORE.pop(state, None)   # 1회용: 꺼내면서 삭제
    if flow is None:
        return "state 불일치", 400

    code = request.args.get("code")
    token_endpoint = flow["token_endpoint"]
    good_verifier  = flow["verifier"]

    # negative-first: 틀린 verifier 를 '먼저' (code 미소진 상태)
    wrong_verifier = b64url(secrets.token_bytes(32))
    neg = exchange_code(token_endpoint, code, wrong_verifier)

    # 그 다음 올바른 verifier 로 재시도
    pos = exchange_code(token_endpoint, code, good_verifier)

    verdict = ("PKCE 실검증됨" if neg.status_code != 200
               else "주의: 틀린 verifier인데도 발급됨(=검증 안 함)")
    # ... 결과 정리 ...

실측 결과는 다음과 같았다.

{
  "step1_wrong_verifier": {
    "http": 400,
    "error": "invalid_grant",
    "error_description": "MSIS9720: Unable to validate code_verifier."
  },
  "step2_correct_verifier_after": {
    "http": 400,
    "error": "invalid_grant"
  },
  "VERDICT": "ADFS가 PKCE를 실제로 검증함. 틀린 code_verifier -> 거부(invalid_grant). discovery에 광고 없어도 강제됨."
}

negative-first 실측 — 틀린 verifier가 MSIS9720(code_verifier 검증 실패)로 거부됨


5. 결정적 증거: MSIS9612 vs MSIS9720

이 실측의 핵심은 에러 코드가 갈렸다는 점이다.

상황 에러 코드 의미
code 재사용 (소진된 code) MSIS9612 authorization code가 무효
틀린 verifier (code 미소진) MSIS9720 code_verifier 검증 실패

MSIS9720: Unable to validate code_verifier — ADFS가 PKCE 검증 그 자체에서 실패했다고 명시적으로 보고한 것이다. code 소진(MSIS9612)과는 완전히 다른, PKCE에 특정된 에러다.

negative-first로 code를 소진하기 전에 틀린 verifier를 던졌기 때문에, 이 거부가 다른 이유(재사용 등)가 섞이지 않은 순수 PKCE 실패임이 분리 증명됐다. 만약 positive를 먼저 했다면 MSIS9612에 묻혀 이 증거를 볼 수 없었을 것이다.

부가로 step2도 의미가 있다. 틀린 verifier 교환 실패 후에 올바른 verifier로 재시도했더니 이것도 invalid_grant이었다. 즉 ADFS는 실패한 교환에서도 authorization code를 소진시킨다. 한 번 토큰 교환에 쓰인 code는 성공이든 실패든 무효화하는 것으로, 이는 보안적으로 올바른 동작이다. (동시에, 이 때문에 code 하나로 positive와 negative를 모두 깨끗이 관찰할 수 없어 negative-first가 필요했다는 점이 재확인된다.)


6. 곁다리 트러블슈팅 — 세션 쿠키 대신 서버측 state

실측 도중 로컬 Flask 앱에서 콜백마다 세션이 통째로 비는 문제(session keys = [])를 만났다. https 로컬 + 자가서명 인증서 + InPrivate 조합에서 세션 쿠키가 콜백 요청에 실리지 않았다.

PKCE 검증이 목적인 실측 도구였기에, 근본 원인 추적 대신 쿠키 의존을 제거하는 방향으로 우회했다. state를 키로 서버 메모리 dict(FLOW_STORE)에 흐름 상태(verifier/nonce/endpoints)를 저장하고, 콜백에서 돌아온 state로 되찾는 방식이다.

FLOW_STORE = {}   # state -> {verifier, nonce, token_endpoint, ...}

# /login 에서 저장
FLOW_STORE[state] = {"verifier": verifier, "nonce": nonce, ...}

# /callback 에서 되찾음 (쿠키 왕복 불필요)
flow = FLOW_STORE.pop(state, None)

주의 — 이건 “정석”이 아니다. 표준 CSRF 방어에서 state는 세션(쿠키)에 저장한 값과 대조해 “이게 이 사용자의 요청인가”를 확인해야 한다. 서버 전역 dict의 키로만 쓰면 그 사용자 바인딩이 약해져 CSRF 방어가 느슨해진다.

그럼에도 이 실측 도구에서 용인되는 이유는, ① localhost 단일 사용자 폐쇄망이라 CSRF 표면이 실질적으로 없고, ② 검증 대상이 CSRF가 아니라 PKCE 강제 여부라 이 저장 방식이 결과에 영향을 주지 않기 때문이다. RFC 9700 관점에서도 PKCE가 있으면 code 탈취 시 재사용을 code_verifier가 막아 state의 역할을 상당 부분 대신한다. 즉 “PKCE가 있으니 용인되는 단순화”이지, 프로덕션 세션 관리의 모범은 아니다. 실무(예: Django 연동)에서는 세션 기반 state 검증을 정상적으로 써야 한다.


정리

이번 실측으로 확정된 사실.

  1. ADFS는 discovery에 PKCE를 광고하지 않는다code_challenge_methods_supported 부재. 하지만 이는 미지원 근거가 아니다.
  2. ADFS는 Native(public)+PKCE를 받는다 — client_secret 없이 code_verifier만으로 토큰(+refresh_token) 발급.
  3. ADFS는 PKCE를 실제로 검증·강제한다 — 틀린 code_verifier를 MSIS9720으로 거부. 이것이 1·2 사이의 간극을 메우는 결정적 증거다.

교훈을 한 줄로 남기면 — discovery의 침묵을 미지원으로 읽지 말 것. 그리고 “받는다”와 “검증한다”를 가르려면 code를 소진하기 전에 틀린 값을 먼저 던지는 negative-first가 필요하다.

MSIS9612(code 소진)와 MSIS9720(PKCE 실패)의 구분은 검색으로 잘 나오지 않는 실무 지식이다. ADFS OIDC를 붙이는 누군가에게 이 기록이 시간을 아껴주길.

Comments