From 9b4918f80f5f0c4c91c1e51a5ba296fcd6d02b9c Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Tue, 8 Sep 2026 07:54:16 -0400 Subject: [PATCH 01/18] feat(public): add Auth.js infra alongside bento-auth-js Adds a next-auth v5 config (custom OIDC provider against the same identity provider, JWT sessions with manual refresh-token rotation) and its Route Handler, and wires SessionProvider into the app. This lands next to the still-active bento-auth-js so the new plumbing can be reviewed in isolation before consumers are switched over. --- package-lock.json | 95 ++++++++++++++++ package.json | 1 + src/app/api/auth/[...nextauth]/route.ts | 3 + src/js/App.tsx | 41 ++++--- src/js/auth.ts | 139 ++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/js/auth.ts diff --git a/package-lock.json b/package-lock.json index 87cbf491b..4bbb94d37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "bento_public", "version": "1.1.0", + "hasInstallScript": true, "license": "LGPL-3.0-only", "dependencies": { "@ant-design/icons": "^5.6.1", @@ -26,6 +27,7 @@ "i18next-http-backend": "^4.0.1", "leaflet": "^1.9.4", "next": "16.3.1", + "next-auth": "^5.0.0-beta.32", "react": "^19.2.8", "react-dom": "^19.2.8", "react-error-boundary": "^6.1.2", @@ -159,6 +161,35 @@ "react": ">=16.9.0" } }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1540,6 +1571,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgr/core": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", @@ -7477,6 +7517,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -7564,6 +7631,15 @@ "node": ">=18" } }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7910,6 +7986,25 @@ "node": ">= 0.4" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", diff --git a/package.json b/package.json index f51642e71..9f18cd2fc 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "i18next-http-backend": "^4.0.1", "leaflet": "^1.9.4", "next": "16.3.1", + "next-auth": "^5.0.0-beta.32", "react": "^19.2.8", "react-dom": "^19.2.8", "react-error-boundary": "^6.1.2", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..0a98352ff --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth'; + +export const { GET, POST } = handlers; diff --git a/src/js/App.tsx b/src/js/App.tsx index 668574c9e..431105e23 100644 --- a/src/js/App.tsx +++ b/src/js/App.tsx @@ -6,6 +6,7 @@ import { useEffect } from 'react'; // Redux and routing imports import { Provider } from 'react-redux'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { SessionProvider } from 'next-auth/react'; // i18n and constants imports import { useTranslation } from 'react-i18next'; @@ -92,25 +93,29 @@ const InnerRootApp = () => { ); }; +// TODO(authjs-migration): drop once every bento-auth-js consumer has moved to next-auth's useSession()/signIn()/ +// signOut(); SessionProvider (below) is next-auth's equivalent and already covers the whole app. const RootApp = () => ( - - - - - - - - - + + + + + + + + + + + ); export default RootApp; diff --git a/src/js/auth.ts b/src/js/auth.ts new file mode 100644 index 000000000..19dbb11d3 --- /dev/null +++ b/src/js/auth.ts @@ -0,0 +1,139 @@ +import NextAuth from 'next-auth'; +import type { OAuthConfig } from 'next-auth/providers'; +// Explicit import needed so `declare module 'next-auth/jwt'` below resolves under moduleResolution: "bundler" - +// without it, TS reports the augmentation target module as unresolvable even though it's a valid subpath. +import type { JWT } from 'next-auth/jwt'; + +const CLIENT_ID = process.env.CLIENT_ID ?? ''; +const OPENID_CONFIG_URL = process.env.OPENID_CONFIG_URL ?? ''; + +type BentoTokenSet = { + access_token: string; + refresh_token?: string; + id_token: string; + expires_in: number; +}; + +// Bento's identity provider client is registered as a public client (PKCE, no secret) - the same client bento-auth-js +// used to drive its hand-rolled authorization code + PKCE flow from the browser. Auth.js drives that flow itself +// server-side now, so `token_endpoint_auth_method: "none"` tells it not to expect/send a client secret. +const bentoProvider: OAuthConfig> = { + id: 'bento', + name: 'Bento', + type: 'oidc', + wellKnown: OPENID_CONFIG_URL, + clientId: CLIENT_ID, + checks: ['pkce', 'state'], + client: { token_endpoint_auth_method: 'none' }, + authorization: { params: { scope: 'openid email' } }, +}; + +// The discovered token_endpoint is cached the same way bento-auth-js cached OpenID configuration (a multi-hour TTL +// is plenty - these documents change rarely, if ever, for a given deployment). +let cachedTokenEndpoint: { url: string; expiry: number } | undefined; + +const getTokenEndpoint = async (): Promise => { + if (cachedTokenEndpoint && Date.now() < cachedTokenEndpoint.expiry) { + return cachedTokenEndpoint.url; + } + const res = await fetch(OPENID_CONFIG_URL); + if (!res.ok) throw new Error('Could not fetch identity provider configuration'); + const { token_endpoint: url } = (await res.json()) as { token_endpoint: string }; + cachedTokenEndpoint = { url, expiry: Date.now() + 3 * 60 * 60 * 1000 }; + return url; +}; + +const refreshAccessToken = async (refreshToken: string): Promise => { + const tokenEndpoint = await getTokenEndpoint(); + const res = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: refreshToken, + }), + }); + const body = await res.json(); + if (!res.ok) { + const detail = body?.error ? `: ${body.error} - ${body.error_description ?? ''}` : ''; + throw new Error(`Error encountered while refreshing token${detail}`); + } + return body as BentoTokenSet; +}; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + trustHost: true, + providers: [bentoProvider], + session: { strategy: 'jwt' }, + callbacks: { + async jwt({ token, account }): Promise { + if (account) { + // First-time sign-in: seed the token from the just-completed authorization code exchange. + if (!account.access_token || !account.expires_at || !account.id_token) { + throw new TypeError('Missing access_token, id_token, or expires_at from authorization response'); + } + return { + ...token, + access_token: account.access_token, + id_token: account.id_token, + expires_at: account.expires_at, + refresh_token: account.refresh_token, + error: undefined, + }; + } + + if (Date.now() < token.expires_at * 1000) { + // Existing session, access token still valid. + return token; + } + + // Existing session, access token expired - attempt a refresh. + if (!token.refresh_token) { + return { ...token, error: 'RefreshTokenError' as const }; + } + + try { + const refreshed = await refreshAccessToken(token.refresh_token); + return { + ...token, + access_token: refreshed.access_token, + id_token: refreshed.id_token, + expires_at: Math.floor(Date.now() / 1000 + refreshed.expires_in), + // Not every grant necessarily returns a new refresh token - keep the old one if so. + refresh_token: refreshed.refresh_token ?? token.refresh_token, + error: undefined, + }; + } catch (err) { + console.error('Error refreshing access_token', err); + return { ...token, error: 'RefreshTokenError' as const }; + } + }, + async session({ session, token }) { + return { + ...session, + accessToken: token.access_token, + idToken: token.id_token, + error: token.error, + }; + }, + }, +}); + +declare module 'next-auth' { + interface Session { + accessToken?: string; + idToken?: string; + error?: 'RefreshTokenError'; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + access_token: string; + id_token: string; + expires_at: number; + refresh_token?: string; + error?: 'RefreshTokenError'; + } +} From 9382bc1e33f615abb076af87fb3766518d219cd0 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Tue, 8 Sep 2026 08:03:18 -0400 Subject: [PATCH 02/18] feat(public): switch every bento-auth-js consumer over to Auth.js Adds src/js/features/auth/ (resources, permission strings, and a resource-permissions Redux slice, all ported from bento-auth-js since they're Bento's own authorization-service contract, not part of the OIDC library) and repoints every component/hook that used bento-auth-js at it and at next-auth's useSession()/signIn()/signOut(). Session state (access/id tokens) now lives in Auth.js's own session, not Redux/localStorage. Redux thunks that need the access token synchronously (they only have getState(), no hook access) get it via a small mirror kept in sync by useSyncAccessToken(), dispatched once near the app root - the token itself is never persisted to localStorage the way it used to be. Sign-out now also ends the identity provider's own session (RP-initiated logout via end_session_endpoint), matching prior behaviour, via a small session-independent discovery route since next-auth has no built-in support for it. Per the migration plan, the popup-window reauth flow and the localStorage-flag-driven auto-reauth ("was signed in, try silently signing in again") are both dropped rather than ported: Auth.js's persistent session cookie plus refresh-on-read already covers what auto-reauth was for, and popup reauth would mean building custom code back on top of the thing this migration is meant to simplify. bento-auth-js itself is still a listed dependency for now; removing it and the dead code it leaves behind (the token-refresh Web Worker) is the next step. --- src/app/api/auth/end-session-url/route.ts | 14 ++ src/app/public/config.js/route.ts | 4 - src/js/App.tsx | 20 +-- src/js/auth.ts | 32 +++-- .../Beacon/BeaconCommon/BeaconQueryFormUi.tsx | 2 +- src/js/components/BentoAppRouter.tsx | 5 +- src/js/components/SiteHeader.tsx | 8 +- src/js/components/Util/AuthOutlet.tsx | 69 +-------- src/js/components/Util/DownloadButton.tsx | 2 +- src/js/components/Util/FileModal.tsx | 2 +- src/js/config.ts | 8 -- src/js/features/auth/authSlice.ts | 118 +++++++++++++++ src/js/features/auth/hooks.ts | 135 ++++++++++++++++++ src/js/features/auth/permissions.ts | 39 +++++ src/js/features/auth/resources.ts | 65 +++++++++ src/js/features/auth/utils.ts | 2 + src/js/features/metadata/hooks.ts | 7 +- src/js/features/reference/hooks.ts | 2 +- src/js/hooks.ts | 6 +- src/js/hooks/censorship.ts | 2 +- src/js/hooks/useDownloadSelectedMatches.ts | 10 +- src/js/store.ts | 37 +---- src/js/utils/requests.ts | 2 +- 23 files changed, 431 insertions(+), 160 deletions(-) create mode 100644 src/app/api/auth/end-session-url/route.ts create mode 100644 src/js/features/auth/authSlice.ts create mode 100644 src/js/features/auth/hooks.ts create mode 100644 src/js/features/auth/permissions.ts create mode 100644 src/js/features/auth/resources.ts create mode 100644 src/js/features/auth/utils.ts diff --git a/src/app/api/auth/end-session-url/route.ts b/src/app/api/auth/end-session-url/route.ts new file mode 100644 index 000000000..387a58ec2 --- /dev/null +++ b/src/app/api/auth/end-session-url/route.ts @@ -0,0 +1,14 @@ +import { CLIENT_ID, getEndSessionEndpoint } from '@/auth'; + +// Small, session-independent lookup so the client can build an RP-initiated logout URL (killing the identity +// provider's own session on sign-out, not just this app's) without exposing the full OIDC discovery URL/config to +// the client the way bento-auth-js used to. +export async function GET() { + try { + const endSessionEndpoint = await getEndSessionEndpoint(); + return Response.json({ endSessionEndpoint: endSessionEndpoint ?? null, clientId: CLIENT_ID }); + } catch (err) { + console.error('Error discovering end_session_endpoint', err); + return Response.json({ endSessionEndpoint: null, clientId: CLIENT_ID }); + } +} diff --git a/src/app/public/config.js/route.ts b/src/app/public/config.js/route.ts index 258821ecd..7076e238d 100644 --- a/src/app/public/config.js/route.ts +++ b/src/app/public/config.js/route.ts @@ -35,10 +35,6 @@ export function GET() { BEACON_URL: process.env.BEACON_URL || null, BEACON_UI_ENABLED: parseBoolean(process.env.BENTO_BEACON_UI_ENABLED), BEACON_NETWORK_ENABLED: parseBoolean(process.env.BENTO_BEACON_NETWORK_ENABLED), - - // Authentication - CLIENT_ID: process.env.CLIENT_ID || null, - OPENID_CONFIG_URL: process.env.OPENID_CONFIG_URL || null, }; return new Response(`BENTO_PUBLIC_CONFIG = ${JSON.stringify(siteConfig, null, 2)};\n`, { diff --git a/src/js/App.tsx b/src/js/App.tsx index 431105e23..293ed754d 100644 --- a/src/js/App.tsx +++ b/src/js/App.tsx @@ -20,20 +20,18 @@ import frCA from 'antd/locale/fr_CA'; import dayjs from 'dayjs'; import 'dayjs/locale/fr-ca'; import { ChartConfigProvider } from 'bento-charts'; -import Loader from '@/components/Loader'; import BentoAppRouter from '@/components/BentoAppRouter'; import LanguageHandler from '@/components/Util/LanguageHandler'; import AuthOutlet from '@/components/Util/AuthOutlet'; import ResponsiveProvider from '@/components/Util/ResponsiveProvider'; // Hooks and utilities imports -import { BentoAuthContextProvider } from 'bento-auth-js'; import { NotificationProvider } from '@/hooks/notifications'; import { useSmallScreen } from '@/hooks/useResponsiveContext'; // Store and configuration imports import { store } from './store'; -import { AUTH_CALLBACK_URL, CLIENT_ID, OPENID_CONFIG_URL, PCGL_MODE, PUBLIC_URL_NO_TRAILING_SLASH } from './config'; +import { PCGL_MODE } from './config'; // Styles imports import 'antd/dist/reset.css'; @@ -48,7 +46,6 @@ const BaseRoutes = () => { return ( }> - } /> }> } /> } /> @@ -93,25 +90,12 @@ const InnerRootApp = () => { ); }; -// TODO(authjs-migration): drop once every bento-auth-js consumer has moved to next-auth's useSession()/signIn()/ -// signOut(); SessionProvider (below) is next-auth's equivalent and already covers the whole app. const RootApp = () => ( - - - + diff --git a/src/js/auth.ts b/src/js/auth.ts index 19dbb11d3..5c1c4c3f9 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -4,7 +4,7 @@ import type { OAuthConfig } from 'next-auth/providers'; // without it, TS reports the augmentation target module as unresolvable even though it's a valid subpath. import type { JWT } from 'next-auth/jwt'; -const CLIENT_ID = process.env.CLIENT_ID ?? ''; +export const CLIENT_ID = process.env.CLIENT_ID ?? ''; const OPENID_CONFIG_URL = process.env.OPENID_CONFIG_URL ?? ''; type BentoTokenSet = { @@ -28,21 +28,33 @@ const bentoProvider: OAuthConfig> = { authorization: { params: { scope: 'openid email' } }, }; -// The discovered token_endpoint is cached the same way bento-auth-js cached OpenID configuration (a multi-hour TTL -// is plenty - these documents change rarely, if ever, for a given deployment). -let cachedTokenEndpoint: { url: string; expiry: number } | undefined; +type OidcDiscoveryDocument = { + token_endpoint: string; + end_session_endpoint?: string; +}; + +// The discovery document is cached the same way bento-auth-js cached it (a multi-hour TTL is plenty - this changes +// rarely, if ever, for a given deployment). +let cachedDiscovery: { doc: OidcDiscoveryDocument; expiry: number } | undefined; -const getTokenEndpoint = async (): Promise => { - if (cachedTokenEndpoint && Date.now() < cachedTokenEndpoint.expiry) { - return cachedTokenEndpoint.url; +const getDiscoveryDocument = async (): Promise => { + if (cachedDiscovery && Date.now() < cachedDiscovery.expiry) { + return cachedDiscovery.doc; } const res = await fetch(OPENID_CONFIG_URL); if (!res.ok) throw new Error('Could not fetch identity provider configuration'); - const { token_endpoint: url } = (await res.json()) as { token_endpoint: string }; - cachedTokenEndpoint = { url, expiry: Date.now() + 3 * 60 * 60 * 1000 }; - return url; + const doc = (await res.json()) as OidcDiscoveryDocument; + cachedDiscovery = { doc, expiry: Date.now() + 3 * 60 * 60 * 1000 }; + return doc; }; +const getTokenEndpoint = async (): Promise => (await getDiscoveryDocument()).token_endpoint; + +// Used by /api/auth/end-session-url so the client can drive an RP-initiated logout (killing the identity +// provider's session, not just this app's) without OPENID_CONFIG_URL itself needing to be client-exposed. +export const getEndSessionEndpoint = async (): Promise => + (await getDiscoveryDocument()).end_session_endpoint; + const refreshAccessToken = async (refreshToken: string): Promise => { const tokenEndpoint = await getTokenEndpoint(); const res = await fetch(tokenEndpoint, { diff --git a/src/js/components/Beacon/BeaconCommon/BeaconQueryFormUi.tsx b/src/js/components/Beacon/BeaconCommon/BeaconQueryFormUi.tsx index 65add2769..6ea966bbb 100644 --- a/src/js/components/Beacon/BeaconCommon/BeaconQueryFormUi.tsx +++ b/src/js/components/Beacon/BeaconCommon/BeaconQueryFormUi.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Button, Card, Col, Flex, Form, Row } from 'antd'; -import { useIsAuthenticated } from 'bento-auth-js'; +import { useIsAuthenticated } from '@/features/auth/hooks'; import { useAppDispatch, useQueryWithAuthIfAllowed, useTranslationFn } from '@/hooks'; import { useSelectedScope } from '@/features/metadata/hooks'; import VariantsForm from './VariantsForm'; diff --git a/src/js/components/BentoAppRouter.tsx b/src/js/components/BentoAppRouter.tsx index 610160260..3c78fbfba 100644 --- a/src/js/components/BentoAppRouter.tsx +++ b/src/js/components/BentoAppRouter.tsx @@ -1,6 +1,6 @@ import { lazy, useEffect } from 'react'; import { Routes, Route, useNavigate, useParams, Outlet } from 'react-router-dom'; -import { useAutoAuthenticate, useIsAuthenticated } from 'bento-auth-js'; +import { useIsAuthenticated } from '@/features/auth/hooks'; import { useAppDispatch, useLanguage } from '@/hooks'; import { @@ -105,7 +105,6 @@ const BentoAppRouter = () => { const dispatch = useAppDispatch(); const language = useLanguage(); - const { isAutoAuthenticating } = useAutoAuthenticate(); const isAuthenticated = useIsAuthenticated(); const { selectedScope: { scope, scopeSet }, @@ -188,7 +187,7 @@ const BentoAppRouter = () => { dispatch(getProjects(language)); }, [dispatch, language]); - if (isAutoAuthenticating || projectsStatus === RequestStatus.Pending) { + if (projectsStatus === RequestStatus.Pending) { return ; } diff --git a/src/js/components/SiteHeader.tsx b/src/js/components/SiteHeader.tsx index c84b248cb..0f7dc7190 100644 --- a/src/js/components/SiteHeader.tsx +++ b/src/js/components/SiteHeader.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Button, Flex, Layout, Menu, type MenuProps, Space, Typography, theme } from 'antd'; -import { useAuthState, useIsAuthenticated, useOpenIdConfig, usePerformAuth, usePerformSignOut } from 'bento-auth-js'; +import { useSession } from 'next-auth/react'; +import { useIsAuthenticated, usePerformAuth, usePerformSignOut } from '@/features/auth/hooks'; import { RiTranslate } from 'react-icons/ri'; import { LoginOutlined, LogoutOutlined } from '@ant-design/icons'; @@ -74,8 +75,7 @@ const SiteHeader = ({ menuItems }: SiteHeaderProps) => { const currentPage = getCurrentPage(location); const navigateToRoot = useNavigateToRoot(); - const { isFetching: openIdConfigFetching } = useOpenIdConfig(); - const { isHandingOffCodeForToken } = useAuthState(); + const { status: sessionStatus } = useSession(); const isAuthenticated = useIsAuthenticated(); const performSignOut = usePerformSignOut(); @@ -194,7 +194,7 @@ const SiteHeader = ({ menuItems }: SiteHeaderProps) => { ) : ( ))} diff --git a/src/js/components/Util/AuthOutlet.tsx b/src/js/components/Util/AuthOutlet.tsx index 1a526c753..0c9f8b7a2 100644 --- a/src/js/components/Util/AuthOutlet.tsx +++ b/src/js/components/Util/AuthOutlet.tsx @@ -1,73 +1,12 @@ -import { useRef, useState } from 'react'; -import { useQueryWithAuthIfAllowed, useTranslationFn } from '@/hooks'; -import { - checkIsInAuthPopup, - useHandleCallback, - useOpenSignInWindowCallback, - usePopupOpenerAuthCallback, - useSessionWorkerTokenRefresh, - useSignInPopupTokenHandoff, -} from 'bento-auth-js'; -import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; -import { Button, message, Modal } from 'antd'; +import { useQueryWithAuthIfAllowed } from '@/hooks'; +import { useSyncAccessToken } from '@/features/auth/hooks'; import { Outlet } from 'react-router-dom'; -const SIGN_IN_WINDOW_FEATURES = 'scrollbars=no, toolbar=no, menubar=no, width=800, height=600'; -const CALLBACK_PATH = '/callback'; - -const createSessionWorker = () => new Worker(new URL('../../workers/tokenRefresh.ts', import.meta.url)); - const AuthOutlet = () => { - const t = useTranslationFn(); - - // AUTHENTICATION - const [signedOutModal, setSignedOutModal] = useState(false); - - const sessionWorker = useRef(null); - const signInWindow = useRef(null); - const windowMessageHandler = useRef<((event: MessageEvent) => void) | null>(null); - - const openSignInWindow = useOpenSignInWindowCallback(signInWindow, SIGN_IN_WINDOW_FEATURES); - - const popupOpenerAuthCallback = usePopupOpenerAuthCallback(); - const isInAuthPopup = checkIsInAuthPopup(PUBLIC_URL_NO_TRAILING_SLASH); - - useHandleCallback( - CALLBACK_PATH, - () => { - console.debug('authenticated'); - }, - isInAuthPopup ? popupOpenerAuthCallback : undefined, - (msg) => message.error(msg) - ); - - // Set up message handling from sign-in popup - useSignInPopupTokenHandoff(windowMessageHandler); - - useSessionWorkerTokenRefresh(sessionWorker, createSessionWorker); - + useSyncAccessToken(); useQueryWithAuthIfAllowed(); - return ( - <> - { - setSignedOutModal(false); - }} - open={signedOutModal} - footer={[ - , - ]} - > - {t('Please sign in to the research portal.')} -
-
- - - ); + return ; }; export default AuthOutlet; diff --git a/src/js/components/Util/DownloadButton.tsx b/src/js/components/Util/DownloadButton.tsx index aea9789b3..25f6762ca 100644 --- a/src/js/components/Util/DownloadButton.tsx +++ b/src/js/components/Util/DownloadButton.tsx @@ -3,7 +3,7 @@ import { type MouseEventHandler, useCallback } from 'react'; import { Button, type ButtonProps } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; -import { useAccessToken } from 'bento-auth-js'; +import { useAccessToken } from '@/features/auth/hooks'; import { useTranslationFn } from '@/hooks'; import { useDrsHttpsAccessOrPassThrough } from '@/features/drs/hooks'; diff --git a/src/js/components/Util/FileModal.tsx b/src/js/components/Util/FileModal.tsx index 8603ae636..36353f1ea 100644 --- a/src/js/components/Util/FileModal.tsx +++ b/src/js/components/Util/FileModal.tsx @@ -3,7 +3,7 @@ import { Modal, type ModalProps } from 'antd'; import { FileDisplay } from 'bento-file-display'; -import { useAuthorizationHeader } from 'bento-auth-js'; +import { useAuthorizationHeader } from '@/features/auth/hooks'; import { useDrsHttpsAccessOrPassThrough } from '@/features/drs/hooks'; const MODAL_STYLE: CSSProperties = { diff --git a/src/js/config.ts b/src/js/config.ts index 8824d6071..de2737599 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -22,9 +22,6 @@ export interface PublicConfig { BEACON_URL: string | null; BEACON_UI_ENABLED: boolean; BEACON_NETWORK_ENABLED: boolean; - // Authentication - CLIENT_ID: string | null; - OPENID_CONFIG_URL: string | null; } // Declaration required for global config @@ -63,8 +60,3 @@ export const BEACON_UI_ENABLED = BENTO_PUBLIC_CONFIG.BEACON_UI_ENABLED ?? stringToBoolean(process.env.BENTO_BEACON_UI_ENABLED); export const BEACON_NETWORK_ENABLED = BENTO_PUBLIC_CONFIG.BEACON_NETWORK_ENABLED ?? stringToBoolean(process.env.BENTO_BEACON_NETWORK_ENABLED); - -// Authentication -export const CLIENT_ID = BENTO_PUBLIC_CONFIG.CLIENT_ID ?? process.env.CLIENT_ID ?? ''; -export const OPENID_CONFIG_URL = BENTO_PUBLIC_CONFIG.OPENID_CONFIG_URL ?? process.env.OPENID_CONFIG_URL ?? ''; -export const AUTH_CALLBACK_URL = `${PUBLIC_URL_NO_TRAILING_SLASH}/callback`; diff --git a/src/js/features/auth/authSlice.ts b/src/js/features/auth/authSlice.ts new file mode 100644 index 000000000..a8d40444a --- /dev/null +++ b/src/js/features/auth/authSlice.ts @@ -0,0 +1,118 @@ +import { createAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import type { RootState } from '@/store'; +import { type Resource, makeResourceKey } from './resources'; +import { makeAuthorizationHeader } from './utils'; + +// Auth.js's own JWT session cookie is the source of truth for the session (access/id/refresh tokens) - it's not +// duplicated into localStorage here. `accessToken` below is a synchronous *mirror* of that session's access token +// (kept in sync by `useSyncAccessToken`, dispatched once near the app root), so that Redux thunks - which only have +// `getState()`, not React hook access to `useSession()` - can still attach an Authorization header the same way +// they did when bento-auth-js stored the token in Redux directly. This slice otherwise only caches Bento +// authorization-service resource-permission lookups. + +type FetchPermissionsPayload = { + result: string[][]; +}; +type FetchPermissionsParams = { + resources: Resource[]; + authzUrl: string; + accessToken: string | undefined; +}; + +export const fetchResourcesPermissions = createAsyncThunk< + FetchPermissionsPayload, + FetchPermissionsParams, + { state: RootState } +>( + 'auth/FETCH_RESOURCES_PERMISSIONS', + async ({ resources, authzUrl, accessToken }) => { + const url = `${authzUrl}/policy/permissions`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...makeAuthorizationHeader(accessToken) }, + body: JSON.stringify({ resources }), + }); + return await response.json(); + }, + { + condition: ({ resources }, { getState }) => { + // Allow the fetch to fire only if none of the requested resource permission-sets are already being fetched. + const { auth } = getState(); + return resources.every((resource) => { + const key = makeResourceKey(resource); + const rp = auth.resourcePermissions?.[key]; + return !rp?.isFetching; + }); + }, + } +); + +export type AuthSliceState = { + accessToken?: string; + resourcePermissions: { + [name: string]: { + isFetching: boolean; + hasAttempted: boolean; + error: string; + permissions: string[]; + }; + }; +}; +const initialState: AuthSliceState = { + resourcePermissions: {}, +}; + +export const setAccessToken = createAction('auth/SET_ACCESS_TOKEN'); + +export const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(setAccessToken, (state, { payload }) => { + state.accessToken = payload; + }) + .addCase(fetchResourcesPermissions.pending, (state, { meta }) => { + for (const resource of meta.arg.resources) { + const key = makeResourceKey(resource); + state.resourcePermissions[key] = { + ...state.resourcePermissions[key], + isFetching: true, + hasAttempted: false, + permissions: [], + error: '', + }; + } + }) + .addCase(fetchResourcesPermissions.fulfilled, (state, { meta, payload }) => { + const resources = meta.arg.resources; + for (const r in resources) { + const key = makeResourceKey(resources[r]); + state.resourcePermissions[key] = { + ...state.resourcePermissions[key], + isFetching: false, + hasAttempted: true, + permissions: payload?.result?.[r] ?? [], + }; + } + }) + .addCase(fetchResourcesPermissions.rejected, (state, { meta, error }) => { + if (error) console.error(error); + + for (const resource of meta.arg.resources) { + const key = makeResourceKey(resource); + + const permissionsError = error.message ?? 'An error occurred while fetching permissions for a resource'; + state.resourcePermissions[key] = { + ...state.resourcePermissions[key], + isFetching: false, + hasAttempted: true, + error: permissionsError, + }; + } + }); + }, +}); + +export default authSlice.reducer; diff --git a/src/js/features/auth/hooks.ts b/src/js/features/auth/hooks.ts new file mode 100644 index 000000000..b26eab9c7 --- /dev/null +++ b/src/js/features/auth/hooks.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { signIn, signOut, useSession } from 'next-auth/react'; + +import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; +import type { AppDispatch, RootState } from '@/store'; +import { type Resource, makeResourceKey } from './resources'; +import { fetchResourcesPermissions, setAccessToken } from './authSlice'; +import { makeAuthorizationHeader } from './utils'; + +const BENTO_OIDC_PROVIDER_ID = 'bento'; + +export const useIsAuthenticated = (): boolean => useSession().status === 'authenticated'; + +export const useAccessToken = (): string | undefined => useSession().data?.accessToken; + +// Mirrors the Auth.js session's access token into Redux, so `getState()`-only Redux thunks (which can't call +// `useSession()` themselves) can still read it synchronously via `state.auth.accessToken`, the same way they did +// when bento-auth-js stored the token in Redux directly. Call this once, near the app root. +export const useSyncAccessToken = () => { + const dispatch: AppDispatch = useDispatch(); + const accessToken = useAccessToken(); + useEffect(() => { + dispatch(setAccessToken(accessToken)); + }, [dispatch, accessToken]); +}; + +export const useAuthorizationHeader = () => { + const accessToken = useAccessToken(); + return useMemo(() => makeAuthorizationHeader(accessToken), [accessToken]); +}; + +type BaseResourcePermissionsState = { + isFetching: boolean; + hasAttempted: boolean; + error: string; +}; +type ResourcePermissionsState = BaseResourcePermissionsState & { permissions: string[] }; +type ResourceHasPermissionState = BaseResourcePermissionsState & { hasPermission: boolean }; + +export const useResourcesPermissions = ( + resources: Resource[], + authzUrl: string | undefined +): Record => { + const dispatch: AppDispatch = useDispatch(); + + const { status: sessionStatus, data: session } = useSession(); + const accessToken = session?.accessToken; + + const keys = useMemo(() => resources.map((resource) => makeResourceKey(resource)), [resources]); + + const resourcePermissions = useSelector((state: RootState) => state.auth.resourcePermissions); + + useEffect(() => { + const anyFetching = keys.some((key) => !!resourcePermissions[key]?.isFetching); + const allHavePermissions = keys.every((key) => !!resourcePermissions[key]?.permissions?.length); + const allAttempted = keys.every((key) => !!resourcePermissions[key]?.hasAttempted); + + // If the session is still loading, or any permissions are currently fetching, or all requested permissions have + // already been tried/returned, we don't need to dispatch the fetch action: + if (!authzUrl || sessionStatus === 'loading' || anyFetching || allHavePermissions || allAttempted) return; + + dispatch(fetchResourcesPermissions({ resources, authzUrl, accessToken })); + }, [dispatch, keys, resources, resourcePermissions, authzUrl, sessionStatus, accessToken]); + + // Construct an object with resource keys yielding an object containing the permissions on the object + return useMemo( + () => + Object.fromEntries( + keys.map((key) => { + const { permissions, isFetching, hasAttempted, error } = resourcePermissions[key] ?? {}; + return [ + key, + { + permissions: permissions ?? [], + isFetching: isFetching ?? false, + hasAttempted: hasAttempted ?? false, + error: error ?? '', + }, + ]; + }) + ), + [keys, resourcePermissions] + ); +}; + +export const useResourcePermissions = (resource: Resource, authzUrl: string | undefined): ResourcePermissionsState => { + const key = makeResourceKey(resource); + const resourcesPermissions = useResourcesPermissions([resource], authzUrl); + return resourcesPermissions[key]; +}; + +export const useHasResourcePermission = ( + resource: Resource, + authzUrl: string | undefined, + permission: string +): ResourceHasPermissionState => { + const { permissions, ...props } = useResourcePermissions(resource, authzUrl) ?? {}; + return { ...props, hasPermission: permissions.includes(permission) }; +}; + +export const usePerformAuth = () => useCallback(() => signIn(BENTO_OIDC_PROVIDER_ID), []); + +// Signs out of this app *and* the identity provider (RP-initiated logout), so a re-visit doesn't silently pick the +// session back up. Falls back to a plain local sign-out if the provider doesn't support/expose end_session_endpoint. +export const usePerformSignOut = () => { + const { data: session } = useSession(); + const idToken = session?.idToken; + + return useCallback(async () => { + try { + const res = await fetch('/api/auth/end-session-url'); + if (res.ok) { + const { endSessionEndpoint, clientId } = (await res.json()) as { + endSessionEndpoint: string | null; + clientId: string | null; + }; + if (endSessionEndpoint) { + const url = new URL(endSessionEndpoint); + if (idToken) url.searchParams.set('id_token_hint', idToken); + if (clientId) url.searchParams.set('client_id', clientId); + url.searchParams.set('post_logout_redirect_uri', `${PUBLIC_URL_NO_TRAILING_SLASH}/`); + + await signOut({ redirect: false }); // clear our own session first - we've already captured idToken above + window.location.href = url.toString(); // ...then hand off to the identity provider to end its session too + return; + } + } + } catch (err) { + console.error('Error building identity provider end-session URL', err); + } + // Fallback: provider doesn't support RP-initiated logout, or discovery failed. + await signOut(); + }, [idToken]); +}; diff --git a/src/js/features/auth/permissions.ts b/src/js/features/auth/permissions.ts new file mode 100644 index 000000000..4a8ddfc89 --- /dev/null +++ b/src/js/features/auth/permissions.ts @@ -0,0 +1,39 @@ +// Bento authorization-service permission strings. Ported from bento-auth-js: these are Bento domain constants, not +// part of the OIDC/session library, so they don't move with the rest of the auth stack to Auth.js. + +export const queryProjectLevelBoolean = 'query:project_level_boolean'; +export const queryDatasetLevelBoolean = 'query:dataset_level_boolean'; + +export const queryProjectLevelCounts = 'query:project_level_counts'; +export const queryDatasetLevelCounts = 'query:dataset_level_counts'; + +export const queryData = 'query:data'; +export const downloadData = 'download:data'; +export const deleteData = 'delete:data'; + +export const ingestData = 'ingest:data'; +export const analyzeData = 'analyze:data'; +export const exportData = 'export:data'; + +export const viewRuns = 'view:runs'; + +export const viewNotifications = 'view:notifications'; +export const createNotifications = 'create:notifications'; + +export const createProject = 'create:project'; +export const editProject = 'edit:project'; +export const deleteProject = 'delete:project'; + +export const createDataset = 'create:dataset'; +export const deleteDataset = 'delete:dataset'; +export const editDataset = 'edit:dataset'; + +export const viewPermissions = 'view:permissions'; +export const editPermissions = 'edit:permissions'; + +export const viewDropBox = 'view:drop_box'; +export const ingestDropBox = 'ingest:drop_box'; +export const deleteDropBox = 'delete:drop_box'; + +export const ingestReferenceMaterial = 'ingest:reference_material'; +export const deleteReferenceMaterial = 'delete:reference_material'; diff --git a/src/js/features/auth/resources.ts b/src/js/features/auth/resources.ts new file mode 100644 index 000000000..961844481 --- /dev/null +++ b/src/js/features/auth/resources.ts @@ -0,0 +1,65 @@ +// Bento authorization-service resource shapes. Ported from bento-auth-js: these describe what's being asked +// permission for (a project, a dataset, "everything"), which is Bento's own authz contract, not an Auth.js concern. + +/** + * Resources are expressed as simple key-value pairs. + * Where the key is always a string identifying the resource type, + * and the value is either a string identifying the resource ID, + * or a boolean for everything. + * + * e.g. The resource for a project with ID "project-1" would be expressed as: + * { "project": "project-1" } + */ +export type Resource = { everything: true } | { project: string; dataset?: string; data_type?: string }; + +export const RESOURCE_EVERYTHING: Resource = { everything: true }; + +export const makeProjectResource = (projectId: string): Resource => { + return { + project: projectId, + }; +}; + +export const makeProjectDataTypeResource = (projectId: string, dataType: string): Resource => { + return { + ...makeProjectResource(projectId), + data_type: dataType, + }; +}; + +export const makeProjectDatasetResource = (projectId: string, datasetId: string): Resource => { + return { + ...makeProjectResource(projectId), + dataset: datasetId, + }; +}; + +export const makeProjectDatasetDataTypeResource = ( + projectId: string, + datasetId: string, + dataType: string +): Resource => { + return { + ...makeProjectDatasetResource(projectId, datasetId), + data_type: dataType, + }; +}; + +// Recursively sorts an object's keys (without touching array order) so two structurally-equal resources always +// serialize to the same string, regardless of property insertion order. +const recursiveOrderedObject = (x: unknown): unknown => { + if (Array.isArray(x)) { + return x.map((y) => recursiveOrderedObject(y)); + } else if (typeof x === 'object' && x !== null) { + return Object.keys(x) + .sort() + .reduce>((acc, y) => { + acc[y] = (x as Record)[y]; + return acc; + }, {}); + } else { + return x; + } +}; + +export const makeResourceKey = (x: Resource): string => JSON.stringify(recursiveOrderedObject(x)); diff --git a/src/js/features/auth/utils.ts b/src/js/features/auth/utils.ts new file mode 100644 index 000000000..5d8ee44e2 --- /dev/null +++ b/src/js/features/auth/utils.ts @@ -0,0 +1,2 @@ +export const makeAuthorizationHeader = (token: string | null | undefined): Record => + token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/src/js/features/metadata/hooks.ts b/src/js/features/metadata/hooks.ts index 7317ac330..ce5f7e4e7 100644 --- a/src/js/features/metadata/hooks.ts +++ b/src/js/features/metadata/hooks.ts @@ -1,5 +1,10 @@ import { useMemo } from 'react'; -import { makeProjectDatasetResource, makeProjectResource, type Resource, RESOURCE_EVERYTHING } from 'bento-auth-js'; +import { + makeProjectDatasetResource, + makeProjectResource, + type Resource, + RESOURCE_EVERYTHING, +} from '@/features/auth/resources'; import { useAppSelector } from '@/hooks'; import type { Project } from '@/types/metadata'; import type { Dataset } from '@/types/dataset'; diff --git a/src/js/features/reference/hooks.ts b/src/js/features/reference/hooks.ts index a1b5791b6..41fce0142 100644 --- a/src/js/features/reference/hooks.ts +++ b/src/js/features/reference/hooks.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useAuthorizationHeader } from 'bento-auth-js'; +import { useAuthorizationHeader } from '@/features/auth/hooks'; import { referenceGenomesUrl } from '@/constants/configConstants'; import { useAppSelector } from '@/hooks'; import { RequestStatus } from '@/types/requests'; diff --git a/src/js/hooks.ts b/src/js/hooks.ts index fffdf40a4..e43becef9 100644 --- a/src/js/hooks.ts +++ b/src/js/hooks.ts @@ -2,8 +2,10 @@ import { useCallback, useEffect } from 'react'; import type { TypedUseSelectorHook } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import type { Resource } from 'bento-auth-js'; -import { RESOURCE_EVERYTHING, queryData, useHasResourcePermission } from 'bento-auth-js'; +import type { Resource } from '@/features/auth/resources'; +import { RESOURCE_EVERYTHING } from '@/features/auth/resources'; +import { queryData } from '@/features/auth/permissions'; +import { useHasResourcePermission } from '@/features/auth/hooks'; import { filesize } from 'filesize'; import { useSelectedScopeAsResource } from '@/features/metadata/hooks'; diff --git a/src/js/hooks/censorship.ts b/src/js/hooks/censorship.ts index 28b6ff750..ee33914b5 100644 --- a/src/js/hooks/censorship.ts +++ b/src/js/hooks/censorship.ts @@ -1,4 +1,4 @@ -import { downloadData, queryData } from 'bento-auth-js'; +import { downloadData, queryData } from '@/features/auth/permissions'; import { useConfig } from '@/features/config/hooks'; import { useHasScopePermission } from '@/hooks'; diff --git a/src/js/hooks/useDownloadSelectedMatches.ts b/src/js/hooks/useDownloadSelectedMatches.ts index beb5c8aae..45f8cef41 100644 --- a/src/js/hooks/useDownloadSelectedMatches.ts +++ b/src/js/hooks/useDownloadSelectedMatches.ts @@ -1,9 +1,9 @@ import { useCallback } from 'react'; import axios from 'axios'; import FileSaver from 'file-saver'; -import { makeAuthorizationHeader } from 'bento-auth-js'; +import { useAccessToken } from '@/features/auth/hooks'; +import { makeAuthorizationHeader } from '@/features/auth/utils'; -import { useAppSelector } from '@/hooks'; import { biosampleBatchUrl, experimentBatchUrl, @@ -21,7 +21,7 @@ const BATCH_URL_BY_ENTITY: Record = { }; export const useDownloadSelectedMatches = () => { - const auth = useAppSelector((state) => state.auth); + const accessToken = useAccessToken(); return useCallback( async (entity: ExportDataEntity, ids: string[], format: ExportFormat, filename: string, fields?: string[]) => { @@ -35,13 +35,13 @@ export const useDownloadSelectedMatches = () => { url, { id, format, ...(fields && fields.length > 0 ? { fields } : {}) }, { - headers: { ...makeAuthorizationHeader(auth.accessToken) }, + headers: { ...makeAuthorizationHeader(accessToken) }, responseType: 'blob', } ); FileSaver.saveAs(res.data, filename); }, - [auth] + [accessToken] ); }; diff --git a/src/js/store.ts b/src/js/store.ts index 7a28fb28b..025002d15 100644 --- a/src/js/store.ts +++ b/src/js/store.ts @@ -1,12 +1,6 @@ -import { configureStore, type Reducer, type UnknownAction } from '@reduxjs/toolkit'; +import { configureStore } from '@reduxjs/toolkit'; -import type { OIDCSliceState } from 'bento-auth-js'; -import { LS_OPENID_CONFIG_KEY, AuthReducer as auth, OIDCReducer } from 'bento-auth-js'; - -// bento-auth-js types its exported reducer's preloaded-state param as exactly `OIDCSliceState` rather than -// `OIDCSliceState | undefined`, even though (like any createSlice reducer) it handles undefined state fine at -// runtime. Recast so it satisfies configureStore's expectations alongside our optional preloadedState below. -const openIdConfiguration = OIDCReducer as Reducer; +import auth from '@/features/auth/authSlice'; import { LOCALSTORAGE_UI_SETTINGS_KEY } from '@/constants/ui'; import catalogueReducer from '@/features/catalogue/catalogue.store'; @@ -21,23 +15,11 @@ import beaconNetworkReducer from './features/beacon/network.store'; import metadataReducer from '@/features/metadata/metadata.store'; import reference from '@/features/reference/reference.store'; import ui, { type UIState } from '@/features/ui/ui.store'; -import { getValue, saveValue } from './utils/localStorage'; - -interface PersistedState { - openIdConfiguration?: OIDCSliceState; -} - -const persistedState: PersistedState = {}; -const persistedOpenIDConfig = getValue(LS_OPENID_CONFIG_KEY, undefined, (value) => typeof value === 'object'); -if (persistedOpenIDConfig) { - console.debug('attempting to load OpenID configuration from localStorage'); - persistedState.openIdConfiguration = persistedOpenIDConfig; -} +import { saveValue } from './utils/localStorage'; export const store = configureStore({ reducer: { auth, - openIdConfiguration, catalogue: catalogueReducer, clinPhen: clinPhenReducer, config: configReducer, @@ -51,7 +33,6 @@ export const store = configureStore({ reference, ui, }, - preloadedState: persistedState, }); export type RootState = ReturnType; @@ -88,18 +69,6 @@ const observeStore = ( return unsubscribe; }; -// Persist OIDC config on state changes -observeStore( - store, - (state) => state.openIdConfiguration, - (currentState) => { - const { data, expiry, isFetching } = currentState; - if (data && expiry && !isFetching) { - saveValue(LS_OPENID_CONFIG_KEY, { data, expiry, isFetching }); - } - } -); - // Persist UI settings on state changes observeStore( store, diff --git a/src/js/utils/requests.ts b/src/js/utils/requests.ts index ba29da651..29d33b19f 100644 --- a/src/js/utils/requests.ts +++ b/src/js/utils/requests.ts @@ -1,5 +1,5 @@ import type { AxiosRequestConfig } from 'axios'; -import { makeAuthorizationHeader } from 'bento-auth-js'; +import { makeAuthorizationHeader } from '@/features/auth/utils'; import type { RootState } from '@/store'; import type { DiscoveryScope, DiscoveryScopeSelection } from '@/features/metadata/metadata.store'; import type { QueryParamEntries } from '@/features/search/types'; From de57388b9b9f9d6a3bcb39cb850c770600689b37 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Tue, 8 Sep 2026 08:04:58 -0400 Subject: [PATCH 03/18] chore(public): drop bento-auth-js dependency Removes bento-auth-js and its hand-built postinstall step now that nothing in the app imports it, and deletes the token-refresh Web Worker it needed (bento-auth-js used it to work around setInterval throttling in inactive tabs; SessionProvider's refetchInterval plus its refetchOnWindowFocus default cover the same need without a dedicated worker). --- package-lock.json | 18 ------------------ package.json | 4 +--- src/js/App.tsx | 4 +++- src/js/workers/tokenRefresh.ts | 13 ------------- 4 files changed, 4 insertions(+), 35 deletions(-) delete mode 100644 src/js/workers/tokenRefresh.ts diff --git a/package-lock.json b/package-lock.json index 4bbb94d37..9d896c00a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "bento_public", "version": "1.1.0", - "hasInstallScript": true, "license": "LGPL-3.0-only", "dependencies": { "@ant-design/icons": "^5.6.1", @@ -15,7 +14,6 @@ "@types/geojson": "^7946.0.16", "antd": "^5.24.5", "axios": "^1.8.4", - "bento-auth-js": "github:bento-platform/bento_auth_js#feat/react-19-compat", "bento-charts": "github:bento-platform/bento_charts#chore/drop-legacy-react-leaflet-peerdep", "bento-file-display": "https://github.com/bento-platform/bento-file-display/releases/download/v0.5.0/bento-file-display-v0.5.0.tgz", "clsx": "^2.1.1", @@ -3015,22 +3013,6 @@ "node": ">=6.0.0" } }, - "node_modules/bento-auth-js": { - "version": "7.2.3", - "resolved": "git+ssh://git@github.com/bento-platform/bento_auth_js.git#385850d02b22869bbccc8e3eb7db1445f6416e51", - "license": "MIT", - "dependencies": { - "jose": "^6.2.3" - }, - "peerDependencies": { - "@reduxjs/toolkit": "^2.5.0", - "react": "^18.3.1 || ^19.0.0", - "react-dom": "^18.3.1 || ^19.0.0", - "react-redux": "^9.1.0", - "react-router-dom": "^6.21.1", - "redux": "^5.0.0" - } - }, "node_modules/bento-charts": { "version": "4.1.0", "resolved": "git+ssh://git@github.com/bento-platform/bento_charts.git#d57071aab1fbc2621aeb31f8126e2f5067a936c6", diff --git a/package.json b/package.json index 9f18cd2fc..efb863a85 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "start": "next start -p ${BENTO_PUBLIC_PORT:-5000}", "lint": "eslint src", "lint-fix": "eslint --fix src", - "prettier": "prettier --write src", - "postinstall": "test -f node_modules/bento-auth-js/dist/index.js || npm run build --prefix node_modules/bento-auth-js" + "prettier": "prettier --write src" }, "keywords": [], "author": "", @@ -21,7 +20,6 @@ "@types/geojson": "^7946.0.16", "antd": "^5.24.5", "axios": "^1.8.4", - "bento-auth-js": "github:bento-platform/bento_auth_js#feat/react-19-compat", "bento-charts": "github:bento-platform/bento_charts#chore/drop-legacy-react-leaflet-peerdep", "bento-file-display": "https://github.com/bento-platform/bento-file-display/releases/download/v0.5.0/bento-file-display-v0.5.0.tgz", "clsx": "^2.1.1", diff --git a/src/js/App.tsx b/src/js/App.tsx index 293ed754d..b81cd3d2a 100644 --- a/src/js/App.tsx +++ b/src/js/App.tsx @@ -91,7 +91,9 @@ const InnerRootApp = () => { }; const RootApp = () => ( - + // refetchInterval matches bento-auth-js's old token-refresh-worker cadence; refetchOnWindowFocus (on by default) + // covers the case where that interval was throttled while the tab was in the background. + diff --git a/src/js/workers/tokenRefresh.ts b/src/js/workers/tokenRefresh.ts deleted file mode 100644 index fb5ae2024..000000000 --- a/src/js/workers/tokenRefresh.ts +++ /dev/null @@ -1,13 +0,0 @@ -// We need to constantly ping the server to make sure the token set is as -// up-to-date as possible. Unfortunately, setInterval slows down when a tab is -// not active, so we need to move this pinging to a web worker to maintain the -// session when the tab doesn't have focus. - -// When the main page receives the message, it will trigger the REFRESH_TOKENS.* -// network action to get a new access & refresh token. - -setInterval(() => { - self.postMessage(true); // Send a window-inactive-resilient ping -}, 120000); // every 2 minutes - -export {}; From 5b5c06423397e994be09e400932f31954282bd9a Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Tue, 8 Sep 2026 09:46:18 -0400 Subject: [PATCH 04/18] fix(public): set explicit OIDC issuer for Auth.js provider config Auth.js's own config validation requires `issuer` even though `wellKnown` is what it actually fetches from at request time - without it, every request throws InvalidEndpoints and /api/auth/session 500s. Derive it from OPENID_CONFIG_URL by stripping the well-known suffix. --- src/js/auth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/js/auth.ts b/src/js/auth.ts index 5c1c4c3f9..449ff3369 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -6,6 +6,9 @@ import type { JWT } from 'next-auth/jwt'; export const CLIENT_ID = process.env.CLIENT_ID ?? ''; const OPENID_CONFIG_URL = process.env.OPENID_CONFIG_URL ?? ''; +// OIDC discovery URLs are always `{issuer}/.well-known/openid-configuration` - Auth.js's own config validation +// requires `issuer` to be set explicitly even though `wellKnown` (below) is what it actually fetches at request time. +const ISSUER = OPENID_CONFIG_URL.replace(/\/\.well-known\/.*$/, ''); type BentoTokenSet = { access_token: string; @@ -21,6 +24,7 @@ const bentoProvider: OAuthConfig> = { id: 'bento', name: 'Bento', type: 'oidc', + issuer: ISSUER, wellKnown: OPENID_CONFIG_URL, clientId: CLIENT_ID, checks: ['pkce', 'state'], From 2bb7cfc29e81005ed94a9a6d08327394f4c9d1b2 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Tue, 8 Sep 2026 12:56:48 -0400 Subject: [PATCH 05/18] refactor(public): extract session refetch interval into a named constant --- src/js/App.tsx | 6 ++---- src/js/constants/configConstants.ts | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/js/App.tsx b/src/js/App.tsx index b81cd3d2a..d26f64ea0 100644 --- a/src/js/App.tsx +++ b/src/js/App.tsx @@ -11,7 +11,7 @@ import { SessionProvider } from 'next-auth/react'; // i18n and constants imports import { useTranslation } from 'react-i18next'; import { NEW_BENTO_PUBLIC_THEME } from '@/constants/exploreConstants'; -import { SUPPORTED_LNGS } from '@/constants/configConstants'; +import { SESSION_REFETCH_INTERVAL_SECONDS, SUPPORTED_LNGS } from '@/constants/configConstants'; // Component imports import { ConfigProvider } from 'antd'; @@ -91,9 +91,7 @@ const InnerRootApp = () => { }; const RootApp = () => ( - // refetchInterval matches bento-auth-js's old token-refresh-worker cadence; refetchOnWindowFocus (on by default) - // covers the case where that interval was throttled while the tab was in the background. - + diff --git a/src/js/constants/configConstants.ts b/src/js/constants/configConstants.ts index cbf3a3e9f..8ee28e5df 100644 --- a/src/js/constants/configConstants.ts +++ b/src/js/constants/configConstants.ts @@ -2,6 +2,10 @@ import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; export const MAX_CHARTS = 3; +// Mirrors the 2-minute interval bento-auth-js's tokenRefresh Web Worker used to ping on - SessionProvider's own +// refetch re-enters the jwt callback and refreshes the access token when needed, without a dedicated worker. +export const SESSION_REFETCH_INTERVAL_SECONDS = 120; + const katsuBaseUrl = `${PUBLIC_URL_NO_TRAILING_SLASH}/api/metadata`; // Katsu discovery URLs From 64a1ac091fba6ddea7533b78c604c50662b66a64 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Tue, 8 Sep 2026 12:56:58 -0400 Subject: [PATCH 06/18] refactor(public): reuse bento-auth-js's resource/permission exports instead of duplicating them The Auth.js migration copied bento-auth-js's Resource type, permission string constants, and makeAuthorizationHeader verbatim into features/auth/ purely to drop the dependency - none of that is OIDC-related, it's Bento's own authorization-service contract. Keeping bento-auth-js as a dependency and importing those pieces directly avoids maintaining a duplicate copy. features/auth/authSlice.ts and hooks.ts stay as local code: they're genuinely new logic bridging Auth.js's session to the resource- permission cache, not something bento-auth-js's own (differently wired) Redux slice and hooks can serve. --- package-lock.json | 18 ++++++ package.json | 4 +- src/js/features/auth/authSlice.ts | 3 +- src/js/features/auth/hooks.ts | 3 +- src/js/features/auth/permissions.ts | 39 ------------- src/js/features/auth/resources.ts | 65 ---------------------- src/js/features/auth/utils.ts | 2 - src/js/features/metadata/hooks.ts | 7 +-- src/js/hooks.ts | 5 +- src/js/hooks/censorship.ts | 2 +- src/js/hooks/useDownloadSelectedMatches.ts | 2 +- src/js/utils/requests.ts | 2 +- 12 files changed, 29 insertions(+), 123 deletions(-) delete mode 100644 src/js/features/auth/permissions.ts delete mode 100644 src/js/features/auth/resources.ts delete mode 100644 src/js/features/auth/utils.ts diff --git a/package-lock.json b/package-lock.json index 9d896c00a..158739d3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "bento_public", "version": "1.1.0", + "hasInstallScript": true, "license": "LGPL-3.0-only", "dependencies": { "@ant-design/icons": "^5.6.1", @@ -14,6 +15,7 @@ "@types/geojson": "^7946.0.16", "antd": "^5.24.5", "axios": "^1.8.4", + "bento-auth-js": "github:bento-platform/bento_auth_js#feat/react-19-compat", "bento-charts": "github:bento-platform/bento_charts#chore/drop-legacy-react-leaflet-peerdep", "bento-file-display": "https://github.com/bento-platform/bento-file-display/releases/download/v0.5.0/bento-file-display-v0.5.0.tgz", "clsx": "^2.1.1", @@ -3013,6 +3015,22 @@ "node": ">=6.0.0" } }, + "node_modules/bento-auth-js": { + "version": "7.2.3", + "resolved": "git+ssh://git@github.com/bento-platform/bento_auth_js.git#c1d0508e797f247e292e37309405df7b034013b4", + "license": "MIT", + "dependencies": { + "jose": "^6.2.3" + }, + "peerDependencies": { + "@reduxjs/toolkit": "^2.5.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "react-redux": "^9.1.0", + "react-router-dom": "^6.21.1", + "redux": "^5.0.0" + } + }, "node_modules/bento-charts": { "version": "4.1.0", "resolved": "git+ssh://git@github.com/bento-platform/bento_charts.git#d57071aab1fbc2621aeb31f8126e2f5067a936c6", diff --git a/package.json b/package.json index efb863a85..9f18cd2fc 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "next start -p ${BENTO_PUBLIC_PORT:-5000}", "lint": "eslint src", "lint-fix": "eslint --fix src", - "prettier": "prettier --write src" + "prettier": "prettier --write src", + "postinstall": "test -f node_modules/bento-auth-js/dist/index.js || npm run build --prefix node_modules/bento-auth-js" }, "keywords": [], "author": "", @@ -20,6 +21,7 @@ "@types/geojson": "^7946.0.16", "antd": "^5.24.5", "axios": "^1.8.4", + "bento-auth-js": "github:bento-platform/bento_auth_js#feat/react-19-compat", "bento-charts": "github:bento-platform/bento_charts#chore/drop-legacy-react-leaflet-peerdep", "bento-file-display": "https://github.com/bento-platform/bento-file-display/releases/download/v0.5.0/bento-file-display-v0.5.0.tgz", "clsx": "^2.1.1", diff --git a/src/js/features/auth/authSlice.ts b/src/js/features/auth/authSlice.ts index a8d40444a..c8ef0780a 100644 --- a/src/js/features/auth/authSlice.ts +++ b/src/js/features/auth/authSlice.ts @@ -1,7 +1,6 @@ import { createAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import type { RootState } from '@/store'; -import { type Resource, makeResourceKey } from './resources'; -import { makeAuthorizationHeader } from './utils'; +import { type Resource, makeResourceKey, makeAuthorizationHeader } from 'bento-auth-js'; // Auth.js's own JWT session cookie is the source of truth for the session (access/id/refresh tokens) - it's not // duplicated into localStorage here. `accessToken` below is a synchronous *mirror* of that session's access token diff --git a/src/js/features/auth/hooks.ts b/src/js/features/auth/hooks.ts index b26eab9c7..f6b5aba6f 100644 --- a/src/js/features/auth/hooks.ts +++ b/src/js/features/auth/hooks.ts @@ -4,9 +4,8 @@ import { signIn, signOut, useSession } from 'next-auth/react'; import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; import type { AppDispatch, RootState } from '@/store'; -import { type Resource, makeResourceKey } from './resources'; +import { type Resource, makeResourceKey, makeAuthorizationHeader } from 'bento-auth-js'; import { fetchResourcesPermissions, setAccessToken } from './authSlice'; -import { makeAuthorizationHeader } from './utils'; const BENTO_OIDC_PROVIDER_ID = 'bento'; diff --git a/src/js/features/auth/permissions.ts b/src/js/features/auth/permissions.ts deleted file mode 100644 index 4a8ddfc89..000000000 --- a/src/js/features/auth/permissions.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Bento authorization-service permission strings. Ported from bento-auth-js: these are Bento domain constants, not -// part of the OIDC/session library, so they don't move with the rest of the auth stack to Auth.js. - -export const queryProjectLevelBoolean = 'query:project_level_boolean'; -export const queryDatasetLevelBoolean = 'query:dataset_level_boolean'; - -export const queryProjectLevelCounts = 'query:project_level_counts'; -export const queryDatasetLevelCounts = 'query:dataset_level_counts'; - -export const queryData = 'query:data'; -export const downloadData = 'download:data'; -export const deleteData = 'delete:data'; - -export const ingestData = 'ingest:data'; -export const analyzeData = 'analyze:data'; -export const exportData = 'export:data'; - -export const viewRuns = 'view:runs'; - -export const viewNotifications = 'view:notifications'; -export const createNotifications = 'create:notifications'; - -export const createProject = 'create:project'; -export const editProject = 'edit:project'; -export const deleteProject = 'delete:project'; - -export const createDataset = 'create:dataset'; -export const deleteDataset = 'delete:dataset'; -export const editDataset = 'edit:dataset'; - -export const viewPermissions = 'view:permissions'; -export const editPermissions = 'edit:permissions'; - -export const viewDropBox = 'view:drop_box'; -export const ingestDropBox = 'ingest:drop_box'; -export const deleteDropBox = 'delete:drop_box'; - -export const ingestReferenceMaterial = 'ingest:reference_material'; -export const deleteReferenceMaterial = 'delete:reference_material'; diff --git a/src/js/features/auth/resources.ts b/src/js/features/auth/resources.ts deleted file mode 100644 index 961844481..000000000 --- a/src/js/features/auth/resources.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Bento authorization-service resource shapes. Ported from bento-auth-js: these describe what's being asked -// permission for (a project, a dataset, "everything"), which is Bento's own authz contract, not an Auth.js concern. - -/** - * Resources are expressed as simple key-value pairs. - * Where the key is always a string identifying the resource type, - * and the value is either a string identifying the resource ID, - * or a boolean for everything. - * - * e.g. The resource for a project with ID "project-1" would be expressed as: - * { "project": "project-1" } - */ -export type Resource = { everything: true } | { project: string; dataset?: string; data_type?: string }; - -export const RESOURCE_EVERYTHING: Resource = { everything: true }; - -export const makeProjectResource = (projectId: string): Resource => { - return { - project: projectId, - }; -}; - -export const makeProjectDataTypeResource = (projectId: string, dataType: string): Resource => { - return { - ...makeProjectResource(projectId), - data_type: dataType, - }; -}; - -export const makeProjectDatasetResource = (projectId: string, datasetId: string): Resource => { - return { - ...makeProjectResource(projectId), - dataset: datasetId, - }; -}; - -export const makeProjectDatasetDataTypeResource = ( - projectId: string, - datasetId: string, - dataType: string -): Resource => { - return { - ...makeProjectDatasetResource(projectId, datasetId), - data_type: dataType, - }; -}; - -// Recursively sorts an object's keys (without touching array order) so two structurally-equal resources always -// serialize to the same string, regardless of property insertion order. -const recursiveOrderedObject = (x: unknown): unknown => { - if (Array.isArray(x)) { - return x.map((y) => recursiveOrderedObject(y)); - } else if (typeof x === 'object' && x !== null) { - return Object.keys(x) - .sort() - .reduce>((acc, y) => { - acc[y] = (x as Record)[y]; - return acc; - }, {}); - } else { - return x; - } -}; - -export const makeResourceKey = (x: Resource): string => JSON.stringify(recursiveOrderedObject(x)); diff --git a/src/js/features/auth/utils.ts b/src/js/features/auth/utils.ts deleted file mode 100644 index 5d8ee44e2..000000000 --- a/src/js/features/auth/utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const makeAuthorizationHeader = (token: string | null | undefined): Record => - token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/src/js/features/metadata/hooks.ts b/src/js/features/metadata/hooks.ts index ce5f7e4e7..7317ac330 100644 --- a/src/js/features/metadata/hooks.ts +++ b/src/js/features/metadata/hooks.ts @@ -1,10 +1,5 @@ import { useMemo } from 'react'; -import { - makeProjectDatasetResource, - makeProjectResource, - type Resource, - RESOURCE_EVERYTHING, -} from '@/features/auth/resources'; +import { makeProjectDatasetResource, makeProjectResource, type Resource, RESOURCE_EVERYTHING } from 'bento-auth-js'; import { useAppSelector } from '@/hooks'; import type { Project } from '@/types/metadata'; import type { Dataset } from '@/types/dataset'; diff --git a/src/js/hooks.ts b/src/js/hooks.ts index e43becef9..42b0c86db 100644 --- a/src/js/hooks.ts +++ b/src/js/hooks.ts @@ -2,9 +2,8 @@ import { useCallback, useEffect } from 'react'; import type { TypedUseSelectorHook } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import type { Resource } from '@/features/auth/resources'; -import { RESOURCE_EVERYTHING } from '@/features/auth/resources'; -import { queryData } from '@/features/auth/permissions'; +import type { Resource } from 'bento-auth-js'; +import { RESOURCE_EVERYTHING, queryData } from 'bento-auth-js'; import { useHasResourcePermission } from '@/features/auth/hooks'; import { filesize } from 'filesize'; diff --git a/src/js/hooks/censorship.ts b/src/js/hooks/censorship.ts index ee33914b5..28b6ff750 100644 --- a/src/js/hooks/censorship.ts +++ b/src/js/hooks/censorship.ts @@ -1,4 +1,4 @@ -import { downloadData, queryData } from '@/features/auth/permissions'; +import { downloadData, queryData } from 'bento-auth-js'; import { useConfig } from '@/features/config/hooks'; import { useHasScopePermission } from '@/hooks'; diff --git a/src/js/hooks/useDownloadSelectedMatches.ts b/src/js/hooks/useDownloadSelectedMatches.ts index 45f8cef41..d634373b1 100644 --- a/src/js/hooks/useDownloadSelectedMatches.ts +++ b/src/js/hooks/useDownloadSelectedMatches.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import axios from 'axios'; import FileSaver from 'file-saver'; import { useAccessToken } from '@/features/auth/hooks'; -import { makeAuthorizationHeader } from '@/features/auth/utils'; +import { makeAuthorizationHeader } from 'bento-auth-js'; import { biosampleBatchUrl, diff --git a/src/js/utils/requests.ts b/src/js/utils/requests.ts index 29d33b19f..ba29da651 100644 --- a/src/js/utils/requests.ts +++ b/src/js/utils/requests.ts @@ -1,5 +1,5 @@ import type { AxiosRequestConfig } from 'axios'; -import { makeAuthorizationHeader } from '@/features/auth/utils'; +import { makeAuthorizationHeader } from 'bento-auth-js'; import type { RootState } from '@/store'; import type { DiscoveryScope, DiscoveryScopeSelection } from '@/features/metadata/metadata.store'; import type { QueryParamEntries } from '@/features/search/types'; From 87fd4b1233c3ae81d1c8144f89d26dcb8f84e625 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 9 Sep 2026 13:14:06 -0400 Subject: [PATCH 07/18] refactor(public): remove redundant comments from Auth.js migration --- src/app/api/auth/end-session-url/route.ts | 3 --- src/js/auth.ts | 11 ----------- src/js/constants/configConstants.ts | 3 +-- src/js/features/auth/authSlice.ts | 7 ------- src/js/features/auth/hooks.ts | 6 +----- 5 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/app/api/auth/end-session-url/route.ts b/src/app/api/auth/end-session-url/route.ts index 387a58ec2..e90cd5084 100644 --- a/src/app/api/auth/end-session-url/route.ts +++ b/src/app/api/auth/end-session-url/route.ts @@ -1,8 +1,5 @@ import { CLIENT_ID, getEndSessionEndpoint } from '@/auth'; -// Small, session-independent lookup so the client can build an RP-initiated logout URL (killing the identity -// provider's own session on sign-out, not just this app's) without exposing the full OIDC discovery URL/config to -// the client the way bento-auth-js used to. export async function GET() { try { const endSessionEndpoint = await getEndSessionEndpoint(); diff --git a/src/js/auth.ts b/src/js/auth.ts index 449ff3369..3e4c45b95 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -1,13 +1,9 @@ import NextAuth from 'next-auth'; import type { OAuthConfig } from 'next-auth/providers'; -// Explicit import needed so `declare module 'next-auth/jwt'` below resolves under moduleResolution: "bundler" - -// without it, TS reports the augmentation target module as unresolvable even though it's a valid subpath. import type { JWT } from 'next-auth/jwt'; export const CLIENT_ID = process.env.CLIENT_ID ?? ''; const OPENID_CONFIG_URL = process.env.OPENID_CONFIG_URL ?? ''; -// OIDC discovery URLs are always `{issuer}/.well-known/openid-configuration` - Auth.js's own config validation -// requires `issuer` to be set explicitly even though `wellKnown` (below) is what it actually fetches at request time. const ISSUER = OPENID_CONFIG_URL.replace(/\/\.well-known\/.*$/, ''); type BentoTokenSet = { @@ -17,9 +13,6 @@ type BentoTokenSet = { expires_in: number; }; -// Bento's identity provider client is registered as a public client (PKCE, no secret) - the same client bento-auth-js -// used to drive its hand-rolled authorization code + PKCE flow from the browser. Auth.js drives that flow itself -// server-side now, so `token_endpoint_auth_method: "none"` tells it not to expect/send a client secret. const bentoProvider: OAuthConfig> = { id: 'bento', name: 'Bento', @@ -37,8 +30,6 @@ type OidcDiscoveryDocument = { end_session_endpoint?: string; }; -// The discovery document is cached the same way bento-auth-js cached it (a multi-hour TTL is plenty - this changes -// rarely, if ever, for a given deployment). let cachedDiscovery: { doc: OidcDiscoveryDocument; expiry: number } | undefined; const getDiscoveryDocument = async (): Promise => { @@ -54,8 +45,6 @@ const getDiscoveryDocument = async (): Promise => { const getTokenEndpoint = async (): Promise => (await getDiscoveryDocument()).token_endpoint; -// Used by /api/auth/end-session-url so the client can drive an RP-initiated logout (killing the identity -// provider's session, not just this app's) without OPENID_CONFIG_URL itself needing to be client-exposed. export const getEndSessionEndpoint = async (): Promise => (await getDiscoveryDocument()).end_session_endpoint; diff --git a/src/js/constants/configConstants.ts b/src/js/constants/configConstants.ts index 8ee28e5df..b3f807127 100644 --- a/src/js/constants/configConstants.ts +++ b/src/js/constants/configConstants.ts @@ -2,8 +2,7 @@ import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; export const MAX_CHARTS = 3; -// Mirrors the 2-minute interval bento-auth-js's tokenRefresh Web Worker used to ping on - SessionProvider's own -// refetch re-enters the jwt callback and refreshes the access token when needed, without a dedicated worker. +// Same as bento-auth-js export const SESSION_REFETCH_INTERVAL_SECONDS = 120; const katsuBaseUrl = `${PUBLIC_URL_NO_TRAILING_SLASH}/api/metadata`; diff --git a/src/js/features/auth/authSlice.ts b/src/js/features/auth/authSlice.ts index c8ef0780a..3a7d41c9c 100644 --- a/src/js/features/auth/authSlice.ts +++ b/src/js/features/auth/authSlice.ts @@ -2,13 +2,6 @@ import { createAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import type { RootState } from '@/store'; import { type Resource, makeResourceKey, makeAuthorizationHeader } from 'bento-auth-js'; -// Auth.js's own JWT session cookie is the source of truth for the session (access/id/refresh tokens) - it's not -// duplicated into localStorage here. `accessToken` below is a synchronous *mirror* of that session's access token -// (kept in sync by `useSyncAccessToken`, dispatched once near the app root), so that Redux thunks - which only have -// `getState()`, not React hook access to `useSession()` - can still attach an Authorization header the same way -// they did when bento-auth-js stored the token in Redux directly. This slice otherwise only caches Bento -// authorization-service resource-permission lookups. - type FetchPermissionsPayload = { result: string[][]; }; diff --git a/src/js/features/auth/hooks.ts b/src/js/features/auth/hooks.ts index f6b5aba6f..6a051b878 100644 --- a/src/js/features/auth/hooks.ts +++ b/src/js/features/auth/hooks.ts @@ -13,9 +13,7 @@ export const useIsAuthenticated = (): boolean => useSession().status === 'authen export const useAccessToken = (): string | undefined => useSession().data?.accessToken; -// Mirrors the Auth.js session's access token into Redux, so `getState()`-only Redux thunks (which can't call -// `useSession()` themselves) can still read it synchronously via `state.auth.accessToken`, the same way they did -// when bento-auth-js stored the token in Redux directly. Call this once, near the app root. +// Copies the Auth.js session's access token into Redux export const useSyncAccessToken = () => { const dispatch: AppDispatch = useDispatch(); const accessToken = useAccessToken(); @@ -100,8 +98,6 @@ export const useHasResourcePermission = ( export const usePerformAuth = () => useCallback(() => signIn(BENTO_OIDC_PROVIDER_ID), []); -// Signs out of this app *and* the identity provider (RP-initiated logout), so a re-visit doesn't silently pick the -// session back up. Falls back to a plain local sign-out if the provider doesn't support/expose end_session_endpoint. export const usePerformSignOut = () => { const { data: session } = useSession(); const idToken = session?.idToken; From 9c2cdba370401895a174b65d74c9c80fcda9a6af Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Fri, 11 Sep 2026 11:02:56 -0400 Subject: [PATCH 08/18] fix(public): force re-auth when refresh token becomes invalid Previously a RefreshTokenError just sat on the session, so every subsequent session check retried the same dead refresh token against the identity provider and logged the same invalid_grant error forever. --- src/js/App.tsx | 3 +++ src/js/features/auth/hooks.ts | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/js/App.tsx b/src/js/App.tsx index d26f64ea0..8776e3886 100644 --- a/src/js/App.tsx +++ b/src/js/App.tsx @@ -28,6 +28,7 @@ import ResponsiveProvider from '@/components/Util/ResponsiveProvider'; // Hooks and utilities imports import { NotificationProvider } from '@/hooks/notifications'; import { useSmallScreen } from '@/hooks/useResponsiveContext'; +import { useHandleRefreshTokenError } from '@/features/auth/hooks'; // Store and configuration imports import { store } from './store'; @@ -61,6 +62,8 @@ const InnerRootApp = () => { const antdLocale = i18n.language === SUPPORTED_LNGS.FRENCH ? frCA : enUS; const isSmallScreen = useSmallScreen(); + useHandleRefreshTokenError(); + // antd's ConfigProvider locale only translates UI text (buttons, placeholders); the DatePicker's // month/day names come from dayjs's own locale, which must be set separately or it stays English. useEffect(() => { diff --git a/src/js/features/auth/hooks.ts b/src/js/features/auth/hooks.ts index 6a051b878..58018c223 100644 --- a/src/js/features/auth/hooks.ts +++ b/src/js/features/auth/hooks.ts @@ -98,6 +98,15 @@ export const useHasResourcePermission = ( export const usePerformAuth = () => useCallback(() => signIn(BENTO_OIDC_PROVIDER_ID), []); +export const useHandleRefreshTokenError = () => { + const { data: session } = useSession(); + useEffect(() => { + if (session?.error === 'RefreshTokenError') { + signIn(BENTO_OIDC_PROVIDER_ID); + } + }, [session?.error]); +}; + export const usePerformSignOut = () => { const { data: session } = useSession(); const idToken = session?.idToken; From b459d2c309f97319a9c8909f0a271b4aede3569f Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 23 Sep 2026 12:11:19 -0400 Subject: [PATCH 09/18] fix(public): wait for the Auth.js session before fetching projects getProjects ran on mount, before the session resolved and its token was mirrored into Redux, so signed-in users' project list was fetched without an Authorization header and never refetched. Gate that fetch and the loader on a new useIsAuthReady hook, which stays true across token refreshes so it doesn't re-trigger fetches. --- src/js/components/BentoAppRouter.tsx | 9 ++++++--- src/js/features/auth/hooks.ts | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/js/components/BentoAppRouter.tsx b/src/js/components/BentoAppRouter.tsx index 3c78fbfba..1721b8f6c 100644 --- a/src/js/components/BentoAppRouter.tsx +++ b/src/js/components/BentoAppRouter.tsx @@ -1,6 +1,6 @@ import { lazy, useEffect } from 'react'; import { Routes, Route, useNavigate, useParams, Outlet } from 'react-router-dom'; -import { useIsAuthenticated } from '@/features/auth/hooks'; +import { useIsAuthenticated, useIsAuthReady } from '@/features/auth/hooks'; import { useAppDispatch, useLanguage } from '@/hooks'; import { @@ -106,6 +106,7 @@ const BentoAppRouter = () => { const language = useLanguage(); const isAuthenticated = useIsAuthenticated(); + const isAuthReady = useIsAuthReady(); const { selectedScope: { scope, scopeSet }, projectsByID, @@ -183,11 +184,13 @@ const BentoAppRouter = () => { }, [dispatch]); useEffect(() => { + // Projects are fetched with the user's token, so wait for the session to resolve first. + if (!isAuthReady) return; dispatch(resetProjects()); dispatch(getProjects(language)); - }, [dispatch, language]); + }, [dispatch, language, isAuthReady]); - if (projectsStatus === RequestStatus.Pending) { + if (!isAuthReady || projectsStatus === RequestStatus.Pending) { return ; } diff --git a/src/js/features/auth/hooks.ts b/src/js/features/auth/hooks.ts index 58018c223..768902ba0 100644 --- a/src/js/features/auth/hooks.ts +++ b/src/js/features/auth/hooks.ts @@ -22,6 +22,13 @@ export const useSyncAccessToken = () => { }, [dispatch, accessToken]); }; +export const useIsAuthReady = (): boolean => { + const { status } = useSession(); + const mirroredToken = useSelector((state: RootState) => state.auth.accessToken); + if (status === 'loading') return false; + return status !== 'authenticated' || !!mirroredToken; +}; + export const useAuthorizationHeader = () => { const accessToken = useAccessToken(); return useMemo(() => makeAuthorizationHeader(accessToken), [accessToken]); From d12f77ead205fa5a10d3a837af77211b542511d3 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 23 Sep 2026 12:59:05 -0400 Subject: [PATCH 10/18] refactor(public): make the session refetch interval an env var Read BENTO_PUBLIC_SESSION_REFETCH_INTERVAL_SECONDS (default 120) through the runtime config like the other display/auth settings, instead of a hardcoded constant. --- src/app/public/config.js/route.ts | 3 +++ src/js/App.tsx | 4 ++-- src/js/config.ts | 8 ++++++++ src/js/constants/configConstants.ts | 3 --- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app/public/config.js/route.ts b/src/app/public/config.js/route.ts index 7076e238d..d12969184 100644 --- a/src/app/public/config.js/route.ts +++ b/src/app/public/config.js/route.ts @@ -35,6 +35,9 @@ export function GET() { BEACON_URL: process.env.BEACON_URL || null, BEACON_UI_ENABLED: parseBoolean(process.env.BENTO_BEACON_UI_ENABLED), BEACON_NETWORK_ENABLED: parseBoolean(process.env.BENTO_BEACON_NETWORK_ENABLED), + + // Authentication + SESSION_REFETCH_INTERVAL_SECONDS: process.env.BENTO_PUBLIC_SESSION_REFETCH_INTERVAL_SECONDS || '120', }; return new Response(`BENTO_PUBLIC_CONFIG = ${JSON.stringify(siteConfig, null, 2)};\n`, { diff --git a/src/js/App.tsx b/src/js/App.tsx index 8776e3886..10b1b6567 100644 --- a/src/js/App.tsx +++ b/src/js/App.tsx @@ -11,7 +11,7 @@ import { SessionProvider } from 'next-auth/react'; // i18n and constants imports import { useTranslation } from 'react-i18next'; import { NEW_BENTO_PUBLIC_THEME } from '@/constants/exploreConstants'; -import { SESSION_REFETCH_INTERVAL_SECONDS, SUPPORTED_LNGS } from '@/constants/configConstants'; +import { SUPPORTED_LNGS } from '@/constants/configConstants'; // Component imports import { ConfigProvider } from 'antd'; @@ -32,7 +32,7 @@ import { useHandleRefreshTokenError } from '@/features/auth/hooks'; // Store and configuration imports import { store } from './store'; -import { PCGL_MODE } from './config'; +import { PCGL_MODE, SESSION_REFETCH_INTERVAL_SECONDS } from './config'; // Styles imports import 'antd/dist/reset.css'; diff --git a/src/js/config.ts b/src/js/config.ts index de2737599..2d0017bdd 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -22,6 +22,8 @@ export interface PublicConfig { BEACON_URL: string | null; BEACON_UI_ENABLED: boolean; BEACON_NETWORK_ENABLED: boolean; + // Authentication + SESSION_REFETCH_INTERVAL_SECONDS: string; // Seconds between session refreshes, as an integer string: e.g., '120' } // Declaration required for global config @@ -60,3 +62,9 @@ export const BEACON_UI_ENABLED = BENTO_PUBLIC_CONFIG.BEACON_UI_ENABLED ?? stringToBoolean(process.env.BENTO_BEACON_UI_ENABLED); export const BEACON_NETWORK_ENABLED = BENTO_PUBLIC_CONFIG.BEACON_NETWORK_ENABLED ?? stringToBoolean(process.env.BENTO_BEACON_NETWORK_ENABLED); + +// Authentication +export const SESSION_REFETCH_INTERVAL_SECONDS = parseInt( + (BENTO_PUBLIC_CONFIG.SESSION_REFETCH_INTERVAL_SECONDS ?? process.env.BENTO_PUBLIC_SESSION_REFETCH_INTERVAL_SECONDS) || + '120' +); diff --git a/src/js/constants/configConstants.ts b/src/js/constants/configConstants.ts index b3f807127..cbf3a3e9f 100644 --- a/src/js/constants/configConstants.ts +++ b/src/js/constants/configConstants.ts @@ -2,9 +2,6 @@ import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; export const MAX_CHARTS = 3; -// Same as bento-auth-js -export const SESSION_REFETCH_INTERVAL_SECONDS = 120; - const katsuBaseUrl = `${PUBLIC_URL_NO_TRAILING_SLASH}/api/metadata`; // Katsu discovery URLs From 1ad5662ed82460540be13425673331618ad6363b Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 23 Sep 2026 13:28:30 -0400 Subject: [PATCH 11/18] refactor(public): share the OIDC provider id between Auth.js config and sign-in The provider id was written as a literal in both auth.ts and the client hooks, and it also forms the /api/auth/callback/ path. Move it to a dependency-free constants module both sides can import. --- src/js/auth.ts | 3 ++- src/js/features/auth/constants.ts | 2 ++ src/js/features/auth/hooks.ts | 3 +-- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 src/js/features/auth/constants.ts diff --git a/src/js/auth.ts b/src/js/auth.ts index 3e4c45b95..25f9e126f 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -1,6 +1,7 @@ import NextAuth from 'next-auth'; import type { OAuthConfig } from 'next-auth/providers'; import type { JWT } from 'next-auth/jwt'; +import { BENTO_OIDC_PROVIDER_ID } from '@/features/auth/constants'; export const CLIENT_ID = process.env.CLIENT_ID ?? ''; const OPENID_CONFIG_URL = process.env.OPENID_CONFIG_URL ?? ''; @@ -14,7 +15,7 @@ type BentoTokenSet = { }; const bentoProvider: OAuthConfig> = { - id: 'bento', + id: BENTO_OIDC_PROVIDER_ID, name: 'Bento', type: 'oidc', issuer: ISSUER, diff --git a/src/js/features/auth/constants.ts b/src/js/features/auth/constants.ts new file mode 100644 index 000000000..c41861579 --- /dev/null +++ b/src/js/features/auth/constants.ts @@ -0,0 +1,2 @@ +// Server and client both import this (keep it dependency-free); the id also forms /api/auth/callback/. +export const BENTO_OIDC_PROVIDER_ID = 'bento'; diff --git a/src/js/features/auth/hooks.ts b/src/js/features/auth/hooks.ts index 768902ba0..1cb9c24ce 100644 --- a/src/js/features/auth/hooks.ts +++ b/src/js/features/auth/hooks.ts @@ -6,8 +6,7 @@ import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; import type { AppDispatch, RootState } from '@/store'; import { type Resource, makeResourceKey, makeAuthorizationHeader } from 'bento-auth-js'; import { fetchResourcesPermissions, setAccessToken } from './authSlice'; - -const BENTO_OIDC_PROVIDER_ID = 'bento'; +import { BENTO_OIDC_PROVIDER_ID } from './constants'; export const useIsAuthenticated = (): boolean => useSession().status === 'authenticated'; From 2412a42082111ccb0a4e82744fd6113fb05371a0 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 30 Sep 2026 14:04:07 -0400 Subject: [PATCH 12/18] refactor(public): rename bentoProvider to bentoOidcProvider for clarity --- src/js/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/auth.ts b/src/js/auth.ts index 25f9e126f..b1cb96df3 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -14,7 +14,7 @@ type BentoTokenSet = { expires_in: number; }; -const bentoProvider: OAuthConfig> = { +const bentoOidcProvider: OAuthConfig> = { id: BENTO_OIDC_PROVIDER_ID, name: 'Bento', type: 'oidc', @@ -70,7 +70,7 @@ const refreshAccessToken = async (refreshToken: string): Promise export const { handlers, auth, signIn, signOut } = NextAuth({ trustHost: true, - providers: [bentoProvider], + providers: [bentoOidcProvider], session: { strategy: 'jwt' }, callbacks: { async jwt({ token, account }): Promise { From f495d4a2fdeea3935b19e302635c2f0212fe2be8 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 30 Sep 2026 14:05:26 -0400 Subject: [PATCH 13/18] docs(public): note the discovery document cache duration --- src/js/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/auth.ts b/src/js/auth.ts index b1cb96df3..ed912bc4e 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -40,7 +40,7 @@ const getDiscoveryDocument = async (): Promise => { const res = await fetch(OPENID_CONFIG_URL); if (!res.ok) throw new Error('Could not fetch identity provider configuration'); const doc = (await res.json()) as OidcDiscoveryDocument; - cachedDiscovery = { doc, expiry: Date.now() + 3 * 60 * 60 * 1000 }; + cachedDiscovery = { doc, expiry: Date.now() + 3 * 60 * 60 * 1000 }; // cache for 3 hours return doc; }; From 37d439bae4575d5c9ece3cc216b9299f103eb6d2 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 30 Sep 2026 14:14:25 -0400 Subject: [PATCH 14/18] docs(public): credit bento-auth-js for the ported permission hooks --- src/js/features/auth/hooks.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js/features/auth/hooks.ts b/src/js/features/auth/hooks.ts index 1cb9c24ce..9dc056ef6 100644 --- a/src/js/features/auth/hooks.ts +++ b/src/js/features/auth/hooks.ts @@ -1,3 +1,6 @@ +// The resource-permission fetch/cache hooks below (useResourcesPermissions, useResourcePermissions, +// useHasResourcePermission) are adapted from bento-auth-js's own hooks.ts, ported here to read the access token +// from the Auth.js session instead of bento-auth-js's Redux auth slice. import { useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { signIn, signOut, useSession } from 'next-auth/react'; From bae01598f959d2a2be0c8ed48a953e4329959163 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 30 Sep 2026 14:26:04 -0400 Subject: [PATCH 15/18] docs(public): combine session/JWT module augmentation comments --- src/js/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/js/auth.ts b/src/js/auth.ts index ed912bc4e..c5268b8eb 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -126,6 +126,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }); +// Defines the shape of the session and JWT for Bento Public - without this, accessing session.accessToken, +// token.access_token, etc. elsewhere would be a type error. declare module 'next-auth' { interface Session { accessToken?: string; From a562795db52f65de2fb96be5f2c1d1d785292191 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Wed, 30 Sep 2026 14:33:05 -0400 Subject: [PATCH 16/18] fix(public): use our own useAccessToken in TracksView, not bento-auth-js's Main added IGV track support using bento-auth-js's useAccessToken, which reads from a Redux auth slice bento-auth-js no longer populates on this branch. It happened to work by coincidence (our slice also has an accessToken field), but relies on an internal implementation detail rather than the session. Use our own hook instead. --- src/js/components/ClinPhen/TracksDisplay/TracksView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/components/ClinPhen/TracksDisplay/TracksView.tsx b/src/js/components/ClinPhen/TracksDisplay/TracksView.tsx index 84746f1d0..737beafe5 100644 --- a/src/js/components/ClinPhen/TracksDisplay/TracksView.tsx +++ b/src/js/components/ClinPhen/TracksDisplay/TracksView.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Flex, Typography } from 'antd'; -import { useAccessToken } from 'bento-auth-js'; +import { useAccessToken } from '@/features/auth/hooks'; import igv from 'igv/dist/igv.esm.js'; import type { Browser, CreateOpt } from 'igv'; import { saveIgvPosition } from '@/features/igv/igv.store'; From 5b9782722a1fdb6a2bff6507da06e069938602c6 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Thu, 1 Oct 2026 13:11:00 -0400 Subject: [PATCH 17/18] fix(public): keep Sign Out button in a loading state through the redirect performSignOut() clears the session client-side before redirecting to the identity provider to end its session too, so isAuthenticated flips false mid-callback and the button flashed to Sign In right before the page navigated away. Track sign-out locally and keep the button loading instead. --- src/js/components/SiteHeader.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/js/components/SiteHeader.tsx b/src/js/components/SiteHeader.tsx index 1964974f5..3ab36516c 100644 --- a/src/js/components/SiteHeader.tsx +++ b/src/js/components/SiteHeader.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Button, Flex, Layout, Menu, type MenuProps, Space, Typography, theme } from 'antd'; @@ -81,6 +81,15 @@ const SiteHeader = ({ menuItems }: SiteHeaderProps) => { const performSignOut = usePerformSignOut(); const performSignIn = usePerformAuth(); + // performSignOut() clears our session before it redirects to the identity provider to end its session too, so + // isAuthenticated flips to false mid-callback - without this, the button would flash to "Sign In" and back before + // the page actually navigates away. + const [isSigningOut, setIsSigningOut] = useState(false); + const handleSignOut = useCallback(() => { + setIsSigningOut(true); + performSignOut(); + }, [performSignOut]); + const { token: { colorBgContainer, colorBorderSecondary }, } = theme.useToken(); @@ -181,14 +190,15 @@ const SiteHeader = ({ menuItems }: SiteHeaderProps) => { )} {SHOW_SIGN_IN && - (isAuthenticated ? ( + (isAuthenticated || isSigningOut ? ( From 4190f9a5537b60fa0e9d98d7533123a61f332135 Mon Sep 17 00:00:00 2001 From: Sanjeev Lakhwani Date: Fri, 2 Oct 2026 11:35:00 -0400 Subject: [PATCH 18/18] docs(public): explain jwt/session callbacks and add safety note + TODO --- src/js/auth.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/js/auth.ts b/src/js/auth.ts index c5268b8eb..b87acd7a1 100644 --- a/src/js/auth.ts +++ b/src/js/auth.ts @@ -73,6 +73,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [bentoOidcProvider], session: { strategy: 'jwt' }, callbacks: { + // The returned token is the entire session, stored in an encrypted cookie sent to the frontend (wrapper JWT). + // See: https://github.com/nextauthjs/next-auth/blob/a1a16a5a7780488c7449feece410033f445d0b31/packages/core/src/index.ts#L424-L433 async jwt({ token, account }): Promise { if (account) { // First-time sign-in: seed the token from the just-completed authorization code exchange. @@ -115,6 +117,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ return { ...token, error: 'RefreshTokenError' as const }; } }, + // SAFETY: the value returned here is what gets exposed to the client (via useSession / the /api/auth/session + // endpoint), so only put things in it that are safe to be visible in the browser. Never spread the whole `token` + // in - it holds the refresh token. Pick fields explicitly. + // TODO: stop sending the access token (and ID token) to the front end; make authenticated calls to the backend + // from server-side code instead, where the token can be read from the JWT without leaving the server. async session({ session, token }) { return { ...session,