From 453957068c26f67d0097d2986cfad8ff299e047b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 24 Apr 2026 09:02:53 -0700 Subject: [PATCH 01/13] feat(client): inject bearer token into StacApiProvider and Api.fetch --- packages/client/src/api/index.ts | 30 ++++++++++++++++++++++++++---- packages/client/src/main.tsx | 31 +++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/client/src/api/index.ts b/packages/client/src/api/index.ts index b587241..7205b8a 100644 --- a/packages/client/src/api/index.ts +++ b/packages/client/src/api/index.ts @@ -1,8 +1,32 @@ import { GenericObject, ApiError } from '../types'; +let authToken: string | undefined; + +export function setApiAuthToken(token: string | undefined) { + authToken = token; +} + +function isStacApiUrl(url: string): boolean { + const base = process.env.REACT_APP_STAC_API; + return !!base && url.startsWith(base); +} + class Api { - static fetch(url: string, options?: GenericObject) { - return fetch(url, options).then(async (response) => { + static fetch(url: string, options: GenericObject = {}) { + const injected = + authToken && isStacApiUrl(url) + ? { Authorization: `Bearer ${authToken}` } + : {}; + + const finalOptions: GenericObject = { + ...options, + headers: { + ...injected, + ...(options.headers || {}) + } + }; + + return fetch(url, finalOptions).then(async (response) => { if (response.ok) { return response.json(); } @@ -12,8 +36,6 @@ class Api { status, statusText }; - // Some STAC APIs return errors as JSON others as string. - // Clone the response so we can read the body as text if json fails. const clone = response.clone(); try { e.detail = await response.json(); diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx index 7948017..fb1a94d 100644 --- a/packages/client/src/main.tsx +++ b/packages/client/src/main.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter as Router } from 'react-router-dom'; import { ChakraProvider, ColorModeScript } from '@chakra-ui/react'; @@ -8,9 +8,11 @@ import { PluginConfigProvider } from '@stac-manager/data-core'; import { App } from './App'; import theme from './theme/theme'; import { config } from './plugin-system/config'; -import { AuthProvider } from './auth/Context'; +import { AuthProvider, useAuth } from './auth/Context'; +import { setApiAuthToken } from './api'; const publicUrl = process.env.PUBLIC_URL || ''; +const stacApiUrl = process.env.REACT_APP_STAC_API!; let basename: string | undefined; if (publicUrl) { @@ -22,6 +24,27 @@ if (publicUrl) { } } +function StacApiAuthBridge({ children }: { children: React.ReactNode }) { + const { token } = useAuth(); + + useEffect(() => { + setApiAuthToken(token); + return () => setApiAuthToken(undefined); + }, [token]); + + const options = useMemo( + () => + token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, + [token] + ); + + return ( + + {children} + + ); +} + // Root component. function Root() { useEffect(() => { @@ -38,11 +61,11 @@ function Root() { - + - + From f104f072bd03021e2cd63e05a27d9979cb39ea41 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 24 Apr 2026 09:10:43 -0700 Subject: [PATCH 02/13] =?UTF-8?q?refactor(client):=20address=20Task=201=20?= =?UTF-8?q?review=20=E2=80=94=20drop=20cleanup,=20tighten=20URL=20scope,?= =?UTF-8?q?=20restore=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/src/api/index.ts | 6 +++++- packages/client/src/main.tsx | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/client/src/api/index.ts b/packages/client/src/api/index.ts index 7205b8a..2f1ad30 100644 --- a/packages/client/src/api/index.ts +++ b/packages/client/src/api/index.ts @@ -8,7 +8,9 @@ export function setApiAuthToken(token: string | undefined) { function isStacApiUrl(url: string): boolean { const base = process.env.REACT_APP_STAC_API; - return !!base && url.startsWith(base); + if (!base) return false; + const normalized = base.endsWith('/') ? base : `${base}/`; + return url === base || url.startsWith(normalized); } class Api { @@ -36,6 +38,8 @@ class Api { status, statusText }; + // Some STAC APIs return errors as JSON others as string. + // Clone the response so we can read the body as text if json fails. const clone = response.clone(); try { e.detail = await response.json(); diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx index fb1a94d..2d89998 100644 --- a/packages/client/src/main.tsx +++ b/packages/client/src/main.tsx @@ -29,9 +29,11 @@ function StacApiAuthBridge({ children }: { children: React.ReactNode }) { useEffect(() => { setApiAuthToken(token); - return () => setApiAuthToken(undefined); }, [token]); + // Re-creating options on every token change intentionally triggers + // useStacApi's effect to rebuild StacApi (and re-probe the landing page) + // so subsequent requests carry the new token. const options = useMemo( () => token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, From 2a18a7e4ed97f27ec499c96cc4a7d136a272ec16 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 24 Apr 2026 09:18:00 -0700 Subject: [PATCH 03/13] test(api): cover bearer-token injection rules for Api.fetch --- jest-setup.ts | 2 + packages/client/src/api/index.test.ts | 79 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 packages/client/src/api/index.test.ts diff --git a/jest-setup.ts b/jest-setup.ts index 7b0828b..d5360c4 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1 +1,3 @@ import '@testing-library/jest-dom'; + +process.env.REACT_APP_STAC_API = 'https://fake-stac-api.net'; diff --git a/packages/client/src/api/index.test.ts b/packages/client/src/api/index.test.ts new file mode 100644 index 0000000..b54d9c3 --- /dev/null +++ b/packages/client/src/api/index.test.ts @@ -0,0 +1,79 @@ +/** + * @jest-environment node + */ +import Api, { setApiAuthToken } from './index'; + +const STAC_API = process.env.REACT_APP_STAC_API!; + +describe('Api.fetch auth injection', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + setApiAuthToken(undefined); + }); + + it('adds Authorization when token is set and URL is under the STAC API base', async () => { + setApiAuthToken('abc123'); + + await Api.fetch(`${STAC_API}/collections/foo`, { method: 'GET' }); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers).toMatchObject({ Authorization: 'Bearer abc123' }); + }); + + it('omits Authorization when token is absent', async () => { + setApiAuthToken(undefined); + + await Api.fetch(`${STAC_API}/collections/foo`, { method: 'GET' }); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers || {}).not.toHaveProperty('Authorization'); + }); + + it('omits Authorization for URLs outside the STAC API base', async () => { + setApiAuthToken('abc123'); + + await Api.fetch('https://other.example.com/foo', { method: 'GET' }); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers || {}).not.toHaveProperty('Authorization'); + }); + + it('lets caller headers override the injected Authorization', async () => { + setApiAuthToken('abc123'); + + await Api.fetch(`${STAC_API}/collections/foo`, { + method: 'GET', + headers: { Authorization: 'Bearer override' } + }); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers).toMatchObject({ Authorization: 'Bearer override' }); + }); + + it('omits Authorization when REACT_APP_STAC_API is unset', async () => { + const original = process.env.REACT_APP_STAC_API; + delete process.env.REACT_APP_STAC_API; + setApiAuthToken('abc123'); + + try { + await Api.fetch('https://fake-stac-api.net/collections/foo', { + method: 'GET' + }); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers || {}).not.toHaveProperty('Authorization'); + } finally { + process.env.REACT_APP_STAC_API = original; + } + }); +}); From a76a1cb14a9e8d9e4ba2d1e115857af5fdbe6e1b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 24 Apr 2026 09:26:34 -0700 Subject: [PATCH 04/13] refactor(client): pass authed options into local useCollections' StacApi --- .../src/pages/CollectionList/useCollections.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/client/src/pages/CollectionList/useCollections.ts b/packages/client/src/pages/CollectionList/useCollections.ts index 8953d6b..06c767a 100644 --- a/packages/client/src/pages/CollectionList/useCollections.ts +++ b/packages/client/src/pages/CollectionList/useCollections.ts @@ -6,9 +6,11 @@ */ import { useStacApi } from '@developmentseed/stac-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { StacCollection, StacLink } from 'stac-ts'; +import { useAuth } from '../../auth/Context'; + type ApiError = { detail?: { [key: string]: any } | string; status: number; @@ -49,7 +51,17 @@ export function useCollections(opts?: { }): StacCollectionsHook { const { limit = 10, initialOffset = 0 } = opts || {}; - const { stacApi } = useStacApi(process.env.REACT_APP_STAC_API!); + const { token } = useAuth(); + const stacApiOptions = useMemo( + () => + token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, + [token] + ); + + const { stacApi } = useStacApi( + process.env.REACT_APP_STAC_API!, + stacApiOptions + ); const [collections, setCollections] = useState(); const [state, setState] = useState('IDLE'); From 0d4cb03e53c65c63bbbae858ad678ce9b6add79f Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 24 Apr 2026 09:31:07 -0700 Subject: [PATCH 05/13] refactor(client): route collection mutations through Api.fetch auth injection --- packages/client/src/pages/CollectionForm/index.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/client/src/pages/CollectionForm/index.tsx b/packages/client/src/pages/CollectionForm/index.tsx index 613879a..c5e133b 100644 --- a/packages/client/src/pages/CollectionForm/index.tsx +++ b/packages/client/src/pages/CollectionForm/index.tsx @@ -6,7 +6,6 @@ import { useCollection } from '@developmentseed/stac-react'; import { StacCollection } from 'stac-ts'; import Api from '../../api'; -import { useAuth } from '../../auth/Context'; import { EditForm } from './EditForm'; import usePageTitle from '$hooks/usePageTitle'; import { @@ -33,8 +32,6 @@ export function CollectionFormNew() { AppNotification[] | undefined >(); - const { token } = useAuth(); - const onSubmit = async (data: any, formikHelpers: FormikHelpers) => { try { toast.closeAll(); @@ -47,7 +44,7 @@ export function CollectionFormNew() { position: 'bottom-right' }); - await collectionTransaction(token).create(data); + await collectionTransaction().create(data); toast.update('collection-submit', { title: 'Collection created', @@ -81,8 +78,6 @@ export function CollectionFormEdit(props: { id: string }) { const toast = useToast(); - const { token } = useAuth(); - useEffect(() => { if (state === 'LOADING') { setTriedLoading(true); @@ -108,7 +103,7 @@ export function CollectionFormEdit(props: { id: string }) { duration: null, position: 'bottom-right' }); - await collectionTransaction(token).update(id, data); + await collectionTransaction().update(id, data); toast.update('collection-submit', { title: 'Collection updated', @@ -139,7 +134,7 @@ type collectionTransactionType = { create: (data: StacCollection) => Promise; }; -function collectionTransaction(token?: string): collectionTransactionType { +function collectionTransaction(): collectionTransactionType { const createRequest = async ( url: string, method: string, @@ -148,8 +143,7 @@ function collectionTransaction(token?: string): collectionTransactionType { return Api.fetch(url, { method, headers: { - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : undefined + 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); From 88c662dd325fedf59514582610f1daf6981a596a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 27 Apr 2026 17:02:42 -0700 Subject: [PATCH 06/13] Normalize STAC API URL --- packages/client/src/api/index.ts | 10 +++++++--- packages/client/src/pages/CollectionForm/index.tsx | 14 +++----------- packages/client/src/pages/ItemDetail/index.tsx | 3 ++- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/client/src/api/index.ts b/packages/client/src/api/index.ts index 2f1ad30..14d41bf 100644 --- a/packages/client/src/api/index.ts +++ b/packages/client/src/api/index.ts @@ -6,11 +6,15 @@ export function setApiAuthToken(token: string | undefined) { authToken = token; } +// Normalized STAC API base (no trailing slash) so callers can safely +// concatenate paths without producing `/stac//collections`. +export const STAC_API_URL: string | undefined = + process.env.REACT_APP_STAC_API?.replace(/\/+$/, ''); + function isStacApiUrl(url: string): boolean { - const base = process.env.REACT_APP_STAC_API; + const base = process.env.REACT_APP_STAC_API?.replace(/\/+$/, ''); if (!base) return false; - const normalized = base.endsWith('/') ? base : `${base}/`; - return url === base || url.startsWith(normalized); + return url === base || url.startsWith(`${base}/`); } class Api { diff --git a/packages/client/src/pages/CollectionForm/index.tsx b/packages/client/src/pages/CollectionForm/index.tsx index c5e133b..4abebb2 100644 --- a/packages/client/src/pages/CollectionForm/index.tsx +++ b/packages/client/src/pages/CollectionForm/index.tsx @@ -5,7 +5,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { useCollection } from '@developmentseed/stac-react'; import { StacCollection } from 'stac-ts'; -import Api from '../../api'; +import Api, { STAC_API_URL } from '../../api'; import { EditForm } from './EditForm'; import usePageTitle from '$hooks/usePageTitle'; import { @@ -151,16 +151,8 @@ function collectionTransaction(): collectionTransactionType { return { update: (id: string, data: StacCollection) => - createRequest( - `${process.env.REACT_APP_STAC_API}/collections/${id}`, - 'PUT', - data - ), + createRequest(`${STAC_API_URL}/collections/${id}`, 'PUT', data), create: (data: StacCollection) => - createRequest( - `${process.env.REACT_APP_STAC_API}/collections/`, - 'POST', - data - ) + createRequest(`${STAC_API_URL}/collections/`, 'POST', data) }; } diff --git a/packages/client/src/pages/ItemDetail/index.tsx b/packages/client/src/pages/ItemDetail/index.tsx index 8edf975..c9cdd7b 100644 --- a/packages/client/src/pages/ItemDetail/index.tsx +++ b/packages/client/src/pages/ItemDetail/index.tsx @@ -23,6 +23,7 @@ import { } from '@devseed-ui/collecticons-chakra'; import { usePageTitle } from '../../hooks'; +import { STAC_API_URL } from '../../api'; import AssetList from './AssetList'; import { InnerPageHeader } from '$components/InnerPageHeader'; import { StacBrowserMenuItem } from '$components/StacBrowserMenuItem'; @@ -37,7 +38,7 @@ const dateFormat: Intl.DateTimeFormatOptions = { function ItemDetail() { const { collectionId, itemId } = useParams(); usePageTitle(`Item ${itemId}`); - const itemResource = `${process.env.REACT_APP_STAC_API}/collections/${collectionId}/items/${itemId}`; + const itemResource = `${STAC_API_URL}/collections/${collectionId}/items/${itemId}`; const { item, state } = useItem(itemResource); if (!item || state === 'LOADING') { From 059b928f1ef2b28ff0fdb9b9a5c3898e7348dd9c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 15 May 2026 17:30:02 -0700 Subject: [PATCH 07/13] Simplify auth config --- packages/client/src/auth/Context.tsx | 18 +++++--- .../client/src/auth/resolveAuthConfig.test.ts | 44 ------------------- packages/client/src/auth/resolveAuthConfig.ts | 19 -------- 3 files changed, 11 insertions(+), 70 deletions(-) delete mode 100644 packages/client/src/auth/resolveAuthConfig.test.ts delete mode 100644 packages/client/src/auth/resolveAuthConfig.ts diff --git a/packages/client/src/auth/Context.tsx b/packages/client/src/auth/Context.tsx index 8f13e1d..7f5aeb2 100644 --- a/packages/client/src/auth/Context.tsx +++ b/packages/client/src/auth/Context.tsx @@ -5,8 +5,6 @@ import { } from 'react-oidc-context'; import { WebStorageStateStore } from 'oidc-client-ts'; -import { resolveAuthConfig } from './resolveAuthConfig'; - export type AuthProfile = { username?: string; email?: string; @@ -35,10 +33,13 @@ const DisabledContext: AuthContextValue = { const AuthContext = createContext(DisabledContext); -const config = resolveAuthConfig({ - REACT_APP_OIDC_AUTHORITY: process.env.REACT_APP_OIDC_AUTHORITY, - REACT_APP_OIDC_CLIENT_ID: process.env.REACT_APP_OIDC_CLIENT_ID -}); +const config: { authority: string; clientId: string } | undefined = + process.env.REACT_APP_OIDC_AUTHORITY && process.env.REACT_APP_OIDC_CLIENT_ID + ? { + authority: process.env.REACT_APP_OIDC_AUTHORITY, + clientId: process.env.REACT_APP_OIDC_CLIENT_ID + } + : undefined; function EnabledAuthBridge(props: { children: React.ReactNode }) { const oidc = useOidcAuth(); @@ -78,7 +79,10 @@ function EnabledAuthBridge(props: { children: React.ReactNode }) { } export function AuthProvider(props: { children: React.ReactNode }) { - if (!config.isEnabled) { + if (!config) { + console.debug( + 'OIDC config not found, authentication disabled. To enable, set REACT_APP_OIDC_AUTHORITY and REACT_APP_OIDC_CLIENT_ID environment variables.' + ); return ( {props.children} diff --git a/packages/client/src/auth/resolveAuthConfig.test.ts b/packages/client/src/auth/resolveAuthConfig.test.ts deleted file mode 100644 index 61c529d..0000000 --- a/packages/client/src/auth/resolveAuthConfig.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { resolveAuthConfig } from './resolveAuthConfig'; - -describe('resolveAuthConfig', () => { - it('returns disabled when no env vars are set', () => { - expect(resolveAuthConfig({})).toEqual({ isEnabled: false }); - }); - - it('returns enabled when both OIDC vars are set', () => { - const result = resolveAuthConfig({ - REACT_APP_OIDC_AUTHORITY: 'https://idp.example.com', - REACT_APP_OIDC_CLIENT_ID: 'my-app' - }); - expect(result).toEqual({ - isEnabled: true, - authority: 'https://idp.example.com', - clientId: 'my-app' - }); - }); - - it('returns disabled if only authority is set', () => { - expect( - resolveAuthConfig({ - REACT_APP_OIDC_AUTHORITY: 'https://idp.example.com' - }) - ).toEqual({ isEnabled: false }); - }); - - it('returns disabled if only clientId is set', () => { - expect( - resolveAuthConfig({ - REACT_APP_OIDC_CLIENT_ID: 'my-app' - }) - ).toEqual({ isEnabled: false }); - }); - - it('treats empty-string env values as absent', () => { - expect( - resolveAuthConfig({ - REACT_APP_OIDC_AUTHORITY: '', - REACT_APP_OIDC_CLIENT_ID: '' - }) - ).toEqual({ isEnabled: false }); - }); -}); diff --git a/packages/client/src/auth/resolveAuthConfig.ts b/packages/client/src/auth/resolveAuthConfig.ts deleted file mode 100644 index 7d0673d..0000000 --- a/packages/client/src/auth/resolveAuthConfig.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type AuthConfig = - | { isEnabled: false } - | { isEnabled: true; authority: string; clientId: string }; - -type EnvShape = { - REACT_APP_OIDC_AUTHORITY?: string; - REACT_APP_OIDC_CLIENT_ID?: string; -}; - -export function resolveAuthConfig(env: EnvShape): AuthConfig { - const authority = env.REACT_APP_OIDC_AUTHORITY; - const clientId = env.REACT_APP_OIDC_CLIENT_ID; - - if (authority && clientId) { - return { isEnabled: true, authority, clientId }; - } - - return { isEnabled: false }; -} From 1afb98fc7c8d55d5b60d102b07ee64e75bed06a7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 15 May 2026 18:53:53 -0700 Subject: [PATCH 08/13] fix: Defer mounting StacApiProvider until OIDC has resolved. Otherwise useStacApi probes the landing page (and useCollections fetches) once without auth, then again when the token arrives. App.tsx already gates content rendering on isLoading, so no consumer hooks need the context during this window. --- packages/client/src/main.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx index 2d89998..dc71581 100644 --- a/packages/client/src/main.tsx +++ b/packages/client/src/main.tsx @@ -25,21 +25,23 @@ if (publicUrl) { } function StacApiAuthBridge({ children }: { children: React.ReactNode }) { - const { token } = useAuth(); + const { token, isLoading } = useAuth(); useEffect(() => { setApiAuthToken(token); }, [token]); - // Re-creating options on every token change intentionally triggers - // useStacApi's effect to rebuild StacApi (and re-probe the landing page) - // so subsequent requests carry the new token. const options = useMemo( () => token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, [token] ); + // defer mounting StacApiProvider until OIDC has resolved + if (isLoading) { + return <>{children}; + } + return ( {children} From d91abf96f72caa9467277ac596f17b5ee478f7dc Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 19 May 2026 16:39:16 -0700 Subject: [PATCH 09/13] chore: cleanup --- packages/client/src/api/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/client/src/api/index.ts b/packages/client/src/api/index.ts index 14d41bf..2699bc3 100644 --- a/packages/client/src/api/index.ts +++ b/packages/client/src/api/index.ts @@ -12,9 +12,8 @@ export const STAC_API_URL: string | undefined = process.env.REACT_APP_STAC_API?.replace(/\/+$/, ''); function isStacApiUrl(url: string): boolean { - const base = process.env.REACT_APP_STAC_API?.replace(/\/+$/, ''); - if (!base) return false; - return url === base || url.startsWith(`${base}/`); + if (!STAC_API_URL) return false; + return url === STAC_API_URL || url.startsWith(`${STAC_API_URL}/`); } class Api { From 3cce30ce97309c1fcbcd736134a064844c4ad5ba Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 19 May 2026 17:04:57 -0700 Subject: [PATCH 10/13] chore: lint & tests --- packages/client/src/api/index.test.ts | 17 +++++++++++------ packages/client/src/auth/Context.tsx | 1 + 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/client/src/api/index.test.ts b/packages/client/src/api/index.test.ts index b54d9c3..50b8842 100644 --- a/packages/client/src/api/index.test.ts +++ b/packages/client/src/api/index.test.ts @@ -63,15 +63,20 @@ describe('Api.fetch auth injection', () => { it('omits Authorization when REACT_APP_STAC_API is unset', async () => { const original = process.env.REACT_APP_STAC_API; delete process.env.REACT_APP_STAC_API; - setApiAuthToken('abc123'); try { - await Api.fetch('https://fake-stac-api.net/collections/foo', { - method: 'GET' - }); + await jest.isolateModulesAsync(async () => { + const freshModule = await import('./index'); + freshModule.setApiAuthToken('abc123'); - const init = fetchSpy.mock.calls[0][1] as RequestInit; - expect(init.headers || {}).not.toHaveProperty('Authorization'); + await freshModule.default.fetch( + 'https://fake-stac-api.net/collections/foo', + { method: 'GET' } + ); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers || {}).not.toHaveProperty('Authorization'); + }); } finally { process.env.REACT_APP_STAC_API = original; } diff --git a/packages/client/src/auth/Context.tsx b/packages/client/src/auth/Context.tsx index 7f5aeb2..ec5a9dd 100644 --- a/packages/client/src/auth/Context.tsx +++ b/packages/client/src/auth/Context.tsx @@ -80,6 +80,7 @@ function EnabledAuthBridge(props: { children: React.ReactNode }) { export function AuthProvider(props: { children: React.ReactNode }) { if (!config) { + // eslint-disable-next-line no-console console.debug( 'OIDC config not found, authentication disabled. To enable, set REACT_APP_OIDC_AUTHORITY and REACT_APP_OIDC_CLIENT_ID environment variables.' ); From cfcea5c283b73cb5e59c74f24332ab941bcb40dc Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 19 May 2026 17:05:10 -0700 Subject: [PATCH 11/13] chore: bump node types --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index cab1466..59f72f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", "@types/jest": "^29.5.14", - "@types/node": "^22.10.2", + "@types/node": "^22.19.19", "@types/rollup-plugin-peer-deps-external": "^2.2.5", "@types/testing-library__jest-dom": "^5.14.9", "babel-jest": "^29.7.0", @@ -6281,12 +6281,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", - "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -19443,9 +19443,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/union-value": { diff --git a/package.json b/package.json index 5ae6dc6..0ed0eee 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", "@types/jest": "^29.5.14", - "@types/node": "^22.10.2", + "@types/node": "^22.19.19", "@types/rollup-plugin-peer-deps-external": "^2.2.5", "@types/testing-library__jest-dom": "^5.14.9", "babel-jest": "^29.7.0", From deb0290c6c3767fab9e66a04860422694ffd8af8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 21 May 2026 11:13:04 -0700 Subject: [PATCH 12/13] refactor: update Api class to use constructor for auth token and base URL --- packages/client/src/api/index.test.ts | 59 +++++++++---------- packages/client/src/api/index.ts | 45 +++++++++----- packages/client/src/main.tsx | 24 ++++---- .../client/src/pages/CollectionForm/index.tsx | 12 ++-- 4 files changed, 77 insertions(+), 63 deletions(-) diff --git a/packages/client/src/api/index.test.ts b/packages/client/src/api/index.test.ts index 50b8842..dc4b22f 100644 --- a/packages/client/src/api/index.test.ts +++ b/packages/client/src/api/index.test.ts @@ -1,9 +1,9 @@ /** * @jest-environment node */ -import Api, { setApiAuthToken } from './index'; +import Api from './index'; -const STAC_API = process.env.REACT_APP_STAC_API!; +const STAC_BASE = 'https://stac.example.com'; describe('Api.fetch auth injection', () => { let fetchSpy: jest.SpyInstance; @@ -18,40 +18,39 @@ describe('Api.fetch auth injection', () => { afterEach(() => { fetchSpy.mockRestore(); - setApiAuthToken(undefined); }); it('adds Authorization when token is set and URL is under the STAC API base', async () => { - setApiAuthToken('abc123'); + const api = new Api('abc123', STAC_BASE); - await Api.fetch(`${STAC_API}/collections/foo`, { method: 'GET' }); + await api.fetch(`${STAC_BASE}/collections/foo`, { method: 'GET' }); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.headers).toMatchObject({ Authorization: 'Bearer abc123' }); }); it('omits Authorization when token is absent', async () => { - setApiAuthToken(undefined); + const api = new Api(undefined, STAC_BASE); - await Api.fetch(`${STAC_API}/collections/foo`, { method: 'GET' }); + await api.fetch(`${STAC_BASE}/collections/foo`, { method: 'GET' }); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.headers || {}).not.toHaveProperty('Authorization'); }); it('omits Authorization for URLs outside the STAC API base', async () => { - setApiAuthToken('abc123'); + const api = new Api('abc123', STAC_BASE); - await Api.fetch('https://other.example.com/foo', { method: 'GET' }); + await api.fetch('https://other.example.com/foo', { method: 'GET' }); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.headers || {}).not.toHaveProperty('Authorization'); }); it('lets caller headers override the injected Authorization', async () => { - setApiAuthToken('abc123'); + const api = new Api('abc123', STAC_BASE); - await Api.fetch(`${STAC_API}/collections/foo`, { + await api.fetch(`${STAC_BASE}/collections/foo`, { method: 'GET', headers: { Authorization: 'Bearer override' } }); @@ -60,25 +59,23 @@ describe('Api.fetch auth injection', () => { expect(init.headers).toMatchObject({ Authorization: 'Bearer override' }); }); - it('omits Authorization when REACT_APP_STAC_API is unset', async () => { - const original = process.env.REACT_APP_STAC_API; - delete process.env.REACT_APP_STAC_API; - - try { - await jest.isolateModulesAsync(async () => { - const freshModule = await import('./index'); - freshModule.setApiAuthToken('abc123'); - - await freshModule.default.fetch( - 'https://fake-stac-api.net/collections/foo', - { method: 'GET' } - ); - - const init = fetchSpy.mock.calls[0][1] as RequestInit; - expect(init.headers || {}).not.toHaveProperty('Authorization'); - }); - } finally { - process.env.REACT_APP_STAC_API = original; - } + it('omits Authorization when no STAC base URL is configured', async () => { + const api = new Api('abc123', undefined); + + await api.fetch(`${STAC_BASE}/collections/foo`, { method: 'GET' }); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers || {}).not.toHaveProperty('Authorization'); + }); + + it('does not match sibling paths (e.g. /stac-admin against /stac)', async () => { + const api = new Api('abc123', 'https://stac.example.com/stac'); + + await api.fetch('https://stac.example.com/stac-admin/collections', { + method: 'GET' + }); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.headers || {}).not.toHaveProperty('Authorization'); }); }); diff --git a/packages/client/src/api/index.ts b/packages/client/src/api/index.ts index 2699bc3..2b11d0d 100644 --- a/packages/client/src/api/index.ts +++ b/packages/client/src/api/index.ts @@ -1,26 +1,33 @@ -import { GenericObject, ApiError } from '../types'; - -let authToken: string | undefined; +import { createContext, useContext } from 'react'; -export function setApiAuthToken(token: string | undefined) { - authToken = token; -} +import { GenericObject, ApiError } from '../types'; // Normalized STAC API base (no trailing slash) so callers can safely // concatenate paths without producing `/stac//collections`. export const STAC_API_URL: string | undefined = process.env.REACT_APP_STAC_API?.replace(/\/+$/, ''); -function isStacApiUrl(url: string): boolean { - if (!STAC_API_URL) return false; - return url === STAC_API_URL || url.startsWith(`${STAC_API_URL}/`); -} - class Api { - static fetch(url: string, options: GenericObject = {}) { + private token: string | undefined; + private stacBaseUrl: string | undefined; + + constructor( + token: string | undefined, + stacBaseUrl: string | undefined = STAC_API_URL + ) { + this.token = token; + this.stacBaseUrl = stacBaseUrl; + } + + private isStacUrl(url: string): boolean { + if (!this.stacBaseUrl) return false; + return url === this.stacBaseUrl || url.startsWith(`${this.stacBaseUrl}/`); + } + + fetch(url: string, options: GenericObject = {}) { const injected = - authToken && isStacApiUrl(url) - ? { Authorization: `Bearer ${authToken}` } + this.token && this.isStacUrl(url) + ? { Authorization: `Bearer ${this.token}` } : {}; const finalOptions: GenericObject = { @@ -55,4 +62,14 @@ class Api { } } +export const ApiContext = createContext(null); + +export function useApi(): Api { + const api = useContext(ApiContext); + if (!api) { + throw new Error('useApi must be used within an ApiContext.Provider'); + } + return api; +} + export default Api; diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx index dc71581..6d51fb2 100644 --- a/packages/client/src/main.tsx +++ b/packages/client/src/main.tsx @@ -9,7 +9,7 @@ import { App } from './App'; import theme from './theme/theme'; import { config } from './plugin-system/config'; import { AuthProvider, useAuth } from './auth/Context'; -import { setApiAuthToken } from './api'; +import Api, { ApiContext } from './api'; const publicUrl = process.env.PUBLIC_URL || ''; const stacApiUrl = process.env.REACT_APP_STAC_API!; @@ -27,25 +27,23 @@ if (publicUrl) { function StacApiAuthBridge({ children }: { children: React.ReactNode }) { const { token, isLoading } = useAuth(); - useEffect(() => { - setApiAuthToken(token); - }, [token]); - + const api = useMemo(() => new Api(token), [token]); const options = useMemo( () => token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, [token] ); - // defer mounting StacApiProvider until OIDC has resolved - if (isLoading) { - return <>{children}; - } - return ( - - {children} - + + {isLoading ? ( + children + ) : ( + + {children} + + )} + ); } diff --git a/packages/client/src/pages/CollectionForm/index.tsx b/packages/client/src/pages/CollectionForm/index.tsx index 4abebb2..d72a37f 100644 --- a/packages/client/src/pages/CollectionForm/index.tsx +++ b/packages/client/src/pages/CollectionForm/index.tsx @@ -5,7 +5,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { useCollection } from '@developmentseed/stac-react'; import { StacCollection } from 'stac-ts'; -import Api, { STAC_API_URL } from '../../api'; +import Api, { STAC_API_URL, useApi } from '../../api'; import { EditForm } from './EditForm'; import usePageTitle from '$hooks/usePageTitle'; import { @@ -28,6 +28,7 @@ export function CollectionFormNew() { const toast = useToast(); const navigate = useNavigate(); + const api = useApi(); const [notifications, setNotifications] = useState< AppNotification[] | undefined >(); @@ -44,7 +45,7 @@ export function CollectionFormNew() { position: 'bottom-right' }); - await collectionTransaction().create(data); + await collectionTransaction(api).create(data); toast.update('collection-submit', { title: 'Collection created', @@ -67,6 +68,7 @@ export function CollectionFormNew() { export function CollectionFormEdit(props: { id: string }) { const { id } = props; const { collection, state, error } = useCollection(id); + const api = useApi(); const [triedLoading, setTriedLoading] = useState(!!collection); const [notifications, setNotifications] = useState< AppNotification[] | undefined @@ -103,7 +105,7 @@ export function CollectionFormEdit(props: { id: string }) { duration: null, position: 'bottom-right' }); - await collectionTransaction().update(id, data); + await collectionTransaction(api).update(id, data); toast.update('collection-submit', { title: 'Collection updated', @@ -134,13 +136,13 @@ type collectionTransactionType = { create: (data: StacCollection) => Promise; }; -function collectionTransaction(): collectionTransactionType { +function collectionTransaction(api: Api): collectionTransactionType { const createRequest = async ( url: string, method: string, data: StacCollection ) => { - return Api.fetch(url, { + return api.fetch(url, { method, headers: { 'Content-Type': 'application/json' From f9a73e5e3af116dbbbd04bf7c154a7b5c28c12b8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 21 May 2026 11:49:42 -0700 Subject: [PATCH 13/13] docs: add docs --- packages/client/src/api/index.ts | 15 +++++++++++++++ packages/client/src/main.tsx | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/client/src/api/index.ts b/packages/client/src/api/index.ts index 2b11d0d..8de133d 100644 --- a/packages/client/src/api/index.ts +++ b/packages/client/src/api/index.ts @@ -7,6 +7,10 @@ import { GenericObject, ApiError } from '../types'; export const STAC_API_URL: string | undefined = process.env.REACT_APP_STAC_API?.replace(/\/+$/, ''); +// Authed fetcher for direct (non-stac-react) calls to the STAC API — used by +// mutations (PUT/POST/DELETE) and any reads outside the stac-react hook tree. +// Each instance is immutable; the bridge constructs a new one when the token +// changes, which propagates to consumers via ApiContext. class Api { private token: string | undefined; private stacBaseUrl: string | undefined; @@ -19,6 +23,10 @@ class Api { this.stacBaseUrl = stacBaseUrl; } + // Scope guard: only attach Authorization for URLs under the configured STAC + // base. Prevents leaking the bearer to OIDC discovery, static assets, or + // unrelated third-party origins. Path-boundary check ensures `/stac-admin` + // does not match a `/stac` base. private isStacUrl(url: string): boolean { if (!this.stacBaseUrl) return false; return url === this.stacBaseUrl || url.startsWith(`${this.stacBaseUrl}/`); @@ -30,6 +38,8 @@ class Api { ? { Authorization: `Bearer ${this.token}` } : {}; + // Caller-provided headers win on collision, so a caller can override the + // injected Authorization (e.g. force an explicit anonymous request). const finalOptions: GenericObject = { ...options, headers: { @@ -62,8 +72,13 @@ class Api { } } +// Context carrying the currently-authed Api instance. The bridge in main.tsx +// is the only producer; consumers read via useApi(). export const ApiContext = createContext(null); +// Hook for components and custom hooks that need to issue direct STAC API +// calls. Throws if used outside the bridge — that's a wiring bug, not a +// runtime condition worth handling gracefully. export function useApi(): Api { const api = useContext(ApiContext); if (!api) { diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx index 6d51fb2..3d6395a 100644 --- a/packages/client/src/main.tsx +++ b/packages/client/src/main.tsx @@ -24,6 +24,17 @@ if (publicUrl) { } } +// Single source of truth for auth-to-API wiring. Reads the current OIDC token +// from useAuth() and feeds it into two consumers: +// 1. ApiContext — for direct Api.fetch calls (mutations, ad-hoc reads). +// 2. StacApiProvider — for stac-react hooks (useCollection, useStacSearch…). +// A token change rebuilds both: a new Api instance via useMemo, and a new +// `options` identity that triggers stac-react's useStacApi effect to rebuild +// its StacApi with fresh headers. +// +// During OIDC load we render children without StacApiProvider so the rest of +// the app can still mount and read from ApiContext; stac-react hooks defer +// their requests until the provider appears. function StacApiAuthBridge({ children }: { children: React.ReactNode }) { const { token, isLoading } = useAuth();