+ Invalid login request
+ There are no login schemes configured for this request.
+
+ }
+
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Account/Login/Index.cshtml.cs b/src/IdentitySvc/Pages/Account/Login/Index.cshtml.cs
new file mode 100644
index 0000000..2a37d18
--- /dev/null
+++ b/src/IdentitySvc/Pages/Account/Login/Index.cshtml.cs
@@ -0,0 +1,216 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer;
+using Duende.IdentityServer.Events;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Services;
+using Duende.IdentityServer.Stores;
+using IdentitySvc.Models;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace IdentitySvc.Pages.Login;
+
+[SecurityHeaders]
+[AllowAnonymous]
+public class Index : PageModel
+{
+ private readonly UserManager _userManager;
+ private readonly SignInManager _signInManager;
+ private readonly IIdentityServerInteractionService _interaction;
+ private readonly IEventService _events;
+ private readonly IAuthenticationSchemeProvider _schemeProvider;
+ private readonly IIdentityProviderStore _identityProviderStore;
+
+ public ViewModel View { get; set; } = default!;
+
+ [BindProperty]
+ public InputModel Input { get; set; } = default!;
+
+ public Index(
+ IIdentityServerInteractionService interaction,
+ IAuthenticationSchemeProvider schemeProvider,
+ IIdentityProviderStore identityProviderStore,
+ IEventService events,
+ UserManager userManager,
+ SignInManager signInManager)
+ {
+ _userManager = userManager;
+ _signInManager = signInManager;
+ _interaction = interaction;
+ _schemeProvider = schemeProvider;
+ _identityProviderStore = identityProviderStore;
+ _events = events;
+ }
+
+ public async Task OnGet(string? returnUrl)
+ {
+ await BuildModelAsync(returnUrl);
+
+ if (View.IsExternalLoginOnly)
+ {
+ // we only have one option for logging in and it's an external provider
+ return RedirectToPage("/ExternalLogin/Challenge", new { scheme = View.ExternalLoginScheme, returnUrl });
+ }
+
+ return Page();
+ }
+
+ public async Task OnPost()
+ {
+ // check if we are in the context of an authorization request
+ var context = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl);
+
+ // the user clicked the "cancel" button
+ if (Input.Button != "login")
+ {
+ if (context != null)
+ {
+ // This "can't happen", because if the ReturnUrl was null, then the context would be null
+ ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));
+
+ // if the user cancels, send a result back into IdentityServer as if they
+ // denied the consent (even if this client does not require consent).
+ // this will send back an access denied OIDC error response to the client.
+ await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);
+
+ // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
+ if (context.IsNativeClient())
+ {
+ // The client is native, so this change in how to
+ // return the response is for better UX for the end user.
+ return this.LoadingPage(Input.ReturnUrl);
+ }
+
+ return Redirect(Input.ReturnUrl ?? "~/");
+ }
+ else
+ {
+ // since we don't have a valid context, then we just go back to the home page
+ return Redirect("~/");
+ }
+ }
+
+ if (ModelState.IsValid)
+ {
+ var result = await _signInManager.PasswordSignInAsync(Input.Username!, Input.Password!, Input.RememberLogin, lockoutOnFailure: true);
+ if (result.Succeeded)
+ {
+ var user = await _userManager.FindByNameAsync(Input.Username!);
+ await _events.RaiseAsync(new UserLoginSuccessEvent(user!.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));
+ Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider);
+
+ if (context != null)
+ {
+ // This "can't happen", because if the ReturnUrl was null, then the context would be null
+ ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));
+
+ if (context.IsNativeClient())
+ {
+ // The client is native, so this change in how to
+ // return the response is for better UX for the end user.
+ return this.LoadingPage(Input.ReturnUrl);
+ }
+
+ // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
+ return Redirect(Input.ReturnUrl ?? "~/");
+ }
+
+ // request for a local page
+ if (Url.IsLocalUrl(Input.ReturnUrl))
+ {
+ return Redirect(Input.ReturnUrl);
+ }
+ else if (string.IsNullOrEmpty(Input.ReturnUrl))
+ {
+ return Redirect("~/");
+ }
+ else
+ {
+ // user might have clicked on a malicious link - should be logged
+ throw new ArgumentException("invalid return URL");
+ }
+ }
+
+ const string error = "invalid credentials";
+ await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId));
+ Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error);
+ ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage);
+ }
+
+ // something went wrong, show form with error
+ await BuildModelAsync(Input.ReturnUrl);
+ return Page();
+ }
+
+ private async Task BuildModelAsync(string? returnUrl)
+ {
+ Input = new InputModel
+ {
+ ReturnUrl = returnUrl
+ };
+
+ var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
+ if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
+ {
+ var local = context.IdP == Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider;
+
+ // this is meant to short circuit the UI and only trigger the one external IdP
+ View = new ViewModel
+ {
+ EnableLocalLogin = local,
+ };
+
+ Input.Username = context.LoginHint;
+
+ if (!local)
+ {
+ View.ExternalProviders = new[] { new ViewModel.ExternalProvider ( authenticationScheme: context.IdP ) };
+ }
+
+ return;
+ }
+
+ var schemes = await _schemeProvider.GetAllSchemesAsync();
+
+ var providers = schemes
+ .Where(x => x.DisplayName != null)
+ .Select(x => new ViewModel.ExternalProvider
+ (
+ authenticationScheme: x.Name,
+ displayName: x.DisplayName ?? x.Name
+ )).ToList();
+
+ var dynamicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync())
+ .Where(x => x.Enabled)
+ .Select(x => new ViewModel.ExternalProvider
+ (
+ authenticationScheme: x.Scheme,
+ displayName: x.DisplayName ?? x.Scheme
+ ));
+ providers.AddRange(dynamicSchemes);
+
+
+ var allowLocal = true;
+ var client = context?.Client;
+ if (client != null)
+ {
+ allowLocal = client.EnableLocalLogin;
+ if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Count != 0)
+ {
+ providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList();
+ }
+ }
+
+ View = new ViewModel
+ {
+ AllowRememberLogin = LoginOptions.AllowRememberLogin,
+ EnableLocalLogin = allowLocal && LoginOptions.AllowLocalLogin,
+ ExternalProviders = providers.ToArray()
+ };
+ }
+}
diff --git a/src/IdentitySvc/Pages/Account/Login/InputModel.cs b/src/IdentitySvc/Pages/Account/Login/InputModel.cs
new file mode 100644
index 0000000..7b40060
--- /dev/null
+++ b/src/IdentitySvc/Pages/Account/Login/InputModel.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.ComponentModel.DataAnnotations;
+
+namespace IdentitySvc.Pages.Login;
+
+public class InputModel
+{
+ [Required]
+ public string? Username { get; set; }
+ [Required]
+ public string? Password { get; set; }
+ public bool RememberLogin { get; set; }
+ public string? ReturnUrl { get; set; }
+ public string? Button { get; set; }
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Account/Login/LoginOptions.cs b/src/IdentitySvc/Pages/Account/Login/LoginOptions.cs
new file mode 100644
index 0000000..087784e
--- /dev/null
+++ b/src/IdentitySvc/Pages/Account/Login/LoginOptions.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Login;
+
+public static class LoginOptions
+{
+ public static readonly bool AllowLocalLogin = true;
+ public static readonly bool AllowRememberLogin = true;
+ public static readonly TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30);
+ public static readonly string InvalidCredentialsErrorMessage = "Invalid username or password";
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Account/Login/ViewModel.cs b/src/IdentitySvc/Pages/Account/Login/ViewModel.cs
new file mode 100644
index 0000000..ebdf99f
--- /dev/null
+++ b/src/IdentitySvc/Pages/Account/Login/ViewModel.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Login;
+
+public class ViewModel
+{
+ public bool AllowRememberLogin { get; set; } = true;
+ public bool EnableLocalLogin { get; set; } = true;
+
+ public IEnumerable ExternalProviders { get; set; } = Enumerable.Empty();
+ public IEnumerable VisibleExternalProviders => ExternalProviders.Where(x => !String.IsNullOrWhiteSpace(x.DisplayName));
+
+ public bool IsExternalLoginOnly => EnableLocalLogin == false && ExternalProviders?.Count() == 1;
+ public string? ExternalLoginScheme => IsExternalLoginOnly ? ExternalProviders?.SingleOrDefault()?.AuthenticationScheme : null;
+
+ public class ExternalProvider
+ {
+ public ExternalProvider(string authenticationScheme, string? displayName = null)
+ {
+ AuthenticationScheme = authenticationScheme;
+ DisplayName = displayName;
+ }
+
+ public string? DisplayName { get; set; }
+ public string AuthenticationScheme { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Account/Logout/Index.cshtml b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml
new file mode 100644
index 0000000..79cb1f5
--- /dev/null
+++ b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml
@@ -0,0 +1,17 @@
+@page
+@model IdentitySvc.Pages.Logout.Index
+
+
+
+
Logout
+
Would you like to logout of IdentityServer?
+
+
+
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Account/Logout/Index.cshtml.cs b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml.cs
new file mode 100644
index 0000000..c73c93e
--- /dev/null
+++ b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml.cs
@@ -0,0 +1,104 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Events;
+using Duende.IdentityServer.Extensions;
+using Duende.IdentityServer.Services;
+using IdentityModel;
+using IdentitySvc.Models;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace IdentitySvc.Pages.Logout;
+
+[SecurityHeaders]
+[AllowAnonymous]
+public class Index : PageModel
+{
+ private readonly SignInManager _signInManager;
+ private readonly IIdentityServerInteractionService _interaction;
+ private readonly IEventService _events;
+
+ [BindProperty]
+ public string? LogoutId { get; set; }
+
+ public Index(SignInManager signInManager, IIdentityServerInteractionService interaction, IEventService events)
+ {
+ _signInManager = signInManager;
+ _interaction = interaction;
+ _events = events;
+ }
+
+ public async Task OnGet(string? logoutId)
+ {
+ LogoutId = logoutId;
+
+ var showLogoutPrompt = LogoutOptions.ShowLogoutPrompt;
+
+ if (User.Identity?.IsAuthenticated != true)
+ {
+ // if the user is not authenticated, then just show logged out page
+ showLogoutPrompt = false;
+ }
+ else
+ {
+ var context = await _interaction.GetLogoutContextAsync(LogoutId);
+ if (context?.ShowSignoutPrompt == false)
+ {
+ // it's safe to automatically sign-out
+ showLogoutPrompt = false;
+ }
+ }
+
+ if (showLogoutPrompt == false)
+ {
+ // if the request for logout was properly authenticated from IdentityServer, then
+ // we don't need to show the prompt and can just log the user out directly.
+ return await OnPost();
+ }
+
+ return Page();
+ }
+
+ public async Task OnPost()
+ {
+ if (User.Identity?.IsAuthenticated == true)
+ {
+ // if there's no current logout context, we need to create one
+ // this captures necessary info from the current logged in user
+ // this can still return null if there is no context needed
+ LogoutId ??= await _interaction.CreateLogoutContextAsync();
+
+ // delete local authentication cookie
+ await _signInManager.SignOutAsync();
+
+ // see if we need to trigger federated logout
+ var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value;
+
+ // raise the logout event
+ await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
+ Telemetry.Metrics.UserLogout(idp);
+
+ // if it's a local login we can ignore this workflow
+ if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider)
+ {
+ // we need to see if the provider supports external logout
+ if (await HttpContext.GetSchemeSupportsSignOutAsync(idp))
+ {
+ // build a return URL so the upstream provider will redirect back
+ // to us after the user has logged out. this allows us to then
+ // complete our single sign-out processing.
+ var url = Url.Page("/Account/Logout/Loggedout", new { logoutId = LogoutId });
+
+ // this triggers a redirect to the external provider for sign-out
+ return SignOut(new AuthenticationProperties { RedirectUri = url }, idp);
+ }
+ }
+ }
+
+ return RedirectToPage("/Account/Logout/LoggedOut", new { logoutId = LogoutId });
+ }
+}
diff --git a/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml b/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml
new file mode 100644
index 0000000..d57a2a8
--- /dev/null
+++ b/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml
@@ -0,0 +1,30 @@
+@page
+@model IdentitySvc.Pages.Logout.LoggedOut
+
+
+ @foreach (var resource in Model.Resources)
+ {
+
@resource.DisplayName
+ }
+
+
+ }
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Consent/ConsentOptions.cs b/src/IdentitySvc/Pages/Consent/ConsentOptions.cs
new file mode 100644
index 0000000..4916fbb
--- /dev/null
+++ b/src/IdentitySvc/Pages/Consent/ConsentOptions.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Consent;
+
+public static class ConsentOptions
+{
+ public static readonly bool EnableOfflineAccess = true;
+ public static readonly string OfflineAccessDisplayName = "Offline Access";
+ public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline";
+
+ public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission";
+ public static readonly string InvalidSelectionErrorMessage = "Invalid selection";
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Consent/Index.cshtml b/src/IdentitySvc/Pages/Consent/Index.cshtml
new file mode 100644
index 0000000..eec803a
--- /dev/null
+++ b/src/IdentitySvc/Pages/Consent/Index.cshtml
@@ -0,0 +1,107 @@
+@page
+@model IdentitySvc.Pages.Consent.Index
+@{
+}
+
+
+
+ @if (Model.View.ClientLogoUrl != null)
+ {
+
+ }
+
+ @Model.View.ClientName
+ is requesting your permission
+
+
Uncheck the permissions you do not wish to grant.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/IdentitySvc/Pages/Consent/Index.cshtml.cs b/src/IdentitySvc/Pages/Consent/Index.cshtml.cs
new file mode 100644
index 0000000..b649910
--- /dev/null
+++ b/src/IdentitySvc/Pages/Consent/Index.cshtml.cs
@@ -0,0 +1,236 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Events;
+using Duende.IdentityServer.Extensions;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Services;
+using Duende.IdentityServer.Validation;
+using IdentityModel;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace IdentitySvc.Pages.Consent;
+
+[Authorize]
+[SecurityHeaders]
+public class Index : PageModel
+{
+ private readonly IIdentityServerInteractionService _interaction;
+ private readonly IEventService _events;
+ private readonly ILogger _logger;
+
+ public Index(
+ IIdentityServerInteractionService interaction,
+ IEventService events,
+ ILogger logger)
+ {
+ _interaction = interaction;
+ _events = events;
+ _logger = logger;
+ }
+
+ public ViewModel View { get; set; } = default!;
+
+ [BindProperty]
+ public InputModel Input { get; set; } = default!;
+
+ public async Task OnGet(string? returnUrl)
+ {
+ if (!await SetViewModelAsync(returnUrl))
+ {
+ return RedirectToPage("/Home/Error/Index");
+ }
+
+ Input = new InputModel
+ {
+ ReturnUrl = returnUrl,
+ };
+
+ return Page();
+ }
+
+ public async Task OnPost()
+ {
+ // validate return url is still valid
+ var request = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl);
+ if (request == null) return RedirectToPage("/Home/Error/Index");
+
+ ConsentResponse? grantedConsent = null;
+
+ // user clicked 'no' - send back the standard 'access_denied' response
+ if (Input.Button == "no")
+ {
+ grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied };
+
+ // emit event
+ await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues));
+ Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName));
+ }
+ // user clicked 'yes' - validate the data
+ else if (Input.Button == "yes")
+ {
+ // if the user consented to some scope, build the response model
+ if (Input.ScopesConsented.Any())
+ {
+ var scopes = Input.ScopesConsented;
+ if (ConsentOptions.EnableOfflineAccess == false)
+ {
+ scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess);
+ }
+
+ grantedConsent = new ConsentResponse
+ {
+ RememberConsent = Input.RememberConsent,
+ ScopesValuesConsented = scopes.ToArray(),
+ Description = Input.Description
+ };
+
+ // emit event
+ await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent));
+ Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent);
+ var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented);
+ Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied);
+ }
+ else
+ {
+ ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage);
+ }
+ }
+ else
+ {
+ ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage);
+ }
+
+ if (grantedConsent != null)
+ {
+ ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));
+
+ // communicate outcome of consent back to identityserver
+ await _interaction.GrantConsentAsync(request, grantedConsent);
+
+ // redirect back to authorization endpoint
+ if (request.IsNativeClient() == true)
+ {
+ // The client is native, so this change in how to
+ // return the response is for better UX for the end user.
+ return this.LoadingPage(Input.ReturnUrl);
+ }
+
+ return Redirect(Input.ReturnUrl);
+ }
+
+ // we need to redisplay the consent UI
+ if (!await SetViewModelAsync(Input.ReturnUrl))
+ {
+ return RedirectToPage("/Home/Error/Index");
+ }
+ return Page();
+ }
+
+ private async Task SetViewModelAsync(string? returnUrl)
+ {
+ ArgumentNullException.ThrowIfNull(returnUrl);
+
+ var request = await _interaction.GetAuthorizationContextAsync(returnUrl);
+ if (request != null)
+ {
+ View = CreateConsentViewModel(request);
+ return true;
+ }
+ else
+ {
+ _logger.NoConsentMatchingRequest(returnUrl);
+ return false;
+ }
+ }
+
+ private ViewModel CreateConsentViewModel(AuthorizationRequest request)
+ {
+ var vm = new ViewModel
+ {
+ ClientName = request.Client.ClientName ?? request.Client.ClientId,
+ ClientUrl = request.Client.ClientUri,
+ ClientLogoUrl = request.Client.LogoUri,
+ AllowRememberConsent = request.Client.AllowRememberConsent
+ };
+
+ vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources
+ .Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name)))
+ .ToArray();
+
+ var resourceIndicators = request.Parameters.GetValues(OidcConstants.AuthorizeRequest.Resource) ?? Enumerable.Empty();
+ var apiResources = request.ValidatedResources.Resources.ApiResources.Where(x => resourceIndicators.Contains(x.Name));
+
+ var apiScopes = new List();
+ foreach (var parsedScope in request.ValidatedResources.ParsedScopes)
+ {
+ var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName);
+ if (apiScope != null)
+ {
+ var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue));
+ scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName))
+ .Select(x => new ResourceViewModel
+ {
+ Name = x.Name,
+ DisplayName = x.DisplayName ?? x.Name,
+ }).ToArray();
+ apiScopes.Add(scopeVm);
+ }
+ }
+ if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess)
+ {
+ apiScopes.Add(CreateOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess)));
+ }
+ vm.ApiScopes = apiScopes;
+
+ return vm;
+ }
+
+ private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check)
+ {
+ return new ScopeViewModel
+ {
+ Name = identity.Name,
+ Value = identity.Name,
+ DisplayName = identity.DisplayName ?? identity.Name,
+ Description = identity.Description,
+ Emphasize = identity.Emphasize,
+ Required = identity.Required,
+ Checked = check || identity.Required
+ };
+ }
+
+ private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check)
+ {
+ var displayName = apiScope.DisplayName ?? apiScope.Name;
+ if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter))
+ {
+ displayName += ":" + parsedScopeValue.ParsedParameter;
+ }
+
+ return new ScopeViewModel
+ {
+ Name = parsedScopeValue.ParsedName,
+ Value = parsedScopeValue.RawValue,
+ DisplayName = displayName,
+ Description = apiScope.Description,
+ Emphasize = apiScope.Emphasize,
+ Required = apiScope.Required,
+ Checked = check || apiScope.Required
+ };
+ }
+
+ private static ScopeViewModel CreateOfflineAccessScope(bool check)
+ {
+ return new ScopeViewModel
+ {
+ Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess,
+ DisplayName = ConsentOptions.OfflineAccessDisplayName,
+ Description = ConsentOptions.OfflineAccessDescription,
+ Emphasize = true,
+ Checked = check
+ };
+ }
+}
diff --git a/src/IdentitySvc/Pages/Consent/InputModel.cs b/src/IdentitySvc/Pages/Consent/InputModel.cs
new file mode 100644
index 0000000..d9a49d6
--- /dev/null
+++ b/src/IdentitySvc/Pages/Consent/InputModel.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Consent;
+
+public class InputModel
+{
+ public string? Button { get; set; }
+ public IEnumerable ScopesConsented { get; set; } = new List();
+ public bool RememberConsent { get; set; } = true;
+ public string? ReturnUrl { get; set; }
+ public string? Description { get; set; }
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Consent/ViewModel.cs b/src/IdentitySvc/Pages/Consent/ViewModel.cs
new file mode 100644
index 0000000..88b3491
--- /dev/null
+++ b/src/IdentitySvc/Pages/Consent/ViewModel.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Consent;
+
+public class ViewModel
+{
+ public string? ClientName { get; set; }
+ public string? ClientUrl { get; set; }
+ public string? ClientLogoUrl { get; set; }
+ public bool AllowRememberConsent { get; set; }
+
+ public IEnumerable IdentityScopes { get; set; } = Enumerable.Empty();
+ public IEnumerable ApiScopes { get; set; } = Enumerable.Empty();
+}
+
+public class ScopeViewModel
+{
+ public string? Name { get; set; }
+ public string? Value { get; set; }
+ public string? DisplayName { get; set; }
+ public string? Description { get; set; }
+ public bool Emphasize { get; set; }
+ public bool Required { get; set; }
+ public bool Checked { get; set; }
+ public IEnumerable Resources { get; set; } = Enumerable.Empty();
+}
+
+public class ResourceViewModel
+{
+ public string? Name { get; set; }
+ public string? DisplayName { get; set; }
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Consent/_ScopeListItem.cshtml b/src/IdentitySvc/Pages/Consent/_ScopeListItem.cshtml
new file mode 100644
index 0000000..8fc4547
--- /dev/null
+++ b/src/IdentitySvc/Pages/Consent/_ScopeListItem.cshtml
@@ -0,0 +1,47 @@
+@using IdentitySvc.Pages.Consent
+@model ScopeViewModel
+
+
+ @foreach (var resource in Model.Resources)
+ {
+
@resource.DisplayName
+ }
+
+
+ }
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Device/DeviceOptions.cs b/src/IdentitySvc/Pages/Device/DeviceOptions.cs
new file mode 100644
index 0000000..2a70255
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/DeviceOptions.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Device;
+
+public static class DeviceOptions
+{
+ public static readonly bool EnableOfflineAccess = true;
+ public static readonly string OfflineAccessDisplayName = "Offline Access";
+ public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline";
+
+ public static readonly string InvalidUserCode = "Invalid user code";
+ public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission";
+ public static readonly string InvalidSelectionErrorMessage = "Invalid selection";
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Device/Index.cshtml b/src/IdentitySvc/Pages/Device/Index.cshtml
new file mode 100644
index 0000000..9c2adbd
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/Index.cshtml
@@ -0,0 +1,141 @@
+@page
+@model IdentitySvc.Pages.Device.Index
+@{
+}
+
+@if (Model.Input.UserCode == null)
+{
+ @*We need to collect the user code*@
+
+
+
User Code
+
Please enter the code displayed on your device.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
+else
+{
+ @*collect consent for the user code provided*@
+
+
+ @if (Model.View.ClientLogoUrl != null)
+ {
+
+ }
+
+ @Model.View.ClientName
+ is requesting your permission
+
+
Please confirm that the authorization request matches the code: @Model.Input.UserCode.
+
Uncheck the permissions you do not wish to grant.
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/src/IdentitySvc/Pages/Device/Index.cshtml.cs b/src/IdentitySvc/Pages/Device/Index.cshtml.cs
new file mode 100644
index 0000000..81ca290
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/Index.cshtml.cs
@@ -0,0 +1,220 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Configuration;
+using Duende.IdentityServer.Events;
+using Duende.IdentityServer.Extensions;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Services;
+using Duende.IdentityServer.Validation;
+using IdentitySvc.Pages.Consent;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Options;
+
+namespace IdentitySvc.Pages.Device;
+
+[SecurityHeaders]
+[Authorize]
+public class Index : PageModel
+{
+ private readonly IDeviceFlowInteractionService _interaction;
+ private readonly IEventService _events;
+ private readonly IOptions _options;
+ private readonly ILogger _logger;
+
+ public Index(
+ IDeviceFlowInteractionService interaction,
+ IEventService eventService,
+ IOptions options,
+ ILogger logger)
+ {
+ _interaction = interaction;
+ _events = eventService;
+ _options = options;
+ _logger = logger;
+ }
+
+ public ViewModel View { get; set; } = default!;
+
+ [BindProperty]
+ public InputModel Input { get; set; } = default!;
+
+ public async Task OnGet(string? userCode)
+ {
+ if (String.IsNullOrWhiteSpace(userCode))
+ {
+ return Page();
+ }
+
+ if (!await SetViewModelAsync(userCode))
+ {
+ ModelState.AddModelError("", DeviceOptions.InvalidUserCode);
+ return Page();
+ }
+
+ Input = new InputModel {
+ UserCode = userCode,
+ };
+
+ return Page();
+ }
+
+ public async Task OnPost()
+ {
+ var request = await _interaction.GetAuthorizationContextAsync(Input.UserCode ?? throw new ArgumentNullException(nameof(Input.UserCode)));
+ if (request == null) return RedirectToPage("/Home/Error/Index");
+
+ ConsentResponse? grantedConsent = null;
+
+ // user clicked 'no' - send back the standard 'access_denied' response
+ if (Input.Button == "no")
+ {
+ grantedConsent = new ConsentResponse
+ {
+ Error = AuthorizationError.AccessDenied
+ };
+
+ // emit event
+ await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues));
+ Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName));
+ }
+ // user clicked 'yes' - validate the data
+ else if (Input.Button == "yes")
+ {
+ // if the user consented to some scope, build the response model
+ if (Input.ScopesConsented.Any())
+ {
+ var scopes = Input.ScopesConsented;
+ if (ConsentOptions.EnableOfflineAccess == false)
+ {
+ scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess);
+ }
+
+ grantedConsent = new ConsentResponse
+ {
+ RememberConsent = Input.RememberConsent,
+ ScopesValuesConsented = scopes.ToArray(),
+ Description = Input.Description
+ };
+
+ // emit event
+ await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent));
+ Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent);
+ var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented);
+ Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied);
+ }
+ else
+ {
+ ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage);
+ }
+ }
+ else
+ {
+ ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage);
+ }
+
+ if (grantedConsent != null)
+ {
+ // communicate outcome of consent back to identityserver
+ await _interaction.HandleRequestAsync(Input.UserCode, grantedConsent);
+
+ // indicate that's it ok to redirect back to authorization endpoint
+ return RedirectToPage("/Device/Success");
+ }
+
+ // we need to redisplay the consent UI
+ if (!await SetViewModelAsync(Input.UserCode))
+ {
+ return RedirectToPage("/Home/Error/Index");
+ }
+ return Page();
+ }
+
+
+ private async Task SetViewModelAsync(string userCode)
+ {
+ var request = await _interaction.GetAuthorizationContextAsync(userCode);
+ if (request != null)
+ {
+ View = CreateConsentViewModel(request);
+ return true;
+ }
+ else
+ {
+ View = new ViewModel();
+ return false;
+ }
+ }
+
+ private ViewModel CreateConsentViewModel(DeviceFlowAuthorizationRequest request)
+ {
+ var vm = new ViewModel
+ {
+ ClientName = request.Client.ClientName ?? request.Client.ClientId,
+ ClientUrl = request.Client.ClientUri,
+ ClientLogoUrl = request.Client.LogoUri,
+ AllowRememberConsent = request.Client.AllowRememberConsent
+ };
+
+ vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))).ToArray();
+
+ var apiScopes = new List();
+ foreach (var parsedScope in request.ValidatedResources.ParsedScopes)
+ {
+ var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName);
+ if (apiScope != null)
+ {
+ var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue));
+ apiScopes.Add(scopeVm);
+ }
+ }
+ if (DeviceOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess)
+ {
+ apiScopes.Add(GetOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess)));
+ }
+ vm.ApiScopes = apiScopes;
+
+ return vm;
+ }
+
+ private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check)
+ {
+ return new ScopeViewModel
+ {
+ Value = identity.Name,
+ DisplayName = identity.DisplayName ?? identity.Name,
+ Description = identity.Description,
+ Emphasize = identity.Emphasize,
+ Required = identity.Required,
+ Checked = check || identity.Required
+ };
+ }
+
+ private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check)
+ {
+ return new ScopeViewModel
+ {
+ Value = parsedScopeValue.RawValue,
+ // todo: use the parsed scope value in the display?
+ DisplayName = apiScope.DisplayName ?? apiScope.Name,
+ Description = apiScope.Description,
+ Emphasize = apiScope.Emphasize,
+ Required = apiScope.Required,
+ Checked = check || apiScope.Required
+ };
+ }
+
+ private static ScopeViewModel GetOfflineAccessScope(bool check)
+ {
+ return new ScopeViewModel
+ {
+ Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess,
+ DisplayName = DeviceOptions.OfflineAccessDisplayName,
+ Description = DeviceOptions.OfflineAccessDescription,
+ Emphasize = true,
+ Checked = check
+ };
+ }
+}
diff --git a/src/IdentitySvc/Pages/Device/InputModel.cs b/src/IdentitySvc/Pages/Device/InputModel.cs
new file mode 100644
index 0000000..4923ef9
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/InputModel.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Device;
+
+public class InputModel
+{
+ public string? Button { get; set; }
+ public IEnumerable ScopesConsented { get; set; } = new List();
+ public bool RememberConsent { get; set; } = true;
+ public string? ReturnUrl { get; set; }
+ public string? Description { get; set; }
+ public string? UserCode { get; set; }
+}
diff --git a/src/IdentitySvc/Pages/Device/Success.cshtml b/src/IdentitySvc/Pages/Device/Success.cshtml
new file mode 100644
index 0000000..d861d3d
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/Success.cshtml
@@ -0,0 +1,12 @@
+@page
+@model IdentitySvc.Pages.Device.SuccessModel
+@{
+}
+
+
+
+
+
Success
+
You have successfully authorized the device
+
+
diff --git a/src/IdentitySvc/Pages/Device/Success.cshtml.cs b/src/IdentitySvc/Pages/Device/Success.cshtml.cs
new file mode 100644
index 0000000..5cefd21
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/Success.cshtml.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace IdentitySvc.Pages.Device;
+
+[SecurityHeaders]
+[Authorize]
+public class SuccessModel : PageModel
+{
+ public void OnGet()
+ {
+ }
+}
diff --git a/src/IdentitySvc/Pages/Device/ViewModel.cs b/src/IdentitySvc/Pages/Device/ViewModel.cs
new file mode 100644
index 0000000..bd2e5f2
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/ViewModel.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace IdentitySvc.Pages.Device;
+
+public class ViewModel
+{
+ public string? ClientName { get; set; }
+ public string? ClientUrl { get; set; }
+ public string? ClientLogoUrl { get; set; }
+ public bool AllowRememberConsent { get; set; }
+
+ public IEnumerable IdentityScopes { get; set; } = Enumerable.Empty();
+ public IEnumerable ApiScopes { get; set; } = Enumerable.Empty();
+}
+
+public class ScopeViewModel
+{
+ public string? Value { get; set; }
+ public string? DisplayName { get; set; }
+ public string? Description { get; set; }
+ public bool Emphasize { get; set; }
+ public bool Required { get; set; }
+ public bool Checked { get; set; }
+}
diff --git a/src/IdentitySvc/Pages/Device/_ScopeListItem.cshtml b/src/IdentitySvc/Pages/Device/_ScopeListItem.cshtml
new file mode 100644
index 0000000..a930990
--- /dev/null
+++ b/src/IdentitySvc/Pages/Device/_ScopeListItem.cshtml
@@ -0,0 +1,35 @@
+@using IdentitySvc.Pages.Device
+@model ScopeViewModel
+
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Diagnostics/Index.cshtml b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml
new file mode 100644
index 0000000..76a5c8f
--- /dev/null
+++ b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml
@@ -0,0 +1,67 @@
+@page
+@model IdentitySvc.Pages.Diagnostics.Index
+
+
+ @{
+ var clients = Model.View.Clients.ToArray();
+ for(var i = 0; i < clients.Length; i++)
+ {
+ @clients[i]
+ if (i < clients.Length - 1)
+ {
+ ,
+ }
+ }
+ }
+
+ }
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Diagnostics/Index.cshtml.cs b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml.cs
new file mode 100644
index 0000000..a693057
--- /dev/null
+++ b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml.cs
@@ -0,0 +1,34 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Authorization;
+
+namespace IdentitySvc.Pages.Diagnostics;
+
+[SecurityHeaders]
+[Authorize]
+public class Index : PageModel
+{
+ public ViewModel View { get; set; } = default!;
+
+ public async Task OnGet()
+ {
+ var localAddresses = new List { "127.0.0.1", "::1" };
+ if(HttpContext.Connection.LocalIpAddress != null)
+ {
+ localAddresses.Add(HttpContext.Connection.LocalIpAddress.ToString());
+ }
+
+ if (!localAddresses.Contains(HttpContext.Connection.RemoteIpAddress?.ToString()))
+ {
+ return NotFound();
+ }
+
+ View = new ViewModel(await HttpContext.AuthenticateAsync());
+
+ return Page();
+ }
+}
diff --git a/src/IdentitySvc/Pages/Diagnostics/ViewModel.cs b/src/IdentitySvc/Pages/Diagnostics/ViewModel.cs
new file mode 100644
index 0000000..7e77983
--- /dev/null
+++ b/src/IdentitySvc/Pages/Diagnostics/ViewModel.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using IdentityModel;
+using Microsoft.AspNetCore.Authentication;
+using System.Text;
+using System.Text.Json;
+
+namespace IdentitySvc.Pages.Diagnostics;
+
+public class ViewModel
+{
+ public ViewModel(AuthenticateResult result)
+ {
+ AuthenticateResult = result;
+
+ if (result?.Properties?.Items.TryGetValue("client_list", out var encoded) == true)
+ {
+ if (encoded != null)
+ {
+ var bytes = Base64Url.Decode(encoded);
+ var value = Encoding.UTF8.GetString(bytes);
+ Clients = JsonSerializer.Deserialize(value) ?? Enumerable.Empty();
+ return;
+ }
+ }
+ Clients = Enumerable.Empty();
+ }
+
+ public AuthenticateResult AuthenticateResult { get; }
+ public IEnumerable Clients { get; }
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/Extensions.cs b/src/IdentitySvc/Pages/Extensions.cs
new file mode 100644
index 0000000..fdf6959
--- /dev/null
+++ b/src/IdentitySvc/Pages/Extensions.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Models;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace IdentitySvc.Pages;
+
+public static class Extensions
+{
+ ///
+ /// Determines if the authentication scheme support signout.
+ ///
+ internal static async Task GetSchemeSupportsSignOutAsync(this HttpContext context, string scheme)
+ {
+ var provider = context.RequestServices.GetRequiredService();
+ var handler = await provider.GetHandlerAsync(context, scheme);
+ return (handler is IAuthenticationSignOutHandler);
+ }
+
+ ///
+ /// Checks if the redirect URI is for a native client.
+ ///
+ internal static bool IsNativeClient(this AuthorizationRequest context)
+ {
+ return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)
+ && !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Renders a loading page that is used to redirect back to the redirectUri.
+ ///
+ internal static IActionResult LoadingPage(this PageModel page, string? redirectUri)
+ {
+ page.HttpContext.Response.StatusCode = 200;
+ page.HttpContext.Response.Headers["Location"] = "";
+
+ return page.RedirectToPage("/Redirect/Index", new { RedirectUri = redirectUri });
+ }
+}
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml
new file mode 100644
index 0000000..5603939
--- /dev/null
+++ b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml
@@ -0,0 +1,19 @@
+@page
+@model IdentitySvc.Pages.ExternalLogin.Callback
+
+@{
+ Layout = null;
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml.cs b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml.cs
new file mode 100644
index 0000000..1468400
--- /dev/null
+++ b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml.cs
@@ -0,0 +1,203 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+using Duende.IdentityServer;
+using Duende.IdentityServer.Events;
+using Duende.IdentityServer.Services;
+using IdentityModel;
+using IdentitySvc.Models;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace IdentitySvc.Pages.ExternalLogin;
+
+[AllowAnonymous]
+[SecurityHeaders]
+public class Callback : PageModel
+{
+ private readonly UserManager _userManager;
+ private readonly SignInManager _signInManager;
+ private readonly IIdentityServerInteractionService _interaction;
+ private readonly ILogger _logger;
+ private readonly IEventService _events;
+
+ public Callback(
+ IIdentityServerInteractionService interaction,
+ IEventService events,
+ ILogger logger,
+ UserManager userManager,
+ SignInManager signInManager)
+ {
+ _userManager = userManager;
+ _signInManager = signInManager;
+ _interaction = interaction;
+ _logger = logger;
+ _events = events;
+ }
+
+ public async Task OnGet()
+ {
+ // read external identity from the temporary cookie
+ var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
+ if (result.Succeeded != true)
+ {
+ throw new InvalidOperationException($"External authentication error: { result.Failure }");
+ }
+
+ var externalUser = result.Principal ??
+ throw new InvalidOperationException("External authentication produced a null Principal");
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ var externalClaims = externalUser.Claims.Select(c => $"{c.Type}: {c.Value}");
+ _logger.ExternalClaims(externalClaims);
+ }
+
+ // lookup our user and external provider info
+ // try to determine the unique id of the external user (issued by the provider)
+ // the most common claim type for that are the sub claim and the NameIdentifier
+ // depending on the external provider, some other claim type might be used
+ var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ??
+ externalUser.FindFirst(ClaimTypes.NameIdentifier) ??
+ throw new InvalidOperationException("Unknown userid");
+
+ var provider = result.Properties.Items["scheme"] ?? throw new InvalidOperationException("Null scheme in authentiation properties");
+ var providerUserId = userIdClaim.Value;
+
+ // find external user
+ var user = await _userManager.FindByLoginAsync(provider, providerUserId);
+ if (user == null)
+ {
+ // this might be where you might initiate a custom workflow for user registration
+ // in this sample we don't show how that would be done, as our sample implementation
+ // simply auto-provisions new external user
+ user = await AutoProvisionUserAsync(provider, providerUserId, externalUser.Claims);
+ }
+
+ // this allows us to collect any additional claims or properties
+ // for the specific protocols used and store them in the local auth cookie.
+ // this is typically used to store data needed for signout from those protocols.
+ var additionalLocalClaims = new List();
+ var localSignInProps = new AuthenticationProperties();
+ CaptureExternalLoginContext(result, additionalLocalClaims, localSignInProps);
+
+ // issue authentication cookie for user
+ await _signInManager.SignInWithClaimsAsync(user, localSignInProps, additionalLocalClaims);
+
+ // delete temporary cookie used during external authentication
+ await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
+
+ // retrieve return URL
+ var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";
+
+ // check if external login is in the context of an OIDC request
+ var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
+ await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId));
+ Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!);
+
+ if (context != null)
+ {
+ if (context.IsNativeClient())
+ {
+ // The client is native, so this change in how to
+ // return the response is for better UX for the end user.
+ return this.LoadingPage(returnUrl);
+ }
+ }
+
+ return Redirect(returnUrl);
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1851:Possible multiple enumerations of 'IEnumerable' collection", Justification = "")]
+ private async Task AutoProvisionUserAsync(string provider, string providerUserId, IEnumerable claims)
+ {
+ var sub = Guid.NewGuid().ToString();
+
+ var user = new ApplicationUser
+ {
+ Id = sub,
+ UserName = sub, // don't need a username, since the user will be using an external provider to login
+ };
+
+ // email
+ var email = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Email)?.Value ??
+ claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
+ if (email != null)
+ {
+ user.Email = email;
+ }
+
+ // create a list of claims that we want to transfer into our store
+ var filtered = new List();
+
+ // user's display name
+ var name = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name)?.Value ??
+ claims.FirstOrDefault(x => x.Type == ClaimTypes.Name)?.Value;
+ if (name != null)
+ {
+ filtered.Add(new Claim(JwtClaimTypes.Name, name));
+ }
+ else
+ {
+ var first = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.GivenName)?.Value ??
+ claims.FirstOrDefault(x => x.Type == ClaimTypes.GivenName)?.Value;
+ var last = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.FamilyName)?.Value ??
+ claims.FirstOrDefault(x => x.Type == ClaimTypes.Surname)?.Value;
+ if (first != null && last != null)
+ {
+ filtered.Add(new Claim(JwtClaimTypes.Name, first + " " + last));
+ }
+ else if (first != null)
+ {
+ filtered.Add(new Claim(JwtClaimTypes.Name, first));
+ }
+ else if (last != null)
+ {
+ filtered.Add(new Claim(JwtClaimTypes.Name, last));
+ }
+ }
+
+ var identityResult = await _userManager.CreateAsync(user);
+ if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description);
+
+ if (filtered.Count != 0)
+ {
+ identityResult = await _userManager.AddClaimsAsync(user, filtered);
+ if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description);
+ }
+
+ identityResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider));
+ if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description);
+
+ return user;
+ }
+
+ // if the external login is OIDC-based, there are certain things we need to preserve to make logout work
+ // this will be different for WS-Fed, SAML2p or other protocols
+ private static void CaptureExternalLoginContext(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps)
+ {
+ ArgumentNullException.ThrowIfNull(externalResult.Principal, nameof(externalResult.Principal));
+
+ // capture the idp used to login, so the session knows where the user came from
+ localClaims.Add(new Claim(JwtClaimTypes.IdentityProvider, externalResult.Properties?.Items["scheme"] ?? "unknown identity provider"));
+
+ // if the external system sent a session id claim, copy it over
+ // so we can use it for single sign-out
+ var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
+ if (sid != null)
+ {
+ localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));
+ }
+
+ // if the external provider issued an id_token, we'll keep it for signout
+ var idToken = externalResult.Properties?.GetTokenValue("id_token");
+ if (idToken != null)
+ {
+ localSignInProps.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = idToken } });
+ }
+ }
+}
diff --git a/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml
new file mode 100644
index 0000000..791f8fa
--- /dev/null
+++ b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml
@@ -0,0 +1,19 @@
+@page
+@model IdentitySvc.Pages.ExternalLogin.Challenge
+
+@{
+ Layout = null;
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml.cs b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml.cs
new file mode 100644
index 0000000..17f1d23
--- /dev/null
+++ b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Services;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace IdentitySvc.Pages.ExternalLogin;
+
+[AllowAnonymous]
+[SecurityHeaders]
+public class Challenge : PageModel
+{
+ private readonly IIdentityServerInteractionService _interactionService;
+
+ public Challenge(IIdentityServerInteractionService interactionService)
+ {
+ _interactionService = interactionService;
+ }
+
+ public IActionResult OnGet(string scheme, string? returnUrl)
+ {
+ if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/";
+
+ // validate returnUrl - either it is a valid OIDC URL or back to a local page
+ if (Url.IsLocalUrl(returnUrl) == false && _interactionService.IsValidReturnUrl(returnUrl) == false)
+ {
+ // user might have clicked on a malicious link - should be logged
+ throw new ArgumentException("invalid return URL");
+ }
+
+ // start challenge and roundtrip the return URL and scheme
+ var props = new AuthenticationProperties
+ {
+ RedirectUri = Url.Page("/externallogin/callback"),
+
+ Items =
+ {
+ { "returnUrl", returnUrl },
+ { "scheme", scheme },
+ }
+ };
+
+ return Challenge(props, scheme);
+ }
+}
diff --git a/src/IdentitySvc/Pages/Grants/Index.cshtml b/src/IdentitySvc/Pages/Grants/Index.cshtml
new file mode 100644
index 0000000..a8be8a6
--- /dev/null
+++ b/src/IdentitySvc/Pages/Grants/Index.cshtml
@@ -0,0 +1,90 @@
+@page
+@model IdentitySvc.Pages.Grants.Index
+@{
+}
+
+
+
+
Client Application Permissions
+
Below is the list of applications you have given permission to and the resources they have access to.
+
+
+ @if (!Model.View.Grants.Any())
+ {
+
+
+
+ You have not given access to any applications
+
+
+
+ }
+ else
+ {
+ foreach (var grant in Model.View.Grants)
+ {
+