From 1d7f78aece452aeb38644bcdf0a73f23ab381d50 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 12 Jun 2026 11:50:23 +0800 Subject: [PATCH] fix(auth): fail closed invalid cli bearer tokens Signed-off-by: dongmucat <1127093059@qq.com> --- docs/03-authentication-design.md | 1 + .../cli/CliSkillControllerTest.java | 112 +++++++++++++++++ .../token/ApiTokenAuthenticationFilter.java | 112 ++++++++++++----- .../ApiTokenAuthenticationFilterTest.java | 118 +++++++++++++++++- .../auth/token/ApiTokenServiceTest.java | 40 ++++++ 5 files changed, 353 insertions(+), 30 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index db7c8b22..1c62b1c6 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -377,6 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成 - 存储:只存 SHA-256 哈希,明文只展示一次 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 +- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` > **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index d7edf81d..afa33be2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -1,15 +1,27 @@ package com.iflytek.skillhub.controller.cli; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.cli.CliSkillAppService; import jakarta.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -19,11 +31,17 @@ import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -35,7 +53,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @ActiveProfiles("test") class CliSkillControllerTest { @Autowired MockMvc mockMvc; + @Autowired NamespaceMemberRepository namespaceMemberRepository; @MockBean CliSkillAppService cliSkillAppService; + @MockBean ApiTokenService apiTokenService; + @MockBean UserAccountRepository userAccountRepository; + @MockBean UserRoleBindingRepository userRoleBindingRepository; @Test void downloadRoutesUseDownloadRateLimit() throws Exception { @@ -73,6 +95,47 @@ class CliSkillControllerTest { .andExpect(jsonPath("$.data.items[0].latestVersion").value("1.2.0")); } + @Test + void searchRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.search("pdf", 20, null, null)).willReturn( + new CliSkillAppService.CliSearchResult(List.of(), 0, 20) + ); + + mockMvc.perform(get("/api/cli/v1/skills/search") + .param("q", "pdf") + .param("limit", "20") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(cliSkillAppService); + } + + @Test + void searchWithValidBearerProjectsIdentityAndNamespaceRoles() throws Exception { + ApiToken token = new ApiToken("user-cli-token", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("user-cli-token", "CLI User", "cli@example.com", ""); + Map nsRoles = Map.of(9L, NamespaceRole.MEMBER); + + given(apiTokenService.validateToken("raw-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("user-cli-token")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("user-cli-token")).willReturn(List.of()); + namespaceMemberRepository.save(new NamespaceMember(9L, "user-cli-token", NamespaceRole.MEMBER)); + given(cliSkillAppService.search("private", 20, "user-cli-token", nsRoles)).willReturn( + new CliSkillAppService.CliSearchResult(List.of(), 0, 20) + ); + + mockMvc.perform(get("/api/cli/v1/skills/search") + .param("q", "private") + .param("limit", "20") + .header(HttpHeaders.AUTHORIZATION, "Bearer raw-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()); + + verify(cliSkillAppService).search("private", 20, "user-cli-token", nsRoles); + verify(apiTokenService).touchLastUsed(token); + } + @Test void resolveReturnsCliResolveResponse() throws Exception { given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn( @@ -91,6 +154,47 @@ class CliSkillControllerTest { .andExpect(jsonPath("$.data.fingerprint").value("abc123")); } + @Test + void resolveRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn( + new com.iflytek.skillhub.dto.cli.CliResolveResponse( + "global", "demo", "2.0.0", 42L, "abc123", + "/api/v1/skills/global/demo/versions/2.0.0/download" + ) + ); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).resolve(any(), any(), any(), any(), any()); + } + + @Test + void downloadLatestRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.downloadLatest(any(), any(), any())).willReturn(downloadResponse()); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/download") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).downloadLatest(any(), any(), any()); + } + + @Test + void downloadVersionRejectsInvalidBearerBeforeAnonymousAccess() throws Exception { + givenInvalidBearerToken(); + given(cliSkillAppService.downloadVersion(any(), any(), any(), any())).willReturn(downloadResponse()); + + mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download") + .header(HttpHeaders.AUTHORIZATION, "Bearer unknown-token")) + .andExpect(status().isUnauthorized()); + + verify(cliSkillAppService, never()).downloadVersion(any(), any(), any(), any()); + } + @Test void deleteRequiresAuthentication() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders @@ -131,4 +235,12 @@ class CliSkillControllerTest { assertEquals(120, rateLimit.authenticated()); assertEquals(30, rateLimit.anonymous()); } + + private static ResponseEntity downloadResponse() { + return ResponseEntity.ok(new InputStreamResource(new ByteArrayInputStream("zip".getBytes()))); + } + + private void givenInvalidBearerToken() { + given(apiTokenService.validateToken("unknown-token")).willReturn(Optional.empty()); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 6f594c1e..8b24aa86 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -10,9 +10,12 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -37,49 +40,74 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; private final ApiTokenScopeService apiTokenScopeService; + private final AuthenticationEntryPoint authenticationEntryPoint; + @Autowired public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - ApiTokenScopeService apiTokenScopeService) { + ApiTokenScopeService apiTokenScopeService, + AuthenticationEntryPoint authenticationEntryPoint) { this.apiTokenService = apiTokenService; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; this.apiTokenScopeService = apiTokenScopeService; + this.authenticationEntryPoint = authenticationEntryPoint; + } + + ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo, + ApiTokenScopeService apiTokenScopeService) { + this(apiTokenService, userRepo, roleBindingRepo, apiTokenScopeService, + (request, response, authException) -> + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage())); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader(AUTH_HEADER); - if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { - String rawToken = authHeader.substring(BEARER_PREFIX.length()); - apiTokenService.validateToken(rawToken).ifPresent(token -> { - userRepo.findById(token.getUserId()).ifPresent(user -> { - if (!user.isActive()) { - return; - } - Set roles = roleBindingRepo.findByUserId(user.getId()).stream() - .map(rb -> rb.getRole().getCode()) - .collect(Collectors.toSet()); - roles = PlatformRoleDefaults.withDefaultUserRole(roles); - Set scopes = apiTokenScopeService.parseScopes(token.getScopeJson()); - PlatformPrincipal principal = new PlatformPrincipal( - user.getId(), user.getDisplayName(), user.getEmail(), - user.getAvatarUrl(), "api_token", roles - ); - List authorities = new ArrayList<>(); - authorities.addAll(roles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .toList()); - authorities.addAll(scopes.stream() - .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) - .toList()); - var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - apiTokenService.touchLastUsed(token); - }); - }); + if (authHeader != null && isBearerAuthorization(authHeader)) { + String rawToken = extractBearerToken(authHeader); + if (rawToken == null) { + rejectBearer(request, response); + return; + } + + var token = apiTokenService.validateToken(rawToken); + if (token.isEmpty()) { + rejectBearer(request, response); + return; + } + + ApiToken apiToken = token.get(); + var user = userRepo.findById(apiToken.getUserId()); + if (user.isEmpty() || !user.get().isActive()) { + rejectBearer(request, response); + return; + } + + UserAccount userAccount = user.get(); + Set roles = roleBindingRepo.findByUserId(userAccount.getId()).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + Set scopes = apiTokenScopeService.parseScopes(apiToken.getScopeJson()); + PlatformPrincipal principal = new PlatformPrincipal( + userAccount.getId(), userAccount.getDisplayName(), userAccount.getEmail(), + userAccount.getAvatarUrl(), "api_token", roles + ); + List authorities = new ArrayList<>(); + authorities.addAll(roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList()); + authorities.addAll(scopes.stream() + .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) + .toList()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + apiTokenService.touchLastUsed(apiToken); } filterChain.doFilter(request, response); } @@ -91,4 +119,30 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { || path.startsWith("/api/web/") || path.startsWith("/api/cli/")); } + + private boolean isBearerAuthorization(String authHeader) { + if (!authHeader.regionMatches(true, 0, "Bearer", 0, "Bearer".length())) { + return false; + } + return authHeader.length() == "Bearer".length() + || Character.isWhitespace(authHeader.charAt("Bearer".length())); + } + + private String extractBearerToken(String authHeader) { + if (authHeader.length() <= BEARER_PREFIX.length() - 1 + || authHeader.charAt(BEARER_PREFIX.length() - 1) != ' ') { + return null; + } + String rawToken = authHeader.substring(BEARER_PREFIX.length()).trim(); + return rawToken.isEmpty() ? null : rawToken; + } + + private void rejectBearer(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence( + request, + response, + new BadCredentialsException("Invalid bearer token") + ); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index e82f7a1a..d9f030a9 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -17,11 +17,13 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -89,12 +91,117 @@ class ApiTokenAuthenticationFilterTest { request.setRequestURI("/api/v1/publish"); request.addHeader("Authorization", "Bearer raw-token"); - filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(chain.getRequest()); verify(apiTokenService, never()).touchLastUsed(token); } + @Test + void shouldRejectUnknownBearerTokenOnCliReadRoutes() throws Exception { + when(apiTokenService.validateToken("unknown-token")).thenReturn(Optional.empty()); + + for (String route : cliReadRoutes()) { + SecurityContextHolder.clearContext(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", route); + request.addHeader("Authorization", "Bearer unknown-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), route); + assertNull(SecurityContextHolder.getContext().getAuthentication(), route); + assertNull(chain.getRequest(), route); + } + } + + @Test + void shouldRejectBearerTokenWhenUserIsMissing() throws Exception { + ApiToken token = new ApiToken("missing-user", "cli", "sk_test", "hash", "[]"); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("missing-user")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer raw-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).touchLastUsed(token); + } + + @Test + void shouldRejectEmptyBearerTokenWithoutValidatingIt() throws Exception { + when(apiTokenService.validateToken("")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer "); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldRejectMalformedBearerHeaderWithoutValidatingIt() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Bearer"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldAllowAnonymousCliReadsWhenAuthorizationHeaderIsAbsent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_OK, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNotNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + + @Test + void shouldIgnoreNonBearerAuthorizationHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cli/v1/skills/search"); + request.addHeader("Authorization", "Basic abc123"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(MockHttpServletResponse.SC_OK, response.getStatus()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertNotNull(chain.getRequest()); + verify(apiTokenService, never()).validateToken(any()); + } + @Test void shouldAuthenticateBearerTokensForApiWebRequests() throws Exception { ApiToken token = new ApiToken("user-3", "cli", "sk_test", "hash", "[\"skill:publish\"]"); @@ -113,4 +220,13 @@ class ApiTokenAuthenticationFilterTest { assertNotNull(SecurityContextHolder.getContext().getAuthentication()); verify(apiTokenService).touchLastUsed(token); } + + private static List cliReadRoutes() { + return Stream.of( + "/api/cli/v1/skills/search", + "/api/cli/v1/skills/global/demo/resolve", + "/api/cli/v1/skills/global/demo/download", + "/api/cli/v1/skills/global/demo/versions/1.0.0/download" + ).toList(); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java index 2e4de008..d9ed2c75 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -10,9 +10,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -124,4 +129,39 @@ class ApiTokenServiceTest { .isInstanceOf(DomainBadRequestException.class) .hasMessageContaining("error.token.name.duplicate"); } + + @Test + void validateToken_returnsEmptyForUnknownToken() { + when(tokenRepo.findByTokenHash(sha256("missing-token"))).thenReturn(Optional.empty()); + + assertThat(service.validateToken("missing-token")).isEmpty(); + } + + @Test + void validateToken_returnsEmptyForExpiredToken() { + ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("expired-token"), "[]"); + token.setExpiresAt(Instant.parse("2026-03-17T23:59:59Z")); + when(tokenRepo.findByTokenHash(sha256("expired-token"))).thenReturn(Optional.of(token)); + + assertThat(service.validateToken("expired-token")).isEmpty(); + } + + @Test + void validateToken_returnsEmptyForRevokedToken() { + ApiToken token = new ApiToken("user-1", "CLI", "sk_test", sha256("revoked-token"), "[]"); + token.setRevokedAt(Instant.parse("2026-03-17T23:59:59Z")); + when(tokenRepo.findByTokenHash(sha256("revoked-token"))).thenReturn(Optional.of(token)); + + assertThat(service.validateToken("revoked-token")).isEmpty(); + } + + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } }