Skip to content

Commit 9be37da

Browse files
FrostyApeOneFrostyApeOne
andauthored
Feature/cypress test auth (#81)
* Updated API package reference --------- Co-authored-by: FrostyApeOne <Farshad.DASHTI@EDUCATION.GOV.UK>
1 parent 824f8ff commit 9be37da

13 files changed

Lines changed: 359 additions & 53 deletions

src/DfE.ExternalApplications.Application/DfE.ExternalApplications.Application.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
<ItemGroup>
1010
<PackageReference Include="GovUK.Dfe.CoreLibs.Notifications" Version="0.1.5" />
11-
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.28" />
11+
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.29" />
1212
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="2.3.0" />
1313
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.0" />
1414
</ItemGroup>

src/DfE.ExternalApplications.Web/Authentication/TestAuthenticationHandler.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Security.Claims;
44
using System.Text.Encodings.Web;
55
using System.Diagnostics.CodeAnalysis;
6+
using DfE.ExternalApplications.Web.Services;
67

78
namespace DfE.ExternalApplications.Web.Authentication;
89

@@ -11,6 +12,8 @@ public class TestAuthenticationHandler : AuthenticationHandler<TestAuthenticatio
1112
{
1213
public const string SchemeName = "TestAuthentication";
1314

15+
private readonly ICypressAuthenticationService _cypressAuthService;
16+
1417
private static class SessionKeys
1518
{
1619
public const string Email = "TestAuth:Email";
@@ -27,13 +30,21 @@ public TestAuthenticationHandler(
2730
IOptionsMonitor<TestAuthenticationSchemeOptions> options,
2831
ILoggerFactory logger,
2932
UrlEncoder encoder,
30-
ISystemClock clock)
33+
ISystemClock clock,
34+
ICypressAuthenticationService cypressAuthService)
3135
: base(options, logger, encoder, clock)
3236
{
37+
_cypressAuthService = cypressAuthService;
3338
}
3439

3540
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
3641
{
42+
// Check if test authentication should be enabled (either globally or via Cypress)
43+
if (!_cypressAuthService.ShouldEnableTestAuthentication(Context))
44+
{
45+
return Task.FromResult(AuthenticateResult.NoResult());
46+
}
47+
3748
var requestPath = Context.Request.Path;
3849

3950

src/DfE.ExternalApplications.Web/Pages/TestLogin.cshtml.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ namespace DfE.ExternalApplications.Web.Pages;
1313
[AllowAnonymous]
1414
public class TestLoginModel : PageModel
1515
{
16+
private readonly IConfiguration _configuration;
1617
private readonly TestAuthenticationOptions _testAuthOptions;
1718
private readonly ITestAuthenticationService _testAuthenticationService;
19+
private readonly ICypressAuthenticationService _cypressAuthService;
1820

1921
[BindProperty]
2022
public InputModel Input { get; set; } = new();
@@ -25,17 +27,21 @@ public class TestLoginModel : PageModel
2527
public string? ErrorMessage { get; set; }
2628

2729
public TestLoginModel(
30+
IConfiguration configuration,
2831
IOptions<TestAuthenticationOptions> testAuthOptions,
29-
ITestAuthenticationService testAuthenticationService)
32+
ITestAuthenticationService testAuthenticationService,
33+
ICypressAuthenticationService cypressAuthService)
3034
{
35+
_configuration = configuration;
3136
_testAuthOptions = testAuthOptions.Value;
3237
_testAuthenticationService = testAuthenticationService;
38+
_cypressAuthService = cypressAuthService;
3339
}
3440

3541
public IActionResult OnGet()
3642
{
37-
// Only allow access if test authentication is enabled
38-
if (!_testAuthOptions.Enabled)
43+
// Allow access if test authentication is enabled OR Cypress toggle is enabled
44+
if (!_cypressAuthService.ShouldEnableTestAuthentication(HttpContext))
3945
{
4046
return NotFound();
4147
}
@@ -45,8 +51,8 @@ public IActionResult OnGet()
4551

4652
public async Task<IActionResult> OnPostAsync()
4753
{
48-
// Only allow access if test authentication is enabled
49-
if (!_testAuthOptions.Enabled)
54+
// Allow access if test authentication is enabled OR Cypress toggle is enabled
55+
if (!_cypressAuthService.ShouldEnableTestAuthentication(HttpContext))
5056
{
5157
return NotFound();
5258
}

src/DfE.ExternalApplications.Web/Program.cs

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
using System.Diagnostics.CodeAnalysis;
2828
using GovUK.Dfe.CoreLibs.Security.TokenRefresh.Extensions;
2929
using System.IO.Compression;
30+
using Microsoft.AspNetCore.Authentication;
3031

3132
var builder = WebApplication.CreateBuilder(args);
3233

@@ -61,6 +62,9 @@
6162
options.ValueCountLimit = 1000; // Allow more form values
6263
});
6364

65+
// Check if Cypress toggle is allowed (for shared dev/test environments)
66+
var allowCypressToggle = configuration.GetValue<bool>("CypressAuthentication:AllowToggle");
67+
6468
builder.Services.AddRazorPages(options =>
6569
{
6670
options.Conventions.ConfigureFilter(new ExternalApiPageExceptionFilter());
@@ -69,8 +73,8 @@
6973
options.Conventions.AllowAnonymousToPage("/Index");
7074
options.Conventions.AllowAnonymousToPage("/Logout");
7175

72-
// Allow anonymous access to test login page when test auth is enabled
73-
if (isTestAuthEnabled)
76+
// Allow anonymous access to test login page when test auth is enabled OR Cypress toggle is allowed
77+
if (isTestAuthEnabled || allowCypressToggle)
7478
{
7579
options.Conventions.AllowAnonymousToPage("/TestLogin");
7680
options.Conventions.AllowAnonymousToPage("/TestLogout");
@@ -88,6 +92,10 @@
8892

8993
builder.Services.AddHttpContextAccessor();
9094

95+
// Register Cypress authentication services using CoreLibs pattern
96+
builder.Services.AddScoped<ICustomRequestChecker, ExternalAppsCypressRequestChecker>();
97+
builder.Services.AddScoped<ICypressAuthenticationService, CypressAuthenticationService>();
98+
9199
// Add confirmation interceptor filter globally for all MVC actions
92100
builder.Services.Configure<Microsoft.AspNetCore.Mvc.MvcOptions>(options =>
93101
{
@@ -112,32 +120,17 @@
112120

113121
builder.Services.Configure<TokenRefreshSettings>(configuration.GetSection("TokenRefresh"));
114122

115-
// Configure authentication based on test mode
116-
if (isTestAuthEnabled)
117-
{
118-
builder.Services.AddAuthentication(options =>
119-
{
120-
options.DefaultScheme = TestAuthenticationHandler.SchemeName;
121-
options.DefaultChallengeScheme = TestAuthenticationHandler.SchemeName;
122-
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
123-
options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
124-
})
123+
// Register both schemes once, and use a dynamic scheme provider to pick per-request
124+
builder.Services
125+
.AddAuthentication()
125126
.AddCookie()
127+
.AddCustomOpenIdConnect(configuration, sectionName: "DfESignIn")
126128
.AddScheme<TestAuthenticationSchemeOptions, TestAuthenticationHandler>(
127-
TestAuthenticationHandler.SchemeName,
129+
TestAuthenticationHandler.SchemeName,
128130
options => { });
129-
}
130-
else
131-
{
132-
builder.Services.AddAuthentication(options =>
133-
{
134-
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
135-
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
136-
options.DefaultSignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;
137-
})
138-
.AddCookie()
139-
.AddCustomOpenIdConnect(configuration, sectionName: "DfESignIn");
140-
}
131+
132+
// Replace default scheme provider with dynamic provider
133+
builder.Services.AddSingleton<IAuthenticationSchemeProvider, DynamicAuthenticationSchemeProvider>();
141134

142135
builder.Services
143136
.AddApplicationAuthorization(
@@ -161,18 +154,10 @@
161154

162155
builder.Services.AddExternalApplicationsApiClients(configuration);
163156

164-
// Register authentication strategies in consuming app (Clean Architecture)
165-
// These were moved out of the library to remove coupling
166-
if (isTestAuthEnabled)
167-
{
168-
// Register TestAuthenticationStrategy when test auth is enabled
169-
builder.Services.AddScoped<IAuthenticationSchemeStrategy, TestAuthenticationStrategy>();
170-
}
171-
else
172-
{
173-
// Register OidcAuthenticationStrategy when OIDC is enabled
174-
builder.Services.AddScoped<IAuthenticationSchemeStrategy, OidcAuthenticationStrategy>();
175-
}
157+
// Register authentication strategies and composite selector (per-request)
158+
builder.Services.AddScoped<OidcAuthenticationStrategy>();
159+
builder.Services.AddScoped<TestAuthenticationStrategy>();
160+
builder.Services.AddScoped<IAuthenticationSchemeStrategy, CompositeAuthenticationSchemeStrategy>();
176161

177162
builder.Services.AddGovUkFrontend(options => options.Rebrand = true);
178163
builder.Services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
@@ -206,10 +191,8 @@
206191

207192
builder.Services.AddSingleton<ITemplateStore, ApiTemplateStore>();
208193

209-
210-
211-
// Add test token handler and services when test authentication is enabled
212-
if (isTestAuthEnabled)
194+
// Add test token handler and services when test authentication or Cypress is enabled
195+
if (isTestAuthEnabled || allowCypressToggle)
213196
{
214197
builder.Services.AddUserTokenService(configuration);
215198
builder.Services.AddScoped<ITestAuthenticationService, TestAuthenticationService>();
@@ -273,4 +256,4 @@
273256

274257

275258
[ExcludeFromCodeCoverage]
276-
public static partial class Program { }
259+
public static partial class Program { }
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using GovUK.Dfe.CoreLibs.Security.Configurations;
2+
using GovUK.Dfe.CoreLibs.Security.Interfaces;
3+
using GovUK.Dfe.ExternalApplications.Api.Client.Security;
4+
using Microsoft.AspNetCore.Authentication;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.Configuration;
7+
using Microsoft.Extensions.Options;
8+
9+
namespace DfE.ExternalApplications.Web.Security;
10+
11+
/// <summary>
12+
/// Chooses the appropriate authentication strategy per-request.
13+
/// - TestAuth when globally enabled or the request is a valid Cypress request (and toggle allowed)
14+
/// - OIDC otherwise
15+
/// </summary>
16+
public class CompositeAuthenticationSchemeStrategy(
17+
ILogger<CompositeAuthenticationSchemeStrategy> logger,
18+
IHttpContextAccessor httpContextAccessor,
19+
IOptions<TestAuthenticationOptions> testAuthOptions,
20+
IConfiguration configuration,
21+
OidcAuthenticationStrategy oidcStrategy,
22+
TestAuthenticationStrategy testStrategy,
23+
ICustomRequestChecker? requestChecker = null) : IAuthenticationSchemeStrategy
24+
{
25+
private bool IsTestEnabled() => testAuthOptions.Value.Enabled;
26+
private bool AllowToggle() => configuration.GetValue<bool>("CypressAuthentication:AllowToggle");
27+
28+
private bool IsCypressRequest()
29+
{
30+
var ctx = httpContextAccessor.HttpContext;
31+
if (ctx == null || !AllowToggle()) return false;
32+
// Request checker may be null in some DI graphs; treat as not Cypress in that case
33+
return requestChecker != null && requestChecker.IsValidRequest(ctx);
34+
}
35+
36+
private IAuthenticationSchemeStrategy Select()
37+
{
38+
if (IsTestEnabled() || IsCypressRequest())
39+
{
40+
return testStrategy;
41+
}
42+
return oidcStrategy;
43+
}
44+
45+
public string SchemeName => Select().SchemeName;
46+
47+
public Task<TokenInfo> GetExternalIdpTokenAsync(HttpContext context)
48+
=> Select().GetExternalIdpTokenAsync(context);
49+
50+
public Task<bool> CanRefreshTokenAsync(HttpContext context)
51+
=> Select().CanRefreshTokenAsync(context);
52+
53+
public Task<bool> RefreshExternalIdpTokenAsync(HttpContext context)
54+
=> Select().RefreshExternalIdpTokenAsync(context);
55+
56+
public string? GetUserId(HttpContext context)
57+
=> Select().GetUserId(context);
58+
}
59+
60+
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using DfE.ExternalApplications.Web.Authentication;
2+
using GovUK.Dfe.CoreLibs.Security.Configurations;
3+
using GovUK.Dfe.CoreLibs.Security.Interfaces;
4+
using Microsoft.AspNetCore.Authentication;
5+
using Microsoft.AspNetCore.Authentication.Cookies;
6+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.Extensions.Configuration;
9+
using Microsoft.Extensions.Options;
10+
using Microsoft.Extensions.DependencyInjection;
11+
12+
namespace DfE.ExternalApplications.Web.Security;
13+
14+
/// <summary>
15+
/// Selects the active authentication scheme per request.
16+
/// - If TestAuthentication.Enabled is true, uses Test scheme for all.
17+
/// - Else if AllowToggle is true AND request is from Cypress, uses Test scheme.
18+
/// - Otherwise uses OIDC (Cookies + OIDC challenge/sign-out).
19+
/// </summary>
20+
public class DynamicAuthenticationSchemeProvider(
21+
IOptions<AuthenticationOptions> options,
22+
IHttpContextAccessor httpContextAccessor,
23+
IOptions<TestAuthenticationOptions> testAuthOptions,
24+
IConfiguration configuration)
25+
: AuthenticationSchemeProvider(options)
26+
{
27+
private bool IsTestAuthGloballyEnabled()
28+
{
29+
return testAuthOptions.Value.Enabled;
30+
}
31+
32+
private bool IsCypressToggleAllowed()
33+
{
34+
return configuration.GetValue<bool>("CypressAuthentication:AllowToggle");
35+
}
36+
37+
private bool IsCypressRequest()
38+
{
39+
var httpContext = httpContextAccessor.HttpContext;
40+
if (httpContext == null) return false;
41+
if (!IsCypressToggleAllowed()) return false;
42+
var checker = httpContext.RequestServices.GetService<ICustomRequestChecker>();
43+
return checker != null && checker.IsValidRequest(httpContext);
44+
}
45+
46+
public override Task<AuthenticationScheme?> GetDefaultAuthenticateSchemeAsync()
47+
{
48+
if (IsTestAuthGloballyEnabled() || IsCypressRequest())
49+
{
50+
return GetSchemeAsync(TestAuthenticationHandler.SchemeName);
51+
}
52+
return GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
53+
}
54+
55+
public override Task<AuthenticationScheme?> GetDefaultChallengeSchemeAsync()
56+
{
57+
if (IsTestAuthGloballyEnabled() || IsCypressRequest())
58+
{
59+
return GetSchemeAsync(TestAuthenticationHandler.SchemeName);
60+
}
61+
return GetSchemeAsync(OpenIdConnectDefaults.AuthenticationScheme);
62+
}
63+
64+
public override Task<AuthenticationScheme?> GetDefaultForbidSchemeAsync()
65+
{
66+
// Match challenge behaviour
67+
return GetDefaultChallengeSchemeAsync();
68+
}
69+
70+
public override Task<AuthenticationScheme?> GetDefaultSignInSchemeAsync()
71+
{
72+
// Always use Cookies for sign-in
73+
return GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
74+
}
75+
76+
public override Task<AuthenticationScheme?> GetDefaultSignOutSchemeAsync()
77+
{
78+
if (IsTestAuthGloballyEnabled() || IsCypressRequest())
79+
{
80+
// Test auth signs out cookies only
81+
return GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
82+
}
83+
// OIDC sign-out triggers federated sign-out
84+
return GetSchemeAsync(OpenIdConnectDefaults.AuthenticationScheme);
85+
}
86+
}
87+
88+

0 commit comments

Comments
 (0)