Files
baron-sso-sample/BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs
최준영 f178d69a99 로그아웃 시 WebView 창/에러 페이지 노출 방지 (오프스크린 end_session)
- LoginWindow.EndSessionAsync 추가: 1×1 화면 밖 WebView2로 end_session을 수행해
  로그아웃 시 창이나 네비게이션 에러 페이지(ERR_ABORTED 등)가 보이지 않게 함.
  post_logout_redirect_uri 복귀를 NavigationStarting에서 가로채 종료하고,
  복귀를 못 가로채도 timeout 후 조용히 반환.
- SsoClient.LogoutAsync: 보이는 AuthenticateAsync 대신 EndSessionAsync 사용.
- BaronSSO.SignOutAsync: CurrentUser가 null이면 파일에서 로드 후 로그아웃.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:11:14 +09:00

213 lines
9.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 인증 엔드포인트를 WebView2로 로드하고, redirect_uri로의 이동을 가로채
/// authorization code가 담긴 콜백 URL을 반환하는 로그인 창.
/// redirect_uri로 실제 페이지가 로드되기 전에 NavigationStarting에서 취소하므로,
/// localhost에 실제 서버를 띄울 필요가 없습니다.
/// </summary>
internal partial class LoginWindow : Window
{
private readonly string _authorizeUrl;
private readonly string _redirectUri;
private readonly TaskCompletionSource<string> _tcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
internal LoginWindow(string authorizeUrl, string redirectUri)
{
InitializeComponent();
_authorizeUrl = authorizeUrl;
_redirectUri = redirectUri;
Loaded += async (s, e) =>
{
try
{
await webview.EnsureCoreWebView2Async();
webview.CoreWebView2.NavigationStarting += OnNavigationStarting;
webview.CoreWebView2.Navigate(_authorizeUrl);
}
catch (Exception ex)
{
_tcs.TrySetException(ex);
Dispatcher.BeginInvoke(new Action(Close));
}
};
Closed += (_, _) => _tcs.TrySetException(new OperationCanceledException());
}
private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
{
if (e.Uri.StartsWith(_redirectUri, StringComparison.OrdinalIgnoreCase))
{
e.Cancel = true; // 실제 localhost 로딩 차단
_tcs.TrySetResult(e.Uri);
Dispatcher.BeginInvoke(new Action(Close));
}
}
/// <summary>창을 띄우고 콜백 URL(code 포함)을 비동기로 반환.</summary>
internal Task<string> ShowAndGetRedirectAsync()
{
Show();
return _tcs.Task;
}
/// <summary>
/// 호출 스레드가 MTA/STA 무엇이든, 전용 STA 스레드(자체 Dispatcher 펌프)에서
/// 로그인 창을 띄워 콜백 URL(code 또는 logout 복귀)을 비동기로 반환한다.
/// </summary>
internal static Task<string> AuthenticateAsync(string authorizeUrl, string redirectUri)
=> RunOnDedicatedUiThreadAsync(async () =>
{
var window = new LoginWindow(authorizeUrl, redirectUri);
return await window.ShowAndGetRedirectAsync();
});
/// <summary>
/// 로그인 WebView가 사용하는 프로필의 모든 쿠키(=SSO 세션 쿠키)를 삭제합니다.
/// 전용 STA 스레드에서 오프스크린 WebView2를 잠깐 띄워 동일 프로필의 쿠키를 비웁니다.
/// 이후 다음 로그인 시 세션이 없어 로그인 폼이 다시 표시됩니다.
/// </summary>
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();
}
});
/// <summary>
/// SSO 로그아웃(end_session)을 화면 밖(오프스크린) WebView2에서 수행합니다.
/// 로그인 창과 달리 1×1 화면 밖 창을 쓰므로, 사용자에게 WebView 창이나
/// 네비게이션 에러 페이지(ERR_ABORTED 등)가 일절 보이지 않습니다.
/// end_session_endpoint를 로드하고 post_logout_redirect_uri로의 복귀를 가로채 종료하며,
/// 복귀를 끝내 가로채지 못해도(서버/네트워크 문제) timeoutMs 후 조용히 반환합니다.
/// </summary>
internal static Task EndSessionAsync(
string endSessionUrl, string postLogoutRedirectUri, int timeoutMs = 10000)
=> RunOnDedicatedUiThreadAsync(async () =>
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
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 확보(화면 밖)
void OnNavStarting(object? s, CoreWebView2NavigationStartingEventArgs e)
{
if (e.Uri.StartsWith(postLogoutRedirectUri, StringComparison.OrdinalIgnoreCase))
{
e.Cancel = true; // 복귀 주소 실제 로딩 차단
tcs.TrySetResult(true);
}
}
try
{
await web.EnsureCoreWebView2Async();
web.CoreWebView2.NavigationStarting += OnNavStarting;
web.CoreWebView2.Navigate(endSessionUrl);
// 복귀를 가로채면 즉시 완료, 못 가로채도 timeout 후 조용히 종료한다.
await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs));
}
catch
{
// 오프스크린이라 어떤 네비게이션 에러도 사용자에게 노출되지 않는다. 조용히 무시.
}
finally
{
web.Dispose();
holder.Close();
}
});
/// <summary>
/// 호출 스레드(MTA/STA)와 무관하게, 전용 STA 스레드에서 자체 Dispatcher 메시지펌프를 돌려
/// WPF/WebView2 UI 작업을 실행하고 그 결과를 Task로 반환한다.
/// (WPF 창은 STA + 메시지펌프가 필요하므로, MTA/Task.Run/콘솔에서도 안전하게 동작)
/// </summary>
internal static Task<T> RunOnDedicatedUiThreadAsync<T>(Func<Task<T>> work)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
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<Task> work)
=> RunOnDedicatedUiThreadAsync<bool>(async () => { await work(); return true; });
}
}