Skip to content

Commit 555b3aa

Browse files
committed
docs: Security schemes (OpenAPI 3.1) design spec
Captures the Wave 7 plan from the OpenAPI 3.1 refactor inventory: parse securitySchemes + security requirements, extract credentials per scheme, let consumers validate via name-keyed callback, library renders 401/403. Includes useExternalAuthentication() opt-out for OPA-sidecar deployments and explicit k6 compatibility constraints.
1 parent 5bd695a commit 555b3aa

1 file changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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

Comments
 (0)