Skip to content

Commit 53b347b

Browse files
authored
feat: Support securitySchemes (#60)
Parses `components.securitySchemes` and `security` requirements (root and per-operation) into a typed model, extracts credentials per scheme, and hands them to consumer-provided validators that decide allow/deny. The library renders RFC 7807 rejections — 401 with `WWW-Authenticate` for missing/malformed credentials, 403 when a validator denies. ### Supported scheme types - apiKey (in header / query / cookie) - http bearer - http basic - oauth2 / openIdConnect / mutualTLS parse to `SecurityScheme.Unsupported`; `build()` fails if any operation actually references them ### Spec model - New sealed `SecurityScheme` (`ApiKey`, `HttpBearer`, `HttpBasic`, `Unsupported`) and `Location` enum, under `com.retailsvc.http.spec.security` - New `SecurityRequirement` record (OR-of-AND map) - `Spec` gains `securitySchemes` and root `security` components - `Operation` gains `Optional<List<SecurityRequirement>> security` with override semantics: empty list means "no auth required", absent inherits root ### Public API - Sealed `Credential` (`ApiKeyCredential`, `BearerCredential`, `BasicCredential`) - `@FunctionalInterface SchemeValidator`: `Optional<Object> validate(Request, Credential)` - `Request.principals()` / `principal(name)` / `withPrincipals(map)` — the validator's principal is stashed under its scheme name and survives to the handler via `ScopedValue` rebinding ### Builder - `.securityValidator(schemeName, validator)` — register per scheme - `.useExternalAuthentication()` — opt out of in-process enforcement for sidecar deployments (OPA/Envoy in GCP). `SecurityFilter` short-circuits, boot-time validator check is skipped, `principals()` stays empty ### Request pipeline - New `SecurityFilter` slotted between `RequestPreparationFilter` and `DispatchHandler`; evaluates effective requirements as OR-of-AND, tries each group, rebinds `DispatchHandler.CURRENT` to a `Request` with principals on success, renders rejection on full failure - `ProblemDetailRenderer` gains a generic `render(status, title, detail)` overload reused for 401/403 bodies - Boot-time validation in `OpenApiServer.builder().build()`: every scheme name referenced by any operation must exist, must not be `Unsupported`, and must have a registered validator (skipped under `externalAuth`) ### Acceptance & tests - New `/api/v1/secure/{api-key,bearer,basic,open}` operations and a `securitySchemes` block added to `openapi.json` and `openapi.yaml`. No root-level `security` — existing routes (and the k6 acceptance script) remain unauthenticated. - `SecurityIT` covers 200 / 401 / 403 + external-auth bypass against the real `HttpServer` - Unit coverage: scheme + requirement parsing, credential extraction per scheme type, single-scheme allow, 401 with `WWW-Authenticate`, 403, OR-of-AND, `externalAuth` bypass, boot validation (missing validator, unsupported scheme, unknown reference) ### README - New "Security" section with scheme declaration, validator examples (apiKey / bearer / basic, pattern-matching across types), three principal patterns (domain record / claims map / plain identifier), and the `useExternalAuthentication` opt-out flow for sidecars
1 parent 5bd695a commit 53b347b

37 files changed

Lines changed: 4172 additions & 37 deletions

README.md

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,203 @@ What the example demonstrates:
305305
- **One decorator stamps a response header.** `Response.withHeader(...)` is non-destructive — the handler's `Response` is replaced with one that has the extra header.
306306
- **Handler is a pure function.** Reads from `Request`, returns a `Response` value. No `HttpExchange`, no try/catch IOException, no builder.
307307

308+
### Security (OpenAPI `securitySchemes` + `security`)
309+
310+
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.
311+
312+
Supported scheme types in this release:
313+
314+
- `apiKey` (in `header`, `query`, or `cookie`)
315+
- `http` `bearer`
316+
- `http` `basic`
317+
318+
`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.
319+
320+
#### Declaring schemes in the spec
321+
322+
```yaml
323+
components:
324+
securitySchemes:
325+
apiKeyAuth:
326+
type: apiKey
327+
name: X-API-Key
328+
in: header
329+
bearerAuth:
330+
type: http
331+
scheme: bearer
332+
basicAuth:
333+
type: http
334+
scheme: basic
335+
336+
# Either default for every operation:
337+
security:
338+
- bearerAuth: []
339+
340+
# Or attach per-operation (overrides the root default):
341+
paths:
342+
/reports/{id}:
343+
get:
344+
operationId: getReport
345+
security:
346+
- apiKeyAuth: []
347+
responses:
348+
"200": { description: ok }
349+
```
350+
351+
`security: []` on an operation means "no security required" (overrides the root default). Omitting `security` on an operation inherits the root default.
352+
353+
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:
354+
355+
```yaml
356+
security:
357+
# Either an API key …
358+
- apiKeyAuth: []
359+
# … or BOTH a bearer token AND a tenant header validator:
360+
- bearerAuth: []
361+
tenantAuth: []
362+
```
363+
364+
#### Registering validators
365+
366+
```java
367+
import com.retailsvc.http.Credential;
368+
import com.retailsvc.http.Credential.ApiKeyCredential;
369+
import com.retailsvc.http.Credential.BearerCredential;
370+
import com.retailsvc.http.Credential.BasicCredential;
371+
import com.retailsvc.http.OpenApiServer;
372+
import java.util.Optional;
373+
374+
OpenApiServer.builder()
375+
.spec(spec)
376+
.handlers(handlers)
377+
.securityValidator("apiKeyAuth", (request, credential) -> {
378+
String key = ((ApiKeyCredential) credential).value();
379+
return apiKeyStore.lookup(key).map(user -> user); // Optional<User>
380+
})
381+
.securityValidator("bearerAuth", (request, credential) -> {
382+
String token = ((BearerCredential) credential).token();
383+
return jwt.verify(token).map(claims -> claims); // Optional<JwtClaims>
384+
})
385+
.securityValidator("basicAuth", (request, credential) -> {
386+
BasicCredential bc = (BasicCredential) credential;
387+
return userService
388+
.authenticate(bc.username(), bc.password())
389+
.map(user -> user); // Optional<User>
390+
})
391+
.build();
392+
```
393+
394+
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:
395+
396+
```java
397+
.securityValidator("multi", (request, credential) -> switch (credential) {
398+
case ApiKeyCredential ak -> apiKeyStore.lookup(ak.value()).map(user -> user);
399+
case BearerCredential b -> jwt.verify(b.token()).map(claims -> claims);
400+
case BasicCredential bc -> userService.authenticate(bc.username(), bc.password()).map(u -> u);
401+
})
402+
```
403+
404+
#### Constructing the principal
405+
406+
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<Object>` and the library stashes the value on the `Request` under the scheme name. **Whatever you return becomes your principal.**
407+
408+
Three common patterns:
409+
410+
**1. A domain record.** Best for typed access in handlers.
411+
412+
```java
413+
public record AuthenticatedUser(String userId, String tenantId, Set<String> roles) {}
414+
415+
.securityValidator("bearerAuth", (request, credential) -> {
416+
String token = ((BearerCredential) credential).token();
417+
return jwt.verify(token).map(claims ->
418+
new AuthenticatedUser(claims.subject(), claims.tenant(), claims.roles()));
419+
})
420+
```
421+
422+
Handler reads it:
423+
424+
```java
425+
public Response handle(Request request) {
426+
AuthenticatedUser user = (AuthenticatedUser) request.principal("bearerAuth").orElseThrow();
427+
return Response.ok(reports.findForTenant(user.tenantId()));
428+
}
429+
```
430+
431+
**2. A `Map<String, Object>` of claims.** Useful when the shape is dynamic or you want to forward JWT claims as-is.
432+
433+
```java
434+
.securityValidator("bearerAuth", (request, credential) ->
435+
jwt.verify(((BearerCredential) credential).token()).map(claims -> Map.copyOf(claims.asMap())))
436+
```
437+
438+
```java
439+
@SuppressWarnings("unchecked")
440+
Map<String, Object> claims = (Map<String, Object>) request.principal("bearerAuth").orElseThrow();
441+
String sub = (String) claims.get("sub");
442+
```
443+
444+
**3. A plain `String` identifier.** Simplest when the handler only needs an ID.
445+
446+
```java
447+
.securityValidator("apiKeyAuth", (request, credential) ->
448+
apiKeyStore.lookup(((ApiKeyCredential) credential).value())) // Optional<String> userId
449+
```
450+
451+
```java
452+
String userId = (String) request.principal("apiKeyAuth").orElseThrow();
453+
```
454+
455+
If your operation requires multiple schemes simultaneously (AND-group), all principals are stashed under their scheme names:
456+
457+
```java
458+
Map<String, Object> principals = request.principals(); // {"bearerAuth": claims, "tenantAuth": tenant}
459+
```
460+
461+
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.
462+
463+
#### Boot-time validation
464+
465+
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.
466+
467+
#### Opt-out: external authentication
468+
469+
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.
470+
471+
`useExternalAuthentication()` opts the entire library out of in-process enforcement:
472+
473+
```java
474+
OpenApiServer.builder()
475+
.spec(spec)
476+
.handlers(handlers)
477+
.useExternalAuthentication() // SecurityFilter becomes a no-op
478+
.build();
479+
```
480+
481+
Effects when set:
482+
483+
- `SecurityFilter` short-circuits to the next chain step regardless of any `security` declarations — every request reaches the handler.
484+
- The boot-time validator-registration check is skipped, so you don't have to register `.securityValidator(...)` callbacks at all.
485+
- `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).
486+
487+
Typical sidecar pattern:
488+
489+
```java
490+
ScopedValue<String> AUTHENTICATED_USER = ScopedValue.newInstance();
491+
492+
OpenApiServer.builder()
493+
.spec(spec)
494+
.handlers(handlers)
495+
.useExternalAuthentication()
496+
.interceptor((request, next) -> {
497+
String user = request.header("X-Authenticated-User").orElseThrow();
498+
return ScopedValue.where(AUTHENTICATED_USER, user).call(next::proceed);
499+
})
500+
.build();
501+
```
502+
503+
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.
504+
308505
### Request body content types
309506

310507
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):

0 commit comments

Comments
 (0)