namespace BaronSoftware.SSO { /// /// baron-sso(Ory Hydra · OIDC PKCE)로 로그인하고 사용자 정보를 보관하는 바론의 인증모듈. /// public class BaronSSO { private readonly SsoClient client; private readonly BaronSSOOption option; public UserInfo CurrentUser { get; private set; } public BaronSSO(BaronSSOOption option) { if(option == null) throw new ArgumentNullException(nameof(option)); if (!string.IsNullOrWhiteSpace(option.Authority)) GlobalConfigs.SsoUri = option.Authority; client = SsoClient.Create(option); this.option = option; } public void SignIn() { // UI 스레드에서 호출돼도 데드락이 없도록 Task.Run(스레드풀)에서 실행한다. // (실제 WebView 창은 SsoClient가 전용 STA 스레드에서 띄우므로 STA/MTA 무관하게 동작) // .GetAwaiter().GetResult()로 원본 예외를 그대로 전파한다(AggregateException 래핑 방지). Task.Run(() => SignInAsync()).GetAwaiter().GetResult(); } public void SignOut() { Task.Run(() => SignOutAsync()).GetAwaiter().GetResult(); } /// /// 웹뷰 로그인 창을 띄워 인증합니다. (비동기) /// /// /// ⚠️ 절대 UI 스레드에서 .Wait() / .Result 로 블로킹하지 마세요. 데드락이 발생합니다. /// /// [데드락이 나는 이유 — sync-over-async] /// 1) WPF UI 스레드에는 DispatcherSynchronizationContext 가 설치돼 있습니다. /// 2) 이 메서드를 UI 스레드에서 호출하면, 내부의 첫 await /// (CheckConnection → GetDiscoveryAsync → httpclient.GetStringAsync 등)에서 /// ConfigureAwait(false) 를 쓰지 않으므로 "연속(continuation)을 UI 스레드로 되돌려" 실행하도록 /// 컨텍스트를 캡처합니다. /// 3) 그런데 호출 측이 .Wait() 로 UI 스레드를 막으면 메시지펌프가 멈춥니다. /// 4) await 대상(HTTP 등)이 스레드풀에서 끝나고 연속을 UI 디스패처 큐에 post 하지만, /// UI 스레드가 막혀 있어 그 큐를 영원히 처리하지 못합니다 → Task 미완료 → .Wait() 무한 대기 → 데드락. /// (로그인 창에 도달하기 전, 첫 HTTP await 에서 이미 멈춥니다.) /// /// [올바른 사용법] /// • UI 스레드라면 블로킹하지 말고 await 하세요: await auth.SignInAsync(); /// • 동기적으로 써야 하면 동기 진입점 을 쓰세요. /// SignIn() 은 Task.Run(() => SignInAsync()).GetAwaiter().GetResult() 로, /// SignInAsync 를 SynchronizationContext 가 없는 스레드풀 스레드에서 실행하므로 /// 연속이 UI 스레드로 되돌아올 필요가 없어 데드락이 발생하지 않습니다. /// (실제 WebView 로그인 창은 SsoClient 가 전용 STA 스레드에서 띄우므로 STA/MTA 무관하게 동작합니다.) /// public async Task SignInAsync() { UserInfo? user = null; if (option.EnableAutoLogin) user = UserInfo.FromSsoFile(); await SignInAsync(user?.RefreshToken); } /// /// refresh_token 으로 무창 인증을 시도하고, 불가하면 웹뷰 로그인으로 진행합니다. (비동기) /// /// /// UI 스레드에서 .Wait()/.Result 로 블로킹하면 데드락입니다. 자세한 이유는 /// 의 설명을 참고하세요. (UI 스레드면 await, 동기 호출은 ) /// public async Task SignInAsync(string refreshToken) { UserInfo user = null; var isConnected = await CheckConnection(); if (isConnected) { var token = await client.LoginAsync(refreshToken); var userJson = await client.GetUserInfoAsync(token.AccessToken); if (string.IsNullOrEmpty(userJson)) throw new InvalidOperationException($"Failed to get userinfo : {token.ToString()}"); user = UserInfo.FromJson(userJson, token); if (user == null) throw new InvalidUserException($"Broken user token. Token {token.ToString()}, UserJson {userJson}"); } else { if (!option.EnableOffline) throw new InvalidOperationException("Network isn't available."); user = UserInfo.FromSsoFile(); if (user == null) throw new InvalidUserException("Not found sso data for offline."); } // 가족사가 아니고, 인증도 실패 시 if (user.IsFamily()) option?.FamilyValidator?.Validate(user); else option?.ExtraUserValidator?.Validate(user); CurrentUser = user; CurrentUser.Save(); } /// /// 로그아웃: 현재 사용자 정보 + 저장된 refresh_token + WebView SSO 세션 쿠키를 모두 삭제합니다. /// 세션 쿠키까지 지우므로 다음 로그인 시 로그인 창이 다시 표시됩니다. /// public async Task SignOutAsync() { try { if (CurrentUser == null) CurrentUser = UserInfo.FromSsoFile(); //SSO 서버 세션 종료(RP-Initiated Logout) + 로컬 WebView 세션 쿠키 정리 UserInfo.Clear(); await client.LogoutAsync(CurrentUser?.IdToken); } catch { // 로그아웃 실패해도 로컬 사용자 정보는 이미 삭제했으므로 로컬 로그아웃으로 간주. } } private async Task CheckConnection() { try { await client.GetDiscoveryAsync(); return true; } catch { return false; } } } }