Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ export default {
if (path.startsWith("/api/")) {
const rest = path.slice(5).replace(/\/$/, "");

// GET /api/auth/config — public config for plugin (Discord client ID for PKCE authorize URL)
if (method === "GET" && rest === "auth/config") {
return jsonResponse({
success: true,
data: { discordClientId: env.DISCORD_CLIENT_ID ?? "" },
});
}

// POST /api/auth/discord/callback — server-side web callback (if frontend posts code)
if (method === "POST" && rest === "auth/discord/callback") {
let body: { code?: string; state?: string; redirect_uri?: string };
Expand Down
49 changes: 49 additions & 0 deletions apps/plugin/WinPodiums.Plugin/Auth/PkceHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using System.Security.Cryptography;
using System.Text;

namespace WinPodiums.Plugin.Auth
{
/// <summary>
/// PKCE code verifier and code challenge (S256) for Discord OAuth2.
/// See TP-SPOC-002 and docs/design/integrations/discord-integration.md.
/// </summary>
public static class PkceHelper
{
/// <summary>Generate a cryptographically random code verifier (43–128 chars, base64url).</summary>
public static string GenerateVerifier()
{
var bytes = new byte[32];
using (var rng = RandomNumberGenerator.Create())
rng.GetBytes(bytes);
return Base64UrlEncode(bytes);
}

/// <summary>Compute code challenge = base64url(SHA256(utf8(verifier))). Method S256.</summary>
public static string GenerateChallenge(string codeVerifier)
{
if (string.IsNullOrEmpty(codeVerifier))
throw new ArgumentNullException(nameof(codeVerifier));
var bytes = Encoding.UTF8.GetBytes(codeVerifier);
byte[] hash;
using (var sha = SHA256.Create())
hash = sha.ComputeHash(bytes);
return Base64UrlEncode(hash);
}

/// <summary>Generate a random state string for OAuth2 CSRF protection.</summary>
public static string GenerateState()
{
var bytes = new byte[16];
using (var rng = RandomNumberGenerator.Create())
rng.GetBytes(bytes);
return Base64UrlEncode(bytes);
}

private static string Base64UrlEncode(byte[] bytes)
{
var base64 = Convert.ToBase64String(bytes);
return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
}
110 changes: 107 additions & 3 deletions apps/plugin/WinPodiums.Plugin/Core/PluginMain.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
using System;
using System.Diagnostics;
using System.Net;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Media;
using GameReaderCommon;
using SimHub.Plugins;
using WinPodiums.Plugin.Auth;
using WinPodiums.Plugin.Services;
using WinPodiums.Plugin.UI;

namespace WinPodiums.Plugin.Core
{
/// <summary>
/// SimHub plugin entry point. Phase 1: manual token auth + one heartbeat API call.
/// SimHub plugin entry point. Phase 1: browser PKCE primary; manual token debug-only; one heartbeat API call.
/// Implements IPlugin and IDataPlugin so SimHub loads the DLL. See docs/design/components/simhub-plugin.md.
/// </summary>
[PluginName("WinPodiums")]
[PluginDescription("WinPodiums telemetry verification and podium submission.")]
[PluginAuthor("WinPodiums")]
public class PluginMain : IPlugin, IDataPlugin
public class PluginMain : IPlugin, IDataPlugin, IWPFSettingsV2
{
private ApiClient? _apiClient;
private string _apiBaseUrl = "https://winpodiums.com";
Expand Down Expand Up @@ -82,7 +86,99 @@ public string? DiscordId
}

/// <summary>
/// Authenticate using a one-time token from the website (manual flow).
/// Authenticate using browser-launched Discord OAuth (PKCE). Primary auth per PRD-001.
/// Opens browser; user signs in with Discord; callback returns to plugin; tokens stored with DPAPI.
/// </summary>
/// <returns>True if auth succeeded and tokens were stored.</returns>
public async Task<bool> AuthenticateWithBrowserAsync()
{
if (_apiClient == null)
return false;

const int callbackPort = 54321;
var redirectUri = $"http://127.0.0.1:{callbackPort}/callback";

AuthConfigResult config;
try
{
config = await _apiClient.GetAuthConfigAsync();
}
catch
{
return false;
}

if (string.IsNullOrEmpty(config.DiscordClientId))
return false;

var state = PkceHelper.GenerateState();
var codeVerifier = PkceHelper.GenerateVerifier();
var codeChallenge = PkceHelper.GenerateChallenge(codeVerifier);

var authUrl =
"https://discord.com/api/oauth2/authorize?" +
$"client_id={Uri.EscapeDataString(config.DiscordClientId)}" +
$"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
"&response_type=code" +
"&scope=identify" +
$"&state={Uri.EscapeDataString(state)}" +
$"&code_challenge={Uri.EscapeDataString(codeChallenge)}" +
"&code_challenge_method=S256";

var listener = new HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{callbackPort}/callback/");
listener.Start();

try
{
Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });

var getContextTask = listener.GetContextAsync();
var delayTask = Task.Delay(TimeSpan.FromMinutes(5));
var completed = await Task.WhenAny(getContextTask, delayTask).ConfigureAwait(false);

if (completed == delayTask)
{
listener.Stop();
return false;
}

var context = await getContextTask.ConfigureAwait(false);
var query = context.Request.QueryString;
var receivedState = query["state"];
var code = query["code"];

var responseHtml = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"/><title>WinPodiums</title></head><body><p>Success, you can close this window.</p></body></html>";
var responseBytes = System.Text.Encoding.UTF8.GetBytes(responseHtml);
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength64 = responseBytes.Length;
await context.Response.OutputStream.WriteAsync(responseBytes, 0, responseBytes.Length).ConfigureAwait(false);
context.Response.OutputStream.Close();

if (receivedState != state || string.IsNullOrEmpty(code))
{
listener.Stop();
return false;
}

listener.Stop();

var result = await _apiClient.DiscordExchangeAsync(code, codeVerifier, redirectUri).ConfigureAwait(false);
if (string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.DiscordId))
return false;

TokenStorage.Save(result.AccessToken!, result.DiscordId!);
return true;
}
catch
{
try { listener.Stop(); } catch { }
return false;
}
}

/// <summary>
/// Authenticate using a one-time token from the website (manual flow). Debug only, feature-flagged.
/// Call after user pastes token from https://winpodiums.com/auth/token.
/// </summary>
/// <param name="tokenCode">The 8-character token from the website.</param>
Expand Down Expand Up @@ -133,5 +229,13 @@ public void Logout()
{
TokenStorage.Clear();
}

/// <summary>
/// Return the WPF settings control for SimHub (Link to Discord, Send heartbeat, status). TP-SPOC-004.
/// </summary>
public Control GetWPFSettingsControl(PluginManager pluginManager)
{
return new WinPodiumsSettingsControl(this);
}
}
}
62 changes: 61 additions & 1 deletion apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,62 @@ public ApiClient(string baseUrl = "https://winpodiums.com")
}

/// <summary>
/// Exchange a one-time manual token for Discord ID and access token.
/// Get public auth config (e.g. Discord client ID for PKCE authorize URL). No auth required.
/// </summary>
public async Task<AuthConfigResult> GetAuthConfigAsync()
{
var url = $"{_baseUrl}/api/auth/config";
var res = await _http.GetAsync(url);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
{
var err = TryParseError(json);
throw new ApiException(err ?? $"HTTP {(int)res.StatusCode}", (int)res.StatusCode);
}
var obj = JObject.Parse(json);
var data = obj["data"];
if (data == null)
throw new ApiException("Invalid response: missing data", 200);
return new AuthConfigResult
{
DiscordClientId = data["discordClientId"]?.ToString() ?? ""
};
}

/// <summary>
/// Exchange PKCE authorization code for Discord ID and access token (plugin browser flow).
/// </summary>
public async Task<TokenExchangeResult> DiscordExchangeAsync(string code, string codeVerifier, string redirectUri)
{
var url = $"{_baseUrl}/api/auth/discord/exchange";
var body = new JObject
{
["code"] = code?.Trim(),
["code_verifier"] = codeVerifier?.Trim(),
["redirect_uri"] = redirectUri?.Trim()
};
var content = new StringContent(body.ToString(), Encoding.UTF8, "application/json");
var res = await _http.PostAsync(url, content);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
{
var err = TryParseError(json);
throw new ApiException(err ?? $"HTTP {(int)res.StatusCode}", (int)res.StatusCode);
}
var obj = JObject.Parse(json);
var data = obj["data"];
if (data == null)
throw new ApiException("Invalid response: missing data", 200);
return new TokenExchangeResult
{
DiscordId = data["discordId"]?.ToString(),
AccessToken = data["access_token"]?.ToString(),
ExpiresIn = data["expires_in"]?.Value<int>() ?? 0
};
}

/// <summary>
/// Exchange a one-time manual token for Discord ID and access token (debug only).
/// </summary>
public async Task<TokenExchangeResult> TokenExchangeAsync(string tokenCode)
{
Expand Down Expand Up @@ -85,6 +140,11 @@ public async Task HeartbeatAsync(string accessToken, string pluginVersion = "1.0
}
}

public class AuthConfigResult
{
public string DiscordClientId { get; set; } = "";
}

public class TokenExchangeResult
{
public string? DiscordId { get; set; }
Expand Down
26 changes: 26 additions & 0 deletions apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<UserControl x:Class="WinPodiums.Plugin.UI.WinPodiumsSettingsControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="280" d:DesignWidth="360">
<StackPanel Margin="16">
<TextBlock Text="WinPodiums" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,12"/>

<!-- Auth section -->
<TextBlock Text="Authentication" FontWeight="SemiBold" Margin="0,0,0,6"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button x:Name="LinkButton" Content="Link to Discord" Padding="12,6" Margin="0,0,8,0" Click="LinkButton_Click"/>
<Button x:Name="UnlinkButton" Content="Unlink" Padding="12,6" Margin="0,0,8,0" Click="UnlinkButton_Click" IsEnabled="False"/>
</StackPanel>
<TextBlock x:Name="AuthStatusText" Text="Not linked" Margin="0,0,0,16"/>

<!-- Heartbeat section -->
<TextBlock Text="Verification" FontWeight="SemiBold" Margin="0,0,0,6"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button x:Name="HeartbeatButton" Content="Send heartbeat" Padding="12,6" Margin="0,0,8,0" Click="HeartbeatButton_Click" IsEnabled="False"/>
</StackPanel>
<TextBlock x:Name="HeartbeatStatusText" Text="—" Margin="0,0,0,0"/>
</StackPanel>
</UserControl>
87 changes: 87 additions & 0 deletions apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System;
using System.Windows;
using System.Windows.Controls;
using WinPodiums.Plugin.Core;

namespace WinPodiums.Plugin.UI
{
/// <summary>
/// Minimal SimHub settings panel: Link to Discord, Send heartbeat, status (TP-SPOC-004).
/// </summary>
public partial class WinPodiumsSettingsControl : UserControl
{
private readonly PluginMain _plugin;

public WinPodiumsSettingsControl(PluginMain plugin)
{
InitializeComponent();
_plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
Loaded += (_, __) => RefreshStatus();
}

private void RefreshStatus()
{
var linked = _plugin.IsAuthenticated;
AuthStatusText.Text = linked ? $"Linked" + (string.IsNullOrEmpty(_plugin.DiscordId) ? "" : $" ({_plugin.DiscordId})") : "Not linked";
LinkButton.Visibility = linked ? Visibility.Collapsed : Visibility.Visible;
UnlinkButton.Visibility = linked ? Visibility.Visible : Visibility.Collapsed;
UnlinkButton.IsEnabled = linked;
HeartbeatButton.IsEnabled = linked;
}

private async void LinkButton_Click(object sender, RoutedEventArgs e)
{
LinkButton.IsEnabled = false;
AuthStatusText.Text = "Linking…";
try
{
var ok = await _plugin.AuthenticateWithBrowserAsync().ConfigureAwait(true);
if (ok)
{
AuthStatusText.Text = "Linked" + (string.IsNullOrEmpty(_plugin.DiscordId) ? "" : $" ({_plugin.DiscordId})");
LinkButton.Visibility = Visibility.Collapsed;
UnlinkButton.Visibility = Visibility.Visible;
UnlinkButton.IsEnabled = true;
HeartbeatButton.IsEnabled = true;
}
else
{
AuthStatusText.Text = "Link failed";
}
}
catch
{
AuthStatusText.Text = "Link failed";
}
finally
{
LinkButton.IsEnabled = true;
}
}

private void UnlinkButton_Click(object sender, RoutedEventArgs e)
{
_plugin.Logout();
RefreshStatus();
}

private async void HeartbeatButton_Click(object sender, RoutedEventArgs e)
{
HeartbeatButton.IsEnabled = false;
HeartbeatStatusText.Text = "Sending…";
try
{
var ok = await _plugin.SendHeartbeatAsync("1.0.0").ConfigureAwait(true);
HeartbeatStatusText.Text = ok ? "Heartbeat OK" : "Heartbeat failed";
}
catch
{
HeartbeatStatusText.Text = "Heartbeat failed";
}
finally
{
HeartbeatButton.IsEnabled = true;
}
}
}
}
Loading
Loading