- 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>
151 lines
6.4 KiB
C#
151 lines
6.4 KiB
C#
namespace BaronSoftware.SSO
|
|
{
|
|
/// <summary>
|
|
/// baron-sso(Ory Hydra · OIDC PKCE)로 로그인하고 사용자 정보를 보관하는 바론의 인증모듈.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 웹뷰 로그인 창을 띄워 인증합니다. (비동기)
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ 절대 UI 스레드에서 <c>.Wait()</c> / <c>.Result</c> 로 블로킹하지 마세요. 데드락이 발생합니다.
|
|
///
|
|
/// [데드락이 나는 이유 — sync-over-async]
|
|
/// 1) WPF UI 스레드에는 DispatcherSynchronizationContext 가 설치돼 있습니다.
|
|
/// 2) 이 메서드를 UI 스레드에서 호출하면, 내부의 첫 <c>await</c>
|
|
/// (CheckConnection → GetDiscoveryAsync → httpclient.GetStringAsync 등)에서
|
|
/// ConfigureAwait(false) 를 쓰지 않으므로 "연속(continuation)을 UI 스레드로 되돌려" 실행하도록
|
|
/// 컨텍스트를 캡처합니다.
|
|
/// 3) 그런데 호출 측이 <c>.Wait()</c> 로 UI 스레드를 막으면 메시지펌프가 멈춥니다.
|
|
/// 4) await 대상(HTTP 등)이 스레드풀에서 끝나고 연속을 UI 디스패처 큐에 post 하지만,
|
|
/// UI 스레드가 막혀 있어 그 큐를 영원히 처리하지 못합니다 → Task 미완료 → .Wait() 무한 대기 → 데드락.
|
|
/// (로그인 창에 도달하기 전, 첫 HTTP await 에서 이미 멈춥니다.)
|
|
///
|
|
/// [올바른 사용법]
|
|
/// • UI 스레드라면 블로킹하지 말고 await 하세요: <c>await auth.SignInAsync();</c>
|
|
/// • 동기적으로 써야 하면 동기 진입점 <see cref="SignIn"/> 을 쓰세요.
|
|
/// SignIn() 은 <c>Task.Run(() => SignInAsync()).GetAwaiter().GetResult()</c> 로,
|
|
/// SignInAsync 를 SynchronizationContext 가 없는 스레드풀 스레드에서 실행하므로
|
|
/// 연속이 UI 스레드로 되돌아올 필요가 없어 데드락이 발생하지 않습니다.
|
|
/// (실제 WebView 로그인 창은 SsoClient 가 전용 STA 스레드에서 띄우므로 STA/MTA 무관하게 동작합니다.)
|
|
/// </remarks>
|
|
public async Task SignInAsync()
|
|
{
|
|
UserInfo? user = null;
|
|
if (option.EnableAutoLogin)
|
|
user = UserInfo.FromSsoFile();
|
|
|
|
await SignInAsync(user?.RefreshToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// refresh_token 으로 무창 인증을 시도하고, 불가하면 웹뷰 로그인으로 진행합니다. (비동기)
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// UI 스레드에서 <c>.Wait()</c>/<c>.Result</c> 로 블로킹하면 데드락입니다. 자세한 이유는
|
|
/// <see cref="SignInAsync()"/> 의 설명을 참고하세요. (UI 스레드면 await, 동기 호출은 <see cref="SignIn"/>)
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 로그아웃: 현재 사용자 정보 + 저장된 refresh_token + WebView SSO 세션 쿠키를 모두 삭제합니다.
|
|
/// 세션 쿠키까지 지우므로 다음 로그인 시 로그인 창이 다시 표시됩니다.
|
|
/// </summary>
|
|
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<bool> CheckConnection()
|
|
{
|
|
try
|
|
{
|
|
await client.GetDiscoveryAsync();
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|