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;
}
///
/// 웹뷰 기반 인증: 로그인 창을 띄워 Authorization Code + PKCE로 토큰을 받아온다. (대화형)
///
public async Task 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}";
var window = new LoginWindow(authorizeUrl, options.RedirectUri);
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, window))
window.Owner = owner;
var redirected = await window.ShowAndGetRedirectAsync();
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
{
["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;
}
///
/// refreshToken 기반 인증: 저장된 refresh_token으로 창 없이(無窓) 토큰을 재발급한다.
///
public async Task LoginAsync(string refreshToken)
{
if (string.IsNullOrWhiteSpace(refreshToken))
return await LoginAsync();
var disc = await GetDiscoveryAsync();
return await ExchangeAsync(disc, new Dictionary
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = options.ClientId,
});
}
/// .well-known/openid-configuration 문서를 1회 로드 후 캐시.
public async Task GetDiscoveryAsync()
{
var json = await httpclient.GetStringAsync(GlobalConfigs.SsoDiscoveryUri);
var discovery = JsonNode.Parse(json) ?? throw new InvalidOperationException("Pasing error");
return discovery;
}
/// 로그인 WebView의 세션 쿠키(SSO 세션)를 삭제한다.
public Task ClearWebSessionAsync() => LoginWindow.ClearSessionCookiesAsync();
///
/// SSO 로그아웃(RP-Initiated Logout): end_session_endpoint로 이동해 Baron SSO 세션을 종료한다.
/// PostLogoutRedirectUri가 설정돼 있으면 그 주소로의 복귀를 WebView에서 가로채 창을 닫고,
/// 마지막으로 로컬 WebView 세션 쿠키까지 정리한다.
///
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()}";
var window = new LoginWindow(url, options.PostLogoutRedirectUri);
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, window))
window.Owner = owner;
try
{
await window.ShowAndGetRedirectAsync();
}
catch (OperationCanceledException)
{
// 사용자가 로그아웃 창을 닫아도 로컬 정리는 계속 진행한다.
}
}
}
catch
{
// end_session 호출 실패(네트워크/디스커버리 등)해도 로컬 쿠키 정리는 시도한다.
}
finally
{
await ClearWebSessionAsync();
}
}
/// access_token으로 userinfo 호출 (granted scope에 따른 프로필/소속 클레임).
public async Task 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 ExchangeAsync(JsonNode disc, Dictionary 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() ?? 3600,
body);
}
}
}