Skip to content

JSON Web Tokens

Paulo Gomes da Cruz Junior edited this page Jun 16, 2026 · 3 revisions

JSON Web Tokens (JWT) Authentication & Authorization

📌 Overview

This document provides a comprehensive guide to the JSON Web Token (JWT) authentication and authorization system implemented in the NR Waste Plus backend. The system leverages Spring Security's OAuth2 resource server capabilities with custom extensions to support role-based access control (RBAC), identity provider validation, and fine-grained authorization rules.

The implementation goes beyond Spring's default JWT handling to support:

  • Custom JWT Claims: Processing of cognito:groups claims instead of standard authorities
  • Abstract Role Mapping: Dynamic role composition based on client identifiers (e.g., PLANNER_12345)
  • Identity Provider Validation: Enforcement of access rules based on user identity providers (BCeID, IDIR, etc.)
  • Request-Level Authorization: Custom authorization managers for granular endpoint-level security

🏗️ Architecture Overview

The JWT authentication system is built on several foundational Spring Security components:

┌─────────────────────────────────────────────────────────────────┐
│                      Spring Security Filter Chain               │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│           BearerTokenAuthenticationFilter (OAuth2)              │
│  ◄─ Extracts JWT from Authorization: Bearer <token> header      │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│        Oauth2SecurityCustomizer Configuration                   │
│  ◄─ Configures JwtAuthenticationConverter & JWK Set URI         │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│      JwtAuthenticationConverter & Authority Processing          │
│  ◄─ Extracts cognito:groups from JWT claims                     │
│  ◄─ Converts groups to GrantedAuthority instances               │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│     JwtRoleChecker & JwtRoleAuthorizationManagerFactory         │
│  ◄─ Performs runtime authorization checks                       │
│  ◄─ Evaluates abstract roles, concrete roles, and IDP           │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│              ApiAuthorizationCustomizer                         │
│  ◄─ Applies endpoint-level authorization rules                  │
└─────────────────────────────────────────────────────────────────┘

🔑 Core Components

1. SecurityConfiguration

Location: ca.bc.gov.nrs.hrs.configuration.SecurityConfiguration

Purpose: The primary configuration class that assembles the complete Spring Security filter chain.

Key Responsibilities:

  • Registers the primary SecurityFilterChain bean
  • Composes multiple security customizers in a logical order
  • Enables method-level security via @EnableMethodSecurity

Why Custom Configuration: Spring's default configuration doesn't natively support:

  • Custom JWT claim extraction from cognito:groups
  • Abstract role composition (role + client ID)
  • Identity provider-based access control
  • Cross-service JWT forwarding

By creating a modular configuration with composable customizers, we gain:

  • Flexibility: Each security concern is isolated in its own customizer
  • Testability: Individual components can be tested independently
  • Maintainability: Changes to one aspect don't affect others
  • Reusability: Customizers can be applied to different filter chains

Reference: Spring Security HTTP Security Builders

2. Oauth2SecurityCustomizer

Location: ca.bc.gov.nrs.hrs.security.Oauth2SecurityCustomizer

Purpose: Configures OAuth2 resource server support and custom JWT processing.

Implementation Details:

@Component
public class Oauth2SecurityCustomizer implements
    Customizer<OAuth2ResourceServerConfigurer<HttpSecurity>> {

  @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
  String jwkSetUri;

  @Override
  public void customize(OAuth2ResourceServerConfigurer<HttpSecurity> customize) {
    customize.jwt(jwt -> jwt.jwtAuthenticationConverter(converter()).jwkSetUri(jwkSetUri));
  }

  private Converter<Jwt, AbstractAuthenticationToken> converter() {
    JwtGrantedAuthoritiesConverter authConverter = new JwtGrantedAuthoritiesConverter();
    authConverter.setAuthoritiesClaimName("cognito:groups");
    authConverter.setAuthorityPrefix("");

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authConverter);
    return converter;
  }
}

Why It's Customized:

Spring's default JwtAuthenticationConverter looks for authorities in the scope or scp claim. However, AWS Cognito (our identity provider) places group information in the cognito:groups claim. This customizer:

  1. Redirects Authority Extraction: Tells Spring to extract authorities from cognito:groups instead of the default claim
  2. Removes Authority Prefix: By default, Spring prefixes authorities with "SCOPE_". We disable this since our groups are already properly formatted (e.g., PLANNER, VIEWER)
  3. Configures JWK Set URI: Points to the JWKS endpoint for JWT signature validation

Reference:

3. JwtRoleChecker

Location: ca.bc.gov.nrs.hrs.security.JwtRoleChecker

Purpose: Runtime role checking utility used in @PreAuthorize annotations and custom authorization logic.

Key Methods:

hasRole(String role) - Flexible Role Matching

Checks for both concrete and abstract role variants:

public boolean hasRole(String role) {
    return hasRoleMatching(
        currentRole -> currentRole.equalsIgnoreCase(role)
                       || currentRole.startsWith((role + "_").toUpperCase(Locale.ROOT))
    );
}

Example:

  • User has role: PLANNER_12345
  • Query: hasRole("PLANNER")
  • Result: true (matches both the prefix and abstract variant)

Use Cases:

  • General role checking where both generic and client-specific variants are acceptable
  • Backwards compatibility with code expecting concrete roles

hasConcreteRole(String role) - Exact Matching

Checks for exact role match only:

public boolean hasConcreteRole(String role) {
    return hasRoleMatching(currentRole -> currentRole.equalsIgnoreCase(role));
}

Example:

  • User has role: VIEWER
  • Query: hasConcreteRole("VIEWER")
  • Result: true

Use Cases:

  • Global administrator roles (e.g., ADMIN)
  • System-wide permissions that don't require client-specific variants
  • Strict access control scenarios

hasAbstractRole(String rolePrefix, String clientId) - Client-Specific Roles

Checks for a dynamically composed abstract role:

public boolean hasAbstractRole(String rolePrefix, String clientId) {
    return hasRoleMatching(currentRole ->
        currentRole.equalsIgnoreCase(rolePrefix + "_" + clientId)
    );
}

Example:

  • User has role: PLANNER_12345
  • Query: hasAbstractRole("PLANNER", "12345")
  • Result: true

Use Cases:

  • Multi-tenant scenarios where users have roles scoped to specific clients
  • Resource-based access control (e.g., "Is this user a planner for this specific forest client?")
  • Dynamic role composition based on request parameters

hasRoleMatching(Predicate<String> matcher) - Custom Predicate Matching

Generic role checking using arbitrary predicates:

public boolean hasRoleMatching(Predicate<String> matcher) {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null || !authentication.isAuthenticated()) {
        return false;
    }
    return authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .map(String::toUpperCase)
        .anyMatch(matcher);
}

Use Cases:

  • Pattern-based role checking (starts with, contains, regex matching)
  • Complex authorization logic
  • Future-proof checking when role names follow conventions

hasIdpProvider(IdentityProvider provider) - Identity Provider Validation

Checks if the authenticated user's identity provider matches:

public boolean hasIdpProvider(IdentityProvider identityProvider) {
    return JwtPrincipalUtil
        .getIdentityProvider(getJwt())
        .equals(identityProvider);
}

Example:

  • User authenticated via BCeID
  • Query: hasIdpProvider(IdentityProvider.BUSINESS_BCEID)
  • Result: true

Use Cases:

  • Restricting endpoints to specific identity providers (e.g., only IDIR users can access admin features)
  • External partner access control
  • Compliance with organizational policies

Reference: Spring Security Method Security

4. JwtRoleAuthorizationManagerFactory

Location: ca.bc.gov.nrs.hrs.security.JwtRoleAuthorizationManagerFactory

Purpose: Factory for creating AuthorizationManager instances that bridge JwtRoleChecker with Spring Security's request-level authorization framework.

Why It's Needed:

Spring Security distinguishes between:

  • Method-level authorization: @PreAuthorize, @PostAuthorize (handled via SpEL)
  • Request-level authorization: Endpoint-level rules in SecurityFilterChain

The factory translates JwtRoleChecker logic into AuthorizationManager instances suitable for request-level authorization:

.authorizeHttpRequests(authorize ->
    authorize
        .requestMatchers("/api/planner/**")
        .access(roleFactory.gotRole("PLANNER"))  // ◄─ Factory creates this
)

Key Factory Methods:

// Arbitrary predicate-based checking
AuthorizationManager<RequestAuthorizationContext> gotRoleMatching(Predicate<String> matcher)

// Concrete role checking
AuthorizationManager<RequestAuthorizationContext> gotRole(String role)

// Abstract role checking with dynamic client ID extraction
AuthorizationManager<RequestAuthorizationContext> gotAbstractRole(
    String rolePrefix, 
    Function<HttpServletRequest, String> clientIdExtractor)

// Identity provider checking (both string and enum variants)
AuthorizationManager<RequestAuthorizationContext> gotIdp(String provider)
AuthorizationManager<RequestAuthorizationContext> gotIdp(IdentityProvider provider)

Reference: Spring Security Authorization Architecture

5. ApiAuthorizationCustomizer

Location: ca.bc.gov.nrs.hrs.security.ApiAuthorizationCustomizer

Purpose: Applies route-level authorization rules to HTTP endpoints.

Current Authorization Rules:

authorize
    // Actuator endpoints - no auth required
    .requestMatchers(HttpMethod.GET, "/metrics", "/health")
    .permitAll()

    // OPTIONS requests require authentication (CORS preflight)
    .requestMatchers(HttpMethod.OPTIONS, "/**")
    .authenticated()

    // User preferences - authenticated users only
    .requestMatchers("/api/users/preferences")
    .authenticated()

    // Codes endpoint - authenticated users only
    .requestMatchers("/api/codes/**")
    .authenticated()

    // Forest clients - BCeID users only
    .requestMatchers("/api/forest-clients/clients")
    .access(roleCheck.gotIdp(IdentityProvider.BUSINESS_BCEID))

    // Forest clients - authenticated users only
    .requestMatchers("/api/forest-clients/**")
    .authenticated()

    // Search endpoints - authenticated users only
    .requestMatchers("/api/search/**")
    .authenticated()

Why Custom Configuration:

Default Spring Security requires all endpoints to be explicitly configured. By creating a customizer, we:

  • Keep authorization rules centralized and maintainable
  • Support both simple (authenticated()) and complex (access() with custom managers) rules
  • Enable easy auditing of endpoint security

Reference: Spring Security Authorize HTTP Requests

🔐 Using JWT Security in Your Code

Method-Level Authorization with @PreAuthorize

Method-level security is enforced at the method invocation level and is perfect for protecting business logic methods. The @PreAuthorize annotation uses Spring Expression Language (SpEL) to evaluate authorization rules.

Available SpEL Beans:

  • @auth → JwtRoleChecker instance
  • @roles → Enum of role constants

Pattern 1: Checking Concrete Roles

Use when you need to verify an exact role match:

@PreAuthorize("@auth.hasConcreteRole(@roles.VIEWER)")
public ResponseEntity<?> getDataList() {
    return ResponseEntity.ok("Data for VIEWER role users");
}

When to use:

  • Global/system-wide permissions
  • Roles that don't have client-specific variants
  • Strict access control for sensitive operations

Spring Documentation: SpEL for Authorization


Pattern 2: Checking Abstract Roles with Dynamic Client ID

Use when you need to verify a role that is scoped to a specific client. The client ID is typically extracted from a path variable:

@PreAuthorize("@auth.hasAbstractRole(@roles.PLANNER, #clientNumber)")
public ResponseEntity<?> getDataByClient(@PathVariable String clientNumber) {
    return ResponseEntity.ok("Access granted for client " + clientNumber);
}

Flow:

  1. User makes request to /api/forest-clients/12345/planning
  2. Spring extracts clientNumber = "12345" from path variable
  3. JwtRoleChecker checks if user has role PLANNER_12345
  4. If user's JWT contains PLANNER_12345 in cognito:groups, access is granted

When to use:

  • Multi-tenant applications
  • Client-specific permissions
  • Resource-based access control
  • Most common pattern in forest client management

Example Scenario:

@GetMapping("/api/forest-clients/{clientNumber}/planning-data")
@PreAuthorize("@auth.hasAbstractRole(@roles.PLANNER, #clientNumber)")
public ResponseEntity<?> getPlanningData(@PathVariable String clientNumber) {
    // Only users with PLANNER_<clientNumber> role can access
    return ResponseEntity.ok(planningService.getData(clientNumber));
}

Pattern 3: Using Custom Role Matchers

Use when you need complex role-matching logic, such as pattern-based checks:

@PreAuthorize("@auth.hasRoleMatching(role -> role.startsWith('APPROVER_'))")
public ResponseEntity<?> getApprovalQueue() {
    return ResponseEntity.ok("Approval queue data");
}

Advanced Examples:

// Check for any client-scoped planner role
@PreAuthorize("@auth.hasRoleMatching(role -> role.matches('^PLANNER_\\d+$'))")
public ResponseEntity<?> getPlannerDashboard() {
    return ResponseEntity.ok("All clients where user is a planner");
}

// Check for multiple role prefixes
@PreAuthorize("@auth.hasRoleMatching(role -> " +
    "role.startsWith('ADMIN_') || role.startsWith('SUPER_'))")
public ResponseEntity<?> getAdminPanel() {
    return ResponseEntity.ok("Admin functionality");
}

// Check for roles containing specific pattern
@PreAuthorize("@auth.hasRoleMatching(role -> role.contains('REVIEWER'))")
public ResponseEntity<?> getReviewTasks() {
    return ResponseEntity.ok("Tasks requiring review");
}

When to use:

  • Complex authorization logic
  • Bulk role checking across multiple role families
  • Future-proof checking when role naming conventions are stable

Benefits:

  • Eliminates need for multiple @PreAuthorize annotations
  • Reduces code duplication
  • Makes business logic clearer

Pattern 4: Identity Provider-Based Access Control

Use when you need to restrict access based on the user's identity provider:

@PreAuthorize("@auth.hasIdpProvider(@providers.BUSINESS_BCEID)")
public ResponseEntity<?> registerNewBusiness() {
    return ResponseEntity.ok("Business registration endpoint");
}

Use Cases:

  • Restricting external business operations to BCeID users
  • Limiting internal administrative tasks to IDIR users
  • Compliance-based access control

Example Scenario:

@PostMapping("/api/forest-clients")
@PreAuthorize("@auth.hasIdpProvider(@providers.BUSINESS_BCEID)")
public ResponseEntity<?> createForestClient(@RequestBody ClientRequest request) {
    // Only BCeID-authenticated users can create clients
    return ResponseEntity.ok(clientService.create(request));
}

Request-Level Authorization in SecurityFilterChain

Request-level authorization is evaluated early in the filter chain and is best for endpoint-level rules. This approach integrates with the security configuration and provides consistent enforcement across all access paths.

Example: Endpoint Authorization Configuration

@Component
public class ApiAuthorizationCustomizer implements
    Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>
        .AuthorizationManagerRequestMatcherRegistry> {

    private final JwtRoleAuthorizationManagerFactory roleCheck;

    @Override
    public void customize(
        AuthorizeHttpRequestsConfigurer<HttpSecurity>
            .AuthorizationManagerRequestMatcherRegistry authorize) {
        
        authorize
            // Forest clients accessible by BCeID users only
            .requestMatchers("/api/forest-clients/clients")
            .access(roleCheck.gotIdp(IdentityProvider.BUSINESS_BCEID))
            
            // Planning endpoints accessible by users with PLANNER role
            .requestMatchers("/api/planning/**")
            .access(roleCheck.gotRole("PLANNER"))
            
            // Generic admin endpoints
            .requestMatchers("/api/admin/**")
            .access(roleCheck.gotRole("ADMIN"))
            
            // Everything else requires authentication
            .anyRequest()
            .authenticated();
    }
}

Advantages:

  • Early filtering (before business logic execution)
  • Centralized security policy
  • Consistent rule application
  • Better performance for denied requests

Reference: Spring Security Request Authorization


🧩 Complete Code Examples

Scenario 1: Multi-Tenant Forest Client Management

@RestController
@RequestMapping("/api/forest-clients")
@RequiredArgsConstructor
public class ForestClientController {

    private final ForestClientService clientService;

    /**
     * Retrieve forest client details.
     * Accessible to authenticated users (any role, any IDP).
     */
    @GetMapping("/{clientNumber}")
    @PreAuthorize("authenticated")
    public ResponseEntity<ClientDTO> getClient(@PathVariable String clientNumber) {
        ClientDTO client = clientService.findByNumber(clientNumber);
        return ResponseEntity.ok(client);
    }

    /**
     * Get planning data for a specific client.
     * Only PLANNER_<clientNumber> role holders can access this.
     */
    @GetMapping("/{clientNumber}/planning")
    @PreAuthorize("@auth.hasAbstractRole(@roles.PLANNER, #clientNumber)")
    public ResponseEntity<PlanningDataDTO> getPlanningData(
            @PathVariable String clientNumber) {
        PlanningDataDTO data = clientService.getPlanningData(clientNumber);
        return ResponseEntity.ok(data);
    }

    /**
     * Update planning data for a client.
     * Requires both PLANNER role for the client AND BCeID identity.
     */
    @PutMapping("/{clientNumber}/planning")
    @PreAuthorize("@auth.hasAbstractRole(@roles.PLANNER, #clientNumber) AND " +
                  "@auth.hasIdpProvider(@providers.BUSINESS_BCEID)")
    public ResponseEntity<PlanningDataDTO> updatePlanningData(
            @PathVariable String clientNumber,
            @RequestBody UpdatePlanningRequest request) {
        PlanningDataDTO updated = clientService.updatePlanningData(clientNumber, request);
        return ResponseEntity.ok(updated);
    }

    /**
     * Approve a plan for a client.
     * Only users with APPROVER_<clientNumber> role can approve.
     */
    @PostMapping("/{clientNumber}/planning/approve")
    @PreAuthorize("@auth.hasAbstractRole(@roles.APPROVER, #clientNumber)")
    public ResponseEntity<ApprovalResultDTO> approvePlan(
            @PathVariable String clientNumber,
            @RequestBody ApprovalRequest request) {
        ApprovalResultDTO result = clientService.approvePlan(clientNumber, request);
        return ResponseEntity.ok(result);
    }

    /**
     * Create a new forest client.
     * Only BCeID business users can create new clients.
     */
    @PostMapping
    @PreAuthorize("@auth.hasIdpProvider(@providers.BUSINESS_BCEID)")
    public ResponseEntity<ClientDTO> createClient(@RequestBody CreateClientRequest request) {
        ClientDTO created = clientService.create(request);
        return ResponseEntity.created(URI.create("/api/forest-clients/" + created.getNumber()))
                .body(created);
    }

    /**
     * List all clients where user has any role.
     * Uses role matching to find all PLANNER_* or APPROVER_* roles.
     */
    @GetMapping("/my-clients")
    @PreAuthorize("@auth.hasRoleMatching(role -> " +
                  "role.startsWith('PLANNER_') || role.startsWith('APPROVER_'))")
    public ResponseEntity<List<ClientSummaryDTO>> getMyClients() {
        List<ClientSummaryDTO> clients = clientService.getUserClients();
        return ResponseEntity.ok(clients);
    }
}

Scenario 2: Administrative Operations with Concrete Roles

@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {

    private final AdminService adminService;

    /**
     * System administration dashboard.
     * Only users with exact ADMIN role can access.
     */
    @GetMapping("/dashboard")
    @PreAuthorize("@auth.hasConcreteRole(@roles.ADMIN)")
    public ResponseEntity<AdminDashboardDTO> getDashboard() {
        AdminDashboardDTO dashboard = adminService.buildDashboard();
        return ResponseEntity.ok(dashboard);
    }

    /**
     * Grant roles to users.
     * System-wide admin operation.
     */
    @PostMapping("/users/{userId}/roles")
    @PreAuthorize("@auth.hasConcreteRole(@roles.ADMIN)")
    public ResponseEntity<UserDTO> grantRole(
            @PathVariable String userId,
            @RequestBody GrantRoleRequest request) {
        UserDTO user = adminService.grantRole(userId, request.getRole());
        return ResponseEntity.ok(user);
    }

    /**
     * Revoke roles from users.
     */
    @DeleteMapping("/users/{userId}/roles/{roleId}")
    @PreAuthorize("@auth.hasConcreteRole(@roles.ADMIN)")
    public ResponseEntity<Void> revokeRole(
            @PathVariable String userId,
            @PathVariable String roleId) {
        adminService.revokeRole(userId, roleId);
        return ResponseEntity.noContent().build();
    }

    /**
     * System configuration endpoint.
     */
    @PutMapping("/config")
    @PreAuthorize("@auth.hasConcreteRole(@roles.ADMIN)")
    public ResponseEntity<SystemConfigDTO> updateConfig(
            @RequestBody UpdateConfigRequest request) {
        SystemConfigDTO config = adminService.updateConfig(request);
        return ResponseEntity.ok(config);
    }
}

Scenario 3: Complex Authorization with Role Matching

@RestController
@RequestMapping("/api/compliance")
@RequiredArgsConstructor
public class ComplianceController {

    private final ComplianceService complianceService;

    /**
     * Compliance audit trail endpoint.
     * Accessible to any auditor or reviewer role.
     */
    @GetMapping("/audit-trail")
    @PreAuthorize("@auth.hasRoleMatching(role -> " +
                  "role.startsWith('AUDITOR') || role.startsWith('REVIEWER'))")
    public ResponseEntity<List<AuditLogDTO>> getAuditTrail() {
        List<AuditLogDTO> logs = complianceService.getAuditTrail();
        return ResponseEntity.ok(logs);
    }

    /**
     * Generate compliance report.
     * Accessible to power users (multiple role variants).
     */
    @PostMapping("/reports")
    @PreAuthorize("@auth.hasRoleMatching(role -> " +
                  "role.equals('ADMIN') || role.startsWith('COMPLIANCE_OFFICER'))")
    public ResponseEntity<ComplianceReportDTO> generateReport(
            @RequestBody ReportRequest request) {
        ComplianceReportDTO report = complianceService.generateReport(request);
        return ResponseEntity.ok(report);
    }

    /**
     * View compliance status for a specific client.
     * Combines abstract role check with custom matcher.
     */
    @GetMapping("/{clientNumber}/status")
    @PreAuthorize("@auth.hasAbstractRole(@roles.PLANNER, #clientNumber) OR " +
                  "@auth.hasRoleMatching(role -> role.startsWith('COMPLIANCE_'))")
    public ResponseEntity<ComplianceStatusDTO> getComplianceStatus(
            @PathVariable String clientNumber) {
        ComplianceStatusDTO status = complianceService.getStatus(clientNumber);
        return ResponseEntity.ok(status);
    }
}

Scenario 4: Identity Provider-Based Access Control

@RestController
@RequestMapping("/api/public")
@RequiredArgsConstructor
public class PublicIntegrationController {

    private final IntegrationService integrationService;

    /**
     * Business partner integration endpoint.
     * Restricted to BCeID business users.
     * Allows external businesses to interact with the system.
     */
    @PostMapping("/partner/submit-data")
    @PreAuthorize("@auth.hasIdpProvider(@providers.BUSINESS_BCEID)")
    public ResponseEntity<SubmissionDTO> submitPartnerData(
            @RequestBody PartnerDataRequest request) {
        SubmissionDTO submission = integrationService.processPartnerSubmission(request);
        return ResponseEntity.ok(submission);
    }

    /**
     * Internal administration endpoint.
     * Restricted to IDIR users only (government employees).
     */
    @GetMapping("/internal/statistics")
    @PreAuthorize("@auth.hasIdpProvider(@providers.IDIR)")
    public ResponseEntity<SystemStatisticsDTO> getInternalStatistics() {
        SystemStatisticsDTO stats = integrationService.getStatistics();
        return ResponseEntity.ok(stats);
    }

    /**
     * Service account operations.
     * Restricted to service account identity provider.
     */
    @PostMapping("/service/process-batch")
    @PreAuthorize("@auth.hasIdpProvider(@providers.SERVICE_ACCOUNT)")
    public ResponseEntity<BatchProcessingResultDTO> processBatch(
            @RequestBody BatchProcessRequest request) {
        BatchProcessingResultDTO result = integrationService.processBatch(request);
        return ResponseEntity.ok(result);
    }

    /**
     * Combine role and IDP checks.
     * Only IDIR auditors can generate internal compliance reports.
     */
    @PostMapping("/internal/audit-report")
    @PreAuthorize("@auth.hasIdpProvider(@providers.IDIR) AND " +
                  "@auth.hasConcreteRole(@roles.AUDITOR)")
    public ResponseEntity<AuditReportDTO> generateAuditReport(
            @RequestBody AuditReportRequest request) {
        AuditReportDTO report = integrationService.generateAuditReport(request);
        return ResponseEntity.ok(report);
    }
}

📊 JWT Claims Structure

The JWT tokens issued by the identity provider contain the following claims used by the system:

{
  "sub": "user-id",
  "aud": "application-client-id",
  "iss": "https://cognito-idp.region.amazonaws.com/user-pool-id",
  "cognito:groups": [
    "PLANNER_12345",
    "VIEWER",
    "APPROVER_67890"
  ],
  "custom:idp_name": "BUSINESS_BCEID",
  "custom:idp_username": "username@business.ca",
  "custom:idp_business_id": "BC0123456789",
  "custom:idp_business_name": "Business Name Ltd.",
  "email": "user@business.ca",
  "iat": 1609459200,
  "exp": 1609545600
}

Key Claims:

  • cognito:groups: Array of roles/groups the user belongs to. This is the source of authorization data.
  • custom:idp_name: Identity provider name (extracted and normalized by JwtPrincipalUtil)
  • custom:idp_username: User's username in the identity provider
  • custom:idp_business_id: Business identifier (for BCeID users)
  • custom:idp_business_name: Business name (for BCeID users)

🔄 How JWT Authentication Flow Works

sequenceDiagram
    participant Client
    participant Frontend
    participant SecurityFilter
    participant OAuth2Filter
    participant JwtConverter
    participant RoleChecker
    participant Controller

    Client->>Frontend: Login request
    Frontend->>Client: Issue JWT (with cognito:groups)
    
    Client->>SecurityFilter: HTTP request with Authorization: Bearer <JWT>
    SecurityFilter->>OAuth2Filter: Extract bearer token
    
    OAuth2Filter->>JwtConverter: Validate JWT signature & decode
    JwtConverter->>JwtConverter: Extract cognito:groups claim
    JwtConverter->>JwtConverter: Convert groups to GrantedAuthority
    
    OAuth2Filter->>SecurityFilter: Set JwtAuthenticationToken
    SecurityFilter->>SecurityFilter: Store in SecurityContext
    
    SecurityFilter->>Controller: Forward request
    Controller->>RoleChecker: Evaluate @PreAuthorize
    RoleChecker->>RoleChecker: Check roles from SecurityContext
    RoleChecker->>Controller: Authorization decision
    
    Controller-->>Client: Response
Loading

🔗 Related Documentation

🚀 Best Practices

1. Use Appropriate Authorization Level

  • Request-level: For coarse-grained endpoint protection
  • Method-level: For fine-grained business logic protection
  • Combine both: For defense-in-depth security

2. Choose the Right Role Checking Method

  • hasConcreteRole(): For system-wide permissions
  • hasAbstractRole(): For client-scoped multi-tenant access
  • hasRoleMatching(): For complex pattern matching
  • hasIdpProvider(): For external/internal boundary enforcement

3. Test Authorization Rules

@SpringBootTest
@WithMockJwt(authorities = {"PLANNER_12345"})
void testPlannerAccess() {
    // Test will include role in mock JWT
}

4. Document Security Requirements

  • Add JavaDoc comments explaining authorization rules
  • Include examples in API documentation
  • Document role names and their purposes

5. Monitor and Audit

  • Log authorization failures for debugging
  • Track role assignments and changes
  • Regularly review who has which roles

📝 FAQ

Q: Why do we need both hasRole() and hasConcreteRole()? A: hasRole() provides flexibility for both generic and client-specific roles, while hasConcreteRole() ensures exact matches for system-wide permissions.

Q: Can I combine multiple @PreAuthorize conditions? A: Yes, use SpEL operators: @auth.hasRole('PLANNER') AND @auth.hasIdpProvider('IDIR') or use || for OR logic.

Q: How are role changes reflected in existing sessions? A: JWT tokens are immutable. Role changes require token re-issuance. Implement token refresh to apply changes.

Q: What happens if JWT validation fails? A: Spring Security rejects the request with a 401 Unauthorized or 403 Forbidden response.

Q: Can I extract custom claims from JWT in my code? A: Yes, inject the JWT authentication and call getToken().getClaim("claim-name").

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally