Initial commit: BARON SSO 샘플 (WebView OIDC PKCE 인증 라이브러리 + 데모 앱)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
|
||||||
9
BaronSoftware.SSO.Sample/App.xaml
Normal file
9
BaronSoftware.SSO.Sample/App.xaml
Normal 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>
|
||||||
14
BaronSoftware.SSO.Sample/App.xaml.cs
Normal file
14
BaronSoftware.SSO.Sample/App.xaml.cs
Normal 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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
10
BaronSoftware.SSO.Sample/AssemblyInfo.cs
Normal file
10
BaronSoftware.SSO.Sample/AssemblyInfo.cs
Normal 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)
|
||||||
|
)]
|
||||||
21
BaronSoftware.SSO.Sample/BaronSoftware.SSO.Sample.csproj
Normal file
21
BaronSoftware.SSO.Sample/BaronSoftware.SSO.Sample.csproj
Normal 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>
|
||||||
38
BaronSoftware.SSO.Sample/MainWindow.xaml
Normal file
38
BaronSoftware.SSO.Sample/MainWindow.xaml
Normal 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="‘웹뷰로 로그인’ → 토큰 저장 → 다음 실행 시 ‘자동 로그인(갱신)’.

[콘솔 사전 설정 2가지]
 1) 리디렉션 URI 설정 → 인증 콜백 URL 에 추가:
 http://127.0.0.1:8421/baron-sample/auth/callback
 2) 스코프 추가 → 'offline_access' (refresh_token 발급용, 자동 로그인 필수)"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
137
BaronSoftware.SSO.Sample/MainWindow.xaml.cs
Normal file
137
BaronSoftware.SSO.Sample/MainWindow.xaml.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
77
BaronSoftware.SSO.Sample/SampleSettings.cs
Normal file
77
BaronSoftware.SSO.Sample/SampleSettings.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
50
BaronSoftware.SSO.Sample/SettingsWindow.xaml
Normal file
50
BaronSoftware.SSO.Sample/SettingsWindow.xaml
Normal 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
• ClientId : 콘솔 '앱 자격 증명'의 퍼블릭 클라이언트 ID
• RedirectUri : 콘솔에 등록된 로그인 콜백과 문자 그대로 일치
• 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>
|
||||||
33
BaronSoftware.SSO.Sample/SettingsWindow.xaml.cs
Normal file
33
BaronSoftware.SSO.Sample/SettingsWindow.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
BaronSoftware.SSO.Sample/appsettings.json
Normal file
21
BaronSoftware.SSO.Sample/appsettings.json
Normal 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
31
BaronSoftware.SSO.sln
Normal 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
|
||||||
126
BaronSoftware.SSO/BaronSSO.cs
Normal file
126
BaronSoftware.SSO/BaronSSO.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
46
BaronSoftware.SSO/BaronSSOOption.cs
Normal file
46
BaronSoftware.SSO/BaronSSOOption.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
20
BaronSoftware.SSO/BaronSoftware.SSO.csproj
Normal file
20
BaronSoftware.SSO/BaronSoftware.SSO.csproj
Normal 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>
|
||||||
15
BaronSoftware.SSO/Exceptions/InvalidUserException.cs
Normal file
15
BaronSoftware.SSO/Exceptions/InvalidUserException.cs
Normal 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
14
BaronSoftware.SSO/Exceptions/UserJsonParsingException.cs
Normal file
14
BaronSoftware.SSO/Exceptions/UserJsonParsingException.cs
Normal 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; }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
11
BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml
Normal file
11
BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml
Normal 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>
|
||||||
114
BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs
Normal file
114
BaronSoftware.SSO/Features/LoginWindow/LoginWindow.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
BaronSoftware.SSO/Features/Validator/IUserValidator.cs
Normal file
9
BaronSoftware.SSO/Features/Validator/IUserValidator.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
namespace BaronSoftware.SSO
|
||||||
|
{
|
||||||
|
/// <summary>id_token을 검증(서명/발급자/대상/만료)하고 파싱된 JWT를 반환합니다.</summary>
|
||||||
|
public interface IUserValidator
|
||||||
|
{
|
||||||
|
public void Validate(UserInfo user);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
BaronSoftware.SSO/GlobalConfigs.cs
Normal file
18
BaronSoftware.SSO/GlobalConfigs.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
103
BaronSoftware.SSO/Models/UserInfo.cs
Normal file
103
BaronSoftware.SSO/Models/UserInfo.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
BaronSoftware.SSO/OIDC/PKCEUtil.cs
Normal file
28
BaronSoftware.SSO/OIDC/PKCEUtil.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
BaronSoftware.SSO/OIDC/SsoClient.cs
Normal file
188
BaronSoftware.SSO/OIDC/SsoClient.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
BaronSoftware.SSO/OIDC/TokenResponse.cs
Normal file
10
BaronSoftware.SSO/OIDC/TokenResponse.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace BaronSoftware.SSO
|
||||||
|
{
|
||||||
|
/// <summary>토큰 엔드포인트 응답.</summary>
|
||||||
|
internal sealed record TokenResponse(
|
||||||
|
string IdToken,
|
||||||
|
string AccessToken,
|
||||||
|
string? RefreshToken,
|
||||||
|
int ExpiresIn,
|
||||||
|
string RawJson);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user