예전에 회사에서 Streamlit을 어떻게든 프로덕션 환경에 맞게 사용해보려고 여러 시도를 했습니다. 그 과정에서 로그인 기능을 구현하기 위해 streamlit-authenticator 라이브러리의 OAuth 기능을 적용해봤지만, 쿠키 확인 과정에서 속도가 느려 결국 포기했었습니다.
그런데 최근 다시 살펴보니, st.login을 이용한 OAuth 기능이 공식적으로 추가됐었네요.
인증/인가 개념도 다시 복습할 겸, 이번에는 이 기능이 어떻게 동작하는지 내부 코드를 직접 분석해 보았습니다.
(Streamlit OAuth 사용 방법은 여기를 참고해주세요)
동작 방식을 이해하기 위해서는 Streamlit 웹 서버 구조부터 살펴봐야 합니다.
[ Tornado Server ]
│
┌────┴────┐
│ Runtime │ ← 앱 전체 관리, 세션 생성·삭제
└────┬────┘
│
┌─────┴─────┐
│ Session #1│ ← 사용자 A의 UI/상태/코드 실행
│ Session #2│ ← 사용자 B의 UI/상태/코드 실행
│ Session #3│ ← 사용자 C의 UI/상태/코드 실행
Streamlit의 웹 서버는 Tornado 기반 WebSocket 서버로 구현되어 있습니다. streamlit run main.py
CLI를 실행하면 웹 서버가 구동되고, Runtime이 생성됩니다. 사용자가 baseUrl로 접속하면 Runtime은 세션을 새로 생성하고, 해당 세션에서 사용자의 상태를 관리합니다.
사용자 event가 들어오면
1. 세션은 ScriptRunner 객체를 생성하고,
2. 이 객체가 별도의 Script Thread를 만들게 됩니다.
3. Python 스크립트 실행에 필요한 내용을 ScriptRunContext에 담아 Script Thread에 전달하고,
4. 해당 Script Thread에서 Python 스크립트를 exec로 실행하게 됩니다
st.login은 OAuth 2.0 인증 프로세스를 수행하고, 인증 결과로 얻은 user_info를 cookie_secret 키로 암호화해 브라우저 쿠키에 저장합니다. 이후 baseUrl로 리다이렉트합니다.
OAuth2.0 인증 프로세스
- st.login() 실행 시 secrets.toml에서 OIDC provider 정보를 읽어 URL을 생성하고 리다이렉트합니다.
- redirect_url:
https://{baseUrlPath}/auth/login?provider={provider_token}
- provider_token: provider 정보와 만료 시간을 cookie_secret 키로 암호화한 JWT
- 웹 서버의 AuthLoginHandler가 요청을 받아 provider 정보를 파싱한 후, 실제 로그인 페이지 URL로 리다이렉트합니다.
- 예시 구글 인증 URL:
https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id={client_id}.apps.googleusercontent.com&redirect_uri={redirect_url}&scope=openid+email+profile&state={state}&nonce={nonce}&code_challenge={code_challenge}&code_challenge_method=S256&prompt=select_account
- 사용자가 인증을 완료하면 Authorization Server가 사전에 등록한 redirect URL로 Authorization Code를 보냅니다.
- 웹 서버의 AuthCallbackHandler가 authorization code를 받아, authorization server에 Access Token과 ID Token을 요청합니다.
- secret.toml의 server_metadata_url에서 jwks_uri를 얻고, 해당 URI로 통해 JWT키를 얻습니다. 얻은 JWT 키로 id_token을 디코딩해 user_info를 생성합니다.
그 외 특징으로는...
• user_info는 기본적으로 30일 동안 쿠키에 저장됩니다.
• st.context.cookies.to_dict()로 쿠키를 확인하면, _streamlit_user 키에 암호화된 값이 있고 이를 디코딩하면 user_info를 얻을 수 있습니다.
• access_token과 id_token은 쿠키나 다른 저장소에 보관되지 않습니다.
• OAuth 요청 시 전달되는 매개변수가 제한적이어서 Refresh Token 요청은 지원되지 않는 것으로 보입니다.
튜토리얼 내용대로, st.user.to_dict()
을 통해서 user_info를 확인할 수 있습니다. 위의 st.login 과정을 보면 쿠키 복호화로 user_info를 얻을 것 같지만, 실제로는 Script Thread에서 ScriptRunContext를 통해 가져오게 됩니다.
# streamlit/runtime/scriptrunner_utils/script_run_context.py#L258
def get_script_run_ctx(suppress_warning: bool = False) -> ScriptRunContext | None:
thread = threading.current_thread()
ctx: ScriptRunContext | None = getattr(thread, SCRIPT_RUN_CONTEXT_ATTR_NAME, None)
...
return ctx
# streamlit/user_info.py#L358
def _get_user_info() -> UserInfo:
ctx = _get_script_run_ctx()
...
context_user_info = ctx.user_info.copy()
...
return context_user_info
세션이 생성될 때, Runtime은 쿠키에서 user_info를 복화하여 세션에 저장하게 됩니다. 그리고 세션에서 ScriptRunner를 생성할 때, user_info를 전달하게 되고 ScriptRunContext에서 user_info를 참조할 수 있게 됩니다.
이러한 구조 덕분에 사용자 브라우저에 쿠키가 남아 있으면, 새로고침을 하거나 다른 브라우저 탭을 열어도 사용자 정보를 확인할 수 있게 됩니다.
st.logout은 훨씬 단순합니다. https://{baseUrlPath}/auth/logout
로 리다이렉트하고, 웹 서버에서는 사용자 브라우저의 인증 쿠키를 삭제하고 baseUrl로 리다이렉트합니다.
인증 쿠키가 삭제되면서 새로운 세션과 ScriptRunContext에서도 사용자 정보를 얻을 수 없게 됩니다.
만약 st.session_state에 사용자 정보를 백업시키더라도, 리다이렉트할 때 새로운 session이 시작되기 때문에 백업된 사용자 정보까지 확인할 수 없게 됩니다.
OAuth 개념만 보면 단순해 보였는데, 역시 실제 구현은 생각보다 복잡했습니다.
streamlit도 아쉬운 점은 많이 있는 듯합니다.
보안 강화를 위해 사용자 접속 정보를 DB에 저장하고, 일정 시간 후 자동 로그아웃 처리하거나 Refresh Token을 직접 구현하는 방법을 고려해야할 듯합니다.