|
| 1 | +# Security schemes (OpenAPI 3.1 `securitySchemes` + `security`) |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +## Decisions (locked during brainstorming) |
| 10 | + |
| 11 | +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. |
| 12 | +2. **Scheme types v1: `apiKey` (in `header` / `query` / `cookie`), `http` (`bearer`, `basic`).** `oauth2`, `openIdConnect`, `mutualTLS` are parsed-but-unsupported in v1. |
| 13 | +3. **Callback keyed by scheme name** (not by scheme type), matching the OpenAPI model where two `bearer` schemes can mean different things. |
| 14 | +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. |
| 15 | +5. **Callback returns `Optional<Object>` principal.** Empty = deny. Non-empty = allow, with the principal attached to the `Request` for the handler to read. |
| 16 | +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`. |
| 17 | + |
| 18 | +## High-level architecture |
| 19 | + |
| 20 | +Request flow with security added: |
| 21 | + |
| 22 | +``` |
| 23 | +HttpServer |
| 24 | + ExceptionFilter |
| 25 | + RequestPreparationFilter (existing: parses body, validates params) |
| 26 | + SecurityFilter (NEW) |
| 27 | + DispatchHandler |
| 28 | +``` |
| 29 | + |
| 30 | +`SecurityFilter` is a new step between request preparation and dispatch. Reasons it lives in the filter chain rather than in `RequestInterceptor`: |
| 31 | + |
| 32 | +- Security is a precondition on whether the request should reach the handler — interceptors can be reordered/disabled, filters are mandatory. |
| 33 | +- Consumer `RequestInterceptor`s can still run before the handler (e.g. to bind a `ScopedValue` from the resolved principal), but after security has decided. |
| 34 | + |
| 35 | +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. |
| 36 | + |
| 37 | +## Spec model additions |
| 38 | + |
| 39 | +New package: `com.retailsvc.http.spec.security`. |
| 40 | + |
| 41 | +```java |
| 42 | +public sealed interface SecurityScheme |
| 43 | + permits ApiKey, HttpBearer, HttpBasic, Unsupported { |
| 44 | + |
| 45 | + record ApiKey(String name, Location location) implements SecurityScheme { |
| 46 | + public enum Location { HEADER, QUERY, COOKIE } |
| 47 | + } |
| 48 | + record HttpBearer(Optional<String> bearerFormat) implements SecurityScheme {} |
| 49 | + record HttpBasic() implements SecurityScheme {} |
| 50 | + |
| 51 | + /** oauth2 / openIdConnect / mutualTLS — parsed for completeness, fail at boot if referenced. */ |
| 52 | + record Unsupported(String type) implements SecurityScheme {} |
| 53 | +} |
| 54 | + |
| 55 | +public record SecurityRequirement(Map<String, List<String>> schemes) { |
| 56 | + // schemes: scheme name → required scopes (scopes ignored in v1 since oauth2/oidc aren't supported) |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +Additions to `com.retailsvc.http.spec.Spec`: |
| 61 | + |
| 62 | +```java |
| 63 | +record Spec( |
| 64 | + ..., |
| 65 | + Map<String, SecurityScheme> securitySchemes, // NEW (empty map if absent) |
| 66 | + List<SecurityRequirement> security // NEW (root-level default, empty if absent) |
| 67 | +) |
| 68 | +``` |
| 69 | + |
| 70 | +Additions to `com.retailsvc.http.spec.Operation`: |
| 71 | + |
| 72 | +```java |
| 73 | +record Operation( |
| 74 | + ..., |
| 75 | + Optional<List<SecurityRequirement>> security // NEW |
| 76 | +) |
| 77 | +``` |
| 78 | + |
| 79 | +Semantics: |
| 80 | +- `Operation.security() == Optional.empty()` → inherit `Spec.security()`. |
| 81 | +- `Operation.security() == Optional.of(emptyList)` → "no security required" override (per OpenAPI 3.1 §4.8.2). |
| 82 | +- `Operation.security() == Optional.of(nonEmptyList)` → override root-level requirements with this list. |
| 83 | + |
| 84 | +`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. |
| 85 | + |
| 86 | +## Builder API |
| 87 | + |
| 88 | +```java |
| 89 | +public final class OpenApiServer.Builder { |
| 90 | + /** Registers a credential validator for the named security scheme. */ |
| 91 | + public Builder securityValidator(String schemeName, SchemeValidator validator); |
| 92 | + |
| 93 | + /** Opts out of in-process enforcement entirely (e.g. OPA/Envoy sidecar deployment). */ |
| 94 | + public Builder useExternalAuthentication(); |
| 95 | + ... |
| 96 | +} |
| 97 | + |
| 98 | +@FunctionalInterface |
| 99 | +public interface SchemeValidator { |
| 100 | + /** @return non-empty principal on allow, empty on deny */ |
| 101 | + Optional<Object> validate(Request request, Credential credential); |
| 102 | +} |
| 103 | + |
| 104 | +public sealed interface Credential permits ApiKeyCredential, BearerCredential, BasicCredential { |
| 105 | + record ApiKeyCredential(String value) implements Credential {} |
| 106 | + record BearerCredential(String token) implements Credential {} |
| 107 | + record BasicCredential(String username, String password) implements Credential {} |
| 108 | +} |
| 109 | +``` |
| 110 | + |
| 111 | +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. |
| 112 | + |
| 113 | +## SecurityFilter behavior |
| 114 | + |
| 115 | +For each request the filter: |
| 116 | + |
| 117 | +1. Reads the operationId resolved by `RequestPreparationFilter` and looks up the `Operation`. |
| 118 | +2. Computes effective requirements: `op.security().orElse(spec.security())`. |
| 119 | +3. If effective requirements is empty → `next.proceed()` (no auth required for this operation). |
| 120 | +4. Otherwise, evaluates the OR-of-AND: |
| 121 | + - For each `SecurityRequirement` (OR branch): |
| 122 | + - For each scheme in the AND map, extract the credential. |
| 123 | + - **`ApiKey(name, HEADER)`** → `request.headers().firstValue(name)`. |
| 124 | + - **`ApiKey(name, QUERY)`** → first occurrence of `name` in the query string. |
| 125 | + - **`ApiKey(name, COOKIE)`** → first cookie named `name`. |
| 126 | + - **`HttpBearer`** → `Authorization` header must match `Bearer\s+<token>` (case-insensitive scheme word per RFC 6750). |
| 127 | + - **`HttpBasic`** → `Authorization` header must match `Basic\s+<base64>`; base64 must decode to `user:password`. |
| 128 | + - If any credential in the group is missing → record "missing", skip to next OR branch. |
| 129 | + - If a credential is malformed (e.g. Basic with non-base64) → record "malformed", skip to next OR branch. |
| 130 | + - Otherwise call `SchemeValidator.validate(request, credential)` for each scheme. |
| 131 | + - If every validator returns non-empty → group succeeds. Stash `Map<schemeName, Object>` of principals on the exchange under attribute `security.principals`. `next.proceed()`. |
| 132 | +5. If no group succeeds → render rejection (see below). |
| 133 | + |
| 134 | +## Rejection rendering |
| 135 | + |
| 136 | +Pick the strongest signal across all attempted groups: |
| 137 | + |
| 138 | +- If at least one group had a callback that returned `Optional.empty()` (credential present and validator said "no") → **403 Forbidden**. |
| 139 | +- Otherwise (all groups had missing or malformed credentials) → **401 Unauthorized**. |
| 140 | + |
| 141 | +Headers: |
| 142 | +- 401 emits one `WWW-Authenticate` header per distinct scheme attempted. Examples: |
| 143 | + - `Bearer realm="api"` for `HttpBearer` |
| 144 | + - `Basic realm="api"` for `HttpBasic` |
| 145 | + - For `ApiKey` schemes, RFC 7235 has no registered challenge type — we emit a custom advisory header `WWW-Authenticate: ApiKey location=<header|query|cookie>, name="<name>"` since the alternative is to omit the challenge entirely (also valid per spec). Both behaviors are acceptable; pick the informative one. |
| 146 | +- 403 emits no `WWW-Authenticate` (the credential was accepted at the protocol level, just not authorized). |
| 147 | + |
| 148 | +Body is `application/problem+json` matching the existing parameter-validation format: |
| 149 | + |
| 150 | +```json |
| 151 | +{ "type": "about:blank", |
| 152 | + "title": "Unauthorized", |
| 153 | + "status": 401, |
| 154 | + "detail": "credential missing for scheme 'bearerAuth'" } |
| 155 | +``` |
| 156 | + |
| 157 | +The `detail` is the most specific reason for the *closest-to-success* attempted group (e.g. "credential missing" vs "validator denied for scheme 'apiKeyAuth'"). |
| 158 | + |
| 159 | +## Handler access to principal |
| 160 | + |
| 161 | +```java |
| 162 | +public final class Request { |
| 163 | + /** Principals keyed by securityScheme name, set by SecurityFilter on success. Empty when no security ran. */ |
| 164 | + public Map<String, Object> principals(); |
| 165 | + |
| 166 | + /** Convenience for the common single-scheme case. */ |
| 167 | + public Optional<Object> principal(String schemeName); |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +Backed by the exchange attribute `security.principals`. Empty map when `useExternalAuthentication()` is set or when the operation had no security requirements. |
| 172 | + |
| 173 | +## Boot-time validation |
| 174 | + |
| 175 | +When `OpenApiServer.builder(spec).build()` runs: |
| 176 | + |
| 177 | +1. If `useExternalAuthentication()` was called: skip the rest of this section. |
| 178 | +2. For every `securityScheme` referenced by *any* operation's effective requirements: |
| 179 | + - It must exist in `spec.securitySchemes` (else `IllegalStateException` — spec is malformed). |
| 180 | + - It must not be `Unsupported` (else `IllegalStateException("scheme '<name>' uses unsupported type '<type>'")`). |
| 181 | + - A validator must be registered for its name (else `IllegalStateException("no SchemeValidator registered for security scheme '<name>'")`). |
| 182 | + |
| 183 | +Fail-fast at boot rather than at request time: prevents silent 401s in production when a validator was forgotten. |
| 184 | + |
| 185 | +## External-auth opt-out |
| 186 | + |
| 187 | +`Builder.useExternalAuthentication()` flips a single boolean. Effects: |
| 188 | + |
| 189 | +- `SecurityFilter` short-circuits to `next.proceed()` for every request. |
| 190 | +- Boot-time validator check is skipped. |
| 191 | +- `Request.principals()` returns an empty map; `Request.principal(name)` returns `Optional.empty()`. |
| 192 | +- `securitySchemes` is still parsed and exposed on `Spec` (introspection unaffected). |
| 193 | + |
| 194 | +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. |
| 195 | + |
| 196 | +## Testing |
| 197 | + |
| 198 | +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: |
| 199 | + |
| 200 | +- `apiKeyAuth` (header `X-API-Key`) |
| 201 | +- `bearerAuth` (HTTP bearer) |
| 202 | +- `basicAuth` (HTTP basic) |
| 203 | +- One operation with `security: []` to verify the per-operation opt-out |
| 204 | +- One operation with a two-scheme AND group to verify the AND semantics |
| 205 | + |
| 206 | +**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. |
| 207 | + |
| 208 | +New unit tests cover: |
| 209 | +- `SchemeParserTest` — every scheme type parses; unknown type maps to `Unsupported`; missing required fields throw. |
| 210 | +- `RequirementResolutionTest` — op override (present/empty/absent) cases; OR-of-AND evaluation table. |
| 211 | +- `CredentialExtractionTest` — happy path and malformed Basic, missing header, multiple-cookie selection, query parameter, mixed-case `Bearer`. |
| 212 | +- `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. |
| 213 | +- `BootValidationTest` — missing validator throws; unsupported scheme throws when referenced; opt-out suppresses both. |
| 214 | + |
| 215 | +Integration test (`SecurityIT`) runs the real `HttpServer`: |
| 216 | +- Authenticated request → 200 with principal-derived response body. |
| 217 | +- Missing header → 401 with `WWW-Authenticate`. |
| 218 | +- Wrong key → 403. |
| 219 | +- Opt-out mode: missing credential still passes through to the handler. |
| 220 | + |
| 221 | +**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. |
| 222 | + |
| 223 | +## Out of scope |
| 224 | + |
| 225 | +- `oauth2` / `openIdConnect` / `mutualTLS` — parsed to `Unsupported`, no extraction logic. |
| 226 | +- OAuth2 scope checking — the `scopes` list on `SecurityRequirement` is preserved in the model but ignored by the v1 filter. |
| 227 | +- Library-side principal types — `Object` is what the callback returns; we don't ship a `Principal` interface or JWT decoder. |
| 228 | +- Configurable "external auth" header bindings — the opt-out is all-or-nothing; consumers map sidecar headers themselves. |
| 229 | +- Multi-error reporting — a rejected request stops at the first failed group's worst error, matching the current single-error parameter-validation behavior. |
| 230 | +- WWW-Authenticate `realm` configurability — hardcoded to `"api"` in v1. |
0 commit comments