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; +}