diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0e634cbc..92bb155f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -95,7 +95,7 @@
-
+
diff --git a/OpenIddict.Samples.sln b/OpenIddict.Samples.sln
index 5f941c0c..d3641808 100644
--- a/OpenIddict.Samples.sln
+++ b/OpenIddict.Samples.sln
@@ -136,6 +136,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sorgan.Wpf.Client", "sample
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sorgan.BlazorHybrid.Client", "samples\Sorgan\Sorgan.BlazorHybrid.Client\Sorgan.BlazorHybrid.Client.csproj", "{C392496F-B3E4-4B7C-97F3-66EB13206985}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sorgan.Console.Client", "samples\Sorgan\Sorgan.Console.Client\Sorgan.Console.Client.csproj", "{A2B093AC-6044-467E-B94F-936343DCD11B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -290,6 +292,10 @@ Global
{C392496F-B3E4-4B7C-97F3-66EB13206985}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C392496F-B3E4-4B7C-97F3-66EB13206985}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C392496F-B3E4-4B7C-97F3-66EB13206985}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A2B093AC-6044-467E-B94F-936343DCD11B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A2B093AC-6044-467E-B94F-936343DCD11B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A2B093AC-6044-467E-B94F-936343DCD11B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A2B093AC-6044-467E-B94F-936343DCD11B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -347,6 +353,7 @@ Global
{6E1B3224-B529-4B45-AD66-969BBBA08F63} = {F2076FDE-06F9-441B-938E-97953A3C0906}
{5132ABBD-6FC5-4232-B9E1-7F53EC52C826} = {F2076FDE-06F9-441B-938E-97953A3C0906}
{C392496F-B3E4-4B7C-97F3-66EB13206985} = {F2076FDE-06F9-441B-938E-97953A3C0906}
+ {A2B093AC-6044-467E-B94F-936343DCD11B} = {F2076FDE-06F9-441B-938E-97953A3C0906}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F3ECDD26-F40D-4AB4-BC48-8DF143F98FAE}
diff --git a/README.md b/README.md
index 5e164d65..b3b4266e 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ This repository contains samples demonstrating **how to use [OpenIddict](https:/
## .NET samples
- - [Sorgan](samples/Sorgan): Windows Forms, Windows Presentation Foundation and Blazor Hybrid clients using GitHub for user authentication.
+ - [Sorgan](samples/Sorgan): console, Windows Forms, Windows Presentation Foundation and Blazor Hybrid clients using GitHub for user authentication.
## OWIN/ASP.NET 4.8 samples
- [Fornax](samples/Fornax): authorization code flow demo using ASP.NET Web Forms 4.8 and OWIN/Katana, with a .NET Framework 4.8 console acting as the client.
diff --git a/samples/Kalarba/Kalarba.Client/Kalarba.Client.csproj b/samples/Kalarba/Kalarba.Client/Kalarba.Client.csproj
index 4b6f4df0..92b49a00 100644
--- a/samples/Kalarba/Kalarba.Client/Kalarba.Client.csproj
+++ b/samples/Kalarba/Kalarba.Client/Kalarba.Client.csproj
@@ -1,11 +1,12 @@
- net8.0
+ net48
Exe
+
diff --git a/samples/Kalarba/Kalarba.Client/Program.cs b/samples/Kalarba/Kalarba.Client/Program.cs
index 29324bf3..7f9efc0c 100644
--- a/samples/Kalarba/Kalarba.Client/Program.cs
+++ b/samples/Kalarba/Kalarba.Client/Program.cs
@@ -32,7 +32,7 @@
});
});
-await using var provider = services.BuildServiceProvider();
+using var provider = services.BuildServiceProvider();
var token = await GetTokenAsync(provider, "alice@wonderland.com", "P@ssw0rd");
Console.WriteLine("Access token: {0}", token);
@@ -58,7 +58,8 @@ static async Task GetTokenAsync(IServiceProvider provider, string email,
static async Task GetResourceAsync(IServiceProvider provider, string token)
{
- using var client = provider.GetRequiredService();
+ var factory = provider.GetRequiredService();
+ using var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:58779/api/message");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
diff --git a/samples/Sorgan/Sorgan.Console.Client/InteractiveService.cs b/samples/Sorgan/Sorgan.Console.Client/InteractiveService.cs
new file mode 100644
index 00000000..8e5292c5
--- /dev/null
+++ b/samples/Sorgan/Sorgan.Console.Client/InteractiveService.cs
@@ -0,0 +1,231 @@
+using System.Security.Claims;
+using Microsoft.Extensions.Hosting;
+using OpenIddict.Client;
+using Spectre.Console;
+using static OpenIddict.Abstractions.OpenIddictConstants;
+using static OpenIddict.Abstractions.OpenIddictExceptions;
+
+namespace Sorgan.Console.Client;
+
+public class InteractiveService : BackgroundService
+{
+ private readonly IHostApplicationLifetime _lifetime;
+ private readonly OpenIddictClientService _service;
+
+ public InteractiveService(
+ IHostApplicationLifetime lifetime,
+ OpenIddictClientService service)
+ {
+ _lifetime = lifetime;
+ _service = service;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Wait for the host to confirm that the application has started.
+ var source = new TaskCompletionSource();
+ using (_lifetime.ApplicationStarted.Register(static state => ((TaskCompletionSource) state!).SetResult(true), source))
+ {
+ await source.Task;
+ }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var provider = await GetSelectedProviderAsync(stoppingToken);
+
+ try
+ {
+ // Resolve the server configuration and determine the type of flow
+ // to use depending on the supported grants and the user selection.
+ var configuration = await _service.GetServerConfigurationByProviderNameAsync(provider, stoppingToken);
+ if (configuration.GrantTypesSupported.Contains(GrantTypes.DeviceCode) &&
+ configuration.DeviceAuthorizationEndpoint is not null &&
+ await UseDeviceAuthorizationGrantAsync(stoppingToken))
+ {
+ // Ask OpenIddict to send a device authorization request and write
+ // the complete verification endpoint URI to the console output.
+ var result = await _service.ChallengeUsingDeviceAsync(new()
+ {
+ CancellationToken = stoppingToken,
+ ProviderName = provider
+ });
+
+ if (result.VerificationUriComplete is not null)
+ {
+ AnsiConsole.MarkupLineInterpolated($"""
+ [yellow]Please visit [link]{result.VerificationUriComplete}[/] and confirm the
+ displayed code is '{result.UserCode}' to complete the authentication demand.[/]
+ """);
+ }
+
+ else
+ {
+ AnsiConsole.MarkupLineInterpolated($"""
+ [yellow]Please visit [link]{result.VerificationUri}[/] and enter
+ '{result.UserCode}' to complete the authentication demand.[/]
+ """);
+ }
+
+ AnsiConsole.MarkupLine("[cyan]Waiting for the user to approve the authorization demand.[/]");
+
+ // Wait for the user to complete the demand on the other device.
+ var response = await _service.AuthenticateWithDeviceAsync(new()
+ {
+ CancellationToken = stoppingToken,
+ DeviceCode = result.DeviceCode,
+ Interval = result.Interval,
+ ProviderName = provider,
+ Timeout = result.ExpiresIn < TimeSpan.FromMinutes(5) ? result.ExpiresIn : TimeSpan.FromMinutes(5)
+ });
+
+ AnsiConsole.MarkupLine("[green]Device authentication successful:[/]");
+ AnsiConsole.Write(CreateClaimTable(response.Principal));
+
+ // If a refresh token was returned by the authorization server, ask the user
+ // if the access token should be refreshed using the refresh_token grant.
+ if (!string.IsNullOrEmpty(response.RefreshToken) && await UseRefreshTokenGrantAsync(stoppingToken))
+ {
+ AnsiConsole.MarkupLine("[steelblue]Claims extracted from the refreshed identity:[/]");
+ AnsiConsole.Write(CreateClaimTable((await _service.AuthenticateWithRefreshTokenAsync(new()
+ {
+ CancellationToken = stoppingToken,
+ ProviderName = provider,
+ RefreshToken = response.RefreshToken
+ })).Principal));
+ }
+ }
+
+ else
+ {
+ AnsiConsole.MarkupLine("[cyan]Launching the system browser.[/]");
+
+ // Ask OpenIddict to initiate the authentication flow (typically, by starting the system browser).
+ var result = await _service.ChallengeInteractivelyAsync(new()
+ {
+ CancellationToken = stoppingToken,
+ ProviderName = provider
+ });
+
+ AnsiConsole.MarkupLine("[cyan]Waiting for the user to approve the authorization demand.[/]");
+
+ // Wait for the user to complete the authorization process.
+ var response = await _service.AuthenticateInteractivelyAsync(new()
+ {
+ CancellationToken = stoppingToken,
+ Nonce = result.Nonce
+ });
+
+ AnsiConsole.MarkupLine("[green]Interactive authentication successful:[/]");
+ AnsiConsole.Write(CreateClaimTable(response.Principal));
+
+ // If a refresh token was returned by the authorization server, ask the user
+ // if the access token should be refreshed using the refresh_token grant.
+ if (!string.IsNullOrEmpty(response.RefreshToken) && await UseRefreshTokenGrantAsync(stoppingToken))
+ {
+ AnsiConsole.MarkupLine("[steelblue]Claims extracted from the refreshed identity:[/]");
+ AnsiConsole.Write(CreateClaimTable((await _service.AuthenticateWithRefreshTokenAsync(new()
+ {
+ CancellationToken = stoppingToken,
+ ProviderName = provider,
+ RefreshToken = response.RefreshToken
+ })).Principal));
+ }
+ }
+ }
+
+ catch (OperationCanceledException)
+ {
+ AnsiConsole.MarkupLine("[red]The authentication process was aborted.[/]");
+ }
+
+ catch (ProtocolException exception) when (exception.Error is Errors.AccessDenied)
+ {
+ AnsiConsole.MarkupLine("[yellow]The authorization was denied by the end user.[/]");
+ }
+
+ catch
+ {
+ AnsiConsole.MarkupLine("[red]An error occurred while trying to authenticate the user.[/]");
+ }
+ }
+
+ static Table CreateClaimTable(ClaimsPrincipal principal)
+ {
+ var table = new Table()
+ .LeftAligned()
+ .AddColumn("Claim type")
+ .AddColumn("Claim value type")
+ .AddColumn("Claim value")
+ .AddColumn("Claim issuer");
+
+ foreach (var claim in principal.Claims)
+ {
+ table.AddRow(
+ claim.Type.EscapeMarkup(),
+ claim.ValueType.EscapeMarkup(),
+ claim.Value.EscapeMarkup(),
+ claim.Issuer.EscapeMarkup());
+ }
+
+ return table;
+ }
+
+ static Task UseDeviceAuthorizationGrantAsync(CancellationToken cancellationToken)
+ {
+ static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
+ "Would you like to authenticate using the device authorization grant?")
+ {
+ Comparer = StringComparer.CurrentCultureIgnoreCase,
+ DefaultValue = false,
+ ShowDefaultValue = true
+ });
+
+ return WaitAsync(Task.Run(Prompt, cancellationToken), cancellationToken);
+ }
+
+ static Task UseRefreshTokenGrantAsync(CancellationToken cancellationToken)
+ {
+ static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
+ "Would you like to refresh the user authentication using the refresh token grant?")
+ {
+ Comparer = StringComparer.CurrentCultureIgnoreCase,
+ DefaultValue = false,
+ ShowDefaultValue = true
+ });
+
+ return WaitAsync(Task.Run(Prompt, cancellationToken), cancellationToken);
+ }
+
+ Task GetSelectedProviderAsync(CancellationToken cancellationToken)
+ {
+ async Task PromptAsync() => AnsiConsole.Prompt(new SelectionPrompt()
+ .Title("Select the authentication provider you'd like to log in with.")
+ .AddChoices(from registration in await _service.GetClientRegistrationsAsync(stoppingToken)
+ where !string.IsNullOrEmpty(registration.ProviderName)
+ where !string.IsNullOrEmpty(registration.ProviderDisplayName)
+ select registration)
+ .UseConverter(registration => registration.ProviderDisplayName!)).ProviderName!;
+
+ return WaitAsync(Task.Run(PromptAsync, cancellationToken), cancellationToken);
+ }
+
+ static async Task WaitAsync(Task task, CancellationToken cancellationToken)
+ {
+#if SUPPORTS_TASK_WAIT_ASYNC
+ return await task.WaitAsync(cancellationToken);
+#else
+ var source = new TaskCompletionSource(TaskCreationOptions.None);
+
+ using (cancellationToken.Register(static state => ((TaskCompletionSource) state!).SetResult(true), source))
+ {
+ if (await Task.WhenAny(task, source.Task) == source.Task)
+ {
+ throw new OperationCanceledException(cancellationToken);
+ }
+
+ return await task;
+ }
+#endif
+ }
+ }
+}
diff --git a/samples/Sorgan/Sorgan.Console.Client/Program.cs b/samples/Sorgan/Sorgan.Console.Client/Program.cs
new file mode 100644
index 00000000..4e7db552
--- /dev/null
+++ b/samples/Sorgan/Sorgan.Console.Client/Program.cs
@@ -0,0 +1,84 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Sorgan.Console.Client;
+
+var host = new HostBuilder()
+ // Note: applications for which a single instance is preferred can reference
+ // the Dapplo.Microsoft.Extensions.Hosting.AppServices package and call this
+ // method to automatically close extra instances based on the specified identifier:
+ //
+ // .ConfigureSingleInstance(options => options.MutexId = "{5519A32F-5B86-4CBB-A601-0CC7872A126A}")
+ //
+ .ConfigureLogging(options => options.AddDebug())
+ .ConfigureServices(services =>
+ {
+ services.AddDbContext(options =>
+ {
+ options.UseSqlite($"Filename={Path.Combine(Path.GetTempPath(), "openiddict-sorgan-console-client.sqlite3")}");
+ options.UseOpenIddict();
+ });
+
+ services.AddOpenIddict()
+
+ // Register the OpenIddict core components.
+ .AddCore(options =>
+ {
+ // Configure OpenIddict to use the Entity Framework Core stores and models.
+ // Note: call ReplaceDefaultEntities() to replace the default OpenIddict entities.
+ options.UseEntityFrameworkCore()
+ .UseDbContext();
+ })
+
+ // Register the OpenIddict client components.
+ .AddClient(options =>
+ {
+ // Note: this sample uses the authorization code, device authorization code
+ // and refresh token flows, but you can enable the other flows if necessary.
+ options.AllowAuthorizationCodeFlow()
+ .AllowDeviceCodeFlow()
+ .AllowRefreshTokenFlow();
+
+ // Register the signing and encryption credentials used to protect
+ // sensitive data like the state tokens produced by OpenIddict.
+ options.AddDevelopmentEncryptionCertificate()
+ .AddDevelopmentSigningCertificate();
+
+ // Add the operating system integration.
+ options.UseSystemIntegration();
+
+ // Register the System.Net.Http integration and use the identity of the current
+ // assembly as a more specific user agent, which can be useful when dealing with
+ // providers that use the user agent as a way to throttle requests (e.g Reddit).
+ options.UseSystemNetHttp()
+ .SetProductInformation(typeof(Program).Assembly);
+
+ // Register the Web providers integrations.
+ //
+ // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint
+ // address per provider, unless all the registered providers support returning an "iss"
+ // parameter containing their URL as part of authorization responses. For more information,
+ // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4.
+ options.UseWebProviders()
+ .AddGitHub(options =>
+ {
+ options.SetClientId("5c11c030ca570e8c5a16")
+ .SetClientSecret("a5d36464b2ac2fe3e87fbfb95f0ebcf06c5992c1")
+ .SetRedirectUri("callback/login/github");
+ });
+ });
+
+ // Register the worker responsible for creating the database used to store tokens
+ // and adding the registry entries required to register the custom URI scheme.
+ //
+ // Note: in a real world application, this step should be part of a setup script.
+ services.AddHostedService();
+
+ // Register the background service responsible for handling the console interactions.
+ services.AddHostedService();
+ })
+ .UseConsoleLifetime()
+ .Build();
+
+await host.RunAsync();
\ No newline at end of file
diff --git a/samples/Sorgan/Sorgan.Console.Client/Sorgan.Console.Client.csproj b/samples/Sorgan/Sorgan.Console.Client/Sorgan.Console.Client.csproj
new file mode 100644
index 00000000..7d4ec2d4
--- /dev/null
+++ b/samples/Sorgan/Sorgan.Console.Client/Sorgan.Console.Client.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Sorgan/Sorgan.Console.Client/Worker.cs b/samples/Sorgan/Sorgan.Console.Client/Worker.cs
new file mode 100644
index 00000000..140aeefe
--- /dev/null
+++ b/samples/Sorgan/Sorgan.Console.Client/Worker.cs
@@ -0,0 +1,23 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Sorgan.Console.Client;
+
+public class Worker : IHostedService
+{
+ private readonly IServiceProvider _provider;
+
+ public Worker(IServiceProvider provider)
+ => _provider = provider;
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ using var scope = _provider.CreateScope();
+
+ var context = scope.ServiceProvider.GetRequiredService();
+ await context.Database.EnsureCreatedAsync();
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}