diff --git a/.gitignore b/.gitignore index 1c87f25..a04dcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ build/ # Local environment secrets .env +# PlantUML local cache for diagram generation +docs/diagrams/.plantuml-cache/ diff --git a/README.md b/README.md index 557b81d..55aead5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Reactive OAuth2 proxy/service built with Spring Boot 4 + Java 25, using WebFlux, - `SECURITY.md` - security policy and runtime security model - `CONTRIBUTING.md` - contribution workflow and coding/testing standards - `CHANGELOG.md` - notable project changes +- `docs/architecture.md` - architecture and sequence diagrams (PlantUML + PNG) This project keeps the same business scenarios as `jwt-demo`, but the implementation is fully reactive: - WebFlux controllers (`Mono>`) @@ -205,48 +206,21 @@ Authorization options: ### Login flow -```mermaid -sequenceDiagram - actor C as Client - participant S as Spring Boot (AuthController) - participant K as Keycloak - - C->>S: 1) POST /api/auth/login
{username, password, clientId, clientSecret} - S->>K: 2) KeycloakReactiveAuthService.login() - K->>K: 3) POST /realms/my-realm/protocol/openid-connect/token
grant_type=password
username, password
client_id, client_secret - K-->>S: 4) 200 OK
{access_token, refresh_token} - S-->>C: 5) AppResponse(code=0, data=tokens) -``` +![Login flow](docs/diagrams/sequence-auth-login.png) + +Source: `docs/diagrams/sequence-auth-login.puml` ### Refresh flow -```mermaid -sequenceDiagram - actor C as Client - participant S as Spring Boot (AuthController) - participant K as Keycloak - - C->>S: 1) POST /api/auth/refresh
{refreshToken, clientId, clientSecret} - S->>K: 2) KeycloakReactiveAuthService.refresh() - K->>K: 3) POST /realms/my-realm/protocol/openid-connect/token
grant_type=refresh_token
refresh_token
client_id, client_secret - K-->>S: 4) 200 OK
{new_access_token, new_refresh_token} - S-->>C: 5) AppResponse(code=0, data=tokens) -``` +![Refresh flow](docs/diagrams/sequence-auth-refresh.png) + +Source: `docs/diagrams/sequence-auth-refresh.puml` ### Logout flow -```mermaid -sequenceDiagram - actor C as Client - participant S as Spring Boot (AuthController) - participant K as Keycloak - - C->>S: 1) POST /api/auth/logout
{refreshToken, clientId, clientSecret} - S->>K: 2) KeycloakReactiveAuthService.logout() - K->>K: 3) POST /realms/my-realm/protocol/openid-connect/logout
client_id, client_secret
refresh_token - K-->>S: 4) 200 OK (Keycloak behavior) - S-->>C: 5) AppResponse(code=0) -``` +![Logout flow](docs/diagrams/sequence-auth-logout.png) + +Source: `docs/diagrams/sequence-auth-logout.puml` --- @@ -254,34 +228,9 @@ sequenceDiagram `POST /api/clients` does not create a client synchronously. -```mermaid -sequenceDiagram - actor C as Caller - participant A as API (/api/clients) - participant D as request table (PostgreSQL) - participant W as Request Worker - - C->>A: POST /api/clients - A->>A: Validate payload - A->>D: INSERT type=CLIENT_CREATE, status=PENDING - A-->>C: AppResponse(code=0, data={requestId}) - - loop Poll until terminal status - C->>A: GET /api/requests/{id} - A->>D: SELECT status by id - A-->>C: status=PENDING|PROCESSING|COMPLETED|FAILED - end - - W->>D: Reclaim stale PROCESSING rows - W->>D: Claim PENDING batch (FOR UPDATE SKIP LOCKED) - W->>D: UPDATE status=PROCESSING - - alt Success - W->>D: UPDATE status=COMPLETED, response_json - else Failure - W->>D: UPDATE status=FAILED, error_json - end -``` +![Asynchronous client creation flow](docs/diagrams/sequence-async-client-create.png) + +Source: `docs/diagrams/sequence-async-client-create.puml` For multi-instance safety, stale `PROCESSING` reclaim is implemented and indexed (`V2__add_request_reclaim_index.sql`). @@ -345,32 +294,9 @@ Recommended settings: - `management.otlp.metrics.export.enabled=false` - `management.tracing.sampling.probability=1.0` -```mermaid -flowchart LR - subgraph App[Spring Boot jwt-demo-reactive] - A1[HTTP metrics\nActuator /prometheus] - A2[Traces OTLP\nmanagement.otlp.tracing.endpoint] - A3[Logs OTLP\nmanagement.otlp.logging.endpoint] - end - - subgraph Infra[Observability Infra] - C[OTel Collector] - T[Tempo] - L[Loki] - P[Prometheus] - G[Grafana] - end - - A1 -->|pull /actuator/prometheus| P - A2 -->|OTLP traces| C - A3 -->|OTLP logs| C - C -->|traces| T - C -->|logs| L - - P --> G - T --> G - L --> G -``` +![Observability data flow](docs/diagrams/sequence-observability-flow.png) + +Source: `docs/diagrams/sequence-observability-flow.puml` --- @@ -472,4 +398,3 @@ Main integration suites: - This is the fastest path to diagnose `401`, `403`, and `429` scenarios across API + security filters. --- - diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2c2c87f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,39 @@ +# Architecture Diagrams + +Diagrams are stored in `docs/diagrams/` as PlantUML source and exported PNG files. + +To regenerate PNG files: + +```pwsh +./docs/diagrams/generate-diagrams.ps1 +``` + +## Sequence: Login flow + +![Login flow](./diagrams/sequence-auth-login.png) + +Source: `docs/diagrams/sequence-auth-login.puml` + +## Sequence: Refresh flow + +![Refresh flow](./diagrams/sequence-auth-refresh.png) + +Source: `docs/diagrams/sequence-auth-refresh.puml` + +## Sequence: Logout flow + +![Logout flow](./diagrams/sequence-auth-logout.png) + +Source: `docs/diagrams/sequence-auth-logout.puml` + +## Sequence: Asynchronous client creation + +![Asynchronous client creation](./diagrams/sequence-async-client-create.png) + +Source: `docs/diagrams/sequence-async-client-create.puml` + +## Sequence: Observability data flow + +![Observability data flow](./diagrams/sequence-observability-flow.png) + +Source: `docs/diagrams/sequence-observability-flow.puml` diff --git a/docs/diagrams/generate-diagrams.ps1 b/docs/diagrams/generate-diagrams.ps1 new file mode 100644 index 0000000..6e16996 --- /dev/null +++ b/docs/diagrams/generate-diagrams.ps1 @@ -0,0 +1,23 @@ +param( + [string]$PlantUmlVersion = "1.2025.3" +) + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$cacheDir = Join-Path $scriptDir ".plantuml-cache" +$tempJar = Join-Path $cacheDir "plantuml-$PlantUmlVersion.jar" +$downloadUrl = "https://github.com/plantuml/plantuml/releases/download/v$PlantUmlVersion/plantuml-$PlantUmlVersion.jar" + +if (-not (Test-Path $cacheDir)) { + New-Item -ItemType Directory -Path $cacheDir | Out-Null +} + +if (-not (Test-Path $tempJar)) { + Invoke-WebRequest -Uri $downloadUrl -OutFile $tempJar -ErrorAction Stop +} + +$pumlFiles = Get-ChildItem -Path $scriptDir -Filter "*.puml" | Select-Object -ExpandProperty FullName +if (-not $pumlFiles) { + throw "No .puml files found in $scriptDir" +} + +java -jar $tempJar -charset UTF-8 -tpng @pumlFiles diff --git a/docs/diagrams/sequence-async-client-create.png b/docs/diagrams/sequence-async-client-create.png new file mode 100644 index 0000000..9f9e6e8 Binary files /dev/null and b/docs/diagrams/sequence-async-client-create.png differ diff --git a/docs/diagrams/sequence-async-client-create.puml b/docs/diagrams/sequence-async-client-create.puml new file mode 100644 index 0000000..2901fd9 --- /dev/null +++ b/docs/diagrams/sequence-async-client-create.puml @@ -0,0 +1,31 @@ +@startuml +title Asynchronous client creation flow + +autonumber +actor "Caller" as C +participant "API\nPOST /api/clients" as A +database "request table\n(PostgreSQL)" as D +participant "Request Worker" as W + +C -> A: POST /api/clients +A -> A: Validate payload +A -> D: INSERT type=CLIENT_CREATE,\nstatus=PENDING +A --> C: AppResponse(code=0,\ndata={requestId}) + +loop Poll until terminal status + C -> A: GET /api/requests/{id} + A -> D: SELECT status by id + A --> C: status=PENDING|PROCESSING|COMPLETED|FAILED +end + +W -> D: Reclaim stale PROCESSING rows +W -> D: Claim PENDING batch\n(FOR UPDATE SKIP LOCKED) +W -> D: UPDATE status=PROCESSING + +alt Success + W -> D: UPDATE status=COMPLETED,\nresponse_json +else Failure + W -> D: UPDATE status=FAILED,\nerror_json +end + +@enduml diff --git a/docs/diagrams/sequence-auth-login.png b/docs/diagrams/sequence-auth-login.png new file mode 100644 index 0000000..8705e3c Binary files /dev/null and b/docs/diagrams/sequence-auth-login.png differ diff --git a/docs/diagrams/sequence-auth-login.puml b/docs/diagrams/sequence-auth-login.puml new file mode 100644 index 0000000..aaad680 --- /dev/null +++ b/docs/diagrams/sequence-auth-login.puml @@ -0,0 +1,14 @@ +@startuml +title Login flow + +autonumber +actor "Client" as C +participant "jwt-demo-reactive\nAuthController" as S +participant "Keycloak\nToken endpoint" as K + +C -> S: POST /api/auth/login\n{username, password, clientId, clientSecret} +S -> K: grant_type=password\nusername, password\nclient_id, client_secret +K --> S: 200 OK\n{access_token, refresh_token} +S --> C: AppResponse(code=0, data=tokens) + +@enduml diff --git a/docs/diagrams/sequence-auth-logout.png b/docs/diagrams/sequence-auth-logout.png new file mode 100644 index 0000000..b7a308a Binary files /dev/null and b/docs/diagrams/sequence-auth-logout.png differ diff --git a/docs/diagrams/sequence-auth-logout.puml b/docs/diagrams/sequence-auth-logout.puml new file mode 100644 index 0000000..777f95b --- /dev/null +++ b/docs/diagrams/sequence-auth-logout.puml @@ -0,0 +1,14 @@ +@startuml +title Logout flow + +autonumber +actor "Client" as C +participant "jwt-demo-reactive\nAuthController" as S +participant "Keycloak\nLogout endpoint" as K + +C -> S: POST /api/auth/logout\n{refreshToken, clientId, clientSecret} +S -> K: client_id, client_secret\nrefresh_token +K --> S: 200 OK +S --> C: AppResponse(code=0) + +@enduml diff --git a/docs/diagrams/sequence-auth-refresh.png b/docs/diagrams/sequence-auth-refresh.png new file mode 100644 index 0000000..fbbba9f Binary files /dev/null and b/docs/diagrams/sequence-auth-refresh.png differ diff --git a/docs/diagrams/sequence-auth-refresh.puml b/docs/diagrams/sequence-auth-refresh.puml new file mode 100644 index 0000000..f4d827f --- /dev/null +++ b/docs/diagrams/sequence-auth-refresh.puml @@ -0,0 +1,14 @@ +@startuml +title Refresh flow + +autonumber +actor "Client" as C +participant "jwt-demo-reactive\nAuthController" as S +participant "Keycloak\nToken endpoint" as K + +C -> S: POST /api/auth/refresh\n{refreshToken, clientId, clientSecret} +S -> K: grant_type=refresh_token\nrefresh_token\nclient_id, client_secret +K --> S: 200 OK\n{access_token, refresh_token} +S --> C: AppResponse(code=0, data=tokens) + +@enduml diff --git a/docs/diagrams/sequence-observability-flow.png b/docs/diagrams/sequence-observability-flow.png new file mode 100644 index 0000000..a49d7b5 Binary files /dev/null and b/docs/diagrams/sequence-observability-flow.png differ diff --git a/docs/diagrams/sequence-observability-flow.puml b/docs/diagrams/sequence-observability-flow.puml new file mode 100644 index 0000000..d3d5a86 --- /dev/null +++ b/docs/diagrams/sequence-observability-flow.puml @@ -0,0 +1,21 @@ +@startuml +title Observability data flow (OTLP-first) + +autonumber +participant "Spring Boot app\njwt-demo-reactive" as App +participant "Prometheus" as Prom +participant "OTel Collector" as OTel +participant "Tempo" as Tempo +participant "Loki" as Loki +participant "Grafana" as Grafana + +Prom -> App: pull /actuator/prometheus +App -> OTel: OTLP traces\nmanagement.otlp.tracing.endpoint +App -> OTel: OTLP logs\nmanagement.otlp.logging.endpoint +OTel -> Tempo: export traces +OTel -> Loki: export logs +Grafana -> Prom: query metrics datasource +Grafana -> Tempo: query traces datasource +Grafana -> Loki: query logs datasource + +@enduml