diff --git a/Avalonia.Controls.WebView.slnx b/Avalonia.Controls.WebView.slnx
index ab5330a..a3fcdf3 100644
--- a/Avalonia.Controls.WebView.slnx
+++ b/Avalonia.Controls.WebView.slnx
@@ -17,6 +17,7 @@
+
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml b/samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml
new file mode 100644
index 0000000..8b34271
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml.cs b/samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml.cs
new file mode 100644
index 0000000..b90bf05
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml.cs
@@ -0,0 +1,20 @@
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace Avalonia.Controls.WebView.Samples.Oidc;
+
+public class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ desktop.MainWindow = new MainWindow();
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/Avalonia.Controls.WebView.Samples.Oidc.csproj b/samples/Avalonia.Controls.WebView.Samples.Oidc/Avalonia.Controls.WebView.Samples.Oidc.csproj
new file mode 100644
index 0000000..96e3f7c
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/Avalonia.Controls.WebView.Samples.Oidc.csproj
@@ -0,0 +1,22 @@
+
+
+ WinExe
+ net10.0
+ enable
+ Avalonia.Controls.WebView.Samples.Oidc
+
+
+
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml b/samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml
new file mode 100644
index 0000000..e403c63
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml.cs b/samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml.cs
new file mode 100644
index 0000000..7807b38
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Text;
+using Avalonia.Controls;
+using Avalonia.Controls.OAuth2;
+using Avalonia.Interactivity;
+
+namespace Avalonia.Controls.WebView.Samples.Oidc;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ async void SignIn_OnClick(object? sender, RoutedEventArgs e)
+ {
+ var issuer = IssuerBox.Text?.Trim() ?? "";
+ var clientId = ClientIdBox.Text?.Trim() ?? "";
+ var redirectText = RedirectBox.Text?.Trim() ?? "";
+ var scope = ScopeBox.Text?.Trim() ?? "";
+
+ if (issuer.Length == 0 || clientId.Length == 0 || redirectText.Length == 0 || scope.Length == 0)
+ {
+ AppendLog("Fill issuer, client ID, redirect URI, and scope.");
+ return;
+ }
+
+ try
+ {
+ AppendLog($"GET {AuthorizationServerMetadataClient.GetWellKnownMetadataUrl(issuer)}");
+ var metadata = await AuthorizationServerMetadataClient.GetAsync(issuer).ConfigureAwait(true);
+ if (metadata.AuthorizationEndpoint is { } ae)
+ AppendLog($"authorization_endpoint: {ae}");
+ if (metadata.TokenEndpoint is { } te)
+ AppendLog($"token_endpoint: {te}");
+
+ var session = AuthorizationCodePkceSession.Create(metadata, clientId, redirectText, scope);
+
+ var options = new WebAuthenticatorOptions(session.AuthorizationUri, session.RedirectUri)
+ {
+ PreferNativeWebDialog = true,
+ };
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is null)
+ {
+ AppendLog("TopLevel not found.");
+ return;
+ }
+
+ AppendLog("Opening WebAuthenticationBroker…");
+ var result = await WebAuthenticationBroker.AuthenticateAsync(topLevel, options).ConfigureAwait(true);
+
+ var parsed = AuthorizationCallbackParser.Parse(result.CallbackUri, session.State);
+ AppendLog("Authorization code received; exchanging at token_endpoint…");
+
+ var token = await AuthorizationServerTokenClient.ExchangeAuthorizationCodeAsync(
+ metadata,
+ clientId,
+ parsed.AuthorizationCode,
+ session.RedirectUriString,
+ session.CodeVerifier).ConfigureAwait(true);
+
+ var sb = new StringBuilder();
+ sb.AppendLine("Token response:");
+ sb.AppendLine($" token_type: {token.TokenType}");
+ sb.AppendLine($" expires_in: {token.ExpiresIn}");
+ sb.AppendLine($" scope: {token.Scope}");
+ if (!string.IsNullOrEmpty(token.AccessToken))
+ sb.AppendLine($" access_token: {Preview(token.AccessToken)}");
+ if (!string.IsNullOrEmpty(token.IdToken))
+ sb.AppendLine($" id_token: {Preview(token.IdToken)}");
+ if (!string.IsNullOrEmpty(token.RefreshToken))
+ sb.AppendLine($" refresh_token: {Preview(token.RefreshToken)}");
+ AppendLog(sb.ToString());
+ }
+ catch (Exception ex)
+ {
+ AppendLog(ex.ToString());
+ }
+ }
+
+ static string Preview(string value)
+ {
+ const int max = 48;
+ return value.Length <= max ? value : value[..max] + "…";
+ }
+
+ void AppendLog(string line)
+ {
+ LogBox.Text += line + Environment.NewLine;
+ }
+}
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/Program.cs b/samples/Avalonia.Controls.WebView.Samples.Oidc/Program.cs
new file mode 100644
index 0000000..ee7797a
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/Program.cs
@@ -0,0 +1,16 @@
+using System;
+using Avalonia;
+
+namespace Avalonia.Controls.WebView.Samples.Oidc;
+
+internal static class Program
+{
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .LogToTrace();
+}
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/README.md b/samples/Avalonia.Controls.WebView.Samples.Oidc/README.md
new file mode 100644
index 0000000..f6b3956
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/README.md
@@ -0,0 +1,5 @@
+# OAuth 2.0 + PKCE sample (RFC 8414)
+
+This app loads **Authorization Server Metadata** from `{issuer}/.well-known/oauth-authorization-server`, starts an **authorization code** request with **PKCE (S256)**, completes login via `WebAuthenticationBroker`, then **exchanges the code** at the metadata `token_endpoint`.
+
+Register a public client with your identity provider and add the redirect URI you use here (for example `http://localhost`). The issuer must publish RFC 8414 metadata; if only OpenID Connect discovery is available, use an issuer that exposes both or a server that implements RFC 8414.
diff --git a/samples/Avalonia.Controls.WebView.Samples.Oidc/app.manifest b/samples/Avalonia.Controls.WebView.Samples.Oidc/app.manifest
new file mode 100644
index 0000000..8bcc872
--- /dev/null
+++ b/samples/Avalonia.Controls.WebView.Samples.Oidc/app.manifest
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Controls.WebView/OAuth2/AuthorizationCallbackParser.cs b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationCallbackParser.cs
new file mode 100644
index 0000000..48d1482
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationCallbackParser.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Controls.OAuth2;
+
+/// Parses the authorization response redirect (query string).
+public static class AuthorizationCallbackParser
+{
+ ///
+ /// Parses query for code, state, and OAuth error parameters.
+ ///
+ /// The redirect URI including query from the authorization server.
+ /// The state value from the authorization request.
+ /// The parsed authorization code.
+ /// Missing code, state mismatch, or error response.
+ public static AuthorizationCallbackResult Parse(Uri callbackUri, string expectedState)
+ {
+ var query = callbackUri.Query;
+ if (string.IsNullOrEmpty(query))
+ throw new InvalidOperationException("Callback URI has no query string.");
+
+ var coll = ParseQueryString(query);
+ if (coll.TryGetValue("error", out var error) && !string.IsNullOrEmpty(error))
+ {
+ coll.TryGetValue("error_description", out var desc);
+ throw new InvalidOperationException(
+ string.IsNullOrEmpty(desc) ? error : $"{error}: {desc}");
+ }
+
+ if (!coll.TryGetValue("code", out var code) || string.IsNullOrEmpty(code))
+ throw new InvalidOperationException("Callback URI is missing code.");
+
+ if (!coll.TryGetValue("state", out var state) || string.IsNullOrEmpty(state))
+ throw new InvalidOperationException("Callback URI is missing state.");
+
+ if (!string.Equals(state, expectedState, StringComparison.Ordinal))
+ throw new InvalidOperationException("State does not match the authorization request.");
+
+ return new AuthorizationCallbackResult(code);
+ }
+
+ static Dictionary ParseQueryString(string query)
+ {
+ var d = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var trimmed = query.StartsWith("?", StringComparison.Ordinal) ? query[1..] : query;
+ if (trimmed.Length == 0)
+ return d;
+
+ foreach (var part in trimmed.Split('&'))
+ {
+ if (part.Length == 0)
+ continue;
+ var i = part.IndexOf('=');
+ string key;
+ string value;
+ if (i < 0)
+ {
+ key = Uri.UnescapeDataString(part);
+ value = "";
+ }
+ else
+ {
+ key = Uri.UnescapeDataString(part[..i]);
+ value = Uri.UnescapeDataString(part[(i + 1)..]);
+ }
+
+ d[key] = value;
+ }
+
+ return d;
+ }
+}
+
+/// OAuth authorization redirect response values.
+/// The authorization code for the token endpoint.
+public readonly record struct AuthorizationCallbackResult(string AuthorizationCode);
diff --git a/src/Avalonia.Controls.WebView/OAuth2/AuthorizationCodePkceSession.cs b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationCodePkceSession.cs
new file mode 100644
index 0000000..d653ad1
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationCodePkceSession.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+
+namespace Avalonia.Controls.OAuth2;
+
+///
+/// Holds PKCE and CSRF values for one authorization code flow built from
+/// (RFC 8414).
+///
+public sealed class AuthorizationCodePkceSession
+{
+ AuthorizationCodePkceSession(
+ Uri authorizationUri,
+ Uri redirectUri,
+ string redirectUriString,
+ string state,
+ string codeVerifier,
+ string? nonce)
+ {
+ AuthorizationUri = authorizationUri;
+ RedirectUri = redirectUri;
+ RedirectUriString = redirectUriString;
+ State = state;
+ CodeVerifier = codeVerifier;
+ Nonce = nonce;
+ }
+
+ /// Gets the full authorization request URL (includes query).
+ public Uri AuthorizationUri { get; }
+
+ ///
+ /// Gets the redirect URI registered for the client (used for broker callback matching).
+ ///
+ public Uri RedirectUri { get; }
+
+ ///
+ /// Gets the exact redirect_uri string sent to the authorize and token endpoints
+ /// (must match client registration byte-for-byte).
+ /// Use this instead of on , which can add a trailing slash.
+ ///
+ public string RedirectUriString { get; }
+
+ /// Gets the opaque CSRF value sent as state.
+ public string State { get; }
+
+ /// Gets the PKCE code verifier; send to the token endpoint as code_verifier.
+ public string CodeVerifier { get; }
+
+ /// Gets the optional OIDC nonce, if requested.
+ public string? Nonce { get; }
+
+ ///
+ /// Creates a session: validates metadata for authorization code + S256 PKCE, then builds the authorization URL.
+ ///
+ /// Authorization server metadata from RFC 8414 discovery.
+ /// OAuth client identifier.
+ /// Registered redirect URI (exact string; must match token request).
+ /// Space-separated OAuth scopes.
+ /// Optional OpenID Connect nonce.
+ /// Optional resource indicator (RFC 8707).
+ /// A session holding URIs, PKCE verifier, and state for the broker and token exchange.
+ public static AuthorizationCodePkceSession Create(
+ AuthorizationServerMetadata metadata,
+ string clientId,
+ string redirectUri,
+ string scope,
+ string? nonce = null,
+ string? resource = null)
+ {
+ if (metadata.AuthorizationEndpoint is not { Length: > 0 } authEndpoint)
+ throw new InvalidOperationException("Authorization server metadata is missing authorization_endpoint.");
+
+ var redirectForOAuth = redirectUri.Trim();
+ if (redirectForOAuth.Length == 0)
+ throw new ArgumentException("Redirect URI is required.", nameof(redirectUri));
+
+ if (!Uri.TryCreate(redirectForOAuth, UriKind.Absolute, out var redirectUriParsed))
+ throw new ArgumentException("Redirect URI must be an absolute URL.", nameof(redirectUri));
+
+ EnsurePkceS256Supported(metadata);
+
+ var codeVerifier = Pkce.CreateCodeVerifier();
+ var codeChallenge = Pkce.CreateCodeChallengeS256(codeVerifier);
+ var state = CreateState();
+
+ var query = new List
+ {
+ "response_type=code",
+ $"client_id={Uri.EscapeDataString(clientId)}",
+ $"redirect_uri={Uri.EscapeDataString(redirectForOAuth)}",
+ $"scope={Uri.EscapeDataString(scope)}",
+ $"state={Uri.EscapeDataString(state)}",
+ $"code_challenge={Uri.EscapeDataString(codeChallenge)}",
+ "code_challenge_method=S256",
+ };
+
+ if (!string.IsNullOrEmpty(nonce))
+ query.Add($"nonce={Uri.EscapeDataString(nonce)}");
+
+ if (!string.IsNullOrEmpty(resource))
+ query.Add($"resource={Uri.EscapeDataString(resource)}");
+
+ var separator = authEndpoint.Contains('?', StringComparison.Ordinal) ? '&' : '?';
+ var authorizationUri = new Uri($"{authEndpoint}{separator}{string.Join("&", query)}");
+
+ return new AuthorizationCodePkceSession(
+ authorizationUri,
+ redirectUriParsed,
+ redirectForOAuth,
+ state,
+ codeVerifier,
+ nonce);
+ }
+
+ static void EnsurePkceS256Supported(AuthorizationServerMetadata metadata)
+ {
+ var methods = metadata.CodeChallengeMethodsSupported;
+ if (methods is null || methods.Length == 0)
+ return;
+
+ foreach (var m in methods)
+ {
+ if (string.Equals(m, "S256", StringComparison.OrdinalIgnoreCase))
+ return;
+ }
+
+ throw new InvalidOperationException(
+ "Authorization server metadata lists code_challenge_methods_supported but does not include S256.");
+ }
+
+ static string CreateState()
+ {
+ var bytes = new byte[32];
+ RandomNumberGenerator.Fill(bytes);
+ return Convert.ToHexString(bytes);
+ }
+}
diff --git a/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerMetadata.cs b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerMetadata.cs
new file mode 100644
index 0000000..7834e01
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerMetadata.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+
+namespace Avalonia.Controls.OAuth2;
+
+///
+/// OAuth 2.0 Authorization Server Metadata per RFC 8414.
+///
+public sealed class AuthorizationServerMetadata
+{
+ /// Gets the required issuer identifier.
+ [JsonPropertyName("issuer")]
+ public string? Issuer { get; init; }
+
+ /// Gets the URL of the authorization endpoint.
+ [JsonPropertyName("authorization_endpoint")]
+ public string? AuthorizationEndpoint { get; init; }
+
+ /// Gets the URL of the token endpoint.
+ [JsonPropertyName("token_endpoint")]
+ public string? TokenEndpoint { get; init; }
+
+ /// Gets the PKCE code challenge methods supported by the authorization server.
+ [JsonPropertyName("code_challenge_methods_supported")]
+ public string[]? CodeChallengeMethodsSupported { get; init; }
+
+ /// Gets the OAuth scopes the authorization server supports.
+ [JsonPropertyName("scopes_supported")]
+ public string[]? ScopesSupported { get; init; }
+
+ /// Gets the OAuth response types the authorization server supports.
+ [JsonPropertyName("response_types_supported")]
+ public string[]? ResponseTypesSupported { get; init; }
+}
diff --git a/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerMetadataClient.cs b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerMetadataClient.cs
new file mode 100644
index 0000000..eec3258
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerMetadataClient.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Avalonia.Controls.OAuth2;
+
+///
+/// Fetches from the RFC 8414 well-known URL
+/// /.well-known/oauth-authorization-server.
+///
+public static class AuthorizationServerMetadataClient
+{
+ private static readonly HttpClient SharedClient = new();
+
+ ///
+ /// GET {issuer}/.well-known/oauth-authorization-server and deserialize metadata.
+ ///
+ /// Authorization server issuer identifier (URL, no fragment or query).
+ /// Optional ; a shared instance is used when null.
+ /// Token to cancel the operation.
+ /// The deserialized authorization server metadata.
+ public static async Task GetAsync(
+ string issuer,
+ HttpClient? httpClient = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(issuer))
+ throw new ArgumentException("Issuer is required.", nameof(issuer));
+
+ var metadataUrl = GetWellKnownMetadataUrl(issuer);
+ var client = httpClient ?? SharedClient;
+ using var response = await client.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ var metadata = JsonSerializer.Deserialize(json, OAuth2JsonContext.Default.AuthorizationServerMetadata);
+ if (metadata is null)
+ throw new InvalidOperationException("Authorization server metadata response was empty.");
+
+ return metadata;
+ }
+
+ ///
+ /// Builds the RFC 8414 metadata URL: issuer path + /.well-known/oauth-authorization-server.
+ ///
+ /// The authorization server issuer identifier.
+ /// The absolute metadata document URL.
+ public static string GetWellKnownMetadataUrl(string issuer)
+ {
+ var trimmed = issuer.TrimEnd('/');
+ return $"{trimmed}/.well-known/oauth-authorization-server";
+ }
+}
diff --git a/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerTokenClient.cs b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerTokenClient.cs
new file mode 100644
index 0000000..601ac50
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/AuthorizationServerTokenClient.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Avalonia.Controls.OAuth2;
+
+/// Exchanges an authorization code for tokens at the RFC 8414 token_endpoint.
+public static class AuthorizationServerTokenClient
+{
+ private static readonly HttpClient SharedClient = new();
+
+ ///
+ /// POST grant_type=authorization_code with PKCE .
+ ///
+ /// Authorization server metadata (must include token_endpoint).
+ /// OAuth client identifier.
+ /// The code from the authorization redirect.
+ /// Exact redirect_uri used in the authorization request.
+ /// The PKCE code verifier for this flow.
+ /// Optional HTTP client; a shared instance is used when null.
+ /// Optional client secret for confidential clients.
+ /// Token to cancel the operation.
+ /// The token response from the authorization server.
+ public static async Task ExchangeAuthorizationCodeAsync(
+ AuthorizationServerMetadata metadata,
+ string clientId,
+ string authorizationCode,
+ string redirectUri,
+ string codeVerifier,
+ HttpClient? httpClient = null,
+ string? clientSecret = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (metadata.TokenEndpoint is not { Length: > 0 } tokenEndpoint)
+ throw new InvalidOperationException("Authorization server metadata is missing token_endpoint.");
+
+ var form = new List>
+ {
+ new("grant_type", "authorization_code"),
+ new("client_id", clientId),
+ new("code", authorizationCode),
+ new("redirect_uri", redirectUri),
+ new("code_verifier", codeVerifier),
+ };
+
+ if (!string.IsNullOrEmpty(clientSecret))
+ form.Add(new KeyValuePair("client_secret", clientSecret));
+
+ var client = httpClient ?? SharedClient;
+ using var content = new FormUrlEncodedContent(form);
+ using var response = await client.PostAsync(tokenEndpoint, content, cancellationToken).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ throw new InvalidOperationException($"Token endpoint returned {(int)response.StatusCode}: {body}");
+
+ var token = JsonSerializer.Deserialize(body, OAuth2JsonContext.Default.OAuth2TokenResponse);
+ if (token is null || string.IsNullOrEmpty(token.AccessToken))
+ throw new InvalidOperationException("Token response could not be read.");
+
+ return token;
+ }
+}
diff --git a/src/Avalonia.Controls.WebView/OAuth2/OAuth2JsonContext.cs b/src/Avalonia.Controls.WebView/OAuth2/OAuth2JsonContext.cs
new file mode 100644
index 0000000..8d9b1a6
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/OAuth2JsonContext.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace Avalonia.Controls.OAuth2;
+
+/// System.Text.Json source generation context for OAuth 2.0 metadata and token JSON.
+[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
+[JsonSerializable(typeof(AuthorizationServerMetadata))]
+[JsonSerializable(typeof(OAuth2TokenResponse))]
+internal partial class OAuth2JsonContext : JsonSerializerContext;
diff --git a/src/Avalonia.Controls.WebView/OAuth2/OAuth2TokenResponse.cs b/src/Avalonia.Controls.WebView/OAuth2/OAuth2TokenResponse.cs
new file mode 100644
index 0000000..8c078e2
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/OAuth2TokenResponse.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+
+namespace Avalonia.Controls.OAuth2;
+
+///
+/// Successful token endpoint response (subset of fields commonly used with authorization code + PKCE).
+///
+public sealed class OAuth2TokenResponse
+{
+ /// Gets the access token issued by the authorization server.
+ [JsonPropertyName("access_token")]
+ public string? AccessToken { get; init; }
+
+ /// Gets the token type (typically Bearer).
+ [JsonPropertyName("token_type")]
+ public string? TokenType { get; init; }
+
+ /// Gets the lifetime in seconds of the access token.
+ [JsonPropertyName("expires_in")]
+ public long? ExpiresIn { get; init; }
+
+ /// Gets the refresh token, if issued.
+ [JsonPropertyName("refresh_token")]
+ public string? RefreshToken { get; init; }
+
+ /// Gets the OpenID Connect ID token, if issued.
+ [JsonPropertyName("id_token")]
+ public string? IdToken { get; init; }
+
+ /// Gets the granted scope, if the server returns it.
+ [JsonPropertyName("scope")]
+ public string? Scope { get; init; }
+}
diff --git a/src/Avalonia.Controls.WebView/OAuth2/Pkce.cs b/src/Avalonia.Controls.WebView/OAuth2/Pkce.cs
new file mode 100644
index 0000000..5dff95e
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/OAuth2/Pkce.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Avalonia.Controls.OAuth2;
+
+///
+/// Proof Key for Code Exchange (PKCE) helpers per RFC 7636.
+///
+public static class Pkce
+{
+ ///
+ /// Creates a new cryptographically random code verifier string (RFC 7636).
+ ///
+ /// Number of random bytes to encode (default 64). Must be 43-128.
+ /// The code verifier string.
+ public static string CreateCodeVerifier(int size = 64)
+ {
+ if (size is < 43 or > 128)
+ throw new ArgumentOutOfRangeException(nameof(size), "Verifier length must be between 43 and 128.");
+
+ // Unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
+ var bytes = new byte[size];
+ RandomNumberGenerator.Fill(bytes);
+ return Base64UrlEncode(bytes);
+ }
+
+ ///
+ /// Computes the S256 code challenge (BASE64URL(SHA256(code_verifier))) for a verifier.
+ ///
+ /// The PKCE code verifier.
+ /// The code challenge string.
+ public static string CreateCodeChallengeS256(string codeVerifier)
+ {
+ var hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
+ return Base64UrlEncode(hash);
+ }
+
+ private static string Base64UrlEncode(ReadOnlySpan data)
+ {
+ return Convert.ToBase64String(data)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+}
diff --git a/src/Avalonia.Controls.WebView/README.md b/src/Avalonia.Controls.WebView/README.md
index d83c0b1..bee6cec 100644
--- a/src/Avalonia.Controls.WebView/README.md
+++ b/src/Avalonia.Controls.WebView/README.md
@@ -1,4 +1,4 @@
-# Avalonia WebView
+# Avalonia WebView
The Avalonia WebView component provides native web browser functionality for your Avalonia applications. Unlike embedded WebView solutions that require bundling Chromium, this implementation leverages the platform's native web rendering capabilities, resulting in smaller application size and better performance.
@@ -41,3 +41,9 @@ Native web dialog that provides a way to display web content in a separate windo
WebAuthenticationBroker is a utility class that facilitates OAuth and other web-based authentication flows by providing a secure way to handle web authentication in desktop applications.
**Documentation**: https://docs.avaloniaui.net/accelerate/components/webview/webauthenticationbroker
+
+### OAuth 2.0 Authorization Server Metadata (RFC 8414)
+
+The `Avalonia.Controls.OAuth2` namespace provides discovery at `/.well-known/oauth-authorization-server`, PKCE (RFC 7636), building an authorization code request against `authorization_endpoint`, parsing the redirect, and exchanging the code at `token_endpoint`. Use it together with `WebAuthenticationBroker` when you want standards-based metadata instead of hand-built authorize URLs.
+
+**Sample**: `samples/Avalonia.Controls.WebView.Samples.Oidc`
diff --git a/tests/Avalonia.Controls.WebView.Tests/OAuth2Tests.cs b/tests/Avalonia.Controls.WebView.Tests/OAuth2Tests.cs
new file mode 100644
index 0000000..cc044ea
--- /dev/null
+++ b/tests/Avalonia.Controls.WebView.Tests/OAuth2Tests.cs
@@ -0,0 +1,99 @@
+using System;
+using Avalonia.Controls.OAuth2;
+using Xunit;
+
+namespace Avalonia.Controls.WebView.Tests;
+
+public class OAuth2Tests
+{
+ [Fact]
+ public void Creating_S256_code_challenge_should_match_RFC7636_appendix_B_vector()
+ {
+ const string verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ var challenge = Pkce.CreateCodeChallengeS256(verifier);
+ Assert.Equal("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge);
+ }
+
+ [Fact]
+ public void Metadata_client_should_build_RFC8414_well_known_URL()
+ {
+ var url = AuthorizationServerMetadataClient.GetWellKnownMetadataUrl("https://example.com/tenant/");
+ Assert.Equal("https://example.com/tenant/.well-known/oauth-authorization-server", url);
+ }
+
+ [Fact]
+ public void Authorization_callback_parser_should_parse_code_and_validate_state()
+ {
+ var callback = new Uri("http://localhost/callback?code=abc&state=xyz");
+ var r = AuthorizationCallbackParser.Parse(callback, "xyz");
+ Assert.Equal("abc", r.AuthorizationCode);
+ }
+
+ [Fact]
+ public void Authorization_callback_parser_should_throw_when_state_mismatch()
+ {
+ var callback = new Uri("http://localhost/callback?code=abc&state=wrong");
+ Assert.Throws(() => AuthorizationCallbackParser.Parse(callback, "xyz"));
+ }
+
+ [Fact]
+ public void Authorization_code_pkce_session_should_throw_when_S256_not_supported()
+ {
+ var metadata = new AuthorizationServerMetadata
+ {
+ AuthorizationEndpoint = "https://id.example.com/authorize",
+ CodeChallengeMethodsSupported = new[] { "plain" },
+ };
+
+ Assert.Throws(() =>
+ AuthorizationCodePkceSession.Create(
+ metadata,
+ "client",
+ "http://localhost/cb",
+ "openid"));
+ }
+
+ [Fact]
+ public void Authorization_code_pkce_session_should_build_authorization_request_uri()
+ {
+ var metadata = new AuthorizationServerMetadata
+ {
+ AuthorizationEndpoint = "https://id.example.com/authorize",
+ CodeChallengeMethodsSupported = new[] { "S256" },
+ };
+
+ var session = AuthorizationCodePkceSession.Create(
+ metadata,
+ "my-client",
+ "http://localhost/cb",
+ "openid offline_access");
+
+ Assert.Equal("http://localhost/cb", session.RedirectUriString);
+ Assert.Contains("response_type=code", session.AuthorizationUri.Query, StringComparison.Ordinal);
+ Assert.Contains("code_challenge_method=S256", session.AuthorizationUri.Query, StringComparison.Ordinal);
+ Assert.Contains("client_id=my-client", session.AuthorizationUri.Query, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Authorization_code_pkce_session_should_preserve_redirect_uri_without_trailing_slash()
+ {
+ var metadata = new AuthorizationServerMetadata
+ {
+ AuthorizationEndpoint = "https://id.example.com/authorize",
+ CodeChallengeMethodsSupported = new[] { "S256" },
+ };
+
+ var session = AuthorizationCodePkceSession.Create(
+ metadata,
+ "id",
+ "http://localhost",
+ "openid");
+
+ Assert.Equal("http://localhost", session.RedirectUriString);
+ Assert.Contains("redirect_uri=http%3A%2F%2Flocalhost&", session.AuthorizationUri.Query, StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "redirect_uri=http%3A%2F%2Flocalhost%2F",
+ session.AuthorizationUri.Query,
+ StringComparison.Ordinal);
+ }
+}