From 347bbe0543d80694693563c95619548838321730 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Sun, 1 Feb 2026 20:37:43 -0500 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20complete=20SimHub=20POC=20?= =?UTF-8?q?=E2=80=94=20PKCE=20auth,=20minimal=20UI,=20E2E=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: GET /api/auth/config for Discord client ID (PKCE) - Plugin: PkceHelper, browser PKCE flow, WinPodiumsSettingsControl (WPF) - PluginMain: IWPFSettingsV2, AuthenticateWithBrowserAsync - Docs: manual E2E steps in development.md, next-steps link --- apps/api/src/index.ts | 8 ++ .../WinPodiums.Plugin/Auth/PkceHelper.cs | 49 ++++++++ .../WinPodiums.Plugin/Core/PluginMain.cs | 110 +++++++++++++++++- .../WinPodiums.Plugin/Services/ApiClient.cs | 62 +++++++++- .../UI/WinPodiumsSettingsControl.xaml | 26 +++++ .../UI/WinPodiumsSettingsControl.xaml.cs | 87 ++++++++++++++ .../WinPodiums.Plugin.csproj | 1 + docs/architecture/next-steps.md | 2 +- docs/guides/development.md | 19 +++ package-lock.json | 2 +- 10 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 apps/plugin/WinPodiums.Plugin/Auth/PkceHelper.cs create mode 100644 apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml create mode 100644 apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml.cs diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a1fb09a..3fe16de 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 }; diff --git a/apps/plugin/WinPodiums.Plugin/Auth/PkceHelper.cs b/apps/plugin/WinPodiums.Plugin/Auth/PkceHelper.cs new file mode 100644 index 0000000..7483454 --- /dev/null +++ b/apps/plugin/WinPodiums.Plugin/Auth/PkceHelper.cs @@ -0,0 +1,49 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace WinPodiums.Plugin.Auth +{ + /// + /// PKCE code verifier and code challenge (S256) for Discord OAuth2. + /// See TP-SPOC-002 and docs/design/integrations/discord-integration.md. + /// + public static class PkceHelper + { + /// Generate a cryptographically random code verifier (43–128 chars, base64url). + public static string GenerateVerifier() + { + var bytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(bytes); + return Base64UrlEncode(bytes); + } + + /// Compute code challenge = base64url(SHA256(utf8(verifier))). Method S256. + 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); + } + + /// Generate a random state string for OAuth2 CSRF protection. + 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('/', '_'); + } + } +} diff --git a/apps/plugin/WinPodiums.Plugin/Core/PluginMain.cs b/apps/plugin/WinPodiums.Plugin/Core/PluginMain.cs index 2491d90..dbd5c96 100644 --- a/apps/plugin/WinPodiums.Plugin/Core/PluginMain.cs +++ b/apps/plugin/WinPodiums.Plugin/Core/PluginMain.cs @@ -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 { /// - /// 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. /// [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"; @@ -82,7 +86,99 @@ public string? DiscordId } /// - /// 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. + /// + /// True if auth succeeded and tokens were stored. + public async Task 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 = "WinPodiums

Success, you can close this window.

"; + 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; + } + } + + /// + /// 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. /// /// The 8-character token from the website. @@ -133,5 +229,13 @@ public void Logout() { TokenStorage.Clear(); } + + /// + /// Return the WPF settings control for SimHub (Link to Discord, Send heartbeat, status). TP-SPOC-004. + /// + public Control GetWPFSettingsControl(PluginManager pluginManager) + { + return new WinPodiumsSettingsControl(this); + } } } diff --git a/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs b/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs index 8845e6f..84db611 100644 --- a/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs +++ b/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs @@ -24,7 +24,62 @@ public ApiClient(string baseUrl = "https://winpodiums.com") } /// - /// 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. + /// + public async Task 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() ?? "" + }; + } + + /// + /// Exchange PKCE authorization code for Discord ID and access token (plugin browser flow). + /// + public async Task 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() ?? 0 + }; + } + + /// + /// Exchange a one-time manual token for Discord ID and access token (debug only). /// public async Task TokenExchangeAsync(string tokenCode) { @@ -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; } diff --git a/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml b/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml new file mode 100644 index 0000000..734dfec --- /dev/null +++ b/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml @@ -0,0 +1,26 @@ + + + + + + + +