diff --git a/AuthTest/App.xaml b/AuthTest/App.xaml new file mode 100644 index 0000000..4cd93b6 --- /dev/null +++ b/AuthTest/App.xaml @@ -0,0 +1,10 @@ + + + + + diff --git a/AuthTest/App.xaml.cs b/AuthTest/App.xaml.cs new file mode 100644 index 0000000..983332d --- /dev/null +++ b/AuthTest/App.xaml.cs @@ -0,0 +1,31 @@ +using BaronSoftware.SSO; +using System.Configuration; +using System.Data; +using System.Threading.Tasks; +using System.Windows; + +namespace AuthTest +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + BaronSSOOption option = new() + { + Authority = "https://sso.hmac.kr/oidc", + ClientId = "aca44872-8280-40c3-9a80-3aefafdf722a", + RedirectUri = "http://localhost:9090/eg-bim/auth/callback", + PostLogoutRedirectUri = "http://localhost:9090/eg-bim/logout/callback", + ExtraUserValidator = new ExtraUserInvalidator() + }; + var auth = new BaronSSO(option); + auth.SignIn(); + } + } + +} diff --git a/AuthTest/AssemblyInfo.cs b/AuthTest/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/AuthTest/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/AuthTest/AuthTest.csproj b/AuthTest/AuthTest.csproj new file mode 100644 index 0000000..4a4e51e --- /dev/null +++ b/AuthTest/AuthTest.csproj @@ -0,0 +1,15 @@ + + + + WinExe + net8.0-windows + enable + enable + true + + + + + + + diff --git a/AuthTest/ExtraUserInvalidator.cs b/AuthTest/ExtraUserInvalidator.cs new file mode 100644 index 0000000..456ca5a --- /dev/null +++ b/AuthTest/ExtraUserInvalidator.cs @@ -0,0 +1,16 @@ +using BaronSoftware.SSO; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AuthTest +{ + public class ExtraUserInvalidator : IUserValidator + { + public void Validate(UserInfo user) + { + } + } +} diff --git a/AuthTest/MainWindow.xaml b/AuthTest/MainWindow.xaml new file mode 100644 index 0000000..669c1d9 --- /dev/null +++ b/AuthTest/MainWindow.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/AuthTest/MainWindow.xaml.cs b/AuthTest/MainWindow.xaml.cs new file mode 100644 index 0000000..5124d38 --- /dev/null +++ b/AuthTest/MainWindow.xaml.cs @@ -0,0 +1,24 @@ +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace AuthTest +{ + /// + /// Interaction logic for MainWindow.xaml + /// + public partial class MainWindow : Window + { + public MainWindow() + { + InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/BaronSoftware.SSO.sln b/BaronSoftware.SSO.sln index b477436..360dc8d 100644 --- a/BaronSoftware.SSO.sln +++ b/BaronSoftware.SSO.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaronSoftware.SSO", "BaronS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaronSoftware.SSO.Sample", "BaronSoftware.SSO.Sample\BaronSoftware.SSO.Sample.csproj", "{6F80A264-7099-3F9E-64A3-89E569A3E7A4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthTest", "AuthTest\AuthTest.csproj", "{CC253CA9-0F0E-43D9-80D5-CEC2A181E889}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {6F80A264-7099-3F9E-64A3-89E569A3E7A4}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F80A264-7099-3F9E-64A3-89E569A3E7A4}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F80A264-7099-3F9E-64A3-89E569A3E7A4}.Release|Any CPU.Build.0 = Release|Any CPU + {CC253CA9-0F0E-43D9-80D5-CEC2A181E889}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC253CA9-0F0E-43D9-80D5-CEC2A181E889}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC253CA9-0F0E-43D9-80D5-CEC2A181E889}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC253CA9-0F0E-43D9-80D5-CEC2A181E889}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/BaronSoftware.SSO/BaronSSO.cs b/BaronSoftware.SSO/BaronSSO.cs index dc24e49..9bbc12e 100644 --- a/BaronSoftware.SSO/BaronSSO.cs +++ b/BaronSoftware.SSO/BaronSSO.cs @@ -24,16 +24,42 @@ namespace BaronSoftware.SSO public void SignIn() { - // STA Thread 이슈로 인해, Task.Run() 으로 감싸서 동기화 함수 구현. - Task.Run(async () => await SignInAsync()).Wait(); + // UI 스레드에서 호출돼도 데드락이 없도록 Task.Run(스레드풀)에서 실행한다. + // (실제 WebView 창은 SsoClient가 전용 STA 스레드에서 띄우므로 STA/MTA 무관하게 동작) + // .GetAwaiter().GetResult()로 원본 예외를 그대로 전파한다(AggregateException 래핑 방지). + Task.Run(() => SignInAsync()).GetAwaiter().GetResult(); } public void SignOut() { - Task.Run(async () => await SignOutAsync()).Wait(); + 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; @@ -43,6 +69,13 @@ namespace BaronSoftware.SSO await SignInAsync(user?.RefreshToken); } + /// + /// refresh_token 으로 무창 인증을 시도하고, 불가하면 웹뷰 로그인으로 진행합니다. (비동기) + /// + /// + /// UI 스레드에서 .Wait()/.Result 로 블로킹하면 데드락입니다. 자세한 이유는 + /// 의 설명을 참고하세요. (UI 스레드면 await, 동기 호출은 ) + /// public async Task SignInAsync(string refreshToken) { UserInfo user = null; diff --git a/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml b/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml index 2a02b3a..1e7df0c 100644 --- a/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml +++ b/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml @@ -4,7 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf" Title="BARON 로그인" Height="760" Width="520" - WindowStartupLocation="CenterOwner"> + WindowStartupLocation="CenterScreen"> diff --git a/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs b/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs index 4f95706..3cb1806 100644 --- a/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs +++ b/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs @@ -1,5 +1,7 @@ using Microsoft.Web.WebView2.Core; +using System.Threading; using System.Windows; +using System.Windows.Threading; using WebView2 = Microsoft.Web.WebView2.Wpf.WebView2; namespace BaronSoftware.SSO @@ -58,41 +60,96 @@ namespace BaronSoftware.SSO return _tcs.Task; } + /// + /// 호출 스레드가 MTA/STA 무엇이든, 전용 STA 스레드(자체 Dispatcher 펌프)에서 + /// 로그인 창을 띄워 콜백 URL(code 또는 logout 복귀)을 비동기로 반환한다. + /// + internal static Task AuthenticateAsync(string authorizeUrl, string redirectUri) + => RunOnDedicatedUiThreadAsync(async () => + { + var window = new LoginWindow(authorizeUrl, redirectUri); + return await window.ShowAndGetRedirectAsync(); + }); + /// /// 로그인 WebView가 사용하는 프로필의 모든 쿠키(=SSO 세션 쿠키)를 삭제합니다. - /// 화면에 보이지 않는 오프스크린 WebView2를 잠깐 띄워 동일 프로필의 쿠키를 비웁니다. + /// 전용 STA 스레드에서 오프스크린 WebView2를 잠깐 띄워 동일 프로필의 쿠키를 비웁니다. /// 이후 다음 로그인 시 세션이 없어 로그인 폼이 다시 표시됩니다. /// - internal static async Task ClearSessionCookiesAsync() + internal static Task ClearSessionCookiesAsync() + => RunOnDedicatedUiThreadAsync(async () => + { + var holder = new Window + { + Width = 1, + Height = 1, + Left = -32000, + Top = -32000, + WindowStyle = WindowStyle.None, + ShowInTaskbar = false, + ShowActivated = false, + Title = string.Empty, + }; + var web = new WebView2(); + holder.Content = web; + holder.Show(); // WebView2 초기화에 필요한 HWND 확보(화면 밖) + try + { + await web.EnsureCoreWebView2Async(); + // DeleteAllCookies()는 즉시 반환이라, 디스크 반영 전에 Dispose되면 쿠키가 남아 + // 다음 로그인이 SSO로 조용히 통과될 수 있다. 완료까지 await하는 ClearBrowsingDataAsync로 + // SSO 세션 쿠키/사이트 데이터를 확실히 제거한다. + await web.CoreWebView2.Profile.ClearBrowsingDataAsync( + CoreWebView2BrowsingDataKinds.AllSite); + } + finally + { + web.Dispose(); + holder.Close(); + } + }); + + /// + /// 호출 스레드(MTA/STA)와 무관하게, 전용 STA 스레드에서 자체 Dispatcher 메시지펌프를 돌려 + /// WPF/WebView2 UI 작업을 실행하고 그 결과를 Task로 반환한다. + /// (WPF 창은 STA + 메시지펌프가 필요하므로, MTA/Task.Run/콘솔에서도 안전하게 동작) + /// + internal static Task RunOnDedicatedUiThreadAsync(Func> work) { - var holder = new Window + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var thread = new Thread(() => { - Width = 1, - Height = 1, - Left = -32000, - Top = -32000, - WindowStyle = WindowStyle.None, - ShowInTaskbar = false, - ShowActivated = false, - Title = string.Empty, - }; - var web = new WebView2(); - holder.Content = web; - holder.Show(); // WebView2 초기화에 필요한 HWND 확보(화면 밖) - try - { - await web.EnsureCoreWebView2Async(); - // DeleteAllCookies()는 즉시 반환이라, 디스크 반영 전에 Dispose되면 쿠키가 남아 - // 다음 로그인이 SSO로 조용히 통과될 수 있다. 완료까지 await하는 ClearBrowsingDataAsync로 - // SSO 세션 쿠키/사이트 데이터를 확실히 제거한다. - await web.CoreWebView2.Profile.ClearBrowsingDataAsync( - CoreWebView2BrowsingDataKinds.AllSite); - } - finally - { - web.Dispose(); - holder.Close(); - } + try + { + // 이 전용 스레드에 Dispatcher 기반 SynchronizationContext를 설치 → await 연속이 같은 스레드로 복귀 + SynchronizationContext.SetSynchronizationContext( + new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher)); + + work().ContinueWith(t => + { + if (t.IsFaulted) tcs.TrySetException(t.Exception!.InnerExceptions); + else if (t.IsCanceled) tcs.TrySetCanceled(); + else tcs.TrySetResult(t.Result); + + Dispatcher.CurrentDispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + }, TaskScheduler.FromCurrentSynchronizationContext()); + + Dispatcher.Run(); // 메시지펌프 시작 (작업 완료 시 InvokeShutdown으로 종료) + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + + thread.IsBackground = true; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return tcs.Task; } + + internal static Task RunOnDedicatedUiThreadAsync(Func work) + => RunOnDedicatedUiThreadAsync(async () => { await work(); return true; }); } } diff --git a/BaronSoftware.SSO/GlobalConfigs.cs b/BaronSoftware.SSO/GlobalConfigs.cs index f1afd0b..9d327b9 100644 --- a/BaronSoftware.SSO/GlobalConfigs.cs +++ b/BaronSoftware.SSO/GlobalConfigs.cs @@ -4,7 +4,6 @@ namespace BaronSoftware.SSO { internal static class GlobalConfigs { - internal static readonly string CenterTanant_UUID = "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"; internal static readonly string FamilyTanant_UUID = "038326b6-954a-48a7-a85f-efd83f62b82a"; diff --git a/BaronSoftware.SSO/Models/UserInfo.cs b/BaronSoftware.SSO/Models/UserInfo.cs index 5dc7ceb..115a186 100644 --- a/BaronSoftware.SSO/Models/UserInfo.cs +++ b/BaronSoftware.SSO/Models/UserInfo.cs @@ -7,28 +7,53 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace BaronSoftware.SSO { public class UserInfo { + // System.Text.Json은 기본적으로 private setter에 역직렬화하지 못한다. + // [JsonInclude]를 붙여야 FromSsoFile()의 Deserialize가 private set 프로퍼티를 복원한다. + [JsonInclude] public string UUID { get; private set; } + + [JsonInclude] public string Name { get; private set; } + + [JsonInclude] public string Email { get; private set; } + + [JsonInclude] public string[] SubEmails { get; private set; } + + [JsonInclude] public string RefreshToken { get; private set; } + + [JsonInclude] public string TenantId { get; private set; } /// 'tenants' 클레임 안에 등장하는 모든 테넌트 id 목록(상위/조상 테넌트 포함, 중복 제거). + [JsonInclude] public string[] AllTenantIds { get; private set; } + [JsonInclude] public long LastAuthUnixTimeStamp { get; private set; } + + // 계산형 프로퍼티(setter 없음) — 파일에 저장/복원할 필요 없으므로 직렬화 제외 + [JsonIgnore] public DateTime LastAuthTime => DateTimeOffset.FromUnixTimeSeconds(LastAuthUnixTimeStamp).LocalDateTime; + [JsonInclude] public string IdToken { get; private set; } + + [JsonInclude] public string Raw { get; private set; } + [JsonInclude] public string RawTokenResponse { get; private set; } + + [JsonInclude] public Dictionary Claims { get; private set; } internal static UserInfo FromJson(string rawjson, TokenResponse reposeToken) diff --git a/BaronSoftware.SSO/OIDC/SsoClient.cs b/BaronSoftware.SSO/OIDC/SsoClient.cs index 5e1868a..22d699e 100644 --- a/BaronSoftware.SSO/OIDC/SsoClient.cs +++ b/BaronSoftware.SSO/OIDC/SsoClient.cs @@ -41,11 +41,8 @@ namespace BaronSoftware.SSO $"&code_challenge={PKCEUtil.Challenge(verifier)}&code_challenge_method=S256" + $"&state={state}&nonce={nonce}"; - var window = new LoginWindow(authorizeUrl, options.RedirectUri); - if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, window)) - window.Owner = owner; - - var redirected = await window.ShowAndGetRedirectAsync(); + // 전용 STA 스레드에서 로그인 창을 띄운다(호출 스레드가 MTA/STA 무엇이든 동작). + var redirected = await LoginWindow.AuthenticateAsync(authorizeUrl, options.RedirectUri); var q = PKCEUtil.ParseQuery(redirected); if (q.TryGetValue("error", out var error)) @@ -124,13 +121,10 @@ namespace BaronSoftware.SSO $"&post_logout_redirect_uri={Uri.EscapeDataString(options.PostLogoutRedirectUri)}" + $"&state={PKCEUtil.RandomToken()}"; - var window = new LoginWindow(url, options.PostLogoutRedirectUri); - if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, window)) - window.Owner = owner; - try { - await window.ShowAndGetRedirectAsync(); + // 전용 STA 스레드에서 로그아웃(end_session) 창을 띄운다. + await LoginWindow.AuthenticateAsync(url, options.PostLogoutRedirectUri); } catch (OperationCanceledException) {