diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 61d8ac734..2938848fd 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,8 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + # The SDK that global.json selects builds the identity server for .NET 11; the other services run on .NET 10. + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: + global-json-file: global.json dotnet-version: '10.0.x' - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pr-validation-maui.yml b/.github/workflows/pr-validation-maui.yml index 2f63a0943..20db746e6 100644 --- a/.github/workflows/pr-validation-maui.yml +++ b/.github/workflows/pr-validation-maui.yml @@ -21,8 +21,13 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 - - name: Setup .NET (global.json) - uses: actions/setup-dotnet@v3 + # global.json selects the .NET 11 SDK the repository builds with; the client app and its unit tests target + # .NET 10, whose runtime the tests run on. + - name: Setup .NET (global.json and the .NET 10 runtime) + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + dotnet-version: '10.0.x' - name: Update Workloads run: dotnet workload update diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 77431a0ca..8a4c7f1be 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -21,8 +21,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup .NET (global.json) - uses: actions/setup-dotnet@v3 + # The SDK that global.json selects builds the identity server for .NET 11; the tests of the other services run + # on .NET 10. + - name: Setup .NET (global.json and the .NET 10 runtime) + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + dotnet-version: '10.0.x' - name: Build run: dotnet build eShop.Web.slnf - name: Test diff --git a/Directory.Packages.props b/Directory.Packages.props index 0945a1af4..5ff9969db 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,7 @@ - + diff --git a/README.md b/README.md index 4e66b1ec9..e3e976508 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# eShop on Abblix OIDC Server: a worked IdentityServer migration for .NET 10 +# eShop on Abblix OIDC Server: a worked IdentityServer migration Microsoft's [eShop](https://github.com/dotnet/eShop) reference application with its OpenID Connect provider migrated from Duende IdentityServer to @@ -45,13 +45,14 @@ A reference .NET application implementing an e-commerce website using a services ## Getting Started -This version of eShop is based on .NET 10. +This version of eShop is based on .NET 10. Its identity server, Identity.API, runs on .NET 11, the +runtime Abblix OIDC Server 3.0 is built for. Upstream, including its earlier releases: [dotnet/eShop](https://github.com/dotnet/eShop). ### Prerequisites -1. Install a [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) that satisfies [`global.json`](global.json). +1. Install a [.NET 11 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) that satisfies [`global.json`](global.json), and the [.NET Runtime 10 and ASP.NET Core Runtime 10](https://dotnet.microsoft.com/download/dotnet/10.0) the other services run on. 2. Install the [Aspire CLI](https://aspire.dev/get-started/install-cli/) and verify that it is available: ```console @@ -66,6 +67,16 @@ Upstream, including its earlier releases: [dotnet/eShop](https://github.com/dotn cd eShop ``` +5. Until Abblix OIDC Server 3.0 is published on nuget.org, its packages come from GitHub Packages, which needs a GitHub personal access token with the `read:packages` scope. Set it for the restore: + + ```powershell + $env:NuGetPackageSourceCredentials_abblix = "Username=;Password=" + ``` + + ```bash + export NuGetPackageSourceCredentials_abblix="Username=;Password=" + ``` + No separate Aspire workload or Visual Studio component is required; the AppHost SDK and hosting integrations are referenced by the projects in this repository. #### Optional IDE setup @@ -117,6 +128,16 @@ protocol endpoints and knows only where to send an unauthenticated user, which i fork was built to show. What it issues afterwards is the token behind the claims the checkout form fills itself from. +Every token names its issuer, and the services accept a token only when that issuer is exactly the +address they were given for the identity server. Abblix OIDC Server 3.0 takes the issuer from +configuration rather than from each request, so the AppHost hands `identity-api` the same +address as its `Oidc__Issuer` that it hands every other service as the identity server's. Running +`identity-api` without the AppHost means setting `Oidc__Issuer` yourself. The server writes the configured issuer into +discovery and every token whatever host name a request arrives with, and the MAUI app's OpenID +client refuses a discovery document whose issuer differs from the address it asked. In the +Android emulator the app reaches your machine as `10.0.2.2` rather than `localhost`, so it +cannot sign in. + ### Running tests Run the server tests: diff --git a/global.json b/global.json index 659b45463..d25d59ffe 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,8 @@ { "sdk": { - "version": "10.0.302", + "version": "11.0.100-rc.2.26504.105", "rollForward": "latestFeature", - "allowPrerelease": false + "allowPrerelease": true }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/nuget.config b/nuget.config index 9fbf074fd..d995dbb79 100644 --- a/nuget.config +++ b/nuget.config @@ -4,6 +4,10 @@ + + @@ -11,5 +15,8 @@ + + + \ No newline at end of file diff --git a/src/Identity.API/Configuration/OidcConfiguration.cs b/src/Identity.API/Configuration/OidcConfiguration.cs index 29966a2b9..7a2484e73 100644 --- a/src/Identity.API/Configuration/OidcConfiguration.cs +++ b/src/Identity.API/Configuration/OidcConfiguration.cs @@ -10,6 +10,23 @@ namespace eShop.Identity.API.Configuration /// public class OidcConfiguration { + /// + /// The issuer written into every token and the discovery document. + /// + /// + /// The resource servers and both web clients are given the identity server's address by the AppHost and + /// accept a token only when its issuer is exactly that string, so the AppHost hands this server the same + /// address as Oidc__Issuer. A run without the AppHost sets it to the address the clients use. + /// + public Uri Issuer { get; set; } + + /// + /// Lets the server accept an http issuer. The AppHost sets it only for its http run, made when + /// ESHOP_USE_HTTP_ENDPOINTS=1, and the Ordering functional tests set it for theirs; every other run keeps + /// the https requirement OpenID Connect places on an issuer. + /// + public bool AllowHttpIssuer { get; set; } + public ScopeDefinition[] Scopes { get; set; } = []; /// diff --git a/src/Identity.API/Controllers/AccountController.cs b/src/Identity.API/Controllers/AccountController.cs index e1fccb786..47844e53d 100644 --- a/src/Identity.API/Controllers/AccountController.cs +++ b/src/Identity.API/Controllers/AccountController.cs @@ -1,8 +1,9 @@ using Abblix.Oidc.Server.Features.RandomGenerators; using Abblix.Oidc.Server.Model; +using Abblix.Oidc.Server.Common.Configuration; +using Abblix.Oidc.Server.Common.Interfaces; using IdentityServerHost.Quickstart.UI; using Microsoft.AspNetCore.Authentication.Cookies; -using Path = Abblix.Oidc.Server.Mvc.Path; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; using UriBuilder = Abblix.Utils.UriBuilder; @@ -23,7 +24,7 @@ public class AccountController( SignInManager signInManager, IAuthSessionService authSessionService, ISessionIdGenerator sessionIdGenerator, - IUriResolver uriResolver, + IOidcEndpointResolver endpointResolver, TimeProvider clock) : Controller { [HttpGet] @@ -65,8 +66,14 @@ public async Task Login(LoginViewModel model) await authSessionService.SignInAsync(authSession); - // Resume the interrupted authorization request by its reference. - var authorizeUrl = new UriBuilder(uriResolver.Content(Path.Authorize)) + // Resume the interrupted authorization request by its reference. The library answers with the + // path it mapped the endpoint on, so a moved path or a prefix needs no change here; it answers + // nothing only for a disabled endpoint, and a server without its authorization endpoint has no + // request to resume. + var authorizationEndpoint = endpointResolver.Resolve(OidcEndpoints.Authorize) + ?? throw new InvalidOperationException("The authorization endpoint is not mapped."); + + var authorizeUrl = new UriBuilder(authorizationEndpoint) { Query = { [AuthorizationRequest.Parameters.RequestUri] = model.RequestUri }, }; @@ -74,6 +81,33 @@ public async Task Login(LoginViewModel model) return Redirect(authorizeUrl); } + /// + /// Asks the user to confirm a logout the server would not perform on its own, such as one whose ID token hint + /// names a session other than the current one. + /// + /// + /// sends the browser here with the parameters of the original request + /// and the value the server issued for the answer; the page posts them back to the end-session endpoint. + /// + [HttpGet] + public IActionResult Logout() + { + var endSessionEndpoint = endpointResolver.Resolve(OidcEndpoints.EndSession) + ?? throw new InvalidOperationException("The end-session endpoint is not mapped."); + + return View(new LogoutViewModel + { + EndSessionEndpoint = endSessionEndpoint, + Parameters = + [ + .. Request.Query + .Where(parameter => parameter.Key != LogoutConfirmationPage.AskedAgain) + .Select(parameter => KeyValuePair.Create(parameter.Key, parameter.Value.ToString())), + ], + AskedAgain = Request.Query.ContainsKey(LogoutConfirmationPage.AskedAgain), + }); + } + [HttpGet] public IActionResult AccessDenied() => View(); } diff --git a/src/Identity.API/GlobalUsings.cs b/src/Identity.API/GlobalUsings.cs index 521187071..4308b0830 100644 --- a/src/Identity.API/GlobalUsings.cs +++ b/src/Identity.API/GlobalUsings.cs @@ -9,9 +9,10 @@ global using Abblix.Jwt; global using Abblix.Oidc.Server.Common.Constants; global using Abblix.Oidc.Server.Features.ClientInformation; +global using Abblix.Oidc.Server.Features.MultiTenancy; global using Abblix.Oidc.Server.Features.UserAuthentication; global using Abblix.Oidc.Server.Features.UserInfo; -global using Abblix.Oidc.Server.Mvc; +global using Abblix.Oidc.Server.MinimalApi; global using Microsoft.AspNetCore.Authentication; global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Builder; diff --git a/src/Identity.API/Identity.API.csproj b/src/Identity.API/Identity.API.csproj index 96deb0cb6..c7426fd33 100644 --- a/src/Identity.API/Identity.API.csproj +++ b/src/Identity.API/Identity.API.csproj @@ -1,12 +1,12 @@  - net10.0 + net11.0 aspnet-eShopOnContainers.Identity-90487118-103c-4ff0-b9da-e5e26f7ab0c5 - + diff --git a/src/Identity.API/Models/LogoutViewModel.cs b/src/Identity.API/Models/LogoutViewModel.cs new file mode 100644 index 000000000..1d32d7352 --- /dev/null +++ b/src/Identity.API/Models/LogoutViewModel.cs @@ -0,0 +1,21 @@ +namespace eShop.Identity.API.Models +{ + public class LogoutViewModel + { + /// + /// Where the confirming request goes: the server's end-session endpoint. + /// + public Uri EndSessionEndpoint { get; set; } + + /// + /// The parameters of the logout request being confirmed, the value the server issued for the answer among + /// them, sent back unchanged when the user agrees. + /// + public IReadOnlyList> Parameters { get; set; } + + /// + /// Whether the user already answered and the answer was no longer accepted. + /// + public bool AskedAgain { get; set; } + } +} diff --git a/src/Identity.API/Program.cs b/src/Identity.API/Program.cs index 30ed25a54..53faf22ec 100644 --- a/src/Identity.API/Program.cs +++ b/src/Identity.API/Program.cs @@ -1,5 +1,8 @@ +using Abblix.DependencyInjection; using Abblix.Oidc.Server.AspNetCore; +using Abblix.Oidc.Server.Features.Consents; using Abblix.Oidc.Server.Common.Interfaces; +using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; using Microsoft.AspNetCore.Authentication.Cookies; var builder = WebApplication.CreateBuilder(args); @@ -55,13 +58,35 @@ var oidcConfiguration = builder.Configuration.GetSection("Oidc").Get() ?? throw new InvalidOperationException("The Oidc configuration section is missing."); +// Abblix OIDC Server 3.0 serves its settings per tenant, and eShop is one: one issuer, one client registry. +// The issuer is fixed rather than taken from each request, because every token carries it. It has to equal, character for character, the address the AppHost +// hands the resource servers and the web clients as the identity server's, because they accept no other. +var issuer = oidcConfiguration.Issuer + ?? throw new InvalidOperationException( + "Oidc:Issuer is not set. Set it to the address the clients reach the identity server at."); + builder.Services.AddOidcServices(options => { - options.LoginUri = new Uri("/Account/Login", UriKind.Relative); - options.Scopes = oidcConfiguration.Scopes; - options.Clients = oidcConfiguration.ToClientInfos(); + options.AllowHttpIssuer = oidcConfiguration.AllowHttpIssuer; + options.Tenants.Add(new TenantDefinition + { + Id = "eshop", + Issuer = issuer, + LoginUri = new Uri("/Account/Login", UriKind.Relative), + Scopes = oidcConfiguration.Scopes, + Clients = oidcConfiguration.ToClientInfos(), + }); }); +// Every client registered above is part of eShop itself, so there is no third party the user would be asked to +// trust and no consent screen to show. +builder.Services.AddConsentGrantedForEveryClient(); + +// A logout the user has to confirm is shown on this application's own page rather than answered with the +// library's JSON body, which a browser following a client's sign-out would display as it is. +builder.Services.AddHttpContextAccessor(); +builder.Services.Decorate(); + // The library registers the CORS policy the protocol endpoints name, and an empty origin list // means any origin may read them. eShop has no browser-based client, so the complete list of // origins that could ever need them is the addresses its own clients answer on. @@ -90,6 +115,7 @@ // This cookie policy fixes login issues with Chrome 80+ using HTTP app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Lax }); + app.UseRouting(); // The protocol endpoints carry CORS metadata, because a browser-based client reads the discovery @@ -100,9 +126,10 @@ app.UseAuthentication(); app.UseAuthorization(); -// The protocol endpoints are attribute-routed controllers supplied by the library, so they are -// mapped the same way any other controller is. -app.MapControllers(); +// The protocol endpoints are Minimal API route handlers supplied by the library, mapped at the paths +// its options give them; an endpoint that is not enabled is not mapped at all. The default route below +// serves this application's own sign-in screen, which stays an MVC controller with views. +app.MapOidcEndpoints(); app.MapDefaultControllerRoute(); app.Run(); diff --git a/src/Identity.API/Services/DatabaseKeysProvider.cs b/src/Identity.API/Services/DatabaseKeysProvider.cs index ef06942ff..c4055a04d 100644 --- a/src/Identity.API/Services/DatabaseKeysProvider.cs +++ b/src/Identity.API/Services/DatabaseKeysProvider.cs @@ -16,10 +16,12 @@ public class DatabaseKeysProvider( IDbContextFactory contextFactory, TimeProvider clock) : IAuthServiceKeysProvider { - public IAsyncEnumerable GetEncryptionKeys(bool includePrivateKeys = false) + public IAsyncEnumerable GetEncryptionKeys(TenantDefinition tenant, bool includePrivateKeys = false) => AsyncEnumerable.Empty(); - public async IAsyncEnumerable GetSigningKeys(bool includePrivateKeys = false) + // eShop declares a single tenant, so the one key set in the table is that tenant's and the argument + // selects nothing. + public async IAsyncEnumerable GetSigningKeys(TenantDefinition tenant, bool includePrivateKeys = false) { await using var db = await contextFactory.CreateDbContextAsync(); diff --git a/src/Identity.API/Services/LogoutConfirmationPage.cs b/src/Identity.API/Services/LogoutConfirmationPage.cs new file mode 100644 index 000000000..d3b872136 --- /dev/null +++ b/src/Identity.API/Services/LogoutConfirmationPage.cs @@ -0,0 +1,72 @@ +using Abblix.Oidc.Server.Common; +using Abblix.Oidc.Server.Endpoints.EndSession.Interfaces; +using Abblix.Oidc.Server.MinimalApi.Formatters.Interfaces; +using Abblix.Utils; +using Microsoft.AspNetCore.WebUtilities; +using EndSessionRequest = Abblix.Oidc.Server.Model.EndSessionRequest; + +namespace eShop.Identity.API.Services +{ + /// + /// Sends a logout the end user has to confirm to this application's own confirmation page. + /// + /// + /// The library answers such a request with the value the confirming request has to carry back, as a JSON body, + /// which a browser following a client's sign-out would show as it is. This decorator redirects to the page + /// instead and hands it that value together with the parameters of the original request, so that the page can + /// send all of them back to the end-session endpoint once the user agrees. + /// + public class LogoutConfirmationPage( + IEndSessionResponseFormatter inner, + IHttpContextAccessor httpContextAccessor, + LinkGenerator linkGenerator) : IEndSessionResponseFormatter + { + /// + /// Marks a page shown again because the answer the user gave was no longer accepted, typically because + /// it took longer than the confirmation lasts. It belongs to this page, not to the protocol, so the page + /// does not send it back. + /// + public const string AskedAgain = "asked_again"; + + public Task FormatResponseAsync( + EndSessionRequest request, Result response) + { + if (response.TryGetSuccess(out var success) && success is ConfirmationRequired confirmation) + return Task.FromResult(Results.Redirect(PageUrl(request, confirmation.Confirmation))); + + return inner.FormatResponseAsync(request, response); + } + + /// + /// The page's address with the parameters it posts back as its query. + /// + /// + /// A path, not an absolute address, so the browser stays on the scheme and host it reached this server on + /// rather than one rebuilt from the request's Host header. The path comes from the page's action, so it + /// follows whatever route maps it and the application's base path. + /// + private string PageUrl(EndSessionRequest request, string confirmation) + { + KeyValuePair[] parameters = + [ + new(AskedAgain, request.Confirmation != null ? bool.TrueString : null), + new(EndSessionRequest.Parameters.Confirmation, confirmation), + new(EndSessionRequest.Parameters.IdTokenHint, request.IdTokenHint), + new(EndSessionRequest.Parameters.LogoutHint, request.LogoutHint), + new(EndSessionRequest.Parameters.ClientId, request.ClientId), + new(EndSessionRequest.Parameters.PostLogoutRedirectUri, request.PostLogoutRedirectUri?.OriginalString), + new(EndSessionRequest.Parameters.State, request.State), + new(EndSessionRequest.Parameters.UiLocales, request.UiLocales is { } locales + ? string.Join(' ', locales.Select(locale => locale.Name)) + : null), + ]; + + var httpContext = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("The logout confirmation is formatted outside a request."); + var path = linkGenerator.GetPathByAction(httpContext, nameof(Controllers.AccountController.Logout), "Account") + ?? throw new InvalidOperationException("The logout page is not mapped."); + + return QueryHelpers.AddQueryString(path, parameters.Where(parameter => parameter.Value != null)); + } + } +} diff --git a/src/Identity.API/Services/UserInfoProvider.cs b/src/Identity.API/Services/UserInfoProvider.cs index c3c46471f..cdba07a09 100644 --- a/src/Identity.API/Services/UserInfoProvider.cs +++ b/src/Identity.API/Services/UserInfoProvider.cs @@ -14,7 +14,8 @@ namespace eShop.Identity.API.Services /// public class UserInfoProvider(UserManager userManager) : IUserInfoProvider { - public async Task GetUserInfoAsync(AuthSession authSession, IEnumerable requestedClaims) + public async Task GetUserInfoAsync( + TenantDefinition tenant, AuthSession authSession, IEnumerable requestedClaims) { var user = await userManager.FindByIdAsync(authSession.Subject); if (user is null) diff --git a/src/Identity.API/Views/Account/Logout.cshtml b/src/Identity.API/Views/Account/Logout.cshtml new file mode 100644 index 000000000..2adbc88da --- /dev/null +++ b/src/Identity.API/Views/Account/Logout.cshtml @@ -0,0 +1,25 @@ +@model LogoutViewModel + +
+
+

Logout

+ @if (Model.AskedAgain) + { +

Your earlier answer has expired. Would you still like to log out?

+ } + else + { +

Would you like to log out?

+ } +
+ +
+ @foreach (var parameter in Model.Parameters) + { + + } +
+ +
+
+
diff --git a/src/Identity.API/appsettings.json b/src/Identity.API/appsettings.json index 696f1de80..53ef7a6f5 100644 --- a/src/Identity.API/appsettings.json +++ b/src/Identity.API/appsettings.json @@ -67,7 +67,7 @@ "authorization_code", "refresh_token" ], - "OfflineAccessAllowed": true, + "OfflineAccess": "Allowed", "AllowedScopes": [ "openid", "profile", @@ -82,11 +82,11 @@ // The app refreshes only minutes before its two-hour access token expires, so a refresh // token that lapses after an hour of disuse would already be dead by then. Its services // read the stored token without a lock, so one of them can present a token another has - // just spent; a single-use token would then revoke the whole chain and sign the user out. + // just spent; a rotated token would then revoke the whole chain and sign the user out. "RefreshToken": { "AbsoluteExpiresIn": "30.00:00:00", "SlidingExpiresIn": null, - "AllowReuse": true + "ReusePolicy": "Reuse" } } }, diff --git a/src/eShop.AppHost/AppHost.cs b/src/eShop.AppHost/AppHost.cs index 6650f9090..032d5b05b 100644 --- a/src/eShop.AppHost/AppHost.cs +++ b/src/eShop.AppHost/AppHost.cs @@ -28,6 +28,16 @@ .WithHttpHealthCheck("/health"); var identityEndpoint = identityApi.GetEndpoint(launchProfileName); + +// The identity server writes its issuer into every token as configured, and each service below accepts a token only +// from the address it is given here, so the identity server is given that same address as its issuer +identityApi.WithEnvironment("Oidc__Issuer", identityEndpoint); +if (launchProfileName == "http") +{ + // An issuer over http is refused unless allowed explicitly. Only the http run needs it, so that identity-api + // starts; signing in fails in that run, because the clients are registered at their https addresses. + identityApi.WithEnvironment("Oidc__AllowHttpIssuer", "true"); +} var basketApi = builder.AddDotnetProject("basket-api", "../Basket.API") .WithReference(redis) diff --git a/tests/Application.UnitTests/Application.UnitTests.csproj b/tests/Application.UnitTests/Application.UnitTests.csproj index c79404446..7201446cf 100644 --- a/tests/Application.UnitTests/Application.UnitTests.csproj +++ b/tests/Application.UnitTests/Application.UnitTests.csproj @@ -1,7 +1,7 @@ - net10.0 + net11.0 false false Exe diff --git a/tests/Ordering.FunctionalTests/OrderingApiFixture.cs b/tests/Ordering.FunctionalTests/OrderingApiFixture.cs index 9ca68abf3..ad2d22bf5 100644 --- a/tests/Ordering.FunctionalTests/OrderingApiFixture.cs +++ b/tests/Ordering.FunctionalTests/OrderingApiFixture.cs @@ -23,6 +23,11 @@ public OrderingApiFixture() Postgres = appBuilder.AddPostgres("OrderingDB"); IdentityDB = appBuilder.AddPostgres("IdentityDB"); IdentityApi = appBuilder.AddProject("identity-api").WithReference(IdentityDB); + + // The identity server's issuer is the address the API under test is given as Identity:Url, over http here + IdentityApi + .WithEnvironment("Oidc__Issuer", IdentityApi.GetEndpoint("http")) + .WithEnvironment("Oidc__AllowHttpIssuer", "true"); _app = appBuilder.Build(); }