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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Avalonia.Controls.WebView.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
</Project>
<Project Path="samples/Avalonia.Controls.WebView.Samples.Browser/Avalonia.Controls.WebView.Samples.Browser.csproj" />
<Project Path="samples/Avalonia.Controls.WebView.Samples.Desktop/Avalonia.Controls.WebView.Samples.Desktop.csproj" />
<Project Path="samples/Avalonia.Controls.WebView.Samples.Oidc/Avalonia.Controls.WebView.Samples.Oidc.csproj" />
<Project Path="samples/Avalonia.Controls.WebView.Samples.iOS/Avalonia.Controls.WebView.Samples.iOS.csproj" />
<Project Path="samples/Avalonia.Controls.WebView.Samples/Avalonia.Controls.WebView.Samples.csproj">
<Platform Solution="*|x64" Project="x64" />
Expand Down
8 changes: 8 additions & 0 deletions samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Avalonia.Controls.WebView.Samples.Oidc.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
20 changes: 20 additions & 0 deletions samples/Avalonia.Controls.WebView.Samples.Oidc/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<RootNamespace>Avalonia.Controls.WebView.Samples.Oidc</RootNamespace>
</PropertyGroup>

<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="$(AvaloniaSampleVersion)" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="$(AvaloniaSampleVersion)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Avalonia.Controls.WebView\Avalonia.Controls.WebView.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Controls.WebView.Core\Avalonia.Controls.WebView.Core.csproj" />
</ItemGroup>
</Project>
39 changes: 39 additions & 0 deletions samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Avalonia.Controls.WebView.Samples.Oidc.MainWindow"
Title="OAuth 2.0 Authorization Server Metadata (RFC 8414) + PKCE"
Width="720"
Height="640">
<DockPanel Margin="12">
<StackPanel DockPanel.Dock="Top" Spacing="8">
<TextBlock TextWrapping="Wrap"
Text="Uses /.well-known/oauth-authorization-server, then authorization code with PKCE (S256). Register the redirect URI with your identity provider." />
<TextBlock Text="Issuer (authorization server identifier URL)" FontWeight="SemiBold" />
<TextBox x:Name="IssuerBox"
Text="https://login.microsoftonline.com/common/v2.0" />
<TextBlock Text="Client ID" FontWeight="SemiBold" />
<TextBox x:Name="ClientIdBox"
PlaceholderText="Application (client) ID" />
<TextBlock Text="Redirect URI (must match app registration)" FontWeight="SemiBold" />
<TextBox x:Name="RedirectBox"
Text="http://localhost" />
<TextBlock Text="Scope" FontWeight="SemiBold" />
<TextBox x:Name="ScopeBox"
Text="openid profile offline_access" />
<Button HorizontalAlignment="Left"
Content="Sign in (metadata → broker → token)"
Click="SignIn_OnClick" />
</StackPanel>
<TextBlock DockPanel.Dock="Top"
Margin="0,12,0,4"
Text="Log"
FontWeight="SemiBold" />
<ScrollViewer>
<TextBox x:Name="LogBox"
AcceptsReturn="True"
IsReadOnly="True"
TextWrapping="Wrap"
FontFamily="Consolas" />
</ScrollViewer>
</DockPanel>
</Window>
94 changes: 94 additions & 0 deletions samples/Avalonia.Controls.WebView.Samples.Oidc/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 16 additions & 0 deletions samples/Avalonia.Controls.WebView.Samples.Oidc/Program.cs
Original file line number Diff line number Diff line change
@@ -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<App>()
.UsePlatformDetect()
.LogToTrace();
}
5 changes: 5 additions & 0 deletions samples/Avalonia.Controls.WebView.Samples.Oidc/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions samples/Avalonia.Controls.WebView.Samples.Oidc/app.manifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Avalonia.Controls.WebView.Samples.Oidc"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;

namespace Avalonia.Controls.OAuth2;

/// <summary>Parses the authorization response redirect (query string).</summary>
public static class AuthorizationCallbackParser
{
/// <summary>
/// Parses <paramref name="callbackUri"/> query for <c>code</c>, <c>state</c>, and OAuth <c>error</c> parameters.
/// </summary>
/// <param name="callbackUri">The redirect URI including query from the authorization server.</param>
/// <param name="expectedState">The <c>state</c> value from the authorization request.</param>
/// <returns>The parsed authorization code.</returns>
/// <exception cref="InvalidOperationException">Missing code, state mismatch, or error response.</exception>
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<string, string> ParseQueryString(string query)
{
var d = new Dictionary<string, string>(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;
}
}

/// <summary>OAuth authorization redirect response values.</summary>
/// <param name="AuthorizationCode">The authorization code for the token endpoint.</param>
public readonly record struct AuthorizationCallbackResult(string AuthorizationCode);
Loading