Skip to content

Commit

Permalink
Add OpenIddict OIDC server (#1247)
Browse files Browse the repository at this point in the history
  • Loading branch information
gunndabad authored Mar 19, 2024
1 parent 30845d1 commit 1c2e862
Show file tree
Hide file tree
Showing 17 changed files with 2,810 additions and 20 deletions.
8 changes: 5 additions & 3 deletions TeachingRecordSystem/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
<PackageVersion Include="Microsoft.ApplicationInsights" Version="2.22.0" />
<PackageVersion Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" />
<PackageVersion Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.2" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.1.4" />
Expand All @@ -62,6 +62,8 @@
<PackageVersion Include="Microsoft.Web.LibraryManager.Build" Version="2.1.175" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageVersion Include="OpenIddict.AspNetCore" Version="5.2.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="5.2.0" />
<PackageVersion Include="Optional" Version="4.0.0" />
<PackageVersion Include="PdfSharpCore" Version="1.3.62" />
<PackageVersion Include="Polly.Core" Version="8.2.1" />
Expand Down Expand Up @@ -90,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 @@ -2,6 +2,7 @@ namespace TeachingRecordSystem.AuthorizeAccess;

public static class ClaimTypes
{
public const string Email = "email";
public const string Subject = "sub";
public const string Trn = "trn";
public const string PersonId = "person_id";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System.Security.Claims;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;
using TeachingRecordSystem.Core.DataStore.Postgres;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace TeachingRecordSystem.AuthorizeAccess.Controllers;

public class OidcController(
TrsDbContext dbContext,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager) : Controller
{
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

var clientId = request.ClientId!;
var client = await dbContext.ApplicationUsers.SingleAsync(u => u.ClientId == clientId);

var childAuthenticationScheme = AuthenticationSchemes.MatchToTeachingRecord;
var authenticateResult = await HttpContext.AuthenticateAsync(childAuthenticationScheme);

if (!authenticateResult.Succeeded)
{
var parameters = Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList();


var authenticationProperties = new AuthenticationProperties()
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters)
};
authenticationProperties.Items.Add("OneLoginAuthenticationScheme", client.OneLoginAuthenticationSchemeName);

return Challenge(authenticationProperties, childAuthenticationScheme);
}

var user = authenticateResult.Principal;
var subject = user.FindFirstValue(ClaimTypes.Subject) ??
throw new InvalidOperationException($"Principal does not contain a '{ClaimTypes.Subject}' claim.");

var authorizations = await authorizationManager.FindAsync(
subject: subject,
client: client.UserId.ToString(),
status: Statuses.Valid,
type: AuthorizationTypes.Permanent,
scopes: request.GetScopes()).ToListAsync();

var identity = new ClaimsIdentity(
claims: user.Claims,
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: ClaimTypes.Subject,
roleType: null);

identity.SetScopes(request.GetScopes());
identity.SetResources(await scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());

var authorization = authorizations.LastOrDefault();
authorization ??= await authorizationManager.CreateAsync(
identity: identity,
subject: subject,
client: client.UserId.ToString(),
type: AuthorizationTypes.Permanent,
scopes: identity.GetScopes());

identity.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization));
identity.SetDestinations(GetDestinations);

return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}

[HttpPost("~/connect/token")]
[IgnoreAntiforgeryToken]
[Produces("application/json")]
public async Task<IActionResult> Token()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

var identity = new ClaimsIdentity(
result.Principal!.Claims,
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: ClaimTypes.Subject,
roleType: null);

identity.SetDestinations(GetDestinations);

return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}

throw new InvalidOperationException("The specified grant type is not supported.");
}

[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
[HttpGet("~/connect/userinfo")]
[HttpPost("~/connect/userinfo")]
[Produces("application/json")]
public IActionResult UserInfo()
{
var claims = new Dictionary<string, object>(StringComparer.Ordinal)
{
[ClaimTypes.Subject] = User.GetClaim(ClaimTypes.Subject)!,
[ClaimTypes.Trn] = User.GetClaim(ClaimTypes.Trn)!
};

if (User.HasScope(Scopes.Email))
{
claims[ClaimTypes.Email] = User.GetClaim(ClaimTypes.Email)!;
}

return Ok(claims);
}

private static IEnumerable<string> GetDestinations(Claim claim)
{
switch (claim.Type)
{
case ClaimTypes.Subject:
case ClaimTypes.Trn:
yield return Destinations.AccessToken;
yield return Destinations.IdentityToken;
yield break;

case ClaimTypes.Email:
if (claim.Subject!.HasScope(Scopes.Profile))
{
yield return Destinations.IdentityToken;
}

yield break;

default:
yield break;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.EntityFrameworkCore.Models;

namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.Oidc;

public class ApplicationManager : OpenIddictApplicationManager<OpenIddictEntityFrameworkCoreApplication<Guid>>
{
public ApplicationManager(
IOpenIddictApplicationCache<OpenIddictEntityFrameworkCoreApplication<Guid>> cache,
ILogger<OpenIddictApplicationManager<OpenIddictEntityFrameworkCoreApplication<Guid>>> logger,
IOptionsMonitor<OpenIddictCoreOptions> options,
IOpenIddictApplicationStoreResolver resolver) : base(cache, logger, options, resolver)
{
}

protected override ValueTask<string> ObfuscateClientSecretAsync(string secret, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();

protected override ValueTask<bool> ValidateClientSecretAsync(string secret, string comparand, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(secret.Equals(comparand));
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Filters;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.FormFlow;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Logging;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Oidc;
using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security;
using TeachingRecordSystem.AuthorizeAccess.TagHelpers;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.Dqt;
using TeachingRecordSystem.Core.Services.PersonSearch;
using TeachingRecordSystem.FormFlow;
using TeachingRecordSystem.ServiceDefaults;
using TeachingRecordSystem.SupportUi.Infrastructure.FormFlow;
using static OpenIddict.Abstractions.OpenIddictConstants;

var builder = WebApplication.CreateBuilder(args);

Expand Down Expand Up @@ -58,6 +61,49 @@
.AddSingleton<IHostedService>(sp => sp.GetRequiredService<OneLoginAuthenticationSchemeProvider>());
}

builder.Services.AddOpenIddict()
.AddCore(options =>
{
options
.UseEntityFrameworkCore()
.UseDbContext<TrsDbContext>()
.ReplaceDefaultEntities<Guid>();

options.ReplaceApplicationManager<ApplicationManager>();
})
.AddServer(options =>
{
//options.SetIssuer(); // TODO

options
.SetAuthorizationEndpointUris("connect/authorize")
.SetLogoutEndpointUris("connect/logout")
.SetTokenEndpointUris("connect/token")
.SetUserinfoEndpointUris("connect/userinfo");

// TODO - add teaching record scopes
options.RegisterScopes(Scopes.Email, Scopes.Profile);

options.AllowAuthorizationCodeFlow();

options.DisableAccessTokenEncryption();
options.SetAccessTokenLifetime(TimeSpan.FromHours(1));

//if (!builder.Environment.IsProduction())
{
options
.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
}

options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableLogoutEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableUserinfoEndpointPassthrough()
.EnableStatusCodePagesIntegration();
});

builder.Services
.AddRazorPages()
.AddMvcOptions(options =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,7 @@ public async Task<IResult> CreateOrUpdateOneLoginUser(JourneyInstance<SignInJour
oneLoginUser.LastSignIn = clock.UtcNow;
await dbContext.SaveChangesAsync();

await journeyInstance.UpdateStateAsync(state =>
CreateAndAssignPrincipal(state, oneLoginUser.Person.PersonId, oneLoginUser.Person.Trn!));
await journeyInstance.UpdateStateAsync(state => CreateAndAssignPrincipal(state, oneLoginUser.Person.Trn!));
}
}
else
Expand Down Expand Up @@ -170,8 +169,7 @@ public async Task<bool> TryMatchToTeachingRecord(JourneyInstance<SignInJourneySt
oneLoginUser.LastSignIn = clock.UtcNow;
await dbContext.SaveChangesAsync();

await journeyInstance.UpdateStateAsync(state =>
CreateAndAssignPrincipal(state, matchedPersonId, matchedTrn));
await journeyInstance.UpdateStateAsync(state => CreateAndAssignPrincipal(state, matchedTrn));

return true;
}
Expand All @@ -185,22 +183,26 @@ await journeyInstance.UpdateStateAsync(state =>
string.IsNullOrEmpty(value) ? null : new(value.Where(char.IsAsciiDigit).ToArray());
}

private static void CreateAndAssignPrincipal(SignInJourneyState state, Guid personId, string trn)
private static void CreateAndAssignPrincipal(SignInJourneyState state, string trn)
{
if (state.OneLoginAuthenticationTicket is null)
{
throw new InvalidOperationException("User is not authenticated with One Login.");
}

var oneLoginIdentity = (ClaimsIdentity)state.OneLoginAuthenticationTicket.Principal.Identity!;
var oneLoginPrincipal = state.OneLoginAuthenticationTicket.Principal;

var teachingRecordIdentity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Trn, trn),
new Claim(ClaimTypes.PersonId, personId.ToString())
});
var teachingRecordIdentity = new ClaimsIdentity(
[
new Claim(ClaimTypes.Subject, oneLoginPrincipal.FindFirstValue("sub")!),
new Claim(ClaimTypes.Trn, trn),
new Claim(ClaimTypes.Email, oneLoginPrincipal.FindFirstValue("email")!)
],
authenticationType: "Authorize access to a teaching record",
nameType: "sub",
roleType: null);

var principal = new ClaimsPrincipal([oneLoginIdentity, teachingRecordIdentity]);
var principal = new ClaimsPrincipal(teachingRecordIdentity);

state.AuthenticationTicket = new AuthenticationTicket(principal, state.AuthenticationProperties, AuthenticationSchemes.MatchToTeachingRecord);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PackageReference Include="GovUk.OneLogin.AspNetCore" />
<PackageReference Include="Joonasw.AspNetCore.SecurityHeaders" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" />
<PackageReference Include="OpenIddict.AspNetCore" />
<PackageReference Include="Sentry.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
Expand Down
Loading

0 comments on commit 1c2e862

Please sign in to comment.