Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): Refresh token exchange #1171

Merged
merged 2 commits into from
Dec 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,6 +36,7 @@ export const App = () => (
</Route>
</Route>
<Route path={paths.login} element={<Login />} />
<Route path={paths.tokenRenew} element={<TokenRenew />} />
</Routes>
</BrowserRouter>
</AuthContextProvider>
Expand Down
4 changes: 4 additions & 0 deletions ui/src/config/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const authTokenKey = 'auth_token';
export const refreshTokenKey = 'refresh_token';

export const redirectToQueryParam = 'redirectTo';
3 changes: 2 additions & 1 deletion ui/src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const paths = {
project: '/project/:name',
stage: '/project/:name/stage/:stageName',

login: '/login'
login: '/login',
tokenRenew: '/token-renew'
};
25 changes: 20 additions & 5 deletions ui/src/config/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
}

Expand Down Expand Up @@ -56,6 +66,11 @@ const errorHandler: Interceptor = (next) => (req) => {
};

export const transport = createConnectTransport({
baseUrl: '',
interceptors: [errorHandler]
});

export const transportWithAuth = createConnectTransport({
baseUrl: '',
interceptors: [authHandler, errorHandler]
});
15 changes: 11 additions & 4 deletions ui/src/features/auth/context/auth-context-provider.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +13 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never getting set for me, but I haven't quite figured out why.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And my dex is definitely supporting it:

{
  "issuer": "https://localhost:30081/dex",
  "authorization_endpoint": "https://localhost:30081/dex/auth",
  "token_endpoint": "https://localhost:30081/dex/token",
  "jwks_uri": "https://localhost:30081/dex/keys",
  "userinfo_endpoint": "https://localhost:30081/dex/userinfo",
  "device_authorization_endpoint": "https://localhost:30081/dex/device/code",
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "response_types_supported": [
    "code"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "code_challenge_methods_supported": [
    "S256",
    "plain"
  ],
  "scopes_supported": [
    "openid",
    "email",
    "groups",
    "profile",
    "offline_access"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "claims_supported": [
    "iss",
    "sub",
    "aud",
    "iat",
    "exp",
    "email",
    "email_verified",
    "locale",
    "name",
    "preferred_username",
    "at_hash"
  ]
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk if it's a factor, but I'm using a self-signed cert. In the CLI, I ignore refresh tokens if the user picked --insecure-skip-tls-verify. This is because I want to force them to re-authenticate in order to re-evaluate that decision.

idk if this is a factor here as well and possibly the source of my difficulties?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krancour Can you check the response from the server? Is there a refreshToken?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rpelczar I made a short video.

Note to an nefarious evildoers: The fragments of tokens you see in the video are for an IDP running locally and are completely useless. 😁

Screen.Recording.2023-12-11.at.7.54.12.PM.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krancour I found a bug, and fixed it.


setToken(token);
}, []);

const logout = React.useCallback(() => {
localStorage.removeItem(authTokenKey);
localStorage.removeItem(refreshTokenKey);

setToken(null);
}, []);

Expand Down
2 changes: 1 addition & 1 deletion ui/src/features/auth/context/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
11 changes: 9 additions & 2 deletions ui/src/features/auth/oidc-login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
};
Expand Down Expand Up @@ -134,7 +141,7 @@ export const OIDCLogin = ({ oidcConfig }: Props) => {
return;
}

onLogin(result.id_token);
onLogin(result.id_token, result.refresh_token);
})();
}, [as, client, location]);

Expand Down
8 changes: 7 additions & 1 deletion ui/src/features/auth/protected-route.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,5 +13,9 @@ export const ProtectedRoute = () => {
return <Navigate to={paths.login} replace />;
}

return <Outlet />;
return (
<TransportProvider transport={transportWithAuth}>
<Outlet />
</TransportProvider>
);
};
102 changes: 102 additions & 0 deletions ui/src/features/auth/token-renew.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='pt-40'>
<LoadingState />
</div>
);
};
4 changes: 2 additions & 2 deletions ui/src/features/project/project-details/project-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down
4 changes: 2 additions & 2 deletions ui/src/features/stage/promotions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 }
Expand Down
Loading