Skip to content

Commit ff27413

Browse files
authored
Feature/dfe entra sso (#157)
1 parent b34c539 commit ff27413

11 files changed

Lines changed: 279 additions & 45 deletions

DfE.ExternalApplications.Web.sln

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
3-
# Visual Studio Version 17
4-
VisualStudioVersion = 17.10.35122.118
3+
# Visual Studio Version 18
4+
VisualStudioVersion = 18.4.11605.240 stable
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.ExternalApplications.Web", "src\DfE.ExternalApplications.Web\DfE.ExternalApplications.Web.csproj", "{CA38CBED-6681-4A44-BEC8-832D5171D59F}"
77
EndProject

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
</PropertyGroup>
88

99
<ItemGroup>
10-
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.42-prerelease-7" />
10+
<PackageReference Include="GovUK.Dfe.ExternalApplications.Api.Client" Version="0.1.43" />
1111
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="2.3.0" />
1212
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.0" />
13+
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
1314
</ItemGroup>
1415

1516
<ItemGroup>

src/DfE.ExternalApplications.Web/DfE.ExternalApplications.Web.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
</PropertyGroup>
1212

1313
<ItemGroup>
14-
<PackageReference Include="GovUK.Dfe.CoreLibs.Security" Version="1.1.21-prerelease-15" />
14+
<PackageReference Include="GovUK.Dfe.CoreLibs.Security" Version="1.1.25-prerelease-11" />
1515
<PackageReference Include="GovUk.Frontend.AspNetCore" Version="3.2.2" />
1616
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
1717
<PackageReference Include="Markdig" Version="0.42.0" />

src/DfE.ExternalApplications.Web/Program.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
using Microsoft.AspNetCore.Mvc.ViewFeatures;
2727
using Microsoft.AspNetCore.ResponseCompression;
2828
using System.Diagnostics.CodeAnalysis;
29+
using GovUK.Dfe.CoreLibs.Security.EntraSso;
2930
using GovUK.Dfe.CoreLibs.Security.TokenRefresh.Extensions;
3031
using System.IO.Compression;
3132
using DfE.ExternalApplications.Infrastructure.Consumers;
@@ -146,6 +147,12 @@ static void BindNestedConfiguration(ConfigurationManager config, string parentKe
146147
var testAuthOptions = configuration.GetSection(TestAuthenticationOptions.SectionName).Get<TestAuthenticationOptions>();
147148
var isTestAuthEnabled = testAuthOptions?.Enabled ?? false;
148149

150+
// Configure Entra SSO options
151+
builder.Services.Configure<EntraSsoOptions>(
152+
configuration.GetSection(EntraSsoOptions.SectionName));
153+
var entraSsoOptions = configuration.GetSection(EntraSsoOptions.SectionName).Get<EntraSsoOptions>();
154+
var isEntraSsoEnabled = entraSsoOptions?.Enabled ?? false;
155+
149156
// Configure token settings for test authentication
150157
// This is needed when test auth is enabled
151158
if ((isTestAuthEnabled) && testAuthOptions != null)
@@ -299,7 +306,30 @@ static void BindNestedConfiguration(ConfigurationManager config, string parentKe
299306
options => { })
300307
.AddScheme<InternalServiceAuthenticationSchemeOptions, InternalServiceAuthenticationHandler>(
301308
InternalServiceAuthenticationHandler.SchemeName,
302-
options => { });
309+
options => { })
310+
.AddEntraSso(configuration, sectionName: EntraSsoDefaults.ConfigurationSection, new OpenIdConnectEvents
311+
{
312+
OnRemoteFailure = context =>
313+
{
314+
var error = context.Failure?.Message ?? "Unknown error";
315+
316+
if (error.Contains("message.State", StringComparison.OrdinalIgnoreCase))
317+
{
318+
context.Response.Redirect("/");
319+
context.HandleResponse();
320+
return Task.CompletedTask;
321+
}
322+
323+
return Task.CompletedTask;
324+
},
325+
326+
OnAuthenticationFailed = context =>
327+
{
328+
context.HandleResponse();
329+
context.Response.Redirect("/error?message=" + Uri.EscapeDataString(context.Exception.Message));
330+
return Task.CompletedTask;
331+
}
332+
});
303333

304334
// Use DynamicAuthenticationSchemeProvider to route per request
305335
// Checks for Internal Service Auth (forwarder pattern)
@@ -332,6 +362,7 @@ static void BindNestedConfiguration(ConfigurationManager config, string parentKe
332362
builder.Services.AddScoped<OidcAuthenticationStrategy>();
333363
builder.Services.AddScoped<TestAuthenticationStrategy>();
334364
builder.Services.AddScoped<InternalAuthenticationStrategy>();
365+
builder.Services.AddScoped<EntraSsoAuthenticationStrategy>();
335366
builder.Services.AddScoped<IAuthenticationSchemeStrategy, CompositeAuthenticationSchemeStrategy>();
336367

337368
// Register activity-based token refresh services

src/DfE.ExternalApplications.Web/Security/CompositeAuthenticationSchemeStrategy.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,53 +10,55 @@ namespace DfE.ExternalApplications.Web.Security;
1010

1111
/// <summary>
1212
/// 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
13+
/// Priority: Internal Auth > Test Auth > Entra SSO (when enabled) > DfE Sign-In OIDC
1514
/// </summary>
1615
public class CompositeAuthenticationSchemeStrategy(
1716
ILogger<CompositeAuthenticationSchemeStrategy> logger,
1817
IHttpContextAccessor httpContextAccessor,
1918
IOptions<TestAuthenticationOptions> testAuthOptions,
19+
IOptions<EntraSsoOptions> entraSsoOptions,
2020
IConfiguration configuration,
2121
OidcAuthenticationStrategy oidcStrategy,
2222
TestAuthenticationStrategy testStrategy,
2323
InternalAuthenticationStrategy internalStrategy,
24+
EntraSsoAuthenticationStrategy entraSsoStrategy,
2425
[FromKeyedServices("internal")] ICustomRequestChecker internalRequestChecker
2526
) : IAuthenticationSchemeStrategy
2627
{
2728
private bool IsTestEnabled() => testAuthOptions.Value.Enabled;
2829

30+
private bool IsEntraSsoEnabled() => entraSsoOptions.Value.Enabled;
31+
2932
private bool IsInternalAuthRequest()
3033
{
3134
var ctx = httpContextAccessor.HttpContext;
3235
if (ctx == null) return false;
33-
// Request checker may be null in some DI graphs; treat as false
3436
return internalRequestChecker != null && internalRequestChecker.IsValidRequest(ctx);
3537
}
3638

3739
private IAuthenticationSchemeStrategy Select()
3840
{
3941
var ctx = httpContextAccessor.HttpContext;
4042
var path = ctx?.Request.Path.ToString() ?? "unknown";
41-
var isTestEnabled = IsTestEnabled();
42-
var isInternalAuth = IsInternalAuthRequest();
4343

44-
if (isInternalAuth)
44+
if (IsInternalAuthRequest())
4545
{
46-
logger.LogDebug(
47-
"Selecting InternalAuthenticationStrategy for {Path}.",
48-
path);
46+
logger.LogDebug("Selecting InternalAuthenticationStrategy for {Path}.", path);
4947
return internalStrategy;
5048
}
5149

52-
if (isTestEnabled)
50+
if (IsTestEnabled())
5351
{
54-
logger.LogDebug(
55-
"Selecting TestAuthenticationStrategy for {Path}. TestEnabled: {TestEnabled}",
56-
path, isTestEnabled);
52+
logger.LogDebug("Selecting TestAuthenticationStrategy for {Path}.", path);
5753
return testStrategy;
5854
}
59-
55+
56+
if (IsEntraSsoEnabled())
57+
{
58+
logger.LogDebug("Selecting EntraSsoAuthenticationStrategy for {Path}.", path);
59+
return entraSsoStrategy;
60+
}
61+
6062
logger.LogDebug("Selecting OidcAuthenticationStrategy for {Path}", path);
6163
return oidcStrategy;
6264
}
@@ -75,5 +77,3 @@ public Task<bool> RefreshExternalIdpTokenAsync(HttpContext context)
7577
public string? GetUserId(HttpContext context)
7678
=> Select().GetUserId(context);
7779
}
78-
79-
Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using DfE.ExternalApplications.Web.Authentication;
22
using GovUK.Dfe.CoreLibs.Security.Configurations;
3+
using GovUK.Dfe.CoreLibs.Security.EntraSso;
34
using GovUK.Dfe.CoreLibs.Security.Interfaces;
45
using Microsoft.AspNetCore.Authentication;
56
using Microsoft.AspNetCore.Authentication.Cookies;
@@ -16,13 +17,14 @@ namespace DfE.ExternalApplications.Web.Security;
1617
/// Priority order:
1718
/// 1. If X-Service-Email header present: Uses Internal Service Auth (header-based forwarder)
1819
/// 2. If TestAuthentication.Enabled is true: Uses Test scheme for all
19-
/// 3. If AllowToggle is true AND request is from Cypress: Uses Test scheme
20-
/// 4. Otherwise: Uses OIDC (Cookies + OIDC challenge/sign-out)
20+
/// 3. If EntraSso.Enabled is true: Uses Entra SSO scheme
21+
/// 4. Otherwise: Uses DfE Sign-In OIDC (Cookies + OIDC challenge/sign-out)
2122
/// </summary>
2223
public class DynamicAuthenticationSchemeProvider(
2324
IOptions<AuthenticationOptions> options,
2425
IHttpContextAccessor httpContextAccessor,
2526
IOptions<TestAuthenticationOptions> testAuthOptions,
27+
IOptions<EntraSsoOptions> entraSsoOptions,
2628
IConfiguration configuration)
2729
: AuthenticationSchemeProvider(options)
2830
{
@@ -33,7 +35,6 @@ private bool IsTestAuthGloballyEnabled()
3335

3436
private bool ShouldUseTestAuth()
3537
{
36-
// Always use test auth if globally enabled
3738
if (IsTestAuthGloballyEnabled())
3839
{
3940
return true;
@@ -42,88 +43,83 @@ private bool ShouldUseTestAuth()
4243
return false;
4344
}
4445

46+
private bool IsEntraSsoEnabled()
47+
{
48+
return entraSsoOptions.Value.Enabled;
49+
}
50+
4551
private bool IsInternalServiceRequest()
4652
{
4753
var httpContext = httpContextAccessor.HttpContext;
4854
if (httpContext == null) return false;
4955

50-
// Only engage Internal Service Auth if X-Service-Email header is present
5156
return httpContext.Request.Headers.ContainsKey("x-service-email");
5257
}
5358

59+
private string GetDefaultIdpScheme()
60+
{
61+
return IsEntraSsoEnabled()
62+
? EntraSsoDefaults.AuthenticationScheme
63+
: OpenIdConnectDefaults.AuthenticationScheme;
64+
}
65+
5466
public override Task<AuthenticationScheme?> GetDefaultAuthenticateSchemeAsync()
5567
{
56-
// PRIORITY 1: Internal Service Authentication (check header first - fastest)
5768
if (IsInternalServiceRequest())
5869
{
5970
return GetSchemeAsync(InternalServiceAuthenticationHandler.SchemeName);
6071
}
6172

62-
// PRIORITY 2: Test Authentication (if enabled or Cypress)
6373
if (ShouldUseTestAuth())
6474
{
6575
return GetSchemeAsync(TestAuthenticationHandler.SchemeName);
6676
}
6777

68-
// PRIORITY 3: Default to OIDC (Cookies)
6978
return GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
7079
}
7180

7281
public override Task<AuthenticationScheme?> GetDefaultChallengeSchemeAsync()
7382
{
74-
// Internal Service requests don't challenge - they authenticate directly
7583
if (IsInternalServiceRequest())
7684
{
7785
return GetSchemeAsync(InternalServiceAuthenticationHandler.SchemeName);
7886
}
7987

80-
// Test Auth uses its own challenge
8188
if (ShouldUseTestAuth())
8289
{
8390
return GetSchemeAsync(TestAuthenticationHandler.SchemeName);
8491
}
8592

86-
// Regular users: OIDC challenge (login page)
87-
return GetSchemeAsync(OpenIdConnectDefaults.AuthenticationScheme);
93+
return GetSchemeAsync(GetDefaultIdpScheme());
8894
}
8995

9096
public override Task<AuthenticationScheme?> GetDefaultForbidSchemeAsync()
9197
{
92-
// Don't call GetDefaultChallengeSchemeAsync here as it might trigger recursion
93-
// Instead, inline the logic with the cached result
94-
95-
// Internal Service requests
9698
if (IsInternalServiceRequest())
9799
{
98100
return GetSchemeAsync(InternalServiceAuthenticationHandler.SchemeName);
99101
}
100102

101-
// Test Auth
102103
if (ShouldUseTestAuth())
103104
{
104105
return GetSchemeAsync(TestAuthenticationHandler.SchemeName);
105106
}
106107

107-
// Regular users: OIDC
108-
return GetSchemeAsync(OpenIdConnectDefaults.AuthenticationScheme);
108+
return GetSchemeAsync(GetDefaultIdpScheme());
109109
}
110110

111111
public override Task<AuthenticationScheme?> GetDefaultSignInSchemeAsync()
112112
{
113-
// Always use Cookies for sign-in
114113
return GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
115114
}
116115

117116
public override Task<AuthenticationScheme?> GetDefaultSignOutSchemeAsync()
118117
{
119118
if (ShouldUseTestAuth())
120119
{
121-
// Test auth signs out cookies only
122120
return GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
123121
}
124-
// OIDC sign-out triggers federated sign-out
125-
return GetSchemeAsync(OpenIdConnectDefaults.AuthenticationScheme);
122+
123+
return GetSchemeAsync(GetDefaultIdpScheme());
126124
}
127125
}
128-
129-

0 commit comments

Comments
 (0)