1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

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
This commit is contained in:
Harish Govindarajulu
2025-10-29 12:33:40 -04:00
committed by GitHub
parent def6cc859c
commit 2d42e46eca
9 changed files with 467 additions and 40 deletions

View File

@@ -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;
}

View File

@@ -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', () => {

View File

@@ -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: {

View File

@@ -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() {
<Route path="/createaccount" element={<CreateAccount />} />
<Route path="/updateuser" element={<UpdateUser />} />
<Route path="/oauth-error" element={<OAuthError />} />
<Route path="/oauth/localapp" element={<OAuthLocalHandler />} />
<Route
path="/oauth2/:provider/callback/*"
element={<OAuthCallbackHandler />}

View File

@@ -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: <OverviewList />,

View File

@@ -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<OAuthTokenData | null>(null);
const [isTokenModalOpen, setIsTokenModalOpen] = useState(false);
const [error, setError] = useState<string | null>(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 (
<PageSection variant={PageSectionVariants.light}>
<Spinner />
</PageSection>
);
}
if (error) {
return (
<PageSection variant={PageSectionVariants.light}>
<Alert variant={AlertVariant.warning} title="Authorization Cancelled">
{error}
</Alert>
</PageSection>
);
}
if (!tokenData) {
return null;
}
const scopes = tokenData.scope ? tokenData.scope.split(' ') : [];
return (
<>
{isTokenModalOpen && (
<TokenDisplayModal
isOpen={isTokenModalOpen}
onClose={handleModalClose}
token={tokenData.access_token}
applicationName="OAuth Application"
scopes={scopes}
/>
)}
</>
);
}

View File

@@ -0,0 +1 @@
export {default} from './OAuthLocalHandler';

View File

@@ -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<Entity | null>(null);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [oauthPopup, setOauthPopup] = useState<Window | null>(null);
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [generatedScopes, setGeneratedScopes] = useState<string[]>([]);
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 && (
<TokenDisplayModal
isOpen={isTokenDisplayModalOpen}
onClose={() => {
setIsTokenDisplayModalOpen(false);
setGeneratedToken(null);
setGeneratedScopes([]);
}}
token={generatedToken}
applicationName={application.name}
scopes={generatedScopes}
/>
)}
</PageSection>
);
}

View File

@@ -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: {