diff --git a/Directory.Packages.props b/Directory.Packages.props
index 348ec6c..4dc04f7 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,19 +4,27 @@
true
-
+
-
-
+
+
-
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/NuGet.Config b/NuGet.Config
index ad32aba..7123d24 100644
--- a/NuGet.Config
+++ b/NuGet.Config
@@ -4,6 +4,8 @@
+
@@ -13,6 +15,10 @@
+
+
+
+
\ No newline at end of file
diff --git a/samples/BSkyOAuth/BSkyOAuth.MacCatalyst/BSkyOAuth.MacCatalyst.csproj b/samples/BSkyOAuth/BSkyOAuth.MacCatalyst/BSkyOAuth.MacCatalyst.csproj
index 42ac5fb..d6db9f2 100644
--- a/samples/BSkyOAuth/BSkyOAuth.MacCatalyst/BSkyOAuth.MacCatalyst.csproj
+++ b/samples/BSkyOAuth/BSkyOAuth.MacCatalyst/BSkyOAuth.MacCatalyst.csproj
@@ -12,6 +12,7 @@
18.0
false
true
+ $(NoWarn);CS8002
+
+
+ Exe
+ BSkyOAuthMaui
+ true
+ true
+ enable
+ enable
+ false
+
+
+ SourceGen
+
+
+ BSkyOAuthMaui
+
+
+ com.companyname.bskyoauthmaui
+
+
+ 1.0
+ 1
+
+
+ None
+
+ 15.0
+ 15.0
+ 21.0
+
+ $(NoWarn);CS8002
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/MainPage.xaml b/samples/BSkyOAuth/BSkyOAuthMaui/MainPage.xaml
new file mode 100644
index 0000000..674e394
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/MainPage.xaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/MainPage.xaml.cs b/samples/BSkyOAuth/BSkyOAuthMaui/MainPage.xaml.cs
new file mode 100644
index 0000000..8abd1c7
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/MainPage.xaml.cs
@@ -0,0 +1,251 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AppBsky.Feed;
+using CarpaNet;
+using CarpaNet.Identity;
+using CarpaNet.OAuth;
+using CarpaNet.OAuth.Storage;
+
+namespace BSkyOAuthMaui;
+
+public partial class MainPage : ContentPage
+{
+ private const string ClientMetadataUrl = "https://drasticactions.vip/client-metadata.json";
+ private const string RedirectUri = "vip.drasticactions:/callback";
+
+ private readonly OAuthSession oauthSession;
+ private readonly OauthStore sessionStore;
+
+ private ATProtoOAuthClient? client;
+
+ public MainPage()
+ {
+ InitializeComponent();
+
+ this.sessionStore = new OauthStore();
+
+ var config = new OAuthClientConfig
+ {
+ ClientId = ClientMetadataUrl,
+ RedirectUri = RedirectUri,
+ Scope = "atproto transition:generic",
+ JsonOptions = ATProtoClientFactory.CreateJsonOptions(),
+ SessionStore = this.sessionStore
+ };
+
+ this.oauthSession = new OAuthSession(config);
+ }
+
+ private async void OnLoginClicked(object? sender, EventArgs e)
+ {
+ var atIdentifier = new ATIdentifier(HandleEntry.Text ?? string.Empty);
+ if (!atIdentifier.IsValid)
+ {
+ await DisplayAlertAsync("Error", "Invalid handle", "OK");
+ return;
+ }
+
+ SetLoading(true);
+
+ try
+ {
+ var authUrl = await this.oauthSession.AuthorizeAsync(atIdentifier);
+ var callbackUri = new Uri(RedirectUri);
+
+ var result = await WebAuthenticator.AuthenticateAsync(new Uri(authUrl), callbackUri);
+
+ // Reconstruct the full callback URL from the result properties
+ var queryParams = string.Join("&", result.Properties.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
+ var fullCallbackUrl = $"{RedirectUri}?{queryParams}";
+
+ this.client = await this.oauthSession.CallbackAsync(fullCallbackUrl);
+
+ if (this.client != null)
+ {
+ GetTimelineBtn.IsEnabled = true;
+ StatusLabel.Text = $"Authenticated as {this.client.Did}";
+ await DisplayAlertAsync("Success", $"Authenticated as {this.client.Did}", "OK");
+ }
+ else
+ {
+ await DisplayAlertAsync("Error", "Failed to authenticate", "OK");
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ // User cancelled the auth flow
+ }
+ catch (Exception ex)
+ {
+ await DisplayAlertAsync("Error", $"Authentication failed: {ex.Message}", "OK");
+ }
+ finally
+ {
+ SetLoading(false);
+ }
+ }
+
+ private async void OnLoadSessionClicked(object? sender, EventArgs e)
+ {
+ var input = await DisplayPromptAsync("Restore Session", "Enter your DID or handle", placeholder: "did:plc:... or alice.bsky.social");
+ if (string.IsNullOrWhiteSpace(input))
+ return;
+
+ var atIdentifier = new ATIdentifier(input.Trim());
+ if (!atIdentifier.IsValid)
+ {
+ await DisplayAlertAsync("Error", "Invalid DID or handle.", "OK");
+ return;
+ }
+
+ SetLoading(true);
+
+ try
+ {
+ var did = input.Trim();
+ if (atIdentifier.IsHandle)
+ {
+ using var resolver = new IdentityResolver();
+ var doc = await resolver.ResolveAsync(did);
+ did = doc.Id;
+ }
+
+ var session = await this.oauthSession.RestoreSessionAsync(did);
+ if (session != null)
+ {
+ this.client = session;
+ GetTimelineBtn.IsEnabled = true;
+ StatusLabel.Text = $"Session restored for {session.Did}";
+ await DisplayAlertAsync("Success", $"Session restored for {session.Did}", "OK");
+ }
+ else
+ {
+ await DisplayAlertAsync("Error", "No saved session found for that identifier.", "OK");
+ }
+ }
+ catch (Exception ex)
+ {
+ await DisplayAlertAsync("Error", $"Failed to restore session: {ex.Message}", "OK");
+ }
+ finally
+ {
+ SetLoading(false);
+ }
+ }
+
+ private async void OnGetTimelineClicked(object? sender, EventArgs e)
+ {
+ if (this.client == null)
+ return;
+
+ SetLoading(true);
+
+ try
+ {
+ var parameters = new GetTimelineParameters { Limit = 25 };
+ var timeline = await this.client.AppBskyFeedGetTimelineAsync(parameters);
+
+ var items = new List();
+ if (timeline.Feed != null)
+ {
+ foreach (var item in timeline.Feed)
+ {
+ var author = item.Post?.Author;
+ var text = string.Empty;
+ if (item.Post?.Record is System.Text.Json.JsonElement recordElement)
+ {
+ if (recordElement.TryGetProperty("text", out var textElement))
+ {
+ text = textElement.GetString() ?? string.Empty;
+ }
+ }
+
+ items.Add(new TimelineItem
+ {
+ AuthorDisplay = $"@{author?.Handle} ({author?.DisplayName})",
+ Text = text
+ });
+ }
+ }
+
+ TimelineView.ItemsSource = items;
+ StatusLabel.Text = $"Loaded {items.Count} posts";
+ }
+ catch (Exception ex)
+ {
+ await DisplayAlertAsync("Error", $"Failed to get timeline: {ex.Message}", "OK");
+ }
+ finally
+ {
+ SetLoading(false);
+ }
+ }
+
+ private void SetLoading(bool isLoading)
+ {
+ LoadingIndicator.IsRunning = isLoading;
+ LoadingIndicator.IsVisible = isLoading;
+ LoginBtn.IsEnabled = !isLoading;
+ LoadSessionBtn.IsEnabled = !isLoading;
+ GetTimelineBtn.IsEnabled = !isLoading && this.client != null;
+ }
+}
+
+public class TimelineItem
+{
+ public string AuthorDisplay { get; set; } = string.Empty;
+ public string Text { get; set; } = string.Empty;
+}
+
+public sealed class OauthStore : IOAuthSessionStore
+{
+ private readonly string _directory;
+
+ public OauthStore()
+ {
+ _directory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "BSkyOAuth");
+ Directory.CreateDirectory(_directory);
+ }
+
+ public Task StoreAsync(string sub, OAuthSessionData data, CancellationToken cancellationToken = default)
+ {
+ var json = JsonSerializer.Serialize(data, OAuthStoreJsonContext.Default.OAuthSessionData);
+ File.WriteAllText(GetPath(sub), json);
+ return Task.CompletedTask;
+ }
+
+ public Task GetAsync(string sub, CancellationToken cancellationToken = default)
+ {
+ var path = GetPath(sub);
+ if (!File.Exists(path))
+ return Task.FromResult(null);
+ var json = File.ReadAllText(path);
+ var data = JsonSerializer.Deserialize(json, OAuthStoreJsonContext.Default.OAuthSessionData);
+ return Task.FromResult(data);
+ }
+
+ public Task DeleteAsync(string sub, CancellationToken cancellationToken = default)
+ {
+ var path = GetPath(sub);
+ if (File.Exists(path))
+ File.Delete(path);
+ return Task.CompletedTask;
+ }
+
+ private string GetPath(string sub) =>
+ Path.Combine(_directory, $"oauth-{sub.Replace(":", "_")}.json");
+}
+
+[JsonSerializable(typeof(OAuthSessionData))]
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+public partial class OAuthStoreJsonContext : JsonSerializerContext
+{
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/MauiProgram.cs b/samples/BSkyOAuth/BSkyOAuthMaui/MauiProgram.cs
new file mode 100644
index 0000000..6f7280b
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/MauiProgram.cs
@@ -0,0 +1,24 @@
+using Microsoft.Extensions.Logging;
+
+namespace BSkyOAuthMaui;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/AndroidManifest.xml b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..f65fc33
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/MainActivity.cs b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..47ebab2
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/MainActivity.cs
@@ -0,0 +1,20 @@
+using Android.App;
+using Android.Content;
+using Android.Content.PM;
+using Android.OS;
+
+namespace BSkyOAuthMaui;
+
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
+
+[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
+[IntentFilter(
+ [Intent.ActionView],
+ Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable],
+ DataScheme = "vip.drasticactions")]
+public class WebAuthenticatorCallbackActivity : Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity
+{
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/MainApplication.cs b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..6745ee0
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace BSkyOAuthMaui;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/Resources/values/colors.xml b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 0000000..5cd1604
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #2B0B98
+ #2B0B98
+
\ No newline at end of file
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/AppDelegate.cs b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/AppDelegate.cs
new file mode 100644
index 0000000..6e6a0f7
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace BSkyOAuthMaui;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Entitlements.plist b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Entitlements.plist
new file mode 100644
index 0000000..8e87c0c
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Info.plist b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 0000000..385e722
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UIDeviceFamily
+
+ 2
+
+ LSApplicationCategoryType
+ public.app-category.lifestyle
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+ CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ vip.drasticactions
+
+ CFBundleURLName
+ vip.drasticactions
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Program.cs b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Program.cs
new file mode 100644
index 0000000..3c7c3e5
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/MacCatalyst/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace BSkyOAuthMaui;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/App.xaml b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/App.xaml
new file mode 100644
index 0000000..4c4328d
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/App.xaml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/App.xaml.cs b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/App.xaml.cs
new file mode 100644
index 0000000..82a4a06
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,24 @@
+using Microsoft.UI.Xaml;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace BSkyOAuthMaui.WinUI;
+
+///
+/// Provides application-specific behavior to supplement the default Application class.
+///
+public partial class App : MauiWinUIApplication
+{
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/Package.appxmanifest b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 0000000..0e2bf16
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ $placeholder$
+ User Name
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/app.manifest b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/app.manifest
new file mode 100644
index 0000000..a33f528
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/Windows/app.manifest
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+ true
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/AppDelegate.cs b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/AppDelegate.cs
new file mode 100644
index 0000000..6e6a0f7
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace BSkyOAuthMaui;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Info.plist b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Info.plist
new file mode 100644
index 0000000..4494349
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Info.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+ CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ vip.drasticactions
+
+ CFBundleURLName
+ vip.drasticactions
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Program.cs b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Program.cs
new file mode 100644
index 0000000..3c7c3e5
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace BSkyOAuthMaui;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..1ea3a5d
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,51 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+
+
+
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Properties/launchSettings.json b/samples/BSkyOAuth/BSkyOAuthMaui/Properties/launchSettings.json
new file mode 100644
index 0000000..f4c6c8d
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Windows Machine": {
+ "commandName": "Project",
+ "nativeDebugging": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/AppIcon/appicon.svg b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/AppIcon/appicon.svg
new file mode 100644
index 0000000..5f04fcf
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/AppIcon/appiconfg.svg b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 0000000..62d66d7
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Fonts/OpenSans-Regular.ttf b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Fonts/OpenSans-Regular.ttf
new file mode 100644
index 0000000..9b09396
Binary files /dev/null and b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Fonts/OpenSans-Regular.ttf differ
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Fonts/OpenSans-Semibold.ttf b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Fonts/OpenSans-Semibold.ttf
new file mode 100644
index 0000000..ca26718
Binary files /dev/null and b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Fonts/OpenSans-Semibold.ttf differ
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Images/dotnet_bot.png b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Images/dotnet_bot.png
new file mode 100644
index 0000000..054167e
Binary files /dev/null and b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Images/dotnet_bot.png differ
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Raw/AboutAssets.txt b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Raw/AboutAssets.txt
new file mode 100644
index 0000000..f22d3bf
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Raw/AboutAssets.txt
@@ -0,0 +1,15 @@
+Any raw assets you want to be deployed with your application can be placed in
+this directory (and child directories). Deployment of the asset to your application
+is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
+
+
+
+These files will be deployed with your package and will be accessible using Essentials:
+
+ async Task LoadMauiAsset()
+ {
+ using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
+ using var reader = new StreamReader(stream);
+
+ var contents = reader.ReadToEnd();
+ }
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Splash/splash.svg b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Splash/splash.svg
new file mode 100644
index 0000000..62d66d7
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Splash/splash.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Styles/Colors.xaml b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Styles/Colors.xaml
new file mode 100644
index 0000000..daae3bd
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Styles/Colors.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+ #512BD4
+ #ac99ea
+ #242424
+ #DFD8F7
+ #9880e5
+ #2B0B98
+
+ White
+ Black
+ #D600AA
+ #190649
+ #1f1f1f
+
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #212121
+ #141414
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Styles/Styles.xaml b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Styles/Styles.xaml
new file mode 100644
index 0000000..0dce374
--- /dev/null
+++ b/samples/BSkyOAuth/BSkyOAuthMaui/Resources/Styles/Styles.xaml
@@ -0,0 +1,434 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/BSkyOAuth/BskyOauth.slnx b/samples/BSkyOAuth/BskyOauth.slnx
index a7a1c6a..d33e45d 100644
--- a/samples/BSkyOAuth/BskyOauth.slnx
+++ b/samples/BSkyOAuth/BskyOauth.slnx
@@ -1,5 +1,12 @@
+
+
+
+
+
+
-
\ No newline at end of file
+
+
diff --git a/src/CarpaNet.OAuth/ATProtoDPoPAuthHandler.cs b/src/CarpaNet.OAuth/ATProtoDPoPAuthHandler.cs
index f60249d..a54778f 100644
--- a/src/CarpaNet.OAuth/ATProtoDPoPAuthHandler.cs
+++ b/src/CarpaNet.OAuth/ATProtoDPoPAuthHandler.cs
@@ -52,7 +52,7 @@ protected override async Task SendAsync(
CancellationToken cancellationToken)
{
// Add DPoP proof and auth headers
- _tokenProvider.AddDPoPHeaders(request);
+ await _tokenProvider.AddDPoPHeadersAsync(request).ConfigureAwait(false);
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
_tokenProvider.UpdateNonceFromResponse(response, request.RequestUri!.ToString());
@@ -64,7 +64,7 @@ protected override async Task SendAsync(
// Clone request and add fresh DPoP headers
using var retryRequest = XrpcHttpHandler.CloneRequest(request, "Authorization", "DPoP");
- _tokenProvider.AddDPoPHeaders(retryRequest);
+ await _tokenProvider.AddDPoPHeadersAsync(retryRequest).ConfigureAwait(false);
// Copy custom headers (e.g., atproto-proxy)
foreach (var header in request.Headers)
diff --git a/src/CarpaNet.OAuth/ATProtoOAuthClient.cs b/src/CarpaNet.OAuth/ATProtoOAuthClient.cs
index 9f1f151..81827e8 100644
--- a/src/CarpaNet.OAuth/ATProtoOAuthClient.cs
+++ b/src/CarpaNet.OAuth/ATProtoOAuthClient.cs
@@ -108,7 +108,7 @@ public async Task GetAsync(
_logger.LogDebug("OAuth GET {Nsid}", nsid);
var url = (await XrpcHttpHandler.BuildUrlAsync(BaseUrl, nsid, parameters, _identityResolver, cancellationToken).ConfigureAwait(false)).ToString();
- using var request = _tokenProvider.CreateDPoPRequest(HttpMethod.Get, url);
+ using var request = await _tokenProvider.CreateDPoPRequestAsync(HttpMethod.Get, url).ConfigureAwait(false);
XrpcHttpHandler.AddCommonHeaders(request, null, LabelerDids);
var response = await SendWithRetryAsync(request, url, cancellationToken).ConfigureAwait(false);
@@ -125,7 +125,7 @@ public async Task GetAsync(
ThrowIfDisposed();
var url = (await XrpcHttpHandler.BuildUrlAsync(BaseUrl, nsid, parameters, _identityResolver, cancellationToken).ConfigureAwait(false)).ToString();
- using var request = _tokenProvider.CreateDPoPRequest(HttpMethod.Get, url);
+ using var request = await _tokenProvider.CreateDPoPRequestAsync(HttpMethod.Get, url).ConfigureAwait(false);
XrpcHttpHandler.AddCommonHeaders(request, proxyServiceDid, LabelerDids);
var response = await SendWithRetryAsync(request, url, cancellationToken).ConfigureAwait(false);
@@ -142,7 +142,7 @@ public async Task PostAsync(
_logger.LogDebug("OAuth POST {Nsid}", nsid);
var url = XrpcHttpHandler.BuildUrl(BaseUrl, nsid).ToString();
- using var request = _tokenProvider.CreateDPoPRequest(HttpMethod.Post, url);
+ using var request = await _tokenProvider.CreateDPoPRequestAsync(HttpMethod.Post, url).ConfigureAwait(false);
XrpcHttpHandler.AddCommonHeaders(request, null, LabelerDids);
if (input != null)
@@ -166,7 +166,7 @@ public async Task PostAsync(
ThrowIfDisposed();
var url = XrpcHttpHandler.BuildUrl(BaseUrl, nsid).ToString();
- using var request = _tokenProvider.CreateDPoPRequest(HttpMethod.Post, url);
+ using var request = await _tokenProvider.CreateDPoPRequestAsync(HttpMethod.Post, url).ConfigureAwait(false);
XrpcHttpHandler.AddCommonHeaders(request, proxyServiceDid, LabelerDids);
if (input != null)
@@ -220,7 +220,7 @@ private async Task SendWithRetryAsync(
await _tokenProvider.RefreshAsync(cancellationToken).ConfigureAwait(false);
// Create a new request (can't reuse the old one)
- using var retryRequest = _tokenProvider.CreateDPoPRequest(request.Method, url);
+ using var retryRequest = await _tokenProvider.CreateDPoPRequestAsync(request.Method, url).ConfigureAwait(false);
if (request.Content != null)
{
diff --git a/src/CarpaNet.OAuth/CarpaNet.OAuth.csproj b/src/CarpaNet.OAuth/CarpaNet.OAuth.csproj
index 326e7f0..9e7d7c4 100644
--- a/src/CarpaNet.OAuth/CarpaNet.OAuth.csproj
+++ b/src/CarpaNet.OAuth/CarpaNet.OAuth.csproj
@@ -1,7 +1,7 @@
-
+
- net8.0;net9.0;net10.0;netstandard2.0
+ net8.0;net9.0;net10.0;net10.0-browser;netstandard2.0
enable
enable
latest
@@ -11,6 +11,11 @@
.NET ATProtocol OAuth Implementation Library for CarpaNet.
+
+ $(DefineConstants);BROWSER
+ true
+
+
@@ -20,7 +25,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CarpaNet.OAuth/ClientAssertion.cs b/src/CarpaNet.OAuth/ClientAssertion.cs
index e4ea1d1..ffe05af 100644
--- a/src/CarpaNet.OAuth/ClientAssertion.cs
+++ b/src/CarpaNet.OAuth/ClientAssertion.cs
@@ -1,7 +1,7 @@
using System;
-using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using System.Threading.Tasks;
namespace CarpaNet.OAuth.Crypto;
@@ -23,7 +23,41 @@ public static class ClientAssertion
/// The signing key pair.
/// Optional key ID to include in the header.
/// A signed JWT for client authentication.
+ ///
+ /// On browser platforms, use instead.
+ ///
public static string Create(string clientId, string audience, DPoPKeyPair keyPair, string? keyId = null)
+ {
+ var (headerBase64, payloadBase64) = BuildComponents(clientId, audience, keyPair, keyId);
+ var signingInput = $"{headerBase64}.{payloadBase64}";
+
+ var signature = keyPair.SignData(Encoding.UTF8.GetBytes(signingInput));
+ var signatureBase64 = Pkce.Base64UrlEncode(signature);
+
+ return $"{signingInput}.{signatureBase64}";
+ }
+
+ ///
+ /// Creates a client assertion JWT asynchronously. Works on all platforms including browser.
+ ///
+ /// The client ID.
+ /// The token endpoint URL or issuer.
+ /// The signing key pair.
+ /// Optional key ID to include in the header.
+ /// A signed JWT for client authentication.
+ public static async Task CreateAsync(string clientId, string audience, DPoPKeyPair keyPair, string? keyId = null)
+ {
+ var (headerBase64, payloadBase64) = BuildComponents(clientId, audience, keyPair, keyId);
+ var signingInput = $"{headerBase64}.{payloadBase64}";
+
+ var signature = await keyPair.SignDataAsync(Encoding.UTF8.GetBytes(signingInput)).ConfigureAwait(false);
+ var signatureBase64 = Pkce.Base64UrlEncode(signature);
+
+ return $"{signingInput}.{signatureBase64}";
+ }
+
+ private static (string HeaderBase64, string PayloadBase64) BuildComponents(
+ string clientId, string audience, DPoPKeyPair keyPair, string? keyId)
{
var now = DateTimeOffset.UtcNow;
@@ -46,46 +80,12 @@ public static string Create(string clientId, string audience, DPoPKeyPair keyPai
Exp = now.AddMinutes(1).ToUnixTimeSeconds()
};
- return CreateSignedJwt(header, payload, keyPair);
- }
-
- private static string CreateSignedJwt(
- JwtHeader header,
- ClientAssertionPayload payload,
- DPoPKeyPair keyPair)
- {
var headerJson = JsonSerializer.Serialize(header, OAuthJsonContext.Default.JwtHeader);
var payloadJson = JsonSerializer.Serialize(payload, OAuthJsonContext.Default.ClientAssertionPayload);
var headerBase64 = Pkce.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
var payloadBase64 = Pkce.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
- var signingInput = $"{headerBase64}.{payloadBase64}";
-
- // Use the key pair's internal signing - we need to create a proof without the DPoP-specific fields
- // For now, we'll create a simple ES256 signature
- var jwk = keyPair.ExportKeyPair();
-
- using var ecdsa = CreateEcdsaFromJwk(jwk);
- var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256);
- var signatureBase64 = Pkce.Base64UrlEncode(signature);
-
- return $"{signingInput}.{signatureBase64}";
- }
-
- private static ECDsa CreateEcdsaFromJwk(JsonWebKey jwk)
- {
- var parameters = new ECParameters
- {
- Curve = ECCurve.NamedCurves.nistP256,
- Q = new ECPoint
- {
- X = Pkce.Base64UrlDecode(jwk.X!),
- Y = Pkce.Base64UrlDecode(jwk.Y!)
- },
- D = Pkce.Base64UrlDecode(jwk.D!)
- };
-
- return ECDsa.Create(parameters);
+ return (headerBase64, payloadBase64);
}
}
diff --git a/src/CarpaNet.OAuth/Crypto/BrowserCryptoInterop.cs b/src/CarpaNet.OAuth/Crypto/BrowserCryptoInterop.cs
new file mode 100644
index 0000000..59d8deb
--- /dev/null
+++ b/src/CarpaNet.OAuth/Crypto/BrowserCryptoInterop.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Runtime.InteropServices.JavaScript;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace CarpaNet.OAuth.Crypto;
+
+///
+/// JavaScript interop declarations for WebCrypto ECDSA P-256 operations.
+///
+internal static partial class BrowserCryptoInterop
+{
+ private static Task? _initTask;
+
+ ///
+ /// Ensures the JavaScript module is loaded. Safe to call multiple times.
+ ///
+ internal static Task EnsureInitializedAsync()
+ {
+ return _initTask ??= InitializeCoreAsync();
+ }
+
+ private static async Task InitializeCoreAsync()
+ {
+ // Create a JS module from inline source using a data URI
+ const string moduleSource = """
+ export async function generateKeyPair() {
+ const keyPair = await crypto.subtle.generateKey(
+ { name: "ECDSA", namedCurve: "P-256" },
+ true,
+ ["sign"]
+ );
+ const jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
+ return JSON.stringify({ x: jwk.x, y: jwk.y, d: jwk.d });
+ }
+
+ export async function signData(x, y, d, dataBase64) {
+ const jwk = { kty: "EC", crv: "P-256", x, y, d };
+ const key = await crypto.subtle.importKey(
+ "jwk",
+ jwk,
+ { name: "ECDSA", namedCurve: "P-256" },
+ false,
+ ["sign"]
+ );
+ const data = Uint8Array.from(atob(dataBase64), c => c.charCodeAt(0));
+ const sig = await crypto.subtle.sign(
+ { name: "ECDSA", hash: "SHA-256" },
+ key,
+ data
+ );
+ const bytes = new Uint8Array(sig);
+ let binary = "";
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+ }
+ """;
+
+ // Encode as data URI and import
+ var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(moduleSource));
+ var dataUri = $"data:text/javascript;base64,{base64}";
+
+ await JSHost.ImportAsync("carpanet-crypto", dataUri).ConfigureAwait(false);
+ }
+
+ ///
+ /// Generates a new ECDSA P-256 key pair and returns the JWK as JSON (with x, y, d fields).
+ ///
+ [JSImport("generateKeyPair", "carpanet-crypto")]
+ internal static partial Task GenerateKeyPairAsync();
+
+ ///
+ /// Signs data using ECDSA P-256 with SHA-256.
+ /// Returns signature bytes as a base64-encoded string.
+ ///
+ [JSImport("signData", "carpanet-crypto")]
+ internal static partial Task SignDataRawAsync(
+ string x,
+ string y,
+ string d,
+ string dataBase64);
+
+ ///
+ /// Signs data and returns the signature as a byte array.
+ ///
+ internal static async Task SignDataAsync(string x, string y, string d, byte[] data)
+ {
+ await EnsureInitializedAsync().ConfigureAwait(false);
+ var dataBase64 = Convert.ToBase64String(data);
+ var resultBase64 = await SignDataRawAsync(x, y, d, dataBase64).ConfigureAwait(false);
+ return Convert.FromBase64String(resultBase64);
+ }
+}
diff --git a/src/CarpaNet.OAuth/Crypto/BrowserCryptoProvider.cs b/src/CarpaNet.OAuth/Crypto/BrowserCryptoProvider.cs
new file mode 100644
index 0000000..be75b3f
--- /dev/null
+++ b/src/CarpaNet.OAuth/Crypto/BrowserCryptoProvider.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace CarpaNet.OAuth.Crypto;
+
+///
+/// WebCrypto-based crypto provider for browser (WASM) platforms.
+/// Uses JavaScript interop via .
+///
+internal sealed class BrowserCryptoProvider : ICryptoProvider
+{
+ private readonly string _x;
+ private readonly string _y;
+ private readonly string _d;
+
+ private BrowserCryptoProvider(string x, string y, string d)
+ {
+ _x = x;
+ _y = y;
+ _d = d;
+ }
+
+ ///
+ /// Creates a new provider by generating a P-256 key pair via WebCrypto.
+ ///
+ public static async Task CreateAsync()
+ {
+ await BrowserCryptoInterop.EnsureInitializedAsync().ConfigureAwait(false);
+ var json = await BrowserCryptoInterop.GenerateKeyPairAsync().ConfigureAwait(false);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var x = root.GetProperty("x").GetString()
+ ?? throw new InvalidOperationException("Failed to generate key pair via WebCrypto: missing 'x'.");
+ var y = root.GetProperty("y").GetString()
+ ?? throw new InvalidOperationException("Failed to generate key pair via WebCrypto: missing 'y'.");
+ var d = root.GetProperty("d").GetString()
+ ?? throw new InvalidOperationException("Failed to generate key pair via WebCrypto: missing 'd'.");
+ return new BrowserCryptoProvider(x, y, d);
+ }
+
+ ///
+ /// Creates a provider by importing JWK components (sync, no validation).
+ ///
+ public static BrowserCryptoProvider Import(string x, string y, string d)
+ {
+ return new BrowserCryptoProvider(x, y, d);
+ }
+
+ ///
+ public byte[] SignData(byte[] data)
+ {
+ throw new PlatformNotSupportedException(
+ "Synchronous ECDSA signing is not supported on browser platforms. Use SignDataAsync instead.");
+ }
+
+ ///
+ public async Task SignDataAsync(byte[] data)
+ {
+ return await BrowserCryptoInterop.SignDataAsync(_x, _y, _d, data).ConfigureAwait(false);
+ }
+
+ ///
+ public (string X, string Y) ExportPublicParameters()
+ {
+ return (_x, _y);
+ }
+
+ ///
+ public (string X, string Y, string D) ExportPrivateParameters()
+ {
+ return (_x, _y, _d);
+ }
+
+ ///
+ public void Dispose()
+ {
+ // No-op: no unmanaged resources
+ }
+}
diff --git a/src/CarpaNet.OAuth/Crypto/EcdsaCryptoProvider.cs b/src/CarpaNet.OAuth/Crypto/EcdsaCryptoProvider.cs
new file mode 100644
index 0000000..13ee846
--- /dev/null
+++ b/src/CarpaNet.OAuth/Crypto/EcdsaCryptoProvider.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Security.Cryptography;
+using System.Threading.Tasks;
+
+namespace CarpaNet.OAuth.Crypto;
+
+///
+/// ECDsa-based crypto provider for desktop/server platforms.
+///
+internal sealed class EcdsaCryptoProvider : ICryptoProvider
+{
+ private readonly ECDsa _key;
+
+ private EcdsaCryptoProvider(ECDsa key)
+ {
+ _key = key;
+ }
+
+ ///
+ /// Creates a new provider with a freshly generated P-256 key.
+ ///
+ public static EcdsaCryptoProvider Create()
+ {
+ return new EcdsaCryptoProvider(ECDsa.Create(ECCurve.NamedCurves.nistP256));
+ }
+
+ ///
+ /// Creates a provider by importing JWK components.
+ ///
+ public static EcdsaCryptoProvider Import(string x, string y, string d)
+ {
+ var parameters = new ECParameters
+ {
+ Curve = ECCurve.NamedCurves.nistP256,
+ Q = new ECPoint
+ {
+ X = Pkce.Base64UrlDecode(x),
+ Y = Pkce.Base64UrlDecode(y)
+ },
+ D = Pkce.Base64UrlDecode(d)
+ };
+
+ return new EcdsaCryptoProvider(ECDsa.Create(parameters));
+ }
+
+ ///
+ public byte[] SignData(byte[] data)
+ {
+ return _key.SignData(data, HashAlgorithmName.SHA256);
+ }
+
+ ///
+ public Task SignDataAsync(byte[] data)
+ {
+ return Task.FromResult(SignData(data));
+ }
+
+ ///
+ public (string X, string Y) ExportPublicParameters()
+ {
+ var parameters = _key.ExportParameters(includePrivateParameters: false);
+ return (Pkce.Base64UrlEncode(parameters.Q.X!), Pkce.Base64UrlEncode(parameters.Q.Y!));
+ }
+
+ ///
+ public (string X, string Y, string D) ExportPrivateParameters()
+ {
+ var parameters = _key.ExportParameters(includePrivateParameters: true);
+ return (Pkce.Base64UrlEncode(parameters.Q.X!), Pkce.Base64UrlEncode(parameters.Q.Y!), Pkce.Base64UrlEncode(parameters.D!));
+ }
+
+ ///
+ public void Dispose()
+ {
+ _key.Dispose();
+ }
+}
diff --git a/src/CarpaNet.OAuth/Crypto/ICryptoProvider.cs b/src/CarpaNet.OAuth/Crypto/ICryptoProvider.cs
new file mode 100644
index 0000000..6d9c1e4
--- /dev/null
+++ b/src/CarpaNet.OAuth/Crypto/ICryptoProvider.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Threading.Tasks;
+
+namespace CarpaNet.OAuth.Crypto;
+
+///
+/// Abstracts platform-specific ECDSA P-256 operations for DPoP key pairs.
+///
+internal interface ICryptoProvider : IDisposable
+{
+ ///
+ /// Signs data synchronously using ES256. Throws on browser.
+ ///
+ byte[] SignData(byte[] data);
+
+ ///
+ /// Signs data asynchronously using ES256. Works on all platforms.
+ ///
+ Task SignDataAsync(byte[] data);
+
+ ///
+ /// Exports the public key parameters as base64url-encoded strings.
+ ///
+ (string X, string Y) ExportPublicParameters();
+
+ ///
+ /// Exports the full key parameters (including private key) as base64url-encoded strings.
+ ///
+ (string X, string Y, string D) ExportPrivateParameters();
+}
diff --git a/src/CarpaNet.OAuth/DPoPKeyPair.cs b/src/CarpaNet.OAuth/DPoPKeyPair.cs
index 5b3f599..2d50c0d 100644
--- a/src/CarpaNet.OAuth/DPoPKeyPair.cs
+++ b/src/CarpaNet.OAuth/DPoPKeyPair.cs
@@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using System.Threading.Tasks;
namespace CarpaNet.OAuth.Crypto;
@@ -10,7 +11,7 @@ namespace CarpaNet.OAuth.Crypto;
///
public sealed class DPoPKeyPair : IDisposable
{
- private readonly ECDsa _key;
+ private readonly ICryptoProvider _crypto;
private readonly string _publicKeyJwk;
private readonly string _thumbprint;
private bool _disposed;
@@ -28,9 +29,9 @@ public sealed class DPoPKeyPair : IDisposable
///
/// Creates a new ES256 key pair.
///
- private DPoPKeyPair(ECDsa key, string publicKeyJwk, string thumbprint)
+ private DPoPKeyPair(ICryptoProvider crypto, string publicKeyJwk, string thumbprint)
{
- _key = key;
+ _crypto = crypto;
_publicKeyJwk = publicKeyJwk;
_thumbprint = thumbprint;
}
@@ -38,67 +39,96 @@ private DPoPKeyPair(ECDsa key, string publicKeyJwk, string thumbprint)
///
/// Generates a new ES256 key pair.
///
+ ///
+ /// On browser platforms, use instead. This method throws
+ /// on browser.
+ ///
public static DPoPKeyPair Generate()
{
- var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
- var parameters = key.ExportParameters(includePrivateParameters: false);
+#if BROWSER
+ throw new PlatformNotSupportedException(
+ "Synchronous key generation is not supported on browser platforms. Use GenerateAsync instead.");
+#else
+ var crypto = EcdsaCryptoProvider.Create();
+ return FromCryptoProvider(crypto);
+#endif
+ }
- var x = Pkce.Base64UrlEncode(parameters.Q.X!);
- var y = Pkce.Base64UrlEncode(parameters.Q.Y!);
+ ///
+ /// Generates a new ES256 key pair asynchronously. Works on all platforms including browser.
+ ///
+ public static async Task GenerateAsync()
+ {
+#if BROWSER
+ var crypto = await BrowserCryptoProvider.CreateAsync().ConfigureAwait(false);
+#else
+ var crypto = EcdsaCryptoProvider.Create();
+ await Task.CompletedTask.ConfigureAwait(false);
+#endif
+ return FromCryptoProvider(crypto);
+ }
- // Build JWK for the public key (properties already in alphabetical order for thumbprint)
- var jwk = new EcJwk { Crv = "P-256", Kty = "EC", X = x, Y = y };
- var publicKeyJwk = JsonSerializer.Serialize(jwk, OAuthJsonContext.Default.EcJwk);
+ ///
+ /// Creates a DPoP proof JWT.
+ ///
+ /// The HTTP method (e.g., "POST", "GET").
+ /// The HTTP URI (without query string or fragment).
+ /// Optional server-provided nonce.
+ /// Optional access token for access token hash (ath).
+ /// A signed DPoP proof JWT.
+ ///
+ /// On browser platforms, use instead. This method throws
+ /// on browser.
+ ///
+ public string CreateProof(string httpMethod, string httpUri, string? nonce = null, string? accessToken = null)
+ {
+ ThrowIfDisposed();
- // Compute JWK thumbprint (RFC 7638) - canonical form
- using var sha256 = SHA256.Create();
- var thumbprintBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(publicKeyJwk));
- var thumbprint = Pkce.Base64UrlEncode(thumbprintBytes);
+ var (headerBase64, payloadBase64) = BuildProofComponents(httpMethod, httpUri, nonce, accessToken);
+ var signingInput = $"{headerBase64}.{payloadBase64}";
+ var signature = _crypto.SignData(Encoding.UTF8.GetBytes(signingInput));
+ var signatureBase64 = Pkce.Base64UrlEncode(signature);
- return new DPoPKeyPair(key, publicKeyJwk, thumbprint);
+ return $"{signingInput}.{signatureBase64}";
}
///
- /// Creates a DPoP proof JWT.
+ /// Creates a DPoP proof JWT asynchronously. Works on all platforms including browser.
///
/// The HTTP method (e.g., "POST", "GET").
/// The HTTP URI (without query string or fragment).
/// Optional server-provided nonce.
/// Optional access token for access token hash (ath).
/// A signed DPoP proof JWT.
- public string CreateProof(string httpMethod, string httpUri, string? nonce = null, string? accessToken = null)
+ public async Task CreateProofAsync(string httpMethod, string httpUri, string? nonce = null, string? accessToken = null)
{
ThrowIfDisposed();
- // Build header
- var jwkObj = JsonSerializer.Deserialize(_publicKeyJwk, OAuthJsonContext.Default.EcJwk)!;
- var header = new DPoPProofHeader { Alg = "ES256", Typ = "dpop+jwt", Jwk = jwkObj };
+ var (headerBase64, payloadBase64) = BuildProofComponents(httpMethod, httpUri, nonce, accessToken);
+ var signingInput = $"{headerBase64}.{payloadBase64}";
- // Normalize URI (remove query and fragment)
- var uri = new Uri(httpUri);
- var normalizedUri = $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}";
+ var signature = await _crypto.SignDataAsync(Encoding.UTF8.GetBytes(signingInput)).ConfigureAwait(false);
+ var signatureBase64 = Pkce.Base64UrlEncode(signature);
- // Compute access token hash if needed
- string? ath = null;
- if (!string.IsNullOrEmpty(accessToken))
- {
- using var sha256 = SHA256.Create();
- var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(accessToken));
- ath = Pkce.Base64UrlEncode(hash);
- }
+ return $"{signingInput}.{signatureBase64}";
+ }
- // Build payload
- var payload = new DPoPProofPayload
- {
- Iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
- Jti = Pkce.GenerateNonce(16),
- Htm = httpMethod.ToUpperInvariant(),
- Htu = normalizedUri,
- Nonce = string.IsNullOrEmpty(nonce) ? null : nonce,
- Ath = ath
- };
+ ///
+ /// Signs data using the key pair. Throws on browser.
+ ///
+ internal byte[] SignData(byte[] data)
+ {
+ ThrowIfDisposed();
+ return _crypto.SignData(data);
+ }
- return CreateDPoPJwt(header, payload);
+ ///
+ /// Signs data asynchronously using the key pair. Works on all platforms.
+ ///
+ internal Task SignDataAsync(byte[] data)
+ {
+ ThrowIfDisposed();
+ return _crypto.SignDataAsync(data);
}
///
@@ -108,13 +138,13 @@ public JsonWebKey ExportPublicKey()
{
ThrowIfDisposed();
- var parameters = _key.ExportParameters(includePrivateParameters: false);
+ var (x, y) = _crypto.ExportPublicParameters();
return new JsonWebKey
{
Kty = "EC",
Crv = "P-256",
- X = Pkce.Base64UrlEncode(parameters.Q.X!),
- Y = Pkce.Base64UrlEncode(parameters.Q.Y!),
+ X = x,
+ Y = y,
Alg = "ES256",
Use = "sig"
};
@@ -127,14 +157,14 @@ public JsonWebKey ExportKeyPair()
{
ThrowIfDisposed();
- var parameters = _key.ExportParameters(includePrivateParameters: true);
+ var (x, y, d) = _crypto.ExportPrivateParameters();
return new JsonWebKey
{
Kty = "EC",
Crv = "P-256",
- X = Pkce.Base64UrlEncode(parameters.Q.X!),
- Y = Pkce.Base64UrlEncode(parameters.Q.Y!),
- D = Pkce.Base64UrlEncode(parameters.D!),
+ X = x,
+ Y = y,
+ D = d,
Alg = "ES256",
Use = "sig"
};
@@ -155,43 +185,69 @@ public static DPoPKeyPair Import(JsonWebKey jwk)
throw new ArgumentException("JWK must contain x, y, and d components.", nameof(jwk));
}
- var parameters = new ECParameters
- {
- Curve = ECCurve.NamedCurves.nistP256,
- Q = new ECPoint
- {
- X = Pkce.Base64UrlDecode(jwk.X!),
- Y = Pkce.Base64UrlDecode(jwk.Y!)
- },
- D = Pkce.Base64UrlDecode(jwk.D!)
- };
+#if BROWSER
+ ICryptoProvider crypto = BrowserCryptoProvider.Import(jwk.X!, jwk.Y!, jwk.D!);
+#else
+ ICryptoProvider crypto = EcdsaCryptoProvider.Import(jwk.X!, jwk.Y!, jwk.D!);
+#endif
- var key = ECDsa.Create(parameters);
+ return FromCryptoProvider(crypto);
+ }
- // Build public JWK for thumbprint (properties in alphabetical order)
- var publicJwk = new EcJwk { Crv = "P-256", Kty = "EC", X = jwk.X!, Y = jwk.Y! };
- var publicKeyJwk = JsonSerializer.Serialize(publicJwk, OAuthJsonContext.Default.EcJwk);
+ private static DPoPKeyPair FromCryptoProvider(ICryptoProvider crypto)
+ {
+ var (x, y) = crypto.ExportPublicParameters();
+ // Build JWK for the public key (properties already in alphabetical order for thumbprint)
+ var jwk = new EcJwk { Crv = "P-256", Kty = "EC", X = x, Y = y };
+ var publicKeyJwk = JsonSerializer.Serialize(jwk, OAuthJsonContext.Default.EcJwk);
+
+ // Compute JWK thumbprint (RFC 7638) - canonical form
using var sha256 = SHA256.Create();
var thumbprintBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(publicKeyJwk));
var thumbprint = Pkce.Base64UrlEncode(thumbprintBytes);
- return new DPoPKeyPair(key, publicKeyJwk, thumbprint);
+ return new DPoPKeyPair(crypto, publicKeyJwk, thumbprint);
}
- private string CreateDPoPJwt(DPoPProofHeader header, DPoPProofPayload payload)
+ private (string HeaderBase64, string PayloadBase64) BuildProofComponents(
+ string httpMethod, string httpUri, string? nonce, string? accessToken)
{
+ // Build header
+ var jwkObj = JsonSerializer.Deserialize(_publicKeyJwk, OAuthJsonContext.Default.EcJwk)!;
+ var header = new DPoPProofHeader { Alg = "ES256", Typ = "dpop+jwt", Jwk = jwkObj };
+
+ // Normalize URI (remove query and fragment)
+ var uri = new Uri(httpUri);
+ var normalizedUri = $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}";
+
+ // Compute access token hash if needed
+ string? ath = null;
+ if (!string.IsNullOrEmpty(accessToken))
+ {
+ using var sha256 = SHA256.Create();
+ var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(accessToken));
+ ath = Pkce.Base64UrlEncode(hash);
+ }
+
+ // Build payload
+ var payload = new DPoPProofPayload
+ {
+ Iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ Jti = Pkce.GenerateNonce(16),
+ Htm = httpMethod.ToUpperInvariant(),
+ Htu = normalizedUri,
+ Nonce = string.IsNullOrEmpty(nonce) ? null : nonce,
+ Ath = ath
+ };
+
var headerJson = JsonSerializer.Serialize(header, OAuthJsonContext.Default.DPoPProofHeader);
var payloadJson = JsonSerializer.Serialize(payload, OAuthJsonContext.Default.DPoPProofPayload);
var headerBase64 = Pkce.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
var payloadBase64 = Pkce.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
- var signingInput = $"{headerBase64}.{payloadBase64}";
- var signature = _key.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256);
- var signatureBase64 = Pkce.Base64UrlEncode(signature);
-
- return $"{signingInput}.{signatureBase64}";
+ return (headerBase64, payloadBase64);
}
private void ThrowIfDisposed()
@@ -211,6 +267,6 @@ public void Dispose()
}
_disposed = true;
- _key.Dispose();
+ _crypto.Dispose();
}
}
diff --git a/src/CarpaNet.OAuth/DPoPTokenProvider.cs b/src/CarpaNet.OAuth/DPoPTokenProvider.cs
index 5ec3f56..8cf89d2 100644
--- a/src/CarpaNet.OAuth/DPoPTokenProvider.cs
+++ b/src/CarpaNet.OAuth/DPoPTokenProvider.cs
@@ -293,6 +293,38 @@ public void AddDPoPHeaders(HttpRequestMessage request, bool includeAccessToken =
}
}
+ ///
+ /// Adds DPoP proof and authorization headers to an existing HTTP request asynchronously.
+ /// Works on all platforms including browser.
+ ///
+ /// The HTTP request to add headers to.
+ /// Whether to include the access token.
+ public async Task AddDPoPHeadersAsync(HttpRequestMessage request, bool includeAccessToken = true)
+ {
+ ThrowIfDisposed();
+
+ if (_dpopKey == null)
+ {
+ throw new InvalidOperationException("No DPoP key available.");
+ }
+
+ var url = request.RequestUri!.ToString();
+ var nonce = _nonceCache.Get(url);
+ var accessToken = includeAccessToken ? _tokenSet?.AccessToken : null;
+ var proof = await _dpopKey.CreateProofAsync(request.Method.Method, url, nonce, accessToken).ConfigureAwait(false);
+
+ // Remove existing DPoP headers before adding new ones
+ request.Headers.Remove("DPoP");
+ request.Headers.Remove("Authorization");
+
+ request.Headers.Add("DPoP", proof);
+
+ if (includeAccessToken && !string.IsNullOrEmpty(accessToken))
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue("DPoP", accessToken);
+ }
+ }
+
///
/// Creates a DPoP-signed HTTP request.
///
@@ -324,6 +356,38 @@ public HttpRequestMessage CreateDPoPRequest(HttpMethod method, string url, bool
return request;
}
+ ///
+ /// Creates a DPoP-signed HTTP request asynchronously.
+ /// Works on all platforms including browser.
+ ///
+ /// The HTTP method.
+ /// The request URL.
+ /// Whether to include the access token.
+ /// The HTTP request message with DPoP headers.
+ public async Task CreateDPoPRequestAsync(HttpMethod method, string url, bool includeAccessToken = true)
+ {
+ ThrowIfDisposed();
+
+ if (_dpopKey == null)
+ {
+ throw new InvalidOperationException("No DPoP key available.");
+ }
+
+ var nonce = _nonceCache.Get(url);
+ var accessToken = includeAccessToken ? _tokenSet?.AccessToken : null;
+ var proof = await _dpopKey.CreateProofAsync(method.Method, url, nonce, accessToken).ConfigureAwait(false);
+
+ var request = new HttpRequestMessage(method, url);
+ request.Headers.Add("DPoP", proof);
+
+ if (includeAccessToken && !string.IsNullOrEmpty(accessToken))
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue("DPoP", accessToken);
+ }
+
+ return request;
+ }
+
///
/// Updates the DPoP nonce from a response.
///
@@ -367,7 +431,7 @@ private async Task ExecuteTokenRequestAsync(
CancellationToken cancellationToken)
{
_logger.LogDebug("Executing token request to {Endpoint}", tokenEndpoint);
- using var request = CreateDPoPRequest(HttpMethod.Post, tokenEndpoint, includeAccessToken: false);
+ using var request = await CreateDPoPRequestAsync(HttpMethod.Post, tokenEndpoint, includeAccessToken: false).ConfigureAwait(false);
request.Content = new StringContent(formContent, Encoding.UTF8, "application/x-www-form-urlencoded");
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
diff --git a/src/CarpaNet.OAuth/OAuthSession.cs b/src/CarpaNet.OAuth/OAuthSession.cs
index cc86365..78f0cbd 100644
--- a/src/CarpaNet.OAuth/OAuthSession.cs
+++ b/src/CarpaNet.OAuth/OAuthSession.cs
@@ -75,7 +75,7 @@ public async Task AuthorizeAsync(
var state = Pkce.GenerateState();
// Generate DPoP key
- var dpopKey = DPoPKeyPair.Generate();
+ var dpopKey = await DPoPKeyPair.GenerateAsync().ConfigureAwait(false);
// Store state data
var stateData = new OAuthStateData
@@ -119,7 +119,7 @@ public async Task AuthorizeAsync(
// Try PAR if available
if (!string.IsNullOrEmpty(serverMetadata.PushedAuthorizationRequestEndpoint))
{
- _logger.LogDebug("Attempting PAR");
+ _logger.LogDebug("Attempting PAR to {Endpoint}", serverMetadata.PushedAuthorizationRequestEndpoint);
try
{
var requestUri = await PushAuthorizationRequestAsync(
@@ -137,9 +137,16 @@ public async Task AuthorizeAsync(
["request_uri"] = requestUri
});
}
- catch (OAuthException)
+ catch (OAuthException ex)
{
- _logger.LogWarning("PAR failed, falling back to standard URL");
+ _logger.LogWarning("PAR failed ({Error}): {Description}. Falling back to standard URL.", ex.ErrorCode, ex.Message);
+
+ // If PAR is required by the server, don't silently fall back
+ if (serverMetadata.RequirePushedAuthorizationRequests == true)
+ {
+ throw;
+ }
+
// Fall back to standard authorization URL if PAR fails
}
}
@@ -339,7 +346,7 @@ public async Task RevokeAsync(string sub, CancellationToken cancellationToken =
using var request = new HttpRequestMessage(HttpMethod.Post, serverMetadata.RevocationEndpoint);
var nonce = new DPoPNonceCache().Get(serverMetadata.RevocationEndpoint!);
- var proof = dpopKey.CreateProof("POST", serverMetadata.RevocationEndpoint!, nonce);
+ var proof = await dpopKey.CreateProofAsync("POST", serverMetadata.RevocationEndpoint!, nonce).ConfigureAwait(false);
request.Headers.Add("DPoP", proof);
var content = $"token={Uri.EscapeDataString(sessionData.TokenSet.RefreshToken)}&token_type_hint=refresh_token";
@@ -413,7 +420,21 @@ private async Task PushAuthorizationRequestAsync(
for (int attempt = 0; attempt < 2; attempt++)
{
- var proof = dpopKey.CreateProof("POST", parEndpoint, nonce);
+ var proof = await dpopKey.CreateProofAsync("POST", parEndpoint, nonce).ConfigureAwait(false);
+
+ _logger.LogDebug("PAR attempt {Attempt}: endpoint={Endpoint}, nonce={Nonce}", attempt, parEndpoint, nonce ?? "(none)");
+
+ // Log the decoded DPoP proof JWT for diagnostics
+ var proofParts = proof.Split('.');
+ if (proofParts.Length == 3)
+ {
+ var proofHeader = Encoding.UTF8.GetString(Pkce.Base64UrlDecode(proofParts[0]));
+ var proofPayload = Encoding.UTF8.GetString(Pkce.Base64UrlDecode(proofParts[1]));
+ var sigBytes = Pkce.Base64UrlDecode(proofParts[2]);
+ _logger.LogDebug("DPoP proof header: {Header}", proofHeader);
+ _logger.LogDebug("DPoP proof payload: {Payload}", proofPayload);
+ _logger.LogDebug("DPoP proof signature: {SigLength} bytes", sigBytes.Length);
+ }
using var request = new HttpRequestMessage(HttpMethod.Post, parEndpoint);
request.Headers.Add("DPoP", proof);
@@ -421,8 +442,12 @@ private async Task PushAuthorizationRequestAsync(
var formContent = BuildFormContent(authParams);
request.Content = new StringContent(formContent, Encoding.UTF8, "application/x-www-form-urlencoded");
+ _logger.LogDebug("PAR request body: {Body}", formContent);
+
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ _logger.LogDebug("PAR response: {StatusCode}", (int)response.StatusCode);
+
// Update nonce from response
if (response.Headers.TryGetValues("DPoP-Nonce", out var nonceValues))
{
@@ -449,21 +474,25 @@ private async Task PushAuthorizationRequestAsync(
// Check for use_dpop_nonce error
var errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ _logger.LogWarning("PAR failed with {StatusCode}: {ErrorBody}", (int)response.StatusCode, errorContent);
+
try
{
var errorResponse = JsonSerializer.Deserialize(errorContent, OAuthJsonContext.Default.OAuthErrorResponse);
if (errorResponse?.Error == "use_dpop_nonce" && attempt == 0)
{
+ _logger.LogDebug("PAR retry: use_dpop_nonce, retrying with new nonce");
continue; // Retry with new nonce
}
+ var errorDesc = errorResponse?.ErrorDescription ?? errorContent;
throw new OAuthException(
errorResponse?.Error ?? "par_failed",
- errorResponse?.ErrorDescription ?? errorContent);
+ $"PAR request to {parEndpoint} failed with HTTP {(int)response.StatusCode}: {errorDesc}");
}
catch (JsonException)
{
- throw new OAuthException("par_failed", errorContent);
+ throw new OAuthException("par_failed", $"PAR request to {parEndpoint} failed with HTTP {(int)response.StatusCode}: {errorContent}");
}
}
@@ -482,7 +511,7 @@ private async Task ExchangeCodeAsync(
for (int attempt = 0; attempt < 2; attempt++)
{
- var proof = dpopKey.CreateProof("POST", tokenEndpoint, nonce);
+ var proof = await dpopKey.CreateProofAsync("POST", tokenEndpoint, nonce).ConfigureAwait(false);
using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint);
request.Headers.Add("DPoP", proof);
diff --git a/tests/CarpaNet.UnitTests/OAuth/DPoPKeyPairTests.cs b/tests/CarpaNet.UnitTests/OAuth/DPoPKeyPairTests.cs
index 3abadff..45e8051 100644
--- a/tests/CarpaNet.UnitTests/OAuth/DPoPKeyPairTests.cs
+++ b/tests/CarpaNet.UnitTests/OAuth/DPoPKeyPairTests.cs
@@ -1,6 +1,8 @@
using System;
+using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using System.Threading.Tasks;
using CarpaNet.OAuth;
using CarpaNet.OAuth.Crypto;
using Xunit;
@@ -208,6 +210,268 @@ public void CreateProof_AfterDispose_Throws()
keyPair.CreateProof("POST", "https://example.com/token"));
}
+ [Fact]
+ public void CreateProof_SignatureIsVerifiable()
+ {
+ // This test validates what a server does: extract the JWK from the header,
+ // reconstruct the public key, and verify the ES256 signature.
+ // If the browser WebCrypto implementation produces a different format,
+ // this same verification would fail on the server.
+ using var keyPair = DPoPKeyPair.Generate();
+
+ var proof = keyPair.CreateProof("POST", "https://example.com/token", nonce: "test-nonce");
+
+ AssertProofSignatureIsValid(proof);
+ }
+
+ [Fact]
+ public void CreateProof_ImportedKey_SignatureIsVerifiable()
+ {
+ // Verify that export → import → sign produces verifiable signatures
+ using var original = DPoPKeyPair.Generate();
+ var jwk = original.ExportKeyPair();
+
+ using var imported = DPoPKeyPair.Import(jwk);
+ var proof = imported.CreateProof("POST", "https://example.com/token");
+
+ AssertProofSignatureIsValid(proof);
+ }
+
+ [Fact]
+ public void CreateProof_WithAccessToken_SignatureIsVerifiable()
+ {
+ using var keyPair = DPoPKeyPair.Generate();
+
+ var proof = keyPair.CreateProof("GET", "https://example.com/api",
+ nonce: "server-nonce", accessToken: "my-access-token");
+
+ AssertProofSignatureIsValid(proof);
+ }
+
+ [Fact]
+ public async Task CreateProofAsync_SignatureIsVerifiable()
+ {
+ using var keyPair = await DPoPKeyPair.GenerateAsync();
+
+ var proof = await keyPair.CreateProofAsync("POST", "https://example.com/token", nonce: "test-nonce");
+
+ AssertProofSignatureIsValid(proof);
+ }
+
+ [Fact]
+ public async Task CreateProofAsync_MatchesSyncProofStructure()
+ {
+ // Verify that sync and async paths produce structurally identical JWTs
+ // (different jti/iat values, but same header structure and signature format)
+ using var keyPair = DPoPKeyPair.Generate();
+
+ var syncProof = keyPair.CreateProof("POST", "https://example.com/token");
+ var asyncProof = await keyPair.CreateProofAsync("POST", "https://example.com/token");
+
+ // Both should be valid JWTs with verifiable signatures
+ AssertProofSignatureIsValid(syncProof);
+ AssertProofSignatureIsValid(asyncProof);
+
+ // Both should have identical header structure
+ var syncHeader = ParseJwtPart(syncProof.Split('.')[0]);
+ var asyncHeader = ParseJwtPart(asyncProof.Split('.')[0]);
+
+ Assert.Equal(
+ syncHeader.RootElement.GetProperty("alg").GetString(),
+ asyncHeader.RootElement.GetProperty("alg").GetString());
+ Assert.Equal(
+ syncHeader.RootElement.GetProperty("typ").GetString(),
+ asyncHeader.RootElement.GetProperty("typ").GetString());
+
+ // JWK in header should be identical (same key)
+ Assert.Equal(
+ syncHeader.RootElement.GetProperty("jwk").GetProperty("x").GetString(),
+ asyncHeader.RootElement.GetProperty("jwk").GetProperty("x").GetString());
+ Assert.Equal(
+ syncHeader.RootElement.GetProperty("jwk").GetProperty("y").GetString(),
+ asyncHeader.RootElement.GetProperty("jwk").GetProperty("y").GetString());
+
+ syncHeader.Dispose();
+ asyncHeader.Dispose();
+ }
+
+ [Fact]
+ public void CreateProof_SignatureIs64Bytes()
+ {
+ // ES256 (ECDSA P-256) signatures in IEEE P1363 format are exactly 64 bytes
+ // (32 bytes r + 32 bytes s). Both .NET ECDsa and WebCrypto produce this format.
+ // If the signature were DER-encoded instead, it would be 70-72 bytes.
+ using var keyPair = DPoPKeyPair.Generate();
+
+ var proof = keyPair.CreateProof("POST", "https://example.com/token");
+
+ var parts = proof.Split('.');
+ var signatureBytes = Base64UrlDecode(parts[2]);
+
+ Assert.Equal(64, signatureBytes.Length);
+ }
+
+ [Fact]
+ public void CreateProof_JwkThumbprintMatchesHeader()
+ {
+ // The dpop_jkt claim that gets sent to the server must match the thumbprint
+ // computed from the JWK in the proof header. This validates the thumbprint
+ // computation is correct.
+ using var keyPair = DPoPKeyPair.Generate();
+
+ var proof = keyPair.CreateProof("POST", "https://example.com/token");
+
+ var parts = proof.Split('.');
+ var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[0]));
+ using var headerDoc = JsonDocument.Parse(headerJson);
+
+ var jwkElement = headerDoc.RootElement.GetProperty("jwk");
+ var crv = jwkElement.GetProperty("crv").GetString()!;
+ var kty = jwkElement.GetProperty("kty").GetString()!;
+ var x = jwkElement.GetProperty("x").GetString()!;
+ var y = jwkElement.GetProperty("y").GetString()!;
+
+ // Compute thumbprint per RFC 7638: lexicographic JSON with required members
+ var canonicalJwk = $"{{\"crv\":\"{crv}\",\"kty\":\"{kty}\",\"x\":\"{x}\",\"y\":\"{y}\"}}";
+ using var sha256 = SHA256.Create();
+ var thumbprintBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(canonicalJwk));
+ var computedThumbprint = Pkce.Base64UrlEncode(thumbprintBytes);
+
+ Assert.Equal(computedThumbprint, keyPair.Thumbprint);
+ }
+
+ [Fact]
+ public void CreateProof_AccessTokenHash_IsCorrectSha256()
+ {
+ // Verify the ath (access token hash) claim is computed correctly per RFC 9449
+ using var keyPair = DPoPKeyPair.Generate();
+ var accessToken = "test-access-token-12345";
+
+ var proof = keyPair.CreateProof("GET", "https://example.com/api", accessToken: accessToken);
+
+ var parts = proof.Split('.');
+ var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1]));
+ using var payloadDoc = JsonDocument.Parse(payloadJson);
+
+ var ath = payloadDoc.RootElement.GetProperty("ath").GetString()!;
+
+ // Compute expected ath: base64url(SHA-256(ASCII(access_token)))
+ using var sha256 = SHA256.Create();
+ var expectedHash = sha256.ComputeHash(Encoding.ASCII.GetBytes(accessToken));
+ var expectedAth = Pkce.Base64UrlEncode(expectedHash);
+
+ Assert.Equal(expectedAth, ath);
+ }
+
+ [Fact]
+ public void ClientAssertion_Create_ProducesVerifiableSignature()
+ {
+ using var keyPair = DPoPKeyPair.Generate();
+
+ var jwt = ClientAssertion.Create("client-id", "https://auth.example.com", keyPair);
+
+ var parts = jwt.Split('.');
+ Assert.Equal(3, parts.Length);
+
+ // Verify signature using the public key
+ var signingInput = $"{parts[0]}.{parts[1]}";
+ var signatureBytes = Base64UrlDecode(parts[2]);
+
+ var jwk = keyPair.ExportPublicKey();
+ using var ecdsa = ECDsa.Create(new ECParameters
+ {
+ Curve = ECCurve.NamedCurves.nistP256,
+ Q = new ECPoint
+ {
+ X = Pkce.Base64UrlDecode(jwk.X!),
+ Y = Pkce.Base64UrlDecode(jwk.Y!)
+ }
+ });
+
+ var isValid = ecdsa.VerifyData(
+ Encoding.UTF8.GetBytes(signingInput),
+ signatureBytes,
+ HashAlgorithmName.SHA256);
+
+ Assert.True(isValid, "Client assertion signature verification failed");
+ }
+
+ [Fact]
+ public async Task ClientAssertion_CreateAsync_ProducesVerifiableSignature()
+ {
+ using var keyPair = await DPoPKeyPair.GenerateAsync();
+
+ var jwt = await ClientAssertion.CreateAsync("client-id", "https://auth.example.com", keyPair);
+
+ var parts = jwt.Split('.');
+ Assert.Equal(3, parts.Length);
+
+ // Verify signature using the public key
+ var signingInput = $"{parts[0]}.{parts[1]}";
+ var signatureBytes = Base64UrlDecode(parts[2]);
+
+ var jwk = keyPair.ExportPublicKey();
+ using var ecdsa = ECDsa.Create(new ECParameters
+ {
+ Curve = ECCurve.NamedCurves.nistP256,
+ Q = new ECPoint
+ {
+ X = Pkce.Base64UrlDecode(jwk.X!),
+ Y = Pkce.Base64UrlDecode(jwk.Y!)
+ }
+ });
+
+ var isValid = ecdsa.VerifyData(
+ Encoding.UTF8.GetBytes(signingInput),
+ signatureBytes,
+ HashAlgorithmName.SHA256);
+
+ Assert.True(isValid, "Client assertion async signature verification failed");
+ }
+
+ ///
+ /// Extracts the JWK from a DPoP proof header, reconstructs the ECDSA public key,
+ /// and verifies the signature — exactly what a server does to validate a DPoP proof.
+ ///
+ private static void AssertProofSignatureIsValid(string proof)
+ {
+ var parts = proof.Split('.');
+ Assert.Equal(3, parts.Length);
+
+ // Extract public key from the JWK in the header
+ var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[0]));
+ using var headerDoc = JsonDocument.Parse(headerJson);
+
+ var jwkElement = headerDoc.RootElement.GetProperty("jwk");
+ var x = Pkce.Base64UrlDecode(jwkElement.GetProperty("x").GetString()!);
+ var y = Pkce.Base64UrlDecode(jwkElement.GetProperty("y").GetString()!);
+
+ // Reconstruct the public key (as a server would)
+ using var ecdsa = ECDsa.Create(new ECParameters
+ {
+ Curve = ECCurve.NamedCurves.nistP256,
+ Q = new ECPoint { X = x, Y = y }
+ });
+
+ // Verify the signature over the signing input (header.payload)
+ var signingInput = $"{parts[0]}.{parts[1]}";
+ var signature = Base64UrlDecode(parts[2]);
+
+ var isValid = ecdsa.VerifyData(
+ Encoding.UTF8.GetBytes(signingInput),
+ signature,
+ HashAlgorithmName.SHA256);
+
+ Assert.True(isValid, "DPoP proof ES256 signature verification failed — " +
+ "this would cause a 400 error from the authorization server");
+ }
+
+ private static JsonDocument ParseJwtPart(string base64UrlPart)
+ {
+ var json = Encoding.UTF8.GetString(Base64UrlDecode(base64UrlPart));
+ return JsonDocument.Parse(json);
+ }
+
private static byte[] Base64UrlDecode(string input)
{
return Pkce.Base64UrlDecode(input);