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): 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. 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. 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/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index ee61c0e..bd0e069 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -10,8 +10,12 @@ 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.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; @@ -20,10 +24,13 @@ 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; @@ -49,7 +56,9 @@ record HandlerConfig( List interceptors, List decorators, ExceptionHandler exceptionHandler, - Map extras) {} + Map extras, + Map securityValidators, + boolean externalAuth) {} OpenApiServer( Spec spec, @@ -70,6 +79,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 +90,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(), @@ -139,6 +159,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 +204,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; @@ -232,12 +278,46 @@ 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(handlers, interceptors, decorators, exceptionHandler, extras); + new HandlerConfig( + handlers, + interceptors, + decorators, + exceptionHandler, + extras, + Map.copyOf(securityValidators), + externalAuth); 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/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index bedca16..bb6a9b3 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,35 @@ 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 + */ + // 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, + TypeMapper bodyMapper, + String operationId, + Map pathParameters, + String rawQuery, + UnaryOperator headerLookup, + Map principals) { this.body = body; this.parsed = parsed; this.bodyMapper = bodyMapper; @@ -59,6 +89,7 @@ public Request( this.pathParameters = pathParameters; this.rawQuery = rawQuery; this.headerLookup = headerLookup; + this.principals = Map.copyOf(principals); } public byte[] bytes() { @@ -163,6 +194,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/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); +} 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/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 new file mode 100644 index 0000000..96a9c62 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -0,0 +1,172 @@ +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; +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.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; + +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; + } + + List failures = new ArrayList<>(); + for (SecurityRequirement group : effective) { + GroupOutcome outcome = tryGroup(group, exchange, request); + if (outcome instanceof GroupOutcome.Allowed(Map principals)) { + 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; + } + failures.add((GroupOutcome.Failed) outcome); + } + + renderRejection(exchange, failures); + } + + 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.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 new GroupOutcome.Failed(FailureKind.DENIED, schemeName); + } + principals.put(schemeName, principal.get()); + } + 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 ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED; + 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(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 + "'"); + }; + } + + 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/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 a9b5fbb..c3b68eb 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,9 +28,12 @@ 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 SECURITY_KEY = "security"; private static final String SCHEMA_REF_PREFIX = "#/components/schemas/"; private static final String PARAMETER_REF_PREFIX = "#/components/parameters/"; @@ -138,6 +144,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_KEY)); return new Spec( openapi, info, @@ -148,7 +163,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) { @@ -269,7 +286,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_KEY) + ? Optional.of( + SecuritySchemeParser.parseRequirements((List) raw.get(SECURITY_KEY))) + : Optional.empty(); + return new Operation( + opId, method, path, body, params, responses, extractExtensions(raw), opSecurity); } private static Parameter resolveParameterOrParse( 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/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..3fa3273 --- /dev/null +++ b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java @@ -0,0 +1,73 @@ +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +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))); + } + + 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) { + 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/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/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 = 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(); + } +} 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); + } +} 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}")); + } +} 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/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()); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index e07d43f..3871a46 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) { @@ -89,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]); @@ -126,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]); @@ -145,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]); @@ -165,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); @@ -188,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); @@ -210,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); @@ -233,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); @@ -255,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); @@ -277,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); @@ -302,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/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..db83b2a --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java @@ -0,0 +1,354 @@ +package com.retailsvc.http.internal; + +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; +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.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; +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(); + } + + @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\""); + } + + @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"); + } + + @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); + } +} 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 1196561..091a2bd 100644 --- a/src/test/java/com/retailsvc/http/spec/SpecTest.java +++ b/src/test/java/com/retailsvc/http/spec/SpecTest.java @@ -70,4 +70,129 @@ 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(); + } + + @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(); + } } 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")); + } +} 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(); + } +} 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