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

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
## .NET / Visual Studio
bin/
obj/
.vs/
*.user
*.suo
*.userosscache
*.sln.docstates
## Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
## Publish output
publish/
*.publishsettings
## WebView2 runtime / user data
*.WebView2/
EBWebView/
## Rider / others
.idea/
*.DotSettings.user

View File

@@ -0,0 +1,9 @@
<Application x:Class="BaronSoftware.SSO.Sample.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:BaronSoftware.Auth.Sample"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,14 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace BaronSoftware.Auth.Sample
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BaronSoftware.SSO\BaronSoftware.SSO.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,38 @@
<Window x:Class="BaronSoftware.SSO.Sample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:BaronSoftware.SSO.Sample"
mc:Ignorable="d"
Title="BARON SSO 웹뷰 인증 샘플" Height="560" Width="840">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="BARON SSO (Ory Hydra · OIDC Authorization Code + PKCE) 웹뷰 인증 데모"
FontSize="16" FontWeight="Bold" Margin="0,0,0,12"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,12">
<Button x:Name="LoginButton" Content="웹뷰로 로그인" Width="140" Height="34"
Click="LoginButton_Click"/>
<Button x:Name="TokenLoginBuggon" Content="토큰 로그인" Width="150" Height="34"
Margin="8,0,0,0" Click="TokenLoginBuggon_Click"/>
<Button x:Name="LogoutButton" Content="로그아웃" Width="100" Height="34"
Margin="8,0,0,0" Click="LogoutButton_Click"/>
<Button x:Name="SettingsButton" Content="설정 변경" Width="90" Height="34"
Margin="8,0,0,0" Click="SettingsButton_Click"/>
</StackPanel>
<Border Grid.Row="2" BorderBrush="#DDDDDD" BorderThickness="1" CornerRadius="4">
<TextBox x:Name="OutputBox" IsReadOnly="True" BorderThickness="0" Padding="10"
FontFamily="Consolas" FontSize="13" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto"
Text="‘웹뷰로 로그인’ → 토큰 저장 → 다음 실행 시 ‘자동 로그인(갱신).&#x0a;&#x0a;[콘솔 사전 설정 2가지]&#x0a; 1) 리디렉션 URI 설정 → 인증 콜백 URL 에 추가:&#x0a; http://127.0.0.1:8421/baron-sample/auth/callback&#x0a; 2) 스코프 추가 → 'offline_access' (refresh_token 발급용, 자동 로그인 필수)"/>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,137 @@
using BaronSoftware;
using BaronSoftware.Auth;
using BaronSoftware.Auth.Sample;
using System;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace BaronSoftware.SSO.Sample
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private BaronSSO _license;
private readonly SampleSettings _settings;
public MainWindow()
{
InitializeComponent();
_settings = SampleSettings.Load();
ApplySettings();
}
/// <summary>현재 설정으로 SSO 클라이언트를 (재)생성한다.</summary>
private void ApplySettings() => _license = new BaronSSO(_settings.ToOidcOptions());
private void SettingsButton_Click(object sender, RoutedEventArgs e)
{
var dlg = new SettingsWindow(_settings.Oidc.Authority, _settings.Oidc.ClientId, _settings.Oidc.RedirectUri, _settings.Oidc.LogoutUri)
{
Owner = this
};
if (dlg.ShowDialog() != true) return;
_settings.Oidc.Authority = dlg.Authority;
_settings.Oidc.ClientId = dlg.ClientId;
_settings.Oidc.RedirectUri = dlg.RedirectUri;
_settings.Oidc.LogoutUri = dlg.LogoutUri;
try
{
_settings.Save();
ApplySettings();
OutputBox.Text =
"설정 저장 완료 ✔ (appsettings.json)\n\n" +
$"Authority : {_settings.Oidc.Authority}\n" +
$"ClientId : {_settings.Oidc.ClientId}\n" +
$"RedirectUri : {_settings.Oidc.RedirectUri}\n" +
$"LogoutUri : {_settings.Oidc.LogoutUri}\n\n" +
"‘웹뷰로 로그인’으로 적용된 설정을 테스트하세요.";
}
catch (Exception ex)
{
OutputBox.Text = $"설정 저장 실패:\n{ex.Message}";
}
}
private async void LoginButton_Click(object sender, RoutedEventArgs e)
=> await RunAsync("웹뷰 로그인", () => _license.SignInAsync());
private async void LogoutButton_Click(object sender, RoutedEventArgs e)
{
SetBusy(true);
OutputBox.Text = "로그아웃 중... (토큰 + 세션 쿠키 삭제)";
try
{
await _license.SignOutAsync();
OutputBox.Text = "로그아웃 완료 ✔\n" +
"- refresh_token 삭제\n" +
"- WebView SSO 세션 쿠키 삭제\n" +
"→ 다음 ‘웹뷰로 로그인’ 시 로그인 창이 다시 표시됩니다.";
}
finally
{
SetBusy(false);
}
}
private async Task RunAsync(string action, Func<Task> work)
{
SetBusy(true);
OutputBox.Text = $"{action} 진행 중...";
try
{
await work();
OutputBox.Text = $"{action} 완료 ✔\n\n";
OutputBox.Text += Format(_license.CurrentUser);
OutputBox.CaretIndex = 0; // 맨 위(요약)부터 보이도록
}
catch (OperationCanceledException)
{
OutputBox.Text = $"{action} 취소됨 (로그인 창이 닫힘).";
}
catch (Exception ex)
{
OutputBox.Text = $"{action} 실패:\n{ex.GetType().Name}: {ex.Message}";
}
finally
{
SetBusy(false);
}
}
private void SetBusy(bool busy)
=> LoginButton.IsEnabled = LogoutButton.IsEnabled = !busy;
private string Format(UserInfo u)
{
var sb = new StringBuilder();
sb.AppendLine("로그인 성공 ✔");
sb.AppendLine($"UserId(sub) : {u.UUID}");
sb.AppendLine($"Name : {u.Name}");
sb.AppendLine($"Email : {u.Email}");
sb.AppendLine($"Last Auth Time : {u.LastAuthTime}");
sb.AppendLine($"Claims Start------------ \n ");
sb.AppendLine(string.Join("\n", u.Claims.Select(kv => $" {kv.Key}: {kv.Value}")));
sb.AppendLine($"\nClaims End------------ \n ");
sb.AppendLine(!string.IsNullOrWhiteSpace(u.RefreshToken)
? "자동 로그인 : 사용 가능 (refresh_token 저장됨)"
: "자동 로그인 : 불가 — 리프레시 토큰 없음");
sb.AppendLine();
sb.AppendLine("==== token 엔드포인트 응답 (원본) ====");
sb.AppendLine(u.RawTokenResponse);
sb.AppendLine();
sb.AppendLine("==== userinfo 응답 ====");
sb.AppendLine(u.Raw);
return sb.ToString();
}
private async void TokenLoginBuggon_Click(object sender, RoutedEventArgs e) => await RunAsync("토큰 로그인", () => _license.SignInAsync(_license.CurrentUser.RefreshToken));
}
}

View File

@@ -0,0 +1,77 @@
using BaronSoftware;
using BaronSoftware.Auth;
using BaronSoftware.SSO;
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace BaronSoftware.Auth.Sample
{
/// <summary>
/// 실행 파일과 같은 폴더의 appsettings.json에서 접속 정보를 읽는다.
/// 파일이 없으면 내장 기본값을 사용하고, JSON 형식 오류는 예외로 알린다.
/// </summary>
public sealed class SampleSettings
{
public const string FileName = "appsettings.json";
public OidcSection Oidc { get; set; } = new();
public sealed class OidcSection
{
public string Authority { get; set; }
public string ClientId { get; set; }
public string RedirectUri { get; set; }
public string LogoutUri { get; set; }
public string Scope { get; set; }
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>실행 파일 옆 appsettings.json을 로드. 파일이 없으면 기본값.</summary>
public static SampleSettings Load()
{
var path = Path.Combine(AppContext.BaseDirectory, FileName);
if (!File.Exists(path))
return new SampleSettings();
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<SampleSettings>(json, JsonOptions) ?? new SampleSettings();
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"설정 파일({FileName}) 형식 오류: {ex.Message}", ex);
}
}
private static readonly JsonSerializerOptions SaveOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>현재 설정을 실행 파일 옆 appsettings.json에 저장한다.</summary>
public void Save()
{
var path = Path.Combine(AppContext.BaseDirectory, FileName);
File.WriteAllText(path, JsonSerializer.Serialize(this, SaveOptions));
}
public BaronSSOOption ToOidcOptions() => new()
{
Authority = Oidc.Authority,
ClientId = Oidc.ClientId,
RedirectUri = Oidc.RedirectUri,
};
}
}

View File

@@ -0,0 +1,50 @@
<Window x:Class="BaronSoftware.SSO.Sample.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="설정 변경 (appsettings.json)" Height="380" Width="620"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize" ShowInTaskbar="False">
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.ColumnSpan="2" Margin="0,0,0,12" Foreground="#555"
Text="값을 수정하고 ‘저장’하면 appsettings.json에 기록되고 즉시 적용됩니다."/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Authority" VerticalAlignment="Center" Margin="0,4"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="AuthorityBox" Margin="0,4" Padding="4"
VerticalContentAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="ClientId" VerticalAlignment="Center" Margin="0,4"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="ClientIdBox" Margin="0,4" Padding="4"
VerticalContentAlignment="Center"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="RedirectUri" VerticalAlignment="Center" Margin="0,4"/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="RedirectUriBox" Margin="0,4" Padding="4"
VerticalContentAlignment="Center"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="LogoutUri" VerticalAlignment="Center" Margin="0,4"/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="LogoutUriBox" Margin="0,4" Padding="4"
VerticalContentAlignment="Center"/>
<TextBlock Grid.Row="5" Grid.ColumnSpan="2" Margin="0,10,0,0" TextWrapping="Wrap"
Foreground="#777" FontSize="12"
Text="• Authority : OIDC 서버. 예) https://sso.hmac.kr/oidc 또는 http://localhost:5000/oidc&#x0a;• ClientId : 콘솔 '앱 자격 증명'의 퍼블릭 클라이언트 ID&#x0a;• RedirectUri : 콘솔에 등록된 로그인 콜백과 문자 그대로 일치&#x0a;• LogoutUri : 콘솔 'post_logout_redirect_uri'에 등록된 로그아웃 콜백 (서버 세션 종료용)"/>
<StackPanel Grid.Row="6" Grid.ColumnSpan="2" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="저장" Width="90" Height="32" Click="Save_Click" IsDefault="True"/>
<Button Content="취소" Width="90" Height="32" Margin="8,0,0,0" Click="Cancel_Click" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,33 @@
using System.Windows;
namespace BaronSoftware.SSO.Sample
{
/// <summary>접속 설정(Authority/ClientId/RedirectUri/LogoutUri)을 편집하는 모달 다이얼로그.</summary>
public partial class SettingsWindow : Window
{
public string Authority { get; private set; } = "";
public string ClientId { get; private set; } = "";
public string RedirectUri { get; private set; } = "";
public string LogoutUri { get; private set; } = "";
public SettingsWindow(string authority, string clientId, string redirectUri, string logoutUri)
{
InitializeComponent();
AuthorityBox.Text = authority ?? "";
ClientIdBox.Text = clientId ?? "";
RedirectUriBox.Text = redirectUri ?? "";
LogoutUriBox.Text = logoutUri ?? "";
}
private void Save_Click(object sender, RoutedEventArgs e)
{
Authority = AuthorityBox.Text.Trim();
ClientId = ClientIdBox.Text.Trim();
RedirectUri = RedirectUriBox.Text.Trim();
LogoutUri = LogoutUriBox.Text.Trim();
DialogResult = true;
}
private void Cancel_Click(object sender, RoutedEventArgs e) => DialogResult = false;
}
}

View File

@@ -0,0 +1,21 @@
{
// ─────────────────────────────────────────────────────────────
// BARON SSO 샘플 접속 설정
// 값을 수정한 뒤 앱을 다시 실행하면 반영됩니다. (재컴파일 불필요)
// 이 파일은 실행 파일(.exe)과 같은 폴더에 있어야 합니다.
// ─────────────────────────────────────────────────────────────
"Oidc": {
// OIDC Issuer. Discovery 문서: {Authority}/.well-known/openid-configuration
"Authority": "https://sso.hmac.kr/oidc",
// 퍼블릭 클라이언트 ID (콘솔 '앱 자격 증명' 화면)
"ClientId": "4e4c88fc-2b0a-4b8b-b9b5-fb407cdbbfac",
// 콘솔 '리디렉션 URI 설정'에 등록된 값과 문자 그대로 일치해야 함
"RedirectUri": "http://127.0.0.1:8421/baron-sample/auth/callback",
// 로그아웃 후 돌아올 URL. 콘솔의 'post_logout_redirect_uri'에 등록되어 있어야 함
// (미지정 시 로그아웃은 쿠키 삭제 폴백으로 동작 — 서버 세션이 남을 수 있음)
"LogoutUri": "http://127.0.0.1:8421/baron-sample/logout/callback"
}
}

31
BaronSoftware.SSO.sln Normal file
View File

@@ -0,0 +1,31 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.37301.10
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaronSoftware.SSO", "BaronSoftware.SSO\BaronSoftware.SSO.csproj", "{0A6C43C1-A3CB-4CBA-BDBE-E065D9A506AB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaronSoftware.SSO.Sample", "BaronSoftware.SSO.Sample\BaronSoftware.SSO.Sample.csproj", "{6F80A264-7099-3F9E-64A3-89E569A3E7A4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0A6C43C1-A3CB-4CBA-BDBE-E065D9A506AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0A6C43C1-A3CB-4CBA-BDBE-E065D9A506AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0A6C43C1-A3CB-4CBA-BDBE-E065D9A506AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0A6C43C1-A3CB-4CBA-BDBE-E065D9A506AB}.Release|Any CPU.Build.0 = Release|Any CPU
{6F80A264-7099-3F9E-64A3-89E569A3E7A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F80A264-7099-3F9E-64A3-89E569A3E7A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F80A264-7099-3F9E-64A3-89E569A3E7A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F80A264-7099-3F9E-64A3-89E569A3E7A4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DDFB870B-BDED-4629-927D-07E370E6AD5F}
EndGlobalSection
EndGlobal

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);
}