From 555b3aa18e08566e379cab5082a75a1c1fa47286 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 11:43:44 +0200 Subject: [PATCH 01/23] docs: Security schemes (OpenAPI 3.1) design spec Captures the Wave 7 plan from the OpenAPI 3.1 refactor inventory: parse securitySchemes + security requirements, extract credentials per scheme, let consumers validate via name-keyed callback, library renders 401/403. Includes useExternalAuthentication() opt-out for OPA-sidecar deployments and explicit k6 compatibility constraints. --- .../2026-05-18-security-schemes-design.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-18-security-schemes-design.md diff --git a/docs/superpowers/specs/2026-05-18-security-schemes-design.md b/docs/superpowers/specs/2026-05-18-security-schemes-design.md new file mode 100644 index 0000000..1d954d3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-security-schemes-design.md @@ -0,0 +1,230 @@ +# Security schemes (OpenAPI 3.1 `securitySchemes` + `security`) + +## Context + +The OpenAPI 3.1 refactor design (`2026-05-07-openapi-refactor-design.md` §9) parked security as **Wave 7 — last** because it touches every operation and benefits from the rest of the typed model being settled. That model is now in place and the main-code Sonar baseline is clean, so we can tackle security as a contained slice. + +This spec turns OpenAPI's security metadata into a first-class part of the library: `securitySchemes` is parsed into a typed model, `security` requirements are resolved per operation, and the library extracts credentials and asks the consumer to validate them. The library renders rejections (401/403) so consumers don't have to repeat the challenge-response boilerplate, but never decides whether a credential is *valid* — that stays with the consumer's callback. An opt-out exists for deployments where an external sidecar (OPA/Envoy in GCP) already enforces auth upstream. + +## Decisions (locked during brainstorming) + +1. **Scope: parse + extract credential.** Library reads `securitySchemes` and `security`, extracts the credential per scheme, and hands it to a consumer-provided callback. Library does not validate the credential itself. +2. **Scheme types v1: `apiKey` (in `header` / `query` / `cookie`), `http` (`bearer`, `basic`).** `oauth2`, `openIdConnect`, `mutualTLS` are parsed-but-unsupported in v1. +3. **Callback keyed by scheme name** (not by scheme type), matching the OpenAPI model where two `bearer` schemes can mean different things. +4. **Library renders rejections.** 401 + `WWW-Authenticate` for missing/malformed credentials, 403 for callback denial. Body is RFC 7807 `application/problem+json`, consistent with parameter-validation failures. +5. **Callback returns `Optional` principal.** Empty = deny. Non-empty = allow, with the principal attached to the `Request` for the handler to read. +6. **`useExternalAuthentication()` opt-out.** When set, `SecurityFilter` is a no-op for all operations, validator-registration boot check is skipped, and `Request.principal(...)` returns empty for every scheme. Consumers in sidecar deployments build their own principal from headers the sidecar sets, via their own `RequestInterceptor`. + +## High-level architecture + +Request flow with security added: + +``` +HttpServer + ExceptionFilter + RequestPreparationFilter (existing: parses body, validates params) + SecurityFilter (NEW) + DispatchHandler +``` + +`SecurityFilter` is a new step between request preparation and dispatch. Reasons it lives in the filter chain rather than in `RequestInterceptor`: + +- Security is a precondition on whether the request should reach the handler — interceptors can be reordered/disabled, filters are mandatory. +- Consumer `RequestInterceptor`s can still run before the handler (e.g. to bind a `ScopedValue` from the resolved principal), but after security has decided. + +When `useExternalAuthentication()` is set, the filter still exists in the chain but short-circuits to `next.proceed()`. Keeping it in the chain (rather than conditionally omitted) keeps the chain shape uniform and makes the opt-out visible in logs/traces. + +## Spec model additions + +New package: `com.retailsvc.http.spec.security`. + +```java +public sealed interface SecurityScheme + permits ApiKey, HttpBearer, HttpBasic, Unsupported { + + record ApiKey(String name, Location location) implements SecurityScheme { + public enum Location { HEADER, QUERY, COOKIE } + } + record HttpBearer(Optional bearerFormat) implements SecurityScheme {} + record HttpBasic() implements SecurityScheme {} + + /** oauth2 / openIdConnect / mutualTLS — parsed for completeness, fail at boot if referenced. */ + record Unsupported(String type) implements SecurityScheme {} +} + +public record SecurityRequirement(Map> schemes) { + // schemes: scheme name → required scopes (scopes ignored in v1 since oauth2/oidc aren't supported) +} +``` + +Additions to `com.retailsvc.http.spec.Spec`: + +```java +record Spec( + ..., + Map securitySchemes, // NEW (empty map if absent) + List security // NEW (root-level default, empty if absent) +) +``` + +Additions to `com.retailsvc.http.spec.Operation`: + +```java +record Operation( + ..., + Optional> security // NEW +) +``` + +Semantics: +- `Operation.security() == Optional.empty()` → inherit `Spec.security()`. +- `Operation.security() == Optional.of(emptyList)` → "no security required" override (per OpenAPI 3.1 §4.8.2). +- `Operation.security() == Optional.of(nonEmptyList)` → override root-level requirements with this list. + +`Spec.from(Map)` parses the catalog and the requirement lists. Unknown scheme types map to `Unsupported`. Malformed scheme definitions (missing required fields per the OpenAPI spec) throw `IllegalArgumentException` from `Spec.from(...)` — consistent with current behavior on malformed paths/operations. + +## Builder API + +```java +public final class OpenApiServer.Builder { + /** Registers a credential validator for the named security scheme. */ + public Builder securityValidator(String schemeName, SchemeValidator validator); + + /** Opts out of in-process enforcement entirely (e.g. OPA/Envoy sidecar deployment). */ + public Builder useExternalAuthentication(); + ... +} + +@FunctionalInterface +public interface SchemeValidator { + /** @return non-empty principal on allow, empty on deny */ + Optional validate(Request request, Credential credential); +} + +public sealed interface Credential permits ApiKeyCredential, BearerCredential, BasicCredential { + record ApiKeyCredential(String value) implements Credential {} + record BearerCredential(String token) implements Credential {} + record BasicCredential(String username, String password) implements Credential {} +} +``` + +The sealed `Credential` lets consumers share a single validator across multiple scheme names with a `switch` on the credential type if they want, while keeping per-scheme registration as the default. + +## SecurityFilter behavior + +For each request the filter: + +1. Reads the operationId resolved by `RequestPreparationFilter` and looks up the `Operation`. +2. Computes effective requirements: `op.security().orElse(spec.security())`. +3. If effective requirements is empty → `next.proceed()` (no auth required for this operation). +4. Otherwise, evaluates the OR-of-AND: + - For each `SecurityRequirement` (OR branch): + - For each scheme in the AND map, extract the credential. + - **`ApiKey(name, HEADER)`** → `request.headers().firstValue(name)`. + - **`ApiKey(name, QUERY)`** → first occurrence of `name` in the query string. + - **`ApiKey(name, COOKIE)`** → first cookie named `name`. + - **`HttpBearer`** → `Authorization` header must match `Bearer\s+` (case-insensitive scheme word per RFC 6750). + - **`HttpBasic`** → `Authorization` header must match `Basic\s+`; base64 must decode to `user:password`. + - If any credential in the group is missing → record "missing", skip to next OR branch. + - If a credential is malformed (e.g. Basic with non-base64) → record "malformed", skip to next OR branch. + - Otherwise call `SchemeValidator.validate(request, credential)` for each scheme. + - If every validator returns non-empty → group succeeds. Stash `Map` of principals on the exchange under attribute `security.principals`. `next.proceed()`. +5. If no group succeeds → render rejection (see below). + +## Rejection rendering + +Pick the strongest signal across all attempted groups: + +- If at least one group had a callback that returned `Optional.empty()` (credential present and validator said "no") → **403 Forbidden**. +- Otherwise (all groups had missing or malformed credentials) → **401 Unauthorized**. + +Headers: +- 401 emits one `WWW-Authenticate` header per distinct scheme attempted. Examples: + - `Bearer realm="api"` for `HttpBearer` + - `Basic realm="api"` for `HttpBasic` + - For `ApiKey` schemes, RFC 7235 has no registered challenge type — we emit a custom advisory header `WWW-Authenticate: ApiKey location=, name=""` since the alternative is to omit the challenge entirely (also valid per spec). Both behaviors are acceptable; pick the informative one. +- 403 emits no `WWW-Authenticate` (the credential was accepted at the protocol level, just not authorized). + +Body is `application/problem+json` matching the existing parameter-validation format: + +```json +{ "type": "about:blank", + "title": "Unauthorized", + "status": 401, + "detail": "credential missing for scheme 'bearerAuth'" } +``` + +The `detail` is the most specific reason for the *closest-to-success* attempted group (e.g. "credential missing" vs "validator denied for scheme 'apiKeyAuth'"). + +## Handler access to principal + +```java +public final class Request { + /** Principals keyed by securityScheme name, set by SecurityFilter on success. Empty when no security ran. */ + public Map principals(); + + /** Convenience for the common single-scheme case. */ + public Optional principal(String schemeName); +} +``` + +Backed by the exchange attribute `security.principals`. Empty map when `useExternalAuthentication()` is set or when the operation had no security requirements. + +## Boot-time validation + +When `OpenApiServer.builder(spec).build()` runs: + +1. If `useExternalAuthentication()` was called: skip the rest of this section. +2. For every `securityScheme` referenced by *any* operation's effective requirements: + - It must exist in `spec.securitySchemes` (else `IllegalStateException` — spec is malformed). + - It must not be `Unsupported` (else `IllegalStateException("scheme '' uses unsupported type ''")`). + - A validator must be registered for its name (else `IllegalStateException("no SchemeValidator registered for security scheme ''")`). + +Fail-fast at boot rather than at request time: prevents silent 401s in production when a validator was forgotten. + +## External-auth opt-out + +`Builder.useExternalAuthentication()` flips a single boolean. Effects: + +- `SecurityFilter` short-circuits to `next.proceed()` for every request. +- Boot-time validator check is skipped. +- `Request.principals()` returns an empty map; `Request.principal(name)` returns `Optional.empty()`. +- `securitySchemes` is still parsed and exposed on `Spec` (introspection unaffected). + +Consumers in sidecar deployments derive their own identity from headers the sidecar sets (e.g. `X-Authenticated-User`) via a normal `RequestInterceptor`, which can attach a `ScopedValue` or stash on the exchange as they see fit. + +## Testing + +The acceptance fixture `src/test/resources/openapi.json` (and its YAML mirror) grows a new `paths` group **under a separate prefix** (`/api/v1/secure/...`) with a representative mix: + +- `apiKeyAuth` (header `X-API-Key`) +- `bearerAuth` (HTTP bearer) +- `basicAuth` (HTTP basic) +- One operation with `security: []` to verify the per-operation opt-out +- One operation with a two-scheme AND group to verify the AND semantics + +**No root-level `security` is added to `openapi.json`.** A root-level requirement would apply to every existing operation, including the ones the k6 acceptance script hits (`/api/v1/data`, `/api/v1/list/objects`, `/api/v1/params/...`), causing all of them to 401. Root-level inheritance is exercised by a dedicated unit-test fixture under `src/test/resources/security/`, not by the shared `openapi.json` that `ServerLauncher` and k6 boot against. + +New unit tests cover: +- `SchemeParserTest` — every scheme type parses; unknown type maps to `Unsupported`; missing required fields throw. +- `RequirementResolutionTest` — op override (present/empty/absent) cases; OR-of-AND evaluation table. +- `CredentialExtractionTest` — happy path and malformed Basic, missing header, multiple-cookie selection, query parameter, mixed-case `Bearer`. +- `SecurityFilterTest` — for each combination: allow path stashes principals, deny path renders 403, missing path renders 401 with the right `WWW-Authenticate` headers, `useExternalAuthentication()` bypasses everything. +- `BootValidationTest` — missing validator throws; unsupported scheme throws when referenced; opt-out suppresses both. + +Integration test (`SecurityIT`) runs the real `HttpServer`: +- Authenticated request → 200 with principal-derived response body. +- Missing header → 401 with `WWW-Authenticate`. +- Wrong key → 403. +- Opt-out mode: missing credential still passes through to the handler. + +**k6 compatibility.** The acceptance script in `acceptance/k6/script.js` sends no `Authorization` headers and hits only the unsecured `/api/v1/...` operations. As long as we don't add a root-level `security` block to `src/test/resources/openapi.json` and don't attach `security` to those existing operations, k6 stays green. The new `/api/v1/secure/...` operations are exercised by JUnit only — not added to the k6 script. A quick `./acceptance/k6/...` run (or the equivalent `xargs -P 30 curl` smoke) is part of the PR verification checklist. + +## Out of scope + +- `oauth2` / `openIdConnect` / `mutualTLS` — parsed to `Unsupported`, no extraction logic. +- OAuth2 scope checking — the `scopes` list on `SecurityRequirement` is preserved in the model but ignored by the v1 filter. +- Library-side principal types — `Object` is what the callback returns; we don't ship a `Principal` interface or JWT decoder. +- Configurable "external auth" header bindings — the opt-out is all-or-nothing; consumers map sidecar headers themselves. +- Multi-error reporting — a rejected request stops at the first failed group's worst error, matching the current single-error parameter-validation behavior. +- WWW-Authenticate `realm` configurability — hardcoded to `"api"` in v1. From 8767a9323c39cf5a75e59501e1d2cc6fc759e020 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 11:55:06 +0200 Subject: [PATCH 02/23] docs: Security schemes implementation plan (16 tasks) --- ...6-05-18-security-schemes-implementation.md | 1956 +++++++++++++++++ 1 file changed, 1956 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-security-schemes-implementation.md diff --git a/docs/superpowers/plans/2026-05-18-security-schemes-implementation.md b/docs/superpowers/plans/2026-05-18-security-schemes-implementation.md new file mode 100644 index 0000000..8a29fca --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-security-schemes-implementation.md @@ -0,0 +1,1956 @@ +# Security schemes (OpenAPI 3.1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OpenAPI 3.1 `securitySchemes` + `security` support: library parses the metadata, extracts credentials, lets the consumer validate via a name-keyed callback, renders RFC 7807 rejections (401/403), and offers an `useExternalAuthentication()` opt-out for sidecar deployments. + +**Architecture:** New typed model under `com.retailsvc.http.spec.security` for schemes and requirements. New `SecurityFilter` in `com.retailsvc.http.internal` between `RequestPreparationFilter` and `DispatchHandler`. Consumer-facing types `Credential` (sealed) and `SchemeValidator` (functional interface) on the public surface. `Request` grows an immutable `withPrincipals(...)` factory; principals stash via the existing `ScopedValue` rebinding pattern. + +**Tech Stack:** Java 25, JDK `com.sun.net.httpserver`, JUnit 5, AssertJ, Mockito, Surefire/Failsafe. Code formatted by Google Java Format (pre-commit). Tests use static imports, camelCase method names, curly braces always. + +**Spec:** `docs/superpowers/specs/2026-05-18-security-schemes-design.md`. + +--- + +## File Structure + +**New files (production):** + +- `src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java` — sealed interface + records (`ApiKey`, `HttpBearer`, `HttpBasic`, `Unsupported`) + `Location` enum. +- `src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java` — record wrapping `Map>`. +- `src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java` — static helpers to parse raw maps into the typed model. +- `src/main/java/com/retailsvc/http/Credential.java` — sealed interface + `ApiKeyCredential`, `BearerCredential`, `BasicCredential` records. +- `src/main/java/com/retailsvc/http/SchemeValidator.java` — `@FunctionalInterface` `Optional validate(Request, Credential)`. +- `src/main/java/com/retailsvc/http/internal/CredentialExtractor.java` — package-private; given a scheme + exchange returns `ExtractionResult`. +- `src/main/java/com/retailsvc/http/internal/SecurityFilter.java` — the new filter. + +**Modified files (production):** + +- `src/main/java/com/retailsvc/http/spec/Spec.java` — add `securitySchemes` + `security` record components; parse in `Spec.from(...)`. +- `src/main/java/com/retailsvc/http/spec/Operation.java` — add `Optional> security`. +- `src/main/java/com/retailsvc/http/Request.java` — add `principals` + `principal(String)` + `withPrincipals(Map)`. +- `src/main/java/com/retailsvc/http/OpenApiServer.java` — wire `SecurityFilter` into the chain, boot-time validation. +- `src/main/java/com/retailsvc/http/OpenApiServerBuilder.java` (or wherever `Builder` lives) — `securityValidator(name, validator)` + `useExternalAuthentication()`. + +**New files (tests):** + +- `src/test/java/com/retailsvc/http/spec/security/SchemeParserTest.java` +- `src/test/java/com/retailsvc/http/spec/security/SecurityRequirementParseTest.java` +- `src/test/java/com/retailsvc/http/internal/CredentialExtractorTest.java` +- `src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java` +- `src/test/java/com/retailsvc/http/SecurityBootValidationTest.java` +- `src/test/java/com/retailsvc/http/SecurityIT.java` +- `src/test/resources/security/openapi-secured.json` — fixture with op-level overrides + root-level inheritance. + +**Modified fixtures:** + +- `src/test/resources/openapi.json` and `openapi.yaml` — append `/api/v1/secure/*` operations under a new path prefix. **No root-level `security`.** + +--- + +## Task 1: Spec model — `SecurityScheme` sealed interface + +**Files:** + +- Create: `src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java` +- Test: `src/test/java/com/retailsvc/http/spec/security/SchemeParserTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package com.retailsvc.http.spec.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class SchemeParserTest { + + @Test + void apiKeyHeaderParses() { + var scheme = + SecuritySchemeParser.parse(java.util.Map.of("type", "apiKey", "name", "X-API-Key", "in", "header")); + assertThat(scheme).isEqualTo(new ApiKey("X-API-Key", Location.HEADER)); + } + + @Test + void httpBearerParses() { + var scheme = + SecuritySchemeParser.parse(java.util.Map.of("type", "http", "scheme", "bearer", "bearerFormat", "JWT")); + assertThat(scheme).isEqualTo(new SecurityScheme.HttpBearer(Optional.of("JWT"))); + } + + @Test + void httpBasicParses() { + var scheme = SecuritySchemeParser.parse(java.util.Map.of("type", "http", "scheme", "basic")); + assertThat(scheme).isEqualTo(new SecurityScheme.HttpBasic()); + } + + @Test + void unknownTypeMapsToUnsupported() { + var scheme = SecuritySchemeParser.parse(java.util.Map.of("type", "oauth2")); + assertThat(scheme).isEqualTo(new SecurityScheme.Unsupported("oauth2")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=SchemeParserTest` +Expected: FAIL — `SecurityScheme` and `SecuritySchemeParser` don't exist yet. + +- [ ] **Step 3: Create `SecurityScheme.java`** + +```java +package com.retailsvc.http.spec.security; + +import java.util.Optional; + +public sealed interface SecurityScheme + permits SecurityScheme.ApiKey, + SecurityScheme.HttpBearer, + SecurityScheme.HttpBasic, + SecurityScheme.Unsupported { + + record ApiKey(String name, Location location) implements SecurityScheme { + public enum Location { + HEADER, + QUERY, + COOKIE + } + } + + record HttpBearer(Optional bearerFormat) implements SecurityScheme {} + + record HttpBasic() implements SecurityScheme {} + + /** Parsed but unsupported in v1 (oauth2, openIdConnect, mutualTLS). */ + record Unsupported(String type) implements SecurityScheme {} +} +``` + +- [ ] **Step 4: Create `SecuritySchemeParser.java` minimal version** + +```java +package com.retailsvc.http.spec.security; + +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.retailsvc.http.spec.security.SecurityScheme.Unsupported; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +public final class SecuritySchemeParser { + private SecuritySchemeParser() {} + + public static SecurityScheme parse(Map raw) { + String type = (String) raw.get("type"); + if (type == null) { + throw new IllegalArgumentException("securityScheme missing required 'type'"); + } + return switch (type) { + case "apiKey" -> parseApiKey(raw); + case "http" -> parseHttp(raw); + default -> new Unsupported(type); + }; + } + + private static SecurityScheme parseApiKey(Map raw) { + String name = (String) raw.get("name"); + String in = (String) raw.get("in"); + if (name == null || in == null) { + throw new IllegalArgumentException("apiKey scheme requires 'name' and 'in'"); + } + return new ApiKey(name, Location.valueOf(in.toUpperCase(Locale.ROOT))); + } + + private static SecurityScheme parseHttp(Map raw) { + String scheme = (String) raw.get("scheme"); + if (scheme == null) { + throw new IllegalArgumentException("http securityScheme requires 'scheme'"); + } + return switch (scheme.toLowerCase(Locale.ROOT)) { + case "bearer" -> new HttpBearer(Optional.ofNullable((String) raw.get("bearerFormat"))); + case "basic" -> new HttpBasic(); + default -> new Unsupported("http:" + scheme); + }; + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mvn test -Dtest=SchemeParserTest` +Expected: 4 tests pass. + +- [ ] **Step 6: Run SonarLint on the new files** + +Use the `mcp__sonarlint__sonar_analyze_file` tool for each new file. Fix any reported issues before committing (per the global rule in `~/.claude/memory/feedback_sonar_pre_push.md`). + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java \ + src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java \ + src/test/java/com/retailsvc/http/spec/security/SchemeParserTest.java +git commit -m "feat(spec): Add SecurityScheme sealed model + parser" +``` + +--- + +## Task 2: Spec model — `SecurityRequirement` + parser + +**Files:** + +- Create: `src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java` +- Modify: `src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java` (add `parseRequirements`) +- Test: `src/test/java/com/retailsvc/http/spec/security/SecurityRequirementParseTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package com.retailsvc.http.spec.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SecurityRequirementParseTest { + + @Test + void singleRequirementParses() { + List raw = List.of(Map.of("bearerAuth", List.of())); + List req = SecuritySchemeParser.parseRequirements(raw); + assertThat(req) + .containsExactly(new SecurityRequirement(Map.of("bearerAuth", List.of()))); + } + + @Test + void andGroupParses() { + List raw = List.of(Map.of("apiKey", List.of(), "bearer", List.of("admin"))); + List req = SecuritySchemeParser.parseRequirements(raw); + assertThat(req).hasSize(1); + assertThat(req.get(0).schemes()) + .containsEntry("apiKey", List.of()) + .containsEntry("bearer", List.of("admin")); + } + + @Test + void orGroupsParse() { + List raw = + List.of(Map.of("apiKey", List.of()), Map.of("bearer", List.of())); + List req = SecuritySchemeParser.parseRequirements(raw); + assertThat(req).hasSize(2); + } + + @Test + void nullReturnsEmptyList() { + assertThat(SecuritySchemeParser.parseRequirements(null)).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=SecurityRequirementParseTest` +Expected: FAIL — `SecurityRequirement` and `parseRequirements` don't exist. + +- [ ] **Step 3: Create `SecurityRequirement.java`** + +```java +package com.retailsvc.http.spec.security; + +import java.util.List; +import java.util.Map; + +/** + * One OR-branch in a {@code security} list. Each entry in {@link #schemes} is AND-ed: every scheme + * name must be satisfied for the requirement to hold. Scopes are preserved but unused in v1. + */ +public record SecurityRequirement(Map> schemes) { + public SecurityRequirement { + schemes = Map.copyOf(schemes); + } +} +``` + +- [ ] **Step 4: Add `parseRequirements` to `SecuritySchemeParser.java`** + +Append to the parser class: + +```java +@SuppressWarnings("unchecked") +public static List parseRequirements(List raw) { + if (raw == null || raw.isEmpty()) { + return List.of(); + } + List out = new java.util.ArrayList<>(raw.size()); + for (Object entry : raw) { + if (!(entry instanceof Map map)) { + throw new IllegalArgumentException("security requirement entries must be objects"); + } + Map> schemes = new java.util.LinkedHashMap<>(); + for (var e : map.entrySet()) { + String name = (String) e.getKey(); + List scopes = + e.getValue() instanceof List list + ? list.stream().map(Object::toString).toList() + : List.of(); + schemes.put(name, scopes); + } + out.add(new SecurityRequirement(schemes)); + } + return List.copyOf(out); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mvn test -Dtest=SecurityRequirementParseTest` +Expected: 4 tests pass. + +- [ ] **Step 6: Run SonarLint on the modified parser file. Fix any issues.** + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java \ + src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java \ + src/test/java/com/retailsvc/http/spec/security/SecurityRequirementParseTest.java +git commit -m "feat(spec): Add SecurityRequirement model + parser" +``` + +--- + +## Task 3: Wire `securitySchemes` + root `security` into `Spec` + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/spec/Spec.java` +- Test: `src/test/java/com/retailsvc/http/spec/SpecTest.java` (add cases; do not rewrite existing) + +- [ ] **Step 1: Write failing tests** + +Append to `SpecTest.java`: + +```java +@Test +void parsesSecuritySchemesFromComponents() { + Map raw = Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "paths", Map.of(), + "components", + Map.of( + "securitySchemes", + Map.of("apiKeyAuth", Map.of("type", "apiKey", "name", "X-API-Key", "in", "header")))); + + Spec spec = Spec.from(raw); + + assertThat(spec.securitySchemes()) + .containsKey("apiKeyAuth"); +} + +@Test +void parsesRootSecurity() { + Map raw = Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "paths", Map.of(), + "security", List.of(Map.of("bearerAuth", List.of()))); + + Spec spec = Spec.from(raw); + + assertThat(spec.security()).hasSize(1); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=SpecTest#parsesSecuritySchemesFromComponents+parsesRootSecurity` +Expected: FAIL — `securitySchemes()` and `security()` don't exist on `Spec`. + +- [ ] **Step 3: Extend the `Spec` record** + +In `Spec.java`, add two components to the record: + +```java +public record Spec( + String openapi, + Info info, + List servers, + List operations, + Map componentSchemas, + Map componentParameters, + String basePath, + Map schemaRefIndex, + Map parameterRefIndex, + Map extensions, + Map securitySchemes, + List security) { +``` + +(Add proper `import` statements so the FQN inline isn't needed — per the global memory rule "no inline fully-qualified type names".) + +- [ ] **Step 4: Populate them in `Spec.from(...)`** + +In `Spec.from(...)`, before the final `return new Spec(...)`, build: + +```java +Map components = (Map) raw.getOrDefault("components", Map.of()); +Map rawSchemes = + (Map) components.getOrDefault("securitySchemes", Map.of()); +Map securitySchemes = new java.util.LinkedHashMap<>(); +for (var e : rawSchemes.entrySet()) { + securitySchemes.put(e.getKey(), SecuritySchemeParser.parse((Map) e.getValue())); +} + +List rootSecurity = + SecuritySchemeParser.parseRequirements((List) raw.get("security")); +``` + +And pass `Map.copyOf(securitySchemes), rootSecurity` as the new args to `new Spec(...)`. + +- [ ] **Step 5: Fix any other compile errors** + +Other call sites of `new Spec(...)` (in test helpers if any) will not compile. Search: + +```bash +grep -rn "new Spec(" src/ +``` + +Add `Map.of(), List.of()` as the trailing args. + +- [ ] **Step 6: Run all tests** + +Run: `mvn test` +Expected: BUILD SUCCESS, all existing tests still pass, two new tests pass. + +- [ ] **Step 7: Run SonarLint on `Spec.java`. Fix any new issues.** + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/Spec.java \ + src/test/java/com/retailsvc/http/spec/SpecTest.java +git commit -m "feat(spec): Parse securitySchemes + root security into Spec" +``` + +--- + +## Task 4: Per-operation `security` on `Operation` + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/spec/Operation.java` +- Modify: `src/main/java/com/retailsvc/http/spec/Spec.java` (operation parsing path) +- Test: append to `SpecTest.java` + +- [ ] **Step 1: Write failing tests** + +Append to `SpecTest.java`: + +```java +@Test +void operationLevelSecurityOverridesRoot() { + Map raw = Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "security", List.of(Map.of("bearerAuth", List.of())), + "paths", + Map.of( + "/x", + Map.of( + "get", + Map.of( + "operationId", "getX", + "security", List.of(Map.of("apiKey", List.of())), + "responses", Map.of("200", Map.of("description", "ok")))))); + + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + + assertThat(op.security()).isPresent(); + assertThat(op.security().get()).hasSize(1); + assertThat(op.security().get().get(0).schemes()).containsKey("apiKey"); +} + +@Test +void operationEmptySecurityIsPreserved() { + Map raw = Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "security", List.of(Map.of("bearerAuth", List.of())), + "paths", + Map.of( + "/x", + Map.of( + "get", + Map.of( + "operationId", "getX", + "security", List.of(), + "responses", Map.of("200", Map.of("description", "ok")))))); + + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + + assertThat(op.security()).isPresent(); + assertThat(op.security().get()).isEmpty(); +} + +@Test +void operationWithoutSecurityIsEmptyOptional() { + Map raw = Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "paths", + Map.of( + "/x", + Map.of( + "get", + Map.of( + "operationId", "getX", + "responses", Map.of("200", Map.of("description", "ok")))))); + + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + + assertThat(op.security()).isEmpty(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=SpecTest` +Expected: 3 new tests FAIL. + +- [ ] **Step 3: Extend `Operation.java`** + +```java +package com.retailsvc.http.spec; + +import com.retailsvc.http.spec.security.SecurityRequirement; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public record Operation( + String operationId, + HttpMethod method, + PathTemplate path, + Optional requestBody, + List parameters, + Map responses, + Map extensions, + Optional> security) {} +``` + +- [ ] **Step 4: Update the operation-parsing call site in `Spec.java`** + +Find the `new Operation(...)` construction in `Spec.from(...)` (likely inside the path-iteration loop). For each operation, parse: + +```java +Optional> opSecurity = + rawOp.containsKey("security") + ? Optional.of(SecuritySchemeParser.parseRequirements((List) rawOp.get("security"))) + : Optional.empty(); +``` + +…and append `opSecurity` as the last argument to `new Operation(...)`. + +- [ ] **Step 5: Fix other Operation construction sites if any** + +```bash +grep -rn "new Operation(" src/ +``` + +Append `Optional.empty()` to each. + +- [ ] **Step 6: Run all tests** + +Run: `mvn test` +Expected: BUILD SUCCESS. + +- [ ] **Step 7: SonarLint scan + fix** + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/Operation.java \ + src/main/java/com/retailsvc/http/spec/Spec.java \ + src/test/java/com/retailsvc/http/spec/SpecTest.java +git commit -m "feat(spec): Add per-operation security with root-override semantics" +``` + +--- + +## Task 5: Public API — `Credential` sealed + `SchemeValidator` + +**Files:** + +- Create: `src/main/java/com/retailsvc/http/Credential.java` +- Create: `src/main/java/com/retailsvc/http/SchemeValidator.java` +- Test: none yet — these are just type declarations, covered by Task 6+. + +- [ ] **Step 1: Create `Credential.java`** + +```java +package com.retailsvc.http; + +/** + * A credential the library has extracted from a request, ready to hand to a {@link SchemeValidator}. + * Sealed so consumers can pattern-match across scheme types. + */ +public sealed interface Credential + permits Credential.ApiKeyCredential, Credential.BearerCredential, Credential.BasicCredential { + + record ApiKeyCredential(String value) implements Credential {} + + record BearerCredential(String token) implements Credential {} + + record BasicCredential(String username, String password) implements Credential {} +} +``` + +- [ ] **Step 2: Create `SchemeValidator.java`** + +```java +package com.retailsvc.http; + +import java.util.Optional; + +/** + * Consumer-provided callback that validates an extracted {@link Credential}. Return a non-empty + * {@link Optional} carrying the principal on success, or {@link Optional#empty()} to deny. + */ +@FunctionalInterface +public interface SchemeValidator { + Optional validate(Request request, Credential credential); +} +``` + +- [ ] **Step 3: Compile** + +Run: `mvn compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/retailsvc/http/Credential.java \ + src/main/java/com/retailsvc/http/SchemeValidator.java +git commit -m "feat: Add public Credential sealed type + SchemeValidator interface" +``` + +--- + +## Task 6: `CredentialExtractor` (per-scheme extraction) + +**Files:** + +- Create: `src/main/java/com/retailsvc/http/internal/CredentialExtractor.java` +- Test: `src/test/java/com/retailsvc/http/internal/CredentialExtractorTest.java` + +- [ ] **Step 1: Write failing tests** + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.retailsvc.http.Credential; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.net.URI; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class CredentialExtractorTest { + + private HttpExchange exchangeWithHeader(String key, String value, String query) { + HttpExchange ex = mock(HttpExchange.class); + Headers h = new Headers(); + if (value != null) { + h.add(key, value); + } + when(ex.getRequestHeaders()).thenReturn(h); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/x" + (query == null ? "" : "?" + query))); + return ex; + } + + @Test + void apiKeyHeaderPresentExtracts() { + var scheme = new ApiKey("X-API-Key", Location.HEADER); + var ex = exchangeWithHeader("X-API-Key", "abc123", null); + var result = CredentialExtractor.extract(scheme, ex); + assertThat(result).isEqualTo(ExtractionResult.found(new Credential.ApiKeyCredential("abc123"))); + } + + @Test + void apiKeyHeaderMissingReturnsMissing() { + var scheme = new ApiKey("X-API-Key", Location.HEADER); + var ex = exchangeWithHeader("Other", "irrelevant", null); + assertThat(CredentialExtractor.extract(scheme, ex)).isEqualTo(ExtractionResult.missing()); + } + + @Test + void apiKeyQueryExtracts() { + var scheme = new ApiKey("k", Location.QUERY); + var ex = exchangeWithHeader("Ignored", null, "k=v1&other=v2"); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.ApiKeyCredential("v1"))); + } + + @Test + void httpBearerPresentExtracts() { + var scheme = new HttpBearer(Optional.empty()); + var ex = exchangeWithHeader("Authorization", "Bearer abc.def.ghi", null); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.BearerCredential("abc.def.ghi"))); + } + + @Test + void httpBearerCaseInsensitive() { + var scheme = new HttpBearer(Optional.empty()); + var ex = exchangeWithHeader("Authorization", "bEaReR token", null); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.BearerCredential("token"))); + } + + @Test + void httpBasicValidBase64Extracts() { + var scheme = new HttpBasic(); + String creds = java.util.Base64.getEncoder().encodeToString("alice:s3cret".getBytes()); + var ex = exchangeWithHeader("Authorization", "Basic " + creds, null); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.BasicCredential("alice", "s3cret"))); + } + + @Test + void httpBasicMalformedBase64ReturnsMalformed() { + var scheme = new HttpBasic(); + var ex = exchangeWithHeader("Authorization", "Basic !!!not-base64", null); + assertThat(CredentialExtractor.extract(scheme, ex)).isEqualTo(ExtractionResult.malformed()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=CredentialExtractorTest` +Expected: FAIL — `CredentialExtractor` and `ExtractionResult` don't exist. + +- [ ] **Step 3: Create `ExtractionResult.java`** (inside `internal` package, package-private) + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.Credential; +import java.util.Optional; + +record ExtractionResult(Kind kind, Credential credential) { + enum Kind { + FOUND, + MISSING, + MALFORMED + } + + static ExtractionResult found(Credential credential) { + return new ExtractionResult(Kind.FOUND, credential); + } + + static ExtractionResult missing() { + return new ExtractionResult(Kind.MISSING, null); + } + + static ExtractionResult malformed() { + return new ExtractionResult(Kind.MALFORMED, null); + } +} +``` + +- [ ] **Step 4: Create `CredentialExtractor.java`** + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.Credential; +import com.retailsvc.http.spec.security.SecurityScheme; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.sun.net.httpserver.HttpExchange; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Locale; + +final class CredentialExtractor { + private CredentialExtractor() {} + + static ExtractionResult extract(SecurityScheme scheme, HttpExchange exchange) { + return switch (scheme) { + case ApiKey ak -> extractApiKey(ak, exchange); + case HttpBearer _ -> extractBearer(exchange); + case HttpBasic _ -> extractBasic(exchange); + case SecurityScheme.Unsupported _ -> + throw new IllegalStateException( + "extractor called with Unsupported scheme — should be caught at boot"); + }; + } + + private static ExtractionResult extractApiKey(ApiKey scheme, HttpExchange exchange) { + String value = + switch (scheme.location()) { + case HEADER -> exchange.getRequestHeaders().getFirst(scheme.name()); + case QUERY -> firstQueryValue(exchange.getRequestURI().getRawQuery(), scheme.name()); + case COOKIE -> firstCookieValue(exchange, scheme.name()); + }; + return value == null + ? ExtractionResult.missing() + : ExtractionResult.found(new Credential.ApiKeyCredential(value)); + } + + private static ExtractionResult extractBearer(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth == null) { + return ExtractionResult.missing(); + } + String[] parts = auth.split("\\s+", 2); + if (parts.length != 2 || !parts[0].equalsIgnoreCase("Bearer")) { + return ExtractionResult.missing(); + } + return ExtractionResult.found(new Credential.BearerCredential(parts[1])); + } + + private static ExtractionResult extractBasic(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth == null) { + return ExtractionResult.missing(); + } + String[] parts = auth.split("\\s+", 2); + if (parts.length != 2 || !parts[0].equalsIgnoreCase("Basic")) { + return ExtractionResult.missing(); + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(parts[1]); + } catch (IllegalArgumentException e) { + return ExtractionResult.malformed(); + } + String creds = new String(decoded, StandardCharsets.UTF_8); + int sep = creds.indexOf(':'); + if (sep < 0) { + return ExtractionResult.malformed(); + } + return ExtractionResult.found( + new Credential.BasicCredential(creds.substring(0, sep), creds.substring(sep + 1))); + } + + private static String firstQueryValue(String rawQuery, String name) { + if (rawQuery == null) { + return null; + } + String prefix = name + "="; + for (String pair : rawQuery.split("&")) { + if (pair.startsWith(prefix)) { + return java.net.URLDecoder.decode(pair.substring(prefix.length()), StandardCharsets.UTF_8); + } + } + return null; + } + + private static String firstCookieValue(HttpExchange exchange, String name) { + String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie"); + if (cookieHeader == null) { + return null; + } + for (String pair : cookieHeader.split(";")) { + String trimmed = pair.trim(); + if (trimmed.startsWith(name + "=")) { + return trimmed.substring(name.length() + 1); + } + } + return null; + } +} +``` + +(`Locale` import is for future safety with case handling; remove if Sonar flags it as unused.) + +- [ ] **Step 5: Run tests** + +Run: `mvn test -Dtest=CredentialExtractorTest` +Expected: 7 tests pass. + +- [ ] **Step 6: SonarLint scan + fix** + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/CredentialExtractor.java \ + src/main/java/com/retailsvc/http/internal/ExtractionResult.java \ + src/test/java/com/retailsvc/http/internal/CredentialExtractorTest.java +git commit -m "feat(internal): Add CredentialExtractor for apiKey/bearer/basic schemes" +``` + +--- + +## Task 7: `Request.withPrincipals(...)` + `principal(...)` + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/Request.java` +- Test: append to `src/test/java/com/retailsvc/http/RequestTest.java` + +- [ ] **Step 1: Write failing tests** + +Append to `RequestTest.java`: + +```java +@Test +void requestPrincipalsDefaultsEmpty() { + Request r = newMinimalRequest(); + assertThat(r.principals()).isEmpty(); + assertThat(r.principal("anything")).isEmpty(); +} + +@Test +void withPrincipalsCreatesCopy() { + Request r = newMinimalRequest(); + Map principals = Map.of("bearerAuth", "user-123"); + Request withP = r.withPrincipals(principals); + + assertThat(withP).isNotSameAs(r); + assertThat(r.principals()).isEmpty(); + assertThat(withP.principals()).isEqualTo(principals); + assertThat(withP.principal("bearerAuth")).contains("user-123"); +} + +// Helper — match the rest of the file's style; create with whatever the +// existing tests use. If a builder exists, reuse it; otherwise construct +// directly with empty/dummy values for body, mapper, etc. +private Request newMinimalRequest() { + // ... use the same constructor signature the existing tests use +} +``` + +(If `RequestTest.java` already has a helper, use that.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=RequestTest` +Expected: FAIL — `principals()`, `principal()`, `withPrincipals()` don't exist. + +- [ ] **Step 3: Modify `Request.java`** + +Add a `Map principals` field (final). The existing constructor gets a new parameter; add a delegating constructor that defaults principals to `Map.of()` so existing call sites compile unchanged. + +```java +private final Map principals; + +// existing public/package constructor — add the trailing principals parameter +public Request( + byte[] body, + Object parsedBody, + com.retailsvc.http.JsonMapper mapper, + String operationId, + Map pathParameters, + String rawQuery, + java.util.function.Function firstHeader, + Map principals) { + // assign all fields as before + this.principals = Map.copyOf(principals); +} + +// keep the old constructor as a delegating overload +public Request( + byte[] body, + Object parsedBody, + com.retailsvc.http.JsonMapper mapper, + String operationId, + Map pathParameters, + String rawQuery, + java.util.function.Function firstHeader) { + this(body, parsedBody, mapper, operationId, pathParameters, rawQuery, firstHeader, Map.of()); +} + +public Map principals() { + return principals; +} + +public Optional principal(String schemeName) { + return Optional.ofNullable(principals.get(schemeName)); +} + +public Request withPrincipals(Map principals) { + return new Request( + // pass every other field unchanged, plus the new principals + ); +} +``` + +(Replace inline FQNs with proper imports per the global memory rule.) + +- [ ] **Step 4: Run all tests** + +Run: `mvn test` +Expected: BUILD SUCCESS, existing tests unchanged, new ones pass. + +- [ ] **Step 5: SonarLint scan + fix** + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/Request.java \ + src/test/java/com/retailsvc/http/RequestTest.java +git commit -m "feat: Add Request.principals + withPrincipals immutable copy" +``` + +--- + +## Task 8: `SecurityFilter` — happy path (single allow) + +**Files:** + +- Create: `src/main/java/com/retailsvc/http/internal/SecurityFilter.java` +- Create: `src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java` + +- [ ] **Step 1: Write failing test** + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.retailsvc.http.Credential; +import com.retailsvc.http.Request; +import com.retailsvc.http.SchemeValidator; +import com.retailsvc.http.spec.Operation; +import com.retailsvc.http.spec.security.SecurityRequirement; +import com.retailsvc.http.spec.security.SecurityScheme; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.sun.net.httpserver.Filter.Chain; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class SecurityFilterTest { + + @Test + void allowsRequestWhenValidatorReturnsPrincipal() throws Exception { + // Setup: one op "getX" requires bearerAuth; validator returns "user-1". + Operation op = + new Operation( + "getX", + com.retailsvc.http.spec.HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of()))))); + + Map schemes = Map.of("bearerAuth", new HttpBearer(Optional.empty())); + Map validators = + Map.of("bearerAuth", (req, cred) -> Optional.of("user-1")); + + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), schemes, List.of(), validators, /*externalAuth=*/ false); + + HttpExchange ex = mock(HttpExchange.class); + Headers headers = new Headers(); + headers.add("Authorization", "Bearer token-xyz"); + when(ex.getRequestHeaders()).thenReturn(headers); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX")); + + Request req = newMinimalRequest("getX"); + ScopedValueHarness.runWith(req, () -> { + Chain chain = mock(Chain.class); + filter.doFilter(ex, chain); + }); + + // Verify principals were rebound (the chain-mock implementation should + // capture the ScopedValue at invocation time — see ScopedValueHarness). + assertThat(ScopedValueHarness.lastSeenPrincipals()).containsEntry("bearerAuth", "user-1"); + } + + private static Request newMinimalRequest(String operationId) { + return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null); + } +} +``` + +(`ScopedValueHarness` is a test helper described in step 4.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=SecurityFilterTest` +Expected: FAIL — `SecurityFilter` doesn't exist. + +- [ ] **Step 3: Create the test helper** + +`src/test/java/com/retailsvc/http/internal/ScopedValueHarness.java` (package-private): + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.Request; +import java.util.Map; + +final class ScopedValueHarness { + private static Map lastSeenPrincipals = Map.of(); + + static void runWith(Request seed, ThrowingRunnable r) throws Exception { + ScopedValue.where(DispatchHandler.CURRENT, seed) + .call( + () -> { + try { + r.run(); + } finally { + lastSeenPrincipals = DispatchHandler.CURRENT.get().principals(); + } + return null; + }); + } + + static Map lastSeenPrincipals() { + return lastSeenPrincipals; + } + + interface ThrowingRunnable { + void run() throws Exception; + } +} +``` + +- [ ] **Step 4: Create `SecurityFilter.java` — happy path only** + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.Credential; +import com.retailsvc.http.Request; +import com.retailsvc.http.SchemeValidator; +import com.retailsvc.http.spec.Operation; +import com.retailsvc.http.spec.security.SecurityRequirement; +import com.retailsvc.http.spec.security.SecurityScheme; +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class SecurityFilter extends Filter { + + private final Map operationsById; + private final Map schemes; + private final List rootSecurity; + private final Map validators; + private final boolean externalAuth; + + public SecurityFilter( + Map operationsById, + Map schemes, + List rootSecurity, + Map validators, + boolean externalAuth) { + this.operationsById = Map.copyOf(operationsById); + this.schemes = Map.copyOf(schemes); + this.rootSecurity = List.copyOf(rootSecurity); + this.validators = Map.copyOf(validators); + this.externalAuth = externalAuth; + } + + @Override + public String description() { + return "Security"; + } + + @Override + public void doFilter(HttpExchange exchange, Chain chain) throws IOException { + if (externalAuth) { + chain.doFilter(exchange); + return; + } + + Request request = DispatchHandler.CURRENT.get(); + Operation op = operationsById.get(request.operationId()); + List effective = op.security().orElse(rootSecurity); + + if (effective.isEmpty()) { + chain.doFilter(exchange); + return; + } + + for (SecurityRequirement group : effective) { + Map principals = trySatisfy(group, exchange, request); + if (principals != null) { + try { + ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(principals)) + .call( + () -> { + chain.doFilter(exchange); + return null; + }); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } + return; + } + } + + // No group satisfied. Task 9 will replace this with proper rejection. + throw new UnsupportedOperationException("rejection path not implemented yet"); + } + + /** Returns the principals map on success, null if this group cannot be satisfied. */ + private Map trySatisfy( + SecurityRequirement group, HttpExchange exchange, Request request) { + Map principals = new LinkedHashMap<>(); + for (var entry : group.schemes().entrySet()) { + String schemeName = entry.getKey(); + SecurityScheme scheme = schemes.get(schemeName); + ExtractionResult result = CredentialExtractor.extract(scheme, exchange); + if (result.kind() != ExtractionResult.Kind.FOUND) { + return null; + } + Optional principal = + validators.get(schemeName).validate(request, result.credential()); + if (principal.isEmpty()) { + return null; + } + principals.put(schemeName, principal.get()); + } + return Map.copyOf(principals); + } +} +``` + +- [ ] **Step 5: Run the happy-path test** + +Run: `mvn test -Dtest=SecurityFilterTest#allowsRequestWhenValidatorReturnsPrincipal` +Expected: PASS. + +- [ ] **Step 6: SonarLint scan + fix** + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/SecurityFilter.java \ + src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java \ + src/test/java/com/retailsvc/http/internal/ScopedValueHarness.java +git commit -m "feat(internal): SecurityFilter happy path (single-scheme allow)" +``` + +--- + +## Task 9: `SecurityFilter` — rejection rendering (401 / 403) + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/internal/SecurityFilter.java` +- Modify: `src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java` + +- [ ] **Step 1: Write failing tests** + +Append to `SecurityFilterTest.java`: + +```java +@Test +void missingCredentialReturns401WithWwwAuthenticate() throws Exception { + Operation op = opRequiring("bearerAuth"); + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), + Map.of("bearerAuth", new HttpBearer(Optional.empty())), + List.of(), + Map.of("bearerAuth", (req, cred) -> Optional.of("never-called")), + false); + + HttpExchange ex = exchangeNoAuth("getX"); + java.io.ByteArrayOutputStream body = new java.io.ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, mock(Chain.class))); + + verify(ex).sendResponseHeaders(eq(401), anyLong()); + assertThat(ex.getResponseHeaders().getFirst("WWW-Authenticate")).isEqualTo("Bearer realm=\"api\""); + assertThat(body.toString()).contains("\"status\":401").contains("credential missing"); +} + +@Test +void deniedValidatorReturns403() throws Exception { + Operation op = opRequiring("bearerAuth"); + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), + Map.of("bearerAuth", new HttpBearer(Optional.empty())), + List.of(), + Map.of("bearerAuth", (req, cred) -> Optional.empty()), // deny + false); + + HttpExchange ex = exchangeWithBearer("getX", "Bearer t"); + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, mock(Chain.class))); + + verify(ex).sendResponseHeaders(eq(403), anyLong()); + assertThat(ex.getResponseHeaders().get("WWW-Authenticate")).isNull(); +} +``` + +(Provide `opRequiring`, `exchangeNoAuth`, `exchangeWithBearer` as small private helpers — modeled on `exchangeWithHeader` from `CredentialExtractorTest`.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=SecurityFilterTest` +Expected: FAIL — current rejection path throws `UnsupportedOperationException`. + +- [ ] **Step 3: Replace the rejection path in `SecurityFilter.java`** + +Replace `throw new UnsupportedOperationException(...)` with the logic that tracks the worst failure across all attempted groups and renders the response. Add a small inner record `Outcome` to track per-group results: + +```java +private enum FailureKind { + MISSING, + MALFORMED, + DENIED +} + +private record Outcome(FailureKind worst, String schemeName, String detail) {} + +// at the end of doFilter, after the for-loop, compute the final Outcome and call: +renderRejection(exchange, schemes, finalOutcome); +``` + +Implementation sketch (full body): + +```java +List outcomes = new java.util.ArrayList<>(); +for (SecurityRequirement group : effective) { + Outcome attempted = tryGroup(group, exchange, request, /*principalsOut=*/ null); + if (attempted == null) { + // satisfied — already handled above via trySatisfy + } else { + outcomes.add(attempted); + } +} + +Outcome worst = + outcomes.stream() + .max(java.util.Comparator.comparing(o -> o.worst())) + .orElseThrow(); +renderRejection(exchange, worst); +``` + +Refactor `trySatisfy` so it returns either the principals map (allow) OR an `Outcome` (deny/missing/malformed). Two ways: + +- Return a sealed result type. Cleanest. +- Use two methods: `trySatisfy` (allow path only, returns map or null) and `diagnoseGroup` (failure path, called only if all groups failed). Slightly redundant but easier to follow. + +Either is acceptable; pick the sealed-result approach for readability: + +```java +sealed interface GroupResult permits GroupResult.Allowed, GroupResult.Denied { + record Allowed(Map principals) implements GroupResult {} + record Denied(FailureKind kind, String schemeName, String detail) implements GroupResult {} +} +``` + +Render via the existing `ProblemDetailRenderer` (mirror what `RequestPreparationFilter` does for validation failures — same RFC 7807 envelope, just with `status=401` or `403`). Inspect `ProblemDetailRenderer.java` for its signature; if it doesn't already support arbitrary status, extend it with an overload that accepts `(HttpExchange, int status, String title, String detail)`. + +- [ ] **Step 4: Run tests** + +Run: `mvn test -Dtest=SecurityFilterTest` +Expected: all SecurityFilter tests pass. + +- [ ] **Step 5: SonarLint scan + fix** + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/SecurityFilter.java \ + src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java \ + src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java +git commit -m "feat(internal): SecurityFilter rejection rendering (401/403 + WWW-Authenticate)" +``` + +--- + +## Task 10: OR-of-AND group evaluation + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/internal/SecurityFilter.java` (the existing logic already iterates groups; this task is about exercising the branch) +- Modify: `src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java` + +- [ ] **Step 1: Write failing tests** + +Append: + +```java +@Test +void andGroupAllSchemesMustSucceed() throws Exception { + // Group requires both apiKeyAuth AND bearerAuth. + Operation op = + new Operation( + "getX", com.retailsvc.http.spec.HttpMethod.GET, null, + Optional.empty(), List.of(), Map.of(), Map.of(), + Optional.of( + List.of( + new SecurityRequirement( + Map.of("apiKeyAuth", List.of(), "bearerAuth", List.of()))))); + // ... configure both validators to allow; verify principals contains both keys. +} + +@Test +void orFallbackTriesSecondGroupOnFirstFailure() throws Exception { + // requirements = [{apiKeyAuth}, {bearerAuth}] — first group denied, second allowed. +} +``` + +- [ ] **Step 2: Run tests** + +Most should pass already with current code (the loop iterates groups). If `andGroupAllSchemesMustSucceed` fails, fix. + +- [ ] **Step 3: SonarLint scan + fix** + +- [ ] **Step 4: Commit** + +```bash +git commit -am "test(internal): Cover OR-of-AND evaluation in SecurityFilter" +``` + +--- + +## Task 11: Builder API — `securityValidator(...)` + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` (Builder class — find it; on this codebase it is the inner `Builder` class inside `OpenApiServer.java`) +- Test: `src/test/java/com/retailsvc/http/SecurityBuilderTest.java` + +- [ ] **Step 1: Write failing test** + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class SecurityBuilderTest { + + @Test + void securityValidatorRequiresNonNullName() { + var builder = OpenApiServer.builder(/* a Spec — see existing tests for fixture loading */); + assertThatThrownBy(() -> builder.securityValidator(null, (r, c) -> Optional.empty())) + .isInstanceOf(NullPointerException.class); + } + + @Test + void securityValidatorRequiresNonNullValidator() { + var builder = OpenApiServer.builder(/* Spec */); + assertThatThrownBy(() -> builder.securityValidator("x", null)) + .isInstanceOf(NullPointerException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=SecurityBuilderTest` +Expected: FAIL — method doesn't exist. + +- [ ] **Step 3: Add to `OpenApiServer.Builder`** + +```java +private final java.util.Map securityValidators = new java.util.HashMap<>(); +private boolean externalAuth = false; + +public Builder securityValidator(String schemeName, SchemeValidator validator) { + java.util.Objects.requireNonNull(schemeName, "schemeName"); + java.util.Objects.requireNonNull(validator, "validator"); + securityValidators.put(schemeName, validator); + return this; +} + +public Builder useExternalAuthentication() { + this.externalAuth = true; + return this; +} +``` + +(Replace inline FQNs with proper imports.) + +- [ ] **Step 4: Run tests** + +Run: `mvn test -Dtest=SecurityBuilderTest` +Expected: PASS. + +- [ ] **Step 5: SonarLint scan + fix** + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/OpenApiServer.java \ + src/test/java/com/retailsvc/http/SecurityBuilderTest.java +git commit -m "feat: Add securityValidator + useExternalAuthentication builder methods" +``` + +--- + +## Task 12: Wire `SecurityFilter` into the chain + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` + +- [ ] **Step 1: Locate the chain assembly** + +Read `OpenApiServer.java`. Find where filters are added to the `HttpContext` (look for `.getFilters().add(...)` or similar). The current order is `ExceptionFilter` → `RequestPreparationFilter` → `DispatchHandler`. + +- [ ] **Step 2: Build the `Map` index** + +In the builder's `build()` method (just before the server is started), construct: + +```java +Map operationsById = spec.operations().stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + Operation::operationId, op -> op)); +``` + +- [ ] **Step 3: Insert the filter** + +After the line that adds `RequestPreparationFilter`, add: + +```java +context.getFilters().add( + new SecurityFilter( + operationsById, + spec.securitySchemes(), + spec.security(), + Map.copyOf(securityValidators), + externalAuth)); +``` + +- [ ] **Step 4: Run the full test suite** + +Run: `mvn verify` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: SonarLint scan + fix** + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/OpenApiServer.java +git commit -m "feat: Wire SecurityFilter into the request-processing chain" +``` + +--- + +## Task 13: Boot-time validator-registration check + +**Files:** + +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` (Builder.build) +- Test: `src/test/java/com/retailsvc/http/SecurityBootValidationTest.java` + +- [ ] **Step 1: Write failing tests** + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +// ... + +class SecurityBootValidationTest { + + @Test + void missingValidatorThrows() { + // Load a Spec where operation "getX" requires bearerAuth. + // builder() without .securityValidator("bearerAuth", ...) → .build() throws. + assertThatThrownBy(() -> OpenApiServer.builder(spec).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no SchemeValidator registered for security scheme 'bearerAuth'"); + } + + @Test + void unsupportedSchemeThrowsWhenReferenced() { + // Spec with oauth2 scheme referenced from an operation → build() throws. + assertThatThrownBy(() -> OpenApiServer.builder(specWithOauth2).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("unsupported type"); + } + + @Test + void externalAuthSkipsValidatorCheck() { + // Same spec as the missing-validator case, but with useExternalAuthentication() — must succeed. + OpenApiServer server = + OpenApiServer.builder(spec).useExternalAuthentication().build(); + assertThat(server).isNotNull(); + server.stop(0); + } +} +``` + +(Use a small in-test `Spec` constructed from a `Map`, mirroring `SpecTest` style; do not introduce a new fixture file.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=SecurityBootValidationTest` +Expected: FAIL — no boot validation yet. + +- [ ] **Step 3: Implement the check inside `Builder.build()`** + +Before returning the built server: + +```java +if (!externalAuth) { + java.util.Set referenced = new java.util.HashSet<>(); + for (Operation op : spec.operations()) { + for (SecurityRequirement req : op.security().orElse(spec.security())) { + referenced.addAll(req.schemes().keySet()); + } + } + for (String name : referenced) { + SecurityScheme scheme = spec.securitySchemes().get(name); + if (scheme == null) { + throw new IllegalStateException( + "security requirement references unknown scheme '" + name + "'"); + } + if (scheme instanceof SecurityScheme.Unsupported u) { + throw new IllegalStateException( + "scheme '" + name + "' uses unsupported type '" + u.type() + "'"); + } + if (!securityValidators.containsKey(name)) { + throw new IllegalStateException( + "no SchemeValidator registered for security scheme '" + name + "'"); + } + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn test -Dtest=SecurityBootValidationTest` +Expected: PASS. + +- [ ] **Step 5: SonarLint scan + fix** + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/OpenApiServer.java \ + src/test/java/com/retailsvc/http/SecurityBootValidationTest.java +git commit -m "feat: Fail fast at boot if security validators are missing" +``` + +--- + +## Task 14: `useExternalAuthentication()` opt-out coverage + +**Files:** + +- Modify: `src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java` + +- [ ] **Step 1: Write failing test** + +```java +@Test +void externalAuthBypassesEverything() throws Exception { + Operation op = opRequiring("bearerAuth"); // root says required + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), + Map.of("bearerAuth", new HttpBearer(Optional.empty())), + List.of(), + Map.of(), // no validators registered + /*externalAuth=*/ true); + + HttpExchange ex = exchangeNoAuth("getX"); + Chain chain = mock(Chain.class); + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain)); + + verify(chain).doFilter(ex); + assertThat(ScopedValueHarness.lastSeenPrincipals()).isEmpty(); +} +``` + +- [ ] **Step 2: Run — should pass already** + +Run: `mvn test -Dtest=SecurityFilterTest#externalAuthBypassesEverything` +Expected: PASS (the short-circuit is already in the filter from Task 8). If FAIL, fix. + +- [ ] **Step 3: Commit** + +```bash +git commit -am "test(internal): Cover useExternalAuthentication bypass" +``` + +--- + +## Task 15: Acceptance fixture — `/api/v1/secure/*` operations + +**Files:** + +- Modify: `src/test/resources/openapi.json` +- Modify: `src/test/resources/openapi.yaml` + +- [ ] **Step 1: Add secured operations to `openapi.json`** + +Under `"paths"`, add four entries — all under the `/api/v1/secure/...` prefix so they don't collide with the operations exercised by `acceptance/k6/script.js`: + +```jsonc +"/api/v1/secure/api-key": { + "get": { + "operationId": "secureApiKey", + "security": [{"apiKeyAuth": []}], + "responses": {"200": {"description": "ok"}} + } +}, +"/api/v1/secure/bearer": { + "get": { + "operationId": "secureBearer", + "security": [{"bearerAuth": []}], + "responses": {"200": {"description": "ok"}} + } +}, +"/api/v1/secure/basic": { + "get": { + "operationId": "secureBasic", + "security": [{"basicAuth": []}], + "responses": {"200": {"description": "ok"}} + } +}, +"/api/v1/secure/open": { + "get": { + "operationId": "secureOpen", + "security": [], + "responses": {"200": {"description": "ok"}} + } +} +``` + +Under `"components"`, add: + +```jsonc +"securitySchemes": { + "apiKeyAuth": {"type": "apiKey", "name": "X-API-Key", "in": "header"}, + "bearerAuth": {"type": "http", "scheme": "bearer"}, + "basicAuth": {"type": "http", "scheme": "basic"} +} +``` + +**Do not** add a top-level `"security"` block. Existing operations (`/api/v1/data` etc.) must continue to require no auth so the k6 script stays green. + +- [ ] **Step 2: Mirror the same additions in `openapi.yaml`** + +Per the global memory rule "openapi.yaml mirrors openapi.json", apply identical changes to the YAML fixture. + +- [ ] **Step 3: Run the full test suite** + +Run: `mvn verify` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Run k6 smoke locally** + +Boot the example server (in one terminal): + +```bash +mvn test-compile exec:java \ + -Dexec.mainClass=com.retailsvc.http.start.ServerLauncher \ + -Dexec.classpathScope=test +``` + +Run k6 (in another terminal — k6 must be installed; if it isn't, fall back to the curl smoke described in `CLAUDE.md`): + +```bash +k6 run acceptance/k6/script.js +``` + +Expected: every check passes (no 401s on the existing endpoints). If any check fails, the new operations leaked auth requirements onto an existing route — fix the JSON/YAML before continuing. + +- [ ] **Step 5: Commit** + +```bash +git add src/test/resources/openapi.json src/test/resources/openapi.yaml +git commit -m "test: Add /api/v1/secure/* operations and securitySchemes fixture" +``` + +--- + +## Task 16: `SecurityIT` integration test + +**Files:** + +- Create: `src/test/java/com/retailsvc/http/SecurityIT.java` + +- [ ] **Step 1: Write the integration test** + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.spec.Spec; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class SecurityIT { + + private OpenApiServer server; + + @AfterEach + void tearDown() { + if (server != null) server.stop(0); + } + + @Test + void apiKeyAllowedWithCorrectHeader() throws Exception { + Spec spec = Spec.fromPath(java.nio.file.Path.of("src/test/resources/openapi.json")); + server = + OpenApiServer.builder(spec) + .securityValidator( + "apiKeyAuth", + (req, cred) -> + cred instanceof Credential.ApiKeyCredential ak && "good".equals(ak.value()) + ? Optional.of("ok") + : Optional.empty()) + // register the other two as deny-all so they don't trip boot validation + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()) + .handler("secureApiKey", req -> Response.json(200, "{}")) + .handler("secureBearer", req -> Response.json(200, "{}")) + .handler("secureBasic", req -> Response.json(200, "{}")) + .handler("secureOpen", req -> Response.json(200, "{}")) + // also register handlers for the existing operations in openapi.json so the server boots + // ... (mirror what existing IT tests do — they likely have a helper for this) + .port(0) + .build(); + + HttpResponse ok = call(server.port(), "/api/v1/secure/api-key", "X-API-Key", "good"); + assertThat(ok.statusCode()).isEqualTo(200); + + HttpResponse missing = call(server.port(), "/api/v1/secure/api-key", null, null); + assertThat(missing.statusCode()).isEqualTo(401); + + HttpResponse denied = call(server.port(), "/api/v1/secure/api-key", "X-API-Key", "bad"); + assertThat(denied.statusCode()).isEqualTo(403); + } + + @Test + void externalAuthBypassesEverything() throws Exception { + Spec spec = Spec.fromPath(java.nio.file.Path.of("src/test/resources/openapi.json")); + server = + OpenApiServer.builder(spec) + .useExternalAuthentication() + .handler("secureApiKey", req -> Response.json(200, "{}")) + // ... handlers for the rest + .port(0) + .build(); + + HttpResponse r = call(server.port(), "/api/v1/secure/api-key", null, null); + assertThat(r.statusCode()).isEqualTo(200); + } + + private HttpResponse call(int port, String path, String headerName, String headerValue) + throws Exception { + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + path)) + .GET(); + if (headerName != null) req.header(headerName, headerValue); + return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString()); + } +} +``` + +**Note:** match the existing IT-class scaffolding (port binding, server lifecycle, handler registration helpers) by reading one of the existing `*IT.java` files (e.g. `OpenApiServerIT.java`). If that file has a shared `OpenApiServerHarness` or similar, reuse it instead of duplicating setup. + +- [ ] **Step 2: Run integration tests** + +Run: `mvn verify` +Expected: BUILD SUCCESS, `SecurityIT` runs under Failsafe. + +- [ ] **Step 3: SonarLint scan + fix** + +- [ ] **Step 4: Commit** + +```bash +git add src/test/java/com/retailsvc/http/SecurityIT.java +git commit -m "test: SecurityIT — end-to-end 200/401/403 + external-auth bypass" +``` + +--- + +## Final verification + +- [ ] **Step 1: Full build and test suite** + +Run: `mvn verify` +Expected: BUILD SUCCESS, all tests pass (Surefire + Failsafe). + +- [ ] **Step 2: k6 smoke** + +Boot `ServerLauncher`, run `k6 run acceptance/k6/script.js`. Every check must pass. + +- [ ] **Step 3: SonarLint final pass** + +Run `mcp__sonarlint__sonar_analyze_staged` (or `_file` on each modified file). Fix any remaining issues. Confirm 0 new issues introduced by the branch. + +- [ ] **Step 4: Push and open PR** + +```bash +git push -u origin feat/security-schemes-design +``` + +Then open the PR via the GitHub URL printed by `git push` (the user opens PRs manually in this repo — gh CLI is not configured for PR creation). + +--- + +## Self-review notes (for the executor) + +- Every task ends with a SonarLint scan + commit. The branch must never push code with new Sonar issues (per `~/.claude/memory/feedback_sonar_pre_push.md`). +- Never pass `-c commit.gpgsign=false` or `--no-gpg-sign` (per `~/.claude/memory/feedback_never_bypass_signing.md`). Default commit signing must apply. +- Commit subjects use sentence-case (`fix(test): Hoist...`, not `fix(test): hoist...`) to satisfy commitlint. +- Test method names use camelCase (no underscores). Bodies use static imports for AssertJ/Mockito/JUnit and curly braces for every conditional. +- The k6 compatibility constraint (Task 15) is the most fragile assumption — running k6 (or the curl smoke from `CLAUDE.md`) is non-optional before pushing. From 33c3ece01e05ac76cd0cf2ba8079d0a06f7851cd Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 11:59:05 +0200 Subject: [PATCH 03/23] feat(spec): Add SecurityScheme sealed model + parser --- .../http/spec/security/SecurityScheme.java | 25 ++++++++++ .../spec/security/SecuritySchemeParser.java | 47 +++++++++++++++++++ .../http/spec/security/SchemeParserTest.java | 39 +++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java create mode 100644 src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java create mode 100644 src/test/java/com/retailsvc/http/spec/security/SchemeParserTest.java diff --git a/src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java b/src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java new file mode 100644 index 0000000..facf62d --- /dev/null +++ b/src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java @@ -0,0 +1,25 @@ +package com.retailsvc.http.spec.security; + +import java.util.Optional; + +public sealed interface SecurityScheme + permits SecurityScheme.ApiKey, + SecurityScheme.HttpBearer, + SecurityScheme.HttpBasic, + SecurityScheme.Unsupported { + + record ApiKey(String name, Location location) implements SecurityScheme { + public enum Location { + HEADER, + QUERY, + COOKIE + } + } + + record HttpBearer(Optional bearerFormat) implements SecurityScheme {} + + record HttpBasic() implements SecurityScheme {} + + /** Parsed but unsupported in v1 (oauth2, openIdConnect, mutualTLS). */ + record Unsupported(String type) implements SecurityScheme {} +} diff --git a/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java new file mode 100644 index 0000000..5d43f9b --- /dev/null +++ b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java @@ -0,0 +1,47 @@ +package com.retailsvc.http.spec.security; + +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.retailsvc.http.spec.security.SecurityScheme.Unsupported; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +public final class SecuritySchemeParser { + private SecuritySchemeParser() {} + + public static SecurityScheme parse(Map raw) { + String type = (String) raw.get("type"); + if (type == null) { + throw new IllegalArgumentException("securityScheme missing required 'type'"); + } + return switch (type) { + case "apiKey" -> parseApiKey(raw); + case "http" -> parseHttp(raw); + default -> new Unsupported(type); + }; + } + + private static SecurityScheme parseApiKey(Map raw) { + String name = (String) raw.get("name"); + String in = (String) raw.get("in"); + if (name == null || in == null) { + throw new IllegalArgumentException("apiKey scheme requires 'name' and 'in'"); + } + return new ApiKey(name, Location.valueOf(in.toUpperCase(Locale.ROOT))); + } + + private static SecurityScheme parseHttp(Map raw) { + String scheme = (String) raw.get("scheme"); + if (scheme == null) { + throw new IllegalArgumentException("http securityScheme requires 'scheme'"); + } + return switch (scheme.toLowerCase(Locale.ROOT)) { + case "bearer" -> new HttpBearer(Optional.ofNullable((String) raw.get("bearerFormat"))); + case "basic" -> new HttpBasic(); + default -> new Unsupported("http:" + scheme); + }; + } +} diff --git a/src/test/java/com/retailsvc/http/spec/security/SchemeParserTest.java b/src/test/java/com/retailsvc/http/spec/security/SchemeParserTest.java new file mode 100644 index 0000000..d7110de --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/security/SchemeParserTest.java @@ -0,0 +1,39 @@ +package com.retailsvc.http.spec.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class SchemeParserTest { + + @Test + void apiKeyHeaderParses() { + var scheme = + SecuritySchemeParser.parse(Map.of("type", "apiKey", "name", "X-API-Key", "in", "header")); + assertThat(scheme).isEqualTo(new ApiKey("X-API-Key", Location.HEADER)); + } + + @Test + void httpBearerParses() { + var scheme = + SecuritySchemeParser.parse( + Map.of("type", "http", "scheme", "bearer", "bearerFormat", "JWT")); + assertThat(scheme).isEqualTo(new SecurityScheme.HttpBearer(Optional.of("JWT"))); + } + + @Test + void httpBasicParses() { + var scheme = SecuritySchemeParser.parse(Map.of("type", "http", "scheme", "basic")); + assertThat(scheme).isEqualTo(new SecurityScheme.HttpBasic()); + } + + @Test + void unknownTypeMapsToUnsupported() { + var scheme = SecuritySchemeParser.parse(Map.of("type", "oauth2")); + assertThat(scheme).isEqualTo(new SecurityScheme.Unsupported("oauth2")); + } +} From e7f1295e228585fec779b2b038fe1c2fae5d7dac Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:01:54 +0200 Subject: [PATCH 04/23] feat(spec): Add SecurityRequirement model + parser --- .../spec/security/SecurityRequirement.java | 14 +++++++ .../spec/security/SecuritySchemeParser.java | 27 +++++++++++++ .../SecurityRequirementParseTest.java | 39 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java create mode 100644 src/test/java/com/retailsvc/http/spec/security/SecurityRequirementParseTest.java diff --git a/src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java b/src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java new file mode 100644 index 0000000..c7dd140 --- /dev/null +++ b/src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java @@ -0,0 +1,14 @@ +package com.retailsvc.http.spec.security; + +import java.util.List; +import java.util.Map; + +/** + * One OR-branch in a {@code security} list. Each entry in {@link #schemes} is AND-ed: every scheme + * name must be satisfied for the requirement to hold. Scopes are preserved but unused in v1. + */ +public record SecurityRequirement(Map> schemes) { + public SecurityRequirement { + schemes = Map.copyOf(schemes); + } +} diff --git a/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java index 5d43f9b..fcd6167 100644 --- a/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java +++ b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java @@ -5,6 +5,9 @@ import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic; import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; import com.retailsvc.http.spec.security.SecurityScheme.Unsupported; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -33,6 +36,30 @@ private static SecurityScheme parseApiKey(Map raw) { return new ApiKey(name, Location.valueOf(in.toUpperCase(Locale.ROOT))); } + @SuppressWarnings("unchecked") + public static List parseRequirements(List raw) { + if (raw == null || raw.isEmpty()) { + return List.of(); + } + List out = new ArrayList<>(raw.size()); + for (Object entry : raw) { + if (!(entry instanceof Map map)) { + throw new IllegalArgumentException("security requirement entries must be objects"); + } + Map> schemes = new LinkedHashMap<>(); + for (var e : map.entrySet()) { + String name = (String) e.getKey(); + List scopes = + e.getValue() instanceof List list + ? list.stream().map(Object::toString).toList() + : List.of(); + schemes.put(name, scopes); + } + out.add(new SecurityRequirement(schemes)); + } + return List.copyOf(out); + } + private static SecurityScheme parseHttp(Map raw) { String scheme = (String) raw.get("scheme"); if (scheme == null) { diff --git a/src/test/java/com/retailsvc/http/spec/security/SecurityRequirementParseTest.java b/src/test/java/com/retailsvc/http/spec/security/SecurityRequirementParseTest.java new file mode 100644 index 0000000..edbf46e --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/security/SecurityRequirementParseTest.java @@ -0,0 +1,39 @@ +package com.retailsvc.http.spec.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SecurityRequirementParseTest { + + @Test + void singleRequirementParses() { + List raw = List.of(Map.of("bearerAuth", List.of())); + List req = SecuritySchemeParser.parseRequirements(raw); + assertThat(req).containsExactly(new SecurityRequirement(Map.of("bearerAuth", List.of()))); + } + + @Test + void andGroupParses() { + List raw = List.of(Map.of("apiKey", List.of(), "bearer", List.of("admin"))); + List req = SecuritySchemeParser.parseRequirements(raw); + assertThat(req).hasSize(1); + assertThat(req.get(0).schemes()) + .containsEntry("apiKey", List.of()) + .containsEntry("bearer", List.of("admin")); + } + + @Test + void orGroupsParse() { + List raw = List.of(Map.of("apiKey", List.of()), Map.of("bearer", List.of())); + List req = SecuritySchemeParser.parseRequirements(raw); + assertThat(req).hasSize(2); + } + + @Test + void nullReturnsEmptyList() { + assertThat(SecuritySchemeParser.parseRequirements(null)).isEmpty(); + } +} From 94100e85fa799a77f2e859659b9af41a3306547c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:02:30 +0200 Subject: [PATCH 05/23] fix(spec): Drop unused @SuppressWarnings on parseRequirements --- .../com/retailsvc/http/spec/security/SecuritySchemeParser.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java index fcd6167..3fa3273 100644 --- a/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java +++ b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java @@ -36,7 +36,6 @@ private static SecurityScheme parseApiKey(Map raw) { return new ApiKey(name, Location.valueOf(in.toUpperCase(Locale.ROOT))); } - @SuppressWarnings("unchecked") public static List parseRequirements(List raw) { if (raw == null || raw.isEmpty()) { return List.of(); From 2df13851ffbbcf0fc4b6dfe227bfa9a37b057db4 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:04:41 +0200 Subject: [PATCH 06/23] feat(spec): Parse securitySchemes + root security into Spec --- .../java/com/retailsvc/http/spec/Spec.java | 20 +++++++- .../RequestPreparationFilterTest.java | 4 +- .../com/retailsvc/http/spec/SpecTest.java | 50 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index a9b5fbb..b35a78f 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -2,6 +2,9 @@ import com.retailsvc.http.spec.schema.Schema; import com.retailsvc.http.spec.schema.SchemaParser; +import com.retailsvc.http.spec.security.SecurityRequirement; +import com.retailsvc.http.spec.security.SecurityScheme; +import com.retailsvc.http.spec.security.SecuritySchemeParser; import java.io.IOException; import java.io.UncheckedIOException; import java.lang.reflect.Method; @@ -25,7 +28,9 @@ public record Spec( String basePath, Map schemaRefIndex, Map parameterRefIndex, - Map extensions) { + Map extensions, + Map securitySchemes, + List security) { private static final String SCHEMA_KEY = "schema"; private static final String SCHEMA_REF_PREFIX = "#/components/schemas/"; @@ -138,6 +143,15 @@ public static Spec from(Map raw) { List operations = parseOperations( (Map) raw.getOrDefault("paths", Map.of()), componentParameters); + Map rawSchemes = + (Map) rawComponents.getOrDefault("securitySchemes", Map.of()); + Map securitySchemes = new LinkedHashMap<>(); + for (var entry : rawSchemes.entrySet()) { + securitySchemes.put( + entry.getKey(), SecuritySchemeParser.parse((Map) entry.getValue())); + } + List rootSecurity = + SecuritySchemeParser.parseRequirements((List) raw.get("security")); return new Spec( openapi, info, @@ -148,7 +162,9 @@ public static Spec from(Map raw) { computeBasePath(servers), indexByRef(componentSchemas, SCHEMA_REF_PREFIX), indexByRef(componentParameters, PARAMETER_REF_PREFIX), - extractExtensions(raw)); + extractExtensions(raw), + Map.copyOf(securitySchemes), + rootSecurity); } private static String computeBasePath(List servers) { diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index e07d43f..63be9db 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -58,7 +58,9 @@ private Spec specWith(Operation... ops) { "", Map.of(), Map.of(), - Map.of()); + Map.of(), + Map.of(), + List.of()); } private Filter newFilter(Spec spec) { diff --git a/src/test/java/com/retailsvc/http/spec/SpecTest.java b/src/test/java/com/retailsvc/http/spec/SpecTest.java index 1196561..66d3b9e 100644 --- a/src/test/java/com/retailsvc/http/spec/SpecTest.java +++ b/src/test/java/com/retailsvc/http/spec/SpecTest.java @@ -70,4 +70,54 @@ void parsesExistingFixture() throws Exception { Spec spec = Spec.from(loadJson("openapi.json")); assertThat(spec.operations()).isNotEmpty(); } + + @Test + void parsesSecuritySchemesFromComponents() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "paths", Map.of(), + "components", + Map.of( + "securitySchemes", + Map.of( + "apiKeyAuth", + Map.of("type", "apiKey", "name", "X-API-Key", "in", "header")))); + + Spec spec = Spec.from(raw); + + assertThat(spec.securitySchemes()).containsKey("apiKeyAuth"); + } + + @Test + void parsesRootSecurity() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "paths", Map.of(), + "security", List.of(Map.of("bearerAuth", List.of()))); + + Spec spec = Spec.from(raw); + + assertThat(spec.security()).hasSize(1); + } + + @Test + void securitySchemesDefaultsEmpty() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "paths", Map.of()); + + Spec spec = Spec.from(raw); + + assertThat(spec.securitySchemes()).isEmpty(); + assertThat(spec.security()).isEmpty(); + } } From 017ea209bf046e2db051393b55669e2a30c91400 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:08:40 +0200 Subject: [PATCH 07/23] feat(spec): Add per-operation security with root-override semantics Operation now carries an Optional> security field. When a path operation declares "security" in the OpenAPI doc, the parsed list is present (including an empty list to opt-out of root security); absent means no operation-level override. --- .../com/retailsvc/http/spec/Operation.java | 4 +- .../java/com/retailsvc/http/spec/Spec.java | 8 +- .../RequestPreparationFilterTest.java | 30 +++++--- .../retailsvc/http/internal/RouterTest.java | 9 ++- .../retailsvc/http/spec/OperationTest.java | 9 ++- .../com/retailsvc/http/spec/SpecTest.java | 75 +++++++++++++++++++ 6 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/retailsvc/http/spec/Operation.java b/src/main/java/com/retailsvc/http/spec/Operation.java index ea4203b..912c802 100644 --- a/src/main/java/com/retailsvc/http/spec/Operation.java +++ b/src/main/java/com/retailsvc/http/spec/Operation.java @@ -1,5 +1,6 @@ package com.retailsvc.http.spec; +import com.retailsvc.http.spec.security.SecurityRequirement; import java.util.List; import java.util.Map; import java.util.Optional; @@ -11,4 +12,5 @@ public record Operation( Optional requestBody, List parameters, Map responses, - Map extensions) {} + Map extensions, + Optional> security) {} diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index b35a78f..a9d242e 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -285,7 +285,13 @@ private static Operation parseOperation( .orElse(List.of()); Map responses = parseResponses((Map) raw.getOrDefault("responses", Map.of())); - return new Operation(opId, method, path, body, params, responses, extractExtensions(raw)); + Optional> opSecurity = + raw.containsKey("security") + ? Optional.of( + SecuritySchemeParser.parseRequirements((List) raw.get("security"))) + : Optional.empty(); + return new Operation( + opId, method, path, body, params, responses, extractExtensions(raw), opSecurity); } private static Parameter resolveParameterOrParse( diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 63be9db..3871a46 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -91,7 +91,8 @@ void successPathBindsRequestContextDuringChain() throws Exception { Optional.empty(), List.of(), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); HttpExchange ex = exchange("GET", "/users/42", new byte[0]); @@ -128,7 +129,8 @@ void unknownPathThrowsNotFound() { Optional.empty(), List.of(), Map.of(), - Map.of())); + Map.of(), + Optional.empty())); Filter f = newFilter(spec); HttpExchange ex = exchange("GET", "/missing", new byte[0]); @@ -147,7 +149,8 @@ void wrongMethodThrowsMethodNotAllowed() { Optional.empty(), List.of(), Map.of(), - Map.of())); + Map.of(), + Optional.empty())); Filter f = newFilter(spec); HttpExchange ex = exchange("POST", "/x", new byte[0]); @@ -167,7 +170,8 @@ void invalidQueryParamThrowsValidation() { Optional.empty(), List.of(new Parameter("q", Parameter.Location.QUERY, true, stringSchema)), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); @@ -190,7 +194,8 @@ void integerQueryParamIsCoercedFromStringBeforeValidation() throws Exception { Optional.empty(), List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); @@ -212,7 +217,8 @@ void integerQueryParamRejectsNonNumericString() { Optional.empty(), List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); @@ -235,7 +241,8 @@ void numberQueryParamIsCoercedFromStringBeforeValidation() throws Exception { Optional.empty(), List.of(new Parameter("n", Parameter.Location.QUERY, true, numSchema)), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); @@ -257,7 +264,8 @@ void numberQueryParamRejectsNonNumericString() { Optional.empty(), List.of(new Parameter("n", Parameter.Location.QUERY, true, numSchema)), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); @@ -279,7 +287,8 @@ void booleanQueryParamCoercesTrueAndFalse() throws Exception { Optional.empty(), List.of(new Parameter("b", Parameter.Location.QUERY, true, boolSchema)), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); @@ -304,7 +313,8 @@ void booleanQueryParamRejectsNonBooleanString() { Optional.empty(), List.of(new Parameter("b", Parameter.Location.QUERY, true, boolSchema)), Map.of(), - Map.of()); + Map.of(), + Optional.empty()); Spec spec = specWith(op); Filter f = newFilter(spec); diff --git a/src/test/java/com/retailsvc/http/internal/RouterTest.java b/src/test/java/com/retailsvc/http/internal/RouterTest.java index 4516f81..654e1f6 100644 --- a/src/test/java/com/retailsvc/http/internal/RouterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RouterTest.java @@ -13,7 +13,14 @@ class RouterTest { private Operation op(String id, HttpMethod m, String path) { return new Operation( - id, m, PathTemplate.compile(path), Optional.empty(), List.of(), Map.of(), Map.of()); + id, + m, + PathTemplate.compile(path), + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.empty()); } @Test diff --git a/src/test/java/com/retailsvc/http/spec/OperationTest.java b/src/test/java/com/retailsvc/http/spec/OperationTest.java index ca0dad1..64aaacc 100644 --- a/src/test/java/com/retailsvc/http/spec/OperationTest.java +++ b/src/test/java/com/retailsvc/http/spec/OperationTest.java @@ -22,7 +22,14 @@ void operationCarriesAllFields() { new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of())); Operation op = new Operation( - "get-user", HttpMethod.GET, path, Optional.empty(), List.of(param), Map.of(), Map.of()); + "get-user", + HttpMethod.GET, + path, + Optional.empty(), + List.of(param), + Map.of(), + Map.of(), + Optional.empty()); assertThat(op.operationId()).isEqualTo("get-user"); assertThat(op.method()).isEqualTo(HttpMethod.GET); assertThat(op.parameters()).hasSize(1); diff --git a/src/test/java/com/retailsvc/http/spec/SpecTest.java b/src/test/java/com/retailsvc/http/spec/SpecTest.java index 66d3b9e..091a2bd 100644 --- a/src/test/java/com/retailsvc/http/spec/SpecTest.java +++ b/src/test/java/com/retailsvc/http/spec/SpecTest.java @@ -120,4 +120,79 @@ void securitySchemesDefaultsEmpty() { assertThat(spec.securitySchemes()).isEmpty(); assertThat(spec.security()).isEmpty(); } + + @Test + void operationLevelSecurityOverridesRoot() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "security", List.of(Map.of("bearerAuth", List.of())), + "paths", + Map.of( + "/x", + Map.of( + "get", + Map.of( + "operationId", "getX", + "security", List.of(Map.of("apiKey", List.of())), + "responses", Map.of("200", Map.of("description", "ok")))))); + + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + + assertThat(op.security()).isPresent(); + assertThat(op.security().get()).hasSize(1); + assertThat(op.security().get().get(0).schemes()).containsKey("apiKey"); + } + + @Test + void operationEmptySecurityIsPreserved() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "security", List.of(Map.of("bearerAuth", List.of())), + "paths", + Map.of( + "/x", + Map.of( + "get", + Map.of( + "operationId", "getX", + "security", List.of(), + "responses", Map.of("200", Map.of("description", "ok")))))); + + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + + assertThat(op.security()).isPresent(); + assertThat(op.security().get()).isEmpty(); + } + + @Test + void operationWithoutSecurityIsEmptyOptional() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "T", "version", "1"), + "servers", List.of(Map.of("url", "/v1")), + "paths", + Map.of( + "/x", + Map.of( + "get", + Map.of( + "operationId", + "getX", + "responses", + Map.of("200", Map.of("description", "ok")))))); + + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + + assertThat(op.security()).isEmpty(); + } } From 7a99f14aa06e5de50e71a54aca95a8c0dffa450c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:09:33 +0200 Subject: [PATCH 08/23] feat: Add public Credential sealed type and SchemeValidator interface --- src/main/java/com/retailsvc/http/Credential.java | 15 +++++++++++++++ .../java/com/retailsvc/http/SchemeValidator.java | 12 ++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/Credential.java create mode 100644 src/main/java/com/retailsvc/http/SchemeValidator.java diff --git a/src/main/java/com/retailsvc/http/Credential.java b/src/main/java/com/retailsvc/http/Credential.java new file mode 100644 index 0000000..c3b1134 --- /dev/null +++ b/src/main/java/com/retailsvc/http/Credential.java @@ -0,0 +1,15 @@ +package com.retailsvc.http; + +/** + * A credential extracted from a request by the library, handed to a {@link SchemeValidator} for + * verification. Sealed so consumers can pattern-match across scheme types. + */ +public sealed interface Credential + permits Credential.ApiKeyCredential, Credential.BearerCredential, Credential.BasicCredential { + + record ApiKeyCredential(String value) implements Credential {} + + record BearerCredential(String token) implements Credential {} + + record BasicCredential(String username, String password) implements Credential {} +} diff --git a/src/main/java/com/retailsvc/http/SchemeValidator.java b/src/main/java/com/retailsvc/http/SchemeValidator.java new file mode 100644 index 0000000..00a6d13 --- /dev/null +++ b/src/main/java/com/retailsvc/http/SchemeValidator.java @@ -0,0 +1,12 @@ +package com.retailsvc.http; + +import java.util.Optional; + +/** + * Consumer-provided callback that validates an extracted {@link Credential}. Return a non-empty + * {@link Optional} carrying the principal on success, or {@link Optional#empty()} to deny. + */ +@FunctionalInterface +public interface SchemeValidator { + Optional validate(Request request, Credential credential); +} From 83b9937ec04654ae09a8ea94259f51ee4fac5f30 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:11:39 +0200 Subject: [PATCH 09/23] feat(internal): Add CredentialExtractor for apiKey/bearer/basic --- .../http/internal/CredentialExtractor.java | 101 ++++++++++++++++++ .../http/internal/ExtractionResult.java | 23 ++++ .../internal/CredentialExtractorTest.java | 87 +++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/CredentialExtractor.java create mode 100644 src/main/java/com/retailsvc/http/internal/ExtractionResult.java create mode 100644 src/test/java/com/retailsvc/http/internal/CredentialExtractorTest.java diff --git a/src/main/java/com/retailsvc/http/internal/CredentialExtractor.java b/src/main/java/com/retailsvc/http/internal/CredentialExtractor.java new file mode 100644 index 0000000..9dee2a0 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/CredentialExtractor.java @@ -0,0 +1,101 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.Credential; +import com.retailsvc.http.spec.security.SecurityScheme; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.sun.net.httpserver.HttpExchange; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +final class CredentialExtractor { + private CredentialExtractor() {} + + static ExtractionResult extract(SecurityScheme scheme, HttpExchange exchange) { + return switch (scheme) { + case ApiKey ak -> extractApiKey(ak, exchange); + case HttpBearer _ -> extractBearer(exchange); + case HttpBasic _ -> extractBasic(exchange); + case SecurityScheme.Unsupported _ -> + throw new IllegalStateException( + "extractor called with Unsupported scheme — should be caught at boot"); + }; + } + + private static ExtractionResult extractApiKey(ApiKey scheme, HttpExchange exchange) { + String value = + switch (scheme.location()) { + case HEADER -> exchange.getRequestHeaders().getFirst(scheme.name()); + case QUERY -> firstQueryValue(exchange.getRequestURI().getRawQuery(), scheme.name()); + case COOKIE -> firstCookieValue(exchange, scheme.name()); + }; + return value == null + ? ExtractionResult.missing() + : ExtractionResult.found(new Credential.ApiKeyCredential(value)); + } + + private static ExtractionResult extractBearer(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth == null) { + return ExtractionResult.missing(); + } + String[] parts = auth.split("\\s+", 2); + if (parts.length != 2 || !parts[0].equalsIgnoreCase("Bearer")) { + return ExtractionResult.missing(); + } + return ExtractionResult.found(new Credential.BearerCredential(parts[1])); + } + + private static ExtractionResult extractBasic(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth == null) { + return ExtractionResult.missing(); + } + String[] parts = auth.split("\\s+", 2); + if (parts.length != 2 || !parts[0].equalsIgnoreCase("Basic")) { + return ExtractionResult.missing(); + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(parts[1]); + } catch (IllegalArgumentException e) { + return ExtractionResult.malformed(); + } + String creds = new String(decoded, StandardCharsets.UTF_8); + int sep = creds.indexOf(':'); + if (sep < 0) { + return ExtractionResult.malformed(); + } + return ExtractionResult.found( + new Credential.BasicCredential(creds.substring(0, sep), creds.substring(sep + 1))); + } + + private static String firstQueryValue(String rawQuery, String name) { + if (rawQuery == null) { + return null; + } + String prefix = name + "="; + for (String pair : rawQuery.split("&")) { + if (pair.startsWith(prefix)) { + return URLDecoder.decode(pair.substring(prefix.length()), StandardCharsets.UTF_8); + } + } + return null; + } + + private static String firstCookieValue(HttpExchange exchange, String name) { + String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie"); + if (cookieHeader == null) { + return null; + } + for (String pair : cookieHeader.split(";")) { + String trimmed = pair.trim(); + if (trimmed.startsWith(name + "=")) { + return trimmed.substring(name.length() + 1); + } + } + return null; + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ExtractionResult.java b/src/main/java/com/retailsvc/http/internal/ExtractionResult.java new file mode 100644 index 0000000..cc9f65f --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ExtractionResult.java @@ -0,0 +1,23 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.Credential; + +record ExtractionResult(Kind kind, Credential credential) { + enum Kind { + FOUND, + MISSING, + MALFORMED + } + + static ExtractionResult found(Credential credential) { + return new ExtractionResult(Kind.FOUND, credential); + } + + static ExtractionResult missing() { + return new ExtractionResult(Kind.MISSING, null); + } + + static ExtractionResult malformed() { + return new ExtractionResult(Kind.MALFORMED, null); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/CredentialExtractorTest.java b/src/test/java/com/retailsvc/http/internal/CredentialExtractorTest.java new file mode 100644 index 0000000..716089d --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/CredentialExtractorTest.java @@ -0,0 +1,87 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.retailsvc.http.Credential; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.net.URI; +import java.util.Base64; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class CredentialExtractorTest { + + private HttpExchange exchangeWithHeader(String key, String value, String query) { + HttpExchange ex = mock(HttpExchange.class); + Headers h = new Headers(); + if (value != null) { + h.add(key, value); + } + when(ex.getRequestHeaders()).thenReturn(h); + when(ex.getRequestURI()) + .thenReturn(URI.create("http://h/x" + (query == null ? "" : "?" + query))); + return ex; + } + + @Test + void apiKeyHeaderPresentExtracts() { + var scheme = new ApiKey("X-API-Key", Location.HEADER); + var ex = exchangeWithHeader("X-API-Key", "abc123", null); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.ApiKeyCredential("abc123"))); + } + + @Test + void apiKeyHeaderMissingReturnsMissing() { + var scheme = new ApiKey("X-API-Key", Location.HEADER); + var ex = exchangeWithHeader("Other", "irrelevant", null); + assertThat(CredentialExtractor.extract(scheme, ex)).isEqualTo(ExtractionResult.missing()); + } + + @Test + void apiKeyQueryExtracts() { + var scheme = new ApiKey("k", Location.QUERY); + var ex = exchangeWithHeader("Ignored", null, "k=v1&other=v2"); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.ApiKeyCredential("v1"))); + } + + @Test + void httpBearerPresentExtracts() { + var scheme = new HttpBearer(Optional.empty()); + var ex = exchangeWithHeader("Authorization", "Bearer abc.def.ghi", null); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.BearerCredential("abc.def.ghi"))); + } + + @Test + void httpBearerCaseInsensitive() { + var scheme = new HttpBearer(Optional.empty()); + var ex = exchangeWithHeader("Authorization", "bEaReR token", null); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.BearerCredential("token"))); + } + + @Test + void httpBasicValidBase64Extracts() { + var scheme = new HttpBasic(); + String creds = Base64.getEncoder().encodeToString("alice:s3cret".getBytes()); + var ex = exchangeWithHeader("Authorization", "Basic " + creds, null); + assertThat(CredentialExtractor.extract(scheme, ex)) + .isEqualTo(ExtractionResult.found(new Credential.BasicCredential("alice", "s3cret"))); + } + + @Test + void httpBasicMalformedBase64ReturnsMalformed() { + var scheme = new HttpBasic(); + var ex = exchangeWithHeader("Authorization", "Basic !!!not-base64", null); + assertThat(CredentialExtractor.extract(scheme, ex)).isEqualTo(ExtractionResult.malformed()); + } +} From fa3a49094935c7e38b300c73a256315f67e3181d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:13:40 +0200 Subject: [PATCH 10/23] feat: Add Request.principals and withPrincipals immutable copy --- src/main/java/com/retailsvc/http/Request.java | 50 +++++++++++++++++++ .../java/com/retailsvc/http/RequestTest.java | 32 ++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index bedca16..09ab65b 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -29,6 +29,7 @@ public final class Request { private final Map pathParameters; private final String rawQuery; private final UnaryOperator headerLookup; + private final Map principals; private Map queryParamCache; /** @@ -52,6 +53,31 @@ public Request( Map pathParameters, String rawQuery, UnaryOperator headerLookup) { + this(body, parsed, bodyMapper, operationId, pathParameters, rawQuery, headerLookup, Map.of()); + } + + /** + * Builds a {@code Request} from transport-neutral primitives with an explicit principals map. + * + * @param body raw request body bytes; never {@code null}, may be empty + * @param parsed loose structural view of the body (Map / List / boxed primitive), or {@code null} + * @param bodyMapper {@link TypeMapper} that produced {@code parsed}, used for typed conversion; + * may be {@code null} if there is no body + * @param operationId the OpenAPI {@code operationId} the request was routed to + * @param pathParameters path variables extracted by the router + * @param rawQuery raw (percent-encoded) query string, or {@code null} if absent + * @param headerLookup first-value, case-insensitive header lookup; returns {@code null} if absent + * @param principals principals stashed by the security filter, keyed by scheme name + */ + public Request( + byte[] body, + Object parsed, + TypeMapper bodyMapper, + String operationId, + Map pathParameters, + String rawQuery, + UnaryOperator headerLookup, + Map principals) { this.body = body; this.parsed = parsed; this.bodyMapper = bodyMapper; @@ -59,6 +85,7 @@ public Request( this.pathParameters = pathParameters; this.rawQuery = rawQuery; this.headerLookup = headerLookup; + this.principals = Map.copyOf(principals); } public byte[] bytes() { @@ -163,6 +190,29 @@ public Optional queryParam(String name) { return raw == null || raw.isBlank() ? Optional.empty() : Optional.of(raw); } + /** + * Principals stashed by {@code SecurityFilter}, keyed by securityScheme name. Empty when the + * request had no security requirements or when {@code useExternalAuthentication()} is set. + */ + public Map principals() { + return principals; + } + + /** Convenience for the common single-scheme case. */ + public Optional principal(String schemeName) { + return Optional.ofNullable(principals.get(schemeName)); + } + + /** + * Returns a new {@code Request} identical to this one except with the supplied principals. Used + * by {@code SecurityFilter} on success; the returned instance carries the principals through to + * the {@link RequestHandler}. + */ + public Request withPrincipals(Map principals) { + return new Request( + body, parsed, bodyMapper, operationId, pathParameters, rawQuery, headerLookup, principals); + } + private static Map parseQuery(String query) { if (query == null || query.isBlank()) { return Map.of(); diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index 98afcb5..f8c4429 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.retailsvc.http.internal.DispatchHandler; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.UnaryOperator; @@ -199,6 +200,37 @@ void contentTypeEmptyWhenHeaderAbsent() { assertThat(req.contentType()).isEmpty(); } + @Test + void principalsDefaultsEmpty() { + Request r = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); + + assertThat(r.principals()).isEmpty(); + assertThat(r.principal("anything")).isEmpty(); + } + + @Test + void withPrincipalsCreatesImmutableCopy() { + Request r = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); + Map principals = Map.of("bearerAuth", "user-123"); + Request copy = r.withPrincipals(principals); + + assertThat(copy).isNotSameAs(r); + assertThat(r.principals()).isEmpty(); + assertThat(copy.principals()).isEqualTo(principals); + assertThat(copy.principal("bearerAuth")).contains("user-123"); + } + + @Test + void withPrincipalsDoesNotShareUnderlyingMap() { + Request r = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS); + HashMap mutable = new HashMap<>(); + mutable.put("a", "b"); + Request copy = r.withPrincipals(mutable); + mutable.put("a", "MUTATED"); + + assertThat(copy.principal("a")).contains("b"); + } + @Test void headerReturnsOptionalAndBlankIsAbsent() { Request req = From aa9732b40f7f0a3d68670a201a64da815a0d844a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:16:47 +0200 Subject: [PATCH 11/23] feat(internal): SecurityFilter happy path (single-scheme allow) Adds SecurityFilter with OR-of-AND group evaluation. Satisfied groups update the ScopedValue binding with extracted principals. Rejection path (Task 9) stubs with UnsupportedOperationException. Includes ScopedValueHarness test helper and SecurityFilterTest covering the allowed and no-security cases. --- .../http/internal/SecurityFilter.java | 100 ++++++++++++++++++ .../http/internal/ScopedValueHarness.java | 29 +++++ .../http/internal/SecurityFilterTest.java | 99 +++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/SecurityFilter.java create mode 100644 src/test/java/com/retailsvc/http/internal/ScopedValueHarness.java create mode 100644 src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java diff --git a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java new file mode 100644 index 0000000..5b36fcd --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -0,0 +1,100 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.Request; +import com.retailsvc.http.SchemeValidator; +import com.retailsvc.http.spec.Operation; +import com.retailsvc.http.spec.security.SecurityRequirement; +import com.retailsvc.http.spec.security.SecurityScheme; +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class SecurityFilter extends Filter { + + private final Map operationsById; + private final Map schemes; + private final List rootSecurity; + private final Map validators; + private final boolean externalAuth; + + public SecurityFilter( + Map operationsById, + Map schemes, + List rootSecurity, + Map validators, + boolean externalAuth) { + this.operationsById = Map.copyOf(operationsById); + this.schemes = Map.copyOf(schemes); + this.rootSecurity = List.copyOf(rootSecurity); + this.validators = Map.copyOf(validators); + this.externalAuth = externalAuth; + } + + @Override + public String description() { + return "Security"; + } + + @Override + public void doFilter(HttpExchange exchange, Chain chain) throws IOException { + if (externalAuth) { + chain.doFilter(exchange); + return; + } + + Request request = DispatchHandler.CURRENT.get(); + Operation op = operationsById.get(request.operationId()); + List effective = op.security().orElse(rootSecurity); + + if (effective.isEmpty()) { + chain.doFilter(exchange); + return; + } + + for (SecurityRequirement group : effective) { + Map principals = trySatisfy(group, exchange, request); + if (principals != null) { + try { + ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(principals)) + .call( + () -> { + chain.doFilter(exchange); + return null; + }); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IOException(e); + } + return; + } + } + + // Rejection rendering is Task 9. + throw new UnsupportedOperationException("rejection path not implemented yet"); + } + + private Map trySatisfy( + SecurityRequirement group, HttpExchange exchange, Request request) { + Map principals = new LinkedHashMap<>(); + for (var entry : group.schemes().entrySet()) { + String schemeName = entry.getKey(); + SecurityScheme scheme = schemes.get(schemeName); + ExtractionResult result = CredentialExtractor.extract(scheme, exchange); + if (result.kind() != ExtractionResult.Kind.FOUND) { + return null; + } + Optional principal = + validators.get(schemeName).validate(request, result.credential()); + if (principal.isEmpty()) { + return null; + } + principals.put(schemeName, principal.get()); + } + return Map.copyOf(principals); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/ScopedValueHarness.java b/src/test/java/com/retailsvc/http/internal/ScopedValueHarness.java new file mode 100644 index 0000000..0b7fee0 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ScopedValueHarness.java @@ -0,0 +1,29 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.Request; +import java.util.Map; + +final class ScopedValueHarness { + private static Map lastSeenPrincipals = Map.of(); + + static void runWith(Request seed, ThrowingRunnable r) throws Exception { + ScopedValue.where(DispatchHandler.CURRENT, seed) + .call( + () -> { + try { + r.run(); + } finally { + lastSeenPrincipals = DispatchHandler.CURRENT.get().principals(); + } + return null; + }); + } + + static Map lastSeenPrincipals() { + return lastSeenPrincipals; + } + + interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java new file mode 100644 index 0000000..655df36 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java @@ -0,0 +1,99 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.retailsvc.http.Request; +import com.retailsvc.http.SchemeValidator; +import com.retailsvc.http.spec.HttpMethod; +import com.retailsvc.http.spec.Operation; +import com.retailsvc.http.spec.security.SecurityRequirement; +import com.retailsvc.http.spec.security.SecurityScheme; +import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; +import com.sun.net.httpserver.Filter.Chain; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class SecurityFilterTest { + + @Test + void allowsRequestWhenValidatorReturnsPrincipal() throws Exception { + Operation op = + new Operation( + "getX", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of()))))); + + Map schemes = Map.of("bearerAuth", new HttpBearer(Optional.empty())); + Map validators = + Map.of("bearerAuth", (req, cred) -> Optional.of("user-1")); + + SecurityFilter filter = + new SecurityFilter(Map.of("getX", op), schemes, List.of(), validators, false); + + HttpExchange ex = mock(HttpExchange.class); + Headers headers = new Headers(); + headers.add("Authorization", "Bearer token-xyz"); + when(ex.getRequestHeaders()).thenReturn(headers); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX")); + + AtomicReference> capturedPrincipals = new AtomicReference<>(); + Chain chain = mock(Chain.class); + doAnswer( + inv -> { + capturedPrincipals.set(DispatchHandler.CURRENT.get().principals()); + return null; + }) + .when(chain) + .doFilter(any()); + + Request req = newMinimalRequest("getX"); + ScopedValueHarness.runWith(req, () -> filter.doFilter(ex, chain)); + + verify(chain).doFilter(ex); + assertThat(capturedPrincipals.get()).containsEntry("bearerAuth", "user-1"); + } + + @Test + void passesThroughWhenOperationHasNoSecurity() throws Exception { + Operation op = + new Operation( + "getY", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.empty()); // inherits root, root is empty + + SecurityFilter filter = + new SecurityFilter(Map.of("getY", op), Map.of(), List.of(), Map.of(), false); + + HttpExchange ex = mock(HttpExchange.class); + Chain chain = mock(Chain.class); + ScopedValueHarness.runWith(newMinimalRequest("getY"), () -> filter.doFilter(ex, chain)); + + verify(chain).doFilter(ex); + assertThat(ScopedValueHarness.lastSeenPrincipals()).isEmpty(); + } + + private static Request newMinimalRequest(String operationId) { + return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null); + } +} From 480bc436bb93a61e9872d8a0138aeab53b8d8e5f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:20:24 +0200 Subject: [PATCH 12/23] feat(internal): SecurityFilter renders 401/403 with WWW-Authenticate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the UnsupportedOperationException placeholder with a full rejection path: DENIED failures produce 403 Forbidden with no challenge header; MISSING/MALFORMED failures produce 401 Unauthorized with one WWW-Authenticate header per distinct scheme. Added a generic ProblemDetailRenderer.render(status, title, detail) overload used by the rejection renderer. Three new SecurityFilterTest cases cover bearer-missing→401, bearer-denied→403, and apiKey-missing→401. --- .../http/internal/ProblemDetailRenderer.java | 14 ++ .../http/internal/SecurityFilter.java | 95 ++++++++++++-- .../http/internal/SecurityFilterTest.java | 120 ++++++++++++++++++ 3 files changed, 218 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java index 56cb48b..31931d6 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java @@ -22,6 +22,20 @@ public final class ProblemDetailRenderer { private ProblemDetailRenderer() {} + public static String render(int status, String title, String detail) { + StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); + out.append('{'); + appendStringField(out, "type", PROBLEM_TYPE); + out.append(','); + appendStringField(out, "title", title); + out.append(','); + appendIntField(out, "status", status); + out.append(','); + appendStringField(out, "detail", detail); + out.append('}'); + return out.toString(); + } + public static String render(ValidationError error) { StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); out.append('{'); diff --git a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java index 5b36fcd..53a3579 100644 --- a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -8,8 +8,13 @@ import com.sun.net.httpserver.Filter; import com.sun.net.httpserver.HttpExchange; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -55,11 +60,12 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { return; } + List failures = new ArrayList<>(); for (SecurityRequirement group : effective) { - Map principals = trySatisfy(group, exchange, request); - if (principals != null) { + GroupOutcome outcome = tryGroup(group, exchange, request); + if (outcome instanceof GroupOutcome.Allowed allowed) { try { - ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(principals)) + ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(allowed.principals())) .call( () -> { chain.doFilter(exchange); @@ -72,29 +78,96 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { } return; } + failures.add((GroupOutcome.Failed) outcome); } - // Rejection rendering is Task 9. - throw new UnsupportedOperationException("rejection path not implemented yet"); + renderRejection(exchange, failures); } - private Map trySatisfy( - SecurityRequirement group, HttpExchange exchange, Request request) { + private GroupOutcome tryGroup(SecurityRequirement group, HttpExchange exchange, Request request) { Map principals = new LinkedHashMap<>(); for (var entry : group.schemes().entrySet()) { String schemeName = entry.getKey(); SecurityScheme scheme = schemes.get(schemeName); ExtractionResult result = CredentialExtractor.extract(scheme, exchange); - if (result.kind() != ExtractionResult.Kind.FOUND) { - return null; + if (result.kind() == ExtractionResult.Kind.MISSING) { + return new GroupOutcome.Failed(FailureKind.MISSING, schemeName); + } + if (result.kind() == ExtractionResult.Kind.MALFORMED) { + return new GroupOutcome.Failed(FailureKind.MALFORMED, schemeName); } Optional principal = validators.get(schemeName).validate(request, result.credential()); if (principal.isEmpty()) { - return null; + return new GroupOutcome.Failed(FailureKind.DENIED, schemeName); } principals.put(schemeName, principal.get()); } - return Map.copyOf(principals); + return new GroupOutcome.Allowed(Map.copyOf(principals)); + } + + private void renderRejection(HttpExchange exchange, List failures) + throws IOException { + boolean anyDenied = failures.stream().anyMatch(f -> f.kind() == FailureKind.DENIED); + int status = anyDenied ? 403 : 401; + String title = anyDenied ? "Forbidden" : "Unauthorized"; + + GroupOutcome.Failed pick = + failures.stream().max(Comparator.comparing(GroupOutcome.Failed::kind)).orElseThrow(); + String detail = describe(pick); + + byte[] body = + ProblemDetailRenderer.render(status, title, detail).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/problem+json"); + if (!anyDenied) { + LinkedHashSet attempted = new LinkedHashSet<>(); + for (GroupOutcome.Failed f : failures) { + attempted.add(f.schemeName()); + } + for (String name : attempted) { + exchange.getResponseHeaders().add("WWW-Authenticate", challengeFor(name)); + } + } + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private String describe(GroupOutcome.Failed f) { + return switch (f.kind()) { + case MISSING -> "credential missing for scheme '" + f.schemeName() + "'"; + case MALFORMED -> "credential malformed for scheme '" + f.schemeName() + "'"; + case DENIED -> "validator denied for scheme '" + f.schemeName() + "'"; + }; + } + + private String challengeFor(String schemeName) { + SecurityScheme scheme = schemes.get(schemeName); + return switch (scheme) { + case SecurityScheme.HttpBearer _ -> "Bearer realm=\"api\""; + case SecurityScheme.HttpBasic _ -> "Basic realm=\"api\""; + case SecurityScheme.ApiKey ak -> + "ApiKey location=" + + ak.location().name().toLowerCase(Locale.ROOT) + + ", name=\"" + + ak.name() + + "\""; + case SecurityScheme.Unsupported _ -> + throw new IllegalStateException( + "Unsupported scheme reached challenge rendering for '" + schemeName + "'"); + }; + } + + private sealed interface GroupOutcome permits GroupOutcome.Allowed, GroupOutcome.Failed { + + record Allowed(Map principals) implements GroupOutcome {} + + record Failed(FailureKind kind, String schemeName) implements GroupOutcome {} + } + + private enum FailureKind { + MISSING, + MALFORMED, + DENIED } } diff --git a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java index 655df36..2d2d04c 100644 --- a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -13,10 +15,13 @@ import com.retailsvc.http.spec.Operation; import com.retailsvc.http.spec.security.SecurityRequirement; import com.retailsvc.http.spec.security.SecurityScheme; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey; +import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location; import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer; import com.sun.net.httpserver.Filter.Chain; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; import java.net.URI; import java.util.List; import java.util.Map; @@ -93,6 +98,121 @@ void passesThroughWhenOperationHasNoSecurity() throws Exception { assertThat(ScopedValueHarness.lastSeenPrincipals()).isEmpty(); } + @Test + void missingCredentialReturns401WithBearerChallenge() throws Exception { + Operation op = + new Operation( + "getX", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of()))))); + + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), + Map.of("bearerAuth", new HttpBearer(Optional.empty())), + List.of(), + Map.of("bearerAuth", (req, cred) -> Optional.of("never-called")), + false); + + HttpExchange ex = mock(HttpExchange.class); + Headers headers = new Headers(); + when(ex.getRequestHeaders()).thenReturn(headers); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX")); + + Chain chain = mock(Chain.class); + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain)); + + verify(ex).sendResponseHeaders(eq(401), anyLong()); + assertThat(responseHeaders.getFirst("WWW-Authenticate")).isEqualTo("Bearer realm=\"api\""); + assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo("application/problem+json"); + assertThat(body.toString()) + .contains("\"status\":401") + .contains("credential missing") + .contains("bearerAuth"); + } + + @Test + void deniedValidatorReturns403WithoutChallenge() throws Exception { + Operation op = + new Operation( + "getX", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of()))))); + + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), + Map.of("bearerAuth", new HttpBearer(Optional.empty())), + List.of(), + Map.of("bearerAuth", (req, cred) -> Optional.empty()), + false); + + HttpExchange ex = mock(HttpExchange.class); + Headers headers = new Headers(); + headers.add("Authorization", "Bearer t"); + when(ex.getRequestHeaders()).thenReturn(headers); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX")); + + Chain chain = mock(Chain.class); + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain)); + + verify(ex).sendResponseHeaders(eq(403), anyLong()); + assertThat(responseHeaders.getFirst("WWW-Authenticate")).isNull(); + } + + @Test + void apiKeyMissingReturnsApiKeyChallengeHeader() throws Exception { + Operation op = + new Operation( + "getX", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of(List.of(new SecurityRequirement(Map.of("apiKeyAuth", List.of()))))); + + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), + Map.of("apiKeyAuth", new ApiKey("X-API-Key", Location.HEADER)), + List.of(), + Map.of("apiKeyAuth", (req, cred) -> Optional.of("ok")), + false); + + HttpExchange ex = mock(HttpExchange.class); + when(ex.getRequestHeaders()).thenReturn(new Headers()); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX")); + + ScopedValueHarness.runWith( + newMinimalRequest("getX"), () -> filter.doFilter(ex, mock(Chain.class))); + + verify(ex).sendResponseHeaders(eq(401), anyLong()); + assertThat(responseHeaders.getFirst("WWW-Authenticate")) + .isEqualTo("ApiKey location=header, name=\"X-API-Key\""); + } + private static Request newMinimalRequest(String operationId) { return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null); } From e11ce37781bdb1745ff4b73865eff401c8589180 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:22:01 +0200 Subject: [PATCH 13/23] test(internal): Cover OR-of-AND evaluation in SecurityFilter --- .../http/internal/SecurityFilterTest.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java index 2d2d04c..d895cf6 100644 --- a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java @@ -213,6 +213,112 @@ void apiKeyMissingReturnsApiKeyChallengeHeader() throws Exception { .isEqualTo("ApiKey location=header, name=\"X-API-Key\""); } + @Test + void andGroupRequiresAllSchemesToSucceed() throws Exception { + // Group requires both apiKeyAuth AND bearerAuth. + Operation op = + new Operation( + "getX", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of( + List.of( + new SecurityRequirement( + Map.of("apiKeyAuth", List.of(), "bearerAuth", List.of()))))); + + Map schemes = + Map.of( + "apiKeyAuth", new ApiKey("X-API-Key", Location.HEADER), + "bearerAuth", new HttpBearer(Optional.empty())); + + Map validators = + Map.of( + "apiKeyAuth", (req, cred) -> Optional.of("api-principal"), + "bearerAuth", (req, cred) -> Optional.of("bearer-principal")); + + SecurityFilter filter = + new SecurityFilter(Map.of("getX", op), schemes, List.of(), validators, false); + + HttpExchange ex = mock(HttpExchange.class); + Headers headers = new Headers(); + headers.add("X-API-Key", "abc"); + headers.add("Authorization", "Bearer token"); + when(ex.getRequestHeaders()).thenReturn(headers); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX")); + + Map captured = new java.util.HashMap<>(); + Chain chain = mock(Chain.class); + doAnswer( + inv -> { + captured.putAll(DispatchHandler.CURRENT.get().principals()); + return null; + }) + .when(chain) + .doFilter(ex); + + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain)); + + assertThat(captured) + .containsEntry("apiKeyAuth", "api-principal") + .containsEntry("bearerAuth", "bearer-principal"); + } + + @Test + void orFallsBackToSecondGroupWhenFirstDenied() throws Exception { + // [{apiKeyAuth}, {bearerAuth}] — first group denied, second allowed. + Operation op = + new Operation( + "getX", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of( + List.of( + new SecurityRequirement(Map.of("apiKeyAuth", List.of())), + new SecurityRequirement(Map.of("bearerAuth", List.of()))))); + + Map schemes = + Map.of( + "apiKeyAuth", new ApiKey("X-API-Key", Location.HEADER), + "bearerAuth", new HttpBearer(Optional.empty())); + + Map validators = + Map.of( + "apiKeyAuth", (req, cred) -> Optional.empty(), + "bearerAuth", (req, cred) -> Optional.of("bearer-ok")); + + SecurityFilter filter = + new SecurityFilter(Map.of("getX", op), schemes, List.of(), validators, false); + + HttpExchange ex = mock(HttpExchange.class); + Headers headers = new Headers(); + headers.add("X-API-Key", "bad"); + headers.add("Authorization", "Bearer token"); + when(ex.getRequestHeaders()).thenReturn(headers); + when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX")); + + Map captured = new java.util.HashMap<>(); + Chain chain = mock(Chain.class); + doAnswer( + inv -> { + captured.putAll(DispatchHandler.CURRENT.get().principals()); + return null; + }) + .when(chain) + .doFilter(ex); + + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain)); + + assertThat(captured).containsEntry("bearerAuth", "bearer-ok").doesNotContainKey("apiKeyAuth"); + } + private static Request newMinimalRequest(String operationId) { return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null); } From 5b35d8f442c97f3bd6a68071189c8a858207d2f7 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:24:38 +0200 Subject: [PATCH 14/23] feat: Add securityValidator and useExternalAuthentication builder methods --- .../com/retailsvc/http/OpenApiServer.java | 26 +++++++++++++++ .../retailsvc/http/SecurityBuilderTest.java | 33 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/SecurityBuilderTest.java diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index ee61c0e..215f240 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -139,6 +139,8 @@ public static final class Builder { private int port = DEFAULT_PORT; private int shutdownTimeoutSeconds = 0; private final LinkedHashMap extras = new LinkedHashMap<>(); + private final Map securityValidators = new LinkedHashMap<>(); + private boolean externalAuth = false; private Builder() {} @@ -182,6 +184,30 @@ public Builder interceptor(RequestInterceptor interceptor) { return this; } + /** + * Registers a {@link SchemeValidator} for the OpenAPI security scheme named {@code schemeName}. + * The library extracts a {@link Credential} per request and hands it to this callback; return a + * non-empty {@link Optional} carrying the principal on success, or {@link Optional#empty()} to + * deny. Library renders 401/403 on denial. + */ + public Builder securityValidator(String schemeName, SchemeValidator validator) { + requireNonNull(schemeName, "schemeName must not be null"); + requireNonNull(validator, "validator must not be null"); + securityValidators.put(schemeName, validator); + return this; + } + + /** + * Opts out of in-process security enforcement. Use when an external sidecar (OPA/Envoy etc.) + * authenticates requests upstream. The library still parses {@code securitySchemes} into the + * {@link Spec}, but {@code SecurityFilter} short-circuits and the boot-time + * validator-registration check is skipped. + */ + public Builder useExternalAuthentication() { + this.externalAuth = true; + return this; + } + public Builder exceptionHandler(ExceptionHandler exceptionHandler) { this.exceptionHandler = exceptionHandler; return this; diff --git a/src/test/java/com/retailsvc/http/SecurityBuilderTest.java b/src/test/java/com/retailsvc/http/SecurityBuilderTest.java new file mode 100644 index 0000000..154ac10 --- /dev/null +++ b/src/test/java/com/retailsvc/http/SecurityBuilderTest.java @@ -0,0 +1,33 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class SecurityBuilderTest { + + @Test + void securityValidatorRequiresNonNullName() { + var builder = OpenApiServer.builder(); + assertThatThrownBy(() -> builder.securityValidator(null, (r, c) -> Optional.empty())) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("schemeName"); + } + + @Test + void securityValidatorRequiresNonNullValidator() { + var builder = OpenApiServer.builder(); + assertThatThrownBy(() -> builder.securityValidator("x", null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("validator"); + } + + @Test + void useExternalAuthenticationIsFluent() { + var builder = OpenApiServer.builder(); + var returned = builder.useExternalAuthentication(); + assertThat(returned).isSameAs(builder); + } +} From dbd03c99690e2f4b6c37500ed3f93002eae7dea0 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:26:58 +0200 Subject: [PATCH 15/23] feat: Wire SecurityFilter into the request-processing chain --- .../com/retailsvc/http/OpenApiServer.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 215f240..c36bc05 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -10,7 +10,9 @@ import com.retailsvc.http.internal.RequestPreparationFilter; import com.retailsvc.http.internal.ResponseRenderer; import com.retailsvc.http.internal.Router; +import com.retailsvc.http.internal.SecurityFilter; import com.retailsvc.http.internal.TextTypeMapper; +import com.retailsvc.http.spec.Operation; import com.retailsvc.http.spec.Spec; import com.retailsvc.http.validate.DefaultValidator; import com.sun.net.httpserver.HttpContext; @@ -24,6 +26,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,7 +52,9 @@ record HandlerConfig( List interceptors, List decorators, ExceptionHandler exceptionHandler, - Map extras) {} + Map extras, + Map securityValidators, + boolean externalAuth) {} OpenApiServer( Spec spec, @@ -70,6 +75,9 @@ record HandlerConfig( long t0 = System.currentTimeMillis(); Router router = new Router(spec.operations()); + Map operationsById = + spec.operations().stream() + .collect(Collectors.toUnmodifiableMap(Operation::operationId, op -> op)); DefaultValidator validator = new DefaultValidator(spec::resolveSchema); this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); @@ -78,6 +86,14 @@ record HandlerConfig( HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/")); ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, bodyMappers)); + ctx.getFilters() + .add( + new SecurityFilter( + operationsById, + spec.securitySchemes(), + spec.security(), + handlerConfig.securityValidators(), + handlerConfig.externalAuth())); ctx.setHandler( new DispatchHandler( handlerConfig.handlers(), @@ -260,7 +276,14 @@ public OpenApiServer build() throws IOException { } Map resolved = resolveBodyMappers(bodyMappers); HandlerConfig handlerConfig = - new HandlerConfig(handlers, interceptors, decorators, exceptionHandler, extras); + new HandlerConfig( + handlers, + interceptors, + decorators, + exceptionHandler, + extras, + Map.copyOf(securityValidators), + externalAuth); return new OpenApiServer(spec, resolved, handlerConfig, port, shutdownTimeoutSeconds); } From 8e82dccfc0eceafd74a82ce116afbe93f14a2936 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:45:56 +0200 Subject: [PATCH 16/23] feat: Fail fast at boot if security validators are missing Builder.build() now calls validateSecurityWiring() before constructing the server when externalAuth is false. It collects all scheme names referenced by any operation's effective security, and throws IllegalStateException for unknown, Unsupported, or validator-less schemes. --- .../com/retailsvc/http/OpenApiServer.java | 31 +++++ .../http/SecurityBootValidationTest.java | 127 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/SecurityBootValidationTest.java diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index c36bc05..bd0e069 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -14,6 +14,8 @@ import com.retailsvc.http.internal.TextTypeMapper; import com.retailsvc.http.spec.Operation; import com.retailsvc.http.spec.Spec; +import com.retailsvc.http.spec.security.SecurityRequirement; +import com.retailsvc.http.spec.security.SecurityScheme; import com.retailsvc.http.validate.DefaultValidator; import com.sun.net.httpserver.HttpContext; import com.sun.net.httpserver.HttpHandler; @@ -22,10 +24,12 @@ import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -274,6 +278,9 @@ public OpenApiServer build() throws IOException { "extra handler path " + path + " conflicts with spec basePath " + basePath); } } + if (!externalAuth) { + validateSecurityWiring(spec, securityValidators); + } Map resolved = resolveBodyMappers(bodyMappers); HandlerConfig handlerConfig = new HandlerConfig( @@ -287,6 +294,30 @@ public OpenApiServer build() throws IOException { return new OpenApiServer(spec, resolved, handlerConfig, port, shutdownTimeoutSeconds); } + private static void validateSecurityWiring(Spec spec, Map validators) { + Set referenced = new LinkedHashSet<>(); + for (Operation op : spec.operations()) { + for (SecurityRequirement req : op.security().orElse(spec.security())) { + referenced.addAll(req.schemes().keySet()); + } + } + for (String name : referenced) { + SecurityScheme scheme = spec.securitySchemes().get(name); + if (scheme == null) { + throw new IllegalStateException( + "security requirement references unknown scheme '" + name + "'"); + } + if (scheme instanceof SecurityScheme.Unsupported u) { + throw new IllegalStateException( + "scheme '" + name + "' uses unsupported type '" + u.type() + "'"); + } + if (!validators.containsKey(name)) { + throw new IllegalStateException( + "no SchemeValidator registered for security scheme '" + name + "'"); + } + } + } + private static Map resolveBodyMappers( Map userSupplied) { LinkedHashMap out = new LinkedHashMap<>(); diff --git a/src/test/java/com/retailsvc/http/SecurityBootValidationTest.java b/src/test/java/com/retailsvc/http/SecurityBootValidationTest.java new file mode 100644 index 0000000..52a8bc9 --- /dev/null +++ b/src/test/java/com/retailsvc/http/SecurityBootValidationTest.java @@ -0,0 +1,127 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.retailsvc.http.spec.Spec; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SecurityBootValidationTest { + + private static Map raw( + Map securitySchemes, List rootSecurity, List opSecurity) { + return Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "T", "version", "1"), + "servers", + List.of(Map.of("url", "/v1")), + "security", + rootSecurity == null ? List.of() : rootSecurity, + "components", + securitySchemes == null ? Map.of() : Map.of("securitySchemes", securitySchemes), + "paths", + Map.of( + "/x", + Map.of( + "get", + opSecurity == null + ? Map.of( + "operationId", + "getX", + "responses", + Map.of("200", Map.of("description", "ok"))) + : Map.of( + "operationId", + "getX", + "security", + opSecurity, + "responses", + Map.of("200", Map.of("description", "ok")))))); + } + + @Test + void missingValidatorThrows() { + Map r = + raw( + Map.of("bearerAuth", Map.of("type", "http", "scheme", "bearer")), + List.of(), + List.of(Map.of("bearerAuth", List.of()))); + Spec spec = Spec.from(r); + + assertThatThrownBy( + () -> + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getX", req -> Response.ok(Map.of()))) + .port(0) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("bearerAuth"); + } + + @Test + void unsupportedSchemeThrowsWhenReferenced() { + Map r = + raw( + Map.of("oauth", Map.of("type", "oauth2")), + List.of(), + List.of(Map.of("oauth", List.of()))); + Spec spec = Spec.from(r); + + assertThatThrownBy( + () -> + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getX", req -> Response.ok(Map.of()))) + .port(0) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("unsupported"); + } + + @Test + void unknownSchemeReferenceThrows() { + Map r = + raw( + Map.of(), // no schemes defined + List.of(), + List.of(Map.of("missingScheme", List.of()))); + Spec spec = Spec.from(r); + + assertThatThrownBy( + () -> + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getX", req -> Response.ok(Map.of()))) + .port(0) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missingScheme"); + } + + @Test + void externalAuthSkipsAllChecks() throws Exception { + Map r = + raw( + Map.of("bearerAuth", Map.of("type", "http", "scheme", "bearer")), + List.of(), + List.of(Map.of("bearerAuth", List.of()))); + Spec spec = Spec.from(r); + + // No validator registered, but externalAuth → must succeed. + OpenApiServer server = + OpenApiServer.builder() + .spec(spec) + .useExternalAuthentication() + .handlers(Map.of("getX", req -> Response.ok(Map.of()))) + .port(0) + .build(); + + assertThat(server).isNotNull(); + server.close(); + } +} From 9e816f8609726b2f24579e61d9e4e5ff8a5706f8 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:46:59 +0200 Subject: [PATCH 17/23] test(internal): Cover useExternalAuthentication bypass in SecurityFilter --- .../http/internal/SecurityFilterTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java index d895cf6..db83b2a 100644 --- a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java @@ -319,6 +319,35 @@ void orFallsBackToSecondGroupWhenFirstDenied() throws Exception { assertThat(captured).containsEntry("bearerAuth", "bearer-ok").doesNotContainKey("apiKeyAuth"); } + @Test + void externalAuthBypassesEverything() throws Exception { + // Operation requires bearerAuth, but externalAuth=true should short-circuit. + Operation op = + new Operation( + "getX", + HttpMethod.GET, + null, + Optional.empty(), + List.of(), + Map.of(), + Map.of(), + Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of()))))); + + SecurityFilter filter = + new SecurityFilter( + Map.of("getX", op), + Map.of("bearerAuth", new HttpBearer(Optional.empty())), + List.of(), + Map.of(), // NO validators + /* externalAuth= */ true); + + HttpExchange ex = mock(HttpExchange.class); + Chain chain = mock(Chain.class); + ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain)); + + verify(chain).doFilter(ex); + } + private static Request newMinimalRequest(String operationId) { return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null); } From 6ee69107328ea433c807a447b33fd6cdfecd342e Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:52:22 +0200 Subject: [PATCH 18/23] test: Add /api/v1/secure/* operations and securitySchemes fixture Add four secured paths under /secure/* to openapi.json and openapi.yaml, plus a securitySchemes block (apiKeyAuth, bearerAuth, basicAuth). Wire deny-all SchemeValidator stubs and no-op handlers into ServerBaseTest (via newBuilder helper) and ServerLauncher so existing tests keep booting. --- .../http/DecoratorAndInterceptorIT.java | 8 ++-- .../com/retailsvc/http/ExtraHandlersIT.java | 6 +-- .../http/RequestResponseGatewayTest.java | 10 ++-- .../com/retailsvc/http/ServerBaseTest.java | 23 ++++++++- .../http/TypeMapperRegistrationTest.java | 6 +-- .../retailsvc/http/start/ServerLauncher.java | 9 ++++ src/test/resources/openapi.json | 33 +++++++++++++ src/test/resources/openapi.yaml | 47 +++++++++++++++++++ 8 files changed, 124 insertions(+), 18 deletions(-) diff --git a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java index f022666..75f10f4 100644 --- a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java +++ b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java @@ -21,7 +21,7 @@ class DecoratorAndInterceptorIT extends ServerBaseTest { void responseDecoratorAddsHeadersOnEveryResponse() throws Exception { RequestHandler ok = req -> Response.text(HTTP_OK, "ok"); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of("get-data", ok, "post-data", ok)) .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", "decorator-cid")) @@ -40,7 +40,7 @@ void responseDecoratorAddsHeadersOnEveryResponse() throws Exception { void decoratorHeaderOverridesHandlerHeader() throws Exception { RequestHandler ok = req -> Response.text(HTTP_OK, "ok").withHeader("X-Op", "handler-set"); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of("get-data", ok, "post-data", ok)) .responseDecorator((req, resp) -> resp.withHeader("X-Op", "decorator-wins")) @@ -56,7 +56,7 @@ void decoratorHeaderOverridesHandlerHeader() throws Exception { void interceptorBindsScopedValueVisibleToHandler() throws Exception { RequestHandler echoTenant = req -> Response.text(HTTP_OK, TENANT.get()); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of("get-data", echoTenant, "post-data", echoTenant)) .interceptor((request, next) -> ScopedValue.where(TENANT, "acme").call(next::proceed)) @@ -75,7 +75,7 @@ void interceptorsRunInRegistrationOrder() throws Exception { return Response.status(HTTP_OK); }; server = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of("get-data", ok, "post-data", ok)) .interceptor( diff --git a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java index 59dc123..d6e1393 100644 --- a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java +++ b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java @@ -14,7 +14,7 @@ class ExtraHandlersIT extends ServerBaseTest { @Test void aliveExtraReturns204AndBypassesValidation() throws Exception { try (var s = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) @@ -38,7 +38,7 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception { @Test void specHandlerServesClasspathResource() throws Exception { try (var s = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) @@ -68,7 +68,7 @@ void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { }; try (var s = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of()) .exceptionHandler(defaultExceptionHandler()) diff --git a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java index ab32347..dd77805 100644 --- a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java +++ b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java @@ -20,7 +20,7 @@ class RequestResponseGatewayTest extends ServerBaseTest { void respondJsonWritesBodyAndContentType() throws Exception { RequestHandler echo = req -> Response.ok(Map.of("op", req.operationId())); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of("get-data", echo, "post-data", echo)) .port(0) @@ -47,11 +47,7 @@ void respondJsonWritesBodyAndContentType() throws Exception { void respondEmptyUses204Style() throws Exception { RequestHandler ok = req -> Response.status(HTTP_NO_CONTENT); server = - OpenApiServer.builder() - .spec(spec) - .handlers(Map.of("get-data", ok, "post-data", ok)) - .port(0) - .build(); + newBuilder().spec(spec).handlers(Map.of("get-data", ok, "post-data", ok)).port(0).build(); var resp = HttpClient.newHttpClient() .send( @@ -78,7 +74,7 @@ void respondStreamUsesChunkedEncoding() throws Exception { out.write("world".getBytes()); }); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of("get-data", streamer, "post-data", streamer)) .port(0) diff --git a/src/test/java/com/retailsvc/http/ServerBaseTest.java b/src/test/java/com/retailsvc/http/ServerBaseTest.java index fa081e1..8c45eae 100644 --- a/src/test/java/com/retailsvc/http/ServerBaseTest.java +++ b/src/test/java/com/retailsvc/http/ServerBaseTest.java @@ -12,6 +12,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublisher; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -43,9 +44,29 @@ void tearDown() { Optional.ofNullable(server).ifPresent(OpenApiServer::close); } + protected OpenApiServer.Builder newBuilder() { + return OpenApiServer.builder() + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()); + } + protected OpenApiServer newServer(Map handlers) { + Map all = new HashMap<>(handlers); + all.putIfAbsent("secureApiKey", req -> Response.status(200)); + all.putIfAbsent("secureBearer", req -> Response.status(200)); + all.putIfAbsent("secureBasic", req -> Response.status(200)); + all.putIfAbsent("secureOpen", req -> Response.status(200)); try { - server = OpenApiServer.builder().spec(spec).handlers(handlers).port(0).build(); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(all) + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()) + .port(0) + .build(); return server; } catch (Exception e) { fail(e); diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index 117d07f..5a6436c 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -30,7 +30,7 @@ void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { gson.toJson(req.parsed()).getBytes(StandardCharsets.UTF_8), "application/json"); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .handlers(Map.of("get-data", echo, "post-data", echo)) .port(0) @@ -70,7 +70,7 @@ public byte[] writeTo(Object v) { }; RequestHandler echo = req -> Response.status(200); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .bodyMapper("application/json", marker) .handlers(Map.of("get-data", echo, "post-data", echo)) @@ -106,7 +106,7 @@ public byte[] writeTo(Object v) { }; RequestHandler echo = req -> Response.status(200); server = - OpenApiServer.builder() + newBuilder() .spec(spec) .jsonMapper(marker) .handlers(Map.of("get-data", echo, "post-data", echo)) diff --git a/src/test/java/com/retailsvc/http/start/ServerLauncher.java b/src/test/java/com/retailsvc/http/start/ServerLauncher.java index 614d5b8..670e623 100644 --- a/src/test/java/com/retailsvc/http/start/ServerLauncher.java +++ b/src/test/java/com/retailsvc/http/start/ServerLauncher.java @@ -4,11 +4,13 @@ import com.retailsvc.http.Handlers; import com.retailsvc.http.OpenApiServer; import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; import com.retailsvc.http.spec.Spec; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.yaml.snakeyaml.Yaml; @@ -37,6 +39,10 @@ public ServerLauncher() throws IOException { handlers.put("query-params", new ParamHandler()); handlers.put("path-params", new ParamHandler()); handlers.put("path-params-multi", new ParamHandler()); + handlers.put("secureApiKey", req -> Response.status(200)); + handlers.put("secureBearer", req -> Response.status(200)); + handlers.put("secureBasic", req -> Response.status(200)); + handlers.put("secureOpen", req -> Response.status(200)); ExceptionHandler exceptionHandler = Handlers.defaultExceptionHandler(); @@ -44,6 +50,9 @@ public ServerLauncher() throws IOException { .spec(spec) .handlers(handlers) .exceptionHandler(exceptionHandler) + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()) .build(); LOG.info("Application started in {}ms", System.currentTimeMillis() - t0); } diff --git a/src/test/resources/openapi.json b/src/test/resources/openapi.json index 026e9dd..da08b31 100644 --- a/src/test/resources/openapi.json +++ b/src/test/resources/openapi.json @@ -377,6 +377,34 @@ "200": { "description": "ok" } } } + }, + "/secure/api-key": { + "get": { + "operationId": "secureApiKey", + "security": [{"apiKeyAuth": []}], + "responses": {"200": {"description": "ok"}} + } + }, + "/secure/bearer": { + "get": { + "operationId": "secureBearer", + "security": [{"bearerAuth": []}], + "responses": {"200": {"description": "ok"}} + } + }, + "/secure/basic": { + "get": { + "operationId": "secureBasic", + "security": [{"basicAuth": []}], + "responses": {"200": {"description": "ok"}} + } + }, + "/secure/open": { + "get": { + "operationId": "secureOpen", + "security": [], + "responses": {"200": {"description": "ok"}} + } } }, "components": { @@ -390,6 +418,11 @@ } } }, + "securitySchemes": { + "apiKeyAuth": {"type": "apiKey", "name": "X-API-Key", "in": "header"}, + "bearerAuth": {"type": "http", "scheme": "bearer"}, + "basicAuth": {"type": "http", "scheme": "basic"} + }, "schemas": { "GetDataResponse": { "type": "object", diff --git a/src/test/resources/openapi.yaml b/src/test/resources/openapi.yaml index 35e3988..fd274a5 100644 --- a/src/test/resources/openapi.yaml +++ b/src/test/resources/openapi.yaml @@ -265,7 +265,54 @@ paths: responses: "200": { description: ok } + /secure/api-key: + get: + operationId: secureApiKey + security: + - apiKeyAuth: [] + responses: + "200": + description: ok + + /secure/bearer: + get: + operationId: secureBearer + security: + - bearerAuth: [] + responses: + "200": + description: ok + + /secure/basic: + get: + operationId: secureBasic + security: + - basicAuth: [] + responses: + "200": + description: ok + + /secure/open: + get: + operationId: secureOpen + security: [] + responses: + "200": + description: ok + components: + securitySchemes: + apiKeyAuth: + type: apiKey + name: X-API-Key + in: header + bearerAuth: + type: http + scheme: bearer + basicAuth: + type: http + scheme: basic + parameters: Name-Header: in: header From cb0ed2992473164afbc19f1eecba55259f61a9ec Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:54:58 +0200 Subject: [PATCH 19/23] =?UTF-8?q?test:=20SecurityIT=20=E2=80=94=20end-to-e?= =?UTF-8?q?nd=20200/401/403=20+=20external-auth=20bypass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/retailsvc/http/SecurityIT.java | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/SecurityIT.java diff --git a/src/test/java/com/retailsvc/http/SecurityIT.java b/src/test/java/com/retailsvc/http/SecurityIT.java new file mode 100644 index 0000000..86063b0 --- /dev/null +++ b/src/test/java/com/retailsvc/http/SecurityIT.java @@ -0,0 +1,147 @@ +package com.retailsvc.http; + +import static java.net.http.HttpRequest.BodyPublishers.noBody; +import static java.net.http.HttpResponse.BodyHandlers.ofString; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class SecurityIT extends ServerBaseTest { + + @Test + void apiKeyAllowDenyAndMissing() throws Exception { + server = + OpenApiServer.builder() + .spec(spec) + .handlers(defaultHandlers()) + .securityValidator( + "apiKeyAuth", + (req, cred) -> + cred instanceof Credential.ApiKeyCredential ak && "good".equals(ak.value()) + ? Optional.of("api-principal") + : Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()) + .port(0) + .build(); + + var client = httpClient(); + + var ok = + client.send( + newRequest(server, "/secure/api-key", "GET", noBody(), Map.of("X-API-Key", "good")), + ofString()); + assertThat(ok.statusCode()).isEqualTo(200); + + var missing = + client.send(newRequest(server, "/secure/api-key", "GET", noBody(), Map.of()), ofString()); + assertThat(missing.statusCode()).isEqualTo(401); + assertThat(missing.headers().firstValue("WWW-Authenticate")) + .contains("ApiKey location=header, name=\"X-API-Key\""); + assertThat(missing.body()).contains("\"status\":401"); + + var denied = + client.send( + newRequest(server, "/secure/api-key", "GET", noBody(), Map.of("X-API-Key", "bad")), + ofString()); + assertThat(denied.statusCode()).isEqualTo(403); + assertThat(denied.headers().firstValue("WWW-Authenticate")).isEmpty(); + } + + @Test + void bearerAllowAndMissing() throws Exception { + server = + OpenApiServer.builder() + .spec(spec) + .handlers(defaultHandlers()) + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator( + "bearerAuth", + (req, cred) -> + cred instanceof Credential.BearerCredential bc + && "good-token".equals(bc.token()) + ? Optional.of("bearer-principal") + : Optional.empty()) + .securityValidator("basicAuth", (req, cred) -> Optional.empty()) + .port(0) + .build(); + + var client = httpClient(); + + var ok = + client.send( + newRequest( + server, + "/secure/bearer", + "GET", + noBody(), + Map.of("Authorization", "Bearer good-token")), + ofString()); + assertThat(ok.statusCode()).isEqualTo(200); + + var missing = + client.send(newRequest(server, "/secure/bearer", "GET", noBody(), Map.of()), ofString()); + assertThat(missing.statusCode()).isEqualTo(401); + assertThat(missing.headers().firstValue("WWW-Authenticate")).contains("Bearer realm=\"api\""); + } + + @Test + void basicAuthAllow() throws Exception { + String creds = Base64.getEncoder().encodeToString("alice:s3cret".getBytes()); + server = + OpenApiServer.builder() + .spec(spec) + .handlers(defaultHandlers()) + .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) + .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) + .securityValidator( + "basicAuth", + (req, cred) -> + cred instanceof Credential.BasicCredential bc + && "alice".equals(bc.username()) + && "s3cret".equals(bc.password()) + ? Optional.of("basic-principal") + : Optional.empty()) + .port(0) + .build(); + + var ok = + httpClient() + .send( + newRequest( + server, + "/secure/basic", + "GET", + noBody(), + Map.of("Authorization", "Basic " + creds)), + ofString()); + assertThat(ok.statusCode()).isEqualTo(200); + } + + @Test + void externalAuthBypassesAllChecks() throws Exception { + server = + OpenApiServer.builder() + .spec(spec) + .handlers(defaultHandlers()) + .useExternalAuthentication() + .port(0) + .build(); + + var r = + httpClient() + .send(newRequest(server, "/secure/api-key", "GET", noBody(), Map.of()), ofString()); + assertThat(r.statusCode()).isEqualTo(200); + } + + private static Map defaultHandlers() { + return Map.of( + "secureApiKey", req -> Response.ok("{\"ok\":true}"), + "secureBearer", req -> Response.ok("{\"ok\":true}"), + "secureBasic", req -> Response.ok("{\"ok\":true}"), + "secureOpen", req -> Response.ok("{\"ok\":true}")); + } +} From 91de0e9b777bbe995f4c730feccee188d7d93dfc Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 12:56:22 +0200 Subject: [PATCH 20/23] docs: Document security schemes, validators, principals, and external-auth opt-out --- README.md | 197 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/README.md b/README.md index f1e0c80..eb80cac 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,203 @@ What the example demonstrates: - **One decorator stamps a response header.** `Response.withHeader(...)` is non-destructive — the handler's `Response` is replaced with one that has the extra header. - **Handler is a pure function.** Reads from `Request`, returns a `Response` value. No `HttpExchange`, no try/catch IOException, no builder. +### Security (OpenAPI `securitySchemes` + `security`) + +The library parses `components.securitySchemes` and the `security` requirement lists (root-level and per-operation), extracts the credential per scheme, hands it to a consumer-provided `SchemeValidator` callback, and renders RFC 7807 `application/problem+json` rejections — 401 for missing/malformed credentials (with `WWW-Authenticate`), 403 when the validator denies. + +Supported scheme types in this release: + +- `apiKey` (in `header`, `query`, or `cookie`) +- `http` `bearer` +- `http` `basic` + +`oauth2`, `openIdConnect`, and `mutualTLS` are parsed into a placeholder type (`SecurityScheme.Unsupported`) — if any operation actually *references* one of those scheme names, the server fails at boot. + +#### Declaring schemes in the spec + +```yaml +components: + securitySchemes: + apiKeyAuth: + type: apiKey + name: X-API-Key + in: header + bearerAuth: + type: http + scheme: bearer + basicAuth: + type: http + scheme: basic + +# Either default for every operation: +security: + - bearerAuth: [] + +# Or attach per-operation (overrides the root default): +paths: + /reports/{id}: + get: + operationId: getReport + security: + - apiKeyAuth: [] + responses: + "200": { description: ok } +``` + +`security: []` on an operation means "no security required" (overrides the root default). Omitting `security` on an operation inherits the root default. + +When several entries appear in `security`, they are OR-ed; the request is allowed if *any* entry's schemes all validate. Multiple keys *inside* one entry are AND-ed: + +```yaml +security: + # Either an API key … + - apiKeyAuth: [] + # … or BOTH a bearer token AND a tenant header validator: + - bearerAuth: [] + tenantAuth: [] +``` + +#### Registering validators + +```java +import com.retailsvc.http.Credential; +import com.retailsvc.http.Credential.ApiKeyCredential; +import com.retailsvc.http.Credential.BearerCredential; +import com.retailsvc.http.Credential.BasicCredential; +import com.retailsvc.http.OpenApiServer; +import java.util.Optional; + +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .securityValidator("apiKeyAuth", (request, credential) -> { + String key = ((ApiKeyCredential) credential).value(); + return apiKeyStore.lookup(key).map(user -> user); // Optional + }) + .securityValidator("bearerAuth", (request, credential) -> { + String token = ((BearerCredential) credential).token(); + return jwt.verify(token).map(claims -> claims); // Optional + }) + .securityValidator("basicAuth", (request, credential) -> { + BasicCredential bc = (BasicCredential) credential; + return userService + .authenticate(bc.username(), bc.password()) + .map(user -> user); // Optional + }) + .build(); +``` + +The library guarantees the `Credential` variant matches the scheme's declared type — `apiKey` schemes deliver `ApiKeyCredential`, `http` `bearer` delivers `BearerCredential`, `http` `basic` delivers `BasicCredential`. Pattern matching is cleaner than casts: + +```java +.securityValidator("multi", (request, credential) -> switch (credential) { + case ApiKeyCredential ak -> apiKeyStore.lookup(ak.value()).map(user -> user); + case BearerCredential b -> jwt.verify(b.token()).map(claims -> claims); + case BasicCredential bc -> userService.authenticate(bc.username(), bc.password()).map(u -> u); +}) +``` + +#### Constructing the principal + +A *principal* is whatever the library hands back to the handler after a successful authentication. The library does NOT define a `Principal` type — your validator returns `Optional` and the library stashes the value on the `Request` under the scheme name. **Whatever you return becomes your principal.** + +Three common patterns: + +**1. A domain record.** Best for typed access in handlers. + +```java +public record AuthenticatedUser(String userId, String tenantId, Set roles) {} + +.securityValidator("bearerAuth", (request, credential) -> { + String token = ((BearerCredential) credential).token(); + return jwt.verify(token).map(claims -> + new AuthenticatedUser(claims.subject(), claims.tenant(), claims.roles())); +}) +``` + +Handler reads it: + +```java +public Response handle(Request request) { + AuthenticatedUser user = (AuthenticatedUser) request.principal("bearerAuth").orElseThrow(); + return Response.ok(reports.findForTenant(user.tenantId())); +} +``` + +**2. A `Map` of claims.** Useful when the shape is dynamic or you want to forward JWT claims as-is. + +```java +.securityValidator("bearerAuth", (request, credential) -> + jwt.verify(((BearerCredential) credential).token()).map(claims -> Map.copyOf(claims.asMap()))) +``` + +```java +@SuppressWarnings("unchecked") +Map claims = (Map) request.principal("bearerAuth").orElseThrow(); +String sub = (String) claims.get("sub"); +``` + +**3. A plain `String` identifier.** Simplest when the handler only needs an ID. + +```java +.securityValidator("apiKeyAuth", (request, credential) -> + apiKeyStore.lookup(((ApiKeyCredential) credential).value())) // Optional userId +``` + +```java +String userId = (String) request.principal("apiKeyAuth").orElseThrow(); +``` + +If your operation requires multiple schemes simultaneously (AND-group), all principals are stashed under their scheme names: + +```java +Map principals = request.principals(); // {"bearerAuth": claims, "tenantAuth": tenant} +``` + +Returning `Optional.empty()` from a validator means "deny" — the library then returns 403 Forbidden (or 401 if no scheme produced a valid credential at all). Throwing from a validator propagates to the configured `ExceptionHandler`; it does NOT count as deny, so let your validators throw on internal errors and return `Optional.empty()` only when the credential is genuinely invalid. + +#### Boot-time validation + +If `security` references a scheme that has no registered `securityValidator(...)`, is undeclared in `components.securitySchemes`, or uses an unsupported type, `OpenApiServer.builder()...build()` throws `IllegalStateException` immediately. You can't ship a server that's missing an auth check by accident — the failure is loud at startup, not silent at request time. + +#### Opt-out: external authentication + +In some deployments authentication happens upstream — for example, an Envoy sidecar with OPA, or an API Gateway like Apigee that already verified the credential before the request reaches your JVM. In that case the credential never arrives in a form the library can validate (or the library would be re-validating something the gateway already proved), and forcing you to register stub validators is just friction. + +`useExternalAuthentication()` opts the entire library out of in-process enforcement: + +```java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .useExternalAuthentication() // SecurityFilter becomes a no-op + .build(); +``` + +Effects when set: + +- `SecurityFilter` short-circuits to the next chain step regardless of any `security` declarations — every request reaches the handler. +- The boot-time validator-registration check is skipped, so you don't have to register `.securityValidator(...)` callbacks at all. +- `Request.principals()` returns an empty map; `Request.principal(name)` returns `Optional.empty()`. **The library never reads sidecar-set headers.** If you want a principal in the handler, write a normal `RequestInterceptor` that reads whatever header the sidecar sets and binds a `ScopedValue` (or stashes on the request via a domain wrapper of your own). + +Typical sidecar pattern: + +```java +ScopedValue AUTHENTICATED_USER = ScopedValue.newInstance(); + +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .useExternalAuthentication() + .interceptor((request, next) -> { + String user = request.header("X-Authenticated-User").orElseThrow(); + return ScopedValue.where(AUTHENTICATED_USER, user).call(next::proceed); + }) + .build(); +``` + +The library still parses `components.securitySchemes` and exposes it via `spec.securitySchemes()` — useful if you serve the OpenAPI document or wire a docs UI — it just stops short of *enforcing* anything. + ### Request body content types The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): From 4553f99a31c4d8ddd76b16d59724ebca49301d4d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 13:13:27 +0200 Subject: [PATCH 21/23] fix: Address Sonar S1192 and S107 in security additions --- src/main/java/com/retailsvc/http/Request.java | 4 ++++ src/main/java/com/retailsvc/http/spec/Spec.java | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 09ab65b..bb6a9b3 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -69,6 +69,10 @@ public Request( * @param headerLookup first-value, case-insensitive header lookup; returns {@code null} if absent * @param principals principals stashed by the security filter, keyed by scheme name */ + // Request is transport-neutral and assembled from primitives at the adapter boundary; collapsing + // these into a holder type would just move the parameter count one level out without simplifying + // the call site, so the 8-arg constructor is preferred over the rule's 7-param limit. + @SuppressWarnings("java:S107") public Request( byte[] body, Object parsed, diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index a9d242e..c3b68eb 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -33,6 +33,7 @@ public record Spec( List security) { private static final String SCHEMA_KEY = "schema"; + private static final String SECURITY_KEY = "security"; private static final String SCHEMA_REF_PREFIX = "#/components/schemas/"; private static final String PARAMETER_REF_PREFIX = "#/components/parameters/"; @@ -151,7 +152,7 @@ public static Spec from(Map raw) { entry.getKey(), SecuritySchemeParser.parse((Map) entry.getValue())); } List rootSecurity = - SecuritySchemeParser.parseRequirements((List) raw.get("security")); + SecuritySchemeParser.parseRequirements((List) raw.get(SECURITY_KEY)); return new Spec( openapi, info, @@ -286,9 +287,9 @@ private static Operation parseOperation( Map responses = parseResponses((Map) raw.getOrDefault("responses", Map.of())); Optional> opSecurity = - raw.containsKey("security") + raw.containsKey(SECURITY_KEY) ? Optional.of( - SecuritySchemeParser.parseRequirements((List) raw.get("security"))) + SecuritySchemeParser.parseRequirements((List) raw.get(SECURITY_KEY))) : Optional.empty(); return new Operation( opId, method, path, body, params, responses, extractExtensions(raw), opSecurity); From 5ddd64f63fc7ef7a87616c10e5974761a0967937 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 13:17:57 +0200 Subject: [PATCH 22/23] fix(internal): Use HTTP status constants and record pattern in SecurityFilter --- .../com/retailsvc/http/internal/SecurityFilter.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java index 53a3579..20cda9f 100644 --- a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -1,5 +1,8 @@ package com.retailsvc.http.internal; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; + import com.retailsvc.http.Request; import com.retailsvc.http.SchemeValidator; import com.retailsvc.http.spec.Operation; @@ -109,7 +112,7 @@ private GroupOutcome tryGroup(SecurityRequirement group, HttpExchange exchange, private void renderRejection(HttpExchange exchange, List failures) throws IOException { boolean anyDenied = failures.stream().anyMatch(f -> f.kind() == FailureKind.DENIED); - int status = anyDenied ? 403 : 401; + int status = anyDenied ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED; String title = anyDenied ? "Forbidden" : "Unauthorized"; GroupOutcome.Failed pick = @@ -146,12 +149,8 @@ private String challengeFor(String schemeName) { return switch (scheme) { case SecurityScheme.HttpBearer _ -> "Bearer realm=\"api\""; case SecurityScheme.HttpBasic _ -> "Basic realm=\"api\""; - case SecurityScheme.ApiKey ak -> - "ApiKey location=" - + ak.location().name().toLowerCase(Locale.ROOT) - + ", name=\"" - + ak.name() - + "\""; + case SecurityScheme.ApiKey(String name, SecurityScheme.ApiKey.Location location) -> + "ApiKey location=" + location.name().toLowerCase(Locale.ROOT) + ", name=\"" + name + "\""; case SecurityScheme.Unsupported _ -> throw new IllegalStateException( "Unsupported scheme reached challenge rendering for '" + schemeName + "'"); From 7302fc415e8a91a74689e2d20897d90a1f1eec8d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 13:20:29 +0200 Subject: [PATCH 23/23] fix(internal): Use deconstruction pattern for GroupOutcome.Allowed --- src/main/java/com/retailsvc/http/internal/SecurityFilter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java index 20cda9f..96a9c62 100644 --- a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -66,9 +66,9 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { List failures = new ArrayList<>(); for (SecurityRequirement group : effective) { GroupOutcome outcome = tryGroup(group, exchange, request); - if (outcome instanceof GroupOutcome.Allowed allowed) { + if (outcome instanceof GroupOutcome.Allowed(Map principals)) { try { - ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(allowed.principals())) + ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(principals)) .call( () -> { chain.doFilter(exchange);