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/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", diff --git a/packages/client/src/api/index.test.ts b/packages/client/src/api/index.test.ts new file mode 100644 index 0000000..dc4b22f --- /dev/null +++ b/packages/client/src/api/index.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment node + */ +import Api from './index'; + +const STAC_BASE = 'https://stac.example.com'; + +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(); + }); + + it('adds Authorization when token is set and URL is under the STAC API base', async () => { + const api = new Api('abc123', STAC_BASE); + + 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 () => { + const api = new Api(undefined, STAC_BASE); + + 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 () => { + const api = new Api('abc123', STAC_BASE); + + 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 () => { + const api = new Api('abc123', STAC_BASE); + + await api.fetch(`${STAC_BASE}/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 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 b587241..8de133d 100644 --- a/packages/client/src/api/index.ts +++ b/packages/client/src/api/index.ts @@ -1,8 +1,54 @@ +import { createContext, useContext } from 'react'; + 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(/\/+$/, ''); + +// 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 { - static fetch(url: string, options?: GenericObject) { - return fetch(url, options).then(async (response) => { + private token: string | undefined; + private stacBaseUrl: string | undefined; + + constructor( + token: string | undefined, + stacBaseUrl: string | undefined = STAC_API_URL + ) { + this.token = token; + 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}/`); + } + + fetch(url: string, options: GenericObject = {}) { + const injected = + this.token && this.isStacUrl(url) + ? { 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: { + ...injected, + ...(options.headers || {}) + } + }; + + return fetch(url, finalOptions).then(async (response) => { if (response.ok) { return response.json(); } @@ -26,4 +72,19 @@ 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) { + throw new Error('useApi must be used within an ApiContext.Provider'); + } + return api; +} + export default Api; diff --git a/packages/client/src/auth/Context.tsx b/packages/client/src/auth/Context.tsx index 8f13e1d..ec5a9dd 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,11 @@ function EnabledAuthBridge(props: { children: React.ReactNode }) { } export function AuthProvider(props: { children: React.ReactNode }) { - if (!config.isEnabled) { + 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.' + ); 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 }; -} diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx index 7948017..3d6395a 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 Api, { ApiContext } 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,40 @@ 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(); + + const api = useMemo(() => new Api(token), [token]); + const options = useMemo( + () => + token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, + [token] + ); + + return ( + + {isLoading ? ( + children + ) : ( + + {children} + + )} + + ); +} + // Root component. function Root() { useEffect(() => { @@ -38,11 +74,11 @@ function Root() { - + - + diff --git a/packages/client/src/pages/CollectionForm/index.tsx b/packages/client/src/pages/CollectionForm/index.tsx index 613879a..d72a37f 100644 --- a/packages/client/src/pages/CollectionForm/index.tsx +++ b/packages/client/src/pages/CollectionForm/index.tsx @@ -5,8 +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 { useAuth } from '../../auth/Context'; +import Api, { STAC_API_URL, useApi } from '../../api'; import { EditForm } from './EditForm'; import usePageTitle from '$hooks/usePageTitle'; import { @@ -29,12 +28,11 @@ export function CollectionFormNew() { const toast = useToast(); const navigate = useNavigate(); + const api = useApi(); const [notifications, setNotifications] = useState< AppNotification[] | undefined >(); - const { token } = useAuth(); - const onSubmit = async (data: any, formikHelpers: FormikHelpers) => { try { toast.closeAll(); @@ -47,7 +45,7 @@ export function CollectionFormNew() { position: 'bottom-right' }); - await collectionTransaction(token).create(data); + await collectionTransaction(api).create(data); toast.update('collection-submit', { title: 'Collection created', @@ -70,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 @@ -81,8 +80,6 @@ export function CollectionFormEdit(props: { id: string }) { const toast = useToast(); - const { token } = useAuth(); - useEffect(() => { if (state === 'LOADING') { setTriedLoading(true); @@ -108,7 +105,7 @@ export function CollectionFormEdit(props: { id: string }) { duration: null, position: 'bottom-right' }); - await collectionTransaction(token).update(id, data); + await collectionTransaction(api).update(id, data); toast.update('collection-submit', { title: 'Collection updated', @@ -139,17 +136,16 @@ type collectionTransactionType = { create: (data: StacCollection) => Promise; }; -function collectionTransaction(token?: string): 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', - Authorization: token ? `Bearer ${token}` : undefined + 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); @@ -157,16 +153,8 @@ function collectionTransaction(token?: string): 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/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'); 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') {