From f333cb07b9f1252ab1fc2c54c53bc4bf37916b33 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 4 Aug 2026 22:24:11 +0100 Subject: [PATCH] feat: enhance CATMAID authentication with personal API token support --- docs/datasource/catmaid/index.rst | 66 ++++++ docs/datasource/index.rst | 1 + docs/user-guide/skeleton_editing.rst | 13 +- python/neuroglancer/__init__.py | 5 +- python/neuroglancer/catmaid_credentials.py | 95 ++++++++- .../default_credentials_manager.py | 16 +- python/tests/catmaid_credentials_test.py | 119 +++++++++++ src/datasource/catmaid/api.spec.ts | 43 ++-- src/datasource/catmaid/api.ts | 24 +-- .../catmaid/api_credentials.spec.ts | 140 +++++++++++++ .../catmaid/credentials_provider.spec.ts | 183 ++++++++++++++++ .../catmaid/credentials_provider.ts | 195 ++++++++++++++++-- src/datasource/catmaid/frontend.ts | 2 +- 13 files changed, 844 insertions(+), 58 deletions(-) create mode 100644 docs/datasource/catmaid/index.rst create mode 100644 python/tests/catmaid_credentials_test.py create mode 100644 src/datasource/catmaid/api_credentials.spec.ts create mode 100644 src/datasource/catmaid/credentials_provider.spec.ts diff --git a/docs/datasource/catmaid/index.rst b/docs/datasource/catmaid/index.rst new file mode 100644 index 0000000000..f8057e051c --- /dev/null +++ b/docs/datasource/catmaid/index.rst @@ -0,0 +1,66 @@ +.. _catmaid-datasource: + +CATMAID +======= + +The CATMAID data service driver exposes skeletons from a CATMAID project, +including spatially indexed skeleton loading and optional editing. + +URL syntax +---------- + +- :file:`catmaid://https://{host}/{project-id}` +- :file:`catmaid://http://{host}/{project-id}` + +Browser authentication +---------------------- + +Neuroglancer first requests CATMAID's anonymous API token so public projects +continue to load without interaction. If the anonymous account cannot access a +project, Neuroglancer asks for a personal CATMAID API token. + +Open the CATMAID server, use the account menu to obtain an API token, and paste +it into the Neuroglancer prompt. The token is cached for the current browser tab +under the CATMAID base URL, allowing other projects on the same server to reuse +it. A rejected token is removed automatically and requested again. Tokens are +not included in datasource URLs or serialized viewer state. + +Because an API token has the same permissions as its CATMAID account, use HTTPS +for authenticated deployments and do not share the token. + +Python authentication +--------------------- + +Python-hosted viewers obtain credentials on the Python server. Configure a +token before adding CATMAID layers: + +.. code-block:: python + + import neuroglancer + + neuroglancer.set_catmaid_token( + "https://catmaid.example", + "your-api-token", + ) + +Pass ``None`` as the token to remove a configured value. Deployments may +instead set ``CATMAID_CREDENTIALS`` to a JSON object keyed by CATMAID base +URL: + +.. code-block:: shell + + export CATMAID_CREDENTIALS='{"https://catmaid.example": "your-api-token"}' + +Tokens configured with ``set_catmaid_token`` take precedence over the +environment. If neither is present, the Python provider attempts anonymous +access. + +Server requirements +------------------- + +The CATMAID deployment must allow the Neuroglancer origin to make cross-origin +``GET`` and ``POST`` requests and must allow the ``Authorization`` and +``Content-Type`` request headers. Project read and edit access is determined +by the CATMAID account associated with the token. Separately, Neuroglancer only +enables editing when the linked stack metadata sets ``read_only`` to +``false``; see :ref:`skeleton-editing-sources`. diff --git a/docs/datasource/index.rst b/docs/datasource/index.rst index 280477ef18..40033b1eb2 100644 --- a/docs/datasource/index.rst +++ b/docs/datasource/index.rst @@ -127,5 +127,6 @@ Data services :maxdepth: 1 boss/index + catmaid/index dvid/index render/index diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index 298060dd7e..385b129d14 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -15,7 +15,10 @@ CATMAID documentation to set up a CATMAID server. At minimum you will need: - CATMAID ``2026.05.06.dev11+g...`` or later by git-describe semantics. - A CATMAID project - A linked project stack -- ``AnonymousUser`` permissions to read and edit the data on that project +- CATMAID read permissions for anonymous access or for the account associated + with a personal API token +- CATMAID edit permissions for that account when editing is enabled +- Cross-origin access for the Neuroglancer origin and authorization headers - Skeletons initialised for that project The project stack dimensions and resolution are used to inform the bounding box @@ -56,7 +59,13 @@ If ``spatial`` is absent or empty, Neuroglancer derives a default chunk size from the CATMAID project-space bounds and uses ``limit: 0`` for the generated spatial level. -After setting this up, enter ``catmaid:/`` as a data source in neuroglancer. +After setting this up, enter +``catmaid:/`` as a data +source in Neuroglancer. Public projects use CATMAID's anonymous API token. +Private projects prompt for a personal API token, which is retained for the +current browser tab. Python-hosted viewers can configure the token with +``neuroglancer.set_catmaid_token`` or ``CATMAID_CREDENTIALS``. See +:ref:`catmaid-datasource` for authentication and CORS details. .. _skeleton-editing-subsources: diff --git a/python/neuroglancer/__init__.py b/python/neuroglancer/__init__.py index 4d8d493a6b..a7a9998c0d 100644 --- a/python/neuroglancer/__init__.py +++ b/python/neuroglancer/__init__.py @@ -18,7 +18,10 @@ server, # noqa: F401 skeleton, # noqa: F401 ) -from .default_credentials_manager import set_boss_token # noqa: F401 +from .default_credentials_manager import ( # noqa: F401 + set_boss_token, + set_catmaid_token, +) from .equivalence_map import EquivalenceMap # noqa: F401 from .local_volume import LocalVolume # noqa: F401 from .screenshot import ScreenshotSaver # noqa: F401 diff --git a/python/neuroglancer/catmaid_credentials.py b/python/neuroglancer/catmaid_credentials.py index d3a089a73b..599471f9d0 100644 --- a/python/neuroglancer/catmaid_credentials.py +++ b/python/neuroglancer/catmaid_credentials.py @@ -13,26 +13,115 @@ # limitations under the License. import json +import os +import threading import urllib.request from . import credentials_provider from .futures import run_on_new_thread +_configured_tokens = {} +_providers = {} +_providers_lock = threading.Lock() -class CatmaidAnonymousCredentialsProvider(credentials_provider.CredentialsProvider): + +def canonicalize_server_url(server_url): + if not isinstance(server_url, str) or not server_url: + raise ValueError("CATMAID server URL must be a non-empty string") + return server_url.rstrip("/") + + +def set_token(server_url, token): + """Configure or remove a personal API token for one CATMAID server.""" + server_url = canonicalize_server_url(server_url) + with _providers_lock: + if token is None: + _configured_tokens.pop(server_url, None) + return + if not isinstance(token, str) or not token.strip(): + raise ValueError("CATMAID API token must be a non-empty string or None") + _configured_tokens[server_url] = token.strip() + + +def _get_environment_tokens(): + value = os.environ.get("CATMAID_CREDENTIALS") + if not value: + return {} + try: + parsed = json.loads(value) + except json.JSONDecodeError as error: + raise RuntimeError( + "CATMAID_CREDENTIALS must be a JSON object mapping server URLs to tokens" + ) from error + if not isinstance(parsed, dict) or any( + not isinstance(server_url, str) + or not isinstance(token, str) + or not token.strip() + for server_url, token in parsed.items() + ): + raise RuntimeError( + "CATMAID_CREDENTIALS must be a JSON object mapping server URLs to " + "non-empty string tokens" + ) + return { + canonicalize_server_url(server_url): token.strip() + for server_url, token in parsed.items() + } + + +def _get_configured_token(server_url): + with _providers_lock: + token = _configured_tokens.get(server_url) + if token is not None: + return token + return _get_environment_tokens().get(server_url) + + +class CatmaidCredentialsProvider(credentials_provider.CredentialsProvider): def __init__(self, parameters): super().__init__() - self.server_url = (parameters or {}).get("serverUrl", "") + self.server_url = canonicalize_server_url( + (parameters or {}).get("serverUrl", "") + ) + self._last_personal_token = None + self._anonymous_token_was_returned = False def get_new(self): server_url = self.server_url def func(): + personal_token = _get_configured_token(server_url) + if personal_token is not None: + if personal_token == self._last_personal_token: + raise RuntimeError( + f"The configured CATMAID API token for {server_url} was rejected" + ) + self._last_personal_token = personal_token + return {"token": personal_token, "kind": "personal"} + + if self._anonymous_token_was_returned: + raise RuntimeError( + f"CATMAID server {server_url} requires a personal API token; " + "call neuroglancer.set_catmaid_token or configure " + "CATMAID_CREDENTIALS" + ) + token_url = f"{server_url}/accounts/anonymous-api-token" with urllib.request.urlopen(token_url) as response: data = json.loads(response.read().decode()) if not isinstance(data, dict) or not isinstance(data.get("token"), str): raise RuntimeError(f"Unexpected response from {token_url}: {data!r}") - return {"token": data["token"]} + self._anonymous_token_was_returned = True + return {"token": data["token"], "kind": "anonymous"} return run_on_new_thread(func) + + +def get_credentials_provider(parameters): + server_url = canonicalize_server_url((parameters or {}).get("serverUrl", "")) + with _providers_lock: + provider = _providers.get(server_url) + if provider is None: + provider = CatmaidCredentialsProvider({"serverUrl": server_url}) + _providers[server_url] = provider + return provider diff --git a/python/neuroglancer/default_credentials_manager.py b/python/neuroglancer/default_credentials_manager.py index 606f48de37..2056e43ef2 100644 --- a/python/neuroglancer/default_credentials_manager.py +++ b/python/neuroglancer/default_credentials_manager.py @@ -49,9 +49,7 @@ default_credentials_manager.register( "CATMAID", - lambda parameters: catmaid_credentials.CatmaidAnonymousCredentialsProvider( - parameters - ), + lambda parameters: catmaid_credentials.get_credentials_provider(parameters), ) @@ -62,3 +60,15 @@ def set_boss_token(token): credentials """ boss_credentials_provider.set_token(token) + + +def set_catmaid_token(server_url, token): + """Sets or removes a personal API token for a CATMAID server. + + Pass None as the token to remove a previously configured value. Tokens + should be configured before adding CATMAID layers. + + Group: + credentials + """ + catmaid_credentials.set_token(server_url, token) diff --git a/python/tests/catmaid_credentials_test.py b/python/tests/catmaid_credentials_test.py new file mode 100644 index 0000000000..64db540da1 --- /dev/null +++ b/python/tests/catmaid_credentials_test.py @@ -0,0 +1,119 @@ +# @license +# Copyright 2026 Google Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import io +import json +from unittest import mock + +import pytest +from neuroglancer import catmaid_credentials + + +def get_credentials(provider, invalid_generation=None): + return provider.get(invalid_generation).result(timeout=2) + + +def test_runtime_token_precedes_environment(monkeypatch): + server_url = "https://runtime-token.catmaid.example" + monkeypatch.setenv( + "CATMAID_CREDENTIALS", json.dumps({server_url: "environment-token"}) + ) + catmaid_credentials.set_token(server_url + "/", " runtime-token ") + try: + provider = catmaid_credentials.CatmaidCredentialsProvider( + {"serverUrl": server_url} + ) + assert get_credentials(provider)["credentials"] == { + "token": "runtime-token", + "kind": "personal", + } + finally: + catmaid_credentials.set_token(server_url, None) + + +def test_environment_token_is_normalized(monkeypatch): + server_url = "https://environment-token.catmaid.example" + monkeypatch.setenv( + "CATMAID_CREDENTIALS", + json.dumps({server_url + "/": " environment-token "}), + ) + provider = catmaid_credentials.CatmaidCredentialsProvider( + {"serverUrl": server_url + "/"} + ) + + assert get_credentials(provider)["credentials"] == { + "token": "environment-token", + "kind": "personal", + } + + +def test_anonymous_token_is_used_without_configuration(monkeypatch): + server_url = "https://anonymous.catmaid.example" + monkeypatch.delenv("CATMAID_CREDENTIALS", raising=False) + provider = catmaid_credentials.CatmaidCredentialsProvider( + {"serverUrl": server_url + "/"} + ) + response = io.BytesIO(json.dumps({"token": "anonymous-token"}).encode()) + + with mock.patch( + "neuroglancer.catmaid_credentials.urllib.request.urlopen", + return_value=response, + ) as urlopen: + credentials = get_credentials(provider) + + assert credentials["credentials"] == { + "token": "anonymous-token", + "kind": "anonymous", + } + urlopen.assert_called_once_with(server_url + "/accounts/anonymous-api-token") + + with pytest.raises(RuntimeError, match="requires a personal API token"): + get_credentials(provider, credentials["generation"]) + + +def test_rejected_personal_token_requires_reconfiguration(monkeypatch): + server_url = "https://rejected-token.catmaid.example" + monkeypatch.setenv( + "CATMAID_CREDENTIALS", json.dumps({server_url: "rejected-token"}) + ) + provider = catmaid_credentials.CatmaidCredentialsProvider({"serverUrl": server_url}) + credentials = get_credentials(provider) + + with pytest.raises(RuntimeError, match="was rejected"): + get_credentials(provider, credentials["generation"]) + + +def test_malformed_environment_configuration_is_rejected(monkeypatch): + monkeypatch.setenv("CATMAID_CREDENTIALS", "[]") + provider = catmaid_credentials.CatmaidCredentialsProvider( + {"serverUrl": "https://invalid-config.catmaid.example"} + ) + + with pytest.raises(RuntimeError, match="must be a JSON object"): + get_credentials(provider) + + +def test_provider_cache_is_scoped_by_canonical_server_url(): + first = catmaid_credentials.get_credentials_provider( + {"serverUrl": "https://cached.catmaid.example/"} + ) + second = catmaid_credentials.get_credentials_provider( + {"serverUrl": "https://cached.catmaid.example"} + ) + other = catmaid_credentials.get_credentials_provider( + {"serverUrl": "https://other.catmaid.example"} + ) + + assert first is second + assert first is not other diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 29928b55b6..79707f766e 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -863,18 +863,7 @@ describe("CatmaidClient skeleton editing methods", () => { { nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" }, ], }), - ).resolves.toEqual({ - nodeSourceStateUpdates: [ - { - nodeId: 202, - sourceState: testSourceState("2026-03-29T12:08:00Z"), - }, - { - nodeId: 201, - sourceState: testSourceState("2026-03-29T12:08:00Z"), - }, - ], - }); + ).resolves.toEqual({}); expect(fetchMock).toHaveBeenCalledTimes(1); const requestBody = getFetchBody(fetchMock); @@ -954,11 +943,12 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); - it("rejects reroot when the response is missing edition_time", async () => { + it("does not trust reroot response edition_time for revision state", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ newroot: 202, skeleton_id: 17, + edition_time: "2026-03-29T12:08:00Z", }); (client as any).fetchProjectEndpoint = fetchMock; @@ -978,8 +968,33 @@ describe("CatmaidClient skeleton editing methods", () => { { nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" }, ], }), + ).resolves.toEqual({}); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects reroot when CATMAID reports a different new root", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + newroot: 203, + skeleton_id: 17, + edition_time: "2026-03-29T12:08:00Z", + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await expect( + client.rerootSkeleton(202, { + node: { + nodeId: 202, + parentNodeId: 201, + revisionToken: "2026-03-29T12:05:00Z", + }, + parent: { + nodeId: 201, + revisionToken: "2026-03-29T12:04:00Z", + }, + }), ).rejects.toThrow( - "CATMAID skeleton/reroot did not return the new root edition_time.", + "CATMAID skeleton/reroot did not return the requested new root.", ); expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 2de460c0f6..bc01c9b439 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -50,7 +50,8 @@ interface CatmaidStackInfo { } export interface CatmaidToken { - token?: string; + token: string; + kind: "anonymous" | "personal"; } export const credentialsKey = "CATMAID"; @@ -1193,19 +1194,18 @@ function fetchWithCatmaidCredentials( input, init, (credentials: CatmaidToken, init: RequestInit) => { - const newInit: RequestInit = { ...init }; - if (credentials.token) { - newInit.headers = { - ...newInit.headers, - Authorization: `Token ${credentials.token}`, - }; - } - return newInit; + const headers = new Headers(init.headers); + headers.set("Authorization", `Token ${credentials.token}`); + return { ...init, headers }; }, - (error) => { + (error, credentials) => { const { status } = error; - if (status === 403 || status === 401) { - // Authorization needed. Retry with refreshed token. + if ( + status === 401 || + (status === 403 && credentials.kind === "anonymous") + ) { + // Anonymous credentials may not have access to private projects. A + // 401 also indicates that a personal token must be replaced. return "refresh"; } throw error; diff --git a/src/datasource/catmaid/api_credentials.spec.ts b/src/datasource/catmaid/api_credentials.spec.ts new file mode 100644 index 0000000000..1f6f1cbc8b --- /dev/null +++ b/src/datasource/catmaid/api_credentials.spec.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CredentialsProvider, + type CredentialsWithGeneration, +} from "#src/credentials_provider/index.js"; +import { + CatmaidClient, + type CatmaidToken, +} from "#src/datasource/catmaid/api.js"; + +class SequenceCredentialsProvider extends CredentialsProvider { + calls: Array | undefined> = []; + + constructor(private credentials: CatmaidToken[]) { + super(); + } + + get: CredentialsProvider["get"] = async ( + invalidCredentials, + ) => { + this.calls.push(invalidCredentials); + const index = Math.min(this.calls.length - 1, this.credentials.length - 1); + return { + generation: this.calls.length, + credentials: this.credentials[index], + }; + }; +} + +function jsonResponse(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + statusText: status === 200 ? "OK" : "Forbidden", + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("CatmaidClient authenticated requests", () => { + it("retries anonymous 403 responses with refreshed credentials", async () => { + const provider = new SequenceCredentialsProvider([ + { token: "anonymous-token", kind: "anonymous" }, + { token: "personal-token", kind: "personal" }, + ]); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ detail: "forbidden" }, 403)) + .mockResolvedValueOnce(jsonResponse([17])); + vi.stubGlobal("fetch", fetchMock); + const client = new CatmaidClient("https://catmaid.example", 1, provider); + + await expect(client.listSkeletons()).resolves.toEqual([17]); + expect(provider.calls).toHaveLength(2); + expect(provider.calls[1]).toMatchObject({ + credentials: { kind: "anonymous" }, + }); + expect( + (fetchMock.mock.calls[0][1].headers as Headers).get("Authorization"), + ).toBe("Token anonymous-token"); + expect( + (fetchMock.mock.calls[1][1].headers as Headers).get("Authorization"), + ).toBe("Token personal-token"); + }); + + it("does not retry personal-token 403 responses", async () => { + const provider = new SequenceCredentialsProvider([ + { token: "personal-token", kind: "personal" }, + ]); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ detail: "forbidden" }, 403)); + vi.stubGlobal("fetch", fetchMock); + const client = new CatmaidClient("https://catmaid.example", 1, provider); + + await expect(client.listSkeletons()).rejects.toThrow("HTTP error 403"); + expect(provider.calls).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("replaces personal credentials after a 401 response", async () => { + const provider = new SequenceCredentialsProvider([ + { token: "expired-token", kind: "personal" }, + { token: "replacement-token", kind: "personal" }, + ]); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ detail: "invalid" }, 401)) + .mockResolvedValueOnce(jsonResponse([23])); + vi.stubGlobal("fetch", fetchMock); + const client = new CatmaidClient("https://catmaid.example", 1, provider); + + await expect(client.listSkeletons()).resolves.toEqual([23]); + expect(provider.calls).toHaveLength(2); + expect(provider.calls[1]).toMatchObject({ + credentials: { token: "expired-token", kind: "personal" }, + }); + }); + + it("preserves caller and form headers while adding authorization", async () => { + const provider = new SequenceCredentialsProvider([ + { token: "personal-token", kind: "personal" }, + ]); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + const client = new CatmaidClient("https://catmaid.example", 1, provider); + + await (client as any).fetchProjectEndpoint("test", { + method: "POST", + body: new URLSearchParams({ value: "1" }), + headers: { "X-Test": "present" }, + }); + + const headers = fetchMock.mock.calls[0][1].headers as Headers; + expect(headers.get("Authorization")).toBe("Token personal-token"); + expect(headers.get("Content-Type")).toBe( + "application/x-www-form-urlencoded", + ); + expect(headers.get("X-Test")).toBe("present"); + }); +}); diff --git a/src/datasource/catmaid/credentials_provider.spec.ts b/src/datasource/catmaid/credentials_provider.spec.ts new file mode 100644 index 0000000000..98fee9ad9b --- /dev/null +++ b/src/datasource/catmaid/credentials_provider.spec.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + canonicalizeCatmaidServerUrl, + CatmaidCredentialsProvider, + type CatmaidTokenStorage, + getCatmaidTokenStorageKey, +} from "#src/datasource/catmaid/credentials_provider.js"; +import { statusMessages } from "#src/status.js"; + +class MemoryTokenStorage implements CatmaidTokenStorage { + values = new Map(); + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } + + removeItem(key: string) { + this.values.delete(key); + } +} + +function mockAnonymousToken(token = "anonymous-token") { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ token }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +async function submitPersonalToken(token: string) { + await vi.waitFor(() => { + expect( + document.querySelector('input[aria-label="CATMAID API token"]'), + ).not.toBeNull(); + }); + const input = document.querySelector( + 'input[aria-label="CATMAID API token"]', + )!; + input.value = token; + input.form!.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); + for (const status of statusMessages) { + status.dispose(); + } +}); + +describe("CatmaidCredentialsProvider", () => { + it("canonicalizes server URLs and storage keys", () => { + expect(canonicalizeCatmaidServerUrl("https://catmaid.example///")).toBe( + "https://catmaid.example", + ); + expect(getCatmaidTokenStorageKey("https://catmaid.example/")).toContain( + "https://catmaid.example", + ); + }); + + it("uses anonymous credentials first when no personal token is stored", async () => { + const fetchMock = mockAnonymousToken(); + const provider = new CatmaidCredentialsProvider( + "https://catmaid.example/", + new MemoryTokenStorage(), + ); + + await expect(provider.get()).resolves.toMatchObject({ + credentials: { token: "anonymous-token", kind: "anonymous" }, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://catmaid.example/accounts/anonymous-api-token", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("prompts after anonymous rejection and persists the personal token", async () => { + const fetchMock = mockAnonymousToken(); + const storage = new MemoryTokenStorage(); + const provider = new CatmaidCredentialsProvider( + "https://catmaid.example", + storage, + ); + const anonymous = await provider.get(); + + const personalPromise = provider.get(anonymous); + await submitPersonalToken(" personal-token "); + + await expect(personalPromise).resolves.toMatchObject({ + credentials: { token: "personal-token", kind: "personal" }, + }); + expect( + storage.getItem(getCatmaidTokenStorageKey("https://catmaid.example")), + ).toBe("personal-token"); + + const reloadedProvider = new CatmaidCredentialsProvider( + "https://catmaid.example/", + storage, + ); + await expect(reloadedProvider.get()).resolves.toMatchObject({ + credentials: { token: "personal-token", kind: "personal" }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("clears a rejected personal token and validates its replacement", async () => { + const storage = new MemoryTokenStorage(); + storage.setItem( + getCatmaidTokenStorageKey("https://catmaid.example"), + "rejected-token", + ); + const provider = new CatmaidCredentialsProvider( + "https://catmaid.example", + storage, + ); + const rejected = await provider.get(); + + const replacementPromise = provider.get(rejected); + await submitPersonalToken(" "); + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + "non-empty", + ); + await submitPersonalToken("replacement-token"); + + await expect(replacementPromise).resolves.toMatchObject({ + credentials: { token: "replacement-token", kind: "personal" }, + }); + expect( + storage.getItem(getCatmaidTokenStorageKey("https://catmaid.example")), + ).toBe("replacement-token"); + }); + + it("aborts and removes an outstanding token prompt", async () => { + mockAnonymousToken(); + const provider = new CatmaidCredentialsProvider( + "https://catmaid.example", + new MemoryTokenStorage(), + ); + const anonymous = await provider.get(); + const abortController = new AbortController(); + const reason = new Error("cancelled"); + + const personalPromise = provider.get(anonymous, { + signal: abortController.signal, + }); + await vi.waitFor(() => { + expect( + document.querySelector('input[aria-label="CATMAID API token"]'), + ).not.toBeNull(); + }); + abortController.abort(reason); + + await expect(personalPromise).rejects.toBe(reason); + expect( + document.querySelector('input[aria-label="CATMAID API token"]'), + ).toBeNull(); + }); +}); diff --git a/src/datasource/catmaid/credentials_provider.ts b/src/datasource/catmaid/credentials_provider.ts index 15ffeb96d3..e7f50935eb 100644 --- a/src/datasource/catmaid/credentials_provider.ts +++ b/src/datasource/catmaid/credentials_provider.ts @@ -16,13 +16,40 @@ import { CredentialsProvider, - makeCredentialsGetter, + makeCachedCredentialsGetter, } from "#src/credentials_provider/index.js"; -import { getCredentialsWithStatus } from "#src/credentials_provider/interactive_credentials_provider.js"; import type { CatmaidToken } from "#src/datasource/catmaid/api.js"; +import { StatusMessage } from "#src/status.js"; +import { scopedAbortCallback } from "#src/util/abort.js"; import { fetchOk } from "#src/util/http_request.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; +const CATMAID_TOKEN_STORAGE_PREFIX = "neuroglancer:catmaid:api-token:v1:"; + +export interface CatmaidTokenStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export function canonicalizeCatmaidServerUrl(serverUrl: string) { + return serverUrl.replace(/\/+$/, ""); +} + +export function getCatmaidTokenStorageKey(serverUrl: string) { + return `${CATMAID_TOKEN_STORAGE_PREFIX}${canonicalizeCatmaidServerUrl( + serverUrl, + )}`; +} + +function getSessionTokenStorage(): CatmaidTokenStorage | undefined { + try { + return sessionStorage; + } catch { + return undefined; + } +} + async function getAnonymousToken( serverUrl: string, signal: AbortSignal, @@ -42,34 +69,158 @@ async function getAnonymousToken( json !== null && typeof json.token === "string" ) { - return { token: json.token }; + return { token: json.token, kind: "anonymous" }; } throw new Error( `Unexpected response from ${tokenUrl}: ${JSON.stringify(json)}`, ); } +function requestPersonalToken( + serverUrl: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const status = new StatusMessage(/*delay=*/ false, /*modal=*/ true); + status.setPreventFocusChangeOnMouseDown(false); + + const form = document.createElement("form"); + const instructions = document.createElement("p"); + instructions.append( + `A personal CATMAID API token is required for ${serverUrl}. `, + ); + const serverLink = document.createElement("a"); + serverLink.href = serverUrl; + serverLink.target = "_blank"; + serverLink.rel = "noopener noreferrer"; + serverLink.textContent = "Open CATMAID"; + instructions.append( + serverLink, + " and use the account menu to obtain an API token.", + ); + form.appendChild(instructions); + + const label = document.createElement("label"); + label.textContent = "API token: "; + const input = document.createElement("input"); + input.type = "password"; + input.autocomplete = "off"; + input.spellcheck = false; + input.setAttribute("aria-label", "CATMAID API token"); + label.appendChild(input); + form.appendChild(label); + + const submit = document.createElement("button"); + submit.type = "submit"; + submit.textContent = "Use token"; + form.appendChild(submit); + + const error = document.createElement("span"); + error.setAttribute("role", "alert"); + form.appendChild(error); + status.element.replaceChildren(form); + status.setVisible(true); + + const { promise, resolve, reject } = Promise.withResolvers(); + const abortCleanup = scopedAbortCallback(signal, (reason) => { + status.dispose(); + reject(reason); + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + const token = input.value.trim(); + if (token.length === 0) { + error.textContent = " Enter a non-empty API token."; + input.focus(); + return; + } + abortCleanup?.[Symbol.dispose](); + status.dispose(); + resolve(token); + }); + queueMicrotask(() => input.focus()); + return promise; +} + export class CatmaidCredentialsProvider extends CredentialsProvider { - constructor(public serverUrl: string) { + public readonly serverUrl: string; + private generation = 0; + private personalToken: string | undefined; + private readonly storageKey: string; + + constructor( + serverUrl: string, + private tokenStorage: + | CatmaidTokenStorage + | undefined = getSessionTokenStorage(), + ) { super(); + this.serverUrl = canonicalizeCatmaidServerUrl(serverUrl); + this.storageKey = getCatmaidTokenStorageKey(this.serverUrl); } - get = makeCredentialsGetter(async (options) => { - using _span = new ProgressSpan(options.progressListener, { - message: `Requesting CATMAID access token from ${this.serverUrl}`, - }); - return await getCredentialsWithStatus( - { - description: `CATMAID server ${this.serverUrl}`, - supportsImmediate: true, - get: async (signal, immediate) => { - if (immediate) { - return await getAnonymousToken(this.serverUrl, signal); - } - return await getAnonymousToken(this.serverUrl, signal); - }, - }, - options.signal, - ); - }); + private getStoredPersonalToken() { + if (this.personalToken !== undefined) return this.personalToken; + try { + const token = this.tokenStorage?.getItem(this.storageKey)?.trim(); + if (token) { + this.personalToken = token; + return token; + } + } catch { + // Browser storage can be disabled. In-memory credentials still work. + } + return undefined; + } + + private storePersonalToken(token: string) { + this.personalToken = token; + try { + this.tokenStorage?.setItem(this.storageKey, token); + } catch { + // Fall back to the in-memory copy. + } + } + + private clearPersonalToken(token: string) { + if (this.personalToken === token) { + this.personalToken = undefined; + } + try { + if (this.tokenStorage?.getItem(this.storageKey) === token) { + this.tokenStorage.removeItem(this.storageKey); + } + } catch { + // Ignore unavailable browser storage. + } + } + + get = makeCachedCredentialsGetter( + async (invalidCredentials, options) => { + using _span = new ProgressSpan(options.progressListener, { + message: `Requesting CATMAID access token from ${this.serverUrl}`, + }); + + let credentials: CatmaidToken; + if (invalidCredentials === undefined) { + const storedToken = this.getStoredPersonalToken(); + credentials = + storedToken === undefined + ? await getAnonymousToken(this.serverUrl, options.signal) + : { token: storedToken, kind: "personal" }; + } else { + if (invalidCredentials.credentials.kind === "personal") { + this.clearPersonalToken(invalidCredentials.credentials.token); + } + const token = await requestPersonalToken( + this.serverUrl, + options.signal, + ); + this.storePersonalToken(token); + credentials = { token, kind: "personal" }; + } + + return { generation: ++this.generation, credentials }; + }, + ); } diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 8003c43726..ce6fcf189e 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -339,7 +339,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { throw new Error(`Invalid project ID: ${projectIdStr}`); } - let baseUrl = cleanUrl.substring(0, lastSlash); + let baseUrl = cleanUrl.substring(0, lastSlash).replace(/\/+$/, ""); if (!baseUrl.startsWith("http")) { baseUrl = "https://" + baseUrl; }