diff --git a/package-lock.json b/package-lock.json index 979a129ed..5f33a178f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "igv": "^3.8.0", "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 +160,35 @@ "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.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", @@ -1666,6 +1696,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", @@ -8197,6 +8236,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/node-abi": { "version": "3.96.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", @@ -8256,6 +8322,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", @@ -8630,6 +8705,25 @@ "node": "^10 || ^12 || >=14" } }, + "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 ced1c74c5..6fe4d9a0d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,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/app/api/auth/end-session-url/route.ts b/src/app/api/auth/end-session-url/route.ts new file mode 100644 index 000000000..e90cd5084 --- /dev/null +++ b/src/app/api/auth/end-session-url/route.ts @@ -0,0 +1,11 @@ +import { CLIENT_ID, getEndSessionEndpoint } from '@/auth'; + +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..d12969184 100644 --- a/src/app/public/config.js/route.ts +++ b/src/app/public/config.js/route.ts @@ -37,8 +37,7 @@ export function GET() { 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, + 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 9c91a5961..10b1b6567 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'; @@ -19,20 +20,19 @@ 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 { BentoAuthContext } from 'bento-auth-js'; import { NotificationProvider } from '@/hooks/notifications'; import { useSmallScreen } from '@/hooks/useResponsiveContext'; +import { useHandleRefreshTokenError } from '@/features/auth/hooks'; // 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, SESSION_REFETCH_INTERVAL_SECONDS } from './config'; // Styles imports import 'antd/dist/reset.css'; @@ -47,7 +47,6 @@ const BaseRoutes = () => { return ( }> - } /> }> } /> } /> @@ -63,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(() => { @@ -93,24 +94,15 @@ const InnerRootApp = () => { }; const RootApp = () => ( - - - - + + + + - - - - + + + + ); export default RootApp; diff --git a/src/js/auth.ts b/src/js/auth.ts new file mode 100644 index 000000000..b87acd7a1 --- /dev/null +++ b/src/js/auth.ts @@ -0,0 +1,154 @@ +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 ?? ''; +const ISSUER = OPENID_CONFIG_URL.replace(/\/\.well-known\/.*$/, ''); + +type BentoTokenSet = { + access_token: string; + refresh_token?: string; + id_token: string; + expires_in: number; +}; + +const bentoOidcProvider: OAuthConfig> = { + id: BENTO_OIDC_PROVIDER_ID, + name: 'Bento', + type: 'oidc', + issuer: ISSUER, + wellKnown: OPENID_CONFIG_URL, + clientId: CLIENT_ID, + checks: ['pkce', 'state'], + client: { token_endpoint_auth_method: 'none' }, + authorization: { params: { scope: 'openid email' } }, +}; + +type OidcDiscoveryDocument = { + token_endpoint: string; + end_session_endpoint?: string; +}; + +let cachedDiscovery: { doc: OidcDiscoveryDocument; expiry: number } | undefined; + +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 doc = (await res.json()) as OidcDiscoveryDocument; + cachedDiscovery = { doc, expiry: Date.now() + 3 * 60 * 60 * 1000 }; // cache for 3 hours + return doc; +}; + +const getTokenEndpoint = async (): Promise => (await getDiscoveryDocument()).token_endpoint; + +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, { + 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: [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. + 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 }; + } + }, + // 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, + accessToken: token.access_token, + idToken: token.id_token, + error: token.error, + }; + }, + }, +}); + +// 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; + 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'; + } +} 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 a36680dbc..41236e1a8 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, useIsAuthReady } from '@/features/auth/hooks'; import { useAppDispatch, useLanguage } from '@/hooks'; import { @@ -106,8 +106,8 @@ const BentoAppRouter = () => { const dispatch = useAppDispatch(); const language = useLanguage(); - const { isAutoAuthenticating } = useAutoAuthenticate(); const isAuthenticated = useIsAuthenticated(); + const isAuthReady = useIsAuthReady(); const { selectedScope: { scope, scopeSet }, projectsByID, @@ -186,11 +186,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 (isAutoAuthenticating || projectsStatus === RequestStatus.Pending) { + if (!isAuthReady || projectsStatus === RequestStatus.Pending) { return ; } 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'; diff --git a/src/js/components/SiteHeader.tsx b/src/js/components/SiteHeader.tsx index 4f59009ca..3ab36516c 100644 --- a/src/js/components/SiteHeader.tsx +++ b/src/js/components/SiteHeader.tsx @@ -1,8 +1,9 @@ -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'; -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,13 +75,21 @@ 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(); 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,20 +190,21 @@ const SiteHeader = ({ menuItems }: SiteHeaderProps) => { )} {SHOW_SIGN_IN && - (isAuthenticated ? ( + (isAuthenticated || isSigningOut ? ( ) : ( ))} 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 31565f2ff..377c4a22c 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 10c8c1440..e55dd6e0c 100644 --- a/src/js/components/Util/FileModal.tsx +++ b/src/js/components/Util/FileModal.tsx @@ -2,7 +2,7 @@ import type { CSSProperties, ReactNode } from 'react'; import { lazy, Suspense } from 'react'; import { Modal, Row, Spin, type ModalProps } from 'antd'; -import { useAuthorizationHeader } from 'bento-auth-js'; +import { useAuthorizationHeader } from '@/features/auth/hooks'; import { useDrsHttpsAccessOrPassThrough } from '@/features/drs/hooks'; const FileDisplay = lazy(() => import('bento-file-display').then((m) => ({ default: m.FileDisplay }))); diff --git a/src/js/config.ts b/src/js/config.ts index 8824d6071..2d0017bdd 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -23,8 +23,7 @@ export interface PublicConfig { BEACON_UI_ENABLED: boolean; BEACON_NETWORK_ENABLED: boolean; // Authentication - CLIENT_ID: string | null; - OPENID_CONFIG_URL: string | null; + SESSION_REFETCH_INTERVAL_SECONDS: string; // Seconds between session refreshes, as an integer string: e.g., '120' } // Declaration required for global config @@ -65,6 +64,7 @@ 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`; +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/features/auth/authSlice.ts b/src/js/features/auth/authSlice.ts new file mode 100644 index 000000000..3a7d41c9c --- /dev/null +++ b/src/js/features/auth/authSlice.ts @@ -0,0 +1,110 @@ +import { createAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import type { RootState } from '@/store'; +import { type Resource, makeResourceKey, makeAuthorizationHeader } from 'bento-auth-js'; + +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/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 new file mode 100644 index 000000000..9dc056ef6 --- /dev/null +++ b/src/js/features/auth/hooks.ts @@ -0,0 +1,148 @@ +// 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'; + +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'; +import { BENTO_OIDC_PROVIDER_ID } from './constants'; + +export const useIsAuthenticated = (): boolean => useSession().status === 'authenticated'; + +export const useAccessToken = (): string | undefined => useSession().data?.accessToken; + +// Copies the Auth.js session's access token into Redux +export const useSyncAccessToken = () => { + const dispatch: AppDispatch = useDispatch(); + const accessToken = useAccessToken(); + useEffect(() => { + dispatch(setAccessToken(accessToken)); + }, [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]); +}; + +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), []); + +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; + + 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/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..42b0c86db 100644 --- a/src/js/hooks.ts +++ b/src/js/hooks.ts @@ -3,7 +3,8 @@ 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 { RESOURCE_EVERYTHING, queryData } from 'bento-auth-js'; +import { useHasResourcePermission } from '@/features/auth/hooks'; import { filesize } from 'filesize'; import { useSelectedScopeAsResource } from '@/features/metadata/hooks'; diff --git a/src/js/hooks/useDownloadSelectedMatches.ts b/src/js/hooks/useDownloadSelectedMatches.ts index beb5c8aae..d634373b1 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 { useAccessToken } from '@/features/auth/hooks'; import { makeAuthorizationHeader } from 'bento-auth-js'; -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 cb030d811..58ba4833b 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'; @@ -22,23 +16,11 @@ import metadataReducer from '@/features/metadata/metadata.store'; import reference from '@/features/reference/reference.store'; import igvState from './features/igv/igv.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, @@ -53,7 +35,6 @@ export const store = configureStore({ igv: igvState, ui, }, - preloadedState: persistedState, }); export type RootState = ReturnType; @@ -90,18 +71,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/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 {};