Skip to content

Commit 11c20f8

Browse files
committed
feat(backend): implement step 08 - private endpoint and JWT validation
1 parent 9c12a47 commit 11c20f8

File tree

21 files changed

+480
-33
lines changed

21 files changed

+480
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- **Step 05** – Implement client-side caching for public health endpoint using `sessionStorage` to display stale data when the backend is unavailable. Added a 'stale data' badge to the UI.
1717
- **Step 06** – Added OIDC configuration skeleton (using standard scopes), environment variable setup, and updated gitignore for `.env` files. Updated subsequent step plans (7, 8) to remove custom scope references.
1818
- **Step 07** – Implemented frontend login/logout flow using oidc-client-ts for PKCE. Added tests for Header, AuthCallback, AuthProvider, and AuthService.
19+
- **Step 08** – Added private endpoint and JWT validation.
1920

2021
### Fixed
2122

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,21 @@ Example response:
159159
{
160160
"message": "Service up"
161161
}
162+
163+
### Testing Private Endpoint
164+
165+
To test the private endpoint, you need a valid JWT access token obtained from your OIDC provider (e.g., Zitadel). This token must contain the necessary claims or roles (like `AUTH_USER`) that the backend expects.
166+
167+
```bash
168+
# Replace <your_jwt_token> with a valid access token
169+
curl -H "Authorization: Bearer <your_jwt_token>" http://localhost:8080/api/v1/private/info
170+
```
171+
172+
Example response (if authorized):
173+
```json
174+
{
175+
"info": "This is private information for authenticated users."
176+
}
177+
```
178+
179+
You can obtain a test token from the Zitadel console or by using an OIDC client tool after authenticating.

backend/pom.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,22 @@
4242
<version>2.4.0</version>
4343
</dependency>
4444

45+
<dependency>
46+
<groupId>org.springframework.boot</groupId>
47+
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
48+
</dependency>
49+
4550
<dependency>
4651
<groupId>org.springframework.boot</groupId>
4752
<artifactId>spring-boot-starter-test</artifactId>
4853
<scope>test</scope>
4954
</dependency>
55+
56+
<dependency>
57+
<groupId>org.springframework.security</groupId>
58+
<artifactId>spring-security-test</artifactId>
59+
<scope>test</scope>
60+
</dependency>
5061
</dependencies>
5162

5263
<build>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package ai.bluefields.oidcauthdemo.config; // Correct package
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.security.config.Customizer;
6+
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
7+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
8+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
9+
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
10+
import org.springframework.security.config.http.SessionCreationPolicy;
11+
import org.springframework.security.web.SecurityFilterChain;
12+
13+
/**
14+
* Configures Spring Security settings for the application, including JWT validation, authorization
15+
* rules, and CSRF protection. Enables method-level security checks using {@link
16+
* EnableMethodSecurity}.
17+
*/
18+
@Configuration
19+
@EnableWebSecurity
20+
@EnableMethodSecurity // Enables @PreAuthorize, @PostAuthorize, etc.
21+
public class SecurityConfig {
22+
23+
/**
24+
* Defines the main security filter chain for the application.
25+
*
26+
* <p>Configuration details:
27+
*
28+
* <ul>
29+
* <li>Disables CSRF protection as the API is stateless and relies on JWTs.
30+
* <li>Configures authorization rules:
31+
* <ul>
32+
* <li>Permits access to public API endpoints (`/api/v1/public/**`).
33+
* <li>Permits access to OpenAPI/Swagger UI endpoints.
34+
* <li>Requires authentication for private API endpoints (`/api/v1/private/**`).
35+
* <li>Requires authentication for any other request not explicitly matched.
36+
* </ul>
37+
* <li>Enables OAuth 2.0 Resource Server support with JWT validation using default settings.
38+
* <li>Sets session management to STATELESS, as JWTs handle session state.
39+
* </ul>
40+
*
41+
* @param http The {@link HttpSecurity} to configure.
42+
* @return The configured {@link SecurityFilterChain}.
43+
* @throws Exception If an error occurs during configuration.
44+
*/
45+
@Bean
46+
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
47+
http.csrf(AbstractHttpConfigurer::disable) // Disable CSRF for stateless API
48+
.httpBasic(AbstractHttpConfigurer::disable) // Disable HTTP Basic Auth
49+
.authorizeHttpRequests(
50+
authz ->
51+
authz
52+
.requestMatchers(
53+
"/api/v1/public/**", // Public endpoints
54+
"/v3/api-docs/**", // OpenAPI spec
55+
"/swagger-ui/**", // Swagger UI webjar
56+
"/swagger-ui.html", // Swagger UI entry point
57+
"/error" // Permit default error handling path
58+
)
59+
.permitAll()
60+
.requestMatchers("/api/v1/private/**")
61+
.authenticated() // Require authentication for private endpoints
62+
.anyRequest()
63+
.authenticated() // Default deny: require auth for anything else
64+
)
65+
.oauth2ResourceServer(
66+
oauth2 -> oauth2.jwt(Customizer.withDefaults())) // Enable JWT resource server
67+
.sessionManagement(
68+
session ->
69+
session.sessionCreationPolicy(
70+
SessionCreationPolicy.STATELESS)); // Stateless sessions
71+
72+
return http.build();
73+
}
74+
}

backend/src/main/java/ai/bluefields/oidcauthdemo/HealthController.java renamed to backend/src/main/java/ai/bluefields/oidcauthdemo/controller/HealthController.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
package ai.bluefields.oidcauthdemo;
1+
package ai.bluefields.oidcauthdemo.controller;
22

3-
import ai.bluefields.oidcauthdemo.health.HealthResponse;
4-
import ai.bluefields.oidcauthdemo.health.HealthService;
3+
import ai.bluefields.oidcauthdemo.dto.HealthResponse; // Updated import
4+
import ai.bluefields.oidcauthdemo.service.HealthService;
55
import io.swagger.v3.oas.annotations.Operation;
66
import org.springframework.web.bind.annotation.GetMapping;
77
import org.springframework.web.bind.annotation.RequestMapping;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package ai.bluefields.oidcauthdemo.controller; // Updated package
2+
3+
import ai.bluefields.oidcauthdemo.dto.PrivateInfoResponse;
4+
import ai.bluefields.oidcauthdemo.service.PrivateInfoService;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
7+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import org.springframework.security.access.prepost.PreAuthorize;
10+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
11+
import org.springframework.security.oauth2.jwt.Jwt;
12+
import org.springframework.web.bind.annotation.GetMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RestController;
15+
16+
/**
17+
* Controller handling requests for private information, requiring authentication and authorization.
18+
*/
19+
@RestController
20+
@RequestMapping("/api/v1/private")
21+
@Tag(name = "Private Info API", description = "Endpoints requiring authentication")
22+
@SecurityRequirement(name = "bearerAuth") // Link to security scheme defined in OpenAPI config
23+
public class PrivateInfoController {
24+
25+
private final PrivateInfoService privateInfoService;
26+
27+
/**
28+
* Constructs the controller with the necessary service dependency.
29+
*
30+
* @param privateInfoService The service used to retrieve private information.
31+
*/
32+
public PrivateInfoController(PrivateInfoService privateInfoService) {
33+
this.privateInfoService = privateInfoService;
34+
}
35+
36+
/**
37+
* Retrieves private information for the authenticated user. Requires the user to have the
38+
* 'ROLE_AUTH_USER' authority.
39+
*
40+
* @param jwt The JWT representing the authenticated user, injected by Spring Security.
41+
* @return A {@link PrivateInfoResponse} containing a message and the user's email.
42+
*/
43+
@GetMapping("/info")
44+
@PreAuthorize("hasAuthority('ROLE_AUTH_USER')")
45+
@Operation(
46+
summary = "Get Private Information",
47+
description =
48+
"Returns a simple message and the authenticated user's email. Requires ROLE_AUTH_USER.",
49+
responses = {
50+
@ApiResponse(responseCode = "200", description = "Successfully retrieved private info"),
51+
@ApiResponse(
52+
responseCode = "401",
53+
description = "Unauthorized - JWT token missing or invalid"),
54+
@ApiResponse(
55+
responseCode = "403",
56+
description = "Forbidden - User lacks ROLE_AUTH_USER authority")
57+
})
58+
public PrivateInfoResponse getPrivateInfo(@AuthenticationPrincipal Jwt jwt) {
59+
return privateInfoService.getInfo(jwt);
60+
}
61+
}

backend/src/main/java/ai/bluefields/oidcauthdemo/health/HealthResponse.java renamed to backend/src/main/java/ai/bluefields/oidcauthdemo/dto/HealthResponse.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
package ai.bluefields.oidcauthdemo.health;
1+
package ai.bluefields.oidcauthdemo.dto; // Updated package
22

33
/**
44
* Record representing the health check response. Contains a message indicating the service status.
5+
* Data Transfer Object (DTO).
56
*/
67
public record HealthResponse(String message) {}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package ai.bluefields.oidcauthdemo.dto;
2+
3+
/**
4+
* Represents the response containing private information accessible only to authenticated users.
5+
* Data Transfer Object (DTO).
6+
*
7+
* @param message A static message confirming access.
8+
* @param email The email address of the authenticated user, extracted from the JWT.
9+
*/
10+
public record PrivateInfoResponse(String message, String email) {}

backend/src/main/java/ai/bluefields/oidcauthdemo/error/ApiError.java renamed to backend/src/main/java/ai/bluefields/oidcauthdemo/exception/ApiError.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package ai.bluefields.oidcauthdemo.error;
1+
package ai.bluefields.oidcauthdemo.exception;
22

33
import java.time.Instant;
44

backend/src/main/java/ai/bluefields/oidcauthdemo/error/GlobalExceptionHandler.java renamed to backend/src/main/java/ai/bluefields/oidcauthdemo/exception/GlobalExceptionHandler.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
package ai.bluefields.oidcauthdemo.error;
1+
package ai.bluefields.oidcauthdemo.exception;
22

33
import java.time.Instant;
44
import org.slf4j.Logger;
55
import org.slf4j.LoggerFactory;
66
import org.springframework.http.HttpStatus;
77
import org.springframework.http.MediaType;
88
import org.springframework.http.ResponseEntity;
9+
import org.springframework.security.authorization.AuthorizationDeniedException;
910
import org.springframework.web.HttpMediaTypeNotSupportedException;
1011
import org.springframework.web.HttpRequestMethodNotSupportedException;
1112
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -148,4 +149,28 @@ public ResponseEntity<ApiError> handleUnsupportedMediaType(
148149
.contentType(MediaType.valueOf("application/problem+json"))
149150
.body(apiError);
150151
}
152+
153+
/**
154+
* Handles {@link AuthorizationDeniedException} (typically thrown by {@code @PreAuthorize}) and
155+
* converts it to a standardized {@link ApiError} response with HTTP status 403 (Forbidden).
156+
*
157+
* @param ex the exception that was thrown
158+
* @return a {@link ResponseEntity} containing an {@link ApiError} with status 403
159+
*/
160+
@ExceptionHandler(AuthorizationDeniedException.class)
161+
public ResponseEntity<ApiError> handleAuthorizationDenied(AuthorizationDeniedException ex) {
162+
logger.warn("Authorization denied: {}", ex.getMessage());
163+
164+
ApiError apiError =
165+
new ApiError(
166+
"https://api.bluefields.ai/errors/forbidden",
167+
"Forbidden",
168+
HttpStatus.FORBIDDEN.value(),
169+
"Access to the requested resource is forbidden",
170+
Instant.now());
171+
172+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
173+
.contentType(MediaType.valueOf("application/problem+json"))
174+
.body(apiError);
175+
}
151176
}

0 commit comments

Comments
 (0)