From 1e8376f3627671d16127192a31776cfc9bffad5e Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 15:29:19 -0700 Subject: [PATCH 01/10] Add token-UI + agent-skill design spec & implementation plan (Codex-reviewed) Co-Authored-By: Claude Opus 4.8 --- ...026-06-11-token-ui-and-agent-skill-plan.md | 1058 +++++++++++++++++ ...6-06-11-token-ui-and-agent-skill-design.md | 235 ++++ 2 files changed, 1293 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md create mode 100644 docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md 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. From d1ef6354beeac9cd4e4bfa78a9602e18a3b6992d Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 15:34:26 -0700 Subject: [PATCH 02/10] User: add checkPassword(clearText) for fresh credential verification Co-Authored-By: Claude Opus 4.8 --- src/main/java/org/ecocean/User.java | 17 ++++++++++ .../org/ecocean/UserCheckPasswordTest.java | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/test/java/org/ecocean/UserCheckPasswordTest.java 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/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"); + } +} From 5e2ed4a3a3b7dd2d7a076a509203f834c986dcb6 Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 15:44:10 -0700 Subject: [PATCH 03/10] =?UTF-8?q?AuthToken:=20server-side=20step-up=20?= =?UTF-8?q?=E2=80=94=20require+verify=20fresh=20Basic,=20reject=20session-?= =?UTF-8?q?only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/main/java/org/ecocean/api/AuthToken.java | 61 ++++++++++--- .../org/ecocean/api/AuthTokenStepUpTest.java | 85 +++++++++++++++++++ .../java/org/ecocean/api/AuthTokenTest.java | 44 ++++++---- 3 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 src/test/java/org/ecocean/api/AuthTokenStepUpTest.java 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/test/java/org/ecocean/api/AuthTokenStepUpTest.java b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java new file mode 100644 index 0000000000..78079cfcfc --- /dev/null +++ b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java @@ -0,0 +1,85 @@ +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_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 unit env; otherwise 503. The point is the + // credential was ACCEPTED (not 401) and no-store is set. + verify(r, never()).setStatus(401); + verify(r).setHeader("Cache-Control", "no-store"); + } +} 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( From e72694eb24fd6e56cb12c2e00243f0e024ac7253 Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 15:49:34 -0700 Subject: [PATCH 04/10] agent-skill: add curated agent-skill markdown resource Co-Authored-By: Claude Opus 4.8 --- src/main/resources/agent-skill.md | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/main/resources/agent-skill.md diff --git a/src/main/resources/agent-skill.md b/src/main/resources/agent-skill.md new file mode 100644 index 0000000000..0ec2d2058b --- /dev/null +++ b/src/main/resources/agent-skill.md @@ -0,0 +1,66 @@ +# 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. From f25470e13e610b1eabf93af1ae5af6594d0ff71b Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 15:56:25 -0700 Subject: [PATCH 05/10] agent-skill: serve curated skill at GET /api/v3/agent-skill (anon) + SearchApi allowlist constant + drift-guard Co-Authored-By: Claude Opus 4.8 --- src/main/java/org/ecocean/api/AgentSkill.java | 45 +++++++++++++++ src/main/java/org/ecocean/api/SearchApi.java | 8 ++- src/main/webapp/WEB-INF/web.xml | 10 ++++ .../java/org/ecocean/api/AgentSkillTest.java | 55 +++++++++++++++++++ .../ecocean/api/EndpointAuthWiringTest.java | 30 ++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/ecocean/api/AgentSkill.java create mode 100644 src/test/java/org/ecocean/api/AgentSkillTest.java 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/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/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/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/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 + "'"); + } } From 90f4c74d2cd21e8b538e5432bbeb2dcf5afad0f8 Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 16:09:53 -0700 Subject: [PATCH 06/10] frontend: useMintToken hook (cookie-less step-up Basic mint) Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/models/useMintToken.test.js | 27 +++++++++++++++ frontend/src/models/auth/useMintToken.js | 33 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 frontend/src/__tests__/models/useMintToken.test.js create mode 100644 frontend/src/models/auth/useMintToken.js diff --git a/frontend/src/__tests__/models/useMintToken.test.js b/frontend/src/__tests__/models/useMintToken.test.js new file mode 100644 index 0000000000..e290ad0ad6 --- /dev/null +++ b/frontend/src/__tests__/models/useMintToken.test.js @@ -0,0 +1,27 @@ +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 }); + }); +}); diff --git a/frontend/src/models/auth/useMintToken.js b/frontend/src/models/auth/useMintToken.js new file mode 100644 index 0000000000..2b8dd4d64c --- /dev/null +++ b/frontend/src/models/auth/useMintToken.js @@ -0,0 +1,33 @@ +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 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). +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 }; +} From ca12aa3238e222bc81034e8e0a166673744483cf Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 16:27:59 -0700 Subject: [PATCH 07/10] =?UTF-8?q?frontend:=20ApiAccessPage=20=E2=80=94=20s?= =?UTF-8?q?tep-up=20modal=20+=20one-time=20token=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also adds missing @testing-library/dom peer dependency (required by @testing-library/react@16; was absent, breaking all RTL-based tests). Co-Authored-By: Claude Opus 4.8 --- .../__tests__/pages/ApiAccessPage.test.jsx | 33 +++++ .../src/pages/ApiAccess/ApiAccessPage.jsx | 129 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 frontend/src/__tests__/pages/ApiAccessPage.test.jsx create mode 100644 frontend/src/pages/ApiAccess/ApiAccessPage.jsx 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/pages/ApiAccess/ApiAccessPage.jsx b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx new file mode 100644 index 0000000000..c3d2f5a44c --- /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 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" + /> + +
+ + + + +
+
+
+ ); +} From 555f04b29585a76e8cc30e2e7c519ed59b2b12ac Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 16:47:17 -0700 Subject: [PATCH 08/10] frontend: route /api-access + API Access avatar menu item Co-Authored-By: Claude Opus 4.8 --- frontend/src/AuthenticatedSwitch.jsx | 2 ++ .../components/AvatarApiAccessLink.test.jsx | 27 +++++++++++++++++++ .../header/AvatarAndUserProfile.jsx | 3 +++ 3 files changed, 32 insertions(+) create mode 100644 frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx 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/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 + From d35745f6daa135452c42ff04b48c199eb480e70e Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 11 Jun 2026 17:06:29 -0700 Subject: [PATCH 09/10] =?UTF-8?q?token-ui:=20fold=20final=20review=20?= =?UTF-8?q?=E2=80=94=20UTF-8=20Basic,=20assert=20real=20mint=20200,=20guar?= =?UTF-8?q?d=20username,=20copy=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/models/useMintToken.test.js | 9 +++++++ frontend/src/__tests__/setupTests.js | 5 ++++ frontend/src/models/auth/useMintToken.js | 6 ++++- .../src/pages/ApiAccess/ApiAccessPage.jsx | 6 ++--- .../org/ecocean/api/AuthTokenStepUpTest.java | 26 ++++++++++++------- 5 files changed, 38 insertions(+), 14 deletions(-) diff --git a/frontend/src/__tests__/models/useMintToken.test.js b/frontend/src/__tests__/models/useMintToken.test.js index e290ad0ad6..a97e2fa966 100644 --- a/frontend/src/__tests__/models/useMintToken.test.js +++ b/frontend/src/__tests__/models/useMintToken.test.js @@ -24,4 +24,13 @@ describe("mintToken", () => { 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__/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/models/auth/useMintToken.js b/frontend/src/models/auth/useMintToken.js index 2b8dd4d64c..84aaf68e78 100644 --- a/frontend/src/models/auth/useMintToken.js +++ b/frontend/src/models/auth/useMintToken.js @@ -4,11 +4,15 @@ import { useState, useCallback } from "react"; // 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(`${username}:${password}`), + Authorization: "Basic " + btoa(binary), }, }); let data = null; diff --git a/frontend/src/pages/ApiAccess/ApiAccessPage.jsx b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx index c3d2f5a44c..0564c4b397 100644 --- a/frontend/src/pages/ApiAccess/ApiAccessPage.jsx +++ b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx @@ -51,7 +51,7 @@ export default function ApiAccessPage() {

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

Your agent can learn this API here: {SKILL_URL}{" "} @@ -68,7 +68,7 @@ export default function ApiAccessPage() {

- + {token && (
@@ -118,7 +118,7 @@ export default function ApiAccessPage() { - diff --git a/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java index 78079cfcfc..1467dd0f21 100644 --- a/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java +++ b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java @@ -60,7 +60,7 @@ private String basic(String u, String p) { verify(r).setStatus(401); } - @Test void correctPassword_mints200() throws Exception { + @Test void correctPassword_mints200WithToken() throws Exception { HttpServletRequest req = mock(HttpServletRequest.class); when(req.getHeader("Authorization")).thenReturn(basic("alice", "right")); StringWriter out = new StringWriter(); @@ -68,18 +68,24 @@ private String basic(String u, String p) { User alice = mock(User.class); when(alice.checkPassword("right")).thenReturn(true); when(alice.getId()).thenReturn("uuid-alice"); - when(alice.getUsername()).thenReturn("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); - })) { + 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); } - // 200 only if JWT issuance is configured in the unit env; otherwise 503. The point is the - // credential was ACCEPTED (not 401) and no-store is set. - verify(r, never()).setStatus(401); + 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"); } } From 367ea0f096b15b2e8cd2778b4bab154dfd21b2dc Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Sun, 14 Jun 2026 12:21:55 -0400 Subject: [PATCH 10/10] docs(agent-skill): drop dangling field-reference pointer, add References section The served skill said "See the field reference for full descriptions" but no such document or endpoint exists. Reword to own the inline field list, and add a References section linking the general Wildbook docs (wildbook.docs.wildme.org) while marking this skill authoritative for the token API where the two differ. Co-Authored-By: Claude Opus 4.8 --- src/main/resources/agent-skill.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/resources/agent-skill.md b/src/main/resources/agent-skill.md index 0ec2d2058b..6d962ae248 100644 --- a/src/main/resources/agent-skill.md +++ b/src/main/resources/agent-skill.md @@ -43,7 +43,7 @@ coordinate space. **Fetch `imageUrl`, read its real pixel dimensions, and scale 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: +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`. @@ -64,3 +64,9 @@ Access-control fields exist server-side but are **never** returned. **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.