chore: move OIDC PKCE support from UI to backend (#21729)

Signed-off-by: Yann Soubeyrand <8511577+yann-soubeyrand@users.noreply.github.com>
Signed-off-by: Alexandre Gaudreault <alexandre_gaudreault@intuit.com>
Co-authored-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
Co-authored-by: Alexandre Gaudreault <alexandre_gaudreault@intuit.com>
This commit is contained in:
Yann Soubeyrand
2025-06-17 20:18:24 +02:00
committed by GitHub
parent 9849d2743b
commit d6efb9e538
19 changed files with 146 additions and 313 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -19,3 +19,21 @@ However, since Argo CD now sends unmodified kubeVersions to Helm, the generated
The `--staticassets` directory in the API server (`/app/shared` by default) is now protected against out-of-bounds
symlinks. This is to help protect against symlink attacks. If you have any symlinks in your `--staticassets` directory
to a location outside the directory, they will return a 500 error starting with 3.1.
## OpenID Connect authorization code flow with PKCE is now handled by the server instead of the UI
Previously, when PKCE was enabled, the authorization code flow (the process which happens when you log in to Argo CD using OpenID Connect) was handled by the UI, whereas this flow was handled by the server if PKCE was not enabled. The server now always handles this flow, PKCE being enabled or not.
### Detection
To check whether PKCE is used or not, run the following command:
```shell
kubectl get cm argocd-cm -o=jsonpath="{.data.oidc\.config}" | grep enablePKCEAuthentication
```
If it returns `"enablePKCEAuthentication": true`, then PKCE is used.
### Remediation
On your identity provider, ensure that the OIDC client used for Argo CD has the `/auth/callback` endpoint of your Argo CD URL (e.g. https://argocd.example.com/auth/callback) in the redirect URIs.

View File

@@ -347,10 +347,9 @@ data:
# use the same clientID as the Argo CD server
cliClientID: vvvvwwwwxxxxyyyyzzzz
# PKCE authentication flow processes authorization flow from browser only - default false
# uses the clientID
# make sure the Identity Provider (IdP) is public and doesn't need clientSecret
# make sure the Identity Provider (IdP) has this redirect URI registered: https://argocd.example.com/pkce/verify
# PKCE is an OIDC extension to prevent authorization code interception attacks.
# Make sure the identity provider supports it and that it is activated for Argo CD OIDC client.
# Default is false.
enablePKCEAuthentication: true
```

View File

@@ -107,7 +107,6 @@ Also you can set __Home URL__ to _/applications_ path and __Valid Post logout re
The Valid Redirect URIs should be set to:
- http://localhost:8085/auth/callback (needed for argo-cd cli, depends on value from [--sso-port](../../user-guide/commands/argocd_login.md))
- https://{hostname}/auth/callback
- https://{hostname}/pkce/verify
![Keycloak configure client](../../assets/keycloak-configure-client-pkce.png "Keycloak configure client")

View File

@@ -622,7 +622,7 @@ func getTestServer(t *testing.T, anonymousEnabled bool, withFakeSSO bool, useDex
})
oidcServer := ts
if !useDexForSSO {
oidcServer = testutil.GetOIDCTestServer(t)
oidcServer = testutil.GetOIDCTestServer(t, nil)
}
if withFakeSSO {
cm.Data["url"] = ts.URL

View File

@@ -31,7 +31,6 @@
"minimatch": "^3.1.2",
"moment": "^2.29.4",
"monaco-editor": "^0.33.0",
"oauth4webapi": "^2.3.0",
"path": "^0.12.7",
"prop-types": "^15.8.1",
"react": "^16.9.3",

View File

@@ -1,4 +1,4 @@
import {DataLoader, NavigationManager, NotificationType, Notifications, NotificationsManager, PageContext, Popup, PopupManager, PopupProps} from 'argo-ui';
import {DataLoader, NavigationManager, Notifications, NotificationsManager, PageContext, Popup, PopupManager, PopupProps} from 'argo-ui';
import {createBrowserHistory} from 'history';
import * as PropTypes from 'prop-types';
import * as React from 'react';
@@ -19,8 +19,6 @@ import {hashCode} from './shared/utils';
import {Banner} from './ui-banner/ui-banner';
import userInfo from './user-info';
import {AuthSettings} from './shared/models';
import {PKCEVerification} from './login/components/pkce-verify';
import {getPKCERedirectURI, pkceLogin} from './login/components/utils';
import {SystemLevelExtension} from './shared/services/extensions-service';
services.viewPreferences.init();
@@ -36,8 +34,7 @@ const routes: Routes = {
'/applications': {component: applications.component},
'/settings': {component: settings.component},
'/user-info': {component: userInfo.component},
'/help': {component: help.component},
'/pkce/verify': {component: PKCEVerification, noLayout: true}
'/help': {component: help.component}
};
interface NavItem {
@@ -250,18 +247,7 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
// If basehref is the default `/` it will become an empty string.
const basehref = document.querySelector('head > base').getAttribute('href').replace(/\/$/, '');
if (isSSO) {
const authSettings = await services.authService.settings();
if (authSettings?.oidcConfig?.enablePKCEAuthentication) {
pkceLogin(authSettings.oidcConfig, getPKCERedirectURI().toString()).catch(err => {
this.getChildContext().apis.notifications.show({
type: NotificationType.Error,
content: err?.message || JSON.stringify(err)
});
});
} else {
window.location.href = `${basehref}/auth/login?return_url=${encodeURIComponent(location.href)}`;
}
window.location.href = `${basehref}/auth/login?return_url=${encodeURIComponent(location.href)}`;
} else {
history.push(`/login?return_url=${encodeURIComponent(location.href)}`);
}

View File

@@ -1,4 +1,4 @@
import {FormField, NotificationType} from 'argo-ui';
import {FormField} from 'argo-ui';
import * as PropTypes from 'prop-types';
import * as React from 'react';
import {Form, Text} from 'react-form';
@@ -7,7 +7,6 @@ import {RouteComponentProps} from 'react-router';
import {AppContext} from '../../shared/context';
import {AuthSettings} from '../../shared/models';
import {services} from '../../shared/services';
import {getPKCERedirectURI, pkceLogin} from './utils';
require('./login.scss');
@@ -62,18 +61,7 @@ export class Login extends React.Component<RouteComponentProps<{}>, State> {
</div>
{ssoConfigured && (
<div className='login__box_saml width-control'>
<a
{...(authSettings?.oidcConfig?.enablePKCEAuthentication
? {
onClick: () =>
pkceLogin(authSettings.oidcConfig, getPKCERedirectURI().toString()).catch(err => {
this.appContext.apis.notifications.show({
type: NotificationType.Error,
content: err?.message || JSON.stringify(err)
});
})
}
: {href: `auth/login?return_url=${encodeURIComponent(this.state.returnUrl)}`})}>
<a href={`auth/login?return_url=${encodeURIComponent(this.state.returnUrl)}`}>
<button className='argo-button argo-button--base argo-button--full-width argo-button--xlg'>
{(authSettings.oidcConfig && <span>Log in via {authSettings.oidcConfig.name}</span>) ||
(authSettings.dexConfig.connectors.length === 1 && <span>Log in via {authSettings.dexConfig.connectors[0].name}</span>) || (

View File

@@ -1,8 +0,0 @@
.pkce-verify {
&__container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
}

View File

@@ -1,47 +0,0 @@
import React, {useEffect, useState} from 'react';
import {RouteComponentProps} from 'react-router';
import {services} from '../../shared/services';
import {PKCECodeVerifier, PKCEState, PKCELoginError, getPKCERedirectURI, pkceCallback} from './utils';
import requests from '../../shared/services/requests';
import './pkce-verify.scss';
export const PKCEVerification = (props: RouteComponentProps<any>) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<PKCELoginError | Error>();
useEffect(() => {
setLoading(true);
services.authService
.settings()
.then(authSettings => pkceCallback(props.location.search, authSettings.oidcConfig, getPKCERedirectURI().toString()))
.catch(err => setError(err))
.finally(() => {
setLoading(false);
PKCECodeVerifier.unset();
PKCEState.unset();
});
}, [props.location]);
if (loading) {
return <div className='pkce-verify__container'>Processing...</div>;
}
if (error) {
return (
<div className='pkce-verify__container'>
<div>
<h3>Error occurred: </h3>
<p>{error?.message || JSON.stringify(error)}</p>
<a href={requests.toAbsURL('/login')}>Try to Login again</a>
</div>
</div>
);
}
return (
<div className='pkce-verify__container'>
success. if you are not being redirected automatically please &nbsp;<a href='/applications'>click here</a>
</div>
);
};

View File

@@ -1,177 +0,0 @@
import {
AuthorizationServer,
Client,
authorizationCodeGrantRequest,
calculatePKCECodeChallenge,
discoveryRequest,
generateRandomCodeVerifier,
generateRandomState,
isOAuth2Error,
parseWwwAuthenticateChallenges,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
validateAuthResponse
} from 'oauth4webapi';
import {AuthSettings} from '../../shared/models';
import requests from '../../shared/services/requests';
export const discoverAuthServer = (issuerURL: URL): Promise<AuthorizationServer> => discoveryRequest(issuerURL).then(res => processDiscoveryResponse(issuerURL, res));
export const PKCECodeVerifier = {
get: () => sessionStorage.getItem(window.btoa('code_verifier')),
set: (codeVerifier: string) => sessionStorage.setItem(window.btoa('code_verifier'), codeVerifier),
unset: () => sessionStorage.removeItem(window.btoa('code_verifier'))
};
export const PKCEState = {
get: () => sessionStorage.getItem(window.btoa('pkce_session_id')),
set: (sessionId: string) => sessionStorage.setItem(window.btoa('pkce_session_id'), sessionId),
unset: () => sessionStorage.removeItem(window.btoa('pkce_session_id'))
};
export const getPKCERedirectURI = () => {
const currentOrigin = new URL(window.location.origin);
currentOrigin.pathname = requests.toAbsURL('/pkce/verify');
return currentOrigin;
};
export class PKCELoginError extends Error {
constructor(message: string) {
super(message);
this.name = 'PKCELoginError';
}
}
const validateAndGetOIDCForPKCE = async (oidcConfig: AuthSettings['oidcConfig']) => {
if (!oidcConfig) {
throw new PKCELoginError('No OIDC Config found');
}
let issuerURL: URL;
try {
issuerURL = new URL(oidcConfig.issuer);
} catch (e) {
throw new PKCELoginError(`Invalid oidc issuer ${oidcConfig.issuer}`);
}
if (!oidcConfig.clientID) {
throw new PKCELoginError('No OIDC Client Id found');
}
let authorizationServer: AuthorizationServer;
try {
authorizationServer = await discoverAuthServer(issuerURL);
} catch (e) {
throw new PKCELoginError(e);
}
return {
issuerURL,
authorizationServer,
clientID: oidcConfig.clientID
};
};
export const pkceLogin = async (oidcConfig: AuthSettings['oidcConfig'], redirectURI: string) => {
const {authorizationServer} = await validateAndGetOIDCForPKCE(oidcConfig);
// This sets the return path for the user after the pkce auth flow.
// This is ignored if the return path would be the login page as it would just loop.
if (!location.pathname.startsWith(requests.toAbsURL('/login'))) {
sessionStorage.setItem('return_url', location.pathname + location.search);
}
if (!authorizationServer.authorization_endpoint) {
throw new PKCELoginError('No Authorization Server endpoint found');
}
const state = generateRandomState();
const codeVerifier = generateRandomCodeVerifier();
const codeChallange = await calculatePKCECodeChallenge(codeVerifier);
const authorizationServerConsentScreen = new URL(authorizationServer.authorization_endpoint);
authorizationServerConsentScreen.searchParams.set('client_id', oidcConfig.clientID);
authorizationServerConsentScreen.searchParams.set('code_challenge', codeChallange);
authorizationServerConsentScreen.searchParams.set('code_challenge_method', 'S256');
authorizationServerConsentScreen.searchParams.set('redirect_uri', redirectURI);
authorizationServerConsentScreen.searchParams.set('response_type', 'code');
authorizationServerConsentScreen.searchParams.set('scope', oidcConfig.scopes.join(' '));
authorizationServerConsentScreen.searchParams.set('state', state);
PKCECodeVerifier.set(codeVerifier);
PKCEState.set(state);
window.location.replace(authorizationServerConsentScreen.toString());
};
export const pkceCallback = async (queryParams: string, oidcConfig: AuthSettings['oidcConfig'], redirectURI: string) => {
const codeVerifier = PKCECodeVerifier.get();
if (!codeVerifier) {
throw new PKCELoginError('No code verifier found in session');
}
let callbackQueryParams = new URLSearchParams();
try {
callbackQueryParams = new URLSearchParams(queryParams);
} catch (e) {
throw new PKCELoginError('Invalid query parameters');
}
if (!callbackQueryParams.get('code')) {
throw new PKCELoginError('No code in query parameters');
}
const {authorizationServer} = await validateAndGetOIDCForPKCE(oidcConfig);
const client: Client = {
client_id: oidcConfig.clientID,
token_endpoint_auth_method: 'none'
};
const expectedState = PKCEState.get();
const params = validateAuthResponse(authorizationServer, client, callbackQueryParams, expectedState);
if (isOAuth2Error(params)) {
throw new PKCELoginError('Error validating auth response');
}
const response = await authorizationCodeGrantRequest(authorizationServer, client, params, redirectURI, codeVerifier);
const authChallengeExtract = parseWwwAuthenticateChallenges(response);
if (authChallengeExtract?.length > 0) {
throw new PKCELoginError('Error parsing authentication challenge');
}
const result = await processAuthorizationCodeOpenIDResponse(authorizationServer, client, response);
if (isOAuth2Error(result)) {
throw new PKCELoginError(`Error getting token ${result.error_description}`);
}
if (!result.id_token) {
throw new PKCELoginError('No token in response');
}
// This regex removes any leading or trailing '/' characters and the result is appended to a '/'.
// This is because when base href if not just '/' toAbsURL() will append a trailing '/'.
// Just removing a trailing '/' from the string would break when base href is not specified, defaulted to '/'.
// This pattern is used to handle both cases.
document.cookie = `argocd.token=${result.id_token}; path=/${requests.toAbsURL('').replace(/^\/|\/$/g, '')}`;
const returnURL = sessionStorage.getItem('return_url');
if (returnURL) {
sessionStorage.removeItem('return_url');
window.location.replace(returnURL);
} else {
window.location.replace(requests.toAbsURL('/applications'));
}
};

View File

@@ -551,10 +551,6 @@ export interface AuthSettings {
};
oidcConfig: {
name: string;
issuer: string;
clientID: string;
scopes: string[];
enablePKCEAuthentication: boolean;
};
help: {
chatUrl: string;

View File

@@ -6897,11 +6897,6 @@ oas-validator@^5.0.8:
should "^13.2.1"
yaml "^1.10.0"
oauth4webapi@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-2.3.0.tgz#d01aeb83b60dbe3ff9ef1c6ec4a39e29c7be7ff6"
integrity sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA==
object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"

View File

@@ -87,7 +87,7 @@ func GenerateDexConfigYAML(argocdSettings *settings.ArgoCDSettings, disableTLS b
"id": "argo-cd-pkce",
"name": "Argo CD PKCE",
"redirectURIs": []string{
"http://localhost:4000/pkce/verify",
"http://localhost:4000/auth/callback",
},
"public": true,
}

View File

@@ -62,6 +62,8 @@ type ClientApp struct {
clientID string
// OAuth2 client secret of this application
clientSecret string
// Use Proof Key for Code Exchange (PKCE)
usePKCE bool
// Use Azure Workload Identity for clientID auth instead of clientSecret
useAzureWorkloadIdentity bool
// Callback URL for OAuth2 responses (e.g. https://argocd.example.com/auth/callback)
@@ -117,6 +119,7 @@ func NewClientApp(settings *settings.ArgoCDSettings, dexServerAddr string, dexTL
a := ClientApp{
clientID: settings.OAuth2ClientID(),
clientSecret: settings.OAuth2ClientSecret(),
usePKCE: settings.OAuth2UsePKCE(),
useAzureWorkloadIdentity: settings.UseAzureWorkloadIdentity(),
redirectURI: redirectURL,
issuerURL: settings.IssuerURL(),
@@ -183,7 +186,7 @@ func (a *ClientApp) oauth2Config(request *http.Request, scopes []string) (*oauth
}
// generateAppState creates an app state nonce
func (a *ClientApp) generateAppState(returnURL string, w http.ResponseWriter) (string, error) {
func (a *ClientApp) generateAppState(returnURL string, pkceVerifier string, w http.ResponseWriter) (string, error) {
// According to the spec (https://www.rfc-editor.org/rfc/rfc6749#section-10.10), this must be guessable with
// probability <= 2^(-128). The following call generates one of 52^24 random strings, ~= 2^136 possibilities.
randStr, err := rand.String(24)
@@ -193,7 +196,7 @@ func (a *ClientApp) generateAppState(returnURL string, w http.ResponseWriter) (s
if returnURL == "" {
returnURL = a.baseHRef
}
cookieValue := fmt.Sprintf("%s:%s", randStr, returnURL)
cookieValue := fmt.Sprintf("%s\n%s\n%s", randStr, returnURL, pkceVerifier)
if encrypted, err := crypto.Encrypt([]byte(cookieValue), a.encryptionKey); err != nil {
return "", err
} else {
@@ -211,23 +214,24 @@ func (a *ClientApp) generateAppState(returnURL string, w http.ResponseWriter) (s
return randStr, nil
}
func (a *ClientApp) verifyAppState(r *http.Request, w http.ResponseWriter, state string) (string, error) {
func (a *ClientApp) verifyAppState(r *http.Request, w http.ResponseWriter, state string) (string, string, error) {
c, err := r.Cookie(common.StateCookieName)
if err != nil {
return "", err
return "", "", err
}
val, err := hex.DecodeString(c.Value)
if err != nil {
return "", err
return "", "", err
}
val, err = crypto.Decrypt(val, a.encryptionKey)
if err != nil {
return "", err
return "", "", err
}
cookieVal := string(val)
redirectURL := a.baseHRef
parts := strings.SplitN(cookieVal, ":", 2)
if len(parts) == 2 && parts[1] != "" {
pkceVerifier := ""
parts := strings.SplitN(cookieVal, "\n", 3)
if len(parts) > 1 && parts[1] != "" {
if !isValidRedirectURL(parts[1],
append([]string{a.settings.URL, a.baseHRef}, a.settings.AdditionalURLs...)) {
sanitizedURL := parts[1]
@@ -235,12 +239,15 @@ func (a *ClientApp) verifyAppState(r *http.Request, w http.ResponseWriter, state
sanitizedURL = sanitizedURL[:100]
}
log.Warnf("Failed to verify app state - got invalid redirectURL %q", sanitizedURL)
return "", fmt.Errorf("failed to verify app state: %w", ErrInvalidRedirectURL)
return "", "", fmt.Errorf("failed to verify app state: %w", ErrInvalidRedirectURL)
}
redirectURL = parts[1]
}
if len(parts) > 2 {
pkceVerifier = parts[2]
}
if parts[0] != state {
return "", fmt.Errorf("invalid state in '%s' cookie", common.AuthCookieName)
return "", "", fmt.Errorf("invalid state in '%s' cookie", common.AuthCookieName)
}
// set empty cookie to clear it
http.SetCookie(w, &http.Cookie{
@@ -250,7 +257,7 @@ func (a *ClientApp) verifyAppState(r *http.Request, w http.ResponseWriter, state
SameSite: http.SameSiteLaxMode,
Secure: a.secureCookie,
})
return redirectURL, nil
return redirectURL, pkceVerifier, nil
}
// isValidRedirectURL checks whether the given redirectURL matches on of the
@@ -309,6 +316,7 @@ func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) {
return
}
scopes := make([]string, 0)
pkceVerifier := ""
var opts []oauth2.AuthCodeOption
if config := a.settings.OIDCConfig(); config != nil {
scopes = GetScopesOrDefault(config.RequestedScopes)
@@ -328,7 +336,11 @@ func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid redirect URL: the protocol and host (including port) must match and the path must be within allowed URLs if provided", http.StatusBadRequest)
return
}
stateNonce, err := a.generateAppState(returnURL, w)
if a.usePKCE {
pkceVerifier = oauth2.GenerateVerifier()
opts = append(opts, oauth2.S256ChallengeOption(pkceVerifier))
}
stateNonce, err := a.generateAppState(returnURL, pkceVerifier, w)
if err != nil {
log.Errorf("Failed to initiate login flow: %v", err)
http.Error(w, "Failed to initiate login flow", http.StatusInternalServerError)
@@ -412,7 +424,7 @@ func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) {
a.handleImplicitFlow(r, w, state)
return
}
returnURL, err := a.verifyAppState(r, w, state)
returnURL, pkceVerifier, err := a.verifyAppState(r, w, state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -434,6 +446,10 @@ func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) {
}
}
if a.usePKCE {
options = append(options, oauth2.VerifierOption(pkceVerifier))
}
token, err := oauth2Config.Exchange(ctx, code, options...)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError)
@@ -544,7 +560,8 @@ func (a *ClientApp) handleImplicitFlow(r *http.Request, w http.ResponseWriter, s
CookieName: common.AuthCookieName,
}
if state != "" {
returnURL, err := a.verifyAppState(r, w, state)
// Not using pkceVerifier, since PKCE is not supported in implicit flow.
returnURL, _, err := a.verifyAppState(r, w, state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return

View File

@@ -127,7 +127,7 @@ func TestHandleCallback(t *testing.T) {
}
func TestClientApp_HandleLogin(t *testing.T) {
oidcTestServer := test.GetOIDCTestServer(t)
oidcTestServer := test.GetOIDCTestServer(t, nil)
t.Cleanup(oidcTestServer.Close)
dexTestServer := test.GetDexTestServer(t)
@@ -408,7 +408,7 @@ func Test_Login_Flow(t *testing.T) {
// Show that SSO login works when no redirect URL is provided, and we fall back to the configured base href for the
// Argo CD instance.
oidcTestServer := test.GetOIDCTestServer(t)
oidcTestServer := test.GetOIDCTestServer(t, nil)
t.Cleanup(oidcTestServer.Close)
cdSettings := &settings.ArgoCDSettings{
@@ -416,8 +416,8 @@ func Test_Login_Flow(t *testing.T) {
OIDCConfigRAW: fmt.Sprintf(`
name: Test
issuer: %s
clientID: xxx
clientSecret: yyy
clientID: test-client-id
clientSecret: test-client-secret
requestedScopes: ["oidc"]`, oidcTestServer.URL),
OIDCTLSInsecureSkipVerify: true,
}
@@ -435,7 +435,7 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
redirectURL, err := w.Result().Location()
require.NoError(t, err)
state := redirectURL.Query()["state"]
state := redirectURL.Query().Get("state")
req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("https://argocd.example.com/auth/callback?state=%s&code=abc", state), http.NoBody)
for _, cookie := range w.Result().Cookies() {
@@ -446,11 +446,65 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL),
app.HandleCallback(w, req)
assert.Equal(t, 303, w.Code)
assert.NotContains(t, w.Body.String(), ErrInvalidRedirectURL.Error())
}
func Test_Login_Flow_With_PKCE(t *testing.T) {
var codeChallenge string
oidcTestServer := test.GetOIDCTestServer(t, func(r *http.Request) {
codeVerifier := r.FormValue("code_verifier")
assert.NotEmpty(t, codeVerifier)
assert.Equal(t, oauth2.S256ChallengeFromVerifier(codeVerifier), codeChallenge)
})
t.Cleanup(oidcTestServer.Close)
cdSettings := &settings.ArgoCDSettings{
URL: "https://example.com/argocd",
OIDCConfigRAW: fmt.Sprintf(`
name: Test
issuer: %s
clientID: test-client-id
clientSecret: test-client-secret
requestedScopes: ["oidc"]
enablePKCEAuthentication: true`, oidcTestServer.URL),
OIDCTLSInsecureSkipVerify: true,
}
app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour))
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "https://example.com/argocd/auth/login", http.NoBody)
app.HandleLogin(w, req)
redirectURL, err := w.Result().Location()
require.NoError(t, err)
codeChallenge = redirectURL.Query().Get("code_challenge")
assert.NotEmpty(t, codeChallenge)
assert.Equal(t, "S256", redirectURL.Query().Get("code_challenge_method"))
state := redirectURL.Query().Get("state")
req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("https://example.com/argocd/auth/callback?state=%s&code=abc", state), http.NoBody)
for _, cookie := range w.Result().Cookies() {
req.AddCookie(cookie)
}
w = httptest.NewRecorder()
app.HandleCallback(w, req)
assert.Equal(t, 303, w.Code)
assert.NotContains(t, w.Body.String(), ErrInvalidRedirectURL.Error())
}
func TestClientApp_HandleCallback(t *testing.T) {
oidcTestServer := test.GetOIDCTestServer(t)
oidcTestServer := test.GetOIDCTestServer(t, nil)
t.Cleanup(oidcTestServer.Close)
dexTestServer := test.GetDexTestServer(t)
@@ -758,7 +812,8 @@ func TestGenerateAppState(t *testing.T) {
app, err := NewClientApp(&settings.ArgoCDSettings{ServerSignature: signature, URL: expectedReturnURL}, "", nil, "", cache.NewInMemoryCache(24*time.Hour))
require.NoError(t, err)
generateResponse := httptest.NewRecorder()
state, err := app.generateAppState(expectedReturnURL, generateResponse)
expectedPKCEVerifier := oauth2.GenerateVerifier()
state, err := app.generateAppState(expectedReturnURL, expectedPKCEVerifier, generateResponse)
require.NoError(t, err)
t.Run("VerifyAppState_Successful", func(t *testing.T) {
@@ -767,9 +822,10 @@ func TestGenerateAppState(t *testing.T) {
req.AddCookie(cookie)
}
returnURL, err := app.verifyAppState(req, httptest.NewRecorder(), state)
returnURL, pkceVerifier, err := app.verifyAppState(req, httptest.NewRecorder(), state)
require.NoError(t, err)
assert.Equal(t, expectedReturnURL, returnURL)
assert.Equal(t, expectedPKCEVerifier, pkceVerifier)
})
t.Run("VerifyAppState_Failed", func(t *testing.T) {
@@ -778,7 +834,7 @@ func TestGenerateAppState(t *testing.T) {
req.AddCookie(cookie)
}
_, err := app.verifyAppState(req, httptest.NewRecorder(), "wrong state")
_, _, err := app.verifyAppState(req, httptest.NewRecorder(), "wrong state")
require.Error(t, err)
})
}
@@ -803,7 +859,7 @@ func TestGenerateAppState_XSS(t *testing.T) {
expectedReturnURL := "javascript: alert('hi')"
generateResponse := httptest.NewRecorder()
state, err := app.generateAppState(expectedReturnURL, generateResponse)
state, err := app.generateAppState(expectedReturnURL, "", generateResponse)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
@@ -811,7 +867,7 @@ func TestGenerateAppState_XSS(t *testing.T) {
req.AddCookie(cookie)
}
returnURL, err := app.verifyAppState(req, httptest.NewRecorder(), state)
returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), state)
require.ErrorIs(t, err, ErrInvalidRedirectURL)
assert.Empty(t, returnURL)
})
@@ -819,7 +875,7 @@ func TestGenerateAppState_XSS(t *testing.T) {
t.Run("valid return URL succeeds", func(t *testing.T) {
expectedReturnURL := "https://argocd.example.com/some/path"
generateResponse := httptest.NewRecorder()
state, err := app.generateAppState(expectedReturnURL, generateResponse)
state, err := app.generateAppState(expectedReturnURL, "", generateResponse)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
@@ -827,7 +883,7 @@ func TestGenerateAppState_XSS(t *testing.T) {
req.AddCookie(cookie)
}
returnURL, err := app.verifyAppState(req, httptest.NewRecorder(), state)
returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), state)
require.NoError(t, err)
assert.Equal(t, expectedReturnURL, returnURL)
})
@@ -847,7 +903,7 @@ func TestGenerateAppState_NoReturnURL(t *testing.T) {
require.NoError(t, err)
req.AddCookie(&http.Cookie{Name: common.StateCookieName, Value: hex.EncodeToString(encrypted)})
returnURL, err := app.verifyAppState(req, httptest.NewRecorder(), "123")
returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), "123")
require.NoError(t, err)
assert.Equal(t, "/argo-cd", returnURL)
}

View File

@@ -593,7 +593,7 @@ func getKubeClientWithConfig(config map[string]string, secretConfig map[string][
}
func TestSessionManager_VerifyToken(t *testing.T) {
oidcTestServer := utiltest.GetOIDCTestServer(t)
oidcTestServer := utiltest.GetOIDCTestServer(t, nil)
t.Cleanup(oidcTestServer.Close)
dexTestServer := utiltest.GetDexTestServer(t)

View File

@@ -1893,6 +1893,13 @@ func (a *ArgoCDSettings) OAuth2ClientSecret() string {
return ""
}
func (a *ArgoCDSettings) OAuth2UsePKCE() bool {
if oidcConfig := a.OIDCConfig(); oidcConfig != nil {
return oidcConfig.EnablePKCEAuthentication
}
return false
}
func (a *ArgoCDSettings) UseAzureWorkloadIdentity() bool {
if oidcConfig := a.OIDCConfig(); oidcConfig != nil && oidcConfig.Azure != nil {
return oidcConfig.Azure.UseWorkloadIdentity

View File

@@ -215,13 +215,13 @@ func oidcMockHandler(t *testing.T, url string, tokenRequestPreHandler func(r *ht
}
}
func GetOIDCTestServer(t *testing.T) *httptest.Server {
func GetOIDCTestServer(t *testing.T, tokenRequestPreHandler func(r *http.Request)) *httptest.Server {
t.Helper()
ts := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
// Start with a placeholder. We need the server URL before setting up the real handler.
}))
ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
oidcMockHandler(t, ts.URL, nil)(w, r)
oidcMockHandler(t, ts.URL, tokenRequestPreHandler)(w, r)
})
return ts
}
@@ -258,14 +258,19 @@ func mockTokenEndpointResponse(issuer string) (TokenResponse, error) {
// Helper function to generate a JWT token
func generateJWTToken(issuer string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
"sub": "1234567890",
"aud": "test-client-id",
"name": "John Doe",
"iat": time.Now().Unix(),
"iss": issuer,
"exp": time.Now().Add(time.Hour).Unix(), // Set the expiration time
})
tokenString, err := token.SignedString([]byte("secret"))
key, err := jwt.ParseRSAPrivateKeyFromPEM(PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to parse RSA private key: %w", err)
}
tokenString, err := token.SignedString(key)
if err != nil {
return "", err
}