From f2832bfa21058752aba577f7feff07d24cc22994 Mon Sep 17 00:00:00 2001 From: Alexander Plate Date: Thu, 6 Aug 2026 04:46:43 +0200 Subject: [PATCH] Add session-aware SSO support --- CHANGES.md | 7 ++ README.md | 12 +++- cypress/e2e/session-auth.js | 90 +++++++++++++++++++++++ package-lock.json | 33 ++------- src/api/base.ts | 5 +- src/api/pulp.ts | 9 ++- src/app-routes.tsx | 13 +++- src/layout.tsx | 4 +- src/menu.tsx | 8 ++- src/user-context.tsx | 137 ++++++++++++++++++++++++++++++++++-- 10 files changed, 274 insertions(+), 44 deletions(-) create mode 100644 cypress/e2e/session-auth.js diff --git a/CHANGES.md b/CHANGES.md index abd58321..43e15d3f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,10 @@ +## Unreleased + +### What's Changed +* Add cookie-based session authentication support while retaining HTTP Basic authentication + +--- + ## 0.1.24 (2026-01-02) {: #0.1.24 } ### What's Changed diff --git a/README.md b/README.md index a3595759..b0ca98e5 100644 --- a/README.md +++ b/README.md @@ -84,5 +84,15 @@ The UI builds produced by `npm run build` can be further configured by serving a * `API_BASE_PATH` - defaults to `/pulp/api/v3/` - change when using domains or a different path * `UI_BASE_PATH` - defaults to `/ui/` - change when only serving index in a subdirectory, or want different browser path prefix -* `UI_EXTERNAL_LOGIN_URI` - defaults to nothing - set to something like `/login/` when using an SSO +* `UI_EXTERNAL_LOGIN_URI` - defaults to nothing. When unset, Pulp UI uses its + built-in HTTP Basic login. Set it to the backend login endpoint, for example + `/auth/login/`, to enable cookie-based external authentication such as SSO. + Pulp UI appends a URL-encoded `next` query parameter so the backend can return + the browser to the requested UI route after authentication. In this mode the + UI validates the Django session after every page load and does not persist a + session identity or password in browser storage. The UI and API must share an + origin, or be configured so the browser sends the Django session cookie to + both. The backend must expose its standard browsable users API and Django + logout endpoint. Session detection reads only the authenticated username + shown in the users API header; it does not depend on task permissions. * `EXTRA_VERSION` - an extra version string to display in about modal diff --git a/cypress/e2e/session-auth.js b/cypress/e2e/session-auth.js new file mode 100644 index 00000000..cd275173 --- /dev/null +++ b/cypress/e2e/session-auth.js @@ -0,0 +1,90 @@ +const sessionPage = (username, csrfToken = 'test-csrf-token') => ` + + + ${ + username + ? `${username}` + : 'Log in' + } +
+ +
+ + +`; + +const configureSSO = () => { + cy.intercept('GET', '/pulp-ui-config.json', { + API_BASE_PATH: '/pulp/api/v3/', + UI_BASE_PATH: '/ui/', + UI_EXTERNAL_LOGIN_URI: '/auth/login/', + EXTRA_VERSION: '', + }); +}; + +describe('Session authentication', () => { + it('restores an authenticated Django session', () => { + configureSSO(); + cy.intercept('GET', '/pulp/api/v3/users/?limit=1', { + headers: { 'content-type': 'text/html' }, + body: sessionPage('sso-user'), + }); + + cy.ui('about'); + + cy.get('[data-cy=user-dropdown]').contains('sso-user'); + cy.get('[data-cy=pulp-menu-item-Login]').should('not.exist'); + cy.window() + .then((window) => window.sessionStorage.getItem('credentials')) + .should('be.null'); + cy.window() + .then((window) => window.localStorage.getItem('credentials')) + .should('be.null'); + }); + + it('does not trust cached session identity', () => { + configureSSO(); + cy.intercept('GET', '/pulp/api/v3/users/?limit=1', { + headers: { 'content-type': 'text/html' }, + body: sessionPage(null), + }); + + cy.visit('/ui/about/', { + onBeforeLoad(window) { + window.sessionStorage.credentials = JSON.stringify({ + username: 'stale-user', + password: '', + remember: false, + authentication: 'session', + }); + }, + }); + + cy.get('[data-cy=user-dropdown]').should('not.exist'); + cy.get('[data-cy=pulp-menu-item-Login]').should('exist'); + }); + + it('posts the Django logout and clears the local identity', () => { + configureSSO(); + let authenticated = true; + + cy.intercept('GET', '/pulp/api/v3/users/?limit=1', (request) => { + request.reply({ + headers: { 'content-type': 'text/html' }, + body: sessionPage(authenticated ? 'sso-user' : null), + }); + }); + cy.intercept('POST', '/auth/logout/', (request) => { + authenticated = false; + request.reply({ statusCode: 204 }); + }).as('logout'); + + cy.ui('about'); + cy.get('[data-cy=user-dropdown]').click(); + cy.contains('a', 'Logout').click(); + + cy.wait('@logout'); + cy.get('[data-cy=user-dropdown]').should('not.exist'); + cy.get('[data-cy=pulp-menu-item-Login]').should('exist'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 9671fbe6..dc8d54ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1812,7 +1811,6 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1854,7 +1852,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1898,7 +1895,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3004,7 +3000,6 @@ "integrity": "sha512-LoyRpvcocdKfkfill3VOYtyDYclnrB+c/IOS0D0i+xmBvNDjtG28gOVU81brg2w07Hzi6TKhNf3+YSi2UXsILQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.20.12", "@babel/runtime": "^7.20.13", @@ -3899,7 +3894,8 @@ "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "peer": true }, "node_modules/@types/qs": { "version": "6.9.15", @@ -4073,7 +4069,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -4557,7 +4552,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4606,7 +4600,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5301,7 +5294,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6248,7 +6240,8 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "peer": true }, "node_modules/cypress": { "version": "15.8.1", @@ -6954,7 +6947,6 @@ "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, - "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -7244,7 +7236,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12474,7 +12465,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12641,7 +12631,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12920,7 +12909,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -12932,7 +12920,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13596,7 +13583,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz", "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -14483,7 +14469,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -14747,7 +14732,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -15085,7 +15069,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15216,8 +15199,7 @@ "node_modules/tslib": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", - "peer": true + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -15358,9 +15340,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15768,7 +15749,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -15818,7 +15798,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/src/api/base.ts b/src/api/base.ts index 4c1bf79d..da00c0f8 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -8,9 +8,10 @@ export class BaseAPI { constructor() { this.http = axios.create({ - // adapter + withCredentials ensures no popup on http basic auth fail + // The fetch adapter avoids the browser's Basic Auth popup. Cookies are + // required when the API is authenticated through a Django SSO session. adapter: 'fetch', - withCredentials: false, + withCredentials: true, // baseURL gets set in PulpAPI paramsSerializer: { diff --git a/src/api/pulp.ts b/src/api/pulp.ts index 05321eb5..459b61fe 100644 --- a/src/api/pulp.ts +++ b/src/api/pulp.ts @@ -11,11 +11,18 @@ export class PulpAPI extends BaseAPI { this.http.interceptors.request.use((request) => { if (!request.auth) { - request.auth = JSON.parse( + const credentials = JSON.parse( window.sessionStorage.credentials || window.localStorage.credentials || '{}', ); + if ( + credentials.authentication !== 'session' && + credentials.username && + credentials.password + ) { + request.auth = credentials; + } } request.baseURL = config.API_BASE_PATH; diff --git a/src/app-routes.tsx b/src/app-routes.tsx index dd21576b..1b4c71f7 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -3,7 +3,12 @@ import { Banner, Flex, FlexItem } from '@patternfly/react-core'; import WrenchIcon from '@patternfly/react-icons/dist/esm/icons/wrench-icon'; import { type ElementType } from 'react'; import { Navigate, redirect, useLocation } from 'react-router'; -import { ErrorBoundary, ExternalLink, NotFound } from 'src/components'; +import { + ErrorBoundary, + ExternalLink, + LoadingSpinner, + NotFound, +} from 'src/components'; import { AboutProject, AnsibleRemoteDetail, @@ -322,9 +327,13 @@ const AuthHandler = ({ noAuth, path, }: IRouteConfig) => { - const { credentials } = useUserContext(); + const { credentials, isLoading } = useUserContext(); const { pathname } = useLocation(); + if (isLoading) { + return ; + } + if (!credentials && !noAuth) { // NOTE: also update LoginLink when changing this if (config.UI_EXTERNAL_LOGIN_URI) { diff --git a/src/layout.tsx b/src/layout.tsx index 84f97280..a165c56d 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -86,7 +86,7 @@ const UserDropdown = ({ export const Layout = ({ children }: { children: ReactNode }) => { const [aboutModalVisible, setAboutModalVisible] = useState(false); - const { credentials, clearCredentials } = useUserContext(); + const { credentials, clearCredentials, isLoading } = useUserContext(); const username = credentials?.username; @@ -119,7 +119,7 @@ export const Layout = ({ children }: { children: ReactNode }) => { {credentials ? ( clearCredentials()} /> ) : null} - {!credentials ? : null} + {!credentials && !isLoading ? : null} ); diff --git a/src/menu.tsx b/src/menu.tsx index 614e615b..bb0c29f8 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -4,7 +4,8 @@ import { reject, some } from 'lodash'; import { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router'; import { ExternalLink, NavList } from 'src/components'; -import { plugin_versions } from 'src/utilities'; +import { config } from 'src/ui-config'; +import { loginURL, plugin_versions } from 'src/utilities'; import { Paths, formatPath } from './paths'; import { useUserContext } from './user-context'; @@ -48,7 +49,10 @@ function standaloneMenu() { url: formatPath(Paths.core.status), }), menuItem(t`Login`, { - url: formatPath(Paths.meta.login), + url: config.UI_EXTERNAL_LOGIN_URI + ? loginURL(formatPath(Paths.core.status)) + : formatPath(Paths.meta.login), + external: Boolean(config.UI_EXTERNAL_LOGIN_URI), condition: ({ user }) => !user, // not logged in }), menuItem(t`Search`, { diff --git a/src/user-context.tsx b/src/user-context.tsx index 8f94850c..6e831b7c 100644 --- a/src/user-context.tsx +++ b/src/user-context.tsx @@ -1,3 +1,4 @@ +import Cookies from 'js-cookie'; import { type ReactNode, createContext, @@ -5,19 +6,28 @@ import { useEffect, useState, } from 'react'; +import { config } from 'src/ui-config'; interface IUserContextType { - credentials: { username: string; password: string; remember: boolean }; + credentials: Credentials | null; + isLoading: boolean; setCredentials: ( username: string, password: string, remember?: boolean, ) => void; - clearCredentials: () => void; + clearCredentials: () => Promise; updateUsername: (username: string) => void; updatePassword: (password: string) => void; } +interface Credentials { + username: string; + password: string; + remember: boolean; + authentication?: 'basic' | 'session'; +} + const UserContext = createContext(undefined); export const useUserContext = () => useContext(UserContext); @@ -27,20 +37,93 @@ function cachedCredentials() { } try { - return JSON.parse( + const credentials = JSON.parse( window.sessionStorage.credentials || window.localStorage.credentials, ); + // A Django session is represented by its cookie, not by client-side + // credentials. Always revalidate it after a page load. + return credentials.authentication === 'session' ? null : credentials; } catch (_e) { return null; } } +async function getSessionPage() { + const response = await fetch(`${config.API_BASE_PATH}users/?limit=1`, { + credentials: 'same-origin', + headers: { Accept: 'text/html' }, + }); + + if (!response.ok) { + return {}; + } + + const document = new DOMParser().parseFromString( + await response.text(), + 'text/html', + ); + + return { + csrfToken: document.querySelector( + 'input[name="csrfmiddlewaretoken"]', + )?.value, + username: document + .querySelector('a.dropdown-toggle[href="#"]') + ?.textContent?.trim(), + }; +} + export const UserContextProvider = ({ children }: { children: ReactNode }) => { const [credentials, setCredentials] = useState(cachedCredentials()); + const [isLoading, setIsLoading] = useState( + Boolean(config.UI_EXTERNAL_LOGIN_URI && !credentials), + ); + + useEffect(() => { + if (credentials || !config.UI_EXTERNAL_LOGIN_URI) { + setIsLoading(false); + return; + } + + let cancelled = false; + + const restoreSession = async () => { + // The browsable users endpoint exposes the authenticated Django session + // user in its header. It avoids probing an unrelated permission such as + // viewing tasks and is also the API backing the user's profile page. + const { username } = await getSessionPage(); + + if (!cancelled && username) { + setCredentials({ + username, + password: '', + remember: false, + authentication: 'session', + }); + } + }; + + restoreSession() + .catch(() => null) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); useEffect(() => { - window.sessionStorage.credentials = JSON.stringify(credentials); - if (credentials?.remember) { + if (credentials?.authentication === 'session') { + window.localStorage.removeItem('credentials'); + window.sessionStorage.removeItem('credentials'); + } else if (credentials) { + window.sessionStorage.credentials = JSON.stringify(credentials); + } + if (credentials?.remember && credentials.authentication !== 'session') { window.localStorage.credentials = JSON.stringify(credentials); } if (!credentials) { @@ -49,13 +132,53 @@ export const UserContextProvider = ({ children }: { children: ReactNode }) => { } }, [credentials]); + const clearCredentials = async () => { + try { + if (credentials?.authentication === 'session') { + // Django 5's LogoutView accepts POST only. Prefer the standard CSRF + // cookie and fall back to the browsable API form token. + const sessionPage = await getSessionPage(); + const csrfToken = Cookies.get('csrftoken') || sessionPage.csrfToken; + + if (csrfToken) { + await fetch('/auth/logout/', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrfToken, + }, + body: new URLSearchParams({ + csrfmiddlewaretoken: csrfToken, + next: '/ui/status/', + }), + }); + } + } + } finally { + // Always clear the local identity, even if the server-side logout is + // temporarily unavailable. A remaining server session is revalidated + // from its cookie on the next page load. + window.localStorage.removeItem('credentials'); + window.sessionStorage.removeItem('credentials'); + setCredentials(null); + window.location.assign('/ui/status/'); + } + }; + return ( - setCredentials({ username, password, remember }), - clearCredentials: () => setCredentials(null), + setCredentials({ + username, + password, + remember, + authentication: 'basic', + }), + clearCredentials, updateUsername: (username) => setCredentials((credentials) => ({ ...credentials, username })), updatePassword: (password) =>