diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 1f8cf44dd..b7d20055b 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -12,6 +12,7 @@ import { queryClient } from './config/query-client'; import { themeConfig } from './config/themeConfig'; import { AuthContextProvider } from './features/auth/context/auth-context-provider'; import { ProtectedRoute } from './features/auth/protected-route'; +import { TokenRenew } from './features/auth/token-renew'; import { MainLayout } from './features/common/layout/main-layout'; import { Login } from './pages/login/login'; import { Projects } from './pages/projects'; @@ -35,6 +36,7 @@ export const App = () => ( } /> + } /> diff --git a/ui/src/config/auth.ts b/ui/src/config/auth.ts new file mode 100644 index 000000000..60ba749f0 --- /dev/null +++ b/ui/src/config/auth.ts @@ -0,0 +1,4 @@ +export const authTokenKey = 'auth_token'; +export const refreshTokenKey = 'refresh_token'; + +export const redirectToQueryParam = 'redirectTo'; diff --git a/ui/src/config/paths.ts b/ui/src/config/paths.ts index e373f7a0a..97dee9e97 100644 --- a/ui/src/config/paths.ts +++ b/ui/src/config/paths.ts @@ -4,5 +4,6 @@ export const paths = { project: '/project/:name', stage: '/project/:name/stage/:stageName', - login: '/login' + login: '/login', + tokenRenew: '/token-renew' }; diff --git a/ui/src/config/transport.ts b/ui/src/config/transport.ts index 39a1773ef..206d0c2fb 100644 --- a/ui/src/config/transport.ts +++ b/ui/src/config/transport.ts @@ -2,17 +2,23 @@ import { Code, ConnectError, Interceptor } from '@bufbuild/connect'; import { createConnectTransport } from '@bufbuild/connect-web'; import { notification } from 'antd'; +import { authTokenKey, redirectToQueryParam, refreshTokenKey } from './auth'; import { paths } from './paths'; -export const authTokenKey = 'auth_token'; - const logout = () => { localStorage.removeItem(authTokenKey); window.location.replace(paths.login); }; -const authHandler: Interceptor = (next) => (req) => { +const renewToken = () => { + window.location.replace( + `${paths.tokenRenew}?${redirectToQueryParam}=${window.location.pathname}` + ); +}; + +const authHandler: Interceptor = (next) => async (req) => { const token = localStorage.getItem(authTokenKey); + const refreshToken = localStorage.getItem(refreshTokenKey); let isTokenExpired; try { @@ -23,9 +29,13 @@ const authHandler: Interceptor = (next) => (req) => { throw new ConnectError('Invalid token'); } - if (isTokenExpired) { - logout(); + if (isTokenExpired && refreshToken) { + renewToken(); + throw new ConnectError('Token expired'); + } + if (isTokenExpired && !refreshToken) { + logout(); throw new ConnectError('Token expired'); } @@ -56,6 +66,11 @@ const errorHandler: Interceptor = (next) => (req) => { }; export const transport = createConnectTransport({ + baseUrl: '', + interceptors: [errorHandler] +}); + +export const transportWithAuth = createConnectTransport({ baseUrl: '', interceptors: [authHandler, errorHandler] }); diff --git a/ui/src/features/auth/context/auth-context-provider.tsx b/ui/src/features/auth/context/auth-context-provider.tsx index 8c3179130..e54378c29 100644 --- a/ui/src/features/auth/context/auth-context-provider.tsx +++ b/ui/src/features/auth/context/auth-context-provider.tsx @@ -1,19 +1,26 @@ import React, { PropsWithChildren } from 'react'; -import { authTokenKey } from '@ui/config/transport'; +import { authTokenKey, refreshTokenKey } from '@ui/config/auth'; import { AuthContext } from './auth-context'; export const AuthContextProvider = ({ children }: PropsWithChildren) => { const [token, setToken] = React.useState(localStorage.getItem(authTokenKey)); - const login = React.useCallback((t: string) => { - localStorage.setItem(authTokenKey, t); - setToken(t); + const login = React.useCallback((token: string, refreshToken?: string) => { + localStorage.setItem(authTokenKey, token); + + if (refreshToken) { + localStorage.setItem(refreshTokenKey, refreshToken); + } + + setToken(token); }, []); const logout = React.useCallback(() => { localStorage.removeItem(authTokenKey); + localStorage.removeItem(refreshTokenKey); + setToken(null); }, []); diff --git a/ui/src/features/auth/context/auth-context.tsx b/ui/src/features/auth/context/auth-context.tsx index 078d48801..2988ace35 100644 --- a/ui/src/features/auth/context/auth-context.tsx +++ b/ui/src/features/auth/context/auth-context.tsx @@ -2,7 +2,7 @@ import React from 'react'; export interface AuthContextType { isLoggedIn: boolean; - login: (token: string) => void; + login: (token: string, authToken?: string) => void; logout: () => void; } diff --git a/ui/src/features/auth/oidc-login.tsx b/ui/src/features/auth/oidc-login.tsx index 26915db8c..fb3e5ad60 100644 --- a/ui/src/features/auth/oidc-login.tsx +++ b/ui/src/features/auth/oidc-login.tsx @@ -79,7 +79,14 @@ export const OIDCLogin = ({ oidcConfig }: Props) => { url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('redirect_uri', redirectURI); url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', oidcConfig.scopes.join(' ')); + url.searchParams.set( + 'scope', + [ + ...oidcConfig.scopes, + // Add offline_access scope if it does not exist + ...(oidcConfig.scopes.includes('offline_access') ? [] : ['offline_access']) + ].join(' ') + ); window.location.replace(url.toString()); }; @@ -134,7 +141,7 @@ export const OIDCLogin = ({ oidcConfig }: Props) => { return; } - onLogin(result.id_token); + onLogin(result.id_token, result.refresh_token); })(); }, [as, client, location]); diff --git a/ui/src/features/auth/protected-route.tsx b/ui/src/features/auth/protected-route.tsx index b665e16be..38944ab0c 100644 --- a/ui/src/features/auth/protected-route.tsx +++ b/ui/src/features/auth/protected-route.tsx @@ -1,6 +1,8 @@ +import { TransportProvider } from '@bufbuild/connect-query'; import { Navigate, Outlet } from 'react-router-dom'; import { paths } from '@ui/config/paths'; +import { transportWithAuth } from '@ui/config/transport'; import { useAuthContext } from './context/use-auth-context'; @@ -11,5 +13,9 @@ export const ProtectedRoute = () => { return ; } - return ; + return ( + + + + ); }; diff --git a/ui/src/features/auth/token-renew.tsx b/ui/src/features/auth/token-renew.tsx new file mode 100644 index 000000000..e1beea4d8 --- /dev/null +++ b/ui/src/features/auth/token-renew.tsx @@ -0,0 +1,102 @@ +import { useQuery } from '@tanstack/react-query'; +import { notification } from 'antd'; +import * as oauth from 'oauth4webapi'; +import React from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { redirectToQueryParam, refreshTokenKey } from '@ui/config/auth'; +import { paths } from '@ui/config/paths'; +import { getPublicConfig } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; + +import { LoadingState } from '../common'; + +import { useAuthContext } from './context/use-auth-context'; + +export const TokenRenew = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { login: onLogin, logout } = useAuthContext(); + + const { data, isError } = useQuery(getPublicConfig.useQuery()); + + const issuerUrl = React.useMemo(() => { + try { + return data?.oidcConfig?.issuerUrl ? new URL(data?.oidcConfig?.issuerUrl) : undefined; + } catch (err) { + notification.error({ + message: 'Invalid issuerURL', + placement: 'bottomRight' + }); + } + }, [data?.oidcConfig?.issuerUrl]); + + const client = React.useMemo( + () => + data?.oidcConfig?.clientId + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + { client_id: data?.oidcConfig?.clientId, token_endpoint_auth_method: 'none' as any } + : undefined, + [data?.oidcConfig?.clientId] + ); + + const { data: as, isError: isASError } = useQuery({ + queryKey: [issuerUrl], + queryFn: () => + issuerUrl && + oauth + .discoveryRequest(issuerUrl) + .then((response) => oauth.processDiscoveryResponse(issuerUrl, response)) + .then((response) => { + if (response.code_challenge_methods_supported?.includes('S256') !== true) { + throw new Error('OIDC config fetch error'); + } + + return response; + }), + enabled: !!issuerUrl + }); + + React.useEffect(() => { + const refreshToken = localStorage.getItem(refreshTokenKey); + + if (!refreshToken) { + navigate(paths.home); + + return; + } + + if (!as || !client) { + return; + } + + (async () => { + const response = await oauth.refreshTokenGrantRequest(as, client, refreshToken); + + const result = await oauth.processRefreshTokenResponse(as, client, response); + if (oauth.isOAuth2Error(result) || !result.id_token) { + notification.error({ + message: 'OIDC: Proccess Authorization Code Grant Response error', + placement: 'bottomRight' + }); + logout(); + return; + } + + onLogin(result.id_token, result.refresh_token); + navigate(searchParams.get(redirectToQueryParam) || paths.home); + })(); + }, [as, client]); + + React.useEffect(() => { + if (isError || isASError) { + logout(); + navigate(paths.login); + } + }, [isError, isASError]); + + return ( +
+ +
+ ); +}; diff --git a/ui/src/features/project/project-details/project-details.tsx b/ui/src/features/project/project-details/project-details.tsx index 51c9c36b7..f65512b57 100644 --- a/ui/src/features/project/project-details/project-details.tsx +++ b/ui/src/features/project/project-details/project-details.tsx @@ -19,7 +19,7 @@ import { graphlib, layout } from 'dagre'; import React from 'react'; import { useParams } from 'react-router-dom'; -import { transport } from '@ui/config/transport'; +import { transportWithAuth } from '@ui/config/transport'; import { ColorContext } from '@ui/context/colors'; import { LoadingState } from '@ui/features/common'; import { getAlias } from '@ui/features/common/freight-label'; @@ -108,7 +108,7 @@ export const ProjectDetails = () => { const cancel = new AbortController(); const watchStages = async () => { - const promiseClient = createPromiseClient(KargoService, transport); + const promiseClient = createPromiseClient(KargoService, transportWithAuth); const stream = promiseClient.watchStages({ project: name }, { signal: cancel.signal }); let stages = data.stages.slice(); diff --git a/ui/src/features/stage/promotions.tsx b/ui/src/features/stage/promotions.tsx index 6d8e49eae..00c30fd3f 100644 --- a/ui/src/features/stage/promotions.tsx +++ b/ui/src/features/stage/promotions.tsx @@ -13,7 +13,7 @@ import { format } from 'date-fns'; import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { transport } from '@ui/config/transport'; +import { transportWithAuth } from '@ui/config/transport'; import { listPromotions } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; import { KargoService } from '@ui/gen/service/v1alpha1/service_connect'; import { ListPromotionsResponse } from '@ui/gen/service/v1alpha1/service_pb'; @@ -36,7 +36,7 @@ export const Promotions = () => { const cancel = new AbortController(); const watchPromotions = async () => { - const promiseClient = createPromiseClient(KargoService, transport); + const promiseClient = createPromiseClient(KargoService, transportWithAuth); const stream = promiseClient.watchPromotions( { project: projectName, stage: stageName }, { signal: cancel.signal }