SSO는 ‘한 번 로그인’ 그 이상이다.
출처: Dev.to
실제 아이덴티티, 세션, OAuth 2.0, OpenID Connect, 그리고 테넌트 격리를 살펴보는 실용적인 안내입니다.
싱글 사인온(SSO)은 흔히 “한 번 로그인하고 여러 애플리케이션에 접근한다”라고 요약됩니다. 이는 맞지만 전체를 설명하지는 못합니다. 진정한 SSO 시스템은 다음과 같은 보안 질문에도 답해야 합니다.
- 어떤 애플리케이션이 접근을 요청하고 있나요?
- 그 애플리케이션을 소유한 조직은 어디인가요?
- 사용자가 해당 애플리케이션을 사용할 권한이 있나요?
- 사용자가 공유를 허용한 정보는 무엇인가요?
- 애플리케이션은 받은 토큰을 어떻게 신뢰하나요?
SSO 흐름에는 세 가지 주요 주체가 있습니다.
- 엔드 유저 – 로그인하려는 사람
- 클라이언트 애플리케이션 – 사용자의 아이덴티티가 필요한 서비스
- 아이덴티티 제공자(IdP) – 사용자를 인증하고 토큰을 발급하는 주체
클라이언트 애플리케이션은 사용자의 비밀번호를 절대 받지 않습니다. 대신 브라우저를 IdP로 리다이렉트하고, IdP는 인증을 수행한 뒤 짧은 수명의 인가 코드(authorization code)를 애플리케이션에 전달합니다. 이 분리는 기본적인 원칙이며, 애플리케이션은 인증을 하나의 신뢰할 수 있는 IdP에 위임합니다.
보안 구현은 OAuth 2.0 Authorization Code Flow에 OpenID Connect와 PKCE를 결합해 사용할 수 있습니다. 흐름은 다음과 같습니다.
- 클라이언트가
client_id, 콜백 URL, 요청 스코프,state, 그리고S256PKCE 챌린지를 포함해 브라우저를 IdP로 리다이렉트합니다. - IdP는 클라이언트와 콜백 URL을 검증한 뒤 로그인 페이지를 보여줍니다.
- IdP는 등록된 클라이언트에서 테넌트를 파악하므로, 테넌트 컨텍스트는 신뢰할 수 없는 사용자 입력에서 유래하지 않습니다.
- IdP는 검증된 요청을 짧은 수명의 인가 트랜잭션으로 저장하고, 프론트엔드에는 불투명한 트랜잭션 ID만 전달합니다.
- 사용자가 로그인합니다. IdP는 테넌트 범위 계정, 비밀번호, 계정 상태, 그리고 해당 클라이언트에 대한 역할을 확인합니다.
- 사용자는 동의 화면에서 요청된 접근을 허용하거나 거부합니다.
- 허용되면 IdP는 등록된 콜백으로 짧은 수명·단일 사용 인가 코드를 반환합니다.
- 클라이언트는 이 코드와 PKCE 검증자를 사용해 ID 토큰, 액세스 토큰, 리프레시 토큰을 교환합니다.
- ID 토큰은 누가 인증했는지를 클라이언트에 알려줍니다.
- 액세스 토큰은 API 접근을 허가합니다.
- 리프레시 토큰은 사용자를 다시 로그인시키지 않고 새로운 짧은 수명의 액세스 토큰을 발급받을 수 있게 합니다.
재사용 가능한 부분은 아이덴티티 제공자 세션입니다. 로그인 후 IdP는 불투명한 서버‑사이드 브라우저 세션을 만들고, 그 식별자를 HostOnly, HttpOnly, SameSite=Lax 쿠키에 저장합니다. 이후 인가 요청에서는 같은 세션을 재사용할 수 있으며, 동일 테넌트 내 다른 등록 애플리케이션에도 적용됩니다.
하지만 인증을 재사용한다고 해서 자동으로 접근이 허가되는 것은 아닙니다. IdP는 여전히 다음을 확인합니다.
- 세션이 해당 클라이언트의 테넌트에 속하는가?
- 사용자가 아직 활성 상태인가?
- 사용자가 요청 클라이언트에 대한 역할을 여전히 가지고 있는가?
- 현재 요청이
prompt=login혹은max_age와 같은 규칙을 만족하는가?
이것이 중요한 SSO 원칙을 보여줍니다.
인증은 공유될 수 있지만, 인가는 각 애플리케이션마다 평가되어야 한다.
사용자는 IdP에 로그인했더라도 특정 클라이언트에 대한 접근이 거부될 수 있습니다.
멀티 테넌트 플랫폼에서는 동일한 SSO 서비스가 여러 조직을 지원합니다. 따라서 **격리(isolation)**는 인증 자체의 일부이며, 단순히 데이터베이스 수준의 문제가 아닙니다. 멀티 테넌트 구현에서는 사용자, OAuth 클라이언트, 역할, 세션, 토큰을 모두 테넌트와 연관시켜야 합니다. client_id는 인가 과정에서 테넌트를 결정하고, 발급된 토큰에는 tenant_id와 client_id 클레임이 모두 포함될 수 있습니다. 이렇게 하면 테넌트 A의 유효한 아이덴티티가 테넌트 B가 소유한 애플리케이션에 조용히 적용되는 것을 방지합니다.
흐름을 보호하는 작은 값들
- PKCE – 인가 요청을 최초 시작한 클라이언트와 바인딩합니다. 가로채진 코드도 원본 검증자 없이는 무용지물입니다.
- state – 클라이언트가 위조되거나 불일치하는 콜백을 감지하게 합니다.
- 등록된 리디렉션 URI 매칭 – IdP가 공격자가 제어하는 목적지로 코드를 보내는 것을 방지합니다.
- 단일 사용 인가 코드 – 재사용 위험을 감소시킵니다.
- nonce와 authentication time – ID 토큰에 OpenID Connect 컨텍스트를 보존합니다.
S256 PKCE는 SPA, 모바일 앱 같은 공개 클라이언트와 서버‑사이드 비밀 클라이언트 모두를 보호해야 합니다. 비밀 클라이언트는 해시된 비밀을 저장하고, 생성·갱신 시에만 노출할 수 있습니다.
아이덴티티 제공자는 RS256으로 ID 토큰과 액세스 토큰에 서명합니다. 애플리케이션은 JWKS 엔드포인트에서 공개 키를 받아 토큰 서명을 검증하므로, 개인 서명 키를 직접 받을 필요가 없습니다.
검증은 서명 검증을 넘어 다음을 포함해야 합니다.
- 발행자(issuer)
- 대상(audience)
- 만료(expiration)
- 테넌트(tenant)
- 기대 nonce
그 후 역할과 스코프를 활용해 애플리케이션 수준 인가를 수행할 수 있습니다.
액세스 토큰은 의도적으로 짧은 수명을 갖습니다. 리프레시 토큰은 더 오래 유지되며, 해시 형태로 저장되고, 사용 시 회전(rotating)되며, 필요 시 폐기(revoke)될 수 있습니다. 이는 보안을 강화하면서도 부드러운 사용자 경험을 유지합니다.
실용적인 구현 예시 (Clean Architecture)
- 도메인 엔티티 – 클라이언트, 사용자, 역할, 코드, 토큰, 세션을 정의
- 유스케이스 – 인가, 로그인, 동의, 토큰 교환, 토큰 갱신, 로그아웃 구현
- PostgreSQL – 영구적인 아이덴티티 및 설정 데이터 저장
- Redis – 짧은 수명의 트랜잭션, 세션, 코드, 스로틀링 카운터 저장
- HTTP 핸들러 – 프로토콜 요청을 유스케이스 호출로 변환
이러한 분리 덕분에 OAuth 규칙을 특정 웹 프레임워크나 저장소 기술과 독립적으로 유지할 수 있어, 보안 동작을 테스트하기가 쉬워집니다.
SSO는 단순히 공유 로그인 폼이 아닙니다. 아이덴티티, 조직, 애플리케이션, 브라우저 세션, 동의, 그리고 암호학적으로 검증 가능한 토큰을 연결하는 신뢰 시스템입니다.
- 한 번 인증하고, 클라이언트별 접근을 검증하며, 테넌트 경계를 명시적으로 유지하고, 서버가 검증한 프로토콜 상태만을 신뢰하십시오.
- 이것이 “한 번 로그인”을 편리한 기능에서 안전한 아이덴티티 아키텍처로 전환시키는 핵심입니다.
아래 링크를 통해 이 SSO 구현과 관련된 소스 코드를 공유합니다.
Source code: Keyles