diff --git a/CHANGELOG.md b/CHANGELOG.md index a712015..8b9db78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ include migration notes. ## Unreleased +## 7.2.0 - 2026-07-12 + +- Added stateless Spring MVC async and streaming support based on a request-attribute + `SecurityContextRepository`, preserving identity across legitimate redispatches without + revalidating JWTs or creating an `HttpSession`. +- Added integration coverage and migration guidance for Spring MVC async return types and + dispatcher authorization. + ## 7.1.1 - 2026-07-10 - Updated the Gradle wrapper to 9.6.1, Spotless to 8.8.0, Caffeine to 3.2.4, diff --git a/README.md b/README.md index 3fb9732..6ac2cb4 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,18 @@ Vigil provides JWT lifecycle, request authentication, cookie helpers, tenant con ## Compatibility -Vigil `7.1.x` supports Java 25, Spring Boot 4.1.x, Spring Framework 7.x, Spring Security 7.1.x, Gradle 9.6.x, and Jackson 3. Vigil `6.0.x` was the final Java 21 / Spring Boot 3.5 line. +Vigil `7.2.x` is certified with Java 25, Spring Boot 4.1.0, Spring Framework 7.0.8, +Spring Security 7.1.0, Gradle 9.6.x, and Jackson 3. Later dependency patches are not claimed as +certified until they pass Vigil's complete gate. Vigil `6.0.x` was the final Java 21 / Spring Boot +3.5 line. -`7.1.1` is the current release line. Public consumers should pin an exact version and review the release notes before upgrading. +`7.2.0` is the current release line. Public consumers should pin an exact version and review the release notes before upgrading. ## Install ```kotlin dependencies { - implementation("io.github.sequelcore:vigil-spring-boot-starter:7.0.0") + implementation("io.github.sequelcore:vigil-spring-boot-starter:7.2.0") } ``` @@ -43,6 +46,7 @@ The application must still configure route authorization. `ignored-paths` skips Start at the [documentation index](docs/README.md). - [Authentication guide](docs/guides/authentication.md) +- [Async and streaming security](docs/guides/async-streaming-security.md) - [Configuration reference](docs/reference/configuration.md) - [System boundaries](docs/architecture/system-boundaries.md) - [Security model](docs/security/security-model.md) diff --git a/build.gradle.kts b/build.gradle.kts index e245209..8f11069 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "io.github.sequelcore" -version = "7.1.1" +version = "7.2.0" val hasSigningConfiguration = providers.gradleProperty("signingInMemoryKey").isPresent || providers.gradleProperty("signing.secretKeyRingFile").isPresent @@ -58,6 +58,7 @@ dependencies { // Testing testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.springframework.boot:spring-boot-starter-security") testImplementation("org.springframework.boot:spring-boot-starter-validation") testImplementation("org.springframework.security:spring-security-test") diff --git a/docs/README.md b/docs/README.md index 71f6c4b..3c6b840 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,8 @@ Vigil is a Spring Boot starter for application-owned JWT authentication. It prov ## Start here +- [Async and streaming security](guides/async-streaming-security.md) — preserve stateless authentication across MVC redispatches. + - [Authentication guide](guides/authentication.md) — configure JWTs, cookies, Spring Security, tenants, and reset tokens. - [Configuration reference](reference/configuration.md) — every `vigil.*` property and its defaults. - [System boundaries](architecture/system-boundaries.md) — understand ownership and extension points before integrating. diff --git a/docs/development/testing.md b/docs/development/testing.md index 5041ee0..0d129e2 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -25,4 +25,14 @@ Changes must prove the behavior at the owning boundary: - step-up: actor separation, credential failure/lockout, tenant/audience/purpose binding, expiry, and one-time consumption; - configuration: invalid security settings fail fast at startup. +Async security changes additionally require the real filter chain tests in +`VigilAsyncSecurityIntegrationTest` and the embedded-Tomcat socket tests in +`VigilSseDisconnectTomcatIntegrationTest`. The latter verifies a committed SSE response, a client +RST followed by `IOException`, final `ASYNC` processing, `ERROR` dispatch, callback cleanup, and +the absence of a secondary authentication entry point or access-denied response. + +The certified dependency combination is resolved by the Spring Boot BOM in `build.gradle.kts`. +Documentation must name the exact versions exercised by the full gate; an untested `4.1.x`, `7.x`, +or `7.1.x` range is not a supported compatibility claim. + Use application integration tests for application-owned routes and user persistence. Vigil tests do not replace product authorization or user-lifecycle tests. diff --git a/docs/guides/async-streaming-security.md b/docs/guides/async-streaming-security.md new file mode 100644 index 0000000..17a8327 --- /dev/null +++ b/docs/guides/async-streaming-security.md @@ -0,0 +1,83 @@ +# Async and streaming security + +Vigil preserves an authenticated Spring Security context for the lifetime of one servlet request, +including its `ASYNC` and `ERROR` redispatches. It does not create an `HttpSession`, revalidate a +JWT during redispatch, or weaken the application's authorization rules. + +## Secure stateless configuration + +Use Vigil's request-scoped repository in the application's filter chain. Keep authorization rules +application-owned and continue authorizing every dispatcher type. + +```java +@Bean +SecurityFilterChain securityFilterChain( + HttpSecurity http, + VigilAuthenticationFilter vigilAuthenticationFilter) throws Exception { + var requestSecurityContextRepository = new RequestAttributeSecurityContextRepository(); + vigilAuthenticationFilter.setSecurityContextRepository(requestSecurityContextRepository); + return http + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .securityContext(context -> + context.securityContextRepository(requestSecurityContextRepository)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/auth/**").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(vigilAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); +} +``` + +Vigil validates credentials and runs authentication hooks and context populators only on the +initial `REQUEST`. After successful authentication it saves the `SecurityContext` in a +`RequestAttributeSecurityContextRepository`. Spring Security's `SecurityContextHolderFilter` +loads that context for a legitimate redispatch and clears its thread-local holder afterward. A +new request, or a fabricated `ASYNC`/`ERROR` dispatch without the request attribute, has no saved +identity and remains subject to normal authorization. + +Do not globally `permitAll` `ASYNC` or `ERROR` merely to avoid a secondary authorization failure. +That can bypass application policy. Any deliberate narrow exception remains application-owned. + +## MVC lifecycle + +`DeferredResult`, `ResponseBodyEmitter`, `SseEmitter`, and `StreamingResponseBody` use Servlet +async processing. MVC leaves the response open after the initial dispatch and later performs an +`ASYNC` dispatch to finish processing. When an emitter write fails because the client disconnected, +the application must not call `complete` or `completeWithError`; the container notifies Spring MVC, +which performs the final error dispatch and cleanup. + +A Broken pipe is a normal network event and cannot be prevented. Record expected disconnects +separately from integrity failures. Monitor emitter completion, timeout, active connections, and +unexpected exception-resolver failures. The application owns MVC executors, timeouts, heartbeats, +resource cleanup, and propagation of domain context. Vigil preserves the Spring Security principal, +not arbitrary application `ThreadLocal` values. + +## Responsibility matrix + +| Vigil | Consuming application | +| --- | --- | +| Validate the initial credential and save its authenticated context on the same request | Define HTTP and business authorization rules | +| Avoid reauthentication and authentication side effects on redispatch | Configure MVC async lifecycle and resource cleanup | +| Save into Spring Security's request-attribute repository contract | Install a `RequestAttributeSecurityContextRepository` in `HttpSecurity` | +| Fail closed without evidence of an authenticated initial request | Decide and test any narrow dispatcher-type exceptions | +| Preserve the Spring Security principal across dispatch threads | Propagate additional tenant/domain context when required | + +## Source-backed decisions + +The complete auditable research record, including upstream source/tests, issue evidence, and the +alternatives matrix, is in [async and streaming security research](../research/async-streaming-security-sources.md). + +- [Jakarta Servlet 6.1](https://jakarta.ee/specifications/servlet/6.1/jakarta-servlet-spec-6.1.pdf): `ASYNC` is a dispatch of the same request, supporting request attributes rather than token replay or sessions. +- [Spring Framework async MVC](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html): documents final dispatch and emitter `IOException` handling. +- [Spring Security context persistence](https://docs.spring.io/spring-security/reference/7.0/servlet/authentication/persistence.html): defines request-attribute persistence and explicit saving for custom authentication. +- [Spring Security authorization](https://docs.spring.io/spring-security/reference/7.0/servlet/authorization/authorize-http-requests.html): dispatcher authorization remains application policy. +- [Spring Security issue 12758](https://github.com/spring-projects/spring-security/issues/12758): maintainers prescribe this repository for the equivalent JWT and `StreamingResponseBody` failure. +- [Spring Framework issue 33439](https://github.com/spring-projects/spring-framework/issues/33439): disconnect timing is network/container dependent. + +## Migration + +Synchronous integrations keep their behavior. Async applications must install a +`RequestAttributeSecurityContextRepository` in `HttpSecurity` as shown above. Remove broad +`dispatcherTypeMatchers(ASYNC, ERROR).permitAll()` workarounds after verifying application error +routes. No token, cookie, route, or authorization contract changes are required. diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index 93febfb..a6815d5 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -23,8 +23,13 @@ Vigil auto-configures `VigilAuthenticationFilter`. Add it inside the application ```java @Bean SecurityFilterChain securityFilterChain( - HttpSecurity http, VigilAuthenticationFilter vigilAuthenticationFilter) throws Exception { + HttpSecurity http, + VigilAuthenticationFilter vigilAuthenticationFilter) throws Exception { + var requestSecurityContextRepository = new RequestAttributeSecurityContextRepository(); + vigilAuthenticationFilter.setSecurityContextRepository(requestSecurityContextRepository); return http + .securityContext(context -> + context.securityContextRepository(requestSecurityContextRepository)) .authorizeHttpRequests(authorize -> authorize .requestMatchers("/auth/**").permitAll() .anyRequest().authenticated()) @@ -33,6 +38,10 @@ SecurityFilterChain securityFilterChain( } ``` +The request-scoped repository is required for MVC async and streaming return types. See +[async and streaming security](async-streaming-security.md) for the stateless lifecycle and +dispatcher authorization model. + `ignored-paths` bypasses Vigil entirely. `public-paths` permits an anonymous request while making a valid existing authentication available to the application. Neither setting replaces `authorizeHttpRequests`. ## 3. Issue tokens after application credential validation diff --git a/docs/releases/release-policy.md b/docs/releases/release-policy.md index c4f9a7a..9014991 100644 --- a/docs/releases/release-policy.md +++ b/docs/releases/release-policy.md @@ -1,7 +1,7 @@ # Release Policy Vigil is published as a Spring Boot starter for JWT authentication. Version -`7.1.1` is the current release line and Spring Boot 4.1 certification baseline. +`7.2.0` is the current release line and Spring Boot 4.1 certification baseline. Vigil is used by Sequel applications, but public consumers should pin exact versions, read release notes, and review migration notes before upgrades. @@ -35,15 +35,15 @@ between releases when tests and public behavior remain stable. Current tested compatibility envelope: - Java 25; -- Spring Boot 4.1.x; -- Spring Framework 7.x through Spring Boot 4.1.x; -- Spring Security 7.1.x through Spring Boot 4.1.x; +- Spring Boot 4.1.0; +- Spring Framework 7.0.8 through the Spring Boot 4.1.0 BOM; +- Spring Security 7.1.0 through the Spring Boot 4.1.0 BOM; - Jackson 3 through `tools.jackson` packages; - Gradle 9.6.x wrapper; - HS256 with a configured 256-bit minimum secret; - RS256 with configured PEM private/public keys and JWKS publication. -Vigil `7.0.x` is the active supported platform line. Vigil `6.0.x` was the +Vigil `7.2.x` is the active supported platform line. Vigil `6.0.x` was the final Spring Boot 3.5.x / Java 21 line and is not supported for Spring Boot 4.1 consumers. Do not add compatibility shims between the two lines; Boot 4 changes the default JSON stack to Jackson 3 and modularizes several Boot diff --git a/docs/research/async-streaming-security-sources.md b/docs/research/async-streaming-security-sources.md new file mode 100644 index 0000000..454e5c2 --- /dev/null +++ b/docs/research/async-streaming-security-sources.md @@ -0,0 +1,66 @@ +# Async and streaming security research + +Research cutoff: 2026-07-12. Primary specifications, official documentation, upstream source, +tests, and issue discussions take precedence over secondary guidance. + +## Contract evidence and architectural consequences + +| Primary evidence | Contract used by Vigil | Architectural consequence | +| --- | --- | --- | +| [Servlet 6.1 specification](https://jakarta.ee/specifications/servlet/6.1/jakarta-servlet-spec-6.1.pdf), [AsyncContext](https://jakarta.ee/specifications/servlet/6.1/apidocs/jakarta.servlet/jakarta/servlet/asynccontext), [AsyncListener](https://jakarta.ee/specifications/servlet/6.1/apidocs/jakarta.servlet/jakarta/servlet/asynclistener) | `startAsync` lets the filter/servlet chain return while the same response remains open. Async error and timeout notifications belong to the container lifecycle; a new async cycle requires listener registration again. | Preserve security evidence on the servlet request. Do not replay credentials, manually complete an emitter after failed send, or equate return from the initial chain with request completion. | +| [DispatcherType](https://jakarta.ee/specifications/servlet/6.1/apidocs/jakarta.servlet/jakarta/servlet/dispatchertype), [ServletRequest.getDispatcherType](https://jakarta.ee/specifications/servlet/6.1/apidocs/jakarta.servlet/jakarta/servlet/servletrequest#getDispatcherType()) | `REQUEST`, `ASYNC`, and `ERROR` are distinct filter-chain invocations. `ASYNC` is produced by `AsyncContext.dispatch`; `ERROR` is container error handling. | Authenticate credentials only during the initial request. Continue authorization on redispatch using saved request evidence. A dispatch without that evidence fails closed. | +| [ServletResponse](https://jakarta.ee/specifications/servlet/6.1/apidocs/jakarta.servlet/jakarta/servlet/servletresponse) | Flushing commits status and headers; reset after commit is illegal and later status/header changes cannot repair the response. | Prevent the secondary authorization failure. Do not attempt to render a new 401/403 over committed SSE output. | +| [Spring MVC async processing](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html), [ResponseBodyEmitter](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.html), [SseEmitter](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.html) | `DeferredResult` and streaming return types finish through an `ASYNC` dispatch. An emitter `IOException` caused by client disconnect must be left to the container and MVC, which perform error notification, final dispatch, exception resolution, and completion. | Broken pipe remains an expected network event. Vigil preserves authentication for the final dispatch instead of catching or suppressing the `IOException`. | +| [OncePerRequestFilter](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/OncePerRequestFilter.html) | Async and error dispatch participation is opt-in; both can run on different threads. | Vigil keeps the authentication filter out of redispatch. JWT validation, blacklist lookup, tenant extraction, hooks, and context populators execute once. | +| [Spring Security context persistence](https://docs.spring.io/spring-security/reference/7.0/servlet/authentication/persistence.html), [RequestAttributeSecurityContextRepository API](https://docs.spring.io/spring-security/reference/7.1/api/java/org/springframework/security/web/context/RequestAttributeSecurityContextRepository.html) | Custom authentication must explicitly save. The request-attribute repository restores one request across dispatch types and never persists to later requests. `SecurityContextHolderFilter` loads and clears the holder. | Save the authenticated context explicitly to a request-attribute repository configured in the application chain. This is stateless and creates no `HttpSession`. | +| [Servlet authorization](https://docs.spring.io/spring-security/reference/7.0/servlet/authorization/authorize-http-requests.html), [Servlet async integration](https://docs.spring.io/spring-security/reference/7.0/servlet/integrations/servlet-api.html) | Authorization applies to dispatcher types by default. Async task propagation and redispatch persistence are related but distinct mechanisms. | Vigil does not install dispatcher `permitAll` rules. The application continues to own HTTP authorization, including any narrow error-rendering exception. | + +## Upstream implementation and test evidence + +- Spring Framework 7.0.x: + - [`ResponseBodyEmitterReturnValueHandler`](https://github.com/spring-projects/spring-framework/blob/7.0.x/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java) + - [`DeferredResultMethodReturnValueHandler`](https://github.com/spring-projects/spring-framework/blob/7.0.x/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/DeferredResultMethodReturnValueHandler.java) + - [`ResponseBodyEmitterReturnValueHandlerTests`](https://github.com/spring-projects/spring-framework/blob/7.0.x/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java) + - [`StreamingResponseBodyReturnValueHandlerTests`](https://github.com/spring-projects/spring-framework/blob/7.0.x/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/StreamingResponseBodyReturnValueHandlerTests.java) +- Spring Security 7.0.x: + - [`RequestAttributeSecurityContextRepository`](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/main/java/org/springframework/security/web/context/RequestAttributeSecurityContextRepository.java) and [tests](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/test/java/org/springframework/security/web/context/RequestAttributeSecurityContextRepositoryTests.java) + - [`SecurityContextHolderFilter`](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/main/java/org/springframework/security/web/context/SecurityContextHolderFilter.java) and [tests](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/test/java/org/springframework/security/web/context/SecurityContextHolderFilterTests.java) + - [`AuthorizationFilter`](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/main/java/org/springframework/security/web/access/intercept/AuthorizationFilter.java) and [tests](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/test/java/org/springframework/security/web/access/intercept/AuthorizationFilterTests.java) + - [`ExceptionTranslationFilter`](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java) and [tests](https://github.com/spring-projects/spring-security/blob/7.0.x/web/src/test/java/org/springframework/security/web/access/ExceptionTranslationFilterTests.java) +- Spring Boot 4.1.x: + - [`SecurityFilterAutoConfiguration`](https://github.com/spring-projects/spring-boot/blob/main/module/spring-boot-security/src/main/java/org/springframework/boot/security/autoconfigure/web/servlet/SecurityFilterAutoConfiguration.java) + - [`SecurityFilterAutoConfigurationTests`](https://github.com/spring-projects/spring-boot/blob/main/module/spring-boot-security/src/test/java/org/springframework/boot/security/autoconfigure/web/servlet/SecurityFilterAutoConfigurationTests.java) + +The local executable baseline resolves Spring Boot 4.1.0, Spring Framework MVC 7.0.8, Spring +Security Web 7.1.0, Tomcat 11.0.22, and Servlet 6.1. Vigil does not claim unexecuted version ranges. + +## Official issue evidence + +| Issue | Relevance and decision | +| --- | --- | +| [spring-security#12758](https://github.com/spring-projects/spring-security/issues/12758) | Direct match: custom JWT filter plus `StreamingResponseBody`, final async authorization without saved authentication. A Spring Security maintainer prescribes explicit `RequestAttributeSecurityContextRepository.saveContext`. This is the primary precedent for Vigil's implementation. | +| [spring-security#11962](https://github.com/spring-projects/spring-security/issues/11962) | Records the loss of authentication around async dispatch and `SecurityContextHolderFilter`; reinforces that redispatch restoration belongs to the context repository rather than token replay. | +| [spring-security#5273](https://github.com/spring-projects/spring-security/issues/5273) | Documents why exception translation cannot safely start a second security response after commitment. Vigil prevents the secondary denial instead of swallowing it. | +| [spring-framework#32042](https://github.com/spring-projects/spring-framework/issues/32042) and [#32340](https://github.com/spring-projects/spring-framework/issues/32340) | Cover client-abort async dispatch and concurrent error-handling protection. Vigil tests lifecycle outcomes rather than container-specific exception names. | +| [spring-framework#33439](https://github.com/spring-projects/spring-framework/issues/33439) and [#32629](https://github.com/spring-projects/spring-framework/issues/32629) | Show that disconnect timing and exception shape vary by container and transport. Vigil does not promise to eliminate Broken pipe and uses a real Tomcat/RST test. | + +## Alternatives evaluated + +| Alternative | Decision | +| --- | --- | +| `RequestAttributeSecurityContextRepository` | Selected. It preserves one physical servlet request across dispatches and cannot authenticate a later independent request. | +| `DelegatingSecurityContextRepository` with `HttpSessionSecurityContextRepository` | Rejected as a Vigil stateless default. It adds cross-request session persistence and can create session coupling. An explicitly stateful application may choose it, but that is outside this support contract. | +| Re-run `VigilAuthenticationFilter` for `ASYNC` and `ERROR` | Rejected. It repeats signature validation, revocation checks, tenant extraction, session lookup, populators, hooks, metrics, and audit effects, and may depend on credentials unavailable during redispatch. | +| Restore context inside the Vigil filter during redispatch | Rejected. It duplicates `SecurityContextHolderFilter` and filter-order responsibilities already owned by Spring Security. | +| Globally permit `ASYNC` or `ERROR` | Rejected. It changes application authorization and permits a dispatch without proof of a previously authenticated request. | +| Auto-configure an application `SecurityFilterChain` or dispatcher rules | Rejected. Vigil does not own product HTTP authorization. The repository is explicit in the consumer's chain. | +| Publish a Vigil-specific DSL/configurer | Not selected. The official `HttpSecurity.securityContext` configuration is smaller and clearer; a wrapper would add API without additional behavior. | +| Startup introspection/diagnostics of consumer filter chains | Not automated. Spring applications may have multiple chains and repositories; guessing compatibility can produce false assurance. The requirement is documented and proven by integration tests. | +| Preserve tenant or arbitrary application `ThreadLocal` state | Not automated. The request principal is Vigil's contract; application domain context and async executor propagation remain application-owned. | + +## Responsibility conclusion + +Vigil owns initial credential authentication, explicit request-scoped context saving, single +execution of authentication effects, and fail-closed absence of evidence. Applications own the +`SecurityFilterChain`, authorization policy, MVC executor/timeouts, stream resource lifecycle, +heartbeats, exception rendering, and any non-Spring domain context propagation. diff --git a/src/main/java/io/github/sequelcore/vigil/filter/VigilAuthenticationFilter.java b/src/main/java/io/github/sequelcore/vigil/filter/VigilAuthenticationFilter.java index 6882d80..75c2cca 100644 --- a/src/main/java/io/github/sequelcore/vigil/filter/VigilAuthenticationFilter.java +++ b/src/main/java/io/github/sequelcore/vigil/filter/VigilAuthenticationFilter.java @@ -19,13 +19,17 @@ import java.time.Instant; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import org.springframework.lang.Nullable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.web.filter.OncePerRequestFilter; /** @@ -65,6 +69,10 @@ public class VigilAuthenticationFilter extends OncePerRequestFilter { private final PathMatcher ignoredPathMatcher; private final PathMatcher publicPathMatcher; private final ProfilePathMatcher profilePathMatcher; + private SecurityContextRepository securityContextRepository = + new RequestAttributeSecurityContextRepository(); + private final SecurityContextHolderStrategy securityContextHolderStrategy = + SecurityContextHolder.getContextHolderStrategy(); /** Creates a new authentication filter with all dependencies. */ public VigilAuthenticationFilter( @@ -91,6 +99,11 @@ public VigilAuthenticationFilter( this.profilePathMatcher = new ProfilePathMatcher(filterConfig.profilePaths()); } + /** Sets the repository used to preserve authentication within the same servlet request. */ + public void setSecurityContextRepository(SecurityContextRepository securityContextRepository) { + this.securityContextRepository = Objects.requireNonNull(securityContextRepository); + } + @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -207,7 +220,7 @@ private VigilTokenClaims authenticateJwt( UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(claims.getSubject(), null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); + saveAuthentication(request, response, authentication); // Notify subclasses of successful authentication onAuthenticationSuccess(request, response, claims); @@ -255,7 +268,7 @@ private boolean authenticateSession( UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); + saveAuthentication(request, response, authentication); // Let application populate its context provider.onAuthenticated(session, request); @@ -266,6 +279,16 @@ private boolean authenticateSession( return true; } + private void saveAuthentication( + HttpServletRequest request, + HttpServletResponse response, + UsernamePasswordAuthenticationToken authentication) { + var context = securityContextHolderStrategy.createEmptyContext(); + context.setAuthentication(authentication); + securityContextHolderStrategy.setContext(context); + securityContextRepository.saveContext(context, request, response); + } + private boolean handleTenantContext( HttpServletRequest request, HttpServletResponse response, VigilTokenClaims claims) { diff --git a/src/test/java/io/github/sequelcore/vigil/integration/VigilAsyncSecurityIntegrationTest.java b/src/test/java/io/github/sequelcore/vigil/integration/VigilAsyncSecurityIntegrationTest.java new file mode 100644 index 0000000..ab0f5ff --- /dev/null +++ b/src/test/java/io/github/sequelcore/vigil/integration/VigilAsyncSecurityIntegrationTest.java @@ -0,0 +1,233 @@ +package io.github.sequelcore.vigil.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.github.sequelcore.vigil.autoconfigure.VigilProperties; +import io.github.sequelcore.vigil.context.VigilContextPopulator; +import io.github.sequelcore.vigil.core.jwt.HmacTokenSigner; +import io.github.sequelcore.vigil.core.jwt.TokenRequest; +import io.github.sequelcore.vigil.core.jwt.VigilTokenClaims; +import io.github.sequelcore.vigil.core.jwt.VigilTokenService; +import io.github.sequelcore.vigil.integration.testapp.TestApplication; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource( + properties = + "spring.config.import=classpath:io/github/sequelcore/vigil/integration/testapp/application-test.yml") +@Import(VigilAsyncSecurityIntegrationTest.ProbeConfiguration.class) +class VigilAsyncSecurityIntegrationTest { + + @Autowired private MockMvc mvc; + @Autowired private VigilTokenService tokenService; + @Autowired private AuthenticationProbe authenticationProbe; + @Autowired private VigilProperties properties; + + @BeforeEach + void resetProbe() { + authenticationProbe.reset(); + } + + @Test + void authenticatedDeferredResultRetainsAuthenticationAcrossAsyncDispatch() throws Exception { + String token = + tokenService.generateAccessToken( + TokenRequest.builder().subject("async-user").claim("role", "USER").build()); + + MvcResult initial = + mvc.perform(get("/protected/deferred").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(request().asyncStarted()) + .andReturn(); + assertThat(initial.getRequest().getSession(false)).isNull(); + + MvcResult completed = + mvc.perform(asyncDispatch(initial)).andExpect(status().isOk()).andReturn(); + assertThat(completed.getRequest().getSession(false)).isNull(); + assertThat(authenticationProbe.populationCount()).isEqualTo(1); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void authenticatedStreamingTypesRetainAuthenticationWithoutReauthentication() throws Exception { + for (String path : + new String[] {"/protected/emitter", "/protected/sse", "/protected/streaming"}) { + authenticationProbe.reset(); + MvcResult initial = + mvc.perform(authenticatedGet(path, "stream-user")) + .andExpect(request().asyncStarted()) + .andReturn(); + mvc.perform(asyncDispatch(initial)).andExpect(status().isOk()); + assertThat(authenticationProbe.populationCount()).isEqualTo(1); + } + } + + @Test + void fabricatedAsyncAndErrorDispatchesFailClosed() throws Exception { + mvc.perform( + get("/protected/deferred") + .with( + request -> { + request.setDispatcherType(DispatcherType.ASYNC); + return request; + })) + .andExpect(status().isForbidden()); + mvc.perform( + get("/protected/deferred") + .with( + request -> { + request.setDispatcherType(DispatcherType.ERROR); + return request; + })) + .andExpect(status().isForbidden()); + } + + @Test + void statelessAsyncSupportDoesNotCreateOrReuseHttpSession() throws Exception { + mvc.perform(authenticatedGet("/protected/session-state", "stateless-user")) + .andExpect(status().isOk()) + .andExpect(content().string("false")); + mvc.perform(get("/protected/session-state")).andExpect(status().isForbidden()); + } + + @Test + void authorizationRulesApplyBeforeAndDuringAsyncDispatch() throws Exception { + mvc.perform(authenticatedGet("/protected/admin/deferred", "ordinary-user", "USER")) + .andExpect(status().isForbidden()) + .andExpect(request().asyncNotStarted()); + + MvcResult initial = + mvc.perform(authenticatedGet("/protected/admin/deferred", "admin-user", "ADMIN")) + .andExpect(request().asyncStarted()) + .andReturn(); + mvc.perform(asyncDispatch(initial)) + .andExpect(status().isOk()) + .andExpect(content().string("admin-user")); + } + + @Test + void invalidAndExpiredTokensAreRejectedBeforeAsyncStarts() throws Exception { + for (String token : List.of("not-a-valid-jwt", expiredToken())) { + mvc.perform(get("/protected/deferred").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isForbidden()) + .andExpect(request().asyncNotStarted()); + } + } + + @Test + void concurrentAsyncRequestsKeepPrincipalsIsolatedAndClearThreadLocals() throws Exception { + List> requests = new ArrayList<>(); + for (int index = 0; index < 12; index++) { + String subject = "concurrent-user-" + index; + requests.add( + () -> { + MvcResult initial = + mvc.perform(authenticatedGet("/protected/deferred", subject)) + .andExpect(request().asyncStarted()) + .andReturn(); + mvc.perform(asyncDispatch(initial)) + .andExpect(status().isOk()) + .andExpect(content().string(subject)); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + return null; + }); + } + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + for (var result : executor.invokeAll(requests)) { + result.get(); + } + } + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder + authenticatedGet(String path, String subject) { + return authenticatedGet(path, subject, "USER"); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder + authenticatedGet(String path, String subject, String role) { + String token = + tokenService.generateAccessToken( + TokenRequest.builder().subject(subject).claim("role", role).build()); + return get(path).header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + + private String expiredToken() { + Instant now = Instant.now(); + return new HmacTokenSigner(properties.jwt().secret()) + .sign( + Jwts.builder() + .subject("expired-async-user") + .issuedAt(Date.from(now.minusSeconds(120))) + .expiration(Date.from(now.minusSeconds(60)))); + } + + @TestConfiguration + static class ProbeConfiguration { + @Bean + AuthenticationProbe authenticationProbe() { + return new AuthenticationProbe(); + } + + @Bean + VigilContextPopulator probeContextPopulator(AuthenticationProbe probe) { + return new VigilContextPopulator() { + @Override + public void populate(HttpServletRequest request, @Nullable VigilTokenClaims claims) { + probe.recordPopulation(); + } + + @Override + public void clear() {} + }; + } + } + + static final class AuthenticationProbe { + private final AtomicInteger populations = new AtomicInteger(); + + void recordPopulation() { + populations.incrementAndGet(); + } + + int populationCount() { + return populations.get(); + } + + void reset() { + populations.set(0); + } + } +} diff --git a/src/test/java/io/github/sequelcore/vigil/integration/VigilSseDisconnectTomcatIntegrationTest.java b/src/test/java/io/github/sequelcore/vigil/integration/VigilSseDisconnectTomcatIntegrationTest.java new file mode 100644 index 0000000..c949fa4 --- /dev/null +++ b/src/test/java/io/github/sequelcore/vigil/integration/VigilSseDisconnectTomcatIntegrationTest.java @@ -0,0 +1,443 @@ +package io.github.sequelcore.vigil.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.github.sequelcore.vigil.context.VigilContextPopulator; +import io.github.sequelcore.vigil.core.jwt.TokenRequest; +import io.github.sequelcore.vigil.core.jwt.VigilTokenClaims; +import io.github.sequelcore.vigil.core.jwt.VigilTokenService; +import io.github.sequelcore.vigil.filter.VigilAuthenticationFilter; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.filter.GenericFilterBean; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@SpringBootTest( + classes = VigilSseDisconnectTomcatIntegrationTest.DisconnectTestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@TestPropertySource( + properties = + "spring.config.import=classpath:io/github/sequelcore/vigil/integration/testapp/application-test.yml") +class VigilSseDisconnectTomcatIntegrationTest { + + @LocalServerPort private int port; + @Autowired private VigilTokenService tokenService; + @Autowired private DisconnectProbe probe; + + @BeforeEach + void resetProbe() { + probe.reset(); + } + + @Test + @Timeout(45) + void clientDisconnectCompletesCommittedSseWithoutSecondarySecurityFailure() throws Exception { + UUID streamId = UUID.randomUUID(); + String token = + tokenService.generateAccessToken( + TokenRequest.builder().subject("disconnect-user").claim("role", "USER").build()); + + try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port)) { + socket.setSoTimeout((int) Duration.ofSeconds(10).toMillis()); + writeRequest(socket.getOutputStream(), streamId, token); + String response = readThroughReadyEvent(socket); + + assertThat(response).contains("200").contains("text/event-stream").contains("data:ready"); + assertThat(probe.awaitRegistered(streamId)).isTrue(); + + socket.setSoLinger(true, 0); + } + + IOException disconnect = probe.writeUntilDisconnected(streamId); + + assertThat(disconnect).isNotNull(); + assertThat(probe.awaitReleased(streamId)).isTrue(); + assertThat(probe.error(streamId)).isPresent(); + assertThat(probe.entryPointCount()).isZero(); + assertThat(probe.accessDeniedCount()).isZero(); + assertThat(probe.authenticationCount()).isEqualTo(1); + assertThat(probe.controllerCount()).isEqualTo(1); + assertThat(probe.controllerPrincipals()).containsExactly("disconnect-user"); + assertThat(probe.dispatches()) + .anySatisfy( + dispatch -> { + assertThat(dispatch.type()).isEqualTo(DispatcherType.ASYNC); + assertThat(dispatch.principal()).isEqualTo("disconnect-user"); + assertThat(dispatch.committed()).isTrue(); + assertThat(dispatch.hasSession()).isFalse(); + }); + assertThat( + probe.dispatches().stream() + .filter(dispatch -> dispatch.type() == DispatcherType.ASYNC) + .toList()) + .hasSize(1); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + assertThat(probe.hasActiveStream(streamId)).isFalse(); + } + + @Test + @Timeout(45) + void containerErrorDispatchKeepsAuthenticationWithoutBypassOrRecursion() throws Exception { + String token = + tokenService.generateAccessToken( + TokenRequest.builder().subject("error-user").claim("role", "USER").build()); + + try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port)) { + socket.setSoTimeout((int) Duration.ofSeconds(10).toMillis()); + writeRequest(socket.getOutputStream(), "/protected/container-error", token); + readUntilClosed(socket); + } + + assertThat(probe.awaitErrorDispatch()).isTrue(); + assertThat(probe.entryPointCount()).isZero(); + assertThat(probe.accessDeniedCount()).isZero(); + assertThat(probe.authenticationCount()).isEqualTo(1); + assertThat(probe.dispatches()) + .anySatisfy( + dispatch -> { + assertThat(dispatch.type()).isEqualTo(DispatcherType.ERROR); + assertThat(dispatch.principal()).isEqualTo("error-user"); + assertThat(dispatch.hasSession()).isFalse(); + }); + assertThat( + probe.dispatches().stream() + .filter(dispatch -> dispatch.type() == DispatcherType.ERROR) + .toList()) + .hasSize(1); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private void writeRequest(OutputStream output, UUID streamId, String token) throws IOException { + writeRequest(output, "/protected/disconnect-sse/" + streamId, token); + } + + private void writeRequest(OutputStream output, String path, String token) throws IOException { + String request = + "GET " + + path + + " HTTP/1.1\r\nHost: localhost:" + + port + + "\r\nAccept: text/event-stream\r\nAuthorization: Bearer " + + token + + "\r\nConnection: keep-alive\r\n\r\n"; + output.write(request.getBytes(StandardCharsets.US_ASCII)); + output.flush(); + } + + private void readUntilClosed(Socket socket) throws IOException { + BufferedInputStream input = new BufferedInputStream(socket.getInputStream()); + input.transferTo(OutputStream.nullOutputStream()); + } + + private String readThroughReadyEvent(Socket socket) throws IOException { + BufferedInputStream input = new BufferedInputStream(socket.getInputStream()); + StringBuilder response = new StringBuilder(); + while (!response.toString().contains("data:ready")) { + int next = input.read(); + if (next < 0) { + break; + } + response.append((char) next); + } + return response.toString(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({DisconnectSecurityConfiguration.class, DisconnectSseController.class}) + static class DisconnectTestApplication {} + + @Configuration(proxyBeanMethods = false) + static class DisconnectSecurityConfiguration { + + @Bean + DisconnectProbe disconnectProbe() { + return new DisconnectProbe(); + } + + @Bean + VigilContextPopulator disconnectAuthenticationProbe(DisconnectProbe probe) { + return new VigilContextPopulator() { + @Override + public void populate(HttpServletRequest request, @Nullable VigilTokenClaims claims) { + probe.recordAuthentication(); + } + + @Override + public void clear() {} + }; + } + + @Bean + SecurityFilterChain disconnectSecurityFilterChain( + HttpSecurity http, VigilAuthenticationFilter authenticationFilter, DisconnectProbe probe) + throws Exception { + RequestAttributeSecurityContextRepository repository = + new RequestAttributeSecurityContextRepository(); + authenticationFilter.setSecurityContextRepository(repository); + http.sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .securityContext(context -> context.securityContextRepository(repository)) + .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) + .exceptionHandling( + exceptions -> + exceptions + .authenticationEntryPoint( + (request, response, exception) -> { + probe.recordEntryPoint(); + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } + }) + .accessDeniedHandler( + (request, response, exception) -> { + probe.recordAccessDenied(); + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } + })) + .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(new DispatchProbeFilter(probe), AuthorizationFilter.class); + return http.build(); + } + } + + @RestController + static class DisconnectSseController { + private final DisconnectProbe probe; + + DisconnectSseController(DisconnectProbe probe) { + this.probe = probe; + } + + @GetMapping( + value = "/protected/disconnect-sse/{streamId}", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + SseEmitter connect(@PathVariable UUID streamId, Authentication authentication) + throws IOException { + return probe.open(streamId, authentication.getName()); + } + + @GetMapping("/protected/container-error") + void containerError(HttpServletResponse response) throws IOException { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + static final class DispatchProbeFilter extends GenericFilterBean { + private final DisconnectProbe probe; + + DispatchProbeFilter(DisconnectProbe probe) { + this.probe = probe; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + probe.recordDispatch( + new DispatchObservation( + httpRequest.getDispatcherType(), + authentication == null ? null : authentication.getName(), + httpResponse.isCommitted(), + httpRequest.getSession(false) != null)); + chain.doFilter(request, response); + } + } + + record DispatchObservation( + DispatcherType type, @Nullable String principal, boolean committed, boolean hasSession) {} + + static final class DisconnectProbe { + private static final String LARGE_EVENT = "x".repeat(256 * 1024); + private final Map streams = new ConcurrentHashMap<>(); + private final List dispatches = new CopyOnWriteArrayList<>(); + private final AtomicInteger entryPoints = new AtomicInteger(); + private final AtomicInteger accessDenied = new AtomicInteger(); + private final AtomicInteger authentications = new AtomicInteger(); + private final AtomicInteger controllers = new AtomicInteger(); + private final List controllerPrincipals = new CopyOnWriteArrayList<>(); + private final Map terminalErrors = new ConcurrentHashMap<>(); + + void reset() { + streams.clear(); + dispatches.clear(); + entryPoints.set(0); + accessDenied.set(0); + authentications.set(0); + controllers.set(0); + controllerPrincipals.clear(); + terminalErrors.clear(); + } + + SseEmitter open(UUID id, String principal) throws IOException { + controllers.incrementAndGet(); + controllerPrincipals.add(principal); + SseEmitter emitter = new SseEmitter(Duration.ofSeconds(20).toMillis()); + StreamState state = new StreamState(emitter); + streams.put(id, state); + emitter.onError( + error -> { + terminalErrors.put(id, error); + state.errored.countDown(); + }); + emitter.onCompletion( + () -> { + streams.remove(id, state); + state.completed.countDown(); + }); + emitter.send(SseEmitter.event().name("ready").data("ready")); + state.registered.countDown(); + return emitter; + } + + IOException writeUntilDisconnected(UUID id) throws InterruptedException { + StreamState state = streams.get(id); + if (state == null) { + return null; + } + for (int attempt = 0; attempt < 32; attempt++) { + try { + state.emitter.send(SseEmitter.event().name("data").data(LARGE_EVENT)); + } catch (IOException exception) { + return exception; + } + TimeUnit.MILLISECONDS.sleep(10); + } + return null; + } + + boolean awaitRegistered(UUID id) throws InterruptedException { + StreamState state = streams.get(id); + return state != null && state.registered.await(5, TimeUnit.SECONDS); + } + + boolean awaitReleased(UUID id) throws InterruptedException { + StreamState state = streams.get(id); + if (state == null) { + return true; + } + return state.errored.await(20, TimeUnit.SECONDS) + && state.completed.await(20, TimeUnit.SECONDS); + } + + boolean awaitErrorDispatch() throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (System.nanoTime() < deadline) { + if (dispatches.stream().anyMatch(dispatch -> dispatch.type() == DispatcherType.ERROR)) { + return true; + } + TimeUnit.MILLISECONDS.sleep(10); + } + return false; + } + + void recordEntryPoint() { + entryPoints.incrementAndGet(); + } + + void recordAccessDenied() { + accessDenied.incrementAndGet(); + } + + void recordAuthentication() { + authentications.incrementAndGet(); + } + + void recordDispatch(DispatchObservation observation) { + dispatches.add(observation); + } + + int entryPointCount() { + return entryPoints.get(); + } + + int accessDeniedCount() { + return accessDenied.get(); + } + + int authenticationCount() { + return authentications.get(); + } + + int controllerCount() { + return controllers.get(); + } + + List controllerPrincipals() { + return List.copyOf(controllerPrincipals); + } + + java.util.Optional error(UUID id) { + return java.util.Optional.ofNullable(terminalErrors.get(id)); + } + + List dispatches() { + return List.copyOf(dispatches); + } + + boolean hasActiveStream(UUID id) { + return streams.containsKey(id); + } + } + + static final class StreamState { + private final SseEmitter emitter; + private final CountDownLatch registered = new CountDownLatch(1); + private final CountDownLatch errored = new CountDownLatch(1); + private final CountDownLatch completed = new CountDownLatch(1); + + StreamState(SseEmitter emitter) { + this.emitter = emitter; + } + } +} diff --git a/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestApplication.java b/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestApplication.java index 4c1ec92..89ea4af 100644 --- a/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestApplication.java +++ b/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestApplication.java @@ -10,6 +10,8 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; @SpringBootApplication public class TestApplication { @@ -22,13 +24,19 @@ public static void main(String[] args) { SecurityFilterChain securityFilterChain( HttpSecurity http, VigilAuthenticationFilter authenticationFilter, VigilProperties properties) throws Exception { + SecurityContextRepository securityContextRepository = + new RequestAttributeSecurityContextRepository(); + authenticationFilter.setSecurityContextRepository(securityContextRepository); http.csrf(AbstractHttpConfigurer::disable) .sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .securityContext(context -> context.securityContextRepository(securityContextRepository)) .authorizeHttpRequests( auth -> auth.requestMatchers(properties.filter().publicPaths().toArray(String[]::new)) .permitAll() + .requestMatchers("/protected/admin/**") + .hasRole("ADMIN") .anyRequest() .authenticated()) .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestController.java b/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestController.java index b9929e8..c901881 100644 --- a/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestController.java +++ b/src/test/java/io/github/sequelcore/vigil/integration/testapp/TestController.java @@ -4,7 +4,10 @@ import io.github.sequelcore.vigil.core.jwt.TokenRequest; import io.github.sequelcore.vigil.core.jwt.VigilTokenService; import io.github.sequelcore.vigil.tenant.VigilTenantContext; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -12,6 +15,10 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @RestController @RequiredArgsConstructor @@ -35,6 +42,47 @@ public String protectedHello(Authentication authentication) { return authentication.getName(); } + @GetMapping("/protected/deferred") + public DeferredResult protectedDeferred(Authentication authentication) { + DeferredResult result = new DeferredResult<>(); + result.setResult(authentication.getName()); + return result; + } + + @GetMapping("/protected/emitter") + public ResponseBodyEmitter protectedEmitter(Authentication authentication) throws IOException { + ResponseBodyEmitter emitter = new ResponseBodyEmitter(); + emitter.send(authentication.getName()); + emitter.complete(); + return emitter; + } + + @GetMapping(value = "/protected/sse", produces = "text/event-stream") + public SseEmitter protectedSse(Authentication authentication) throws IOException { + SseEmitter emitter = new SseEmitter(); + emitter.send(SseEmitter.event().data(authentication.getName())); + emitter.complete(); + return emitter; + } + + @GetMapping("/protected/streaming") + public StreamingResponseBody protectedStreaming(Authentication authentication) { + String principal = authentication.getName(); + return output -> output.write(principal.getBytes(StandardCharsets.UTF_8)); + } + + @GetMapping("/protected/admin/deferred") + public DeferredResult protectedAdminDeferred(Authentication authentication) { + DeferredResult result = new DeferredResult<>(); + result.setResult(authentication.getName()); + return result; + } + + @GetMapping("/protected/session-state") + public String sessionState(HttpServletRequest request) { + return Boolean.toString(request.getSession(false) != null); + } + @GetMapping("/protected/tenant") public ResponseEntity tenant() { return VigilTenantContext.getTenant()