Skip to content

Commit 9c12a47

Browse files
committed
feat: implement step 7 - frontend login/logout flow
1 parent fee18b2 commit 9c12a47

40 files changed

+3614
-2863
lines changed

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Zitadel OIDC settings
2+
ZITADEL_AUTHDEMO_ISSUER_URI=https://issuer.zitadel.ch/oauth/v2
3+
ZITADEL_AUTHDEMO_CLIENT_ID=demo-frontend
4+
ZITADEL_AUTHDEMO_BACKEND_CLIENT_ID=demo-backend
5+
ZITADEL_AUTHDEMO_CLIENT_SECRET=change-me
6+
# Standard OIDC scopes
7+
ZITADEL_AUTHDEMO_SCOPES=openid profile email

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@ Thumbs.db
2727
# Maven Wrapper - Ignore everything in .mvn except the wrapper jar
2828
.mvn/*
2929
!.mvn/wrapper/maven-wrapper.jar
30+
31+
.env

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **Step 01:** Initial repository scaffold, backend (Spring Boot) and frontend (React/Vite) skeletons, basic toolchain (Maven, pnpm, Vite, ESLint, Prettier, Spotless, Jacoco, Vitest), Husky pre-commit hooks, and GitHub Actions CI pipeline.
1313
- **Step 02:** Added public health endpoint.
1414
- **Step 03:** Guest UI skeleton with health fetch.
15+
- **Step 04:** Baseline error handling added.
16+
- **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.
17+
- **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.
18+
- **Step 07** – Implemented frontend login/logout flow using oidc-client-ts for PKCE. Added tests for Header, AuthCallback, AuthProvider, and AuthService.
1519

1620
### Fixed
1721

README.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,52 @@ The frontend development server will start on http://localhost:5173.
4040

4141
> **Note:** The frontend dev server includes a proxy configuration that forwards all `/api` requests to the backend server running on port 8080. Make sure the backend server is running when developing the frontend.
4242
43+
### Guest Mode Caching
44+
45+
The frontend implements a caching mechanism for the public health check endpoint in guest mode.
46+
47+
- The response from `/api/v1/public/health` is stored in the browser's `sessionStorage`.
48+
- If the backend is unavailable when the page loads, the UI will display the last known status from the cache ("stale data") instead of showing an error immediately.
49+
- During development, if you need to clear this cache to fetch fresh data, you can do so via your browser's developer tools (usually under the "Application" or "Storage" tab, look for `sessionStorage`).
50+
51+
## Environment Variables
52+
53+
This project uses environment variables for configuration, particularly for OIDC authentication details needed from Step 7 onwards. Template files are provided:
54+
55+
- `.env.example` (at the project root, for backend configuration)
56+
- `frontend/.env.example` (in the frontend directory, for frontend configuration)
57+
58+
These files list the required variables but contain placeholder values.
59+
60+
**To configure your local environment:**
61+
62+
1. **Copy the templates:**
63+
```bash
64+
cp .env.example .env
65+
cp frontend/.env.example frontend/.env
66+
```
67+
2. **Edit the `.env` files:** Open the newly created `.env` files (in the root directory and the `frontend/` directory) and replace the placeholder values with your actual Zitadel application details (Issuer URI, Client IDs, Client Secret). Refer to `docs/auth-config.md` for details on each variable.
68+
69+
**Important:** The `.env` files contain sensitive information and are listed in `.gitignore`. **Never commit `.env` files to the Git repository.**
70+
71+
72+
## Running with OIDC
73+
74+
The frontend application uses the OpenID Connect (OIDC) Authorization Code flow with Proof Key for Code Exchange (PKCE) for user authentication. This is implemented using the `oidc-client-ts` library.
75+
76+
To run the application with OIDC authentication enabled, you need to configure the following environment variables in the `frontend/.env` file (refer to `frontend/.env.example` for the format):
77+
78+
- `VITE_ZITADEL_ISSUER_URI`: The URI of your Zitadel instance.
79+
- `VITE_ZITADEL_CLIENT_ID`: The Client ID of your Zitadel application.
80+
- `VITE_ZITADEL_SCOPES`: The OIDC scopes required by the application (e.g., `openid profile email`).
81+
82+
Additionally, ensure your Zitadel client application is configured with the following settings:
83+
84+
- **Application Type:** `User Agent`
85+
- **Authentication Method:** `None` (PKCE is used)
86+
- **Redirect URIs:** `http://localhost:5173/auth/callback`
87+
- **Post Logout URIs:** `http://localhost:5173/`
88+
4389
## Testing
4490

4591
### Backend
@@ -113,4 +159,3 @@ Example response:
113159
{
114160
"message": "Service up"
115161
}
116-
```
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package ai.bluefields.oidcauthdemo.error;
2+
3+
import java.time.Instant;
4+
5+
/**
6+
* Represents a standardized error response following RFC 7807 "Problem Details for HTTP APIs". This
7+
* record encapsulates error information to be returned to clients in a consistent format with the
8+
* content type "application/problem+json".
9+
*
10+
* <p>The fields follow the RFC 7807 specification:
11+
*
12+
* <ul>
13+
* <li>{@code type} - A URI reference that identifies the problem type
14+
* <li>{@code title} - A short, human-readable summary of the problem type
15+
* <li>{@code status} - The HTTP status code for this occurrence of the problem
16+
* <li>{@code detail} - A human-readable explanation specific to this occurrence of the problem
17+
* <li>{@code timestamp} - The time when the error occurred (extension to the standard)
18+
* </ul>
19+
*
20+
* <p>Example usage:
21+
*
22+
* <pre>
23+
* ApiError error = new ApiError(
24+
* "https://api.bluefields.ai/errors/not-found",
25+
* "Resource Not Found",
26+
* 404,
27+
* "The requested resource could not be found",
28+
* Instant.now()
29+
* );
30+
* </pre>
31+
*
32+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7807">RFC 7807</a>
33+
*/
34+
public record ApiError(String type, String title, int status, String detail, Instant timestamp) {}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package ai.bluefields.oidcauthdemo.error;
2+
3+
import java.time.Instant;
4+
import org.slf4j.Logger;
5+
import org.slf4j.LoggerFactory;
6+
import org.springframework.http.HttpStatus;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.web.HttpMediaTypeNotSupportedException;
10+
import org.springframework.web.HttpRequestMethodNotSupportedException;
11+
import org.springframework.web.bind.annotation.ExceptionHandler;
12+
import org.springframework.web.bind.annotation.RestControllerAdvice;
13+
import org.springframework.web.servlet.NoHandlerFoundException;
14+
15+
/**
16+
* Global exception handler for the application that converts uncaught exceptions into standardized
17+
* {@link ApiError} responses following RFC 7807 "Problem Details for HTTP APIs".
18+
*
19+
* <p>This class is responsible for:
20+
*
21+
* <ul>
22+
* <li>Catching exceptions thrown by controllers
23+
* <li>Converting them to standardized ApiError responses
24+
* <li>Setting the appropriate HTTP status code
25+
* <li>Setting the content type to "application/problem+json"
26+
* </ul>
27+
*
28+
* <p>The handler prevents internal exception details from leaking to clients while still providing
29+
* useful error information. All exceptions are logged for diagnostic purposes.
30+
*
31+
* <p>Example response:
32+
*
33+
* <pre>
34+
* {
35+
* "type": "https://api.bluefields.ai/errors/internal-error",
36+
* "title": "Internal Server Error",
37+
* "status": 500,
38+
* "detail": "An unexpected error occurred while processing your request",
39+
* "timestamp": "2025-04-17T19:08:00Z"
40+
* }
41+
* </pre>
42+
*
43+
* @see ApiError
44+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7807">RFC 7807</a>
45+
*/
46+
@RestControllerAdvice
47+
public class GlobalExceptionHandler {
48+
49+
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
50+
51+
/**
52+
* Handles all uncaught exceptions and converts them to a standardized {@link ApiError} response
53+
* with HTTP status 500 (Internal Server Error).
54+
*
55+
* <p>This method logs the exception for diagnostic purposes but returns a generic error message
56+
* to the client to avoid exposing sensitive implementation details.
57+
*
58+
* @param ex the exception that was thrown
59+
* @return a {@link ResponseEntity} containing an {@link ApiError} with status 500
60+
*/
61+
@ExceptionHandler(Exception.class)
62+
public ResponseEntity<ApiError> handleException(Exception ex) {
63+
logger.error("Unhandled exception caught by global handler", ex);
64+
65+
ApiError apiError =
66+
new ApiError(
67+
"https://api.bluefields.ai/errors/internal-error",
68+
"Internal Server Error",
69+
HttpStatus.INTERNAL_SERVER_ERROR.value(),
70+
"An unexpected error occurred while processing your request",
71+
Instant.now());
72+
73+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
74+
.contentType(MediaType.valueOf("application/problem+json"))
75+
.body(apiError);
76+
}
77+
78+
/**
79+
* Handles {@link NoHandlerFoundException} and converts it to a standardized {@link ApiError}
80+
* response with HTTP status 404 (Not Found).
81+
*
82+
* @param ex the exception that was thrown
83+
* @return a {@link ResponseEntity} containing an {@link ApiError} with status 404
84+
*/
85+
@ExceptionHandler(NoHandlerFoundException.class)
86+
public ResponseEntity<ApiError> handleNoHandlerFoundException(NoHandlerFoundException ex) {
87+
logger.warn("No handler found for {}: {}", ex.getRequestURL(), ex.getMessage());
88+
89+
ApiError apiError =
90+
new ApiError(
91+
"https://api.bluefields.ai/errors/not-found",
92+
"Not Found",
93+
HttpStatus.NOT_FOUND.value(),
94+
"The requested resource could not be found: " + ex.getRequestURL(),
95+
Instant.now());
96+
97+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
98+
.contentType(MediaType.valueOf("application/problem+json"))
99+
.body(apiError);
100+
}
101+
102+
/**
103+
* Handles {@link HttpRequestMethodNotSupportedException} and converts it to a standardized {@link
104+
* ApiError} response with HTTP status 405 (Method Not Allowed).
105+
*
106+
* @param ex the exception that was thrown
107+
* @return a {@link ResponseEntity} containing an {@link ApiError} with status 405
108+
*/
109+
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
110+
public ResponseEntity<ApiError> handleMethodNotAllowed(
111+
HttpRequestMethodNotSupportedException ex) {
112+
logger.warn("Method not allowed: {}", ex.getMessage());
113+
114+
ApiError apiError =
115+
new ApiError(
116+
"https://api.bluefields.ai/errors/method-not-allowed",
117+
"Method Not Allowed",
118+
HttpStatus.METHOD_NOT_ALLOWED.value(),
119+
"The HTTP method " + ex.getMethod() + " is not supported for this resource",
120+
Instant.now());
121+
122+
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
123+
.contentType(MediaType.valueOf("application/problem+json"))
124+
.body(apiError);
125+
}
126+
127+
/**
128+
* Handles {@link HttpMediaTypeNotSupportedException} and converts it to a standardized {@link
129+
* ApiError} response with HTTP status 415 (Unsupported Media Type).
130+
*
131+
* @param ex the exception that was thrown
132+
* @return a {@link ResponseEntity} containing an {@link ApiError} with status 415
133+
*/
134+
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
135+
public ResponseEntity<ApiError> handleUnsupportedMediaType(
136+
HttpMediaTypeNotSupportedException ex) {
137+
logger.warn("Unsupported media type: {}", ex.getMessage());
138+
139+
ApiError apiError =
140+
new ApiError(
141+
"https://api.bluefields.ai/errors/unsupported-media-type",
142+
"Unsupported Media Type",
143+
HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(),
144+
"The content type " + ex.getContentType() + " is not supported",
145+
Instant.now());
146+
147+
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
148+
.contentType(MediaType.valueOf("application/problem+json"))
149+
.body(apiError);
150+
}
151+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
spring.application.name=oidc-auth-demo
2+
3+
# Enable throwing NoHandlerFoundException for 404 errors
4+
spring.mvc.throw-exception-if-no-handler-found=true
5+
spring.web.resources.add-mappings=false
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
spring:
2+
security:
3+
# NOTE: Security is currently disabled. It will be enabled in Step 08.
4+
enabled: false
5+
oauth2:
6+
resourceserver:
7+
jwt:
8+
# Use environment variable ZITADEL_AUTHDEMO_ISSUER_URI, default to empty string if not set
9+
issuer-uri: ${ZITADEL_AUTHDEMO_ISSUER_URI:}
10+
zitadel:
11+
# Use environment variable ZITADEL_AUTHDEMO_BACKEND_CLIENT_ID, default to empty string if not set
12+
client-id: ${ZITADEL_AUTHDEMO_BACKEND_CLIENT_ID:}
13+
# Use environment variable ZITADEL_AUTHDEMO_CLIENT_SECRET, default to empty string if not set
14+
client-secret: ${ZITADEL_AUTHDEMO_CLIENT_SECRET:}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package ai.bluefields.oidcauthdemo.error;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.time.Instant;
6+
import org.junit.jupiter.api.Test;
7+
8+
/** Unit tests for {@link ApiError} record. */
9+
class ApiErrorTest {
10+
11+
@Test
12+
void shouldCreateApiErrorWithAllFields() {
13+
// Given
14+
String type = "https://api.bluefields.ai/errors/validation";
15+
String title = "Validation Error";
16+
int status = 400;
17+
String detail = "The request was invalid";
18+
Instant timestamp = Instant.now();
19+
20+
// When
21+
ApiError apiError = new ApiError(type, title, status, detail, timestamp);
22+
23+
// Then
24+
assertThat(apiError.type()).isEqualTo(type);
25+
assertThat(apiError.title()).isEqualTo(title);
26+
assertThat(apiError.status()).isEqualTo(status);
27+
assertThat(apiError.detail()).isEqualTo(detail);
28+
assertThat(apiError.timestamp()).isEqualTo(timestamp);
29+
}
30+
31+
@Test
32+
void shouldHaveCorrectEqualsAndHashCode() {
33+
// Given
34+
Instant now = Instant.now();
35+
ApiError error1 = new ApiError("type", "title", 400, "detail", now);
36+
ApiError error2 = new ApiError("type", "title", 400, "detail", now);
37+
ApiError differentError = new ApiError("different", "title", 400, "detail", now);
38+
39+
// Then
40+
assertThat(error1).isEqualTo(error2);
41+
assertThat(error1.hashCode()).isEqualTo(error2.hashCode());
42+
assertThat(error1).isNotEqualTo(differentError);
43+
assertThat(error1.hashCode()).isNotEqualTo(differentError.hashCode());
44+
}
45+
46+
@Test
47+
void shouldHaveCorrectToString() {
48+
// Given
49+
Instant now = Instant.now();
50+
ApiError error = new ApiError("type", "title", 400, "detail", now);
51+
52+
// When
53+
String toString = error.toString();
54+
55+
// Then
56+
assertThat(toString).contains("type");
57+
assertThat(toString).contains("title");
58+
assertThat(toString).contains("400");
59+
assertThat(toString).contains("detail");
60+
assertThat(toString).contains(now.toString());
61+
}
62+
}

0 commit comments

Comments
 (0)