- 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>
183 lines
8.2 KiB
C#
183 lines
8.2 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Security;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
using System.Windows;
|
|
|
|
namespace BaronSoftware.SSO
|
|
{
|
|
internal class SsoClient
|
|
{
|
|
private static readonly HttpClient httpclient = new();
|
|
|
|
private BaronSSOOption options;
|
|
|
|
private SsoClient() { }
|
|
|
|
public static SsoClient Create(BaronSSOOption option)
|
|
{
|
|
SsoClient result = new();
|
|
result.options = option;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 웹뷰 기반 인증: 로그인 창을 띄워 Authorization Code + PKCE로 토큰을 받아온다. (대화형)
|
|
/// </summary>
|
|
public async Task<TokenResponse> LoginAsync()
|
|
{
|
|
var disc = await GetDiscoveryAsync();
|
|
var verifier = PKCEUtil.NewVerifier();
|
|
var state = PKCEUtil.RandomToken();
|
|
var nonce = PKCEUtil.RandomToken();
|
|
|
|
var authorizeUrl =
|
|
$"{disc["authorization_endpoint"]}?client_id={Uri.EscapeDataString(options.ClientId)}" +
|
|
$"&redirect_uri={Uri.EscapeDataString(options.RedirectUri)}" +
|
|
$"&response_type=code" +
|
|
$"&code_challenge={PKCEUtil.Challenge(verifier)}&code_challenge_method=S256" +
|
|
$"&state={state}&nonce={nonce}";
|
|
|
|
// 전용 STA 스레드에서 로그인 창을 띄운다(호출 스레드가 MTA/STA 무엇이든 동작).
|
|
var redirected = await LoginWindow.AuthenticateAsync(authorizeUrl, options.RedirectUri);
|
|
|
|
var q = PKCEUtil.ParseQuery(redirected);
|
|
if (q.TryGetValue("error", out var error))
|
|
throw new InvalidOperationException($"인증 거부: {error} {q.GetValueOrDefault("error_description")}".Trim());
|
|
if (!q.TryGetValue("state", out var returnedState) || returnedState != state)
|
|
throw new SecurityException("state 불일치 (CSRF 의심) — 로그인을 중단합니다.");
|
|
if (!q.TryGetValue("code", out var code) || string.IsNullOrEmpty(code))
|
|
throw new InvalidOperationException("authorization code를 받지 못했습니다.");
|
|
|
|
var tokens = await ExchangeAsync(disc, new Dictionary<string, string>
|
|
{
|
|
["grant_type"] = "authorization_code",
|
|
["code"] = code,
|
|
["redirect_uri"] = options.RedirectUri,
|
|
["client_id"] = options.ClientId,
|
|
["code_verifier"] = verifier,
|
|
});
|
|
|
|
// nonce 검증 (id_token replay 방지)
|
|
var payload = new JwtSecurityTokenHandler().ReadJwtToken(tokens.IdToken).Payload;
|
|
payload.TryGetValue("nonce", out var nonceClaim);
|
|
if (nonceClaim?.ToString() != nonce)
|
|
throw new SecurityException("nonce 불일치 — id_token이 이 요청에 대한 것이 아닙니다.");
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/// <summary>
|
|
/// refreshToken 기반 인증: 저장된 refresh_token으로 창 없이(無窓) 토큰을 재발급한다.
|
|
/// </summary>
|
|
public async Task<TokenResponse> LoginAsync(string refreshToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(refreshToken))
|
|
return await LoginAsync();
|
|
|
|
var disc = await GetDiscoveryAsync();
|
|
return await ExchangeAsync(disc, new Dictionary<string, string>
|
|
{
|
|
["grant_type"] = "refresh_token",
|
|
["refresh_token"] = refreshToken,
|
|
["client_id"] = options.ClientId,
|
|
});
|
|
}
|
|
|
|
/// <summary>.well-known/openid-configuration 문서를 1회 로드 후 캐시.</summary>
|
|
public async Task<JsonNode> GetDiscoveryAsync()
|
|
{
|
|
var json = await httpclient.GetStringAsync(GlobalConfigs.SsoDiscoveryUri);
|
|
var discovery = JsonNode.Parse(json) ?? throw new InvalidOperationException("Pasing error");
|
|
return discovery;
|
|
}
|
|
|
|
/// <summary>로그인 WebView의 세션 쿠키(SSO 세션)를 삭제한다.</summary>
|
|
public Task ClearWebSessionAsync() => LoginWindow.ClearSessionCookiesAsync();
|
|
|
|
/// <summary>
|
|
/// SSO 로그아웃(RP-Initiated Logout): end_session_endpoint로 이동해 Baron SSO 세션을 종료한다.
|
|
/// PostLogoutRedirectUri가 설정돼 있으면 그 주소로의 복귀를 WebView에서 가로채 창을 닫고,
|
|
/// 마지막으로 로컬 WebView 세션 쿠키까지 정리한다.
|
|
/// </summary>
|
|
public async Task LogoutAsync(string idToken)
|
|
{
|
|
try
|
|
{
|
|
var disc = await GetDiscoveryAsync();
|
|
var endSession = disc["end_session_endpoint"]?.ToString();
|
|
|
|
// end_session_endpoint와 id_token_hint, 그리고 복귀 주소(PostLogoutRedirectUri)가 모두 있어야
|
|
// 임베디드 WebView에서 깔끔하게 로그아웃 → 복귀를 가로챌 수 있다.
|
|
if (!string.IsNullOrEmpty(endSession)
|
|
&& !string.IsNullOrEmpty(idToken)
|
|
&& !string.IsNullOrWhiteSpace(options.PostLogoutRedirectUri))
|
|
{
|
|
var url =
|
|
$"{endSession}?id_token_hint={Uri.EscapeDataString(idToken)}" +
|
|
$"&post_logout_redirect_uri={Uri.EscapeDataString(options.PostLogoutRedirectUri)}" +
|
|
$"&state={PKCEUtil.RandomToken()}";
|
|
|
|
try
|
|
{
|
|
// 전용 STA 스레드에서 로그아웃(end_session) 창을 띄운다.
|
|
await LoginWindow.AuthenticateAsync(url, options.PostLogoutRedirectUri);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 사용자가 로그아웃 창을 닫아도 로컬 정리는 계속 진행한다.
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// end_session 호출 실패(네트워크/디스커버리 등)해도 로컬 쿠키 정리는 시도한다.
|
|
}
|
|
finally
|
|
{
|
|
await ClearWebSessionAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>access_token으로 userinfo 호출 (granted scope에 따른 프로필/소속 클레임).</summary>
|
|
public async Task<string> GetUserInfoAsync(string accessToken)
|
|
{
|
|
var disc = await GetDiscoveryAsync();
|
|
var endpoint = disc["userinfo_endpoint"]?.ToString()
|
|
?? throw new InvalidOperationException("discovery hasn't userinfo_endpoint.");
|
|
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
using var resp = await httpclient.SendAsync(req);
|
|
var body = await resp.Content.ReadAsStringAsync();
|
|
if (!resp.IsSuccessStatusCode)
|
|
throw new InvalidOperationException($"Failed to call userinfo {(int)resp.StatusCode}: {body}");
|
|
|
|
return body;
|
|
}
|
|
|
|
private static async Task<TokenResponse> ExchangeAsync(JsonNode disc, Dictionary<string, string> form)
|
|
{
|
|
var tokenEndpoint = disc["token_endpoint"]?.ToString()
|
|
?? throw new InvalidOperationException("discovery hasn't userinfo_endpoint.");
|
|
|
|
using var resp = await httpclient.PostAsync(tokenEndpoint, new FormUrlEncodedContent(form));
|
|
var body = await resp.Content.ReadAsStringAsync();
|
|
if (!resp.IsSuccessStatusCode)
|
|
throw new InvalidOperationException($"Failed to exchange token {(int)resp.StatusCode}: {body}");
|
|
|
|
var j = JsonNode.Parse(body) ?? throw new InvalidOperationException("empty token");
|
|
|
|
return new TokenResponse(
|
|
j["id_token"]?.ToString() ?? throw new InvalidOperationException("Response has not id_token."),
|
|
j["access_token"]?.ToString() ?? string.Empty,
|
|
j["refresh_token"]?.ToString(),
|
|
j["expires_in"]?.GetValue<int>() ?? 3600,
|
|
body);
|
|
}
|
|
}
|
|
}
|