콘텐츠로 이동
Study NoteKeycloak

OAuth 2.0과 OpenID Connect 로그인

결론부터
앱은 사용자의 비밀번호를 받는 대신 Keycloak의 로그인 결과를 검증하고, code 교환 뒤 받은 토큰으로 사용자 확인과 API 접근을 구분한다.

사용자가 Keycloak에서 로그인했을 때, 앱은 그 사실을 어떻게 믿고 자기 로그인으로 연결할까? 이 장에서는 브라우저·앱 서버·Keycloak이 무엇을 주고받는지 따라간다.

먼저 각 이름의 목적을 구분하자. OAuth 2.0은 앱에 제한된 API 접근을 맡기는 규칙이고, OIDC는 그 위에 로그인한 사용자를 확인하는 규칙을 더한다. Authorization Code는 일회용 code를 받아 토큰으로 교환하는 절차이며, PKCE는 code만 가로챈 주체가 교환하지 못하게 하는 보호 장치다. 배경부터 읽으려면 기초의 OAuth·OIDC·PKCE가 필요한 이유에서 사진 앱 예제로 이 네 가지를 먼저 구분할 수 있다.

이 장에서 처음 나오는 말3개
OAuth 2.0
사용자나 서비스가 자격 증명을 직접 건네지 않고 제한된 접근 권한을 위임하는 프레임워크.
OIDCOpenID Connect
OAuth 2.0 위에 사용자 인증 결과와 ID token을 정의한 프로토콜.
PKCEProof Key for Code Exchange
authorization code를 요청한 client만 교환할 수 있게 verifier와 challenge를 묶는 보호 장치.

여기서 client는 로그인 결과를 받을 앱이며, 첫 사례의 앱은 서버 측 confidential client다. 즉, 앱의 비밀값을 브라우저에 배포하지 않고 서버에서 보관할 수 있다. 로컬 사용자로 새 로그인하는 정상 경로를 따라가며 단계를 바꾸어 메시지와 객체별 데이터 위치를 확인한다. 각 객체의 역할은 그림 아래에서 읽을 수 있다. 밑줄이 있는 전달 값을 누르면 그림 아래의 용어 설명으로 이동한다. 실제 로그인 요청은 보내지 않는 설명용 모델이다. 모든 통신은 TLS를 전제로 하며, 실제 구현에서는 검증된 OIDC 라이브러리가 요청 연결 확인과 code 교환·토큰 검증을 처리하게 한다.

서버 측 Code + PKCE: 누가 무엇을 보관할까?

각 단계가 끝난 시점의 보관 상태입니다. 강조된 객체 사이의 화살표를 따라 전달 값을 읽으세요. 내부 처리는 같은 객체로 돌아옵니다.

그림에 나오는 값의 뜻
verifier
로그인마다 앱이 새로 만드는 무작위 비밀값. 이 사례에서는 앱 서버에 보관했다가 code를 token으로 교환할 때 Keycloak에 보낸다. code만 가로챈 사람은 이 값을 모르므로 교환할 수 없다.
challenge
verifier에서 계산해 먼저 보내 두는 대조값. S256은 verifier를 SHA-256으로 해시하고 base64url로 표현하는 방식이다. Keycloak은 나중에 받은 verifier로 같은 값을 계산해, 처음 보낸 challenge와 일치하는지 확인한다.
state
돌아온 callback이 내가 시작한 로그인 요청의 응답인지 확인하는 무작위 값. 앱이 저장해 두고 인증 요청에 넣으면 Keycloak이 callback에 그대로 돌려준다. 앱은 원래 브라우저의 거래에 저장한 값과 비교한다.
nonce
받은 ID token이 이번 로그인 요청에 대한 것인지 확인하는 무작위 값. 앱이 인증 요청에 넣으면 Keycloak이 ID token 안에 담아 준다. 앱은 token 검증 때 원래 값과 비교한다. state는 callback을, nonce는 ID token을 연결한다.
구성 요소의 역할
브라우저
redirect를 따라 이동하고 사용자 입력을 Keycloak에 제출한다. 이 사례의 OIDC client는 아니다.
앱 서버
confidential OIDC client. code를 교환하고 로그인 응답을 검증한 뒤 자체 세션을 만든다.
Keycloak
사용자를 인증하고 code와 token을 발급한다. 앱 세션을 만드는 주체와는 다르다.

1. 로그인을 요청하고 교환 준비하기

브라우저 → 앱 서버
브라우저 요청 · GET /login주요 전달 값: GET /login

앱은 새 verifier를 생성하고 S256 challenge를 계산한다. state는 callback을, nonce는 ID token을 원래 로그인 요청과 묶는다. 이 사례는 서버의 임시 로그인 거래를 브라우저와 연결하며, 이때 쓰는 거래 식별용 cookie는 로그인 완료 뒤의 앱 session cookie와 구별한다.

브라우저
로그인 시작. 임시 거래 식별용 cookie를 사용할 수 있으나 아직 로그인된 앱 세션은 없음.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음.
Keycloak
로컬 사용자 계정과 client 설정 보유.

2. 인증 URL로 이동하라는 응답

앱 서버 → 브라우저
redirect 응답 · 302 Location: authorization endpoint주요 전달 값: challenge · state · nonce

인증 URL에는 response_type=code, scope=openid, client_id, redirect_uri, state, nonce, code_challenge와 code_challenge_method=S256을 넣는다. 브라우저는 challenge를 볼 수 있지만 verifier와 client secret은 받지 않는다.

브라우저
Location의 인증 URL: challenge·state·nonce 포함. token 없음.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음.
Keycloak
로컬 사용자 계정과 client 설정 보유.

3. 브라우저가 Keycloak에 요청하기

브라우저 → Keycloak
redirect 후속 요청 · GET authorization endpoint주요 전달 값: challenge · state · nonce

앞 단계의 응답을 받은 브라우저가 별도의 요청을 보낸다. Keycloak은 client와 등록된 redirect URI 등을 확인하고 인증 절차를 시작한다. 앱 서버가 이 요청을 대신 보내는 것은 아니다.

브라우저
Keycloak에 인증 URL 전달. verifier·client secret·token 없음.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음.
Keycloak
로컬 사용자 계정과 client 설정 보유. 인증 요청의 challenge(S256)·nonce와 로그인 진행 상태 보관.

4. Keycloak의 로그인 화면 받기

Keycloak → 브라우저
화면 응답 · 로그인 form주요 전달 값: 로그인 form

로컬 사용자로 처음 로그인하는 정상 경로다. 로그인 화면의 주체는 Keycloak이며 앱은 사용자 비밀번호를 받지 않는다. 화면을 구성하는 정적 자원 요청은 생략한다.

브라우저
Keycloak 로그인 화면과 로그인 진행용 cookie. 앱 로그인 세션·token 없음.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음.
Keycloak
로컬 사용자 계정과 client 설정 보유. 인증 요청의 challenge(S256)·nonce와 로그인 진행 상태 보관.

5. 사용자 자격 증명 확인하기

브라우저 → Keycloak
form 제출 · 사용자 이름·비밀번호 → Keycloak주요 전달 값: 사용자 이름 · 비밀번호

비밀번호는 TLS로 Keycloak에 제출되고 로컬 계정의 저장된 credential과 대조된다. 앱 서버로 전달하거나 앱에 보관하지 않는다. 여기서는 추가 MFA나 동의 화면이 필요 없는 설정을 가정한다.

브라우저
비밀번호를 form으로 제출하는 시점. 앱에 비밀번호나 token을 전달하지 않음.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음.
Keycloak
로컬 사용자 계정과 client 설정 보유. 사용자 인증 완료, Keycloak 세션 유지. 비밀번호는 인증 입력으로 처리하며 발급 결과에 포함하지 않음.

6. code를 담은 callback으로 이동 지시

Keycloak → 브라우저
redirect 응답 · 302 Location: 앱 callback?code=…&state=…주요 전달 값: code · state

Keycloak은 짧은 수명의 일회용 code를 만들고 인증 요청의 challenge와 연결한다. code는 token 자체가 아니다. 브라우저는 Keycloak 세션 cookie와 callback URL을 받고 다음 요청을 준비한다.

브라우저
callback URL의 code·state가 잠시 브라우저를 통과. Keycloak cookie 보유. 앱 session cookie·token 없음.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음.
Keycloak
로컬 사용자 계정과 client 설정 보유. 사용자 인증 완료, Keycloak 세션 유지. 교환 전 code와 challenge의 연결 정보 보유.

7. callback을 받고 state 확인하기

브라우저 → 앱 서버
redirect 후속 요청 · GET callback: code·state주요 전달 값: code · state

앱은 브라우저에 연결된 원래 거래를 찾아 반환된 state를 비교한다. 정상 일치일 때만 다음 교환으로 진행한다. code를 받았다는 사실만으로 사용자가 로그인한 것으로 처리하지 않는다.

브라우저
callback URL의 code·state가 잠시 브라우저를 통과. Keycloak cookie 보유. 앱 session cookie·token 없음.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음. callback의 code 수신, state 일치 확인.
Keycloak
로컬 사용자 계정과 client 설정 보유. 사용자 인증 완료, Keycloak 세션 유지. 교환 전 code와 challenge의 연결 정보 보유.

8. 서버끼리 code 교환 요청하기

앱 서버 → Keycloak
서버 간 직접 요청 · POST token endpoint: code + verifier + client 인증주요 전달 값: code · verifier · client secret

앱은 grant_type=authorization_code, code, redirect_uri, verifier를 보내고 이 사례에서는 client secret으로 client를 인증한다. Keycloak은 code의 유효성·client·redirect URI를 확인하고 verifier에서 계산한 challenge를 원래 값과 비교한다. PKCE는 이 거래의 연결 증명이며 client 인증을 대체하지 않는다.

브라우저
callback 응답 대기. 서버 간 요청의 verifier·client secret을 보지 못함.
앱 서버
client secret은 서버 설정에 보관. 브라우저에 연결된 임시 로그인 거래에 verifier·state·nonce 보관. token·앱 로그인 세션은 아직 없음. 교환 요청 전송, token 응답 대기.
Keycloak
로컬 사용자 계정과 client 설정 보유. 사용자 인증 완료, Keycloak 세션 유지. code·verifier·client 인증을 수신하여 교환 조건 확인.

9. 서버가 token 응답 받기

Keycloak → 앱 서버
서버 간 직접 응답 · ID token · access token · refresh token주요 전달 값: ID token · access token · refresh token

성공 응답의 token은 브라우저가 아니라 앱 서버에 도착한다. 이 사례는 refresh token도 발급되는 설정을 가정한다. code는 소비되어 다시 교환할 수 없고, 앱은 수신한 로그인 응답을 아직 검증해야 한다.

브라우저
callback 응답 대기. token 없음.
앱 서버
client secret·임시 거래 유지. ID·access·refresh token 수신, 로그인 응답 검증 전. 앱 로그인 세션 없음.
Keycloak
로컬 사용자 계정과 client 설정 보유. 사용자 인증 완료, Keycloak 세션 유지. code 소비 완료. token 발급 완료.

10. 로그인 응답 검증과 앱 세션 생성

앱 서버 → 앱 서버
서버 내부 처리 · OIDC 응답 검증 → 서버 session 저장처리 내용: ID token 검증 · 서버 session 저장

앱의 OIDC 라이브러리가 응답을 검증한다. 이 사례에서는 ID token의 서명·issuer·audience·만료·nonce 등을 확인한 뒤 신원을 받아들인다. API가 access token을 검증하고 권한을 판단하는 일은 별도다. 임시 거래를 정리하고 서버 session에 token을 보관한다.

브라우저
callback 응답 대기. 앱 session cookie·token 없음.
앱 서버
client secret 유지. 검증한 신원과 token을 서버 session에 보관. 임시 verifier·state·nonce 정리, code 재사용 안 함.
Keycloak
로컬 사용자 계정과 client 설정 보유. 사용자 인증 완료, Keycloak 세션 유지. code 소비 완료.

11. 브라우저에는 앱 session cookie 전달

앱 서버 → 브라우저
앱 응답 · Set-Cookie: 앱 session 식별자주요 전달 값: 앱 session cookie

브라우저는 HttpOnly·Secure 등 적절한 속성의 앱 session cookie를 받는다. 이는 서버 session을 찾는 식별자이며 ID·access·refresh token과 다르다. 이후 앱 요청은 이 cookie를 사용한다. token을 서버에만 두는 것은 이 구성의 선택이지 모든 OIDC 앱의 보장은 아니다.

브라우저
앱 session cookie와 별도의 Keycloak cookie 보유. OIDC token·verifier·client secret 없음. callback code는 더 이상 사용하지 않음.
앱 서버
client secret 유지. 앱 session 식별자에 연결된 신원·token 보관. 임시 거래 정리 완료.
Keycloak
로컬 사용자 계정과 client 설정 보유. 사용자 인증 완료, Keycloak 세션 유지. 앱 session cookie를 발급하거나 관리하지 않음.

전체 흐름 보기는 같은 설명을 한 번에 펼친다. 단계 제목의 링크로 특정 순간을 직접 참조할 수 있다. OIDC Code Flow와 PKCE의 verifier·challenge 교환 규칙을 기준으로 요청과 응답을 나눴다. discovery·JWKS 조회, 추가 MFA·동의, 실패 분기는 그림에서 생략했다.

확인해 보자. callback에 code가 도착했는데도 서버가 Keycloak을 다시 호출하는 이유는 무엇일까? code 교환 요청과 앱 session cookie 전달을 비교하면 code는 교환할 증표이고, token은 서버에, 앱 session cookie는 브라우저에 남는다는 차이를 확인할 수 있다.

  • OAuth 2.0과 OIDC는 무엇이 다른가?
  • Authorization Code + PKCE에서 브라우저와 앱 server는 무엇을 주고받는가?
  • 서버 없는 SPA는 같은 흐름을 어떻게 수행하는가?
  • frontend와 backend가 나뉜 앱은 누가 client가 되는가?
  • ID·access·refresh token은 누가 어디에 쓰는가?

OAuth 2.0의 핵심 질문은 “이 client가 어떤 resource에 접근하도록 위임받았는가”다. OIDC는 authorization request에 openid scope를 넣고 ID token·UserInfo·표준 claim을 정의해 “누가 로그인했는가”를 추가한다. OAuth access token만 보고 앱 로그인 정보를 임의로 추측하면 안 된다.

Keycloak의 OIDC endpoint 안내는 discovery, authorization, token, JWKS endpoint를 설명한다. 앱은 endpoint를 문자열로 흩어 적기보다 realm의 discovery 문서에서 읽는다.

값막으려는 문제확인 주체
정확한 redirect URI공격자 callback으로 code가 새는 문제Keycloak
state다른 브라우저 요청의 callback을 끼워 넣는 문제앱
PKCE S256탈취한 code를 다른 client가 교환하는 문제Keycloak
nonce다른 인증의 ID token 재사용·혼동앱의 OIDC 라이브러리

public client는 secret을 안전하게 숨길 수 없으므로 PKCE가 특히 중요하다. confidential server-side 앱도 PKCE를 함께 쓰면 code 탈취 방어가 더 명확해진다. 이 실습의 앱 A/B는 confidential client지만 S256을 요구한다.

SPA라면 브라우저가 직접 교환한다

섹션 제목: “SPA라면 브라우저가 직접 교환한다”

HTML·CSS·JS 정적 파일로만 배포되는 SPA(Single Page Application)는 서버가 없어 secret을 숨길 곳이 없다. 그래서 public client로 등록하고, 위 흐름에서 앱 server가 하던 일 — authorization URL 생성, code 교환, token 보관 — 을 브라우저의 JS가 직접 한다. 흐름은 같은 Code+PKCE지만 client secret이 없고 PKCE만으로 code 탈취를 막는다.

서버 없는 SPA가 client secret 없이 PKCE만으로 code를 token으로 교환하는 흐름

대신 token이 브라우저에 저장되므로 페이지에 낀 악성 script(XSS)가 token을 읽을 수 있는 노출면이 생기고, refresh token 수명도 짧게 잡는다. 이 실습의 앱 A/B는 서버가 있으므로 이 경로를 쓰지 않고, token을 서버 session에 두는 confidential client를 쓴다. 두 유형의 선택 기준은 Client 연결과 SSO에 있다.

frontend와 backend가 나뉘면 누가 client인가

섹션 제목: “frontend와 backend가 나뉘면 누가 client인가”

React 같은 SPA frontend와 별도 backend API로 나뉜 앱도 새 흐름이 필요한 것이 아니다. 둘 중 누가 OIDC client가 되는지를 고르면 위 서버 측 흐름이나 SPA 다이어그램으로 정확히 환원된다. 갈리는 것은 token이 어디에 사는가와 backend의 역할 두 가지다.

backend가 client (BFF)frontend가 client (SPA)
client 유형confidential — 위 서버 측 흐름public — 위 SPA 다이어그램
token 위치backend session브라우저
브라우저→backend 인증session cookieBearer access token
backend의 역할OIDC client + API 대리 호출resource server — 검증·인가만

BFF(Backend For Frontend)는 backend가 confidential client로 로그인을 대신 처리하고 token을 자기 session에만 두는 구조다. frontend 정적 파일은 로그인에 관여하지 않고, 브라우저는 session cookie로 backend를 호출한다.

BFF 구조에서 브라우저는 session cookie만 쓰고 backend가 token을 보관하는 관계

token이 브라우저에 전혀 없으므로 XSS로 token을 잃는 노출면이 사라진다. IETF의 브라우저 기반 앱 보안 지침(RFC 10017, BCP 212)은 세 구조를 보안이 강한 순서로 나열하며 BFF를 첫째로 두고, 업무·민감·개인정보 앱에 강하게 권장한다. 이 실습의 앱 A는 HTML까지 직접 주는 server 앱이지만 역할은 BFF와 같다 — access token을 자기 session에 두고 대신 API를 호출한다.

frontend가 client가 되는 쪽을 고르면 backend는 로그인에 관여하지 않는 resource server가 된다. 브라우저가 Bearer로 보낸 access token의 서명·issuer·audience·만료를 검증하고 role로 인가만 하며, 검증 항목은 Access Token 검증이 다룬다.

token받는 사람과 목적쓰면 안 되는 곳
ID tokenclient가 로그인한 사용자와 인증 사건을 확인API bearer 권한 판단
access tokenAPI가 서명·issuer·audience·만료와 권한 claim을 검증브라우저 프로필 표시의 유일한 근거
refresh tokenclient가 새 token을 요청API 요청의 bearer credential

JWT는 암호화된 비밀 상자가 아니라 서명된 claim 묶음인 경우가 많다. 브라우저 console이나 로그에 원문을 남기지 않고, access token은 의도한 API에만 보낸다.

앱 A 자동 검증은 올바른 local-user로 callback과 앱 session까지 성공했다. 오답 비밀번호는 callback에 도달하지 않았고, 변조한 state·nonce는 앱이 HTTP 400으로 거부했으며, 미등록 redirect URI는 Keycloak이 거부했다. password grant로 이 흐름을 대체하지 않았다.

Authorization Code + PKCE를 앱 코드와 실제 callback으로 확인하려면 Client를 연결하고 로그인 확인하기로 이어 간다.

  • OAuth 2.0은 접근 위임, OIDC는 그 위에 사용자 인증을 정의한다.
  • Authorization Code 흐름에서 code는 짧게 브라우저를 지나고, 앱이 PKCE verifier로 token을 교환한다.
  • state·nonce·redirect URI·PKCE는 서로 다른 공격 경계를 막는다.
  • 로그인에는 ID token, API에는 access token, 갱신에는 refresh token을 쓴다.
  • frontend·backend 분리 앱은 backend를 confidential client로 쓰는 BFF가 우선 선택지다.