From 2d42e46ecab03b00a45d440f2559a7f833289256 Mon Sep 17 00:00:00 2001 From: Harish Govindarajulu Date: Wed, 29 Oct 2025 12:33:40 -0400 Subject: [PATCH] fix(ui): OAuth token generation should not redirect to Old UI (PROJQUAY-9600) (#4406) * Add nginx change to allow Oauth token generation in react * Create new component to handle OAuth token generation in new ui * Update cypress test for OAuth token generation * Fix cypress test Adds missing optional chaining + enable update-user.cy.ts tests * Add assignuser OAuth token generation + cypress test --- conf/nginx/server-base.conf.jnj | 4 +- web/cypress/e2e/org-oauth.cy.ts | 174 ++++++++++++++++- web/cypress/e2e/update-user.cy.ts | 2 +- web/src/App.tsx | 2 + web/src/components/sidebar/QuaySidebar.tsx | 2 +- .../OAuthLocalHandler/OAuthLocalHandler.tsx | 135 +++++++++++++ web/src/routes/OAuthLocalHandler/index.ts | 1 + .../GenerateTokenTab.tsx | 181 ++++++++++++++---- web/webpack.dev.js | 6 +- 9 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 web/src/routes/OAuthLocalHandler/OAuthLocalHandler.tsx create mode 100644 web/src/routes/OAuthLocalHandler/index.ts diff --git a/conf/nginx/server-base.conf.jnj b/conf/nginx/server-base.conf.jnj index 75d1f6228..0440870c8 100644 --- a/conf/nginx/server-base.conf.jnj +++ b/conf/nginx/server-base.conf.jnj @@ -47,7 +47,7 @@ location / { # Show new UI if cookie or default is set to "true"/"react" if ($use_new_ui ~* "^(true|react)$") { # catch new static paths and direct to /index.html - rewrite ^(/overview|/organization|/repository|/signin|/createaccount|/oauth-error|/oauth2/[^/]+/callback(/attach|/cli)?|/updateuser|/user/(.*)) /index.html break; + rewrite ^(/overview|/organization|/repository|/signin|/createaccount|/oauth-error|/oauth/localapp|/oauth2/[^/]+/callback(/attach|/cli)?|/updateuser|/user/(.*)) /index.html break; } # Show old UI if not using React @@ -57,7 +57,7 @@ location / { } # Capture traffic that needs to go to web_app, see /web.py -location ~* ^(/config|/csrf_token|/oauth1|/oauth2|/webhooks|/keys|/.well-known|/customtrigger|/authrepoemail|/confirm|/userfiles/(.*)|/health/(.*)|/status|/_internal_ping) { +location ~* ^(/config|/csrf_token|/oauth/authorizeapp|/oauth/authorize/assignuser|/oauth1|/oauth2|/webhooks|/keys|/.well-known|/customtrigger|/authrepoemail|/confirm|/userfiles/(.*)|/health/(.*)|/status|/_internal_ping) { proxy_pass http://web_app_server; } diff --git a/web/cypress/e2e/org-oauth.cy.ts b/web/cypress/e2e/org-oauth.cy.ts index 3599a5203..8d2dd8eac 100644 --- a/web/cypress/e2e/org-oauth.cy.ts +++ b/web/cypress/e2e/org-oauth.cy.ts @@ -613,7 +613,7 @@ describe('Organization OAuth Applications', () => { cy.get('[data-testid="generate-token-button"]').should('not.be.disabled'); }); - it('should open OAuth authorization in new tab when generating token', () => { + it('should open OAuth authorization in popup window when generating token', () => { cy.visit('/organization/testorg?tab=OAuthApplications'); cy.wait('@getOrg'); cy.wait('@getOAuthApplications'); @@ -668,7 +668,7 @@ describe('Organization OAuth Applications', () => { // Check form properties expect(capturedFormAction).to.include('/oauth/authorizeapp'); expect(capturedFormMethod.toLowerCase()).to.equal('post'); - expect(capturedFormTarget).to.equal('_blank'); + expect(capturedFormTarget).to.equal('oauth_authorization'); // Check form data expect(capturedFormData.client_id).to.exist; @@ -677,6 +677,100 @@ describe('Organization OAuth Applications', () => { }); }); + it('should display token in React modal after generation (not redirect to Angular)', () => { + cy.visit('/organization/testorg?tab=OAuthApplications'); + cy.wait('@getOrg'); + cy.wait('@getOAuthApplications'); + + cy.contains('test-app').click(); + cy.get('[data-testid="generate-token-tab"]').click(); + cy.wait('@getCurrentUser'); + cy.wait('@getConfig'); + + // Select scopes + cy.get('[data-testid="scope-repo:read"]').check(); + + // Click generate token to open modal + cy.get('[data-testid="generate-token-button"]').click(); + + // Stub window.open and postMessage to simulate OAuth flow + cy.window().then((win) => { + // Stub window.open to return a fake popup + const fakePopup = { + closed: false, + close: cy.stub(), + }; + cy.stub(win, 'open').returns(fakePopup); + + // Stub form submit to simulate OAuth callback + const submitStub = cy.stub(win.HTMLFormElement.prototype, 'submit'); + submitStub.callsFake(function () { + // Simulate OAuth callback by posting message + setTimeout(() => { + win.postMessage( + { + type: 'OAUTH_TOKEN_GENERATED', + token: 'test-access-token-123456', + scope: 'repo:read', + state: null, + }, + win.location.origin, + ); + }, 100); + }); + }); + + // Click authorize in modal + cy.get('[role="dialog"]').contains('Authorize Application').click(); + + // CRITICAL: Verify token modal appears in React UI + cy.contains('Access Token Generated', {timeout: 5000}).should('exist'); + cy.contains('Your access token has been successfully generated').should( + 'exist', + ); + + // Verify token is displayed in the ClipboardCopy input + cy.get('.pf-v5-c-clipboard-copy input').should( + 'have.value', + 'test-access-token-123456', + ); + + // Verify user is still in React UI (no redirect to Angular) + cy.url().should('include', 'localhost'); + cy.url().should('not.include', '/oauth/localapp'); + cy.url().should('include', '/organization/testorg'); + }); + + it('should handle popup blocked scenario gracefully', () => { + cy.visit('/organization/testorg?tab=OAuthApplications'); + cy.wait('@getOrg'); + cy.wait('@getOAuthApplications'); + + cy.contains('test-app').click(); + cy.get('[data-testid="generate-token-tab"]').click(); + cy.wait('@getCurrentUser'); + cy.wait('@getConfig'); + + // Select scopes + cy.get('[data-testid="scope-repo:read"]').check(); + + // Click generate token to open modal + cy.get('[data-testid="generate-token-button"]').click(); + + // Stub window.open to return null (popup blocked) BEFORE clicking authorize + cy.window().then((win) => { + cy.stub(win, 'open').returns(null); + }); + + // Click authorize in modal + cy.get('[role="dialog"]').contains('Authorize Application').click(); + + // Verify PatternFly warning alert appears (not browser alert) + cy.contains( + 'Popup was blocked by your browser. Please allow popups for this site and try again.', + ).should('exist'); + }); + it('should handle user assignment functionality', () => { cy.visit('/organization/testorg?tab=OAuthApplications'); cy.wait('@getOrg'); @@ -703,6 +797,82 @@ describe('Organization OAuth Applications', () => { 'Assign token', ); }); + + it('should assign token with correct parameter placement (query string)', () => { + // Mock configuration with ASSIGN_OAUTH_TOKEN feature + cy.intercept('GET', '/config', (req) => + req.reply((res) => { + res.body.features = { + ...res.body.features, + ASSIGN_OAUTH_TOKEN: true, + }; + res.body.config.LOCAL_OAUTH_HANDLER = '/oauth/localapp'; + res.body.config.PREFERRED_URL_SCHEME = 'http'; + res.body.config.SERVER_HOSTNAME = 'localhost:8080'; + return res; + }), + ).as('getConfig'); + + // Mock the assignuser OAuth endpoint with success response + cy.intercept('POST', '/oauth/authorize/assignuser*', { + statusCode: 200, + body: { + message: 'Token assigned successfully', + }, + }).as('assignToken'); + + cy.visit('/organization/testorg?tab=OAuthApplications'); + cy.wait('@getOrg'); + cy.wait('@getOAuthApplications'); + + cy.contains('test-app').click(); + cy.get('[data-testid="generate-token-tab"]').click(); + cy.wait('@getCurrentUser'); + cy.wait('@getConfig'); + + // Click assign another user + cy.get('[data-testid="assign-user-button"]').click(); + + // Search for user (user2 exists in seed data) + cy.get('#entity-search-input').type('user2'); + + // Select user from results + cy.contains('user2').click(); + + // Select scopes + cy.get('[data-testid="scope-repo:read"]').check(); + cy.get('[data-testid="scope-repo:write"]').check(); + + // Click assign token button + cy.get('[data-testid="generate-token-button"]').click(); + + // Authorization modal should appear + cy.get('[role="dialog"]').should('be.visible'); + + // Click assign token button in modal to trigger fetch request + cy.get('[role="dialog"]').contains('Assign token').click(); + + // Wait for the assign token request and verify it was called + cy.wait('@assignToken').then((interception) => { + // Verify query parameters are present in URL + expect(interception.request.url).to.include('username=user2'); + expect(interception.request.url).to.include('client_id=TEST123'); + expect(interception.request.url).to.match(/scope=repo(%3A|:)read/); + expect(interception.request.url).to.include('redirect_uri='); + expect(interception.request.url).to.include('response_type=token'); + expect(interception.request.url).to.include('format=json'); + }); + + // Verify success alert appears (PatternFly alert) + cy.contains('Token assigned successfully').should('exist'); + + // Verify modal is closed + cy.get('[role="dialog"]').should('not.exist'); + + // Verify form is reset (user selection cleared) + cy.get('[data-testid="cancel-assign-button"]').should('not.exist'); + cy.get('[data-testid="assign-user-button"]').should('exist'); + }); }); describe('Delete OAuth Application', () => { diff --git a/web/cypress/e2e/update-user.cy.ts b/web/cypress/e2e/update-user.cy.ts index fab309565..646927ecc 100644 --- a/web/cypress/e2e/update-user.cy.ts +++ b/web/cypress/e2e/update-user.cy.ts @@ -65,7 +65,7 @@ describe('Update User Component', () => { cy.get('button[type="submit"]').should('contain.text', 'Confirm Username'); }); - it.only('successfully confirms username', () => { + it('successfully confirms username', () => { cy.intercept('GET', '/api/v1/user/', { statusCode: 200, body: { diff --git a/web/src/App.tsx b/web/src/App.tsx index 3a82b2bb4..e9dec5ace 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import {CreateAccount} from 'src/routes/CreateAccount/CreateAccount'; import UpdateUser from 'src/routes/UpdateUser/UpdateUser'; import {OAuthCallbackHandler} from 'src/routes/OAuthCallback/OAuthCallbackHandler'; import {OAuthError} from 'src/routes/OAuthCallback/OAuthError'; +import OAuthLocalHandler from 'src/routes/OAuthLocalHandler'; import {StandaloneMain} from 'src/routes/StandaloneMain'; import {ThemeProvider} from './contexts/ThemeContext'; @@ -26,6 +27,7 @@ export default function App() { } /> } /> } /> + } /> } diff --git a/web/src/components/sidebar/QuaySidebar.tsx b/web/src/components/sidebar/QuaySidebar.tsx index c9625e28b..3a7ebc90f 100644 --- a/web/src/components/sidebar/QuaySidebar.tsx +++ b/web/src/components/sidebar/QuaySidebar.tsx @@ -28,7 +28,7 @@ export function QuaySidebar() { const quayConfig = useQuayConfig(); const routes: SideNavProps[] = [ { - isSideNav: quayConfig?.config?.BRANDING.quay_io ? true : false, + isSideNav: quayConfig?.config?.BRANDING?.quay_io ? true : false, navPath: NavigationPath.overviewList, title: 'Overview', component: , diff --git a/web/src/routes/OAuthLocalHandler/OAuthLocalHandler.tsx b/web/src/routes/OAuthLocalHandler/OAuthLocalHandler.tsx new file mode 100644 index 000000000..bfd4319b1 --- /dev/null +++ b/web/src/routes/OAuthLocalHandler/OAuthLocalHandler.tsx @@ -0,0 +1,135 @@ +import React, {useEffect, useState} from 'react'; +import {useSearchParams, useNavigate} from 'react-router-dom'; +import { + Alert, + AlertVariant, + PageSection, + PageSectionVariants, + Spinner, +} from '@patternfly/react-core'; +import TokenDisplayModal from 'src/components/modals/TokenDisplayModal'; + +interface OAuthTokenData { + access_token: string; + scope: string; + state?: string; +} + +export default function OAuthLocalHandler() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [tokenData, setTokenData] = useState(null); + const [isTokenModalOpen, setIsTokenModalOpen] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Extract token from URL hash + const hash = window.location.hash.substring(1); + if (!hash) { + setError('Authorization was cancelled'); + setIsLoading(false); + return; + } + + const params = new URLSearchParams(hash); + const token = params.get('access_token'); + const scope = params.get('scope'); + const state = params.get('state'); + + if (!token) { + setError('No access token received'); + setIsLoading(false); + return; + } + + // Store token data + const data: OAuthTokenData = { + access_token: token, + scope: scope || '', + state: state || undefined, + }; + setTokenData(data); + + // Check if format=json requested (for API clients) + if (searchParams.get('format') === 'json') { + document.body.innerHTML = JSON.stringify({access_token: token}); + setIsLoading(false); + return; + } + + // Check if opened in popup window + if (window.opener && !window.opener.closed) { + try { + // Send token to parent window + window.opener.postMessage( + { + type: 'OAUTH_TOKEN_GENERATED', + token: token, + scope: scope, + state: state, + }, + window.location.origin, + ); + + // Close popup after message sent + setTimeout(() => { + window.close(); + }, 500); + } catch (err) { + console.error('Failed to communicate with parent window:', err); + // If postMessage fails, show modal in popup + setIsTokenModalOpen(true); + } + } else { + // Opened in same tab - show modal + setIsTokenModalOpen(true); + } + + setIsLoading(false); + }, [searchParams]); + + const handleModalClose = () => { + setIsTokenModalOpen(false); + // Navigate back to home or organization page + navigate('/organization'); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + + {error} + + + ); + } + + if (!tokenData) { + return null; + } + + const scopes = tokenData.scope ? tokenData.scope.split(' ') : []; + + return ( + <> + {isTokenModalOpen && ( + + )} + + ); +} diff --git a/web/src/routes/OAuthLocalHandler/index.ts b/web/src/routes/OAuthLocalHandler/index.ts new file mode 100644 index 000000000..3dc1616f5 --- /dev/null +++ b/web/src/routes/OAuthLocalHandler/index.ts @@ -0,0 +1 @@ +export {default} from './OAuthLocalHandler'; diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx index f83823bb6..f0d97da73 100644 --- a/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx +++ b/web/src/routes/OrganizationsList/Organization/Tabs/OAuthApplications/ManageOAuthApplicationTabs/GenerateTokenTab.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useState, useEffect, useCallback} from 'react'; import { Button, Flex, @@ -18,10 +18,13 @@ import {useCurrentUser} from 'src/hooks/UseCurrentUser'; import {useQuayConfig} from 'src/hooks/UseQuayConfig'; import {FormCheckbox} from 'src/components/forms/FormCheckbox'; import GenerateTokenAuthorizationModal from 'src/components/modals/GenerateTokenAuthorizationModal'; +import TokenDisplayModal from 'src/components/modals/TokenDisplayModal'; import EntitySearch from 'src/components/EntitySearch'; import {Entity} from 'src/resources/UserResource'; import {GlobalAuthState} from 'src/resources/AuthResource'; import {OAUTH_SCOPES, OAuthScope} from '../types'; +import {useAlerts} from 'src/hooks/UseAlerts'; +import {AlertVariant} from 'src/atoms/AlertState'; interface GenerateTokenTabProps { application: IOAuthApplication | null; @@ -36,8 +39,13 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) { const [customUser, setCustomUser] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); + const [oauthPopup, setOauthPopup] = useState(null); + const [generatedToken, setGeneratedToken] = useState(null); + const [generatedScopes, setGeneratedScopes] = useState([]); + const [isTokenDisplayModalOpen, setIsTokenDisplayModalOpen] = useState(false); const {user} = useCurrentUser(); const quayConfig = useQuayConfig(); + const {addAlert} = useAlerts(); // Initialize form with all scopes set to false const defaultValues: GenerateTokenFormData = {}; @@ -62,11 +70,8 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) { }; const getUrl = (path: string): string => { - const scheme = - quayConfig?.config?.PREFERRED_URL_SCHEME || - window.location.protocol.replace(':', ''); - const hostname = - quayConfig?.config?.SERVER_HOSTNAME || window.location.host; + const scheme = window.location.protocol.replace(':', ''); + const hostname = window.location.host; return `${scheme}://${hostname}${path}`; }; @@ -84,6 +89,7 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) { redirect_uri: getUrl( quayConfig.config.LOCAL_OAUTH_HANDLER || '/oauth/localapp', ), + format: 'json', }); return getUrl(`/oauth/authorize/assignuser?${params.toString()}`); } else { @@ -127,38 +133,133 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) { setSelectedUser(null); }; - const handleAuthModalConfirm = () => { - // Use POST form submission for both assignment and generation - const form = document.createElement('form'); - form.method = 'POST'; - form.action = generateUrl(); - form.target = '_blank'; - form.style.display = 'none'; + const handleOAuthMessage = useCallback( + (event: MessageEvent) => { + // Verify origin for security + if (event.origin !== window.location.origin) { + console.warn('Received message from unexpected origin:', event.origin); + return; + } - // Extract URL parameters and add them as form fields - const url = new URL(generateUrl()); - url.searchParams.forEach((value, key) => { - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = key; - input.value = value; - form.appendChild(input); - }); + if (event.data.type === 'OAUTH_TOKEN_GENERATED') { + setGeneratedToken(event.data.token); + setGeneratedScopes(event.data.scope?.split(' ') || []); + setIsTokenDisplayModalOpen(true); + setIsAuthModalOpen(false); - // Add CSRF token for POST request - if (GlobalAuthState.csrfToken) { - const csrfInput = document.createElement('input'); - csrfInput.type = 'hidden'; - csrfInput.name = '_csrf_token'; - csrfInput.value = GlobalAuthState.csrfToken; - form.appendChild(csrfInput); - } + // Clean up popup + if (oauthPopup && !oauthPopup.closed) { + oauthPopup.close(); + } + setOauthPopup(null); + } + }, + [oauthPopup], + ); - document.body.appendChild(form); - form.submit(); - document.body.removeChild(form); + useEffect(() => { + window.addEventListener('message', handleOAuthMessage); + return () => { + window.removeEventListener('message', handleOAuthMessage); + }; + }, [handleOAuthMessage]); + const handleAuthModalConfirm = async () => { setIsAuthModalOpen(false); + + const fullUrl = new URL(generateUrl()); + const isAssignmentFlow = fullUrl.pathname.includes( + '/oauth/authorize/assignuser', + ); + + if (isAssignmentFlow) { + const formData = new FormData(); + if (GlobalAuthState.csrfToken) { + formData.append('_csrf_token', GlobalAuthState.csrfToken); + } + + try { + // Use relative URL for fetch to ensure proper proxy handling + const response = await fetch(fullUrl.pathname + fullUrl.search, { + method: 'POST', + body: formData, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('Failed to assign token'); + } + + const data = await response.json(); + addAlert({ + variant: AlertVariant.Success, + title: data.message || 'Token assigned successfully', + }); + setCustomUser(false); + setSelectedUser(null); + } catch (error) { + console.error('Error assigning token:', error); + addAlert({ + variant: AlertVariant.Failure, + title: 'Failed to assign token. Please try again.', + }); + } + } else { + const form = document.createElement('form'); + form.method = 'POST'; + form.target = 'oauth_authorization'; + form.style.display = 'none'; + form.action = fullUrl.pathname; + + fullUrl.searchParams.forEach((value, key) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = value; + form.appendChild(input); + }); + + if (GlobalAuthState.csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = '_csrf_token'; + csrfInput.value = GlobalAuthState.csrfToken; + form.appendChild(csrfInput); + } + + const width = 600; + const height = 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + '', + 'oauth_authorization', + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`, + ); + + if (!popup || popup.closed || typeof popup.closed === 'undefined') { + addAlert({ + variant: AlertVariant.Warning, + title: + 'Popup was blocked by your browser. Please allow popups for this site and try again.', + }); + return; + } + + setOauthPopup(popup); + + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); + + const checkPopupClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkPopupClosed); + setOauthPopup(null); + } + }, 500); + } }; const handleAuthModalClose = () => { @@ -294,6 +395,20 @@ export default function GenerateTokenTab(props: GenerateTokenTabProps) { targetUsername={selectedUser?.name} /> )} + + {generatedToken && ( + { + setIsTokenDisplayModalOpen(false); + setGeneratedToken(null); + setGeneratedScopes([]); + }} + token={generatedToken} + applicationName={application.name} + scopes={generatedScopes} + /> + )} ); } diff --git a/web/webpack.dev.js b/web/webpack.dev.js index 74368d4a2..b28cd9a80 100644 --- a/web/webpack.dev.js +++ b/web/webpack.dev.js @@ -1,7 +1,7 @@ const Dotenv = require('dotenv-webpack'); const {merge} = require('webpack-merge'); const common = require('./webpack.common.js'); -const HOST = process.env.HOST || 'localhost'; +const HOST = process.env.HOST || '0.0.0.0'; const PORT = process.env.PORT || '9000'; module.exports = merge(common('development'), { @@ -32,6 +32,10 @@ module.exports = merge(common('development'), { target: 'http://localhost:8080', logLevel: 'debug', }, + '/oauth': { + target: 'http://localhost:8080', + logLevel: 'debug', + }, }, }, module: {