/
Al
/
TESamples
Обзор
Документация
Войти
/
Al
/
TESamples
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
apps/spring/bff-agent/src/main/java/com/tokenexchange/bff/AgentTaskController.java
348 строк
16 KB
Al
Initial Commit
11 май 2026, 17:40
11 май 2026, 17:40
0a04577
Код
Авторство
О чём код?
package com.tokenexchange.bff; import com.fasterxml.jackson.databind.JsonNode; import com.tokenexchange.lib.AuditLogger; import com.tokenexchange.lib.JwtPayloadDecoder; import com.tokenexchange.lib.TokenExchangeClient; import com.tokenexchange.lib.TokenExchangeRequest; import com.tokenexchange.lib.TokenExchangeResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.constraints.NotBlank; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestClient; import org.springframework.web.server.ResponseStatusException; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; /** * BFF-эндпоинты для запуска агентской задачи. Реализует основной паттерн «один * обмен токенами на входе цепочки» — за деталями см. Рекомендации для клиента, * разделы: * <ul> * <li>§1.1 «Обмен токеном ровно один раз — на входе в цепочку» * — docs/client-recommendations.md#11</li> * <li>§2.2 «Кто выполняет обмен токенами» (BFF, не браузер) * — docs/client-recommendations.md#22</li> * <li>§5 «Сужение прав агента» (down-scoping) * — docs/client-recommendations.md#5</li> * <li>§6.1–6.3 «Жизненный цикл токенов» (refresh user_at, retry на * invalid_grant, заблаговременное обновление delegated_at) * — docs/client-recommendations.md#6</li> * </ul> * * <p>Endpoint Keycloak выбирается {@link IdpRouter} на шаге ROPC. Дальше * actor_token и сам обмен используют тот же endpoint — иначе iss выпущенных * токенов и {@code act} будут рассогласованы. */ @RestController @RequestMapping("/agent") public class AgentTaskController { private static final Logger log = LoggerFactory.getLogger(AgentTaskController.class); private final KeycloakClient kc; private final TokenExchangeClient te; private final RestClient http; private final AuditLogger audit; private final IdpRouter idpRouter; private final KcAdminClient kcAdmin; private final LifecycleConfig lifecycle; private final LifecycleMetrics lifecycleMetrics; private final String chainEntryUrl; private final String teAudience; private final String teScope; private final String userScopeOnLogin; private final String bffClientId; public AgentTaskController( KeycloakClient kc, TokenExchangeClient te, RestClient http, AuditLogger audit, IdpRouter idpRouter, KcAdminClient kcAdmin, LifecycleConfig lifecycle, LifecycleMetrics lifecycleMetrics, @Value("${tokenexchange.chain.entry-url}") String chainEntryUrl, @Value("${tokenexchange.te.audience}") String teAudience, @Value("${tokenexchange.te.scope}") String teScope, @Value("${tokenexchange.bff.login-scope}") String userScopeOnLogin, @Value("${tokenexchange.bff.client-id}") String bffClientId ) { this.kc = kc; this.te = te; this.http = http; this.audit = audit; this.idpRouter = idpRouter; this.kcAdmin = kcAdmin; this.lifecycle = lifecycle; this.lifecycleMetrics = lifecycleMetrics; this.chainEntryUrl = chainEntryUrl; this.teAudience = teAudience; this.teScope = teScope; this.userScopeOnLogin = userScopeOnLogin; this.bffClientId = bffClientId; } public record TaskRequest(@NotBlank String username, @NotBlank String password) {} @PostMapping("/task") public Map<String, Object> task(@RequestBody TaskRequest body) { log.info("/agent/task username={}", body.username()); // 1. user_at — IdpRouter выбирает endpoint (primary с failover'ом на secondary) var userTokens = kc.passwordGrant(body.username(), body.password(), userScopeOnLogin); IdpRouter.IdpEndpoint ep = userTokens.endpoint(); log.debug("authenticated against idp={} (realm={})", ep.name(), ep.realm()); // 2. actor_token — на том же endpoint, что и user_at var actorTokens = kc.clientCredentials(ep); // 3. Single TE — owner of the only TE in the whole chain (REQUIREMENTS §1.1.1) TokenExchangeResponse exchanged = te.exchange( TokenExchangeRequest.builder() .subjectToken(userTokens.response().accessToken()) .actorToken(actorTokens.accessToken()) .audience(teAudience) .scope(teScope) .build(), ep.tokenUrl(), bffClientId, ep.bffClientSecret() ); String delegatedAt = exchanged.accessToken(); JsonNode delegatedClaims = JwtPayloadDecoder.decode(delegatedAt); // 4. Один и тот же delegated_at в первый сервис цепочки. long start = System.nanoTime(); @SuppressWarnings("unchecked") Map<String, Object> downstream = http.get() .uri(chainEntryUrl) .header(HttpHeaders.AUTHORIZATION, "Bearer " + delegatedAt) .retrieve() .body(Map.class); long took = (System.nanoTime() - start) / 1_000_000; audit.chainCall("bff-agent", chainEntryUrl, delegatedClaims.path("jti").asText(), 200, took); Map<String, Object> result = new HashMap<>(); result.put("idp", ep.name()); result.put("delegated_claims", delegatedClaims); result.put("downstream", downstream); return result; } // ---------- Phase 7 / FR-13: session-driven flow ---------- /** * Preview consent: возвращает «что будет делегировано» без выполнения TE. * SPA показывает экран consent на основе этого ответа (FR-13.5). */ @GetMapping("/task/preview") public Map<String, Object> preview(HttpSession session) { if (session.getAttribute("user_sub") == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "no_session"); } Map<String, Object> p = new LinkedHashMap<>(); p.put("user", Map.of( "sub", session.getAttribute("user_sub"), "preferred_username", session.getAttribute("user_pref") )); p.put("agent", Map.of( "client_id", "agent-runtime", "name", "demo-agent", "type", "tool", "version", "0.1.0" )); p.put("scope_to_grant", teScope); p.put("audience_chain", teAudience); p.put("delegation_ttl_seconds", 300); // FR-15.5: TTL = min(token.exp - now, max_delegation_ttl) return p; } /** * Подтверждение consent: SPA вызывает после показа preview-экрана. * Использует user_at из session, выполняет ЕДИНСТВЕННЫЙ TE и chain-call. */ @PostMapping("/task/from-session") public Map<String, Object> taskFromSession(HttpSession session) { String userAt = (String) session.getAttribute("user_at"); if (userAt == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "no_session"); } IdpRouter.IdpEndpoint ep = idpRouter.primary(); if (session.getAttribute("idp") != null && "secondary".equals(session.getAttribute("idp"))) { // Если логин был на secondary — продолжаем там же (нет SecondaryEndpoint API, // вернёмся к этому в Phase 8 для consistent failover). } var actorTokens = kc.clientCredentials(ep); TokenExchangeResponse exchanged = te.exchange( TokenExchangeRequest.builder() .subjectToken(userAt) .actorToken(actorTokens.accessToken()) .audience(teAudience) .scope(teScope) .build(), ep.tokenUrl(), bffClientId, ep.bffClientSecret() ); String delegatedAt = exchanged.accessToken(); JsonNode delegatedClaims = JwtPayloadDecoder.decode(delegatedAt); @SuppressWarnings("unchecked") Map<String, Object> downstream = http.get() .uri(chainEntryUrl) .header(HttpHeaders.AUTHORIZATION, "Bearer " + delegatedAt) .retrieve() .body(Map.class); // SPA получает task_id (для последующего revoke) — генерируем простой UUID-stub. String taskId = java.util.UUID.randomUUID().toString(); // Сохраняем в session: список выполненных task'ов (для отображения). @SuppressWarnings("unchecked") java.util.List<String> recent = (java.util.List<String>) session.getAttribute("recent_tasks"); if (recent == null) recent = new java.util.ArrayList<>(); recent.add(taskId); session.setAttribute("recent_tasks", recent); Map<String, Object> result = new LinkedHashMap<>(); result.put("task_id", taskId); result.put("idp", ep.name()); result.put("delegated_claims", delegatedClaims); result.put("downstream", downstream); return result; } /** * Phase 8 / FR-15.1, FR-15.2, FR-15.3: lifecycle-demo endpoint. * * Принимает user_at + user_rt в теле запроса (вместо session). Если user_at * близок к exp или уже истёк — рефрешит через user_rt (FR-15.1). Если refresh * тоже неудачен — 401 reauth_required (FR-15.2). На invalid_grant в TE — * один retry после refresh (FR-15.3). * * Endpoint используется в chaos-сценарии lifecycle-18. */ public record LifecycleDemoRequest( @com.fasterxml.jackson.annotation.JsonProperty("user_at") String userAt, @com.fasterxml.jackson.annotation.JsonProperty("user_rt") String userRt, // optional — позволяет тесту использовать short-TTL test client. @com.fasterxml.jackson.annotation.JsonProperty("client_id") String clientIdOpt, @com.fasterxml.jackson.annotation.JsonProperty("client_secret") String clientSecretOpt ) {} @PostMapping("/task/lifecycle-demo") public Map<String, Object> lifecycleDemo(@RequestBody LifecycleDemoRequest body) { if (body.userAt() == null || body.userRt() == null) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "user_at and user_rt required"); } IdpRouter.IdpEndpoint ep = idpRouter.primary(); String refreshClientId = body.clientIdOpt() != null ? body.clientIdOpt() : bffClientId; String refreshClientSec = body.clientSecretOpt() != null ? body.clientSecretOpt() : ep.bffClientSecret(); String userAt = body.userAt(); String userRt = body.userRt(); Map<String, Object> result = new LinkedHashMap<>(); boolean refreshed = false; // FR-15.1: проактивная проверка exp перед TE. long now = java.time.Instant.now().getEpochSecond(); long exp = JwtPayloadDecoder.decode(userAt).path("exp").asLong(); long secondsLeft = exp - now; if (secondsLeft < lifecycle.safetyMarginSeconds) { log.info("user_at near-expiry ({}s left, margin={}s) — refresh via client_id={}", secondsLeft, lifecycle.safetyMarginSeconds, refreshClientId); var refreshResp = kc.refresh(ep, userRt, refreshClientId, refreshClientSec); if (refreshResp == null) { lifecycleMetrics.userTokenRefresh("expired"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "reauth_required"); } userAt = refreshResp.accessToken(); userRt = refreshResp.refreshToken(); lifecycleMetrics.userTokenRefresh("ok"); refreshed = true; } // Один TE; на invalid_grant (race FR-15.3) — refresh + retry. // TE-call идёт ТЕМ ЖЕ клиентом, что выпустил user_at (audience-validation). TokenExchangeResponse exchanged; try { exchanged = te.exchange(buildTeReq(userAt), ep.tokenUrl(), refreshClientId, refreshClientSec); } catch (com.tokenexchange.lib.TokenExchangeException e1) { // Race-истечение во время TE-call — refresh и один retry. log.warn("TE failed (possibly race expiry), trying refresh + retry: {}", e1.getMessage()); var refreshResp = kc.refresh(ep, userRt, refreshClientId, refreshClientSec); if (refreshResp == null) { lifecycleMetrics.userTokenRefresh("expired"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "reauth_required"); } userAt = refreshResp.accessToken(); lifecycleMetrics.userTokenRefresh("race_recovered"); try { exchanged = te.exchange(buildTeReq(userAt), ep.tokenUrl(), refreshClientId, refreshClientSec); lifecycleMetrics.teExpiredSubjectRecovered(); refreshed = true; } catch (Exception e2) { throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "TE failed after refresh: " + e2.getMessage()); } } if (refreshed) lifecycleMetrics.teExpiredSubjectRecovered(); String delegatedAt = exchanged.accessToken(); JsonNode delegatedClaims = JwtPayloadDecoder.decode(delegatedAt); @SuppressWarnings("unchecked") Map<String, Object> downstream = http.get() .uri(chainEntryUrl) .header(HttpHeaders.AUTHORIZATION, "Bearer " + delegatedAt) .retrieve() .body(Map.class); result.put("refreshed", refreshed); result.put("delegated_claims", delegatedClaims); result.put("downstream", downstream); return result; } private TokenExchangeRequest buildTeReq(String subjectToken) { var actorTokens = kc.clientCredentials(idpRouter.primary()); return TokenExchangeRequest.builder() .subjectToken(subjectToken) .actorToken(actorTokens.accessToken()) .audience(teAudience) .scope(teScope) .build(); } /** * Отзыв полномочий агента (FR-13.7). В Phase 7 реализовано через * Keycloak admin DELETE /users/{id}/logout — это запускает SPI → * Redis pub/sub → deny-list (Phase 6) → chain-сервисы перестают * принимать токены пользователя. */ @PostMapping("/task/revoke") public Map<String, Object> revoke(HttpSession session) { String sub = (String) session.getAttribute("user_sub"); if (sub == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "no_session"); } kcAdmin.logoutUser(sub); Map<String, Object> body = new LinkedHashMap<>(); body.put("revoked", true); body.put("sub", sub); body.put("note", "Keycloak event triggered Redis pub/sub → chain-services updated deny-list (S5)"); // session тоже инвалидируем — пользователь должен залогиниться заново. session.invalidate(); return body; } }