Skip to content

Commit 5b3ab1b

Browse files
authored
Merge pull request #1489 from bcgov/stories/ecer-5388
ECER-5388: BCeID Business authentication
2 parents 6b6b64f + d07bcdf commit 5b3ab1b

File tree

73 files changed

+3278
-871
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+3278
-871
lines changed
Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
using ECER.Clients.PSPPortal.Server.Shared;
2-
using ECER.Managers.Registry.Contract.Registrants;
32
using ECER.Utilities.Security;
43
using MediatR;
54
using Microsoft.Extensions.Caching.Distributed;
65
using Microsoft.Extensions.Options;
76
using System.Security.Claims;
7+
using ECER.Managers.Registry.Contract.PspUsers;
88

99
namespace ECER.Clients.PSPPortal.Server;
1010

@@ -14,7 +14,7 @@ public class AuthenticationService(IMediator messageBus, IDistributedCache cache
1414
{
1515
ArgumentNullException.ThrowIfNull(principal);
1616

17-
var identityProvider = principal.FindFirst(RegistryPortalClaims.IdenityProvider)?.Value;
17+
var identityProvider = principal.FindFirst(PSPPortalClaims.IdentityProvider)?.Value;
1818
var identityId = principal.FindFirst(ClaimTypes.Name)?.Value;
1919

2020
if (string.IsNullOrEmpty(identityProvider) || string.IsNullOrEmpty(identityId)) return principal;
@@ -31,26 +31,18 @@ public class AuthenticationService(IMediator messageBus, IDistributedCache cache
3131

3232
private async Task<Claim[]?> GetUserClaims(UserIdentity userIdentity, CancellationToken ct)
3333
{
34-
// try to find the registrant
35-
var registrant = await cache.GetAsync($"userinfo:{userIdentity.UserId}@{userIdentity.IdentityProvider}",
36-
async ct => (await messageBus.Send(new SearchRegistrantQuery { ByUserIdentity = userIdentity }, ct)).Items.SingleOrDefault(),
34+
// try to find the psp user
35+
var pspRep = await cache.GetAsync($"userinfo:{userIdentity.UserId}@{userIdentity.IdentityProvider}",
36+
async ct => (await messageBus.Send(new SearchPspRepQuery { ByUserIdentity = userIdentity }, ct)).Items.SingleOrDefault(),
3737
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(claimCacheSettings.Value.CacheTimeInSeconds) },
3838
ct);
3939

40-
if (registrant == null) return null;
40+
if (pspRep == null) return null;
4141

4242
// add registrant claims
43-
var userId = new Claim("user_id", registrant.UserId);
44-
Claim verificationStatus = new Claim("verified", "");
45-
if (registrant.Profile.Status == StatusCode.Verified)
46-
{
47-
verificationStatus = new Claim("verified", "true");
48-
}
49-
else if (registrant.Profile.Status is StatusCode.Unverified or StatusCode.PendingforDocuments)
50-
{
51-
verificationStatus = new Claim("verified", "false");
52-
}
53-
54-
return [userId, verificationStatus];
43+
var userId = new Claim("user_id", pspRep.Id);
44+
Claim hasTermsOfUse = new Claim("terms_of_use", pspRep.Profile.HasAcceptedTermsOfUse.HasValue && pspRep.Profile.HasAcceptedTermsOfUse.Value ? "true" : "false");
45+
46+
return [userId, hasTermsOfUse];
5547
}
5648
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using ECER.Utilities.Hosting;
2+
using MediatR;
3+
4+
namespace ECER.Clients.PSPPortal.Server;
5+
6+
public class ConfigurationEndpoints : IRegisterEndpoints
7+
{
8+
public void Register(IEndpointRouteBuilder endpointRouteBuilder)
9+
{
10+
endpointRouteBuilder.MapGet("/api/configuration", async (HttpContext ctx, IMediator messageBus, CancellationToken ct) =>
11+
{
12+
await Task.CompletedTask;
13+
var configuration = ctx.RequestServices.GetRequiredService<IConfiguration>();
14+
var appConfig = configuration.Get<ApplicationConfiguration>()!;
15+
return TypedResults.Ok(appConfig);
16+
}).WithOpenApi("Returns the UI initial configuration", string.Empty, "configuration_get")
17+
.CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)));
18+
}
19+
}
20+
21+
#pragma warning disable CA2227 // Collection properties should be read only
22+
23+
public record ApplicationConfiguration
24+
{
25+
public Dictionary<string, OidcAuthenticationSettings> ClientAuthenticationMethods { get; set; } = [];
26+
}
27+
28+
#pragma warning restore CA2227 // Collection properties should be read only
29+
public record OidcAuthenticationSettings
30+
{
31+
public string Authority { get; set; } = null!;
32+
public string ClientId { get; set; } = null!;
33+
public string Scope { get; set; } = null!;
34+
public string? Idp { get; set; }
35+
}

src/ECER.Clients.PSPPortal/ECER.Clients.PSPPortal.Server/ECER.Clients.PSPPortal.Server.csproj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<PropertyGroup>
44
<SpaRoot>..\ecer.clients.pspportal.client</SpaRoot>
55
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
6-
<SpaProxyServerUrl>https://localhost:5123</SpaProxyServerUrl>
6+
<SpaProxyServerUrl>https://localhost:5130</SpaProxyServerUrl>
77
<UserSecretsId>c88bcf5c-db2f-4814-aa30-1ef3c67d1808</UserSecretsId>
88
</PropertyGroup>
99

@@ -16,6 +16,8 @@
1616
<ItemGroup>
1717
<ProjectReference Include="..\..\ECER.Managers.Admin.Contract\ECER.Managers.Admin.Contract.csproj" />
1818
<ProjectReference Include="..\..\ECER.Managers.Admin\ECER.Managers.Admin.csproj" />
19+
<ProjectReference Include="..\..\ECER.Managers.Registry.Contract\ECER.Managers.Registry.Contract.csproj" />
20+
<ProjectReference Include="..\..\ECER.Managers.Registry\ECER.Managers.Registry.csproj" />
1921
<ProjectReference Include="..\..\ECER.Utilities.Hosting\ECER.Utilities.Hosting.csproj" />
2022
<ProjectReference Include="..\..\ECER.Utilities.Security\ECER.Utilities.Security.csproj" />
2123
<ProjectReference Include="..\ecer.clients.pspportal.client\ecer.clients.pspportal.client.esproj">
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using AutoMapper;
2+
using ECER.Managers.Registry.Contract.PortalInvitations;
3+
using ECER.Utilities.Hosting;
4+
using MediatR;
5+
using Microsoft.AspNetCore.Http.HttpResults;
6+
using Microsoft.AspNetCore.Mvc;
7+
8+
namespace ECER.Clients.PSPPortal.Server.PortalInvitations;
9+
10+
public class PortalInvitationsEndpoints : IRegisterEndpoints
11+
{
12+
public void Register(IEndpointRouteBuilder endpointRouteBuilder)
13+
{
14+
endpointRouteBuilder.MapGet("/api/PortalInvitations/{token?}", async Task<Results<Ok<PortalInvitationQueryResult>, BadRequest<ProblemDetails>>> (string? token, IMediator messageBus, HttpContext httpContext, IMapper mapper, CancellationToken ct) =>
15+
{
16+
if (string.IsNullOrWhiteSpace(token))
17+
{
18+
return TypedResults.BadRequest(new ProblemDetails { Status = StatusCodes.Status400BadRequest, Detail = "Provided token is not valid" });
19+
}
20+
var result = await messageBus.Send(new PortalInvitationVerificationQuery(token), ct);
21+
22+
if (!result.IsSuccess)
23+
{
24+
return TypedResults.BadRequest(new ProblemDetails { Status = StatusCodes.Status400BadRequest, Detail = result.ErrorMessage });
25+
}
26+
return TypedResults.Ok(new PortalInvitationQueryResult(mapper.Map<PortalInvitation>(result.Invitation)));
27+
}).WithOpenApi("Handles portal invitation queries", string.Empty, "portal_invitation_get").WithParameterValidation();
28+
}
29+
}
30+
31+
public record PortalInvitationQueryResult(PortalInvitation PortalInvitation);
32+
33+
public record PortalInvitation(string? Id)
34+
{
35+
public string? PspProgramRepresentativeId { get; set; }
36+
public InviteType? InviteType { get; set; }
37+
}
38+
39+
public enum InviteType
40+
{
41+
PSIProgramRepresentative,
42+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using AutoMapper;
2+
3+
namespace ECER.Clients.PSPPortal.Server.PortalInvitations;
4+
5+
public class PortalInvitationMapper : Profile
6+
{
7+
public PortalInvitationMapper()
8+
{
9+
CreateMap<Managers.Registry.Contract.PortalInvitations.PortalInvitation, PortalInvitation>();
10+
}
11+
}

src/ECER.Clients.PSPPortal/ECER.Clients.PSPPortal.Server/Program.cs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ private static async Task<int> Main(string[] args)
4848
});
4949
opts.AddSecurityRequirement(new OpenApiSecurityRequirement
5050
{
51+
{
52+
new OpenApiSecurityScheme
5153
{
52-
new OpenApiSecurityScheme
53-
{
54-
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
55-
},
56-
[]
57-
}
54+
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
55+
},
56+
new List<string>()
57+
}
5858
});
5959
opts.UseOneOfForPolymorphism();
6060
});
@@ -87,9 +87,16 @@ private static async Task<int> Main(string[] args)
8787
{
8888
policy
8989
.AddAuthenticationSchemes("kc")
90-
.RequireClaim(PSPPortalClaims.IdenityProvider)
91-
.RequireClaim(ClaimTypes.Name)
90+
.RequireClaim(PSPPortalClaims.IdentityProvider)
91+
.RequireAuthenticatedUser();
92+
})
93+
.AddPolicy("psp_user_has_accepted_terms", policy =>
94+
{
95+
policy
96+
.AddAuthenticationSchemes("kc")
97+
.RequireClaim(PSPPortalClaims.IdentityProvider)
9298
.RequireClaim(PSPPortalClaims.UserId)
99+
.RequireClaim(PSPPortalClaims.TermsOfUse, "true")
93100
.RequireAuthenticatedUser();
94101
});
95102

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using AutoMapper;
3+
using ECER.Managers.Registry.Contract.PspUsers;
4+
using ECER.Utilities.Hosting;
5+
using ECER.Utilities.Security;
6+
using MediatR;
7+
using Microsoft.AspNetCore.Http.HttpResults;
8+
using Microsoft.AspNetCore.Mvc;
9+
10+
namespace ECER.Clients.PSPPortal.Server.Users;
11+
12+
public class ProfileEndpoints : IRegisterEndpoints
13+
{
14+
public void Register(IEndpointRouteBuilder endpointRouteBuilder)
15+
{
16+
endpointRouteBuilder.MapGet("api/users/profile", async Task<Results<Ok<PspUserProfile>, NotFound>> (HttpContext ctx, CancellationToken ct, IMediator bus, IMapper mapper) =>
17+
{
18+
var user = ctx.User.GetUserContext()!;
19+
var results = await bus.Send<PspRepQueryResults>(new SearchPspRepQuery() { ByUserIdentity = user.Identity }, ct);
20+
21+
var pspUser = results.Items.SingleOrDefault();
22+
if (pspUser == null) return TypedResults.NotFound();
23+
24+
var pspUserProfile = mapper.Map<PspUserProfile>(pspUser.Profile);
25+
return TypedResults.Ok(pspUserProfile);
26+
})
27+
.WithOpenApi("Gets the currently logged in user profile or NotFound if no profile found", string.Empty, "psp_user_profile_get")
28+
.RequireAuthorization("psp_user")
29+
.WithParameterValidation();
30+
31+
endpointRouteBuilder.MapPut("/api/users/profile",
32+
() => TypedResults.StatusCode(StatusCodes.Status501NotImplemented))
33+
.WithOpenApi("Update a psp user profile", string.Empty, "psp_user_profile_put")
34+
.RequireAuthorization("psp_user")
35+
.WithParameterValidation();
36+
37+
endpointRouteBuilder.MapPost("/api/users/register",
38+
async Task<Results<Ok, BadRequest<PspRegistrationErrorResponse>>> (
39+
RegisterPspUserRequest request, HttpContext ctx,
40+
CancellationToken ct, IMediator bus, IMapper mapper) =>
41+
{
42+
var user = ctx.User.GetUserContext()!;
43+
var result = await bus.Send(
44+
new RegisterPspUserCommand(
45+
request.Token,
46+
request.ProgramRepresentativeId,
47+
request.BceidBusinessId,
48+
mapper.Map<Managers.Registry.Contract.PspUsers.PspUserProfile>(request.Profile)!,
49+
user.Identity
50+
),
51+
ctx.RequestAborted);
52+
53+
if (!result.IsSuccess)
54+
{
55+
var errorCode = PspRegistrationError.GenericError;
56+
57+
if (result.Error.HasValue)
58+
{
59+
errorCode = result.Error.Value switch
60+
{
61+
RegisterPspUserError.PostSecondaryInstitutionNotFound
62+
=> PspRegistrationError.PostSecondaryInstitutionNotFound,
63+
64+
RegisterPspUserError.PortalInvitationTokenInvalid
65+
=> PspRegistrationError.PortalInvitationTokenInvalid,
66+
67+
RegisterPspUserError.PortalInvitationWrongStatus
68+
=> PspRegistrationError.PortalInvitationWrongStatus,
69+
70+
RegisterPspUserError.BceidBusinessIdDoesNotMatch
71+
=> PspRegistrationError.BceidBusinessIdDoesNotMatch,
72+
73+
_ => PspRegistrationError.GenericError
74+
};
75+
}
76+
77+
return TypedResults.BadRequest(new PspRegistrationErrorResponse
78+
{
79+
ErrorCode = errorCode
80+
});
81+
}
82+
83+
return TypedResults.Ok();
84+
})
85+
.WithOpenApi("Update a psp user profile", string.Empty, "psp_user_register_post")
86+
.WithOpenApi()
87+
.RequireAuthorization("psp_user");
88+
}
89+
}
90+
91+
/// <summary>
92+
/// Request to register a new psp user
93+
/// </summary>
94+
public record RegisterPspUserRequest([Required] string Token, [Required] string ProgramRepresentativeId, [Required] string BceidBusinessId)
95+
{
96+
[Required]
97+
public PspUserProfile Profile { get; set; } = null!;
98+
};
99+
100+
/// <summary>
101+
/// User profile information
102+
/// </summary>
103+
public record PspUserProfile
104+
{
105+
public string? FirstName { get; set; }
106+
public string? LastName { get; set; }
107+
public string? Email { get; set; } = null!;
108+
public bool? HasAcceptedTermsOfUse { get; set; }
109+
};
110+
111+
/// <summary>
112+
/// Error codes for PSP user registration failures
113+
/// </summary>
114+
public enum PspRegistrationError
115+
{
116+
/// <summary>The specified post-secondary institution was not found</summary>
117+
PostSecondaryInstitutionNotFound,
118+
/// <summary>The invitation token is invalid or expired</summary>
119+
PortalInvitationTokenInvalid,
120+
/// <summary>The invitation is not in correct status for registration</summary>
121+
PortalInvitationWrongStatus,
122+
/// <summary>The BCeID Business ID doesn't match expected value</summary>
123+
BceidBusinessIdDoesNotMatch,
124+
/// <summary>A generic error occurred during registration</summary>
125+
GenericError
126+
}
127+
128+
/// <summary>
129+
/// Error response for PSP user registration failures. Returns only the error code for frontend handling.
130+
/// </summary>
131+
public record PspRegistrationErrorResponse
132+
{
133+
/// <summary>
134+
/// The specific error code that occurred during registration.
135+
/// Frontend should handle localization and user messaging based on this code.
136+
/// </summary>
137+
/// <example>PostSecondaryInstitutionNotFound</example>
138+
public PspRegistrationError ErrorCode { get; set; }
139+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using AutoMapper;
2+
3+
namespace ECER.Clients.PSPPortal.Server.Users;
4+
5+
internal sealed class PspUserMapper : AutoMapper.Profile
6+
{
7+
public PspUserMapper()
8+
{
9+
CreateMap<PspUserProfile, Managers.Registry.Contract.PspUsers.PspUserProfile>()
10+
.ReverseMap()
11+
.ValidateMemberList(MemberList.Destination);
12+
}
13+
}
14+

src/ECER.Clients.PSPPortal/ECER.Clients.PSPPortal.Server/appsettings.Development.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,26 @@
1212
}
1313
},
1414
"cors": {
15-
"allowedOrigins": "https://dev.loginproxy.gov.bc.ca/;https://idtest.gov.bc.ca/"
15+
"allowedOrigins": "https://test.loginproxy.gov.bc.ca/;https://idtest.gov.bc.ca/"
1616
},
1717
"ContentSecurityPolicy": {
18-
"ConnectSource": "'self' https://dev.loginproxy.gov.bc.ca/ https://idtest.gov.bc.ca/"
18+
"ConnectSource": "'self' https://test.loginproxy.gov.bc.ca/ https://idtest.gov.bc.ca/"
1919
},
2020
"clientAuthenticationMethods": {
2121
"kc": {
22-
"authority": "https://dev.loginproxy.gov.bc.ca/auth/realms/childcare-applications",
23-
"clientId": "childcare-ecer-dev"
22+
"authority": "https://test.loginproxy.gov.bc.ca/auth/realms/childcare-applications",
23+
"clientId": "childcare-ecer-psp-test"
2424
}
2525
},
2626
"authentication": {
2727
"Schemes": {
2828
"kc": {
29-
"Authority": "https://dev.loginproxy.gov.bc.ca/auth/realms/childcare-applications",
29+
"Authority": "https://test.loginproxy.gov.bc.ca/auth/realms/childcare-applications",
3030
"ValidAudiences": [
31-
"childcare-ecer-dev"
31+
"childcare-ecer-psp-test"
3232
],
3333
"ValidIssuers": [
34-
"https://dev.loginproxy.gov.bc.ca/auth/realms/childcare-applications"
34+
"https://test.loginproxy.gov.bc.ca/auth/realms/childcare-applications"
3535
]
3636
}
3737
}

0 commit comments

Comments
 (0)