Skip to content

Commit

Permalink
Add OIDC sign out support (#1252)
Browse files Browse the repository at this point in the history
  • Loading branch information
gunndabad authored Mar 21, 2024
1 parent 07f6680 commit e7b893e
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 5 deletions.
4 changes: 2 additions & 2 deletions TeachingRecordSystem/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<PackageVersion Include="Faker.Net" Version="2.0.154" />
<PackageVersion Include="FakeXrmEasy.v9" Version="3.3.3" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageVersion Include="GovUk.OneLogin.AspNetCore" Version="0.3.0" />
<PackageVersion Include="GovUk.OneLogin.AspNetCore" Version="0.3.1" />
<PackageVersion Include="GovukNotify" Version="6.1.0" />
<PackageVersion Include="GovUk.Frontend.AspNetCore" Version="1.5.0" />
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.7" />
Expand Down Expand Up @@ -92,4 +92,4 @@
<PackageVersion Include="Xunit.DependencyInjection" Version="8.9.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.7" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public static class ClaimTypes
public const string Email = "email";
public const string Subject = "sub";
public const string Trn = "trn";
public const string OneLoginIdToken = "onelogin_id";
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ private static IEnumerable<string> GetDestinations(Claim claim)

yield break;

case ClaimTypes.OneLoginIdToken:
yield return Destinations.IdentityToken;
yield break;

default:
yield break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using GovUk.OneLogin.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
Expand Down Expand Up @@ -154,6 +155,22 @@ void IConfigureNamedOptions<OneLoginOptions>.Configure(string? name, OneLoginOpt

options.SignInScheme = AuthenticationSchemes.FormFlowJourney;

options.Events.OnRedirectToIdentityProviderForSignOut = context =>
{
// The standard sign out process will call Authenticate() on SignInScheme then try to extract the id_token from the Principal.
// That won't work in our case most of the time since sign out journeys won't have the FormFlow instance around that has the AuthenticationTicket.
// Instead, we'll get it passed to us in explicitly in AuthenticationProperties.Items.

if (context.ProtocolMessage.IdTokenHint is null &&
context.Properties.Parameters.TryGetValue(OpenIdConnectParameterNames.IdToken, out var idToken) &&
idToken is string idTokenString)
{
context.ProtocolMessage.IdTokenHint = idTokenString;
}

return Task.CompletedTask;
};

options.CoreIdentityClaimIssuerSigningKey = _coreIdentityIssuerSigningKey;
options.CoreIdentityClaimIssuer = "https://identity.integration.account.gov.uk/";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@{
Layout = "_GovUkPageTemplate";

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

@section Header {
Expand Down Expand Up @@ -31,8 +33,9 @@
</div>
<div class="govuk-header__content">
<a asp-page="#" class="govuk-header__link govuk-header__link--service-name">
[XYZ]
@serviceName
</a>
@RenderSection("Nav", required: false)
</div>
</div>
</header>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@page "/connect/logout"
@using Microsoft.Extensions.Primitives
@addTagHelper *, Joonasw.AspNetCore.SecurityHeaders
@model TeachingRecordSystem.AuthorizeAccess.Pages.SignOutModel
@{
ViewBag.Title = "Sign out" + (Model.ServiceName is not null ? $" of {Model.ServiceName}" : "");
ViewBag.ServiceName = Model.ServiceName;
}

<form asp-page="SignOut" method="post">
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds-from-desktop">
<h1 class="govuk-heading-l">@ViewBag.Title</h1>

@foreach (var parameter in HttpContext.Request.HasFormContentType ? (IEnumerable<KeyValuePair<string, StringValues>>)HttpContext.Request.Form : HttpContext.Request.Query)
{
<input type="hidden" name="@parameter.Key" value="@parameter.Value" />
}

<govuk-button type="submit">Sign out</govuk-button>
</div>
</div>
</form>

<script asp-add-nonce="true">document.forms[0].submit();</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Security.Claims;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.DataStore.Postgres.Models;

namespace TeachingRecordSystem.AuthorizeAccess.Pages;

public class SignOutModel(TrsDbContext dbContext) : PageModel
{
private OpenIddictRequest? _request;
private AuthenticateResult? _authenticateResult;
private ApplicationUser? _client;

public string? ServiceName => _client?.Name;

public void OnGet()
{
}

public async Task<IActionResult> OnPost()
{
// We need to sign out with One Login and then complete the OIDC sign out request.
// We do it by calling SignOutAsync with OpenIddict first, capturing the Location header from its redirect
// then redirecting to OneLogin with that URL as the RedirectUri.

await HttpContext.SignOutAsync(
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
new AuthenticationProperties()
{
RedirectUri = "/"
});

var authenticationProperties = new AuthenticationProperties()
{
RedirectUri = HttpContext.Response.Headers.Location
};
var oneLoginIdToken = _authenticateResult!.Principal!.FindFirstValue(ClaimTypes.OneLoginIdToken)!;
authenticationProperties.SetParameter(OpenIdConnectParameterNames.IdToken, oneLoginIdToken);

return SignOut(authenticationProperties, _client!.OneLoginAuthenticationSchemeName!);
}

public async override Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
// Although the spec allows for logout requests without an id_token_hint, we require one so we can
// a) extract the One Login ID token and;
// b) know which authentication scheme to sign out with.

_request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

if (_request.IdTokenHint is null)
{
context.Result = BadRequest();
return;
}

_authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

string clientId = _authenticateResult.Principal!.GetAudiences().Single();
_client = await dbContext.ApplicationUsers.SingleAsync(u => u.ClientId == clientId);

await base.OnPageHandlerExecutionAsync(context, next);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.Services.PersonSearch;
Expand Down Expand Up @@ -191,12 +192,14 @@ private static void CreateAndAssignPrincipal(SignInJourneyState state, string tr
}

var oneLoginPrincipal = state.OneLoginAuthenticationTicket.Principal;
var oneLoginIdToken = state.OneLoginAuthenticationTicket.Properties.GetTokenValue(OpenIdConnectParameterNames.IdToken)!;

var teachingRecordIdentity = new ClaimsIdentity(
[
new Claim(ClaimTypes.Subject, oneLoginPrincipal.FindFirstValue("sub")!),
new Claim(ClaimTypes.Trn, trn),
new Claim(ClaimTypes.Email, oneLoginPrincipal.FindFirstValue("email")!)
new Claim(ClaimTypes.Email, oneLoginPrincipal.FindFirstValue("email")!),
new Claim(ClaimTypes.OneLoginIdToken, oneLoginIdToken)
],
authenticationType: "Authorize access to a teaching record",
nameType: "sub",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;

namespace TeachingRecordSystem.AuthorizeAccess.EndToEndTests.Infrastructure.Security;
Expand Down Expand Up @@ -36,7 +37,10 @@ public async Task ChallengeAsync(AuthenticationProperties? properties)

var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "Fake One Login", nameType: "sub", roleType: null));

await _context.SignInAsync(AuthenticationSchemes.FormFlowJourney, principal, properties);
var authenticatedProperties = properties?.Clone() ?? new();
authenticatedProperties.StoreTokens([new AuthenticationToken() { Name = OpenIdConnectParameterNames.IdToken, Value = "dummy" }]);

await _context.SignInAsync(AuthenticationSchemes.FormFlowJourney, principal, authenticatedProperties);
}

public Task ForbidAsync(AuthenticationProperties? properties) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.Services.TrsDataSync;
using TeachingRecordSystem.FormFlow;
Expand Down Expand Up @@ -140,6 +141,7 @@ public AuthenticationTicket CreateOneLoginAuthenticationTicket(

var properties = new AuthenticationProperties();
properties.SetVectorOfTrust(vtr);
properties.StoreTokens([new AuthenticationToken() { Name = OpenIdConnectParameterNames.IdToken, Value = "dummy" }]);

return new AuthenticationTicket(principal, properties, authenticationScheme: "OneLogin");
}
Expand Down

0 comments on commit e7b893e

Please sign in to comment.