Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OpenIddict OIDC server #1247

Merged
merged 1 commit into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading