Skip to content

Commit 092bb73

Browse files
authoredOct 30, 2024··
fix(pkce): 20111 PKCE auth flow does not return user to previous path like dex auth flow (#20202)
* Adding non-default basehref support for PKCE auth flow Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Adding ; for linting Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * removing hook function Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Moving unauthorized error handling to class component to access context for error handling within 401 error Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Store the subsrition handle to close in unmount Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * reorder imports Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Actually saving the subscriptions now Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * returning the 401 subscription from helper function Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Handle the promise of a subscription Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Removing then from non async subscribe Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Linter fixes Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> * Adding path caching to sessionStorage on pkceLogin and redirect step to cached path if available in pkceCallback to mirror Dex functionality Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com> --------- Signed-off-by: austin5219 <3936059+austin5219@users.noreply.github.com>
1 parent d408909 commit 092bb73

File tree

3 files changed

+78
-28
lines changed

3 files changed

+78
-28
lines changed
 

‎ui/src/app/app.tsx

+55-24
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import {DataLoader, NavigationManager, Notifications, NotificationsManager, PageContext, Popup, PopupManager, PopupProps} from 'argo-ui';
1+
import {DataLoader, NavigationManager, NotificationType, Notifications, NotificationsManager, PageContext, Popup, PopupManager, PopupProps} from 'argo-ui';
22
import {createBrowserHistory} from 'history';
33
import * as PropTypes from 'prop-types';
44
import * as React from 'react';
55
import {Helmet} from 'react-helmet';
66
import {Redirect, Route, RouteComponentProps, Router, Switch} from 'react-router';
7+
import {Subscription} from 'rxjs';
78
import applications from './applications';
89
import help from './help';
910
import login from './login';
@@ -19,6 +20,7 @@ import {Banner} from './ui-banner/ui-banner';
1920
import userInfo from './user-info';
2021
import {AuthSettings} from './shared/models';
2122
import {PKCEVerification} from './login/components/pkce-verify';
23+
import {getPKCERedirectURI, pkceLogin} from './login/components/utils';
2224
import {SystemLevelExtension} from './shared/services/extensions-service';
2325

2426
services.viewPreferences.init();
@@ -86,28 +88,6 @@ async function isExpiredSSO() {
8688
return false;
8789
}
8890

89-
requests.onError.subscribe(async err => {
90-
if (err.status === 401) {
91-
if (history.location.pathname.startsWith('/login')) {
92-
return;
93-
}
94-
95-
const isSSO = await isExpiredSSO();
96-
// location might change after async method call, so we need to check again.
97-
if (history.location.pathname.startsWith('/login')) {
98-
return;
99-
}
100-
// Query for basehref and remove trailing /.
101-
// If basehref is the default `/` it will become an empty string.
102-
const basehref = document.querySelector('head > base').getAttribute('href').replace(/\/$/, '');
103-
if (isSSO) {
104-
window.location.href = `${basehref}/auth/login?return_url=${encodeURIComponent(location.href)}`;
105-
} else {
106-
history.push(`/login?return_url=${encodeURIComponent(location.href)}`);
107-
}
108-
}
109-
});
110-
11191
export class App extends React.Component<
11292
{},
11393
{popupProps: PopupProps; showVersionPanel: boolean; error: Error; navItems: NavItem[]; routes: Routes; extensionsLoaded: boolean; authSettings: AuthSettings}
@@ -126,6 +106,8 @@ export class App extends React.Component<
126106
private navigationManager: NavigationManager;
127107
private navItems: NavItem[];
128108
private routes: Routes;
109+
private popupPropsSubscription: Subscription;
110+
private unauthorizedSubscription: Subscription;
129111

130112
constructor(props: {}) {
131113
super(props);
@@ -135,11 +117,16 @@ export class App extends React.Component<
135117
this.navigationManager = new NavigationManager(history);
136118
this.navItems = navItems;
137119
this.routes = routes;
120+
this.popupPropsSubscription = null;
121+
this.unauthorizedSubscription = null;
138122
services.extensions.addEventListener('systemLevel', this.onAddSystemLevelExtension.bind(this));
139123
}
140124

141125
public async componentDidMount() {
142-
this.popupManager.popupProps.subscribe(popupProps => this.setState({popupProps}));
126+
this.popupPropsSubscription = this.popupManager.popupProps.subscribe(popupProps => this.setState({popupProps}));
127+
this.subscribeUnauthorized().then(subscription => {
128+
this.unauthorizedSubscription = subscription;
129+
});
143130
const authSettings = await services.authService.settings();
144131
const {trackingID, anonymizeUsers} = authSettings.googleAnalytics || {trackingID: '', anonymizeUsers: true};
145132
const {loggedIn, username} = await services.users.get();
@@ -167,6 +154,15 @@ export class App extends React.Component<
167154
this.setState({...this.state, navItems: this.navItems, routes: this.routes, extensionsLoaded: false, authSettings});
168155
}
169156

157+
public componentWillUnmount() {
158+
if (this.popupPropsSubscription) {
159+
this.popupPropsSubscription.unsubscribe();
160+
}
161+
if (this.unauthorizedSubscription) {
162+
this.unauthorizedSubscription.unsubscribe();
163+
}
164+
}
165+
170166
public render() {
171167
if (this.state.error != null) {
172168
const stack = this.state.error.stack;
@@ -242,6 +238,41 @@ export class App extends React.Component<
242238
return {history, apis: {popup: this.popupManager, notifications: this.notificationsManager, navigation: this.navigationManager}};
243239
}
244240

241+
private async subscribeUnauthorized() {
242+
return requests.onError.subscribe(async err => {
243+
if (err.status === 401) {
244+
if (history.location.pathname.startsWith('/login')) {
245+
return;
246+
}
247+
248+
const isSSO = await isExpiredSSO();
249+
// location might change after async method call, so we need to check again.
250+
if (history.location.pathname.startsWith('/login')) {
251+
return;
252+
}
253+
// Query for basehref and remove trailing /.
254+
// If basehref is the default `/` it will become an empty string.
255+
const basehref = document.querySelector('head > base').getAttribute('href').replace(/\/$/, '');
256+
if (isSSO) {
257+
const authSettings = await services.authService.settings();
258+
259+
if (authSettings?.oidcConfig?.enablePKCEAuthentication) {
260+
pkceLogin(authSettings.oidcConfig, getPKCERedirectURI().toString()).catch(err => {
261+
this.getChildContext().apis.notifications.show({
262+
type: NotificationType.Error,
263+
content: err?.message || JSON.stringify(err)
264+
});
265+
});
266+
} else {
267+
window.location.href = `${basehref}/auth/login?return_url=${encodeURIComponent(location.href)}`;
268+
}
269+
} else {
270+
history.push(`/login?return_url=${encodeURIComponent(location.href)}`);
271+
}
272+
}
273+
});
274+
}
275+
245276
private onAddSystemLevelExtension(extension: SystemLevelExtension) {
246277
const extendedNavItems = this.navItems;
247278
const extendedRoutes = this.routes;

‎ui/src/app/login/components/pkce-verify.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, {useEffect, useState} from 'react';
22
import {RouteComponentProps} from 'react-router';
33
import {services} from '../../shared/services';
44
import {PKCECodeVerifier, PKCEState, PKCELoginError, getPKCERedirectURI, pkceCallback} from './utils';
5+
import requests from '../../shared/services/requests';
56

67
import './pkce-verify.scss';
78

@@ -32,7 +33,7 @@ export const PKCEVerification = (props: RouteComponentProps<any>) => {
3233
<div>
3334
<h3>Error occurred: </h3>
3435
<p>{error?.message || JSON.stringify(error)}</p>
35-
<a href='/login'>Try to Login again</a>
36+
<a href={requests.toAbsURL('/login')}>Try to Login again</a>
3637
</div>
3738
</div>
3839
);

‎ui/src/app/login/components/utils.ts

+21-3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
validateAuthResponse
1414
} from 'oauth4webapi';
1515
import {AuthSettings} from '../../shared/models';
16+
import requests from '../../shared/services/requests';
1617

1718
export const discoverAuthServer = (issuerURL: URL): Promise<AuthorizationServer> => discoveryRequest(issuerURL).then(res => processDiscoveryResponse(issuerURL, res));
1819

@@ -31,7 +32,7 @@ export const PKCEState = {
3132
export const getPKCERedirectURI = () => {
3233
const currentOrigin = new URL(window.location.origin);
3334

34-
currentOrigin.pathname = '/pkce/verify';
35+
currentOrigin.pathname = requests.toAbsURL('/pkce/verify');
3536

3637
return currentOrigin;
3738
};
@@ -76,6 +77,12 @@ const validateAndGetOIDCForPKCE = async (oidcConfig: AuthSettings['oidcConfig'])
7677
export const pkceLogin = async (oidcConfig: AuthSettings['oidcConfig'], redirectURI: string) => {
7778
const {authorizationServer} = await validateAndGetOIDCForPKCE(oidcConfig);
7879

80+
// This sets the return path for the user after the pkce auth flow.
81+
// This is ignored if the return path would be the login page as it would just loop.
82+
if (!location.pathname.startsWith(requests.toAbsURL('/login'))) {
83+
sessionStorage.setItem('return_url', location.pathname + location.search);
84+
}
85+
7986
if (!authorizationServer.authorization_endpoint) {
8087
throw new PKCELoginError('No Authorization Server endpoint found');
8188
}
@@ -153,7 +160,18 @@ export const pkceCallback = async (queryParams: string, oidcConfig: AuthSettings
153160
throw new PKCELoginError('No token in response');
154161
}
155162

156-
document.cookie = `argocd.token=${result.id_token}; path=/`;
163+
// This regex removes any leading or trailing '/' characters and the result is appended to a '/'.
164+
// This is because when base href if not just '/' toAbsURL() will append a trailing '/'.
165+
// Just removing a trailing '/' from the string would break when base href is not specified, defaulted to '/'.
166+
// This pattern is used to handle both cases.
167+
document.cookie = `argocd.token=${result.id_token}; path=/${requests.toAbsURL('').replace(/^\/|\/$/g, '')}`;
157168

158-
window.location.replace('/applications');
169+
const returnURL = sessionStorage.getItem('return_url');
170+
171+
if (returnURL) {
172+
sessionStorage.removeItem('return_url');
173+
window.location.replace(returnURL);
174+
} else {
175+
window.location.replace(requests.toAbsURL('/applications'));
176+
}
159177
};

0 commit comments

Comments
 (0)
Please sign in to comment.