diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 2be902e8..2f4b7705 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -377,7 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台 - 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成 - 存储:只存 SHA-256 哈希,明文只展示一次 - 校验:从 `Authorization: Bearer ` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态 -- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问 +- 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份;Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401,即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session;若无 Session,公共读接口按匿名访问,`whoami` 返回 401 - 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage` - 拒绝原因:API Token 缺少作用域或不能访问某个接口时,403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常 @@ -622,10 +622,15 @@ window.location.href = '/oauth2/authorization/github' ### 10.3 CLI API -| 接口 | 所需凭证 | 额外判定 | -|------|---------|---------| -| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | -| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过 | +| 接口 | 凭证规则 | 授权与错误语义 | +|------|---------|---------------| +| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 | +| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | + +Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session,确保请求使用 token 的用户、角色与 scope;Bearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401,不能回退到 Session 或匿名身份。完全缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session;如果 Session 也不存在,公共读接口按匿名身份执行,而 `whoami` 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。`whoami.email` 字段始终存在,但没有可用邮箱时值为 `null`。 ### 10.4 Admin API diff --git a/docs/api/authentication.openapi.yaml b/docs/api/authentication.openapi.yaml new file mode 100644 index 00000000..e50788ff --- /dev/null +++ b/docs/api/authentication.openapi.yaml @@ -0,0 +1,305 @@ +openapi: 3.0.3 +info: + title: SkillHub CLI Authentication API + version: 1.0.0 + description: >- + Authentication contract for CLI identity and public skill reads. A valid + Bearer credential overrides a Web Session identity. Once the Bearer scheme + is used, the credential must be valid: empty, malformed, unknown, expired, + or revoked Bearer credentials return HTTP 401 and never fall back to the + Session or anonymous access. An absent Authorization header or an + unsupported scheme such as Basic preserves a valid Web Session. Without a + Session, public reads use anonymous visibility and whoami returns HTTP 401. +servers: + - url: / +tags: + - name: CLI Authentication + - name: CLI Skills +paths: + /api/cli/v1/auth/whoami: + get: + tags: [CLI Authentication] + summary: Return the current CLI identity + operationId: cliWhoAmI + description: Requires a valid Bearer credential or Web Session. Bearer takes priority over Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session, but returns 401 when no Session exists. + security: + - bearerAuth: [] + - sessionAuth: [] + responses: + '200': + description: Authenticated CLI identity + content: + application/json: + schema: + $ref: '#/components/schemas/CliWhoAmIEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/search: + get: + tags: [CLI Skills] + summary: Search CLI-installable skills + operationId: cliSearchSkills + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - name: q + in: query + required: false + schema: {type: string} + example: pdf + description: Optional search text. + - name: limit + in: query + required: false + schema: {type: integer, format: int32, default: 20} + example: 20 + description: Maximum number of results. + responses: + '200': + description: Search result + content: + application/json: + schema: + $ref: '#/components/schemas/CliSearchEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/{namespace}/{slug}/resolve: + get: + tags: [CLI Skills] + summary: Resolve a skill version + operationId: cliResolveSkill + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - name: version + in: query + required: false + schema: {type: string} + example: 1.0.0 + description: Optional exact version; omitted resolves latest. + responses: + '200': + description: Resolved version + content: + application/json: + schema: + $ref: '#/components/schemas/CliResolveEnvelope' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /api/cli/v1/skills/{namespace}/{slug}/download: + get: + tags: [CLI Skills] + summary: Download the latest installable skill version + operationId: cliDownloadLatestSkill + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' + /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download: + get: + tags: [CLI Skills] + summary: Download an exact installable skill version + operationId: cliDownloadSkillVersion + description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - $ref: '#/components/parameters/Version' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: SkillHub API token + description: API token issued by SkillHub. A valid token overrides Web Session; invalid lifecycle states all return the same 401 response without Session fallback. + sessionAuth: + type: apiKey + in: cookie + name: SESSION + description: Spring Session browser identity. It is preserved when Authorization is absent or uses a non-Bearer scheme, and is overridden by a valid Bearer token. + parameters: + Namespace: + name: namespace + in: path + required: true + schema: {type: string} + example: global + description: Namespace slug. + Slug: + name: slug + in: path + required: true + schema: {type: string} + example: pdf-parser + description: Skill slug. + Version: + name: version + in: path + required: true + schema: {type: string} + example: 1.0.0 + description: Exact semantic version. + responses: + Download: + description: ZIP package stream + headers: + Content-Disposition: + schema: {type: string} + description: Attachment filename. + content: + application/zip: + schema: {type: string, format: binary} + DownloadRedirect: + description: Redirect to a presigned object-storage URL + headers: + Location: + schema: {type: string, format: uri} + BadRequest: + description: Namespace, skill, or version cannot be resolved. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + Unauthorized: + description: No valid supported identity is present where required, or the Bearer credential is empty, malformed, unknown, expired, revoked, or belongs to an unavailable user. Invalid Bearer never falls back to Web Session. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 401 + msg: Authentication required + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + Forbidden: + description: Credential is valid but token scope or resource permission is insufficient. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 403 + msg: Forbidden + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + StorageUnavailable: + description: Object storage is unavailable. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + Envelope: + type: object + required: [code, msg, data, timestamp, requestId] + properties: + code: {type: integer, format: int32} + msg: {type: string} + data: {type: object, nullable: true} + timestamp: {type: string, format: date-time} + requestId: {type: string, example: req-123} + ErrorEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: {type: object, nullable: true, example: null} + CliWhoAmIEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliWhoAmI' + CliWhoAmI: + type: object + required: [handle, displayName, email] + properties: + handle: {type: string, example: user-123} + displayName: {type: string, example: CLI User} + email: {type: string, format: email, nullable: true, example: cli@example.com, description: Email address when available; the required field is null when the account has no email.} + CliSearchEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliSearchResult' + CliSearchResult: + type: object + required: [items, total, limit] + properties: + items: + type: array + items: {$ref: '#/components/schemas/CliSearchItem'} + total: {type: integer, format: int64, example: 1} + limit: {type: integer, format: int32, example: 20} + CliSearchItem: + type: object + required: [namespace, slug, latestVersion] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + latestVersion: {type: string, example: 1.2.0} + summary: {type: string, nullable: true, example: Parse PDF files} + CliResolveEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliResolveResult' + CliResolveResult: + type: object + required: [namespace, slug, version, versionId, fingerprint, downloadUrl] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + version: {type: string, example: 1.2.0} + versionId: {type: integer, format: int64, example: 42} + fingerprint: {type: string, example: 'sha256:abc123'} + downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download} diff --git a/docs/superpowers/plans/2026-07-28-revoked-token-validation.md b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md new file mode 100644 index 00000000..2888470d --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-revoked-token-validation.md @@ -0,0 +1,1238 @@ +# Revoked API Token Validation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lock the CLI API's fail-closed Bearer behavior and Web Session fallback with persisted lifecycle and mixed-credential tests, prove 401/403 semantics on every affected read endpoint, publish the authentication OpenAPI contract, and reconcile source behavior with the actual runtime artifact. + +**Architecture:** Keep `ApiTokenAuthenticationFilter` as the sole Bearer authentication entry point while preserving Spring Security's existing Web Session identity. Valid Bearer replaces Session; invalid Bearer fails closed without Session fallback; absent or non-Bearer Authorization preserves Session and otherwise leaves public reads anonymous. Use one Spring Boot/MockMvc class with real token and user persistence plus deterministic controller-service stubs for the credential-state matrix, and a second Spring Boot/MockMvc class with real query/download authorization plus persisted PRIVATE and matching PUBLIC skills for authorization checks. Production authentication code remains unchanged unless the unmodified-source matrix reproduces a failure; any such failure stops this plan for systematic root-cause analysis before a minimal fix is planned. + +**Tech Stack:** Java 21, Spring Boot 3.2, Spring Security, Spring Data JPA/H2, MockMvc, JUnit 5 parameterized tests, Mockito, OpenAPI 3.0 YAML, Docker/OCI image inspection. + +--- + +## File Map + +- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java`: persisted valid/revoked/expired/unknown/empty/malformed credential matrix for each CLI endpoint. +- Create `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java`: real PRIVATE-skill search omission and read authorization through resolve, latest download, and versioned download. +- Modify `docs/03-authentication-design.md`: current CLI route table, Web Session/Bearer priority, and explicit anonymous/401/403 rules. +- Create `docs/api/authentication.openapi.yaml`: OpenAPI 3.0 contract for whoami, search, resolve, latest download, and versioned download. +- Do not modify `server/skillhub-auth/src/main/**` unless Task 5 records a failing unmodified-source assertion and a separate systematic-debugging plan amendment identifies the root cause. + +### Task 1: Persisted credential fixture and whoami/search/resolve matrix + +**Files:** +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` + +- [ ] **Step 1: Create the integration test fixture and endpoint tests** + +Create the class with real `ApiTokenService`, `ApiTokenRepository`, and `UserAccountRepository`; mock only `CliSkillAppService` so successful public reads are deterministic. Add independent anonymous, valid, and parameterized invalid-state methods for whoami, search, and resolve: + +```java +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.cli.CliResolveResponse; +import com.iflytek.skillhub.service.cli.CliSkillAppService; +import java.io.ByteArrayInputStream; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.clearInvocations; +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; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliTokenLifecycleSecurityIntegrationTest { + + private enum InvalidCredentialState { + REVOKED, + EXPIRED, + UNKNOWN, + EMPTY, + MALFORMED + } + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired ApiTokenRepository apiTokenRepository; + @Autowired UserAccountRepository userAccountRepository; + @Autowired Clock clock; + @MockBean CliSkillAppService cliSkillAppService; + + private String userId; + + @BeforeEach + void setUp() { + userId = "token-matrix-" + UUID.randomUUID(); + userAccountRepository.save(new UserAccount( + userId, "Token Matrix", userId + "@example.com", "")); + given(cliSkillAppService.search(any(), anyInt(), any(), any())) + .willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20)); + given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any())) + .willReturn(new CliResolveResponse( + "global", "demo", "1.0.0", 1L, "sha256:empty", + "/api/v1/skills/global/demo/versions/1.0.0/download")); + given(cliSkillAppService.downloadLatest(anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + } + + @Test + void whoamiWithoutAuthorizationReturns401() throws Exception { + mockMvc.perform(get("/api/cli/v1/auth/whoami")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void whoamiWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.handle").value(userId)); + } + + @ParameterizedTest(name = "whoami rejects {0}") + @EnumSource(InvalidCredentialState.class) + void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void searchWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20")) + .andExpect(status().isOk()); + } + + @Test + void searchWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "search rejects {0}") + @EnumSource(InvalidCredentialState.class) + void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void resolveWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve")) + .andExpect(status().isOk()); + } + + @Test + void resolveWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "resolve rejects {0}") + @EnumSource(InvalidCredentialState.class) + void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/resolve"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); + } + + private MockHttpServletRequestBuilder withInvalidBearer( + MockHttpServletRequestBuilder request, + InvalidCredentialState state) { + return request + .header(HttpHeaders.AUTHORIZATION, authorizationHeader(state)) + .with(authentication(sessionAuthentication())); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } + + private String authorizationHeader(InvalidCredentialState state) { + return switch (state) { + case REVOKED -> { + ApiTokenService.TokenCreateResult result = createToken(); + apiTokenService.revokeToken(result.entity().getId(), userId); + yield "Bearer " + result.rawToken(); + } + case EXPIRED -> { + ApiTokenService.TokenCreateResult result = createToken(); + ApiToken token = result.entity(); + token.setExpiresAt(Instant.now(clock).minusSeconds(1)); + apiTokenRepository.saveAndFlush(token); + yield "Bearer " + result.rawToken(); + } + case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID(); + case EMPTY -> "Bearer "; + case MALFORMED -> "Bearer"; + }; + } + + private String createActiveToken() { + return createToken().rawToken(); + } + + private ApiTokenService.TokenCreateResult createToken() { + return apiTokenService.createToken( + userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]"); + } + + private UsernamePasswordAuthenticationToken sessionAuthentication() { + PlatformPrincipal principal = new PlatformPrincipal( + userId, "Session User", userId + "@example.com", "", "session", Set.of("USER")); + return new UsernamePasswordAuthenticationToken(principal, null, List.of()); + } + + private ResponseEntity downloadResponse() { + return ResponseEntity.ok(new InputStreamResource( + new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } +} +``` + +- [ ] **Step 2: Apply a reversible fail-open mutation before the first test run** + +Temporarily change both rejection branches in `ApiTokenAuthenticationFilter.doFilterInternal` so malformed and invalid credentials continue down the chain. Do not stage or commit this mutation: + +```java +if (rawToken == null) { + filterChain.doFilter(request, response); + return; +} + +var token = apiTokenService.validateToken(rawToken); +if (token.isEmpty()) { + filterChain.doFilter(request, response); + return; +} +``` + +- [ ] **Step 3: Run whoami RED verification** + +Run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#whoamiRejectsInvalidBearer \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL for the invalid-state invocations because the pre-authenticated session reaches whoami and returns 200 instead of 401. + +- [ ] **Step 4: Run search RED verification** + +Run the same Maven command with `#searchRejectsInvalidBearer`. + +Expected: FAIL with expected 401 but actual 200 for revoked, expired, unknown, empty, and malformed Bearer credentials. + +- [ ] **Step 5: Run resolve RED verification** + +Run the same Maven command with `#resolveRejectsInvalidBearer`. + +Expected: FAIL with expected 401 but actual 200 for all invalid credential states. + +- [ ] **Step 6: Restore the two original reject branches** + +Restore exactly: + +```java +if (rawToken == null) { + rejectBearer(request, response); + return; +} + +var token = apiTokenService.validateToken(rawToken); +if (token.isEmpty()) { + rejectBearer(request, response); + return; +} +``` + +Confirm `git diff -- server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java` is empty. + +- [ ] **Step 7: Run whoami/search/resolve GREEN commands independently** + +Run three Maven commands, one for each of: + +```text +CliTokenLifecycleSecurityIntegrationTest#whoamiRejectsInvalidBearer +CliTokenLifecycleSecurityIntegrationTest#searchRejectsInvalidBearer +CliTokenLifecycleSecurityIntegrationTest#resolveRejectsInvalidBearer +``` + +Expected: each command reports all parameterized invocations PASS, with no production authentication diff. + +### Task 2: Latest download matrix + +**Files:** +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` + +- [ ] **Step 1: Add independent latest-download methods** + +Insert before the helper methods: + +```java +@Test +void latestDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/download")) + .andExpect(status().isOk()); +} + +@Test +void latestDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token)) + .andExpect(status().isOk()); +} + +@ParameterizedTest(name = "latest download rejects {0}") +@EnumSource(InvalidCredentialState.class) +void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/download"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); +} +``` + +- [ ] **Step 2: Reapply the reversible fail-open mutation and run latest-download RED** + +Run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#latestDownloadRejectsInvalidBearer \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL with expected 401 but actual 200 for every invalid state. + +- [ ] **Step 3: Restore the original reject branches and run latest-download GREEN** + +Run the same command after restoring the filter. + +Expected: all five invalid-state invocations PASS. Then run independent anonymous and valid methods with `#latestDownloadWithoutAuthorizationReturns200` and `#latestDownloadWithValidPersistedTokenReturns200`; both PASS. + +### Task 3: Versioned download matrix + +**Files:** +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` + +- [ ] **Step 1: Add independent versioned-download methods** + +Insert before the helper methods: + +```java +@Test +void versionedDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download")) + .andExpect(status().isOk()); +} + +@Test +void versionedDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token)) + .andExpect(status().isOk()); +} + +@ParameterizedTest(name = "versioned download rejects {0}") +@EnumSource(InvalidCredentialState.class) +void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + mockMvc.perform(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + verifyNoInteractions(cliSkillAppService); +} +``` + +- [ ] **Step 2: Reapply the reversible fail-open mutation and run versioned-download RED** + +Run the focused method command for `#versionedDownloadRejectsInvalidBearer`. + +Expected: FAIL with expected 401 but actual 200 for every invalid state. + +- [ ] **Step 3: Restore the filter and run versioned-download GREEN independently** + +Run focused commands for the invalid, anonymous, and valid versioned-download methods. + +Expected: all commands PASS and the filter source has no diff. + +- [ ] **Step 4: Add and prove the same-token valid-to-revoked replay** + +Create one token through `ApiTokenService`, retain its raw value, and use that +same value successfully against whoami, search, resolve, latest download, and +versioned download. Revoke the persisted token through +`ApiTokenService.revokeToken`, clear prior business-service invocations, then +replay the exact same raw value against all five endpoints. Each replay must +return 401 and the mocked business service must receive no post-revocation +interaction. + +For the three valid JSON responses and all five revoked error responses, assert +that the outer JSON object contains exactly `code`, `msg`, `data`, `timestamp`, +and `requestId`; successful downloads remain binary-stream exceptions. + +Apply the reversible invalid-token fail-open mutation and run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#sameRawTokenIsRejectedByAllEndpointsAfterValidUseAndRevocation \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected RED: at least one public read replay returns 200 instead of 401. +Restore the filter, confirm its production diff is empty, and rerun the same +command. Expected GREEN: one test passes with all five valid calls and all five +revoked replays exercised. + +- [ ] **Step 5: Run the complete persisted credential matrix** + +Run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS for all five endpoints and all absent, valid, revoked, expired, +unknown, empty, and malformed credential cases, plus the same-token lifecycle +replay. + +- [ ] **Step 6: Commit the credential matrix** + +```bash +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java +git commit -s -m "test(auth): cover persisted CLI token states (#605)" +``` + +### Task 4: Real restricted-read 403 boundary + +**Files:** +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java` + +- [ ] **Step 1: Create a persisted PRIVATE skill fixture and real authorization tests** + +```java +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +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.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliRestrictedReadAuthorizationIntegrationTest { + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired UserAccountRepository userAccountRepository; + @Autowired NamespaceRepository namespaceRepository; + @Autowired SkillRepository skillRepository; + @Autowired SkillVersionRepository skillVersionRepository; + + private String namespaceSlug; + private String skillSlug; + private String version; + private String ownerToken; + private String outsiderToken; + + @BeforeEach + void setUp() { + String suffix = UUID.randomUUID().toString().replace("-", ""); + String ownerId = "private-owner-" + suffix; + String outsiderId = "private-outsider-" + suffix; + namespaceSlug = "private-ns-" + suffix; + skillSlug = "private-skill-" + suffix; + version = "1.0.0"; + + userAccountRepository.save(new UserAccount(ownerId, "Owner", ownerId + "@example.com", "")); + userAccountRepository.save(new UserAccount( + outsiderId, "Outsider", outsiderId + "@example.com", "")); + ownerToken = apiTokenService.createToken( + ownerId, "owner-token-" + suffix, "[\"skill:read\"]").rawToken(); + outsiderToken = apiTokenService.createToken( + outsiderId, "outsider-token-" + suffix, "[\"skill:read\"]").rawToken(); + + Namespace namespace = namespaceRepository.save(new Namespace(namespaceSlug, "Private NS", ownerId)); + Skill skill = skillRepository.save(new Skill( + namespace.getId(), skillSlug, ownerId, SkillVisibility.PRIVATE)); + SkillVersion published = new SkillVersion(skill.getId(), version, ownerId); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z")); + published.setDownloadReady(true); + published = skillVersionRepository.save(published); + skill.setLatestVersionId(published.getId()); + skillRepository.save(skill); + skillRepository.flush(); + skillVersionRepository.flush(); + } + + @Test + void outsiderCannotResolvePrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void outsiderCannotDownloadLatestPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void outsiderCannotDownloadVersionedPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download", + namespaceSlug, skillSlug, version), + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void ownerCanResolvePrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + ownerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.slug").value(skillSlug)); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } +} +``` + +- [ ] **Step 2: Apply a reversible authorization mutation before the first run** + +Temporarily change only the PRIVATE arm in `VisibilityChecker.canAccess`: + +```java +case PRIVATE -> true; +``` + +Do not stage or commit this mutation. + +- [ ] **Step 3: Run three independent restricted-read RED commands** + +Run the focused Maven command separately for: + +```text +CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotResolvePrivateSkill +CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadLatestPrivateSkill +CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadVersionedPrivateSkill +``` + +Expected: each command FAILS because the outsider no longer receives 403. Resolve reaches 200; downloads proceed past authorization and return a non-403 response. + +- [ ] **Step 4: Restore PRIVATE authorization and run GREEN commands** + +Restore: + +```java +case PRIVATE -> isOwner(skill, currentUserId) || isAdminOrAbove(roles.get(skill.getNamespaceId())); +``` + +Confirm `git diff -- server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java` is empty. Run all four test methods independently. + +Expected: outsider resolve/latest/versioned each PASS with 403; owner resolve PASS with 200. + +- [ ] **Step 5: Persist and verify the PRIVATE search-visibility boundary** + +Persist a `SkillSearchDocumentEntity` for the same PRIVATE fixture, call the CLI +search endpoint with the valid outsider token through the real +`CliSkillAppService` and `SearchQueryService`, and assert HTTP 200 with the +fixture slug omitted. Run it independently: + +```bash +cd server +./mvnw -pl skillhub-app -am \ + -Dtest='CliRestrictedReadAuthorizationIntegrationTest#outsiderSearchOmitsPersistedPrivateSkill' \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Before the GREEN run, temporarily include PRIVATE documents in the search +adapter's visibility predicate and confirm the test fails because the fixture +slug appears. Restore the production predicate and confirm the command passes. +The search omission is not a substitute for the real resolve/download 403 +assertions above. + +- [ ] **Step 6: Commit the restricted-read tests** + +```bash +git add server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java +git commit -s -m "test(auth): cover restricted CLI read authorization (#605)" +``` + +### Task 5: Production-code decision gate + +**Files:** +- Inspect only: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java` +- Inspect only: `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java` + +- [ ] **Step 1: Confirm unmodified-source results and production diff** + +Run both new classes without any mutation, then run: + +```bash +git diff --exit-code origin/main -- \ + server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java \ + server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +``` + +Expected: both classes PASS and the production authentication diff is empty. Record the outcome as “current source matrix passes; no production authentication change justified.” + +- [ ] **Step 2: Stop for systematic debugging if the expected result is false** + +If any unmodified-source assertion fails, stop execution before editing production code. Preserve the failing command and output, invoke `superpowers:systematic-debugging`, trace the request through token persistence, security chains, filters, and endpoint service boundaries, then amend this plan with the confirmed minimal change. Do not continue to documentation with a speculative fix. + +### Task 6: Authentication and OpenAPI documentation + +**Files:** +- Modify: `docs/03-authentication-design.md` +- Create: `docs/api/authentication.openapi.yaml` + +- [ ] **Step 1: Replace the CLI API table with current paths and semantics** + +Use this content in section 10.3: + +```markdown +### 10.3 CLI API + +| 接口 | 凭证规则 | 授权与错误语义 | +|------|---------|---------------| +| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 | +| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | +| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 | + +Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 覆盖 Session;坏 Bearer 清除当前身份并立即返回 401,不回退 Session 或匿名。没有 Authorization 或使用 Basic/其他非 Bearer scheme 时保留 Session;如果 Session 也不存在,公共读匿名而 `whoami` 返回 401。身份已验证但 token scope 或资源权限不足时返回 403。`whoami.email` 字段始终存在,没有邮箱时为 `null`。 +``` + +- [ ] **Step 2: Create the complete OpenAPI 3.0 document** + +Create `docs/api/authentication.openapi.yaml` with `openapi: 3.0.3`, a `bearerAuth` HTTP bearer security scheme, all five paths, and these exact contract rules: + +```yaml +openapi: 3.0.3 +info: + title: SkillHub CLI Authentication API + version: 1.0.0 + description: >- + Authentication contract for CLI identity and public skill reads. Valid + Bearer overrides Web Session. Invalid Bearer returns HTTP 401 without + Session fallback. An absent Authorization header or unsupported scheme such + as Basic preserves Session; without Session, public reads are anonymous and + whoami returns HTTP 401. +servers: + - url: / +tags: + - name: CLI Authentication + - name: CLI Skills +paths: + /api/cli/v1/auth/whoami: + get: + tags: [CLI Authentication] + summary: Return the current CLI identity + operationId: cliWhoAmI + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session. + security: + - bearerAuth: [] + - sessionAuth: [] + responses: + '200': + description: Authenticated CLI identity + content: + application/json: + schema: + $ref: '#/components/schemas/CliWhoAmIEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/search: + get: + tags: [CLI Skills] + summary: Search CLI-installable skills + operationId: cliSearchSkills + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - name: q + in: query + required: false + schema: {type: string} + example: pdf + description: Optional search text. + - name: limit + in: query + required: false + schema: {type: integer, format: int32, default: 20} + example: 20 + description: Maximum number of results. + responses: + '200': + description: Search result + content: + application/json: + schema: + $ref: '#/components/schemas/CliSearchEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + /api/cli/v1/skills/{namespace}/{slug}/resolve: + get: + tags: [CLI Skills] + summary: Resolve a skill version + operationId: cliResolveSkill + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - name: version + in: query + required: false + schema: {type: string} + example: 1.0.0 + description: Optional exact version; omitted resolves latest. + responses: + '200': + description: Resolved version + content: + application/json: + schema: + $ref: '#/components/schemas/CliResolveEnvelope' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /api/cli/v1/skills/{namespace}/{slug}/download: + get: + tags: [CLI Skills] + summary: Download the latest installable skill version + operationId: cliDownloadLatestSkill + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' + /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download: + get: + tags: [CLI Skills] + summary: Download an exact installable skill version + operationId: cliDownloadSkillVersion + description: Valid Bearer overrides Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer header preserves Session, otherwise this route uses anonymous public visibility. + security: + - {} + - sessionAuth: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Namespace' + - $ref: '#/components/parameters/Slug' + - $ref: '#/components/parameters/Version' + responses: + '200': + $ref: '#/components/responses/Download' + '302': + $ref: '#/components/responses/DownloadRedirect' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/StorageUnavailable' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: SkillHub API token + description: API token issued by SkillHub. Valid Bearer overrides Session; invalid lifecycle states return the same 401 response without Session fallback. + sessionAuth: + type: apiKey + in: cookie + name: SESSION + description: Spring Session browser identity, preserved when Authorization is absent or uses a non-Bearer scheme. + parameters: + Namespace: + name: namespace + in: path + required: true + schema: {type: string} + example: global + description: Namespace slug. + Slug: + name: slug + in: path + required: true + schema: {type: string} + example: pdf-parser + description: Skill slug. + Version: + name: version + in: path + required: true + schema: {type: string} + example: 1.0.0 + description: Exact semantic version. + responses: + Download: + description: ZIP package stream + headers: + Content-Disposition: + schema: {type: string} + description: Attachment filename. + content: + application/zip: + schema: {type: string, format: binary} + DownloadRedirect: + description: Redirect to a presigned object-storage URL + headers: + Location: + schema: {type: string, format: uri} + BadRequest: + description: Namespace, skill, or version cannot be resolved. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + Unauthorized: + description: No valid supported identity is present where required, or the Bearer credential is invalid. Invalid Bearer never falls back to Web Session. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 401 + msg: Authentication required + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + Forbidden: + description: Credential is valid but token scope or resource permission is insufficient. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + example: + code: 403 + msg: Forbidden + data: null + timestamp: '2026-07-28T00:00:00Z' + requestId: req-123 + StorageUnavailable: + description: Object storage is unavailable. + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + Envelope: + type: object + required: [code, msg, data, timestamp, requestId] + properties: + code: {type: integer, format: int32} + msg: {type: string} + data: {type: object, nullable: true} + timestamp: {type: string, format: date-time} + requestId: {type: string, example: req-123} + ErrorEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: {type: object, nullable: true, example: null} + CliWhoAmIEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliWhoAmI' + CliWhoAmI: + type: object + required: [handle, displayName, email] + properties: + handle: {type: string, example: user-123} + displayName: {type: string, example: CLI User} + email: {type: string, format: email, nullable: true, example: cli@example.com} + CliSearchEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliSearchResult' + CliSearchResult: + type: object + required: [items, total, limit] + properties: + items: + type: array + items: {$ref: '#/components/schemas/CliSearchItem'} + total: {type: integer, format: int64, example: 1} + limit: {type: integer, format: int32, example: 20} + CliSearchItem: + type: object + required: [namespace, slug, latestVersion] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + latestVersion: {type: string, example: 1.2.0} + summary: {type: string, nullable: true, example: Parse PDF files} + CliResolveEnvelope: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + $ref: '#/components/schemas/CliResolveResult' + CliResolveResult: + type: object + required: [namespace, slug, version, versionId, fingerprint, downloadUrl] + properties: + namespace: {type: string, example: global} + slug: {type: string, example: pdf-parser} + version: {type: string, example: 1.2.0} + versionId: {type: integer, format: int64, example: 42} + fingerprint: {type: string, example: 'sha256:abc123'} + downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download} +``` + +- [ ] **Step 3: Validate documentation formatting and contract paths** + +Run: + +```bash +ruby -e 'require "yaml"; YAML.load_file("docs/api/authentication.openapi.yaml"); puts "OpenAPI YAML OK"' +rg -n '/api/cli/v1/(auth/whoami|skills)' docs/03-authentication-design.md docs/api/authentication.openapi.yaml +git diff --check +``` + +Expected: YAML parser prints `OpenAPI YAML OK`, all five current paths are found, and `git diff --check` exits 0. + +- [ ] **Step 4: Commit authentication documentation** + +```bash +git add docs/03-authentication-design.md docs/api/authentication.openapi.yaml +git commit -s -m "docs(auth): document CLI token failure semantics (#605)" +``` + +### Task 7: Release artifact and runtime identity evidence + +**Files:** +- No repository file changes; evidence belongs in the active issue comment because runtime URLs, replica identities, and operational details may not be suitable for the public repository. + +- [ ] **Step 1: Resolve the published v0.2.14 server digest and revision** + +Run: + +```bash +docker buildx imagetools inspect ghcr.io/iflytek/skillhub-server:v0.2.14 +docker buildx imagetools inspect ghcr.io/iflytek/skillhub-server:sha-982258d +``` + +Expected: record the immutable manifest digest and confirm whether the release tag and SHA tag resolve to the same manifest. If registry access is denied, capture the denial and escalate access to the human owner. + +- [ ] **Step 2: Inspect every affected runtime replica when access is provided** + +On the runtime host, from the release compose directory, run: + +```bash +docker compose -f compose.release.yml config --images +SERVER_CONTAINER_IDS="$(docker compose -f compose.release.yml ps -q server)" +docker inspect --format '{{.Name}} {{.Config.Image}} {{.Image}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}' ${SERVER_CONTAINER_IDS} +for container_id in ${SERVER_CONTAINER_IDS}; do + image_id="$(docker inspect --format '{{.Image}}' "${container_id}")" + docker image inspect --format '{{json .RepoDigests}}' "${image_id}" +done +``` + +Expected: record configured version, resolved image reference, image ID, OCI revision/version, and immutable RepoDigest for every replica. A mutable tag alone is not a pass. + +- [ ] **Step 3: Replay one token lifecycle against the identified runtime** + +Using an authorized dedicated test account, create one token through the normal product flow, verify all five endpoint results while valid, revoke the same token, verify its database `revoked_at` through an authorized operational read, then repeat all five requests with the same raw token. Record HTTP status, response `requestId`, timestamp, and serving replica separately for whoami, search, resolve, latest download, and versioned download. Never paste the raw token into comments or logs. + +Expected after revocation: 401 on every endpoint. If behavior differs, preserve the exact digest/replica/request evidence and continue systematic root-cause investigation; do not claim the defect is fixed or closable. + +- [ ] **Step 4: Escalate missing runtime authority explicitly** + +If no affected runtime URL, host/replica access, or authorization to create/revoke a test token is available, explicitly escalate to the human owner in the active issue. Name the missing authority and request the exact evidence still required: deployed version, immutable server digest or build SHA, all replica identities, and same-token valid-to-revoked replay. State that repository tests do not close the field contradiction and therefore cannot justify closing the defect. + +### Task 8: Preserve Web Session fallback and harden the reviewed contracts + +**Files:** +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java` +- Modify: `docs/03-authentication-design.md` +- Modify: `docs/api/authentication.openapi.yaml` +- Modify: `docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md` + +- [ ] **Step 1: Add the five-endpoint Web Session and mixed-credential matrix** + +Add independent arguments for `whoami`, search, resolve, latest download, and +versioned download. For each endpoint exercise Session-only, Session + Basic, +Basic-only, and Session + valid Bearer. Persist distinct Session and token +users, assert Session identity is retained when Bearer is absent or the scheme +is Basic, assert public reads are anonymous for Basic-only, and assert valid +Bearer identity replaces Session identity. Existing revoked, expired, unknown, +empty, and malformed Bearer cases must attach a real mock HTTP Session and +continue to return the fixed five-field 401 envelope before controller service +logic runs. + +Run a reversible filter mutation that prevents valid Bearer replacement of an +existing Session principal, then run: + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#sessionAndAuthorizationSchemeMatrix \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected RED: Session + valid Bearer exposes the Session user instead of the +token user. Restore production source immediately and rerun the same command. +Expected GREEN: all 20 endpoint/credential arguments pass without a production +source diff. + +- [ ] **Step 2: Lock the nullable whoami email contract** + +Persist an active user whose email is `null`, issue its token through +`ApiTokenService`, call `GET /api/cli/v1/auth/whoami`, and assert the `email` +key is present with a JSON null value inside the standard five-field envelope. + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest#whoamiReturnsNullEmailForPersistedUserWithoutEmail \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS against existing production behavior; this is a response-shape +characterization test. Update `CliWhoAmI.email` in OpenAPI to remain required +while becoming `nullable: true`. + +- [ ] **Step 3: Make PRIVATE search omission a positive and negative proof** + +Use a unique numeric `skillSlug` as `q`, persist an installable PUBLIC skill +whose search document contains the same keyword, and keep the existing +installable PRIVATE skill. Assert the PUBLIC slug is returned and the PRIVATE +slug is omitted for the outsider token. + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected RED before the PUBLIC fixture is persisted: the expected PUBLIC slug +is absent. Expected GREEN after the fixture is added: the same non-empty result +contains PUBLIC and omits PRIVATE. + +- [ ] **Step 4: Assert the fixed five-field 403 envelope on every restricted read** + +Replace status/code-only assertions for restricted resolve, latest download, +and versioned download with a shared assertion for exactly `code`, `msg`, +`data`, `timestamp`, and `requestId`; require `code=403`, `data=null`, and +string timestamps/request IDs. Keep the three routes as separate test methods. + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotResolvePrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadLatestPrivateSkill,CliRestrictedReadAuthorizationIntegrationTest#outsiderCannotDownloadVersionedPrivateSkill \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: all three pass through the real access-denied path. + +- [ ] **Step 5: Align authentication design and OpenAPI priority rules** + +Document these exact rules: valid Bearer overrides Web Session; any Bearer +attempt that is empty, malformed, unknown, expired, revoked, or tied to an +unavailable user returns 401 without Session fallback; no Authorization header +or a non-Bearer scheme preserves a valid Session; without a Session, public +reads use anonymous visibility and `whoami` returns 401. Add cookie +`sessionAuth` to OpenAPI and list it as an alternative on all five operations. +OpenAPI descriptions must state the precedence because security alternatives +cannot encode it alone. + +- [ ] **Step 6: Confirm the review correction did not change production auth** + +```bash +git diff --name-only origin/main...HEAD +git diff --exit-code origin/main...HEAD -- server/skillhub-auth/src/main server/skillhub-app/src/main +``` + +Expected: only tests and documentation changed; the production-code diff +command exits 0. + +### Task 9: Quality gates and implementation review handoff + +**Files:** +- Verify all changed files; do not create a PR in this stage. + +- [ ] **Step 1: Run both focused integration classes** + +```bash +cd server && ./mvnw -pl skillhub-app -am \ + -Dtest=CliTokenLifecycleSecurityIntegrationTest,CliRestrictedReadAuthorizationIntegrationTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS, with latest and versioned download reported as distinct methods. + +- [ ] **Step 2: Run the complete backend gate** + +```bash +make test-backend-app +``` + +Expected: `BUILD SUCCESS`, zero failures, zero errors. + +- [ ] **Step 3: Run repository web gates required before delivery** + +```bash +make typecheck-web +make lint-web +``` + +Expected: zero TypeScript errors and zero ESLint errors/warnings. + +- [ ] **Step 4: Run containerized staging regression** + +```bash +make staging +``` + +Expected: backend/frontend images build, services become healthy, and smoke tests pass. Tear down with `make staging-down` after collecting evidence. + +- [ ] **Step 5: Verify scope, formatting, and commit hygiene** + +```bash +git diff --check origin/main...HEAD +git diff --name-only origin/main...HEAD +git status --short --branch +git log --format='%h %s%n%b' origin/main..HEAD +``` + +Expected: only the approved spec/plan, two test classes, authentication design, and OpenAPI document are changed; no production authentication source is changed when the matrix passes; all commits are signed off and reference GitHub issue #605 without any Multica identifier. + +- [ ] **Step 6: Route to tester and reviewer quality gates** + +Provide the branch, focused commands, complete matrix result, 403 fixture result, docs path, runtime identity/replay evidence or explicit external blocker, and full gate output to the project tester. After tester passes, request structured reviewer/security review. Address any findings on the same branch and rerun affected gates. + +- [ ] **Step 7: Update the existing single PR and report completion** + +Commit and push to the existing `fix/auth-revoked-token-validation` branch so +PR #609 updates in place. Post the implementation result to the active issue +thread. Include commit SHAs, endpoint-by-state matrix, RED mutation evidence, +GREEN results, quality gates, OpenAPI path, production-code decision, and +runtime identity/replay status. Do not create a second PR, do not change issue +status, and do not merge `main` during this stage. diff --git a/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md new file mode 100644 index 00000000..1b6d6488 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-revoked-token-validation-design.md @@ -0,0 +1,279 @@ +# Revoked API Token Validation Design + +## Goal + +Prove and preserve fail-closed API-token behavior across the CLI API using a +real persisted token lifecycle. Invalid Bearer credentials must return HTTP +401 before endpoint business logic runs, including when a valid Web Session is +also present. A valid Bearer credential overrides the Session identity. When +Bearer is absent or the Authorization scheme is unsupported, the existing Web +Session identity is preserved; without a valid Session, public reads remain +anonymous and `whoami` returns 401. Valid credentials without sufficient +authorization continue to return HTTP 403. + +## Scope + +This change covers the following CLI routes: + +- `GET /api/cli/v1/auth/whoami` +- `GET /api/cli/v1/skills/search` +- `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` +- `GET /api/cli/v1/skills/{namespace}/{slug}/download` +- `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` + +It also covers the authenticated-versus-forbidden boundary on the affected +restricted read routes. An existing scope-protected CLI route may provide +supplementary scope-filter evidence only. This change does not add endpoints, +change runtime response fields, change token storage, add a database migration, +or change anonymous resource visibility rules. The OpenAPI correction marks +the already-nullable `whoami.email` value accurately without changing its JSON +field presence. + +## Current-State Finding + +The fail-closed implementation from closed PR #511 was later included in the +single replacement PR #523 and is present in both v0.2.14 and current `main`. +`ApiTokenAuthenticationFilter` already validates Bearer credentials before +business logic and rejects empty, malformed, unknown, expired, revoked, +missing-user, and disabled-user credentials through the configured +`AuthenticationEntryPoint`. + +The verified repository gap is regression coverage, not a demonstrated +production-code gap. Existing tests separately prove token lifecycle +validation and invalid-Bearer filtering, but they do not exercise persisted +token creation, revocation, and all affected CLI endpoints in one integrated +matrix. The CLI API table in `docs/03-authentication-design.md` also retains +legacy paths, and there is no dedicated OpenAPI 3.0 authentication contract in +`docs/api/`. + +The reported v0.2.14 runtime behavior still contradicts the source and test +evidence. Source equality alone does not establish which artifact or replica +served the reported requests. The defect therefore remains open until the +release artifact and affected runtime are identified and the same token +lifecycle is replayed against that identified runtime. + +## Release Artifact and Runtime Identity Gate + +Runtime verification is a required investigation track, not an optional +deployment check. Before interpreting a runtime result, record all of the +following for every server replica that may receive the request: + +1. The configured deployment version and resolved image reference from the + runtime environment and `docker compose config --images`. +2. The running container's image ID and registry `RepoDigest` from + `docker inspect` / `docker image inspect`. +3. The OCI `org.opencontainers.image.revision` and + `org.opencontainers.image.version` labels. The publish workflow generates + these labels and also publishes a `sha-` tag, so the revision can + be mapped back to a repository commit. +4. The externally observed application URL, health result, deployment profile, + and request IDs for the authentication probes. + +If the revision label is absent, the image digest must be mapped to the +corresponding publish-images workflow output or registry manifest. A mutable +tag such as `latest` or `v0.2.14` is not sufficient identity evidence by +itself. If neither a revision nor a digest-to-build mapping can be obtained, +the source/runtime contradiction is unresolved and the defect cannot be +closed. + +Using a dedicated test user and non-production token, replay one lifecycle +against the identified running image: + +1. Issue the token and call every matrix endpoint while it is valid. +2. Revoke that same token through the normal product flow and verify its + persisted `revoked_at` value without exposing the raw token. +3. Reuse the same raw token against every matrix endpoint and capture status, + response envelope, request ID, timestamp, and serving replica when + available. +4. Repeat or pin requests per replica when a load balancer can route to mixed + versions, and compare the image digest/revision of each replica. + +If production mutation is not authorized, run the exact identified digest in +an approved isolated environment with equivalent auth/proxy configuration and +record that limitation. This does not by itself close the original field +report: an authorized runtime replay or owner-provided equivalent evidence is +still required. + +The contradiction is closed only when source commit, published image digest, +running instance identity, and replay result form one consistent chain. A +mismatched digest indicates deployment drift; identical application images +with divergent behavior require investigation of proxy header forwarding, +mixed replicas, session/cookie contamination, and request routing before any +source-code conclusion is accepted. + +## Architecture + +`ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry +point. Spring Security loads an existing Web Session identity before the token +filter runs. A valid Bearer token replaces that identity; an invalid, empty, or +malformed Bearer attempt clears it and returns 401. The filter ignores Basic +and other non-Bearer schemes, preserving the loaded Session identity. If no +Session exists, those schemes reach public reads anonymously and `whoami` +returns 401. Controllers must not duplicate token parsing, Session resolution, +or lifecycle checks. + +The regression test will boot the Spring application with MockMvc, real +`ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI +endpoint business services may be mocked only to make successful public-read +responses deterministic; authentication and token lifecycle components remain +real. This isolates the contract boundary under test: a rejected credential +must stop in the security chain before controller business logic executes. + +The restricted-read authorization test is separate and must not mock the +permission decision. It will persist a PRIVATE or NAMESPACE_ONLY skill owned by +another user, authenticate a valid outsider token with no qualifying namespace +role, and exercise the real `CliSkillAppService` plus domain query/download +authorization path. At least `resolve`, latest download, and versioned download +must return HTTP 403. A DELETE request with a missing token scope may supplement +this check, but cannot replace any affected read-path assertion. + +Production authentication code will be changed only when a new regression +test fails for the expected behavioral reason. Any fix must be the smallest +change at the shared authentication or token-validation source of the failure. +Endpoint-specific authentication patches and unrelated refactoring are out of +scope. + +## Persisted Token Lifecycle + +The test fixture creates an active user and issues a token through +`ApiTokenService`, retaining only the raw token returned at creation time. +Lifecycle transitions use production persistence paths: + +1. Call an affected endpoint with the valid raw token and confirm successful + authentication. +2. Revoke the token through `ApiTokenService.revokeToken`. +3. Call every affected endpoint with the same raw token. +4. Assert HTTP 401 and confirm protected endpoint business logic was not + reached. + +Expired-token coverage persists a token with an expiration timestamp earlier +than the service clock, then validates it through the same filter and +repository path. Unknown and malformed tokens exercise the same HTTP security +chain without creating a token row. + +## Behavioral Matrix + +The authentication rows use deterministic public fixtures. Latest and +versioned downloads are independent endpoints and must have independent test +arguments and assertions for every credential state. + +| Credential state | `whoami` | Public `search` | Public `resolve` | Public latest download | Public versioned download | Meaning | +|---|---:|---:|---:|---:|---:|---| +| No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public | +| Basic or another non-Bearer scheme | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Unsupported schemes are not treated as API-token attempts | +| Valid Web Session, no `Authorization` header | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Existing browser identity is preserved | +| Valid Web Session + Basic | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Non-Bearer schemes do not erase Session identity | +| Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected | +| Valid Web Session + valid active token | 200 as token user | 200 as token user | 200 as token user | Existing 200/302 as token user | Existing 200/302 as token user | Bearer identity overrides Session identity | +| Valid Web Session + revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous | +| Valid Web Session + expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous | +| Valid Web Session + unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous | +| Valid Web Session + empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic | +| Valid Web Session + malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic | + +The authorization row uses a persisted PRIVATE or NAMESPACE_ONLY fixture and +the real read-authorization path: + +| Valid credential, insufficient resource permission | `whoami` | `search` | Restricted `resolve` | Restricted latest download | Restricted versioned download | +|---|---:|---:|---:|---:|---:| +| Outsider token with no qualifying namespace role | 200 | 200 with restricted skill omitted | 403 | 403 | 403 | + +The same fixture must also prove that an authorized owner or qualifying +namespace member can reach the restricted read path, so a 403 cannot be caused +by an invalid fixture. Missing-scope DELETE coverage is optional supplementary +evidence for the API-token scope filter only. + +## Error Handling and Security + +- Invalid Bearer credentials return the existing structured HTTP 401 response + through `ApiAuthenticationEntryPoint`. +- Valid credentials that fail scope or resource authorization return the + existing structured HTTP 403 response through the access-denied path. +- Responses must not reveal whether a token is unknown, expired, or revoked. +- Tests, logs, documentation, and commits must not contain real secrets. Test + credentials are generated locally and exist only in the in-memory test + database. +- Token material must never be logged. + +## Documentation + +Two documentation updates are required: + +1. Update `docs/03-authentication-design.md` so the CLI API section uses the + current `/api/cli/v1/...` routes and explicitly states Bearer-over-Session + priority, Session fallback, and the anonymous/401/403 boundary. +2. Add `docs/api/authentication.openapi.yaml` using OpenAPI 3.0. The document + must define Bearer and Web Session authentication, all affected paths, + query/path parameters, success schemas, the common response envelope, HTTP + 401 and 403 responses, examples, credential priority, and the rule that + requests without either identity are allowed only on existing public-read + routes. `CliWhoAmI.email` remains required but is nullable. + +No controller signature or response schema changes are planned. Therefore the +generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a +production fix unexpectedly changes a controller contract, `make generate-api` +becomes mandatory and the generated diff must be committed. + +## Implementation Plan Requirements + +The detailed implementation plan must preserve the following independent +steps rather than collapsing them into one generic download case: + +1. Create the real persisted token/user fixture and public endpoint stubs used + by the authentication matrix. +2. Exercise `whoami`, `search`, and `resolve` for every credential state. +3. Exercise latest download for every credential state. +4. Exercise versioned download for every credential state. +5. Persist a restricted skill plus authorized and unauthorized users, then use + the real read-authorization path to prove 403 for restricted `resolve`, + latest download, and versioned download and success for an authorized user. +6. Update the authentication design and OpenAPI contract. +7. Exercise Session-only, Session + Basic, Basic-only, and Session + valid or + invalid Bearer independently on all five endpoints; latest and versioned + download remain separate cases. +8. Prove PRIVATE search omission with a non-empty same-keyword PUBLIC result + and assert the fixed five-field 403 envelope on each restricted read. +9. Identify the published/running image and replay the valid-to-revoked token + lifecycle against that exact digest, or record the external access blocker + without treating the field contradiction as resolved. + +Each endpoint/state step must state its own expected status and test command. +The plan may share fixture helpers, but it must not share one assertion in a +way that can skip either download route. + +## Verification + +Verification proceeds in this order: + +1. Run the new focused persisted-token matrix and record whether it fails or + passes on unmodified `main` behavior, with separate results for latest and + versioned download. +2. Run the persisted restricted-resource checks through real query/download + authorization and record outsider 403 plus authorized-user success. +3. If an authentication row fails, preserve the failure output as reproduction + evidence, apply one minimal shared fix, and rerun the focused matrix. +4. Run auth-module and affected app integration tests. +5. Run `make test-backend-app`. +6. Run `make typecheck-web` and `make lint-web` as repository pre-PR gates. +7. Run `make staging` for containerized regression and smoke coverage. +8. Run `git diff --check` and confirm no generated OpenAPI type drift when no + controller contract changed. +9. Record the release tag, build revision, image reference, immutable digest, + and every serving replica's running image identity. +10. Replay the same valid-to-revoked token lifecycle against the identified + runtime and record endpoint-level status, request ID, and replica evidence, + keeping latest and versioned download results separate. +11. Perform structured security and code review before updating the existing + single final pull request. + +## Delivery Constraints + +- Work only on `fix/auth-revoked-token-validation`. +- Keep PR #511 closed and use it only as historical reference. +- Create exactly one final pull request for GitHub issue #605. +- GitHub-facing text must not contain a Multica issue identifier. +- Do not mark the defect resolved or eligible for closure while the reported + runtime behavior and the identified artifact/runtime replay remain + contradictory or incomplete. +- Do not merge `main`; merging remains the responsibility of an explicitly + authorized human owner. diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java new file mode 100644 index 00000000..8f4498ea --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliRestrictedReadAuthorizationIntegrationTest.java @@ -0,0 +1,187 @@ +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +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.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliRestrictedReadAuthorizationIntegrationTest { + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired UserAccountRepository userAccountRepository; + @Autowired NamespaceRepository namespaceRepository; + @Autowired SkillRepository skillRepository; + @Autowired SkillVersionRepository skillVersionRepository; + @Autowired SkillSearchDocumentJpaRepository skillSearchDocumentRepository; + + private String namespaceSlug; + private String skillSlug; + private String publicSkillSlug; + private String version; + private String ownerToken; + private String outsiderToken; + + @BeforeEach + void setUp() { + String suffix = UUID.randomUUID().toString().replace("-", ""); + String ownerId = "private-owner-" + suffix; + String outsiderId = "private-outsider-" + suffix; + namespaceSlug = "private-ns-" + suffix; + skillSlug = Long.toUnsignedString(UUID.randomUUID().getMostSignificantBits()); + publicSkillSlug = "public-skill-" + suffix; + version = "1.0.0"; + + userAccountRepository.save(new UserAccount( + ownerId, "Private Skill Owner", ownerId + "@example.com", "")); + userAccountRepository.save(new UserAccount( + outsiderId, "Private Skill Outsider", outsiderId + "@example.com", "")); + ownerToken = apiTokenService.createToken( + ownerId, "owner-token-" + suffix, "[\"skill:read\"]").rawToken(); + outsiderToken = apiTokenService.createToken( + outsiderId, "outsider-token-" + suffix, "[\"skill:read\"]").rawToken(); + + Namespace namespace = namespaceRepository.save( + new Namespace(namespaceSlug, "Private Namespace", ownerId)); + Skill skill = skillRepository.save(new Skill( + namespace.getId(), skillSlug, ownerId, SkillVisibility.PRIVATE)); + SkillVersion published = new SkillVersion(skill.getId(), version, ownerId); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z")); + published.setDownloadReady(true); + published = skillVersionRepository.save(published); + skill.setLatestVersionId(published.getId()); + skillRepository.save(skill); + skillRepository.flush(); + skillVersionRepository.flush(); + skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity( + skill.getId(), + namespace.getId(), + namespaceSlug, + ownerId, + skillSlug, + "Private skill search fixture", + "private", + skillSlug, + "", + SkillVisibility.PRIVATE.name(), + skill.getStatus().name())); + + Skill publicSkill = skillRepository.save(new Skill( + namespace.getId(), publicSkillSlug, ownerId, SkillVisibility.PUBLIC)); + SkillVersion publicPublished = new SkillVersion(publicSkill.getId(), version, ownerId); + publicPublished.setStatus(SkillVersionStatus.PUBLISHED); + publicPublished.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z")); + publicPublished.setDownloadReady(true); + publicPublished = skillVersionRepository.save(publicPublished); + publicSkill.setLatestVersionId(publicPublished.getId()); + skillRepository.save(publicSkill); + skillRepository.flush(); + skillVersionRepository.flush(); + skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity( + publicSkill.getId(), + namespace.getId(), + namespaceSlug, + ownerId, + skillSlug, + "Public match for " + publicSkillSlug, + "public", + skillSlug, + "", + SkillVisibility.PUBLIC.name(), + publicSkill.getStatus().name())); + } + + @Test + void outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/search") + .param("q", skillSlug) + .param("limit", "20"), + outsiderToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[*].slug", hasItem(publicSkillSlug))) + .andExpect(jsonPath("$.data.items[*].slug", not(hasItem(skillSlug)))); + } + + @Test + void outsiderCannotResolvePrivateSkill() throws Exception { + assertForbiddenEnvelope(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + outsiderToken)); + } + + @Test + void outsiderCannotDownloadLatestPrivateSkill() throws Exception { + assertForbiddenEnvelope(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug), + outsiderToken)); + } + + @Test + void outsiderCannotDownloadVersionedPrivateSkill() throws Exception { + assertForbiddenEnvelope(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download", + namespaceSlug, skillSlug, version), + outsiderToken)); + } + + private void assertForbiddenEnvelope(MockHttpServletRequestBuilder request) throws Exception { + mockMvc.perform(request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(403)) + .andExpect(jsonPath("$.msg").isString()) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.timestamp").isString()) + .andExpect(jsonPath("$.requestId").isString()); + } + + @Test + void ownerCanResolvePrivateSkill() throws Exception { + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug), + ownerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.slug").value(skillSlug)); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java new file mode 100644 index 00000000..1783bf71 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliTokenLifecycleSecurityIntegrationTest.java @@ -0,0 +1,468 @@ +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.cli.CliResolveResponse; +import com.iflytek.skillhub.service.cli.CliSkillAppService; +import jakarta.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +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.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CliTokenLifecycleSecurityIntegrationTest { + + private enum InvalidCredentialState { + REVOKED, + EXPIRED, + UNKNOWN, + EMPTY, + MALFORMED + } + + private enum EndpointCase { + WHOAMI, + SEARCH, + RESOLVE, + LATEST_DOWNLOAD, + VERSIONED_DOWNLOAD + } + + private enum MixedCredentialState { + SESSION_ONLY, + SESSION_BASIC, + BASIC_ONLY, + SESSION_VALID_BEARER + } + + @Autowired MockMvc mockMvc; + @Autowired ApiTokenService apiTokenService; + @Autowired ApiTokenRepository apiTokenRepository; + @Autowired UserAccountRepository userAccountRepository; + @Autowired Clock clock; + @MockBean CliSkillAppService cliSkillAppService; + + private String userId; + private String sessionUserId; + + @BeforeEach + void setUp() { + userId = "token-matrix-" + UUID.randomUUID(); + sessionUserId = "session-matrix-" + UUID.randomUUID(); + userAccountRepository.save(new UserAccount( + userId, "Token Matrix", userId + "@example.com", "")); + userAccountRepository.save(new UserAccount( + sessionUserId, "Session Matrix", sessionUserId + "@example.com", "")); + given(cliSkillAppService.search(any(), anyInt(), any(), any())) + .willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20)); + given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any())) + .willReturn(new CliResolveResponse( + "global", "demo", "1.0.0", 1L, "sha256:empty", + "/api/v1/skills/global/demo/versions/1.0.0/download")); + given(cliSkillAppService.downloadLatest(anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any())) + .willAnswer(ignored -> downloadResponse()); + } + + @Test + void whoamiWithoutAuthorizationReturns401() throws Exception { + mockMvc.perform(get("/api/cli/v1/auth/whoami")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void whoamiWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.handle").value(userId)); + } + + @ParameterizedTest(name = "{0} with {1}") + @MethodSource("mixedCredentialMatrix") + void sessionAndAuthorizationSchemeMatrix( + EndpointCase endpoint, + MixedCredentialState credentialState) throws Exception { + clearInvocations(cliSkillAppService); + String expectedUserId = expectedUserId(credentialState); + MockHttpServletRequestBuilder request = withCredentials(requestFor(endpoint), credentialState); + + if (endpoint == EndpointCase.WHOAMI) { + if (credentialState == MixedCredentialState.BASIC_ONLY) { + assertUnauthorizedEnvelope(request); + } else { + assertSuccessEnvelope(request) + .andExpect(jsonPath("$.data.handle").value(expectedUserId)); + } + verifyNoInteractions(cliSkillAppService); + return; + } + + ResultActions result = mockMvc.perform(request).andExpect(status().isOk()); + if (endpoint == EndpointCase.LATEST_DOWNLOAD + || endpoint == EndpointCase.VERSIONED_DOWNLOAD) { + result.andExpect(content().contentType("application/zip")); + } else { + result.andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(0)); + } + assertProjectedUser(endpoint, expectedUserId); + } + + @Test + void whoamiReturnsNullEmailForPersistedUserWithoutEmail() throws Exception { + String noEmailUserId = "token-no-email-" + UUID.randomUUID(); + userAccountRepository.save(new UserAccount(noEmailUserId, "No Email User", null, "")); + String rawToken = apiTokenService.createToken( + noEmailUserId, "no-email-" + UUID.randomUUID(), "[\"skill:read\"]").rawToken(); + + assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken)) + .andExpect(jsonPath("$.data", hasKey("email"))) + .andExpect(jsonPath("$.data.email").value(nullValue())); + } + + @ParameterizedTest(name = "whoami rejects {0}") + @EnumSource(InvalidCredentialState.class) + void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + assertUnauthorizedEnvelope(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void searchWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20")) + .andExpect(status().isOk()); + } + + @Test + void searchWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "search rejects {0}") + @EnumSource(InvalidCredentialState.class) + void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void resolveWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve")) + .andExpect(status().isOk()); + } + + @Test + void resolveWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token)) + .andExpect(status().isOk()); + } + + @ParameterizedTest(name = "resolve rejects {0}") + @EnumSource(InvalidCredentialState.class) + void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/resolve"), state)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void latestDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/download")) + .andExpect(status().isOk()); + } + + @Test + void latestDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); + } + + @ParameterizedTest(name = "latest download rejects {0}") + @EnumSource(InvalidCredentialState.class) + void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/download"), state)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void versionedDownloadWithoutAuthorizationReturns200() throws Exception { + mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download")) + .andExpect(status().isOk()); + } + + @Test + void versionedDownloadWithValidPersistedTokenReturns200() throws Exception { + String token = createActiveToken(); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); + } + + @ParameterizedTest(name = "versioned download rejects {0}") + @EnumSource(InvalidCredentialState.class) + void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception { + clearInvocations(cliSkillAppService); + assertUnauthorizedEnvelope(withInvalidBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state)); + verifyNoInteractions(cliSkillAppService); + } + + @Test + void sameRawTokenIsRejectedByAllEndpointsAfterValidUseAndRevocation() throws Exception { + ApiTokenService.TokenCreateResult token = createToken(); + String rawToken = token.rawToken(); + + assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken)) + .andExpect(jsonPath("$.data.handle").value(userId)); + assertSuccessEnvelope(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), + rawToken)); + assertSuccessEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/resolve"), rawToken)); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/download"), rawToken)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); + mockMvc.perform(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")); + + apiTokenService.revokeToken(token.entity().getId(), userId); + clearInvocations(cliSkillAppService); + + assertUnauthorizedEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), + rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/resolve"), rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/download"), rawToken)); + assertUnauthorizedEnvelope(withBearer( + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken)); + verifyNoInteractions(cliSkillAppService); + } + + private ResultActions assertSuccessEnvelope(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.msg").isString()) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.timestamp").isString()) + .andExpect(jsonPath("$.requestId").isString()); + } + + private void assertUnauthorizedEnvelope(MockHttpServletRequestBuilder request) throws Exception { + mockMvc.perform(request) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$", aMapWithSize(5))) + .andExpect(jsonPath("$.code").value(401)) + .andExpect(jsonPath("$.msg").isString()) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.timestamp").isString()) + .andExpect(jsonPath("$.requestId").isString()); + } + + private MockHttpServletRequestBuilder withInvalidBearer( + MockHttpServletRequestBuilder request, + InvalidCredentialState state) { + return request + .header(HttpHeaders.AUTHORIZATION, authorizationHeader(state)) + .session(session()); + } + + private static Stream mixedCredentialMatrix() { + return Stream.of(EndpointCase.values()) + .flatMap(endpoint -> Stream.of(MixedCredentialState.values()) + .map(state -> Arguments.of(endpoint, state))); + } + + private MockHttpServletRequestBuilder requestFor(EndpointCase endpoint) { + return switch (endpoint) { + case WHOAMI -> get("/api/cli/v1/auth/whoami"); + case SEARCH -> get("/api/cli/v1/skills/search") + .param("q", "demo") + .param("limit", "20"); + case RESOLVE -> get("/api/cli/v1/skills/global/demo/resolve"); + case LATEST_DOWNLOAD -> get("/api/cli/v1/skills/global/demo/download"); + case VERSIONED_DOWNLOAD -> + get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"); + }; + } + + private MockHttpServletRequestBuilder withCredentials( + MockHttpServletRequestBuilder request, + MixedCredentialState state) { + return switch (state) { + case SESSION_ONLY -> request.session(session()); + case SESSION_BASIC -> request.session(session()) + .header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0"); + case BASIC_ONLY -> request.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0"); + case SESSION_VALID_BEARER -> withBearer(request.session(session()), createActiveToken()); + }; + } + + private String expectedUserId(MixedCredentialState state) { + return switch (state) { + case SESSION_ONLY, SESSION_BASIC -> sessionUserId; + case BASIC_ONLY -> null; + case SESSION_VALID_BEARER -> userId; + }; + } + + private void assertProjectedUser(EndpointCase endpoint, String expectedUserId) { + if (endpoint == EndpointCase.SEARCH) { + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(String.class); + verify(cliSkillAppService).search(any(), anyInt(), userCaptor.capture(), any()); + assertEquals(expectedUserId, userCaptor.getValue()); + return; + } + if (endpoint == EndpointCase.RESOLVE) { + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(String.class); + verify(cliSkillAppService).resolve(anyString(), anyString(), any(), userCaptor.capture(), any()); + assertEquals(expectedUserId, userCaptor.getValue()); + return; + } + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class); + if (endpoint == EndpointCase.LATEST_DOWNLOAD) { + verify(cliSkillAppService).downloadLatest(anyString(), anyString(), requestCaptor.capture()); + } else { + verify(cliSkillAppService).downloadVersion( + anyString(), anyString(), anyString(), requestCaptor.capture()); + } + assertEquals(expectedUserId, requestCaptor.getValue().getAttribute("userId")); + } + + private MockHttpServletRequestBuilder withBearer( + MockHttpServletRequestBuilder request, + String rawToken) { + return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken); + } + + private String authorizationHeader(InvalidCredentialState state) { + return switch (state) { + case REVOKED -> { + ApiTokenService.TokenCreateResult result = createToken(); + apiTokenService.revokeToken(result.entity().getId(), userId); + yield "Bearer " + result.rawToken(); + } + case EXPIRED -> { + ApiTokenService.TokenCreateResult result = createToken(); + ApiToken token = result.entity(); + token.setExpiresAt(Instant.now(clock).minusSeconds(1)); + apiTokenRepository.saveAndFlush(token); + yield "Bearer " + result.rawToken(); + } + case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID(); + case EMPTY -> "Bearer "; + case MALFORMED -> "Bearer"; + }; + } + + private String createActiveToken() { + return createToken().rawToken(); + } + + private ApiTokenService.TokenCreateResult createToken() { + return apiTokenService.createToken( + userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]"); + } + + private MockHttpSession session() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(sessionAuthentication()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + securityContext); + return session; + } + + private UsernamePasswordAuthenticationToken sessionAuthentication() { + PlatformPrincipal principal = new PlatformPrincipal( + sessionUserId, + "Session User", + sessionUserId + "@example.com", + "", + "session", + Set.of("USER")); + return new UsernamePasswordAuthenticationToken(principal, null, List.of()); + } + + private ResponseEntity downloadResponse() { + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/zip")) + .body(new InputStreamResource( + new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } +}