Files
baron-sso-sample/BaronSoftware.SSO/BaronSSO.cs
최준영 dd2c4e4975 로그인 STA 스레드화·SSO 로그아웃·UserInfo 역직렬화 수정 및 AuthTest 추가
- LoginWindow: 로그인/로그아웃/쿠키삭제를 전용 STA 스레드(자체 Dispatcher 펌프)에서 실행
  → SignIn/SignInAsync가 MTA/STA 어느 스레드에서 호출돼도 동작 (RunOnDedicatedUiThreadAsync, AuthenticateAsync)
- SsoClient: 로그인/로그아웃 창을 AuthenticateAsync로 호출, 크로스스레드 Owner 제거
- BaronSSO: SignIn/SignOut을 Task.Run(...).GetAwaiter().GetResult()로 정리, SignInAsync 데드락 주석 추가
- UserInfo: private set 프로퍼티에 [JsonInclude] 적용 → FromSsoFile 역직렬화 복원 정상화, LastAuthTime [JsonIgnore]
- AuthTest 샘플 프로젝트 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 19:20:12 +09:00

147 lines
6.3 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
{
//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;
}
}
}
}