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 }