Skip to content
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
5 changes: 3 additions & 2 deletions api/Controllers/OrdersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@

namespace Scv.Api.Controllers;

// This will be replaced with another OAuth scheme so that the other team can call this API.
[Authorize(AuthenticationSchemes = "SiteMinder, OpenIdConnect", Policy = nameof(ProviderAuthorizationHandler))]

[Route("api/[controller]")]
[ApiController]
public class OrdersController(
Expand All @@ -29,6 +28,7 @@ public class OrdersController(
/// <param name="judgeId">The override judge id.</param>
/// <returns>List of orders for the judge.</returns>
[HttpGet]
[Authorize(AuthenticationSchemes = "SiteMinder, OpenIdConnect", Policy = nameof(ProviderAuthorizationHandler))]
public async Task<IActionResult> GetMyOrders(int? judgeId = null)
{
var orders = await _orderService.GetAllAsync();
Expand All @@ -41,6 +41,7 @@ public async Task<IActionResult> GetMyOrders(int? judgeId = null)
/// <param name="orderRequestDto">The Order payload (supports snake_case, PascalCase, camelCase and case-insensitive)</param>
/// <returns>Processed order</returns>
[HttpPut]
[Authorize(AuthenticationSchemes = CsoPolicies.AuthenticationScheme, Policy = CsoPolicies.RequireWriteRole)]
[ProducesResponseType(typeof(OperationResult<OrderDto>), 200)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
Expand Down
41 changes: 40 additions & 1 deletion api/Helpers/Extensions/ClaimsPrincipalExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;

namespace Scv.Api.Helpers.Extensions
{
Expand Down Expand Up @@ -43,6 +44,10 @@ public static bool IsServiceAccountUser(this ClaimsPrincipal claimsPrincipal)
=> claimsPrincipal.HasClaim(c => c.Type == CustomClaimTypes.PreferredUsername) &&
claimsPrincipal.FindFirstValue(CustomClaimTypes.PreferredUsername).Equals("service-account-scv");

public static bool IsCsoServiceAccountUser(this ClaimsPrincipal claimsPrincipal)
=> claimsPrincipal.HasClaim(c => c.Type == CustomClaimTypes.PreferredUsername) &&
claimsPrincipal.FindFirstValue(CustomClaimTypes.PreferredUsername).StartsWith("service-account-cso-jasper");

public static bool IsIdirUser(this ClaimsPrincipal claimsPrincipal)
=> claimsPrincipal.HasClaim(c => c.Type == CustomClaimTypes.PreferredUsername) &&
claimsPrincipal.FindFirstValue(CustomClaimTypes.PreferredUsername).EndsWith("@idir");
Expand Down Expand Up @@ -86,7 +91,7 @@ public static string UserType(this ClaimsPrincipal claimsPrincipal)
return IDIR;
if (claimsPrincipal.IsVcUser())
return VC;

return JUDICIARY;
}

Expand Down Expand Up @@ -175,6 +180,40 @@ public static string ProvjudUserGuid(this ClaimsPrincipal claimsPrincipal)
public static string ExternalJudgeId(this ClaimsPrincipal claimsPrincipal)
=> claimsPrincipal.FindFirstValue(CustomClaimTypes.ExternalJudgeId);

public static string[] ClientRoles(this ClaimsPrincipal claimsPrincipal, string audience)
{
if (claimsPrincipal == null || string.IsNullOrWhiteSpace(audience))
{
return Array.Empty<string>();
}

var resourceAccessClaim = claimsPrincipal.FindFirst("resource_access")?.Value;
if (string.IsNullOrWhiteSpace(resourceAccessClaim))
{
return Array.Empty<string>();
}

try
{
using var document = JsonDocument.Parse(resourceAccessClaim);
if (document.RootElement.TryGetProperty(audience, out var clientElement) &&
clientElement.TryGetProperty("roles", out var rolesElement))
{
return rolesElement
.EnumerateArray()
.Select(role => role.GetString())
.Where(role => !string.IsNullOrWhiteSpace(role))
.ToArray();
}
}
catch (JsonException)
{
return Array.Empty<string>();
}

return Array.Empty<string>();
}

// Check if any of the user's claims have meaningfully changed compared to the current user data
public static bool HasChanged(this ClaimsPrincipal claimsPrincipal, UserDto currentUser)
=> claimsPrincipal.IsActive() != currentUser.IsActive ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
using PCSSCommon.Models;
using Scv.Api.Helpers;
using Scv.Api.Helpers.Extensions;
using Scv.Api.Infrastructure.Authorization;
using Scv.Api.Infrastructure.Options;
using Scv.Api.Models.AccessControlManagement;
using Scv.Api.Services;
using System;
Expand All @@ -34,6 +36,10 @@ public static class AuthenticationServiceCollectionExtension
public static IServiceCollection AddScvAuthentication(this IServiceCollection services,
IWebHostEnvironment env, IConfiguration configuration)
{
services.AddOptions<KeycloakOptions>()
.Bind(configuration.GetSection("CsoKeycloak"))
.ValidateDataAnnotations();

services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
Expand Down Expand Up @@ -217,6 +223,25 @@ await cookieCtx.HttpContext.SignOutAsync(CookieAuthenticationDefaults
}
};
})
.AddJwtBearer(CsoPolicies.AuthenticationScheme, options =>
{
var csoOptions = new KeycloakOptions();
configuration.GetSection("CsoKeycloak").Bind(csoOptions);

options.Authority = csoOptions.Authority;
options.Audience = csoOptions.Audience;
options.RequireHttpsMetadata = csoOptions.RequireHttpsMetadata;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = csoOptions.ValidateIssuer,
ValidIssuer = csoOptions.Authority,
ValidateAudience = true,
ValidateIssuerSigningKey = csoOptions.ValidateIssuer,
ValidAudience = csoOptions.Audience,
ClockSkew = TimeSpan.FromSeconds(5)
};
})
.AddScheme<AuthenticationSchemeOptions, SiteMinderAuthenticationHandler>(
SiteMinderAuthenticationHandler.SiteMinder, null);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Scv.Api.Services;
using static Scv.Api.Infrastructure.Authorization.ProviderAuthorizationHandler;

namespace Scv.Api.Infrastructure.Authorization
Expand All @@ -10,12 +9,15 @@ public static class AuthorizationServiceCollectionExtension
public static IServiceCollection AddScvAuthorization(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler, ProviderAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CSoRoleAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, PermissionHandler>();

services.AddAuthorization(options =>
{
options.AddPolicy(nameof(ProviderAuthorizationHandler), policy =>
policy.Requirements.Add(new ProviderRequirement()));
options.AddPolicy(CsoPolicies.RequireWriteRole, policy =>
policy.Requirements.Add(new CsoRoleRequirement(CsoRoles.Write)));
});

return services;
Expand Down
107 changes: 107 additions & 0 deletions api/Infrastructure/Authorization/CsoRoleAuthorizationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Scv.Api.Helpers.Extensions;
using Scv.Api.Infrastructure.Options;
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace Scv.Api.Infrastructure.Authorization
{
/// <summary>
/// Authorization handler for CSO API roles.
/// Validates client roles from Keycloak JWT tokens.
/// </summary>
public class CSoRoleAuthorizationHandler : AuthorizationHandler<CsoRoleRequirement>
{
private readonly ILogger<CSoRoleAuthorizationHandler> _logger;
private readonly string _writeRoleName;
private readonly string _audience;

public CSoRoleAuthorizationHandler(
ILogger<CSoRoleAuthorizationHandler> logger,
IOptions<KeycloakOptions> keycloakOptions)
{
_logger = logger;
var options = keycloakOptions?.Value ?? throw new ArgumentNullException(nameof(keycloakOptions));

if (string.IsNullOrWhiteSpace(options.Audience))
{
throw new ArgumentException("CsoKeycloak:Audience must be configured.", nameof(keycloakOptions));
}

if (string.IsNullOrWhiteSpace(options.WriteRole))
{
throw new ArgumentException("CsoKeycloak:WriteRole must be configured.", nameof(keycloakOptions));
}

_audience = options.Audience;
_writeRoleName = options.WriteRole;
}

protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CsoRoleRequirement requirement)
{
if (context.User?.Identity?.IsAuthenticated != true || !context.User.IsCsoServiceAccountUser())
{
_logger.LogWarning("User is not authenticated");
context.Fail();
return Task.CompletedTask;
}

var clientRoles = context.User.ClientRoles(_audience);

if (clientRoles == null || !clientRoles.Any())
{
_logger.LogWarning(
"No client roles found for audience: {ClientId}",
_audience);
context.Fail();
return Task.CompletedTask;
}

var hasRequiredRole =
string.Equals(requirement.RequiredRole, _writeRoleName, StringComparison.OrdinalIgnoreCase) &&
clientRoles.Any(role => role.Equals(_writeRoleName, StringComparison.OrdinalIgnoreCase));

if (hasRequiredRole)
{
var username = context.User.PreferredUsername();

_logger.LogInformation(
"Authorization succeeded for user: {Username}, required role: {RequiredRole}",
username,
requirement.RequiredRole);

context.Succeed(requirement);
}
else
{
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? context.User.FindFirst("preferred_username")?.Value
?? "unknown";

_logger.LogWarning(
"Authorization failed for user: {Username}, required role: {RequiredRole}, user roles: {UserRoles}",
username,
requirement.RequiredRole,
string.Join(", ", clientRoles));

context.Fail();
}

return Task.CompletedTask;
}
}

/// <summary>
/// Authorization requirement for role-based access
/// </summary>
public class CsoRoleRequirement(string requiredRole) : IAuthorizationRequirement
{
public string RequiredRole { get; } = requiredRole;
}
}
23 changes: 23 additions & 0 deletions api/Infrastructure/Authorization/CsoRoles.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Scv.Api.Infrastructure.Authorization
{
/// <summary>
/// Defines the role names for the JASPER CSO integration.
/// These are client roles from Keycloak.
/// </summary>
public static class CsoRoles
{
/// <summary>
/// Allows write order operations from CSO. maps to the CsoAuthorization:WriteRoleName configuration option.
/// </summary>
public const string Write = "cso-order-write";
}

/// <summary>
/// Policy names for authorization. These policies determine which roles are required to access certain resources.
/// </summary>
public static class CsoPolicies
{
public const string RequireWriteRole = nameof(CSoRoleAuthorizationHandler);
public const string AuthenticationScheme = "CsoKeycloak";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using Scv.Api.Infrastructure.Authentication;
using Scv.Api.Services;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using static Scv.Api.Infrastructure.Authorization.ProviderAuthorizationHandler;

Expand Down
44 changes: 44 additions & 0 deletions api/Infrastructure/Options/KeycloakOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;

namespace Scv.Api.Infrastructure.Options
{
/// <summary>
/// Configuration options for Keycloak authentication and authorization.
/// </summary>
public sealed class KeycloakOptions
{
/// <summary>
/// Keycloak authority URL (e.g., https://keycloak.example.com/realms/your-realm)
/// </summary>
[Required]
public string Authority { get; set; } = default!;

/// <summary>
/// Expected audience in the JWT token (default: td-dev)
/// </summary>
[Required]
public string Audience { get; set; } = "jasper";

/// <summary>
/// Client ID for the service account (default: jasper-td-dev)
/// </summary>
[Required]
public string ClientId { get; set; } = "cso-jasper-dev";

/// <summary>
/// Client role name that allows query/search operations
/// </summary>
[Required]
public string WriteRole { get; set; } = "cso-order-write";

/// <summary>
/// Validate the token issuer (default: true)
/// </summary>
public bool ValidateIssuer { get; set; } = true;

/// <summary>
/// Require HTTPS metadata (default: true, set to false only for local dev)
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
}
}
8 changes: 8 additions & 0 deletions api/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
"CsoKeycloak": {
Copy link
Copy Markdown
Contributor

@ronaldo-macapobre ronaldo-macapobre Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be reflected in our [docker-compose.yaml] (https://github.com/bcgov/jasper/blob/master/docker/docker-compose.yaml) file and in Terraform too?

Copy link
Copy Markdown
Contributor Author

@devinleighsmith devinleighsmith Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missed pushing a commit, and updated.

"Authority": "https://common-sso.justice.gov.bc.ca/auth/realms/Judiciary",
"Audience": "JASPER-Dev",
"ClientId": "CSO-JASPER-Dev",
"WriteRole": "cso-order-write",
"ValidateIssuer": true,
"RequireHttpsMetadata": true
},
"DARS": {
"LogsheetUrl": "https://test.jag.gov.bc.ca/darspc/",
"URL": "https://wsgw.test.jag.gov.bc.ca/courts/DARS"
Expand Down
10 changes: 9 additions & 1 deletion api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,13 @@
"LogsheetUrl": "https://jag.gov.bc.ca/darspc/",
"LogSheetSessionCookieName": "LogSheetSessionService.Token",
"URL": "https://wsgw.jag.gov.bc.ca/courts/DARS"
},
"CsoKeycloak": {
"Authority": "https://common-sso.justice.gov.bc.ca/auth/realms/Judiciary",
"Audience": "JASPER",
"ClientId": "CSO-JASPER",
"WriteRole": "cso-order-write",
"ValidateIssuer": true,
"RequireHttpsMetadata": true
}
}
}
5 changes: 5 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ services:
- Keycloak__PresReqConfId=${KeycloakPresReqConfId}
- Keycloak__Secret=${KeycloakSecret}
- Keycloak__VcIdpHint=${KeycloakVcIdpHint}
- CsoKeycloak__Audience=${CsoKeycloakAudience}
- CsoKeycloak__Authority=${CsoKeycloakAuthority}
- CsoKeycloak__ClientId=${CsoKeycloakClient}
- CsoKeycloak__Secret=${CsoKeycloakSecret}
- CsoKeycloak__WriteRole=${CsoKeycloakWriteRole}
- LocationServicesClient__Password=${LocationServicesClientPassword}
- LocationServicesClient__Url=${LocationServicesClientUrl}
- LocationServicesClient__Username=${LocationServicesClientUsername}
Expand Down
Loading
Loading