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}"; // 전용 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 { ["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으로 창 없이(無窓) 토큰을 재발급한다. /// refresh_token이 만료/무효 등으로 실패하면 웹뷰 대화형 로그인(LoginWindow)으로 폴백한다. /// public async Task LoginAsync(string refreshToken) { if (string.IsNullOrWhiteSpace(refreshToken)) return await LoginAsync(); try { var disc = await GetDiscoveryAsync(); return await ExchangeAsync(disc, new Dictionary { ["grant_type"] = "refresh_token", ["refresh_token"] = refreshToken, ["client_id"] = options.ClientId, }); } catch { // 저장된 refresh_token이 만료/폐기/무효 → 자동 로그인 실패. // 사용자가 다시 로그인할 수 있도록 웹뷰 로그인 창을 띄운다. return await LoginAsync(); } } /// .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()}"; // 화면 밖(오프스크린) WebView2로 end_session을 수행해 // 로그아웃 시 WebView 창/에러 페이지가 사용자에게 보이지 않게 한다. await LoginWindow.EndSessionAsync(url, options.PostLogoutRedirectUri); } } 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); } } }