Initial commit: BARON SSO 샘플 (WebView OIDC PKCE 인증 라이브러리 + 데모 앱)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 10:10:37 +09:00
commit 3de67f0052
25 changed files with 1171 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
using BaronSoftware.SSO.Exceptions;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
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()
{
// STA Thread 이슈로 인해, Task.Run() 으로 감싸서 동기화 함수 구현.
Task.Run(async () => await SignInAsync()).Wait();
}
public void SignOut()
{
Task.Run(async () => await SignOutAsync()).Wait();
}
/// <summary>웹뷰 로그인 창을 띄워 인증합니다.</summary>
public async Task SignInAsync() => await SignInAsync(null);
/// <summary>웹뷰 로그인 창을 띄워 인증합니다.</summary>
public async Task SignInAsync(string refreshToken)
{
UserInfo user = null;
if(option.EnableAutoLogin)
user = UserInfo.FromSsoFile();
var isConnected = await CheckConnection();
if(!isConnected && option.EnableOffline)
{
if (user == null)
throw new InvalidUserException("Not found authorized last user.");
option?.Validator?.Validate(user);
return;
}
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);
option?.Validator?.Validate(user);
CurrentUser = user;
CurrentUser.Save();
}
/// <summary>
/// 로그아웃: 현재 사용자 정보 + 저장된 refresh_token + WebView SSO 세션 쿠키를 모두 삭제합니다.
/// 세션 쿠키까지 지우므로 다음 로그인 시 로그인 창이 다시 표시됩니다.
/// </summary>
public async Task SignOutAsync()
{
// 1) 로그아웃 전에 id_token을 확보(서버 세션 종료용 id_token_hint). Clear 이전에 추출해야 한다.
var idToken = ExtractIdToken(CurrentUser) ?? ExtractIdToken(UserInfo.FromSsoFile());
// 2) 로컬 사용자 정보 삭제
UserInfo.Clear();
CurrentUser = null;
// 3) SSO 서버 세션 종료(RP-Initiated Logout) + 로컬 WebView 세션 쿠키 정리
try
{
await client.LogoutAsync(idToken);
}
catch
{
// 로그아웃 실패해도 로컬 사용자 정보는 이미 삭제했으므로 로컬 로그아웃으로 간주.
}
}
/// <summary>UserInfo의 원본 토큰 응답에서 id_token을 추출한다(로그아웃 id_token_hint용).</summary>
private static string ExtractIdToken(UserInfo user)
{
if (string.IsNullOrEmpty(user?.RawTokenResponse))
return null;
try
{
return JsonNode.Parse(user.RawTokenResponse)?["id_token"]?.GetValue<string>();
}
catch
{
return null;
}
}
private async Task<bool> CheckConnection()
{
try
{
await client.GetDiscoveryAsync();
return true;
}
catch
{
return false;
}
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Globalization;
namespace BaronSoftware.SSO
{
public sealed class BaronSSOOption
{
public CultureInfo Culture { get; set; } = CultureInfo.InvariantCulture;
/// <summary>OIDC Issuer(서버 주소). 미지정 시 라이브러리 기본값을 사용합니다.</summary>
public string Authority { get; init; }
/// <summary>퍼블릭 클라이언트 ID. (Client Secret 없음 — PKCE 사용)</summary>
public string ClientId { get; init; }
/// <summary>
/// 콘솔에 등록된 인증 콜백 URL과 문자 그대로 일치해야 합니다.
/// 데스크톱 앱 전용 루프백 주소(127.0.0.1 + 고유 포트/경로)를 사용해
/// 웹 프론트(localhost:3000 등)와 충돌하지 않게 합니다.
/// 임베디드 WebView가 이동을 가로채므로 실제 서버는 띄울 필요가 없습니다.
/// </summary>
public string RedirectUri { get; init; }
/// <summary>
/// SSO 로그아웃(RP-Initiated Logout) 후 돌아올 주소입니다.
/// 콘솔에서 해당 클라이언트의 post_logout_redirect_uris에 동일하게 등록돼 있어야 합니다.
/// 미지정 시 SSO 서버 세션 종료는 생략하고 로컬 사용자 정보/세션 쿠키만 정리합니다.
/// (RedirectUri처럼 전용 루프백 주소 권장 — 임베디드 WebView가 복귀를 가로챕니다.)
/// </summary>
public string PostLogoutRedirectUri { get; init; }
/// <summary>
/// 인터넷이 안되는 상황에서도, refresh_token이 유효한 동안 로그인 인증을 유지할지 여부입니다. (기본값: true)
/// </summary>
public bool EnableOffline { get; set; } = true;
/// <summary>
/// 로그인 후, 로그인 인증은 Refresh token 유효기간 동안 유지합니다.
/// </summary>
public bool EnableAutoLogin { get; set; } = true;
/// <summary>
/// 사용자 인증에 대한 추가 검증이 필요한 경우, IUserValidator 인터페이스를 구현하여 Validator 속성에 할당할 수 있습니다.
/// </summary>
public IUserValidator? Validator { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<!-- 임베디드 웹뷰 (Edge/Chromium 기반) -->
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.*" />
<!-- OIDC Discovery / JWKS / id_token 서명검증 -->
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.*" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.*" />
<!-- refresh_token을 DPAPI로 암호화하여 레지스트리에 저장 -->
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.9" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BaronSoftware.SSO.Exceptions
{
internal class InvalidUserException : Exception
{
public InvalidUserException() { }
public InvalidUserException(string message) : base(message) { }
public InvalidUserException(string message, Exception inner) : base(message, inner) { }
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BaronSoftware.SSO
{
public class UserJsonParsingException : Exception
{
public string Raw { get; init; }
}
}

View File

@@ -0,0 +1,11 @@
<Window x:Class="BaronSoftware.SSO.LoginWindow"
x:ClassModifier="internal"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="BARON 로그인" Height="760" Width="520"
WindowStartupLocation="CenterOwner">
<Grid>
<wv2:WebView2 x:Name="webview" />
</Grid>
</Window>

View File

@@ -0,0 +1,114 @@
using Microsoft.Web.WebView2.Core;
using System.Windows;
using WebView2 = Microsoft.Web.WebView2.Wpf.WebView2;
namespace BaronSoftware.SSO
{
/// <summary>
/// 인증 엔드포인트를 WebView2로 로드하고, redirect_uri로의 이동을 가로채
/// authorization code가 담긴 콜백 URL을 반환하는 로그인 창.
/// redirect_uri로 실제 페이지가 로드되기 전에 NavigationStarting에서 취소하므로,
/// localhost에 실제 서버를 띄울 필요가 없습니다.
/// </summary>
internal partial class LoginWindow : Window
{
private readonly string _authorizeUrl;
private readonly string _redirectUri;
private readonly TaskCompletionSource<string> _tcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
internal LoginWindow(string authorizeUrl, string redirectUri)
{
InitializeComponent();
_authorizeUrl = authorizeUrl;
_redirectUri = redirectUri;
Loaded += async (s, e) =>
{
try
{
#if DEBUG
await webview.EnsureCoreWebView2Async();
webview.CoreWebView2.NavigationStarting += OnNavigationStarting;
webview.CoreWebView2.Navigate(_authorizeUrl);
#else
// 1. 웹뷰 환경 설정 객체 생성
var environment = await CoreWebView2Environment.CreateAsync(null, null, null);
// 2. 컨트롤러 옵션 생성 및 InPrivate 모드 활성화
var options = environment.CreateCoreWebView2ControllerOptions();
options.IsInPrivateModeEnabled = true; // 핵심 설정
// 3. 인프라이빗 옵션을 적용하여 WebView2 초기화
await webview.EnsureCoreWebView2Async(environment, options);
webview.CoreWebView2.NavigationStarting += OnNavigationStarting;
webview.CoreWebView2.Navigate(_authorizeUrl);
#endif
}
catch (Exception ex)
{
_tcs.TrySetException(ex);
Dispatcher.BeginInvoke(new Action(Close));
}
};
Closed += (_, _) => _tcs.TrySetException(new OperationCanceledException());
}
private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
{
if (e.Uri.StartsWith(_redirectUri, StringComparison.OrdinalIgnoreCase))
{
e.Cancel = true; // 실제 localhost 로딩 차단
_tcs.TrySetResult(e.Uri);
Dispatcher.BeginInvoke(new Action(Close));
}
}
/// <summary>창을 띄우고 콜백 URL(code 포함)을 비동기로 반환.</summary>
internal Task<string> ShowAndGetRedirectAsync()
{
Show();
return _tcs.Task;
}
/// <summary>
/// 로그인 WebView가 사용하는 프로필의 모든 쿠키(=SSO 세션 쿠키)를 삭제합니다.
/// 화면에 보이지 않는 오프스크린 WebView2를 잠깐 띄워 동일 프로필의 쿠키를 비웁니다.
/// 이후 다음 로그인 시 세션이 없어 로그인 폼이 다시 표시됩니다.
/// </summary>
internal static async Task ClearSessionCookiesAsync()
{
var holder = new Window
{
Width = 1,
Height = 1,
Left = -32000,
Top = -32000,
WindowStyle = WindowStyle.None,
ShowInTaskbar = false,
ShowActivated = false,
Title = string.Empty,
};
var web = new WebView2();
holder.Content = web;
holder.Show(); // WebView2 초기화에 필요한 HWND 확보(화면 밖)
try
{
await web.EnsureCoreWebView2Async();
// DeleteAllCookies()는 즉시 반환이라, 디스크 반영 전에 Dispose되면 쿠키가 남아
// 다음 로그인이 SSO로 조용히 통과될 수 있다. 완료까지 await하는 ClearBrowsingDataAsync로
// SSO 세션 쿠키/사이트 데이터를 확실히 제거한다.
await web.CoreWebView2.Profile.ClearBrowsingDataAsync(
CoreWebView2BrowsingDataKinds.AllSite);
}
finally
{
web.Dispose();
holder.Close();
}
}
}
}

View File

@@ -0,0 +1,9 @@
namespace BaronSoftware.SSO
{
/// <summary>id_token을 검증(서명/발급자/대상/만료)하고 파싱된 JWT를 반환합니다.</summary>
public interface IUserValidator
{
public void Validate(UserInfo user);
}
}

View File

@@ -0,0 +1,18 @@
using System.IO;
namespace BaronSoftware.SSO
{
internal static class GlobalConfigs
{
internal static readonly string CenterTanant_UUID = "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee";
internal static readonly string FamilyTanant_UUID = "038326b6-954a-48a7-a85f-efd83f62b82a";
/// <summary>OIDC Issuer. Discovery 문서는 {Authority}/.well-known/openid-configuration 입니다.</summary>
internal static string SsoUri = "https://sso.hmac.kr/oidc";
internal static string SsoDiscoveryUri => $"{SsoUri.TrimEnd('/')}/.well-known/openid-configuration";
public readonly static string RegPath = $@"HKEY_CURRENT_USER\Software\Baron\";
public static readonly string SsoFilePath = @$"{Path.GetTempPath()}\.baron\baronsso.dat";
}
}

View File

@@ -0,0 +1,103 @@
using Microsoft.Win32;
using System;
using System.IO;
using System.Linq;
using System.Printing;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace BaronSoftware.SSO
{
public class UserInfo
{
public string UUID { get; private set; }
public string Name { get; private set; }
public string Email { get; private set; }
public string[] SubEmails { get; private set; }
public bool IsFamily { get; private set; }
public string RefreshToken { get; private set; }
public string TenantId { get; private set; }
public string[] JoinedTenantIds { get; private set; }
public DateTime LastAuthTime {get; private set; }
public string Raw { get; private set; }
public string RawTokenResponse { get; private set; }
public Dictionary<string, object> Claims { get; private set; }
internal static UserInfo FromJson(string rawjson, TokenResponse reposeToken)
{
var userjson = JsonNode.Parse(rawjson);
var result = new UserInfo();
result.Raw = rawjson;
result.RawTokenResponse = reposeToken.RawJson;
result.RefreshToken = reposeToken.RefreshToken ?? "";
result.UUID = userjson["sub"]?.GetValue<string>() ?? "";
result.Name = userjson["name"]?.GetValue<string>() ?? "";
result.Email = userjson["email"]?.GetValue<string>() ?? "";
//result.SubEmails = userjson["sub_emails"]?.AsArray().Select(x => x.GetValue<string>()).ToArray() ?? Array.Empty<string>();
result.TenantId = userjson["tenant_id"]?.GetValue<string>() ?? "";
result.JoinedTenantIds = userjson["joined_tenants"]?.AsArray().Select(x => x.GetValue<string>()).ToArray() ?? Array.Empty<string>();
result.Claims = userjson["rp_claims"]?.AsObject().ToDictionary(x => x.Key, x => (object)x.Value["value"]) ?? new Dictionary<string, object>();
result.LastAuthTime = DateTimeOffset.FromUnixTimeSeconds(userjson["auth_time"]?.GetValue<long>() ?? 0).DateTime;
return result;
}
//// root = Ory Hydra id_token payload 또는 /userinfo 응답.
//// 클레임은 (Descope의 customAttributes 하위가 아니라) 최상위에 평탄하게 들어오며,
//// 상세 항목은 profile 스코프가 동의되었을 때만 포함됩니다.
//// (baron-sso backend: buildOidcClaimsFromTraits 참고)
internal void Save()
{
var json = JsonSerializer.Serialize(this);
var bytes = Encoding.UTF8.GetBytes(json);
var protectedBytes = ProtectedData.Protect(bytes, optionalEntropy: null, DataProtectionScope.CurrentUser);
if (File.Exists(GlobalConfigs.SsoFilePath))
File.Delete(GlobalConfigs.SsoFilePath);
if (!Directory.Exists(Path.GetDirectoryName(GlobalConfigs.SsoFilePath)))
Directory.CreateDirectory(Path.GetDirectoryName(GlobalConfigs.SsoFilePath));
File.WriteAllBytes(GlobalConfigs.SsoFilePath, protectedBytes);
}
internal static UserInfo? FromSsoFile()
{
try
{
ValidateSsoFile();
var bytes = ProtectedData.Unprotect(File.ReadAllBytes(GlobalConfigs.SsoFilePath), optionalEntropy: null, DataProtectionScope.CurrentUser);
var json = Encoding.UTF8.GetString(bytes);
var user = JsonSerializer.Deserialize<UserInfo>(json);
return user;
}
catch
{
return null; // 손상/복호화 불가 시 무시
}
}
private static void ValidateSsoFile()
{
//SSO 인증 저장파일을 검증
if (!File.Exists(GlobalConfigs.SsoFilePath))
throw new FileNotFoundException("Not found sso file", GlobalConfigs.SsoFilePath);
var file = new FileInfo(GlobalConfigs.SsoFilePath);
if (DateTime.Now < file.LastWriteTime)
{
//파일의 최종 수정시간이 현재 시간보다 미래인 경우, 시스템 시계가 변경되었거나 파일이 조작되었을 가능성이 있음
throw new InvalidDataException("Invalid sso file: last write time is in the future");
}
}
public static void Clear()
{
if (File.Exists(GlobalConfigs.SsoFilePath))
File.Delete(GlobalConfigs.SsoFilePath);
}
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace BaronSoftware.SSO
{
internal static class PKCEUtil
{
public static string NewVerifier() => B64Url(RandomNumberGenerator.GetBytes(32));
public static string RandomToken() => B64Url(RandomNumberGenerator.GetBytes(16));
public static string Challenge(string verifier) => B64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
private static string B64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static Dictionary<string, string> ParseQuery(string uri)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var query = new Uri(uri).Query.TrimStart('?');
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var kv = pair.Split('=', 2);
result[Uri.UnescapeDataString(kv[0])] = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty;
}
return result;
}
}
}

View File

@@ -0,0 +1,188 @@
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}";
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<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()}";
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();
}
}
/// <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);
}
}
}

View File

@@ -0,0 +1,10 @@
namespace BaronSoftware.SSO
{
/// <summary>토큰 엔드포인트 응답.</summary>
internal sealed record TokenResponse(
string IdToken,
string AccessToken,
string? RefreshToken,
int ExpiresIn,
string RawJson);
}