diff --git a/docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md b/docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md new file mode 100644 index 0000000000..0cb79d1125 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md @@ -0,0 +1,1058 @@ +# Token-Issuance UI + Served Agent Skill — 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:** Let a logged-in user mint a short-lived bearer token from the Wildbook UI (with a server-verified step-up password), and serve an anonymous agent-skill document at `GET /api/v3/agent-skill` that teaches a user's AI agent the token-scoped API, schema, how to get a token, and to never accept the user's credentials. + +**Architecture:** Component A — harden the existing `AuthToken` mint to verify a *fresh* Basic credential server-side (a session cookie must not suffice), then a React `/api-access` page that collects the password and calls it via a cookie-less `fetch`. Component B — an `AgentSkill` servlet streaming a curated, version-controlled markdown resource, anon-gated. + +**Tech Stack:** Java 17 servlets (`javax.servlet`, `Shepherd`/JDO), org.json, JUnit 5 + Mockito; React (react-bootstrap, react-query), Jest/RTL; Shiro (`web.xml [urls]`). + +**Spec:** `docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md` (Codex-reviewed). +**Branch:** `token-auth-scoped-search` (PR #1613). Do NOT push/merge — the user does that. + +**Repo conventions (read before starting):** +- JUnit 5 assertions put the message LAST: `assertEquals(expected, actual, "msg")`. +- Normalize line endings before every commit: `grep -c $'\r' ` must be 0; else `sed -i 's/\r$//' `. +- Java test run: `mvn test -Dtest= -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"` +- Frontend test run: `cd frontend && npx jest ` (CI is continue-on-error for jest, but tests must pass locally). +- Commit messages end with: `Co-Authored-By: Claude Opus 4.8 ` + +**Verified facts this plan relies on:** +- `ServletUtilities.hashAndSaltPassword(String clearText, String salt)` (ServletUtilities.java:752) produces the stored hash; `User.getSalt()`, `User.getPassword()`, `User.getUsername()`, `User.getId()` exist. +- `AuthToken extends ApiBase` and currently mints for `myShepherd.getUser(request)` (session/Basic via Shiro) — this is what we harden. +- `/api/v3/user` (`UserInfo`) exposes `username`. `/api/*` is mapped to `WildbookApi`, so a new **exact** servlet mapping `/api/v3/agent-skill` wins. +- `SearchApi` token allowlist = `encounter`, `annotation`, `individual` (others 403) — the drift-guard test pins the skill to this. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/main/java/org/ecocean/User.java` (**modify**) | Add `checkPassword(String clearText)` — verify against stored salted hash. | +| `src/main/java/org/ecocean/api/AuthToken.java` (**modify**) | Require + verify a fresh Basic credential; reject session-only; `no-store`; audit log. | +| `src/main/java/org/ecocean/api/AgentSkill.java` (**create**) | Stream the curated skill markdown as `text/markdown`. | +| `src/main/resources/agent-skill.md` (**create**) | The curated agent skill content. | +| `src/main/webapp/WEB-INF/web.xml` (**modify**) | Register `AgentSkill` servlet + `/api/v3/agent-skill` mapping + `anon` Shiro rule. | +| `src/test/java/org/ecocean/api/AuthTokenTest.java` (**modify/create**) | Step-up enforcement cases. | +| `src/test/java/org/ecocean/api/AgentSkillTest.java` (**create**) | Serving + content-anchor + drift-guard tests. | +| `src/test/java/org/ecocean/api/EndpointAuthWiringTest.java` (**modify**) | Assert servlet + url-pattern + `anon` rule. | +| `frontend/src/models/auth/useMintToken.js` (**create**) | Cookie-less mint hook (raw fetch, Basic header). | +| `frontend/src/pages/ApiAccess/ApiAccessPage.jsx` (**create**) | Page + password modal + token display. | +| `frontend/src/AuthenticatedSwitch.jsx` (**modify**) | `/api-access` route. | +| `frontend/src/components/header/AvatarAndUserProfile.jsx` (**modify**) | "API Access" menu item. | +| `frontend/src/__tests__/...` (**create**) | Hook + page + menu tests. | + +--- + +## Task 1: `User.checkPassword` helper + +**Files:** +- Modify: `src/main/java/org/ecocean/User.java` +- Test: `src/test/java/org/ecocean/UserCheckPasswordTest.java` + +- [ ] **Step 1: Write the failing test** + +Create `src/test/java/org/ecocean/UserCheckPasswordTest.java`: + +```java +package org.ecocean; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class UserCheckPasswordTest { + + private static final String SALT = "0123456789abcdef"; + + private User userWith(String clear) { + // IMPORTANT: User.setPassword(String) only ASSIGNS the field — it does NOT hash. Production + // stores an already-hashed value alongside a matching salt, so the test must do the same. + User u = new User(); + u.setUsername("alice"); + u.setSalt(SALT); + u.setPassword(org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clear, SALT)); + return u; + } + + @Test void checkPassword_trueForCorrect_falseForWrong() { + User u = userWith("s3cr3t!"); + assertTrue(u.checkPassword("s3cr3t!"), "correct password verifies"); + assertFalse(u.checkPassword("nope"), "wrong password rejected"); + } + + @Test void checkPassword_falseOnNullOrNoStoredPassword() { + User u = new User(); + u.setUsername("bob"); // no password/salt set + assertFalse(u.checkPassword("anything"), "no stored password -> false"); + User u2 = userWith("pw"); + assertFalse(u2.checkPassword(null), "null candidate -> false"); + assertFalse(u2.checkPassword(""), "empty candidate -> false"); + } +} +``` + +Note: `User` has a no-arg constructor, `setSalt(String)`, and `setPassword(String)` that **only +assigns** (does not hash). `checkPassword` (Step 3) hashes the candidate with the stored salt and +compares to the stored hash — exactly mirroring `WildbookBasicHttpAuthenticationFilter`. If any setter +name differs, adapt the setup but keep the "store a real hash + matching salt" shape and the assertions. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=UserCheckPasswordTest -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"` +Expected: FAIL — `checkPassword` undefined (compile error). + +- [ ] **Step 3: Implement** + +Add to `src/main/java/org/ecocean/User.java` (near `getPassword()`): + +```java + /** + * Verify a clear-text password against this user's stored salted hash, using the same hashing + * as login (ServletUtilities.hashAndSaltPassword). Constant-time comparison. Returns false if + * this user has no stored password or the candidate is blank. + */ + public boolean checkPassword(String clearText) { + if ((clearText == null) || clearText.isEmpty()) return false; + String stored = this.getPassword(); + String salt = this.getSalt(); + if ((stored == null) || stored.isEmpty() || (salt == null)) return false; + String hashed = org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clearText, salt); + if (hashed == null) return false; + return java.security.MessageDigest.isEqual( + hashed.getBytes(java.nio.charset.StandardCharsets.UTF_8), + stored.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } +``` + +- [ ] **Step 4: Run test to verify it passes** (same command) — Expected: PASS (2 tests). + +- [ ] **Step 5: Normalize + commit** + +```bash +grep -c $'\r' src/main/java/org/ecocean/User.java src/test/java/org/ecocean/UserCheckPasswordTest.java +git add src/main/java/org/ecocean/User.java src/test/java/org/ecocean/UserCheckPasswordTest.java +git commit -m "User: add checkPassword(clearText) for fresh credential verification + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: `AuthToken` step-up enforcement + +Harden the mint so a session cookie alone can NEVER mint — a fresh, valid `Authorization: Basic` credential is required and verified server-side. (Codex High.) + +**Files:** +- Modify: `src/main/java/org/ecocean/api/AuthToken.java` +- Test: `src/test/java/org/ecocean/api/AuthTokenStepUpTest.java` + +- [ ] **Step 1: Write the failing test** + +Create `src/test/java/org/ecocean/api/AuthTokenStepUpTest.java`: + +```java +package org.ecocean.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Base64; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.ecocean.User; +import org.ecocean.shepherd.core.Shepherd; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; + +class AuthTokenStepUpTest { + + private HttpServletResponse resp(StringWriter out) throws Exception { + HttpServletResponse r = mock(HttpServletResponse.class); + when(r.getWriter()).thenReturn(new PrintWriter(out)); + return r; + } + + private String basic(String u, String p) { + return "Basic " + Base64.getEncoder().encodeToString((u + ":" + p).getBytes()); + } + + @Test void noBasicHeader_sessionOnly_is401() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Authorization")).thenReturn(null); // session-only + StringWriter out = new StringWriter(); + HttpServletResponse r = resp(out); + try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> { + doNothing().when(m).beginDBTransaction(); + doNothing().when(m).setAction(anyString()); + doNothing().when(m).rollbackAndClose(); + })) { + new AuthToken().doPostForTest(req, r); + } + verify(r).setStatus(401); + } + + // Servlet-level check: a present-but-wrong Basic credential is rejected by THIS servlet + // regardless of any session. (The full filter+session end-to-end — "authenticated session + + // wrong Basic still 401" — depends on Shiro and is covered by the live smoke in the spec.) + @Test void wrongPassword_servletRejects_401() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Authorization")).thenReturn(basic("alice", "WRONG")); + StringWriter out = new StringWriter(); + HttpServletResponse r = resp(out); + User alice = mock(User.class); + when(alice.checkPassword("WRONG")).thenReturn(false); + try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> { + doNothing().when(m).beginDBTransaction(); + doNothing().when(m).setAction(anyString()); + doNothing().when(m).rollbackAndClose(); + when(m.getUser("alice")).thenReturn(alice); + })) { + new AuthToken().doPostForTest(req, r); + } + verify(r).setStatus(401); + } + + @Test void correctPassword_mints200() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Authorization")).thenReturn(basic("alice", "right")); + StringWriter out = new StringWriter(); + HttpServletResponse r = resp(out); + User alice = mock(User.class); + when(alice.checkPassword("right")).thenReturn(true); + when(alice.getId()).thenReturn("uuid-alice"); + when(alice.getUsername()).thenReturn("alice"); + try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> { + doNothing().when(m).beginDBTransaction(); + doNothing().when(m).setAction(anyString()); + doNothing().when(m).rollbackAndClose(); + when(m.getUser("alice")).thenReturn(alice); + })) { + new AuthToken().doPostForTest(req, r); + } + // 200 only if JWT issuance is configured in the test env; otherwise 503. + // Assert we did NOT 401 (the credential was accepted) and set no-store. + verify(r, never()).setStatus(401); + verify(r).setHeader("Cache-Control", "no-store"); + } +} +``` + +Note: the `correctPassword_mints200` test asserts "not 401" + the `no-store` header rather than a hard 200, because JWT signing may be unconfigured in the unit env (yielding 503). The point is the credential path, not the signer. + +- [ ] **Step 2: Run test to verify it fails** (`mvn test -Dtest=AuthTokenStepUpTest ...`) — FAIL (`doPostForTest` undefined / logic absent). + +- [ ] **Step 3: Implement** + +Rewrite `AuthToken.doPost` to parse + verify a fresh Basic credential. Replace the current `doPost` body and add a `doPostForTest` shim + a parse helper. Full new `AuthToken.java`: + +```java +package org.ecocean.api; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.ecocean.User; +import org.ecocean.Util; +import org.ecocean.api.auth.JwtService; +import org.ecocean.shepherd.core.Shepherd; +import org.json.JSONObject; + +/** + * POST /api/v3/auth/token + * Mints a short-lived RS256 token. STEP-UP: requires a fresh, valid HTTP Basic credential and + * verifies it server-side (a Shiro session alone is NOT sufficient) — so a stolen/unlocked session + * or same-origin script cannot mint without the password. + */ +public class AuthToken extends ApiBase { + private static final long DEFAULT_TTL_MILLIS = 30L * 60L * 1000L; // 30 min + + @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + handle(request, response); + } + + // package-visible test entry point + void doPostForTest(HttpServletRequest request, HttpServletResponse response) throws IOException { + handle(request, response); + } + + private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException { + response.setHeader("Cache-Control", "no-store"); + final String context = "context0"; // Basic auth is pinned to context0 (see filter) + String clientIp = request.getRemoteAddr(); + + String[] cred = parseBasic(request.getHeader("Authorization")); + if (cred == null) { + // No fresh Basic credential -> session alone is not sufficient. + System.out.println("AuthToken mint DENIED (no Basic credential) ip=" + clientIp); + writeError(response, 401, "basic credentials required"); + return; + } + String username = cred[0]; + String password = cred[1]; + + Shepherd myShepherd = new Shepherd(context); + myShepherd.setAction("api.AuthToken.doPost"); + myShepherd.beginDBTransaction(); + try { + User user = (Util.stringExists(username)) ? myShepherd.getUser(username) : null; + if ((user == null) || !user.checkPassword(password)) { + System.out.println("AuthToken mint DENIED (bad credentials) user=" + username + + " ip=" + clientIp); + writeError(response, 401, "invalid credentials"); + return; + } + String tokenContext = org.ecocean.CommonConfiguration.getProperty("jwtContext", context); + if (!Util.stringExists(tokenContext)) tokenContext = "context0"; + JwtService jwt = JwtService.fromConfig(tokenContext); + if (!jwt.isEnabled()) { + writeError(response, 503, "token issuance not configured"); + return; + } + long ttl = ttlFromConfig(tokenContext); + String token = jwt.sign(user.getId(), tokenContext, ttl); + System.out.println("AuthToken mint OK user=" + username + " ip=" + clientIp); + JSONObject out = new JSONObject(); + out.put("token", token); + out.put("tokenType", "Bearer"); + out.put("expiresInSeconds", ttl / 1000L); + response.setStatus(200); + response.setContentType("application/json"); + response.getWriter().write(out.toString()); + } catch (Exception ex) { + ex.printStackTrace(); + writeError(response, 500, "token issuance failed"); + } finally { + myShepherd.rollbackAndClose(); + } + } + + /** Parse an HTTP Basic Authorization header into [username, password], or null if absent/invalid. */ + private static String[] parseBasic(String header) { + if (header == null) return null; + if (!header.regionMatches(true, 0, "Basic ", 0, 6)) return null; + try { + String decoded = new String(Base64.getDecoder().decode(header.substring(6).trim()), + StandardCharsets.UTF_8); + int i = decoded.indexOf(':'); + if (i < 0) return null; + return new String[] { decoded.substring(0, i), decoded.substring(i + 1) }; + } catch (IllegalArgumentException ex) { + return null; + } + } + + private long ttlFromConfig(String context) { + String v = org.ecocean.CommonConfiguration.getProperty("jwtTtlSeconds", context); + if (Util.stringExists(v)) { + try { + long secs = Long.parseLong(v.trim()); + secs = Math.max(60L, Math.min(secs, 24L * 3600L)); + return secs * 1000L; + } catch (NumberFormatException ignore) {} + } + return DEFAULT_TTL_MILLIS; + } + + private void writeError(HttpServletResponse response, int code, String message) + throws IOException { + response.setStatus(code); + response.setContentType("application/json"); + response.getWriter().write(new JSONObject().put("success", false) + .put("error", message).toString()); + } +} +``` + +Key changes vs the old version: token is minted for the **Basic-verified** user (`getUser(username)` + `checkPassword`), never `getUser(request)` (which would honor the session); `no-store` header; audit `println`s without password/token. The Shiro `authcBasicWildbook` filter stays on the route (unchanged) — this servlet-level check is the authoritative step-up. + +- [ ] **Step 4: Run the new test** (`mvn test -Dtest=AuthTokenStepUpTest ...`) — Expected: PASS (3 tests). + +- [ ] **Step 4b: Update the EXISTING `AuthTokenTest` for the new contract (Codex High)** + +The rewrite changes behavior, so `src/test/java/org/ecocean/api/AuthTokenTest.java` will regress — a +request with **no** Basic header now returns `401` *before* any Shepherd/JWT setup. Read that file and +update each case to the new contract: +- Any "no credentials / session-only" case → expect `401` ("basic credentials required"). +- The JWT-disabled (`503`) and context-pin cases → must now supply a **valid** Basic header + (`Authorization: Basic base64("alice:right")`) and mock `Shepherd.getUser("alice")` → a `User` mock + whose `checkPassword("right")` returns `true` (mirror `AuthTokenStepUpTest`), so execution reaches + the JWT/issuance logic those cases exercise. +Run `mvn test -Dtest=AuthTokenTest ...` → all green. Do NOT delete coverage — adapt it. + +- [ ] **Step 5: Normalize + commit** + +```bash +grep -c $'\r' src/main/java/org/ecocean/api/AuthToken.java src/test/java/org/ecocean/api/AuthTokenStepUpTest.java src/test/java/org/ecocean/api/AuthTokenTest.java +git add src/main/java/org/ecocean/api/AuthToken.java src/test/java/org/ecocean/api/AuthTokenStepUpTest.java src/test/java/org/ecocean/api/AuthTokenTest.java +git commit -m "AuthToken: server-side step-up — require+verify fresh Basic, reject session-only + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: The curated agent-skill markdown + +**Files:** +- Create: `src/main/resources/agent-skill.md` + +- [ ] **Step 1: Create the content** + +Create `src/main/resources/agent-skill.md` with the following (keep it self-contained, agent-agnostic, and free of internal ACL field names / deployment details): + +````markdown +# Wildbook Token-Scoped API — Agent Skill + +You are an AI agent operating Wildbook's **read-only** API on behalf of a human user. You see exactly +what that user is permitted to see (everything is access-controlled to their account). + +## Security — read first +- **Never ask for, accept, or store the user's Wildbook username or password.** You do not need them. +- The user generates a short-lived **bearer token** in Wildbook's UI (Account menu → **API Access**) + and pastes **only the token** to you. +- Treat the token as a secret: never log or persist it, never send it anywhere except Wildbook over + HTTPS. It expires (typically ~30 minutes). When it expires, ask the user to paste a fresh one. + +## Authentication +Send the token as a bearer header on every request: +``` +Authorization: Bearer +``` +The token response includes `expiresInSeconds`. An admin user's token sees all data; a normal user's +token is filtered to their own accessible records. The server also enforces internal access-control +fields, which are never returned to you. + +## Endpoints + +### Search — `POST /api/v3/search/{index}` +`{index}` is one of: `encounter`, `individual`, `annotation`. (`occurrence` and `media_asset` return +`403` for token callers.) Body is an OpenSearch query, e.g.: +```json +{ "query": { "term": { "taxonomy": "Salamandra salamandra" } } } +``` +Pagination via `?from=&size=` query params; total hits in the `X-Wildbook-Total-Hits` response header. +Non-admin `individual` search may only query/sort identity fields. Aggregations, scripted queries, and +cross-index term lookups are rejected. + +### Media resolve — `POST /api/v3/media/resolve` +Resolve up to 100 annotation IDs you are allowed to see into displayable image references: +```json +{ "annotationIds": ["", ""] } +``` +Returns an array of `{ id, imageUrl, imageWidth, imageHeight, bbox: [x,y,w,h], theta, viewpoint, +encounterId, individualId, methodVersion }`. The `bbox` is in the `imageWidth`×`imageHeight` +coordinate space. **Fetch `imageUrl`, read its real pixel dimensions, and scale `bbox` by +`realW/imageWidth`, `realH/imageHeight` before cropping** (usually a no-op). IDs you can't see (or that +don't exist) are simply absent — the response never reveals which. + +## OpenSearch schema (token-exposed fields) +See the field reference for full descriptions. Key indices/fields: +- **encounter** — `id`, `taxonomy`, `locationId`/`locationName`, `date`/`dateMillis`, `individualId`, + `sex`, `lifeStage`, `livingStatus`, `country`, `behavior`, ... +- **individual** — `id`, `displayName`, `names`/`nameMap`, `sex`, `taxonomy`, `timeOfBirth`/`timeOfDeath`. +- **annotation** — `id`, `encounterId`, `viewpoint`, `iaClass`, `matchAgainst`, `mediaAssetId`, and + `embeddings` (nested: `method`, `methodVersion`, and the MiewID `vector`). + +Access-control fields exist server-side but are **never** returned. + +## Worked examples + +**Find an individual's salamander encounters, then view two annotations:** +1. `POST /api/v3/search/encounter` with `{"query":{"term":{"taxonomy":"Salamandra salamandra"}}}`. +2. Collect annotation IDs (search `annotation`, or via the encounters), then + `POST /api/v3/media/resolve` with those IDs. +3. For each result, fetch `imageUrl`, scale `bbox` to the fetched pixels, crop, and present + side-by-side. + +**Comparing embeddings for missed matches:** only compare embeddings within the **same `viewpoint` and +same `methodVersion`** — different viewpoints/versions live in different latent spaces and are not +directly comparable. Calibrate similarity against known same-individual pairs before trusting a score. +```` + +- [ ] **Step 2: Commit** (no test yet; Task 4 wires + tests it) + +```bash +grep -c $'\r' src/main/resources/agent-skill.md +git add src/main/resources/agent-skill.md +git commit -m "media/agent-skill: add curated agent-skill markdown resource + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4: `AgentSkill` servlet + wiring + tests + +**Files:** +- Create: `src/main/java/org/ecocean/api/AgentSkill.java` +- Modify: `src/main/webapp/WEB-INF/web.xml` +- Test: `src/test/java/org/ecocean/api/AgentSkillTest.java` +- Modify: `src/test/java/org/ecocean/api/EndpointAuthWiringTest.java` +- Modify: `src/main/java/org/ecocean/api/SearchApi.java` (extract the allowlist constant) + +- [ ] **Step 1: Extract the token allowlist constant in `SearchApi` (so the drift-guard is real)** + +In `src/main/java/org/ecocean/api/SearchApi.java`, add a package-visible constant near the top of the +class: +```java + /** Indices a token caller may search; everything else is 403. The agent-skill drift-guard test pins to this. */ + static final java.util.Set TOKEN_ALLOWED_INDICES = + java.util.Set.of("encounter", "annotation", "individual"); +``` +Then replace the inline allowlist check (currently `tokenAuth && !"encounter".equals(effectiveIndex) +&& !"annotation".equals(effectiveIndex) && !"individual".equals(effectiveIndex)`) with: +```java + } else if (tokenAuth && !TOKEN_ALLOWED_INDICES.contains(effectiveIndex)) { +``` +This is a behavior-preserving refactor; if `SearchApiTokenAuthTest`/`SearchApiChildIndexTest` exist, +re-run them to confirm no regression. Commit this small refactor with the Task 4 work. + +- [ ] **Step 2: Write the failing test** + +Create `src/test/java/org/ecocean/api/AgentSkillTest.java`: + +```java +package org.ecocean.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.PrintWriter; +import java.io.StringWriter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +class AgentSkillTest { + + private String body(boolean[] statusOk) throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse resp = mock(HttpServletResponse.class); + StringWriter out = new StringWriter(); + when(resp.getWriter()).thenReturn(new PrintWriter(out)); + new AgentSkill().doGetForTest(req, resp); + verify(resp).setStatus(200); + verify(resp).setContentType("text/markdown; charset=UTF-8"); + return out.toString(); + } + + @Test void serves_markdown_with_key_anchors() throws Exception { + String md = body(null); + assertFalse(md.isEmpty(), "skill body must be non-empty"); + assertTrue(md.contains("Authorization: Bearer"), "documents bearer auth"); + assertTrue(md.contains("/api/v3/media/resolve"), "documents media resolve"); + assertTrue(md.contains("/api/v3/search/"), "documents search"); + for (String idx : new String[] {"encounter", "individual", "annotation"}) + assertTrue(md.contains(idx), "mentions index " + idx); + assertTrue(md.toLowerCase().contains("never ask for") + || md.toLowerCase().contains("never give"), + "contains the never-share-credentials guidance"); + } + + // Drift-guard: the skill's claimed allowed indices must match SearchApi's REAL token allowlist + // constant (not a hand-copied list) so the doc fails the build if the allowlist changes. + @Test void skill_index_claims_match_search_allowlist() throws Exception { + String md = body(null); + for (String idx : SearchApi.TOKEN_ALLOWED_INDICES) + assertTrue(md.contains(idx), "skill must list allowed index " + idx); + // denied indices must be named + described as 403 so agents don't try them + assertTrue(md.contains("occurrence") && md.contains("media_asset"), + "skill must name the denied indices"); + assertTrue(md.contains("403"), "skill must state denied indices return 403"); + // and any index the skill says is denied must NOT be in the allowlist (no contradiction) + for (String denied : new String[] {"occurrence", "media_asset"}) + assertFalse(SearchApi.TOKEN_ALLOWED_INDICES.contains(denied), + "denied index " + denied + " must not be in the allowlist"); + } + + // Internal ACL field names must NOT leak into the public skill (Codex Low). + @Test void skill_does_not_leak_internal_acl_field_names() throws Exception { + String md = body(null); + for (String acl : new String[] { + "publiclyReadable", "submitterUserId", "submitterUserIds", "viewUsers", "editUsers"}) + assertFalse(md.contains(acl), "skill must not expose internal ACL field name " + acl); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** (`mvn test -Dtest=AgentSkillTest ...`) — FAIL (`AgentSkill` undefined). + +- [ ] **Step 4: Implement the servlet** + +Create `src/main/java/org/ecocean/api/AgentSkill.java`: + +```java +package org.ecocean.api; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * GET /api/v3/agent-skill + * Serves the curated, version-controlled agent-skill markdown (classpath resource). Anonymous: a + * how-to-authenticate doc cannot itself require auth, and it contains no secrets — only API docs. + */ +public class AgentSkill extends ApiBase { + private static final String RESOURCE = "/agent-skill.md"; + + @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws IOException { + handle(request, response); + } + + void doGetForTest(HttpServletRequest request, HttpServletResponse response) throws IOException { + handle(request, response); + } + + private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException { + String md = readResource(); + if (md == null) { + response.setStatus(500); + response.setContentType("text/plain; charset=UTF-8"); + response.getWriter().write("agent skill unavailable"); + return; + } + response.setStatus(200); + response.setContentType("text/markdown; charset=UTF-8"); + response.getWriter().write(md); + } + + private String readResource() throws IOException { + try (InputStream in = AgentSkill.class.getResourceAsStream(RESOURCE)) { + if (in == null) return null; + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } +} +``` + +- [ ] **Step 5: Wire web.xml** + +In `src/main/webapp/WEB-INF/web.xml`: + +(a) Shiro `[urls]` block — add near the other `/api/v3/*` rules (anonymous): +``` + /api/v3/agent-skill = anon +``` + +(b) Servlet registration (near other `org.ecocean.api.*` servlets): +```xml + + AgentSkill + org.ecocean.api.AgentSkill + + + AgentSkill + /api/v3/agent-skill + +``` +(An exact `` wins over the `/api/*` → `WildbookApi` mapping.) + +- [ ] **Step 6: Add wiring assertions to `EndpointAuthWiringTest.java`** + +```java + @Test + void agentSkill_servletClassIsRegistered() { + assertTrue(fullText().contains("org.ecocean.api.AgentSkill"), + "web.xml must register the AgentSkill servlet"); + } + + @Test + void agentSkill_urlPatternIsRegistered() { + assertTrue(fullText().contains("/api/v3/agent-skill"), + "web.xml must map /api/v3/agent-skill"); + } + + @Test + void agentSkill_shiroRuleIsAnon() { + String ruleLine = lines.stream() + .filter(l -> { + String t = l.stripLeading(); + return !t.startsWith("#") && t.contains("/api/v3/agent-skill"); + }) + .findFirst().orElse(null); + assertNotNull(ruleLine, "Shiro [urls] must contain a rule for /api/v3/agent-skill"); + String value = ruleLine.substring( + ruleLine.indexOf("/api/v3/agent-skill") + "/api/v3/agent-skill".length()).trim(); + if (value.startsWith("=")) value = value.substring(1).trim(); + assertEquals("anon", value, + "the agent-skill doc must be anon (a how-to-auth doc can't require auth); was: '" + value + "'"); + } +``` +(If `EndpointAuthWiringTest` exposes helpers under different names than `fullText()`/`lines`, mirror that file's existing assertions instead.) + +- [ ] **Step 7: Run tests to verify they pass** + +`mvn test -Dtest=AgentSkillTest,EndpointAuthWiringTest -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"` +Expected: PASS (AgentSkillTest 3 + EndpointAuthWiringTest existing + 3 new). + +- [ ] **Step 8: Normalize + commit** + +```bash +grep -c $'\r' src/main/java/org/ecocean/api/SearchApi.java src/main/java/org/ecocean/api/AgentSkill.java src/main/webapp/WEB-INF/web.xml src/test/java/org/ecocean/api/AgentSkillTest.java src/test/java/org/ecocean/api/EndpointAuthWiringTest.java +git add src/main/java/org/ecocean/api/SearchApi.java src/main/java/org/ecocean/api/AgentSkill.java src/main/webapp/WEB-INF/web.xml src/test/java/org/ecocean/api/AgentSkillTest.java src/test/java/org/ecocean/api/EndpointAuthWiringTest.java +git commit -m "agent-skill: serve curated skill at GET /api/v3/agent-skill (anon) + drift-guard tests + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 5: `useMintToken` frontend hook (cookie-less Basic) + +**Files:** +- Create: `frontend/src/models/auth/useMintToken.js` +- Test: `frontend/src/__tests__/models/useMintToken.test.js` + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/models/useMintToken.test.js`: + +```javascript +import { mintToken } from "../../models/auth/useMintToken"; + +describe("mintToken", () => { + let fetchMock; + beforeEach(() => { fetchMock = jest.fn(); global.fetch = fetchMock; }); // jsdom has no fetch by default + afterEach(() => { jest.resetAllMocks(); }); + + it("POSTs with a cookie-less Basic header and returns the token on 200", async () => { + fetchMock.mockResolvedValue({ + status: 200, + json: async () => ({ token: "tok123", tokenType: "Bearer", expiresInSeconds: 1800 }), + }); + const res = await mintToken("alice", "s3cr3t"); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toContain("/api/v3/auth/token"); + expect(opts.method).toBe("POST"); + expect(opts.credentials).toBe("omit"); // no session cookie + expect(opts.headers.Authorization).toBe("Basic " + btoa("alice:s3cr3t")); + expect(res.token).toBe("tok123"); + expect(res.expiresInSeconds).toBe(1800); + }); + + it("throws a typed error with the status on non-200", async () => { + fetchMock.mockResolvedValue({ status: 401, json: async () => ({ error: "invalid credentials" }) }); + await expect(mintToken("alice", "wrong")).rejects.toMatchObject({ status: 401 }); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails**: `cd frontend && npx jest src/__tests__/models/useMintToken.test.js` — FAIL (module missing). + +- [ ] **Step 3: Implement** + +Create `frontend/src/models/auth/useMintToken.js`: + +```javascript +// Mint a short-lived API token via step-up Basic auth. +// IMPORTANT: uses a raw fetch with credentials:"omit" so NO session cookie is sent — the server +// must verify the supplied password fresh (a session alone cannot mint). +export async function mintToken(username, password) { + const resp = await fetch("/api/v3/auth/token", { + method: "POST", + credentials: "omit", + headers: { + Authorization: "Basic " + btoa(`${username}:${password}`), + }, + }); + let data = null; + try { data = await resp.json(); } catch (_e) { /* non-JSON body */ } + if (resp.status !== 200 || !data || !data.token) { + const err = new Error((data && data.error) || `mint failed (${resp.status})`); + err.status = resp.status; + throw err; + } + return data; // { token, tokenType, expiresInSeconds } +} + +// Thin hook wrapper for components (keeps call sites declarative). +import { useState, useCallback } from "react"; +export default function useMintToken() { + const [loading, setLoading] = useState(false); + const mint = useCallback(async (username, password) => { + setLoading(true); + try { return await mintToken(username, password); } + finally { setLoading(false); } + }, []); + return { mint, loading }; +} +``` + +- [ ] **Step 4: Run to verify it passes** (same jest command) — PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +grep -c $'\r' frontend/src/models/auth/useMintToken.js frontend/src/__tests__/models/useMintToken.test.js +git add frontend/src/models/auth/useMintToken.js frontend/src/__tests__/models/useMintToken.test.js +git commit -m "frontend: useMintToken hook (cookie-less step-up Basic mint) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 6: `ApiAccessPage` (page + password modal + token display) + +**Files:** +- Create: `frontend/src/pages/ApiAccess/ApiAccessPage.jsx` +- Test: `frontend/src/__tests__/pages/ApiAccessPage.test.jsx` + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/pages/ApiAccessPage.test.jsx`: + +```javascript +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import ApiAccessPage from "../../pages/ApiAccess/ApiAccessPage"; + +jest.mock("../../models/auth/users/useGetMe", () => () => ({ + data: { username: "alice" }, +})); +const mockMint = jest.fn(); +jest.mock("../../models/auth/useMintToken", () => () => ({ mint: mockMint, loading: false })); + +describe("ApiAccessPage", () => { + beforeEach(() => { mockMint.mockReset(); }); + + it("mints and shows the token on success", async () => { + mockMint.mockResolvedValue({ token: "tok-xyz", expiresInSeconds: 1800 }); + render(); + fireEvent.click(screen.getByRole("button", { name: /generate/i })); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "s3cr3t" } }); + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + await waitFor(() => expect(screen.getByText(/tok-xyz/)).toBeInTheDocument()); + expect(mockMint).toHaveBeenCalledWith("alice", "s3cr3t"); + }); + + it("shows an inline error on 401", async () => { + mockMint.mockRejectedValue(Object.assign(new Error("invalid credentials"), { status: 401 })); + render(); + fireEvent.click(screen.getByRole("button", { name: /generate/i })); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "wrong" } }); + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + await waitFor(() => expect(screen.getByText(/incorrect password/i)).toBeInTheDocument()); + expect(screen.queryByText(/tok-/)).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails**: `cd frontend && npx jest src/__tests__/pages/ApiAccessPage.test.jsx` — FAIL (module missing). + +- [ ] **Step 3: Implement** + +Create `frontend/src/pages/ApiAccess/ApiAccessPage.jsx`: + +```jsx +import React, { useState } from "react"; +import { Button, Modal, Form, Alert } from "react-bootstrap"; +import useGetMe from "../../models/auth/users/useGetMe"; +import useMintToken from "../../models/auth/useMintToken"; + +const SKILL_URL = "/api/v3/agent-skill"; + +export default function ApiAccessPage() { + const me = useGetMe(); + const username = me?.data?.username || ""; + const { mint, loading } = useMintToken(); + + const [showModal, setShowModal] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [token, setToken] = useState(null); + const [expiresIn, setExpiresIn] = useState(null); + + const openModal = () => { setError(null); setPassword(""); setShowModal(true); }; + + const submit = async (e) => { + e.preventDefault(); + setError(null); + try { + const res = await mint(username, password); + setToken(res.token); + setExpiresIn(res.expiresInSeconds); + setPassword(""); + setShowModal(false); + } catch (err) { + if (err.status === 401) setError("Incorrect password. If your account uses single sign-on, API tokens aren't available yet."); + else if (err.status === 503) setError("Token issuance isn't enabled on this server."); + else setError("Couldn't generate a token. Please try again."); + } + }; + + return ( +
+

API Access

+

+ Generate a short-lived token so an AI agent or script can act with your{" "} + Wildbook access. Treat it like a password. +

+ + Do not give your agent your username/password — paste it only a token. + +

+ Your agent can learn this API here:{" "} + {SKILL_URL}{" "} + +

+ + + + {token && ( +
+ + Copy this token now — it won't be shown again + {expiresIn ? ` and expires in ~${Math.round(expiresIn / 60)} min` : ""}. + +
+ {token} + +
+
+ )} + + setShowModal(false)}> +
+ Confirm your password + +

Re-enter your password to mint a token for {username}.

+ {error && {error}} + + Password + setPassword(e.target.value)} autoComplete="current-password" /> + +
+ + + + +
+
+
+ ); +} +``` + +Notes: +- **Verify `useGetMe`'s return shape** before relying on `me?.data?.username`. `useGetMe` wraps + `useFetch` (react-query) whose `dataAccessor` is `result?.data?.data`; confirm where `username` + lands (it may be `me.data.username` or nested differently) and adjust the accessor to match the + real hook. The test mocks `useGetMe` as `{ data: { username: "alice" } }`, so the page's accessor + and that mock must agree. +- The 401 path keeps the modal open — `submit` only calls `setShowModal(false)` on success (the catch + block does not close it). The success test asserts the token appears; the 401 test asserts the + inline error appears with the modal still open. +- The token display uses a static "expires in ~N min" message (computed from `expiresInSeconds`), not + a live ticking countdown — a live countdown is unnecessary for copy-now UX (YAGNI). + +- [ ] **Step 4: Run to verify it passes** (same jest command) — PASS (2 tests). Fix selector/label mismatches if RTL can't find elements (keep `htmlFor`/`id` paired). + +- [ ] **Step 5: Commit** + +```bash +grep -c $'\r' frontend/src/pages/ApiAccess/ApiAccessPage.jsx frontend/src/__tests__/pages/ApiAccessPage.test.jsx +git add frontend/src/pages/ApiAccess/ApiAccessPage.jsx frontend/src/__tests__/pages/ApiAccessPage.test.jsx +git commit -m "frontend: ApiAccessPage — step-up modal + one-time token display + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 7: Route + avatar menu item + +**Files:** +- Modify: `frontend/src/AuthenticatedSwitch.jsx` +- Modify: `frontend/src/components/header/AvatarAndUserProfile.jsx` +- Test: `frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx` + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx`: + +```javascript +import React from "react"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../utils/utils"; +import AvatarAndUserProfile from "../../components/header/AvatarAndUserProfile"; +import AuthContext from "../../AuthProvider"; +import LocaleContext from "../../IntlProvider"; + +// AvatarAndUserProfile uses useNavigate + AuthContext + i18n, so render via the repo's standard +// renderWithProviders (router + intl), wrapped in the same contexts the header tests use +// (mirror frontend/src/__tests__/components/header/AuthenticatedHeader.test.js). +describe("AvatarAndUserProfile", () => { + it("includes an API Access link to /api-access", () => { + renderWithProviders( + + + + + , + ); + const link = screen.getByText(/api access/i).closest("a"); + expect(link).toHaveAttribute("href", expect.stringContaining("/api-access")); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails**: `cd frontend && npx jest src/__tests__/components/AvatarApiAccessLink.test.jsx` — FAIL (no such link). If the component needs providers/router to render, wrap with the same test utilities other component tests in `frontend/src/__tests__/components` use (mirror an existing one). + +- [ ] **Step 3: Implement** + +(a) `frontend/src/AuthenticatedSwitch.jsx` — add the import + route alongside the others (near line 115): +```jsx +import ApiAccessPage from "./pages/ApiAccess/ApiAccessPage"; +``` +```jsx + } /> +``` + +(b) `frontend/src/components/header/AvatarAndUserProfile.jsx` — add a dropdown item before Logout: +```jsx + + API Access + +``` +(Match the existing `NavDropdown.Item` style used by the sibling items.) + +- [ ] **Step 4: Run to verify it passes** (same jest command) — PASS. Run the page + hook tests too to confirm no breakage. + +- [ ] **Step 5: Commit** + +```bash +grep -c $'\r' frontend/src/AuthenticatedSwitch.jsx frontend/src/components/header/AvatarAndUserProfile.jsx frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx +git add frontend/src/AuthenticatedSwitch.jsx frontend/src/components/header/AvatarAndUserProfile.jsx frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx +git commit -m "frontend: route /api-access + API Access avatar menu item + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Final verification (after all tasks) + +- [ ] Backend suite (includes the **existing** `AuthTokenTest` updated in Task 2 Step 4b, plus the + SearchApi token tests to confirm the allowlist-constant refactor didn't regress): + `mvn test -Dtest=UserCheckPasswordTest,AuthTokenTest,AuthTokenStepUpTest,AgentSkillTest,EndpointAuthWiringTest,SearchApiTokenAuthTest,SearchApiChildIndexTest -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"` → all green. (Drop any class name that doesn't exist in this tree.) +- [ ] Frontend suite: `cd frontend && npx jest src/__tests__/models/useMintToken.test.js src/__tests__/pages/ApiAccessPage.test.jsx src/__tests__/components/AvatarApiAccessLink.test.jsx` → all green. +- [ ] `git log --oneline` shows the focused commits. +- [ ] Hand to Codex for a final code review (per the user's standing rule) before any PR/merge. +- [ ] Do NOT push — the user does that. (Live smoke per the spec runs after deploy: anon `GET /api/v3/agent-skill`; UI mint flow; session+wrong-password→401.) diff --git a/docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md b/docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md new file mode 100644 index 0000000000..1518f8d97b --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md @@ -0,0 +1,235 @@ +# Token-Issuance UI + Served Agent Skill — Design + +**Date:** 2026-06-11 +**Status:** Draft — incorporated Codex design review (2026-06-11, verified against source; all +High/Medium/Low findings folded). Pending user review. +**Builds on:** the deployed token-scoped read API — Artifact B (`JwtService`/`AuthToken`, +`POST /api/v3/auth/token`), Artifact D + Spec A (token-scoped `encounter`/`individual`/`annotation` +search), and the media-resolve endpoint (`POST /api/v3/media/resolve`). All on branch +`token-auth-scoped-search` / PR #1613. + +--- + +## Problem + +A logged-in Wildbook user has no way to obtain a bearer token from the UI — tokens are only mintable +by calling `POST /api/v3/auth/token` with HTTP Basic auth, which a browser session can't do on its +own. And even with a token, a user's AI agent has no machine-readable description of *how* to use +Wildbook's token-scoped API (endpoints, the OpenSearch schema/fields, how to authenticate, what not to +do). This design adds the two missing pieces so a user can (1) generate a token from the UI and +(2) point their agent at a Wildbook-served skill that teaches it the API safely. + +## Goals + +1. **Token UI:** a logged-in user can mint a short-lived bearer token from a UI page, with a + step-up password confirmation, and copy it once. +2. **Agent skill:** Wildbook serves an agent-loadable markdown document describing the token-scoped + API, the OpenSearch schema (indices + fields + descriptions), how to get a token, and security + guidance that explicitly discourages giving an agent the user's username/password. + +## Non-Goals + +- Managed/persisted personal API keys (naming, listing, revocation) — the token stays the existing + **stateless** 30-min RS256 JWT. A managed-key store is a documented future phase. +- Any change to token TTL, scoping, or the JWT shape (reuses the deployed signing path). **Exception + (required by Codex review):** the mint endpoint gains server-side enforcement that a *fresh* Basic + credential was supplied — a session cookie alone must NOT be sufficient to mint (see Mint auth). +- Dynamic/live-introspected skill generation — the skill is a curated static doc (future: hybrid). +- Support for password-less/SSO accounts in the step-up flow (noted limitation). + +--- + +## Token model (decided) + +Ephemeral **generate-and-copy**: the existing stateless RS256 JWT (default 30-min TTL, server-clamped +1 min–24 h). No storage, no revocation, no list. Matches exactly what is already deployed. + +## Mint auth (decided) — step-up password, **enforced server-side** + +**Step-up password re-prompt.** The UI collects the user's password; the server verifies it **fresh** +on every mint and never accepts a session cookie as sufficient. + +**Why this needs a backend change (Codex High).** `/api/v3/auth/token` is Shiro-gated by +`authcBasicWildbook`, but Shiro's basic-auth filter lets an *already-authenticated session* pass +**without parsing/verifying the Basic header** (`AuthToken` mints for `myShepherd.getUser(request)`). +So as originally drafted, a logged-in browser could mint via its cookie — even with a *wrong* +password — and the "no CSRF / step-up" guarantee would be false. The fix: + +- **Backend (`AuthToken`):** require an `Authorization: Basic` header and **verify the supplied + username/password against the user's stored credential using Wildbook's existing password check** + (the same credential path login uses), independent of any session. Mint for *that* verified user. + A request with no Basic header (session-only) → `401`; a Basic header with a wrong password → `401`, + regardless of an active session. This also closes the same-origin-XSS vector (a malicious in-page + script can't mint without the password). Add `Cache-Control: no-store` to the token response. +- **Frontend (defense in depth):** send the mint with `fetch(..., { credentials: "omit" })` (a raw + fetch, not the shared cookie-bearing axios client) so no session cookie accompanies the Basic + request. + +CSRF: Basic-auth credentials are not auto-sent cross-site and the token is returned only in the +response body (unreadable cross-origin); with the server-side Basic requirement, a session alone can +never mint. + +--- + +## Component A — Token-issuance UI (+ a small backend enforcement) + +`POST /api/v3/auth/token` returns `{token, tokenType, expiresInSeconds}`. The UI authenticates the +mint with `Authorization: Basic base64(:)` (raw `fetch`, `credentials: +"omit"`) and displays the result; the backend verifies the Basic credential fresh per the Mint-auth +section above. + +### Backend unit +- `AuthToken` (modify) — require + verify a fresh `Authorization: Basic` credential server-side + (reuse Wildbook's existing password check), reject session-only (`401`), add `Cache-Control: + no-store`. **Rate-limit / audit (Codex Medium, adjusted to reality):** verified against source — + Wildbook has **no** app-level login lockout/throttle today (`/api/v3/login`, `/rest/**` Basic auth, + etc. are equally unthrottled), so there is no existing mechanism to "reuse," and adding a bespoke + per-username lockout to *only* the mint would be an inconsistent one-off that also enables targeted + account-DoS. Decision: the mint is no more of a password oracle than the existing auth surfaces; + v1 adds **audit logging** of every mint attempt (username + client IP + success/failure, **never** + the password or token) so abuse is detectable, plus `Cache-Control: no-store`. A platform-wide auth + throttle (covering login + Basic + mint uniformly) is the correct home and is recorded as a + separate follow-up — out of scope here. + +### Units (all under `frontend/src`) +- **Route:** new authenticated route `/api-access` in `AuthenticatedSwitch.jsx` → `ApiAccessPage`. +- **Menu:** a new "API Access" item in `components/header/AvatarAndUserProfile.jsx` linking to it. +- **Hook:** `useMintToken` (a model hook) — builds the Basic header from the `useGetMe` + (`/api/v3/user`) username + the entered password and POSTs to `/api/v3/auth/token`. The password is + passed in at call time, used for the single request, and never stored. +- **Components:** a password-confirm modal + a token-display box (copy button, expiry countdown, + "shown once" warning). + +### UX flow +1. Avatar dropdown → **API Access** → `/api-access`. +2. Page explains the feature, warns *"Do NOT give your agent your username/password — use a token,"* + and links the agent skill URL (`/api/v3/agent-skill`) with a copy button. +3. **Generate API token** → password-confirm modal (username shown read-only from `useGetMe`; + password field; "Confirm your password to mint a token"). +4. Submit → `POST /api/v3/auth/token` with the Basic header. +5. **Success** → modal closes; the token is shown once in a copy box with an expiry countdown + ("expires in ~30 min") and "copy it now — it won't be shown again." Token lives only in component + state; navigating away clears it. + +### Errors +- `401` → inline "Incorrect password" in the modal (do not close it). +- `503` → "Token issuance isn't enabled on this server." +- network / `500` → generic "Couldn't generate a token, try again." +- **Password-less / SSO accounts:** `/api/v3/user` exposes `username` but **not** whether the account + has a local password (confirmed: `UserInfo`/`User.infoJSONObject`), so there is no cheap, reliable + pre-check. v1 does **not** add a `hasLocalPassword` signal — such users simply get the normal `401` + ("Incorrect password") path. The `401` copy may add a hint ("if your account uses single sign-on, + API tokens aren't available yet"), but no tailored detection is built. (A `hasLocalPassword` field + is a documented future option.) + +### Security +- Password sent only in the single mint request over HTTPS, then discarded; never logged/stored. +- Token held only in component state, cleared on navigation; never persisted to localStorage. +- No CSRF surface (Basic-auth path). The `/api-access` route is authenticated (logged-in only); + minting still requires the step-up password. + +--- + +## Component B — Served agent skill (small backend + curated doc) + +### Serving +- New servlet `AgentSkill` (`org.ecocean.api`) at **`GET /api/v3/agent-skill`**, returning + `text/markdown; charset=UTF-8`. +- **Anonymous** Shiro rule (`/api/v3/agent-skill = anon`) — a how-to-authenticate doc cannot itself + sit behind auth, and it contains no secrets (only API docs + schema field metadata + guidance). +- Content lives in a versioned resource `src/main/resources/agent-skill.md`, loaded from the + classpath and streamed; the servlet adds no dynamic data. Deploy-versioned, easy to edit. + +### Skill content (agent-agnostic, self-contained markdown) +1. **Preamble** — "You are an agent operating Wildbook's read API on behalf of a user." Scope: + read-only; the user's own ACL-scoped view. +2. **Security first (the requirement that motivated this):** *Never ask for or accept the user's + Wildbook username/password.* The human mints a short-lived token in the API Access UI and pastes + **only the token**; treat it as a secret; it expires (~30 min) and is re-minted as needed; never + log or persist it. +3. **Auth mechanics** — link to the API Access UI; `Authorization: Bearer `; + `expiresInSeconds`; admin-vs-non-admin scoping (everything is ACL-filtered to what the user sees). +4. **Endpoints** — `POST /api/v3/search/{encounter|individual|annotation}` (allowed query-DSL subset, + pagination headers, what is returned/scrubbed); `POST /api/v3/media/resolve` (annotation IDs → + `imageUrl` + source-frame `bbox` + `imageWidth/imageHeight`, the **consumer-scales** contract, + ≤100 IDs); what is **not** allowed (`occurrence`/`media_asset` → 403; restricted aggregate/script + queries for non-admin individual search). +5. **OpenSearch schema** — the `encounter`/`individual`/`annotation` indices with field descriptions + (sourced from `docs/opensearch-indices-and-fields.md`), limited to **token-exposed, returned** + fields; `embeddings` (nested vector, `method`/`methodVersion`). **Do not** document internal ACL + field names (`publiclyReadable`/`submitterUserId(s)`/`viewUsers`/`editUsers`) or deployment + details (Codex Low) — state only that ACL fields exist server-side and are never returned. +6. **Worked examples** — search by taxonomy; resolve annotation IDs and crop with the bbox; the + missed-match calibration caveat (compare within one `viewpoint` + `methodVersion`). + +### Security +- Anon GET; no secrets, no user data — only documentation. Its content actively discourages + credential sharing and promotes short-lived tokens. + +--- + +## Testing + +### Frontend (Jest / React Testing Library) +- `useMintToken` builds the correct `Authorization: Basic` header from the `useGetMe` username + the + entered password and POSTs to `/api/v3/auth/token`. +- `ApiAccessPage`: **Generate** opens the modal; a successful mint renders the token + expiry + copy + control; `401` renders the inline "Incorrect password" error (modal stays open); `503` renders the + "not enabled" message. +- `AvatarAndUserProfile` includes the new "API Access" item linking to `/api-access`. +- (Repo note: frontend jest is continue-on-error in CI — tests are still written and run locally.) + +### Backend (JUnit) +- **`AuthToken` step-up enforcement (Codex High):** a session-only request (no Basic header) → `401`; + a Basic header with a *wrong* password → `401` *even with an active session*; a correct Basic + credential → `200` + token + `Cache-Control: no-store`. (Mock the session subject + the credential + check.) +- `AgentSkill` returns `200` with `Content-Type: text/markdown; charset=UTF-8`, a non-empty body + containing the key anchors: `Authorization: Bearer`, `/api/v3/media/resolve`, the three index names, + and the "never share credentials" guidance. +- **Skill drift-guard (Codex Medium):** a test asserts the skill markdown's API claims stay in sync — + it mentions exactly the token-allowed indices (`encounter`/`individual`/`annotation`, and that + `occurrence`/`media_asset` are 403) consistent with `SearchApi`'s allowlist and `MediaResolveApi`, + and references no field the skill claims is returned that isn't. (Pragmatic form: assert the + allowed/denied index sets in the markdown match the `SearchApi` token allowlist constants; flag on + mismatch so the doc can't silently drift.) +- `EndpointAuthWiringTest`: the `AgentSkill` servlet + `/api/v3/agent-skill` `` are + registered (an exact mapping that wins over the `/api/*` → `WildbookApi` mapping), and the Shiro + `[urls]` rule for `/api/v3/agent-skill` is exactly `anon`. + +### Live smoke +- `GET /api/v3/agent-skill` on flakebook returns the markdown anonymously. +- Run the UI mint → copy flow as a logged-in user; confirm `401` on wrong password. +- **Step-up enforcement:** while logged in (session cookie present), `POST /api/v3/auth/token` with a + *wrong* Basic password returns `401` (the session does not let it through); with no Basic header at + all returns `401`. +- Paste the minted token into a `POST /api/v3/search/...` call and confirm it works. + +--- + +## Components / file boundary + +**Component A — backend (step-up enforcement):** +- `src/main/java/org/ecocean/api/AuthToken.java` (modify) — require + verify a fresh Basic credential + server-side (reuse Wildbook's password check), reject session-only, reuse login lockout/throttle, + `Cache-Control: no-store`, audit-log without secrets. +- `src/test/java/org/ecocean/api/AuthTokenTest.java` (modify/extend) — the step-up enforcement cases. + +**Component A — frontend:** +- `frontend/src/pages/ApiAccess/ApiAccessPage.jsx` (new) — page + modal + token display. +- `frontend/src/models/auth/useMintToken.js` (new) — the mint hook (raw `fetch`, `credentials: + "omit"`, Basic header). +- `frontend/src/AuthenticatedSwitch.jsx` (modify) — add the `/api-access` route. +- `frontend/src/components/header/AvatarAndUserProfile.jsx` (modify) — add the menu item. +- Frontend tests under `frontend/src/__tests__/`. + +**Backend (Component B):** +- `src/main/java/org/ecocean/api/AgentSkill.java` (new) — the servlet. +- `src/main/resources/agent-skill.md` (new) — the curated skill content. +- `src/main/webapp/WEB-INF/web.xml` (modify) — servlet + mapping + anon Shiro rule. +- `src/test/java/org/ecocean/api/AgentSkillTest.java` (new) + `EndpointAuthWiringTest.java` (modify). + +This is a self-contained increment on the `token-auth-scoped-search` branch. Component A is mostly +frontend plus a focused security hardening of the existing `AuthToken` mint (fresh Basic +verification); Component B adds one servlet + one resource + a web.xml mapping. Neither changes the +token JWT shape, TTL, scoping, or the search/index code paths. diff --git a/frontend/src/AuthenticatedSwitch.jsx b/frontend/src/AuthenticatedSwitch.jsx index 453dd30b36..f2c7e0376b 100644 --- a/frontend/src/AuthenticatedSwitch.jsx +++ b/frontend/src/AuthenticatedSwitch.jsx @@ -34,6 +34,7 @@ const MatchResults = lazy( const Encounter = lazy(() => import("./pages/Encounter/Encounter")); const Citation = lazy(() => import("./pages/Citation")); +const ApiAccessPage = lazy(() => import("./pages/ApiAccess/ApiAccessPage")); const PoliciesAndData = lazy( () => import("./pages/PoliciesAndData/PoliciesAndData"), ); @@ -112,6 +113,7 @@ export default function AuthenticatedSwitch({ } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx b/frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx new file mode 100644 index 0000000000..e50fa01c14 --- /dev/null +++ b/frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import { screen, fireEvent } from "@testing-library/react"; +import { renderWithProviders } from "../../utils/utils"; +import AvatarAndUserProfile from "../../components/header/AvatarAndUserProfile"; +import AuthContext from "../../AuthProvider"; +import LocaleContext from "../../IntlProvider"; + +// AvatarAndUserProfile uses useNavigate + AuthContext + i18n, so render via the repo's standard +// renderWithProviders (router + intl), wrapped in the same contexts the header tests use +// (mirror frontend/src/__tests__/components/header/AuthenticatedHeader.test.js). +describe("AvatarAndUserProfile", () => { + it("includes an API Access link to /api-access", () => { + renderWithProviders( + + + + + , + ); + // The dropdown is hover-controlled (show={shows} toggled by onMouseEnter). + // Fire mouseEnter on the dropdown container to open it before querying items. + const dropdown = document.querySelector(".custom-nav-dropdown"); + fireEvent.mouseEnter(dropdown); + const link = screen.getByText(/api access/i).closest("a"); + expect(link).toHaveAttribute("href", expect.stringContaining("/api-access")); + }); +}); diff --git a/frontend/src/__tests__/models/useMintToken.test.js b/frontend/src/__tests__/models/useMintToken.test.js new file mode 100644 index 0000000000..a97e2fa966 --- /dev/null +++ b/frontend/src/__tests__/models/useMintToken.test.js @@ -0,0 +1,36 @@ +import { mintToken } from "../../models/auth/useMintToken"; + +describe("mintToken", () => { + let fetchMock; + beforeEach(() => { fetchMock = jest.fn(); global.fetch = fetchMock; }); // jsdom has no fetch by default + afterEach(() => { jest.resetAllMocks(); }); + + it("POSTs with a cookie-less Basic header and returns the token on 200", async () => { + fetchMock.mockResolvedValue({ + status: 200, + json: async () => ({ token: "tok123", tokenType: "Bearer", expiresInSeconds: 1800 }), + }); + const res = await mintToken("alice", "s3cr3t"); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toContain("/api/v3/auth/token"); + expect(opts.method).toBe("POST"); + expect(opts.credentials).toBe("omit"); // no session cookie + expect(opts.headers.Authorization).toBe("Basic " + btoa("alice:s3cr3t")); + expect(res.token).toBe("tok123"); + expect(res.expiresInSeconds).toBe(1800); + }); + + it("throws a typed error with the status on non-200", async () => { + fetchMock.mockResolvedValue({ status: 401, json: async () => ({ error: "invalid credentials" }) }); + await expect(mintToken("alice", "wrong")).rejects.toMatchObject({ status: 401 }); + }); + + it("UTF-8 encodes non-ASCII credentials", async () => { + fetchMock.mockResolvedValue({ status: 200, json: async () => ({ token: "t" }) }); + await mintToken("José", "pâss"); + const auth = fetchMock.mock.calls[0][1].headers.Authorization; + const b64 = auth.replace(/^Basic /, ""); + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + expect(new TextDecoder().decode(bytes)).toBe("José:pâss"); // round-trips as UTF-8 + }); +}); diff --git a/frontend/src/__tests__/pages/ApiAccessPage.test.jsx b/frontend/src/__tests__/pages/ApiAccessPage.test.jsx new file mode 100644 index 0000000000..dc2b9e8056 --- /dev/null +++ b/frontend/src/__tests__/pages/ApiAccessPage.test.jsx @@ -0,0 +1,33 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import ApiAccessPage from "../../pages/ApiAccess/ApiAccessPage"; + +jest.mock("../../models/auth/users/useGetMe", () => () => ({ + data: { username: "alice" }, +})); +const mockMint = jest.fn(); +jest.mock("../../models/auth/useMintToken", () => () => ({ mint: mockMint, loading: false })); + +describe("ApiAccessPage", () => { + beforeEach(() => { mockMint.mockReset(); }); + + it("mints and shows the token on success", async () => { + mockMint.mockResolvedValue({ token: "tok-xyz", expiresInSeconds: 1800 }); + render(); + fireEvent.click(screen.getByRole("button", { name: /generate/i })); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "s3cr3t" } }); + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + await waitFor(() => expect(screen.getByText(/tok-xyz/)).toBeInTheDocument()); + expect(mockMint).toHaveBeenCalledWith("alice", "s3cr3t"); + }); + + it("shows an inline error on 401", async () => { + mockMint.mockRejectedValue(Object.assign(new Error("invalid credentials"), { status: 401 })); + render(); + fireEvent.click(screen.getByRole("button", { name: /generate/i })); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "wrong" } }); + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + await waitFor(() => expect(screen.getByText(/incorrect password/i)).toBeInTheDocument()); + expect(screen.queryByText(/tok-/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/setupTests.js b/frontend/src/__tests__/setupTests.js index 63f4cc7e88..8a904fab8d 100644 --- a/frontend/src/__tests__/setupTests.js +++ b/frontend/src/__tests__/setupTests.js @@ -1,5 +1,10 @@ import "@testing-library/jest-dom"; +// jsdom does not ship TextEncoder/TextDecoder; polyfill from Node's util module. +const { TextEncoder, TextDecoder } = require("util"); +if (typeof global.TextEncoder === "undefined") global.TextEncoder = TextEncoder; +if (typeof global.TextDecoder === "undefined") global.TextDecoder = TextDecoder; + jest.mock("react-intl", () => { const actual = jest.requireActual("react-intl"); return { diff --git a/frontend/src/components/header/AvatarAndUserProfile.jsx b/frontend/src/components/header/AvatarAndUserProfile.jsx index 29834490b5..6457f483a6 100644 --- a/frontend/src/components/header/AvatarAndUserProfile.jsx +++ b/frontend/src/components/header/AvatarAndUserProfile.jsx @@ -49,6 +49,9 @@ export default function AvatarAndUserProfile({ avatar }) { + + API Access + diff --git a/frontend/src/models/auth/useMintToken.js b/frontend/src/models/auth/useMintToken.js new file mode 100644 index 0000000000..84aaf68e78 --- /dev/null +++ b/frontend/src/models/auth/useMintToken.js @@ -0,0 +1,37 @@ +import { useState, useCallback } from "react"; + +// Mint a short-lived API token via step-up Basic auth. +// IMPORTANT: uses a raw fetch with credentials:"omit" so NO session cookie is sent — the server +// must verify the supplied password fresh (a session alone cannot mint). +export async function mintToken(username, password) { + const creds = `${username}:${password}`; + const utf8 = new TextEncoder().encode(creds); + let binary = ""; + for (let i = 0; i < utf8.length; i++) binary += String.fromCharCode(utf8[i]); + const resp = await fetch("/api/v3/auth/token", { + method: "POST", + credentials: "omit", + headers: { + Authorization: "Basic " + btoa(binary), + }, + }); + let data = null; + try { data = await resp.json(); } catch (_e) { /* non-JSON body */ } + if (resp.status !== 200 || !data || !data.token) { + const err = new Error((data && data.error) || `mint failed (${resp.status})`); + err.status = resp.status; + throw err; + } + return data; // { token, tokenType, expiresInSeconds } +} + +// Thin hook wrapper for components (keeps call sites declarative). +export default function useMintToken() { + const [loading, setLoading] = useState(false); + const mint = useCallback(async (username, password) => { + setLoading(true); + try { return await mintToken(username, password); } + finally { setLoading(false); } + }, []); + return { mint, loading }; +} diff --git a/frontend/src/pages/ApiAccess/ApiAccessPage.jsx b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx new file mode 100644 index 0000000000..0564c4b397 --- /dev/null +++ b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx @@ -0,0 +1,129 @@ +import React, { useState } from "react"; +import { Button, Modal, Form, Alert } from "react-bootstrap"; +import useGetMe from "../../models/auth/users/useGetMe"; +import useMintToken from "../../models/auth/useMintToken"; + +const SKILL_URL = "/api/v3/agent-skill"; + +export default function ApiAccessPage() { + const me = useGetMe(); + const username = me?.data?.username || ""; + const { mint, loading } = useMintToken(); + + const [showModal, setShowModal] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [token, setToken] = useState(null); + const [expiresIn, setExpiresIn] = useState(null); + + const openModal = () => { + setError(null); + setPassword(""); + setShowModal(true); + }; + + const submit = async (e) => { + e.preventDefault(); + setError(null); + try { + const res = await mint(username, password); + setToken(res.token); + setExpiresIn(res.expiresInSeconds); + setPassword(""); + setShowModal(false); + } catch (err) { + if (err.status === 401) + setError( + "Incorrect password. If your account uses single sign-on, API tokens aren't available yet.", + ); + else if (err.status === 503) + setError("Token issuance isn't enabled on this server."); + else setError("Couldn't generate a token. Please try again."); + } + }; + + return ( +
+

API Access

+

+ Generate a short-lived token so an AI agent or script can act with{" "} + your Wildbook access. Treat it like a password. +

+ + Do not give your agent your username/password — + paste only the token. + +

+ Your agent can learn this API here: {SKILL_URL}{" "} + +

+ + + + {token && ( +
+ + Copy this token now — it won't be shown again + {expiresIn + ? ` and expires in ~${Math.round(expiresIn / 60)} min` + : ""} + . + +
+ {token} + +
+
+ )} + + setShowModal(false)}> +
+ + Confirm your password + + +

+ Re-enter your password to mint a token for{" "} + {username}. +

+ {error && {error}} + + Password + setPassword(e.target.value)} + autoComplete="current-password" + /> + +
+ + + + +
+
+
+ ); +} diff --git a/src/main/java/org/ecocean/User.java b/src/main/java/org/ecocean/User.java index 54dd0205b8..e22003012a 100644 --- a/src/main/java/org/ecocean/User.java +++ b/src/main/java/org/ecocean/User.java @@ -317,6 +317,23 @@ public void setPassword(String password) { public void setSalt(String salt) { this.salt = salt; } public String getSalt() { return salt; } + /** + * Verify a clear-text password against this user's stored salted hash, using the same hashing + * as login (ServletUtilities.hashAndSaltPassword). Constant-time comparison. Returns false if + * this user has no stored password or the candidate is blank. + */ + public boolean checkPassword(String clearText) { + if ((clearText == null) || clearText.isEmpty()) return false; + String stored = this.getPassword(); + String salt = this.getSalt(); + if ((stored == null) || stored.isEmpty() || (salt == null)) return false; + String hashed = org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clearText, salt); + if (hashed == null) return false; + return java.security.MessageDigest.isEqual( + hashed.getBytes(java.nio.charset.StandardCharsets.UTF_8), + stored.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + public void setUserProject(String newProj) { if (newProj != null) { userProject = newProj; } else { userProject = null; } } diff --git a/src/main/java/org/ecocean/api/AgentSkill.java b/src/main/java/org/ecocean/api/AgentSkill.java new file mode 100644 index 0000000000..3a04f07487 --- /dev/null +++ b/src/main/java/org/ecocean/api/AgentSkill.java @@ -0,0 +1,45 @@ +package org.ecocean.api; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * GET /api/v3/agent-skill + * Serves the curated, version-controlled agent-skill markdown (classpath resource). Anonymous: a + * how-to-authenticate doc cannot itself require auth, and it contains no secrets — only API docs. + */ +public class AgentSkill extends ApiBase { + private static final String RESOURCE = "/agent-skill.md"; + + @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws IOException { + handle(request, response); + } + + void doGetForTest(HttpServletRequest request, HttpServletResponse response) throws IOException { + handle(request, response); + } + + private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException { + String md = readResource(); + if (md == null) { + response.setStatus(500); + response.setContentType("text/plain; charset=UTF-8"); + response.getWriter().write("agent skill unavailable"); + return; + } + response.setStatus(200); + response.setContentType("text/markdown; charset=UTF-8"); + response.getWriter().write(md); + } + + private String readResource() throws IOException { + try (InputStream in = AgentSkill.class.getResourceAsStream(RESOURCE)) { + if (in == null) return null; + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/src/main/java/org/ecocean/api/AuthToken.java b/src/main/java/org/ecocean/api/AuthToken.java index a57248102a..b0e7514c3e 100644 --- a/src/main/java/org/ecocean/api/AuthToken.java +++ b/src/main/java/org/ecocean/api/AuthToken.java @@ -1,6 +1,8 @@ package org.ecocean.api; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -12,27 +14,46 @@ /** * POST /api/v3/auth/token - * Gated by existing Shiro auth (authcBasicWildbook). Mints a short-lived - * RS256 token carrying only the caller's identity (uuid + context). The - * external scoped-access kernel validates it with the public key. + * Mints a short-lived RS256 token. STEP-UP: requires a fresh, valid HTTP Basic credential and + * verifies it server-side (a Shiro session alone is NOT sufficient) — so a stolen/unlocked session + * or same-origin script cannot mint without the password. */ public class AuthToken extends ApiBase { private static final long DEFAULT_TTL_MILLIS = 30L * 60L * 1000L; // 30 min @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { - // SECURITY (Codex High): pin to the fixed auth context. Basic auth authenticates - // against context0 (WildbookBasicHttpAuthenticationFilter), so the user lookup, - // signing key/config, and the token's context claim must ALL come from context0 — - // never from ServletUtilities.getContext(request), which honors ?context=. - final String context = "context0"; + handle(request, response); + } + + // package-visible test entry point + void doPostForTest(HttpServletRequest request, HttpServletResponse response) throws IOException { + handle(request, response); + } + + private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException { + response.setHeader("Cache-Control", "no-store"); + final String context = "context0"; // Basic auth is pinned to context0 (see filter) + String clientIp = request.getRemoteAddr(); + + String[] cred = parseBasic(request.getHeader("Authorization")); + if (cred == null) { + System.out.println("AuthToken mint DENIED (no Basic credential) ip=" + clientIp); + writeError(response, 401, "basic credentials required"); + return; + } + String username = cred[0]; + String password = cred[1]; + Shepherd myShepherd = new Shepherd(context); myShepherd.setAction("api.AuthToken.doPost"); myShepherd.beginDBTransaction(); try { - User user = myShepherd.getUser(request); - if (user == null) { - writeError(response, 401, "unauthenticated"); + User user = (Util.stringExists(username)) ? myShepherd.getUser(username) : null; + if ((user == null) || !user.checkPassword(password)) { + System.out.println("AuthToken mint DENIED (bad credentials) user=" + username + + " ip=" + clientIp); + writeError(response, 401, "invalid credentials"); return; } String tokenContext = org.ecocean.CommonConfiguration.getProperty("jwtContext", context); @@ -44,6 +65,7 @@ public class AuthToken extends ApiBase { } long ttl = ttlFromConfig(tokenContext); String token = jwt.sign(user.getId(), tokenContext, ttl); + System.out.println("AuthToken mint OK user=" + username + " ip=" + clientIp); JSONObject out = new JSONObject(); out.put("token", token); out.put("tokenType", "Bearer"); @@ -59,12 +81,27 @@ public class AuthToken extends ApiBase { } } + /** Parse an HTTP Basic Authorization header into [username, password], or null if absent/invalid. */ + private static String[] parseBasic(String header) { + if (header == null) return null; + if (!header.regionMatches(true, 0, "Basic ", 0, 6)) return null; + try { + String decoded = new String(Base64.getDecoder().decode(header.substring(6).trim()), + StandardCharsets.UTF_8); + int i = decoded.indexOf(':'); + if (i < 0) return null; + return new String[] { decoded.substring(0, i), decoded.substring(i + 1) }; + } catch (IllegalArgumentException ex) { + return null; + } + } + private long ttlFromConfig(String context) { String v = org.ecocean.CommonConfiguration.getProperty("jwtTtlSeconds", context); if (Util.stringExists(v)) { try { long secs = Long.parseLong(v.trim()); - // Codex Low: clamp to a sane range (1 min .. 24 h) + // clamp to a sane range (1 min .. 24 h) secs = Math.max(60L, Math.min(secs, 24L * 3600L)); return secs * 1000L; } catch (NumberFormatException ignore) {} diff --git a/src/main/java/org/ecocean/api/SearchApi.java b/src/main/java/org/ecocean/api/SearchApi.java index 1e1a8fdd5d..58f60e4fba 100644 --- a/src/main/java/org/ecocean/api/SearchApi.java +++ b/src/main/java/org/ecocean/api/SearchApi.java @@ -15,6 +15,10 @@ import org.json.JSONObject; public class SearchApi extends ApiBase { + /** Indices a token caller may search; everything else is 403. The agent-skill drift-guard test pins to this. */ + static final java.util.Set TOKEN_ALLOWED_INDICES = + java.util.Set.of("encounter", "annotation", "individual"); + public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); @@ -100,9 +104,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) response.setStatus(403); res.put("error", 403); // --- token index allowlist: encounter, annotation, individual (others 403) --- - } else if (tokenAuth && !"encounter".equals(effectiveIndex) - && !"annotation".equals(effectiveIndex) - && !"individual".equals(effectiveIndex)) { + } else if (tokenAuth && !TOKEN_ALLOWED_INDICES.contains(effectiveIndex)) { response.setStatus(403); res.put("error", "token search is limited to encounter, annotation, individual"); } else if ((query == null) && !"POST".equals(request.getMethod())) { diff --git a/src/main/resources/agent-skill.md b/src/main/resources/agent-skill.md new file mode 100644 index 0000000000..6d962ae248 --- /dev/null +++ b/src/main/resources/agent-skill.md @@ -0,0 +1,72 @@ +# Wildbook Token-Scoped API — Agent Skill + +You are an AI agent operating Wildbook's **read-only** API on behalf of a human user. You see exactly +what that user is permitted to see (everything is access-controlled to their account). + +## Security — read first +- **Never ask for, accept, or store the user's Wildbook username or password.** You do not need them. +- The user generates a short-lived **bearer token** in Wildbook's UI (Account menu → **API Access**) + and pastes **only the token** to you. +- Treat the token as a secret: never log or persist it, never send it anywhere except Wildbook over + HTTPS. It expires (typically ~30 minutes). When it expires, ask the user to paste a fresh one. + +## Authentication +Send the token as a bearer header on every request: +``` +Authorization: Bearer +``` +The token response includes `expiresInSeconds`. An admin user's token sees all data; a normal user's +token is filtered to their own accessible records. The server also enforces internal access-control +fields, which are never returned to you. + +## Endpoints + +### Search — `POST /api/v3/search/{index}` +`{index}` is one of: `encounter`, `individual`, `annotation`. (`occurrence` and `media_asset` return +`403` for token callers.) Body is an OpenSearch query, e.g.: +```json +{ "query": { "term": { "taxonomy": "Salamandra salamandra" } } } +``` +Pagination via `?from=&size=` query params; total hits in the `X-Wildbook-Total-Hits` response header. +Non-admin `individual` search may only query/sort identity fields. Aggregations, scripted queries, and +cross-index term lookups are rejected. + +### Media resolve — `POST /api/v3/media/resolve` +Resolve up to 100 annotation IDs you are allowed to see into displayable image references: +```json +{ "annotationIds": ["", ""] } +``` +Returns an array of `{ id, imageUrl, imageWidth, imageHeight, bbox: [x,y,w,h], theta, viewpoint, +encounterId, individualId, methodVersion }`. The `bbox` is in the `imageWidth`×`imageHeight` +coordinate space. **Fetch `imageUrl`, read its real pixel dimensions, and scale `bbox` by +`realW/imageWidth`, `realH/imageHeight` before cropping** (usually a no-op). IDs you can't see (or that +don't exist) are simply absent — the response never reveals which. + +## OpenSearch schema (token-exposed fields) +Key indices and fields: +- **encounter** — `id`, `taxonomy`, `locationId`/`locationName`, `date`/`dateMillis`, `individualId`, + `sex`, `lifeStage`, `livingStatus`, `country`, `behavior`, ... +- **individual** — `id`, `displayName`, `names`/`nameMap`, `sex`, `taxonomy`, `timeOfBirth`/`timeOfDeath`. +- **annotation** — `id`, `encounterId`, `viewpoint`, `iaClass`, `matchAgainst`, `mediaAssetId`, and + `embeddings` (nested: `method`, `methodVersion`, and the MiewID `vector`). + +Access-control fields exist server-side but are **never** returned. + +## Worked examples + +**Find an individual's salamander encounters, then view two annotations:** +1. `POST /api/v3/search/encounter` with `{"query":{"term":{"taxonomy":"Salamandra salamandra"}}}`. +2. Collect annotation IDs (search `annotation`, or via the encounters), then + `POST /api/v3/media/resolve` with those IDs. +3. For each result, fetch `imageUrl`, scale `bbox` to the fetched pixels, crop, and present + side-by-side. + +**Comparing embeddings for missed matches:** only compare embeddings within the **same `viewpoint` and +same `methodVersion`** — different viewpoints/versions live in different latent spaces and are not +directly comparable. Calibrate similarity against known same-individual pairs before trusting a score. + +## References +- **Wildbook documentation** — https://wildbook.docs.wildme.org/ — background on the data model + (encounters, individuals, annotations, occurrences), taxonomy, and platform concepts. This describes + the broader Wildbook platform and UI; for anything specific to this read-only token API (endpoints, + allowed indices, token handling) **this skill is authoritative** where the two differ. diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 123826beac..eba4633cdc 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -112,6 +112,7 @@ # No Bearer => falls through to SearchApi (which self-401s if no session). Filter handles auth. /api/v3/search/** = tokenAuthSearch /api/v3/media/resolve = tokenAuthSearch + /api/v3/agent-skill = anon # ===== Appadmin ===== @@ -573,6 +574,15 @@ /api/v3/media/resolve + + AgentSkill + org.ecocean.api.AgentSkill + + + AgentSkill + /api/v3/agent-skill + + BulkExport org.ecocean.api.EncounterExport diff --git a/src/test/java/org/ecocean/UserCheckPasswordTest.java b/src/test/java/org/ecocean/UserCheckPasswordTest.java new file mode 100644 index 0000000000..cebcb73d8c --- /dev/null +++ b/src/test/java/org/ecocean/UserCheckPasswordTest.java @@ -0,0 +1,34 @@ +package org.ecocean; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class UserCheckPasswordTest { + + private static final String SALT = "0123456789abcdef"; + + private User userWith(String clear) { + // IMPORTANT: User.setPassword(String) only ASSIGNS the field — it does NOT hash. Production + // stores an already-hashed value alongside a matching salt, so the test must do the same. + User u = new User(); + u.setUsername("alice"); + u.setSalt(SALT); + u.setPassword(org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clear, SALT)); + return u; + } + + @Test void checkPassword_trueForCorrect_falseForWrong() { + User u = userWith("s3cr3t!"); + assertTrue(u.checkPassword("s3cr3t!"), "correct password verifies"); + assertFalse(u.checkPassword("nope"), "wrong password rejected"); + } + + @Test void checkPassword_falseOnNullOrNoStoredPassword() { + User u = new User(); + u.setUsername("bob"); // no password/salt set + assertFalse(u.checkPassword("anything"), "no stored password -> false"); + User u2 = userWith("pw"); + assertFalse(u2.checkPassword(null), "null candidate -> false"); + assertFalse(u2.checkPassword(""), "empty candidate -> false"); + } +} diff --git a/src/test/java/org/ecocean/api/AgentSkillTest.java b/src/test/java/org/ecocean/api/AgentSkillTest.java new file mode 100644 index 0000000000..6b9045cd96 --- /dev/null +++ b/src/test/java/org/ecocean/api/AgentSkillTest.java @@ -0,0 +1,55 @@ +package org.ecocean.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.PrintWriter; +import java.io.StringWriter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +class AgentSkillTest { + + private String body() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse resp = mock(HttpServletResponse.class); + StringWriter out = new StringWriter(); + when(resp.getWriter()).thenReturn(new PrintWriter(out)); + new AgentSkill().doGetForTest(req, resp); + verify(resp).setStatus(200); + verify(resp).setContentType("text/markdown; charset=UTF-8"); + return out.toString(); + } + + @Test void serves_markdown_with_key_anchors() throws Exception { + String md = body(); + assertFalse(md.isEmpty(), "skill body must be non-empty"); + assertTrue(md.contains("Authorization: Bearer"), "documents bearer auth"); + assertTrue(md.contains("/api/v3/media/resolve"), "documents media resolve"); + assertTrue(md.contains("/api/v3/search/"), "documents search"); + for (String idx : new String[] {"encounter", "individual", "annotation"}) + assertTrue(md.contains(idx), "mentions index " + idx); + assertTrue(md.toLowerCase().contains("never ask for") || md.toLowerCase().contains("never give"), + "contains the never-share-credentials guidance"); + } + + @Test void skill_index_claims_match_search_allowlist() throws Exception { + String md = body(); + for (String idx : SearchApi.TOKEN_ALLOWED_INDICES) + assertTrue(md.contains(idx), "skill must list allowed index " + idx); + assertTrue(md.contains("occurrence") && md.contains("media_asset"), + "skill must name the denied indices"); + assertTrue(md.contains("403"), "skill must state denied indices return 403"); + for (String denied : new String[] {"occurrence", "media_asset"}) + assertFalse(SearchApi.TOKEN_ALLOWED_INDICES.contains(denied), + "denied index " + denied + " must not be in the allowlist"); + } + + @Test void skill_does_not_leak_internal_acl_field_names() throws Exception { + String md = body(); + for (String acl : new String[] { + "publiclyReadable", "submitterUserId", "submitterUserIds", "viewUsers", "editUsers"}) + assertFalse(md.contains(acl), "skill must not expose internal ACL field name " + acl); + } +} diff --git a/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java new file mode 100644 index 0000000000..1467dd0f21 --- /dev/null +++ b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java @@ -0,0 +1,91 @@ +package org.ecocean.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Base64; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.ecocean.User; +import org.ecocean.shepherd.core.Shepherd; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; + +class AuthTokenStepUpTest { + + private HttpServletResponse resp(StringWriter out) throws Exception { + HttpServletResponse r = mock(HttpServletResponse.class); + when(r.getWriter()).thenReturn(new PrintWriter(out)); + return r; + } + private String basic(String u, String p) { + return "Basic " + Base64.getEncoder().encodeToString((u + ":" + p).getBytes()); + } + + @Test void noBasicHeader_sessionOnly_is401() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Authorization")).thenReturn(null); // session-only + StringWriter out = new StringWriter(); + HttpServletResponse r = resp(out); + try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> { + doNothing().when(m).beginDBTransaction(); + doNothing().when(m).setAction(anyString()); + doNothing().when(m).rollbackAndClose(); + })) { + new AuthToken().doPostForTest(req, r); + } + verify(r).setStatus(401); + } + + @Test void wrongPassword_servletRejects_401() throws Exception { + // Servlet-level check: a present-but-wrong Basic credential is rejected regardless of session. + // (The full filter+session end-to-end is covered by the live smoke in the spec.) + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Authorization")).thenReturn(basic("alice", "WRONG")); + StringWriter out = new StringWriter(); + HttpServletResponse r = resp(out); + User alice = mock(User.class); + when(alice.checkPassword("WRONG")).thenReturn(false); + try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> { + doNothing().when(m).beginDBTransaction(); + doNothing().when(m).setAction(anyString()); + doNothing().when(m).rollbackAndClose(); + when(m.getUser("alice")).thenReturn(alice); + })) { + new AuthToken().doPostForTest(req, r); + } + verify(r).setStatus(401); + } + + @Test void correctPassword_mints200WithToken() throws Exception { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Authorization")).thenReturn(basic("alice", "right")); + StringWriter out = new StringWriter(); + HttpServletResponse r = resp(out); + User alice = mock(User.class); + when(alice.checkPassword("right")).thenReturn(true); + when(alice.getId()).thenReturn("uuid-alice"); + org.ecocean.api.auth.JwtService jwt = mock(org.ecocean.api.auth.JwtService.class); + when(jwt.isEnabled()).thenReturn(true); + when(jwt.sign(anyString(), anyString(), anyLong())).thenReturn("signed-jwt"); + try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> { + doNothing().when(m).beginDBTransaction(); + doNothing().when(m).setAction(anyString()); + doNothing().when(m).rollbackAndClose(); + when(m.getUser("alice")).thenReturn(alice); + }); + org.mockito.MockedStatic js = + mockStatic(org.ecocean.api.auth.JwtService.class)) { + js.when(() -> org.ecocean.api.auth.JwtService.fromConfig(anyString())).thenReturn(jwt); + new AuthToken().doPostForTest(req, r); + } + verify(r).setStatus(200); + verify(r).setHeader("Cache-Control", "no-store"); + String body = out.toString(); + assertTrue(body.contains("signed-jwt"), "response carries the minted token"); + assertTrue(body.contains("expiresInSeconds"), "response carries expiry"); + } +} diff --git a/src/test/java/org/ecocean/api/AuthTokenTest.java b/src/test/java/org/ecocean/api/AuthTokenTest.java index b944e5629f..3626ef0d5c 100644 --- a/src/test/java/org/ecocean/api/AuthTokenTest.java +++ b/src/test/java/org/ecocean/api/AuthTokenTest.java @@ -2,11 +2,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; +import java.util.Base64; import java.util.concurrent.atomic.AtomicReference; import javax.servlet.http.HttpServletRequest; @@ -40,19 +42,23 @@ void setUp() throws IOException { when(mockRequest.getContextPath()).thenReturn(""); } + private String basic(String u, String p) { + return "Basic " + Base64.getEncoder().encodeToString((u + ":" + p).getBytes()); + } + /** - * Primary protection gate: unauthenticated caller (getUser returns null) → 401. - * This is the most important unit-testable gate: the endpoint must reject requests - * where Shiro passed but no User principal was resolved. + * Primary protection gate: no Basic header (session-only) → 401. + * The new step-up enforcement rejects any request that does not carry a fresh + * Basic credential, regardless of Shiro session state. */ @Test void unauthenticated_returns401() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn(null); try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class, (mock, ctx) -> { doNothing().when(mock).beginDBTransaction(); doNothing().when(mock).setAction(anyString()); doNothing().when(mock).rollbackAndClose(); - when(mock.getUser(any(HttpServletRequest.class))).thenReturn(null); })) { AuthToken servlet = new AuthToken(); servlet.doPost(mockRequest, mockResponse); @@ -63,7 +69,6 @@ void unauthenticated_returns401() throws Exception { assertFalse(body.isEmpty(), "response body must not be empty"); JSONObject json = new JSONObject(body); assertFalse(json.optBoolean("success", true), "success must be false"); - assertEquals("unauthenticated", json.optString("error"), "error message"); } } @@ -73,15 +78,21 @@ void unauthenticated_returns401() throws Exception { * A caller supplying context=evilcontext must not shift user lookup or key config * to another context. We capture the first constructor arg via MockedConstruction * and assert it equals "context0". - * The request is also unauthenticated (getUser→null) so we get a 401, confirming - * the servlet completed the pinned construction path before returning. + * The request carries a valid Basic credential whose password check returns true, + * so execution reaches the Shepherd construction path before JWT check. */ @Test void requestContextParamIgnored() throws Exception { // Make the request look like it carries a context override when(mockRequest.getParameter("context")).thenReturn("evilcontext"); + // Supply a valid Basic credential so execution reaches Shepherd construction + when(mockRequest.getHeader("Authorization")).thenReturn(basic("alice", "right")); AtomicReference capturedCtorArg = new AtomicReference<>(); + User alice = mock(User.class); + when(alice.checkPassword("right")).thenReturn(true); + when(alice.getId()).thenReturn("uuid-alice"); + when(alice.getUsername()).thenReturn("alice"); try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class, (mock, ctx) -> { @@ -92,8 +103,7 @@ void requestContextParamIgnored() throws Exception { doNothing().when(mock).beginDBTransaction(); doNothing().when(mock).setAction(anyString()); doNothing().when(mock).rollbackAndClose(); - // unauthenticated → servlet returns 401 without attempting to sign - when(mock.getUser(any(HttpServletRequest.class))).thenReturn(null); + when(mock.getUser("alice")).thenReturn(alice); })) { AuthToken servlet = new AuthToken(); servlet.doPost(mockRequest, mockResponse); @@ -102,19 +112,23 @@ void requestContextParamIgnored() throws Exception { // Shepherd must have been constructed with the pinned context, not "evilcontext" assertEquals("context0", capturedCtorArg.get(), "Shepherd must be constructed with pinned context0, not a request-derived value"); - // Unauthenticated → 401 (confirms the code path ran fully through construction) - verify(mockResponse).setStatus(401); + // Credential accepted → not 401 (confirms the code path ran through Shepherd construction) + verify(mockResponse, never()).setStatus(401); } } /** * JwtService disabled (no private key configured) → 503. - * Exercises the second gate after successful auth. + * Exercises the second gate after successful credential verification. + * Must supply a valid Basic credential so execution reaches the JWT logic. */ @Test void jwtDisabled_returns503() throws Exception { - User fakeUser = new User(); - fakeUser.setUsername("test-user"); + when(mockRequest.getHeader("Authorization")).thenReturn(basic("alice", "right")); + User alice = mock(User.class); + when(alice.checkPassword("right")).thenReturn(true); + when(alice.getId()).thenReturn("uuid-alice"); + when(alice.getUsername()).thenReturn("alice"); try (MockedStatic mockConfig = mockStatic(org.ecocean.CommonConfiguration.class); @@ -123,7 +137,7 @@ void jwtDisabled_returns503() throws Exception { doNothing().when(mock).beginDBTransaction(); doNothing().when(mock).setAction(anyString()); doNothing().when(mock).rollbackAndClose(); - when(mock.getUser(any(HttpServletRequest.class))).thenReturn(fakeUser); + when(mock.getUser("alice")).thenReturn(alice); })) { // All CommonConfiguration.getProperty calls return null → JwtService disabled mockConfig.when(() -> org.ecocean.CommonConfiguration.getProperty( diff --git a/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java b/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java index 3bd77f268d..7c9455ad68 100644 --- a/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java +++ b/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java @@ -235,4 +235,34 @@ void mediaResolve_shiroRuleIsTokenFilterOnly() { assertEquals("tokenAuthSearch", value, "media path must map to tokenAuthSearch ONLY (no authc/roles chained); was: '" + value + "'"); } + + // ----------------------------------------------------------------------- + // Assertions — /api/v3/agent-skill wiring + // ----------------------------------------------------------------------- + + @Test + void agentSkill_servletClassIsRegistered() { + assertTrue(fullText().contains("org.ecocean.api.AgentSkill"), + "web.xml must register the AgentSkill servlet"); + } + + @Test + void agentSkill_urlPatternIsRegistered() { + assertTrue(fullText().contains("/api/v3/agent-skill"), + "web.xml must map /api/v3/agent-skill"); + } + + @Test + void agentSkill_shiroRuleIsAnon() { + String ruleLine = lines.stream() + .filter(l -> { String t = l.stripLeading(); + return !t.startsWith("#") && t.contains("/api/v3/agent-skill"); }) + .findFirst().orElse(null); + assertNotNull(ruleLine, "Shiro [urls] must contain a rule for /api/v3/agent-skill"); + String value = ruleLine.substring( + ruleLine.indexOf("/api/v3/agent-skill") + "/api/v3/agent-skill".length()).trim(); + if (value.startsWith("=")) value = value.substring(1).trim(); + assertEquals("anon", value, + "the agent-skill doc must be anon (a how-to-auth doc can't require auth); was: '" + value + "'"); + } }