Skip to content

Commit 12f1864

Browse files
authored
feat: build-time oidc configuration (#380)
I don't think there's too many cases where someone will want to configure the OIDC authority and client ID at "runtime", so for now I'm only supporting the white-label case. E.g. this would enable any client to deploy their own version of **stac-map** pointed at their auth provider. This makes sense to me b/c the OIDC stuff only works if you've configured your client id and redirect URL properly, so it's a deploy-time thing. Closes #373
1 parent c2c78bf commit 12f1864

10 files changed

Lines changed: 224 additions & 917 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ See [deploy.yaml](./.github/workflows/deploy.yaml) for a (drop-dead simple) exam
6262

6363
You can deploy your own customized version of stac-map using environment variables:
6464

65-
| Variable | Description | Default |
66-
| ------------------- | ---------------------------------- | ------------------ |
67-
| `VITE_BASE_PATH` | URL path prefix (e.g., `/my-app/`) | `/stac-map/` |
68-
| `VITE_DEFAULT_HREF` | STAC resource to load on startup | None (shows intro) |
65+
| Variable | Description | Default |
66+
| --------------------- | ---------------------------------- | ------------------ |
67+
| `VITE_BASE_PATH` | URL path prefix (e.g., `/my-app/`) | `/stac-map/` |
68+
| `VITE_DEFAULT_HREF` | STAC resource to load on startup | None (shows intro) |
69+
| `VITE_AUTH_AUTHORITY` | The OIDC authority to use for auth | None |
70+
| `VITE_AUTH_CLIENT_ID` | The OIDC client id to use for auth | None |
6971

7072
Example:
7173

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@
5959
"geotiff-geokeys-to-proj4": "^2024.4.13",
6060
"maplibre-gl": "^5.20.1",
6161
"next-themes": "^0.4.3",
62+
"oidc-client-ts": "^3.5.0",
6263
"react": "^19.2.4",
6364
"react-dom": "^19.2.4",
6465
"react-error-boundary": "^6.1.1",
6566
"react-icons": "^5.6.0",
6667
"react-map-gl": "^8.1.0",
6768
"react-markdown": "^10.1.0",
69+
"react-oidc-context": "^3.3.1",
6870
"shiki": "^4.0.2",
6971
"stac-ts": "^1.0.4",
7072
"stac-wasm": "^0.1.0",
@@ -92,7 +94,6 @@
9294
"typescript": "~6.0.2",
9395
"typescript-eslint": "^8.58.0",
9496
"vite": "^8.0.5",
95-
"vite-plugin-node-polyfills": "^0.26.0",
9697
"vite-plugin-top-level-await": "^1.5.0",
9798
"vite-plugin-wasm": "^3.6.0",
9899
"vitest": "^4.1.2",

src/components/collections/href.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useStore } from "@/store";
22
import type { StacCollections } from "@/types/stac";
3+
import { getAccessToken } from "@/utils/auth";
34
import { getLinkHref } from "@/utils/stac";
45
import {
56
ActionBar,
@@ -40,7 +41,10 @@ export default function CollectionsHref({ href }: { href: string }) {
4041
queryKey: ["stac-collections", searchHref],
4142
queryFn: async ({ pageParam }) => {
4243
if (pageParam) {
43-
return await fetch(pageParam).then((response) => {
44+
const headers: Record<string, string> = {};
45+
const token = getAccessToken();
46+
if (token) headers["Authorization"] = `Bearer ${token}`;
47+
return await fetch(pageParam, { headers }).then((response) => {
4448
if (response.ok) return response.json();
4549
else
4650
throw new Error(

src/components/header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Button, HStack } from "@chakra-ui/react";
22
import { Examples } from "./examples";
33
import HrefInput from "./href-input";
4+
import { authConfig, UserButton } from "./ui/auth";
45
import { ColorModeButton } from "./ui/color-mode";
56
import { ProjectionButton } from "./ui/projection";
67
import { SettingsButton } from "./ui/settings";
@@ -17,6 +18,7 @@ export default function Header() {
1718
<ProjectionButton variant={"surface"} />
1819
<ColorModeButton variant={"surface"} />
1920
<SettingsButton variant={"surface"} />
21+
{authConfig && <UserButton variant={"surface"} />}
2022
</HStack>
2123
);
2224
}

src/components/ui/auth.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { IconButtonProps } from "@chakra-ui/react";
2+
import {
3+
AbsoluteCenter,
4+
Button,
5+
CloseButton,
6+
Dialog,
7+
Heading,
8+
HStack,
9+
IconButton,
10+
Portal,
11+
Spinner,
12+
Stack,
13+
Text,
14+
VStack,
15+
} from "@chakra-ui/react";
16+
import * as React from "react";
17+
import { LuLock, LuUser } from "react-icons/lu";
18+
import { useAuth } from "react-oidc-context";
19+
20+
const authority = import.meta.env.VITE_AUTH_AUTHORITY as string | undefined;
21+
const clientId = import.meta.env.VITE_AUTH_CLIENT_ID as string | undefined;
22+
23+
export const authConfig =
24+
authority && clientId
25+
? {
26+
authority,
27+
client_id: clientId,
28+
redirect_uri:
29+
window.location.origin +
30+
(import.meta.env.BASE_URL ?? "/").replace(/\/$/, "") +
31+
"/",
32+
onSigninCallback: () => {
33+
window.history.replaceState(
34+
{},
35+
document.title,
36+
window.location.pathname + window.location.search
37+
);
38+
},
39+
}
40+
: null;
41+
42+
export function LoginSplash({ children }: { children: React.ReactNode }) {
43+
const { isLoading, isAuthenticated, signinRedirect, error } = useAuth();
44+
45+
if (isLoading) {
46+
return (
47+
<AbsoluteCenter>
48+
<Spinner size="xl" />
49+
</AbsoluteCenter>
50+
);
51+
}
52+
53+
if (!isAuthenticated) {
54+
return (
55+
<AbsoluteCenter>
56+
<VStack gap={6}>
57+
<Heading size="2xl">
58+
<HStack>
59+
stac-map with auth <LuLock />
60+
</HStack>
61+
</Heading>
62+
{error && (
63+
<Text color="fg.error" fontSize="sm">
64+
{error.message}
65+
</Text>
66+
)}
67+
<Button size="lg" onClick={() => signinRedirect()}>
68+
Sign in
69+
</Button>
70+
</VStack>
71+
</AbsoluteCenter>
72+
);
73+
}
74+
75+
return <>{children}</>;
76+
}
77+
78+
interface UserButtonProps extends Omit<IconButtonProps, "aria-label"> {}
79+
80+
export const UserButton = React.forwardRef<HTMLButtonElement, UserButtonProps>(
81+
function UserButton(props, ref) {
82+
const { user, removeUser } = useAuth();
83+
84+
return (
85+
<Dialog.Root>
86+
<Dialog.Trigger asChild>
87+
<IconButton
88+
variant="ghost"
89+
aria-label="User info"
90+
ref={ref}
91+
{...props}
92+
>
93+
<LuUser />
94+
</IconButton>
95+
</Dialog.Trigger>
96+
<Portal>
97+
<Dialog.Backdrop />
98+
<Dialog.Positioner>
99+
<Dialog.Content>
100+
<Dialog.Header>
101+
<Dialog.Title>User Info</Dialog.Title>
102+
</Dialog.Header>
103+
<Dialog.Body>
104+
<Stack gap={2}>
105+
{user?.profile?.name && (
106+
<Text>
107+
<Text as="span" fontWeight="bold">
108+
Name:
109+
</Text>{" "}
110+
{user.profile.name}
111+
</Text>
112+
)}
113+
{user?.profile?.email && (
114+
<Text>
115+
<Text as="span" fontWeight="bold">
116+
Email:
117+
</Text>{" "}
118+
{user.profile.email}
119+
</Text>
120+
)}
121+
</Stack>
122+
</Dialog.Body>
123+
<Dialog.Footer>
124+
<Button
125+
variant="outline"
126+
onClick={() => removeUser()}
127+
colorPalette="red"
128+
>
129+
Sign out
130+
</Button>
131+
</Dialog.Footer>
132+
<Dialog.CloseTrigger asChild>
133+
<CloseButton size="sm" />
134+
</Dialog.CloseTrigger>
135+
</Dialog.Content>
136+
</Dialog.Positioner>
137+
</Portal>
138+
</Dialog.Root>
139+
);
140+
}
141+
);

src/main.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,30 @@ import { QueryClientProvider } from "@tanstack/react-query";
22
import { StrictMode } from "react";
33
import { createRoot } from "react-dom/client";
44
import { MapProvider } from "react-map-gl/maplibre";
5+
import { AuthProvider } from "react-oidc-context";
56
import App from "./app.tsx";
7+
import { authConfig, LoginSplash } from "./components/ui/auth.tsx";
68
import { Provider } from "./components/ui/provider.tsx";
79
import { queryClient } from "./query-client.ts";
810

11+
const inner = (
12+
<QueryClientProvider client={queryClient}>
13+
<MapProvider>
14+
<App />
15+
</MapProvider>
16+
</QueryClientProvider>
17+
);
18+
919
createRoot(document.getElementById("root")!).render(
1020
<StrictMode>
1121
<Provider>
12-
<QueryClientProvider client={queryClient}>
13-
<MapProvider>
14-
<App />
15-
</MapProvider>
16-
</QueryClientProvider>
22+
{authConfig ? (
23+
<AuthProvider {...authConfig}>
24+
<LoginSplash>{inner}</LoginSplash>
25+
</AuthProvider>
26+
) : (
27+
inner
28+
)}
1729
</Provider>
1830
</StrictMode>
1931
);

src/utils/auth.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { User } from "oidc-client-ts";
2+
3+
export function getAccessToken(): string | null {
4+
const authority = import.meta.env.VITE_AUTH_AUTHORITY;
5+
const clientId = import.meta.env.VITE_AUTH_CLIENT_ID;
6+
if (!authority || !clientId) return null;
7+
8+
const oidcStorage = sessionStorage.getItem(
9+
`oidc.user:${authority}:${clientId}`
10+
);
11+
if (oidcStorage) {
12+
const user = User.fromStorageString(oidcStorage);
13+
return user?.access_token ?? null;
14+
}
15+
return null;
16+
}

src/utils/stac.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from "stac-ts";
1010
import type { BBox2D } from "../types/map";
1111
import type { AssetWithAlternates, StacAssets, StacValue } from "../types/stac";
12+
import { getAccessToken } from "./auth";
1213
import { GLOBAL_BBOX, sanitizeBbox } from "./bbox";
1314
import { toAbsoluteUrl } from "./href";
1415

@@ -81,11 +82,13 @@ export async function fetchStac({
8182
method?: "GET" | "POST";
8283
body?: string;
8384
}): Promise<StacValue> {
85+
const headers: Record<string, string> = { Accept: "application/json" };
86+
const token = getAccessToken();
87+
if (token) headers["Authorization"] = `Bearer ${token}`;
88+
8489
return await fetch(href, {
8590
method,
86-
headers: {
87-
Accept: "application/json",
88-
},
91+
headers,
8992
body,
9093
}).then(async (response) => {
9194
if (response.ok) {

vite.config.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import react from "@vitejs/plugin-react";
22
import { defineConfig } from "vite";
3-
import { nodePolyfills } from "vite-plugin-node-polyfills";
43
import topLevelAwait from "vite-plugin-top-level-await";
54
import wasm from "vite-plugin-wasm";
65

@@ -13,12 +12,5 @@ export default defineConfig({
1312
resolve: {
1413
tsconfigPaths: true,
1514
},
16-
plugins: [
17-
react(),
18-
wasm(),
19-
topLevelAwait(),
20-
nodePolyfills({
21-
include: ["buffer"],
22-
}),
23-
],
15+
plugins: [react(), wasm(), topLevelAwait()],
2416
});

0 commit comments

Comments
 (0)