← § BLOG

SSE + Spring Security: три ловушки async-dispatch, которые ломают tool-streaming

Запустил SSE для tool-streaming LLM, упёрся в три отдельных бага Spring Security при async-обработке. Разбор каждого и какой минимум кода правильный.

#spring-boot#spring-security#sse#java

В моём ассистенте поверх системы мониторинга OLT каждый диалог стримится в браузер через Server-Sent Events: tool-вызовы, аргументы, результаты, output модели. Без стрима пользователь сидит 6 секунд перед пустым экраном, со стримом — видит, как бот строит ответ.

Локально всё работало. На стейдже — AccessDenied 403 ровно в момент финализации SSE, после уже отданных событий. Лог обрывался на закрывающем complete(). Ушло пол-дня и три отдельных коммита, прежде чем я понял, что это три разных бага в трёх разных слоях, и закрывать их надо все три сразу.

Как устроен SSE в Spring MVC

Контроллер возвращает SseEmitter, и Spring обрабатывает запрос в два прохода:

  1. REQUEST dispatch — нормальный HTTP-цикл: фильтры, аутентификация, контроллер. Он завершается, как только метод вернул SseEmitter — но соединение остаётся открытым.
  2. ASYNC dispatch — отдельный worker thread пишет события в response через emitter.send(...). Он живёт сколько угодно, потом вызывает emitter.complete().
  3. Финальный dispatch — Spring повторно прогоняет запрос через фильтры с DispatcherType.ASYNC для post-processing. Здесь мой SecurityFilterChain снова срабатывал и снова проверял auth — но SecurityContext уже не было в worker'е.

Каждый из трёх шагов — кандидат на сломать.

Ловушка 1: @PreAuthorize на SSE endpoint ломает async-dispatch

@GetMapping(value = "/api/llm/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@PreAuthorize("isAuthenticated()")  // ← вот это ломает
public SseEmitter chat(@RequestParam String message) {
    var emitter = new SseEmitter(0L);
    chatService.runAsync(message, emitter);
    return emitter;
}

@PreAuthorize — это AOP-аспект, проверяющий SecurityContext на каждом dispatch. На REQUEST всё хорошо: пользователь аутентифицирован. На ASYNC dispatch — context пустой, аспект бросает AccessDeniedException, Spring отвечает 403 поверх уже стрима.

Снял аннотацию, проверяю SecurityContextHolder.getContext().getAuthentication() руками в начале метода:

@GetMapping("/api/llm/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String message) {
    var auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth == null || !auth.isAuthenticated()) {
        throw new AccessDeniedException("auth required");
    }
    // ...
}

Auth проверяется один раз на REQUEST, async-dispatch не цепляет аспект. Стало лучше, но не вылечило.

Ловушка 2: SecurityContext теряется в worker thread

chatService.runAsync крутит tool-loop в отдельном потоке через @Async или Executor.submit(). Поток новый — ThreadLocal пустой. Если в loop'е вызывается tool, который сам ходит куда-то с проверкой прав (@PreAuthorize на сервисе), снова летит AccessDenied, но уже изнутри worker'а.

Решение в стандартной библиотеке Spring Security — DelegatingSecurityContextRunnable:

import org.springframework.security.concurrent.DelegatingSecurityContextRunnable;

public void runAsync(String message, SseEmitter emitter) {
    var ctx = SecurityContextHolder.getContext();
    executor.submit(new DelegatingSecurityContextRunnable(
        () -> tryRun(message, emitter),
        ctx
    ));
}

Обёртка копирует SecurityContext в worker thread перед запуском и убирает после. Тот же эффект даёт DelegatingSecurityContextExecutor — обернуть в него сам Executor, тогда руками каждый раз не писать.

Ловушка 3: SecurityFilterChain бьёт по DispatcherType.ASYNC

Это самая хитрая. После завершения worker'а Spring делает финальный async-dispatch для post-processing. Запрос снова проходит через SecurityFilterChain — но это тот же самый запрос, аутентификация уже была. Если фильтр снова попробует достать principal (например, через JwtAuthenticationFilter), он либо упадёт, либо попытается ре-аутентифицировать с пустым Authorization header (он же снят на REQUEST после login).

В моём случае JwtAuthenticationFilter не находил токена и отдавал AccessDenied. Решение — пропустить async/error dispatchтипы:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher(req ->
            req.getDispatcherType() == DispatcherType.REQUEST
        )
        // ... всё остальное как было
        .build();
}

securityMatcher с проверкой DispatcherType.REQUEST означает: фильтр-чейн срабатывает только на первый dispatch. Async и error пропускаются — они и так уже на authorized-запросе, повторная проверка лишняя и вредная.

Что я понял

  1. SSE — это три dispatch'а, не один. REQUEST, ASYNC, и финальный async-после-complete. Любая Security-логика должна понимать, на каком из трёх она исполняется.
  2. Не вешай @PreAuthorize на SSE-endpoint. Аннотация-аспект бьёт на каждый dispatch. Проверяй auth руками один раз и пробрасывай SecurityContext в worker.
  3. DelegatingSecurityContextRunnable — стандарт, не хак. Spring Security даёт его именно для async-сценариев. Не изобретай собственный пробрасыватель ThreadLocal'ов.
  4. securityMatcher(req → DispatcherType.REQUEST) — must-have для проектов с SSE/WebFlux/async. Без него любой post-processing dispatch может уронить уже отвеченный запрос.

Если SSE падает с AccessDenied через секунду после того, как уже отдал данные — ищи async-dispatch, не endpoint.

Поделиться
TelegramX (Twitter)
Discussion

Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.

Открыть обсуждение на GitHub
SSE + Spring Security: три ловушки async-dispatch, которые ломают tool-streaming · Григорий Масич