Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
import '@testing-library/jest-dom';

process.env.REACT_APP_STAC_API = 'https://fake-stac-api.net';
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions packages/client/src/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
65 changes: 63 additions & 2 deletions packages/client/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Expand All @@ -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<Api | null>(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;
19 changes: 12 additions & 7 deletions packages/client/src/auth/Context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,10 +33,13 @@ const DisabledContext: AuthContextValue = {

const AuthContext = createContext<AuthContextValue>(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;
Comment thread
alukach marked this conversation as resolved.

function EnabledAuthBridge(props: { children: React.ReactNode }) {
const oidc = useOidcAuth();
Expand Down Expand Up @@ -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 (
<AuthContext.Provider value={DisabledContext}>
{props.children}
Expand Down
44 changes: 0 additions & 44 deletions packages/client/src/auth/resolveAuthConfig.test.ts

This file was deleted.

19 changes: 0 additions & 19 deletions packages/client/src/auth/resolveAuthConfig.ts

This file was deleted.

44 changes: 40 additions & 4 deletions packages/client/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand All @@ -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 (
<ApiContext.Provider value={api}>
{isLoading ? (
children
) : (
<StacApiProvider apiUrl={stacApiUrl} options={options}>
{children}
</StacApiProvider>
)}
</ApiContext.Provider>
);
}

// Root component.
function Root() {
useEffect(() => {
Expand All @@ -38,11 +74,11 @@ function Root() {
<ChakraProvider theme={theme}>
<Router basename={basename}>
<AuthProvider>
<StacApiProvider apiUrl={process.env.REACT_APP_STAC_API!}>
<StacApiAuthBridge>
<PluginConfigProvider config={config}>
<App />
</PluginConfigProvider>
</StacApiProvider>
</StacApiAuthBridge>
</AuthProvider>
</Router>
</ChakraProvider>
Expand Down
Loading
Loading