Skip to content

Commit

Permalink
Add OIDC test app & e2e tests (#1256)
Browse files Browse the repository at this point in the history
  • Loading branch information
gunndabad authored Apr 3, 2024
1 parent 48721ce commit 606294e
Show file tree
Hide file tree
Showing 22 changed files with 303 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
return AuthenticateResult.Fail($"API key is expired.");
}

var principal = CreatePrincipal(apiKey.ApplicationUserId.ToString(), apiKey.ApplicationUser.Name, apiKey.ApplicationUser.ApiRoles);
var principal = CreatePrincipal(apiKey.ApplicationUserId.ToString(), apiKey.ApplicationUser.Name, apiKey.ApplicationUser.ApiRoles ?? []);
var ticket = new AuthenticationTicket(principal, Scheme.Name);

LogContext.PushProperty("ClientId", apiKey.ApplicationUser.UserId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ public async Task<IActionResult> Authorize()
{
var parameters = Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList();


var authenticationProperties = new AuthenticationProperties()
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.Filters;

public class NotFoundResourceFilter : IResourceFilter
{
public void OnResourceExecuted(ResourceExecutedContext context)
{
throw new NotImplementedException();
}

public void OnResourceExecuting(ResourceExecutingContext context)
{
context.Result = new NotFoundResult();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ void IConfigureNamedOptions<OneLoginOptions>.Configure(string? name, OneLoginOpt

options.SignInScheme = AuthenticationSchemes.FormFlowJourney;

options.Events.OnRedirectToIdentityProvider = context =>
{
// A large RedirectUri here can make the state parameter so large that One Login rejects the request.
// We have the RedirectUri stashed away on the FormFlow journey any way so we can clear it out.
context.Properties.RedirectUri = null;

return Task.CompletedTask;
};

options.Events.OnRedirectToIdentityProviderForSignOut = context =>
{
// The standard sign out process will call Authenticate() on SignInScheme then try to extract the id_token from the Principal.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@page "/oidc-test/sign-out"
@addTagHelper *, Joonasw.AspNetCore.SecurityHeaders
@model TeachingRecordSystem.AuthorizeAccess.Pages.OidcTest.SignOutModel
@{
}

<form asp-page="SignOut" method="post">
<govuk-button type="submit">Sign out</govuk-button>
</form>

<script asp-add-nonce="true">document.forms[0].submit();</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace TeachingRecordSystem.AuthorizeAccess.Pages.OidcTest;

[Authorize(AuthenticationSchemes = TestAppConfiguration.AuthenticationSchemeName)]
public class SignOutModel : PageModel
{
public void OnGet()
{
}

public async Task<IActionResult> OnPost()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

return SignOut(
new AuthenticationProperties()
{
RedirectUri = Url.Page("Start")
},
TestAppConfiguration.AuthenticationSchemeName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@page "/oidc-test/signed-in"
@using System.Text.Json
@using TeachingRecordSystem.AuthorizeAccess.Pages.OidcTest
@addTagHelper *, Joonasw.AspNetCore.SecurityHeaders
@model SignedInModel
@{
var claimsJson = JsonSerializer.Serialize(
User.Claims.ToDictionary(c => c.Type, c => c.Value),
new JsonSerializerOptions() { WriteIndented = true });
}

@section Head {
<style type="text/css" asp-add-nonce="true">
.claims-json {
font-family: monospace !important;
white-space: nowrap;
height: 260px;
}
</style>
}

<p class="govuk-body">
<govuk-textarea name="Claims" textarea-class="claims-json">
<govuk-textarea-label>Claims</govuk-textarea-label>
<govuk-textarea-value>@claimsJson</govuk-textarea-value>
</govuk-textarea>
</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace TeachingRecordSystem.AuthorizeAccess.Pages.OidcTest;

[Authorize(AuthenticationSchemes = TestAppConfiguration.AuthenticationSchemeName)]
public class SignedInModel : PageModel
{
public void OnGet()
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@page "/oidc-test"
@model TeachingRecordSystem.AuthorizeAccess.Pages.OidcTest.StartModel
@{
}

<form asp-page="OidcTest" method="post">
<govuk-button type="submit" is-start-button="true">Start</govuk-button>
</form>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace TeachingRecordSystem.AuthorizeAccess.Pages.OidcTest;

public class StartModel : PageModel
{
public void OnGet()
{
}

public IActionResult OnPost() => Challenge(
new AuthenticationProperties()
{
RedirectUri = Url.Page("SignedIn")
},
TestAppConfiguration.AuthenticationSchemeName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@{
Layout = "../Shared/_Layout";

ViewBag.ServiceName = "OIDC Sample";
}

@section Head {
@RenderSection("Head", required: false)
}

@if (User.Identity?.IsAuthenticated == true)
{
@section Nav {
<nav aria-label="Menu" class="govuk-header__navigation">
<button type="button" class="govuk-header__menu-button govuk-js-header-toggle" aria-controls="navigation" hidden>
Menu
</button>
<ul id="navigation" class="govuk-header__navigation-list">
<li class="govuk-header__navigation-item govuk-header__navigation-item--active">
<a class="govuk-header__link" asp-page="SignOut">
Sign out
</a>
</li>
</ul>
</nav>
}
}

@RenderBody()
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@{
Layout = "./_Layout";
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
@inject GovUk.Frontend.AspNetCore.PageTemplateHelper PageTemplateHelper
@{
Layout = "_GovUkPageTemplate";

var serviceName = ViewBag.ServiceName ?? "Authorise access to a teaching record";
}

@section Head {
@RenderSection("Head", required: false)
@PageTemplateHelper.GenerateStyleImports()
}

@section Header {
<header class="govuk-header" role="banner" data-module="govuk-header">
<div class="govuk-header__container govuk-width-container">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ static SecurityKey LoadKey(string configurationValue)
.ValidateDataAnnotations()
.ValidateOnStart();

builder.AddTestApp();

var app = builder.Build();

app.MapDefaultEndpoints();
Expand Down Expand Up @@ -191,6 +193,10 @@ static SecurityKey LoadKey(string configurationValue)
.From(pageTemplateHelper.GetCspScriptHashes())
.AddNonce();

csp.AllowStyles
.FromSelf()
.AddNonce();

// Ensure ASP.NET Core's auto refresh works
// See https://github.com/dotnet/aspnetcore/issues/33068
if (builder.Environment.IsDevelopment())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Filters;
using static IdentityModel.OidcConstants;

namespace TeachingRecordSystem.AuthorizeAccess;

public static class TestAppConfiguration
{
public const string AuthenticationSchemeName = "OidcTest";
public const string ClientId = "test-app";
public const string ClientSecret = "Devel0pm3ntSecr4t";
public const string RedirectUriPath = "/test-app/callback";
public const string PostLogoutRedirectUriPath = "/test-app/logout-callback";

public static WebApplicationBuilder AddTestApp(this WebApplicationBuilder builder)
{
if (builder.Environment.IsDevelopment() || builder.Environment.IsEndToEndTests())
{
builder.Services.AddAuthentication()
.AddCookie()
.AddOpenIdConnect(AuthenticationSchemeName, options =>
{
options.Authority = "https://localhost:7236";
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.ClientId = ClientId;
options.ClientSecret = ClientSecret;
options.CallbackPath = RedirectUriPath;
options.SignedOutCallbackPath = PostLogoutRedirectUriPath;
options.ResponseMode = ResponseModes.Query;
options.ResponseType = ResponseTypes.Code;
options.MapInboundClaims = false;
options.SaveTokens = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("email");
options.Scope.Add("profile");
});
}
else
{
builder.Services.Configure<RazorPagesOptions>(options =>
options.Conventions.AddFolderApplicationModelConvention(
"/OidcTest", model => model.Filters.Add(new NotFoundResourceFilter())));
}

return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class ApplicationUser : UserBase
public const string ClientIdUniqueIndexName = "ix_users_client_id";
public const string OneLoginAuthenticationSchemeNameUniqueIndexName = "ix_users_one_login_authentication_scheme_name";

public required string[] ApiRoles { get; set; }
public string[]? ApiRoles { get; set; }
public ICollection<ApiKey> ApiKeys { get; } = null!;
public bool IsOidcClient { get; set; }
public string? ClientId { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public record ApplicationUser
{
public required Guid UserId { get; init; }
public required string Name { get; init; }
public required string[] ApiRoles { get; init; }
public string[]? ApiRoles { get; init; }
public bool IsOidcClient { get; init; }
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ key is nameof(ClientId) or nameof(ClientSecret) or nameof(RedirectUris) or nameo

var changes = ApplicationUserUpdatedEventChanges.None |
(Name != _user!.Name ? ApplicationUserUpdatedEventChanges.Name : 0) |
(!new HashSet<string>(_user.ApiRoles).SetEquals(new HashSet<string>(newApiRoles)) ? ApplicationUserUpdatedEventChanges.ApiRoles : 0) |
(!new HashSet<string>(_user.ApiRoles ?? []).SetEquals(new HashSet<string>(newApiRoles)) ? ApplicationUserUpdatedEventChanges.ApiRoles : 0) |
(IsOidcClient != _user.IsOidcClient ? ApplicationUserUpdatedEventChanges.IsOidcClient : 0);

if (IsOidcClient)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Playwright;
using OpenIddict.Server.AspNetCore;
using TeachingRecordSystem.AuthorizeAccess.EndToEndTests.Infrastructure.Security;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.Services.TrsDataSync;
using TeachingRecordSystem.FormFlow.State;
using TeachingRecordSystem.UiTestCommon.Infrastructure.FormFlow;
Expand Down Expand Up @@ -67,6 +70,16 @@ private Host<Program> CreateHost() =>
options.AddScheme(FakeOneLoginAuthenticationScheme, b => b.HandlerType = typeof(FakeOneLoginHandler));
});

services.Configure<OpenIdConnectOptions>(
TestAppConfiguration.AuthenticationSchemeName,
options =>
{
options.Authority = BaseUrl;
options.RequireHttpsMetadata = false;
});

services.Configure<OpenIddictServerAspNetCoreOptions>(options => options.DisableTransportSecurityRequirement = true);

services.AddSingleton<OneLoginCurrentUserProvider>();
services.AddSingleton<TestData>(
sp => ActivatorUtilities.CreateInstance<TestData>(sp, TestDataSyncConfiguration.Sync(sp.GetRequiredService<TrsDataSyncHelper>())));
Expand All @@ -86,6 +99,25 @@ private void ThrowIfNotInitialized()
}
}

private async Task AddTestAppToApplicationUsers()
{
await using var dbContext = await Services.GetRequiredService<IDbContextFactory<TrsDbContext>>().CreateDbContextAsync();

dbContext.ApplicationUsers.Add(new Core.DataStore.Postgres.Models.ApplicationUser()
{
UserId = Guid.NewGuid(),
Name = "Test App",
IsOidcClient = true,
ClientId = TestAppConfiguration.ClientId,
ClientSecret = TestAppConfiguration.ClientSecret,
RedirectUris = [BaseUrl + TestAppConfiguration.RedirectUriPath],
PostLogoutRedirectUris = [BaseUrl + TestAppConfiguration.PostLogoutRedirectUriPath],
OneLoginAuthenticationSchemeName = FakeOneLoginAuthenticationScheme
});

await dbContext.SaveChangesAsync();
}

async Task IStartupTask.Execute()
{
_host = CreateHost();
Expand All @@ -108,6 +140,8 @@ async Task IStartupTask.Execute()
_browser = await _playwright.Chromium.LaunchAsync(browserOptions);

_initialized = true;

await AddTestAppToApplicationUsers();
}

async ValueTask IAsyncDisposable.DisposeAsync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace TeachingRecordSystem.AuthorizeAccess.EndToEndTests.Infrastructure.Security;

public class FakeOneLoginHandler(OneLoginCurrentUserProvider currentUserProvider) : IAuthenticationHandler
public class FakeOneLoginHandler(OneLoginCurrentUserProvider currentUserProvider) : IAuthenticationHandler, IAuthenticationSignOutHandler
{
private HttpContext? _context;

Expand Down Expand Up @@ -51,6 +51,11 @@ public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
_context = context;
return Task.CompletedTask;
}

public Task SignOutAsync(AuthenticationProperties? properties)
{
return Task.CompletedTask;
}
}

public class OneLoginCurrentUserProvider
Expand Down
Loading

0 comments on commit 606294e

Please sign in to comment.