통합 인증 서버 개발과 표준 — Spring Authorization Server
OAuth2·OIDC 표준을 참고해서 Spring Authorization Server로 인증 서버를 만들기

인증 서버를 개발하기로 했다
SSO를 위한 인증 서버를 직접 만들어 볼 일이 생겼다. Keycloak 같은 기성 솔루션을 쓰는 것도 고려했지만, 필요한 범위에 비해 덩치가 커서 가볍게 시작하기엔 부담이 있어 Spring Authorization Server를 검토해보게 됐다. 간단한 사이즈이지만 손으로 개발하면서 OAuth2, OIDC 표준에 대해 조금 더 이해할 수 있는 기회를 얻을 수 있을 거 같아 나름 좋은 경험이 되겠다는 기대감이 있었다. 이 글은 Spring Authorization Server 기반으로 인증 서버를 개발할 때 공부 및 참고한 자료와 표준들에 대한 내용이다.
인증 서버란?
이 글에서 말하는 인증 서버란 일종의 구글 로그인, 카카오 로그인처럼 로그인과 로그인 상태를 통합해서 관리하는 서버이다. 한마디로 인증의 주체가 되는 서버라고 할 수 있다. 내가 운영하는 서비스가 여러개라고 하자. 이때, 각각의 서비스에 각각 로그인할 필요 없이 다른 서비스들은 이 서비스에게 로그인을 위임함으로써 하나의 일관된 로그인 상태를 가져갈 수 있다. 이것을 SSO라고 하고 이 SSO 를 구현할 때 로그인을 수행하는 서버가 되는 곳이 인증 서버라고 할 수 있다.
개발 원칙: 표준을 따른다
통합 인증 서버를 개발하면서 세운 가장 큰 원칙은 표준을 따른다. 는 것이었다. 요즘 대부분의 서비스는 카카오 로그인, 네이버 로그인 등 여러 OAuth2 로그인 연동을 붙여놓는다. 인증 표준을 따르게 되면 클라이언트 서버는 이미 개발된 로그인 기능을 확장하여 인증 서버와의 SSO 기능 개발을 훨씬 용이하게 진행할 수 있다. 무엇보다 수많은 사람들이 오랫동안 고생하면서 다듬어 온 표준(OAuth2, OIDC)이 이미 있고 그 표준을 구현한 프레임워크(Spring Authorization Server)가 이미 존재하는 상황이라서 굳이 보안이나 기타 위험 이슈를 안고 내가 0부터 새로 개발할 필요가 없다고 생각했다.
OAuth2와 OIDC
OAuth2와 OIDC는 자주 섞여 불리지만(일반적으로 OAuth2라고 갈음해서 부르는 듯하다.) 사실 서로 담당하는 것이 다르다.
- OAuth2는 인가(Authorization) 절차를 표준화한 것이다. 자원에 접근하기 위한 Access Token이 발급된다. (RFC 6749: The OAuth 2.0 Authorization Framework)
- OIDC(OpenID Connect) 는 인증(Authentication) 절차를 표준화한 것이다. OAuth2의 확장 프로토콜로, Access Token과 함께 Id Token이 발급된다. (OpenID Connect Core 1.0)
OAuth2와 OIDC의 Token들
OIDC에서 다루는 토큰은 세 종류이고 Spring Authorization Server로 통합 인증 서버를 구현했을 때 기본적으로 아래와 같은 spec으로 발급된다.
Id Token ── JWT ─ 사용자 profile 획득 ─ decode 가능 ─ 유효시간 짧음
Access Token ── JWT ─ 자원(API) 접근 ─ decode 가능 ─ 유효시간 짧음
Refresh Token ── 불투명 토큰 ─ Access Token 재발급 ─ decode 불가 ─ 유효시간 긺
여기서 눈여겨볼 건 Refresh Token이 불투명(opaque) 토큰이라는 점이다. 불투명 토큰이란, decode 되지 않는 토큰으로 어떤 정보값을 가지고 있지 않은 토큰을 말한다. Refresh Token은 Access Token 재발급용으로만 사용되고 인증 서버 본인만이 검증할 수 있으면 되는 토큰이기 때문에 굳이 Token 자체가 어떤 값을 가지고 있을 필요가 없다. 따라서 정보 노출 위험이 있는 JWT가 아니라 불투명 토큰을 사용하는 것이 상대적으로 합리적이라고 볼 수 있다.
추가로 JWT의 주요 claim은 아래와 같다.
토큰 claim
sub ─ 사용자 식별자
iss ─ 발급자 (인증 서버)
aud ─ 이 토큰의 대상 클라이언트 id
azp ─ 토큰이 발급된 당사자(authorized party) 클라이언트 id. aud와 발급 대상이 다를 때 쓰인다
auth_time ─ 인증된 시점 (epoch time)
nonce ─ 클라이언트가 전달한 임의 값. 재생(replay) 공격 방지용
exp / iat ─ 만료 시간 / 발급 시간
nbf ─ 유효 시작 시각. 이 시간 전에는 토큰이 유효하지 않다.
jti ─ 토큰 고유 식별자
sid ─ 인증 서버의 세션 id를 해싱한 값. 로그아웃에 사용됨.
scope ─ 권한 범위
OIDC의 Endpoint
Endpoint들은 HTTP Method부터 파라미터, 응답 Body 형식까지 상당히 상세하게 표준으로 정의 되어있다. 참고한 표준 문서로는 OpenID Connect Discovery 1.0, OpenID Connect Core 1.0, OpenID Connect RP-Initiated Logout 1.0를 보았다.
Discovery 문서 경로 표준
GET https://auth.example.com/.well-known/openid-configuration
Spring Authorization Server의 Discovery 문서 상 기본 Endpoint
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/oauth2/authorize",
"token_endpoint": "https://auth.example.com/oauth2/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/oauth2/jwks",
"end_session_endpoint": "https://auth.example.com/connect/logout",
토큰을 사용하면 무상태인가요?
막연하게 토큰 기반 인증이기 때문에 세션을 사용하지 않는 stateless이지 않을까? 하는 생각을 가지고 있었다. 그러나 이건 완전히 내 오해였다. 토큰 기반 인증이라고 하여 그것이 곧 시스템이 무상태라는 뜻은 아니었다.
Spring Authorization Server로 인증 서버를 구현하면 기본적으로 세션을 사용한다. OIDC 인증은 여러 단계에 걸쳐 진행되는데(로그인 화면 → 인증 → code 발급 → token 교환) 세션이 맡는 것은 로그인된 사용자를 기억하는 일이다. 컴퓨터 입장에서 생각해보면 여기서 “사용자”는 곧 “브라우저”가 되기 때문에 세션은 사실상 브라우저의 로그인 상태 관리를 하게 되는 것이다. 아주 전통적인 세션 로그인 방식이다. keycloak도 동일하게 SSO를 위해 세션을 사용한다.
클라이언트 서버 1 → 인증 서버로 리다이렉트 → 인증 서버 로그인 화면에서 id/pw 로그인
│ (인증 서버에 세션 생성, 브라우저에 세션 쿠키 관리)
▼
클라이언트 서버 2에서 인증 서버로 로그인 요청
│ (브라우저가 인증 서버의 세션 쿠키를 전달)
▼
인증 서버: 세션에 저장된 인증 정보 확인 → id/pw 입력 없이 code 발급
│
▼
클라이언트 서버 2에 SSO 로그인 완료
인메모리에서 DB로 전환할 것들
Spring Authorization Server는 기본 설정으로 꽤 많은 것을 메모리에 저장한다. 모든 토큰, Client 설정, 인증 진행 상태(state)와 Code, 동의 여부(Consent)까지 기본적으로 메모리에 저장된다. 실제로 서비스 되어야하는 경우, 이 모든 것을 인메모리에 저장한다는 건 스케일 아웃 등을 고려했을 때 문제가 되는 부분들이 있어서 Spring Authorization Server에서는 DB이관에 필요한 공식 가이드(How-to: Implement core services with JPA)와 데이터 모델(Core Model / Components)을 제공하고 있다.
가이드의 entity 구조를 보면 Spring Authorization Server는 토큰, code 등 인증 관련 데이터를 하나의 entity로 묶어서 관리하고 있었다. 이 구조를 굳이 분리해서 하나씩 커스텀하는 것보다는, DB에 저장하는 양이 커지더라도 프레임워크의 기본 데이터 모델을 따라가는 쪽이 유지보수에 훨씬 도움이 될 가능성이 있다. 커스텀 비용은 당장 그 코드를 수정하는 것 뿐만 아니라 그것과 관련된 다른 구조들의 커스텀 비용도 수반하는 경우가 있으니까.
직렬화 지옥
그렇게 되면 이제 직렬화 및 역직렬화 지옥에 갇히게 된다.
대표적인 예를 들어 UserDetails를 커스텀해서 사용했다면, 인증 결과를 DB에 저장하는 순간 JSON 직렬화 오류를 바로 맞닥뜨리게 된다. Spring Security는 보안상의 이유로 역직렬화 가능한 타입을 white list로 관리하는데 커스텀한 타입은 물론이고 종종 Long 같은 기본 타입까지 걸리는 경우도 있는 것으로 보인다. 같은 이슈로 고통받고 있는 spring-session#3009와 spring-security#4370 를 참고하면 문제를 해결할 수 있다.
이 문제는 AuthenticationToken을 커스텀했을 때도 동일하게 발생할 수 있다. 결과적으로 DB에 저장되는 모든 커스텀 타입에 대해 Deserializer를 구현하고 Mixin을 등록하는 것이 필요할 수 있다. Spring이 기본 제공하는 UsernamePasswordAuthenticationToken이나 UserDetails 구현체들은 이미 Deserializer가 구현되어 있다. 특별한 이유가 있는 것이 아니라면 기본 구현체를 쓸 수 있는 곳에서는 기본 구현체를 쓰는 게 깔끔하고 좋을 것으로 보인다.
로그아웃하는 법
통합 인증에서 로그아웃은 두 가지 측면에서 생각할 수 있다. (1) 인증 서버 세션 종료. (2) 연결된 다른 서비스에서도 같이 로그아웃하기.
OIDC는 이 둘을 각각 별도의 표준으로 정의하고 있다.
(1) 은 OpenID Connect RP-Initiated Logout 1.0으로, 클라이언트가 사용자를 인증 서버의 end_session_endpoint로 보내 로그아웃하는 방식이다.
(2) 는 인증 서버가 연결된 다른 클라이언트들에게 로그아웃을 알리는 방식으로, 크게 두 가지 채널이 표준으로 정의되어 있다.
(OpenID Connect Front-Channel Logout 1.0, OpenID Connect Back-Channel Logout 1.0)
| Front-Channel Logout | Back-Channel Logout | |
|---|---|---|
| 브라우저 의존 | O | X (서버 간 통신) |
| 전파 방식 | 인증 서버가 각 클라이언트의 로그아웃 URI를 브라우저(iframe)로 호출 | 인증 서버가 각 클라이언트의 로그아웃 URI로 직접 POST |
| 신뢰성 | 서드파티 쿠키 차단 등으로 전파가 실패할 수 있음 | 브라우저와 무관하게 전파가 확실함 |
| 보안 수준 | 중간 | 높음 |
로그아웃에는 Id Token의 sid 클레임이 사용된다. 인증 서버의 세션 id를 해싱한 값이다.
로그아웃 요청이 오면 인증 서버는 이 sid와 실제 세션의 해시를 비교해서 어느 세션을 끝낼지 결정한다.
Public Client와 Refresh Token
인증 클라이언트에는 두 종류가 있다.
- Confidential Client: 서버처럼 client secret을 안전하게 보관할 수 있는 클라이언트. token 교환 시 client id + secret을 사용한다.
- Public Client: 모바일 앱처럼 소스 코드가 노출되어 secret을 보관할 수 없는 클라이언트. code 탈취 방지를 위해 PKCE 확장을 사용한다.
PKCE는 Proof Key for Code Exchange의 약자이며(RFC 7636)
code를 발급하고 code를 토큰으로 교환하는 과정에서 code_challenge와 code_verifier를 사용하여
code를 요청했던 클라이언트와 토큰을 요청하는 클라이언트가 동일하다는 것을 증명하는 확장이다.
버전에 따라 다르지만 Spring Authorization Server는 “Public Client에게는 Refresh Token을 발급하지 않는다.” 가 기본 정책이었다. 그런데 표준(OAuth 2.0) 자체는 Public Client에 Refresh Token을 발급하는 것을 금지하지 않는다. 대신 RFC 9700: Best Current Practice for OAuth 2.0 Security는 Public Client에 Refresh Token을 발급할 때 탈취·재사용에 대비해 sender-constrained token 또는 Refresh Token rotation(한번 쓴 refresh token은 재활용 불가-폐기 처분-) 중 하나를 반드시 적용하도록 요구한다. 실제로 대형 OAuth2 제공자들도 scope를 제한하거나 Refresh Token rotation 같은 보안 정책을 적용해서 Public Client에 Refresh Token을 발급하는 것으로 확인됐다. (예를 들면 구글)
검색을 조금 더 해보니 많은 사람들이 public client에서 refresh token을 발급하기 위해 논의중이었다. public client에서 refresh token을 발급할 일이 있다면 refresh token rotation을 적용하고 아래 자료를 참고하면 금방 개발할 수 있을 것으로 보인다.
- spring-authorization-server#1432: Allow configurable refresh token strategy for authorization_code grant
- PR #1432의 구현 커밋: OAuth2AuthorizationCodeAuthenticationProvider·OAuth2RefreshTokenGenerator 변경
- PR #1432의 테스트 커밋: offline_access scope로 refresh_token 요청
- 메인테이너 코멘트: PR 대신 Client Authentication 설정 커스텀으로 해결하는 방법 안내
- 메인테이너의 테스트 커밋: Public Client의 refresh_token grant 허용 설정 예시
- Spring Authorization Server — Public Client PKCE Authorization code flow (with refresh tokens) (Medium, 선행 사례)
Refresh Token이 만료되면…?
Refresh Token이 만료된 뒤 Access Token 재발급을 요청하면 invalid_grant 에러가 발생한다.
직관적으로 생각해봤을 때 자연스러운 처리는 “로그인 화면으로 보내기”다. 그런데 세션에 대해서 생각해봐야한다.
설정에 따라 다르지만 토큰의 만료 시간과 세션의 만료 시간은 일치하지 않을 수 있다. 예를 들어 Refresh Token은 만료됐지만 인증 서버의 세션은 아직 살아있는 경우, 사용자를 로그인 화면으로 보내면 사용자가 로그인 버튼을 누르는 순간 인증 서버가 살아있는 세션을 보고 SSO로 자동 로그인을 시켜준다. 사용자 입장에서는 “세션이 만료되었다더니 그냥 다시 로그인이 되는” 요상한 경험을 하게 된다.
표준에 따르면 prompt=login으로 세션과 무관하게 강제로 로그인 시키는 방법이 존재하나
Spring Authorization Server에서는 이를 구현하고 있지 않는 것으로 보였다.
그럼 고려해볼 수 있는 대안은 인증 서버 세션을 서버에서 직접 끊는 방법이 있을 수 있다.
그런데 표준상 end_session_endpoint는 브라우저를 통해 요청되고 리다이렉트되는 흐름이라 서버가 대신 부담하기에는 굳이 표준을 깨야하는 리스크가 있었다.
그래서 Refresh Token이 만료되면 사용자를 로그아웃 확인 화면으로 보내는 방향이 대안이 될 수 있을 것 같다.
재로그인을 원하면 재로그인, 로그아웃을 원하면 로그아웃을 선택하게 하면 최대한 표준을 지킬 수 있을 것으로 보인다.
기타 고려 사항 - 세션 클러스터링
앞서 언급했다시피 통합 인증은 기본적으로 세션에 의존하고 있는 것들이 크기 때문에 스케일 아웃시에 세션을 어떻게 관리할 것인지에 대한 고민이 반드시 필요하다. keycloak 역시 세션에 의존하는 부분이 있기 때문에 세션 클러스터링에 대한 고민 없이 스케일 아웃시 SSO가 제대로 동작하지 않을 수 있다. 이 부분에 대해서는 기회가 있다면 별도의 글로 작성할 예정이다.
마무리
서비스를 개발하다보면 보통 인증 클라이언트 쪽을 다룰 일이 더 많은데 이번 기회로 서버 쪽을 다뤄보게 되었다. 조금은 막연하게 알고 있던 OAuth2와 OIDC를 좀 더 확장해서 이해할 수 있는 기회여서 재미있게 임했던 거 같다. 서비스 운영 규모가 커진다면 세션 쪽에서 좀 더 고군분투 하게 될 것이다. 아직 공부할 것이 많다.