From 9f3396d867a524cb5ff24cc9f5e7cb1dd327bf8d Mon Sep 17 00:00:00 2001 From: Jeremy White Date: Tue, 23 Aug 2022 09:51:04 -0500 Subject: [PATCH] rebasing --- awx/settings/defaults.py | 1 + awx/sso/conf.py | 48 ++++++ awx/sso/fields.py | 1 + awx/ui/src/screens/Login/Login.js | 14 ++ awx/ui/src/screens/Setting/OIDC/OIDC.js | 34 ++++ awx/ui/src/screens/Setting/OIDC/OIDC.test.js | 75 ++++++++ .../Setting/OIDC/OIDCDetail/OIDCDetail.js | 98 +++++++++++ .../OIDC/OIDCDetail/OIDCDetail.test.js | 97 +++++++++++ .../screens/Setting/OIDC/OIDCDetail/index.js | 1 + .../screens/Setting/OIDC/OIDCEdit/OIDCEdit.js | 147 ++++++++++++++++ .../Setting/OIDC/OIDCEdit/OIDCEdit.test.js | 161 ++++++++++++++++++ .../screens/Setting/OIDC/OIDCEdit/index.js | 1 + awx/ui/src/screens/Setting/OIDC/index.js | 1 + awx/ui/src/screens/Setting/SettingList.js | 4 + awx/ui/src/screens/Setting/Settings.js | 7 + .../shared/data.allSettingOptions.json | 65 +++++++ .../Setting/shared/data.allSettings.json | 4 + docs/licenses/ecdsa.txt | 24 +++ docs/licenses/python-jose.txt | 21 +++ requirements/requirements.in | 2 +- requirements/requirements.txt | 12 +- 21 files changed, 815 insertions(+), 3 deletions(-) create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDC.js create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDC.test.js create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.js create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.test.js create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDCDetail/index.js create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.js create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.test.js create mode 100644 awx/ui/src/screens/Setting/OIDC/OIDCEdit/index.js create mode 100644 awx/ui/src/screens/Setting/OIDC/index.js create mode 100644 docs/licenses/ecdsa.txt create mode 100644 docs/licenses/python-jose.txt diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 61d0d2921d..2f2b6a59a5 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -379,6 +379,7 @@ AUTHENTICATION_BACKENDS = ( 'social_core.backends.github_enterprise.GithubEnterpriseOAuth2', 'social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2', 'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2', + 'social_core.backends.open_id_connect.OpenIdConnectAuth', 'social_core.backends.azuread.AzureADOAuth2', 'awx.sso.backends.SAMLAuth', 'awx.main.backends.AWXModelBackend', diff --git a/awx/sso/conf.py b/awx/sso/conf.py index ab744ebd2a..62d6f47c3a 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -1215,6 +1215,54 @@ register( placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, ) +############################################################################### +# Generic OIDC AUTHENTICATION SETTINGS +############################################################################### + +register( + 'SOCIAL_AUTH_OIDC_KEY', + field_class=fields.CharField, + allow_null=False, + default=None, + label=_('OIDC Key'), + help_text='The OIDC key (Client ID) from your IDP.', + category=_('Generic OIDC'), + category_slug='oidc', +) + +register( + 'SOCIAL_AUTH_OIDC_SECRET', + field_class=fields.CharField, + allow_blank=True, + default='', + label=_('OIDC Secret'), + help_text=_('The OIDC secret (Client Secret) from your IDP.'), + category=_('Generic OIDC'), + category_slug='oidc', + encrypted=True, +) + +register( + 'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT', + field_class=fields.CharField, + allow_blank=True, + default='', + label=_('OIDC Provider URL'), + help_text=_('The URL for your OIDC provider including the path up to /.well-known/openid-configuration'), + category=_('Generic OIDC'), + category_slug='oidc', +) + +register( + 'SOCIAL_AUTH_OIDC_VERIFY_SSL', + field_class=fields.BooleanField, + default=True, + label=_('Verify OIDC Provider Certificate'), + help_text=_('Verify the OIDV provider ssl certificate.'), + category=_('Generic OIDC'), + category_slug='oidc', +) + ############################################################################### # SAML AUTHENTICATION SETTINGS ############################################################################### diff --git a/awx/sso/fields.py b/awx/sso/fields.py index 1b51cdb49c..6eaef11cb8 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -149,6 +149,7 @@ class AuthenticationBackendsField(fields.StringListField): ('awx.sso.backends.RADIUSBackend', ['RADIUS_SERVER']), ('social_core.backends.google.GoogleOAuth2', ['SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET']), ('social_core.backends.github.GithubOAuth2', ['SOCIAL_AUTH_GITHUB_KEY', 'SOCIAL_AUTH_GITHUB_SECRET']), + ('social_core.backends.open_id_connect.OpenIdConnectAuth', ['SOCIAL_AUTH_OIDC_KEY', 'SOCIAL_AUTH_OIDC_SECRET', 'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT']), ( 'social_core.backends.github.GithubOrganizationOAuth2', ['SOCIAL_AUTH_GITHUB_ORG_KEY', 'SOCIAL_AUTH_GITHUB_ORG_SECRET', 'SOCIAL_AUTH_GITHUB_ORG_NAME'], diff --git a/awx/ui/src/screens/Login/Login.js b/awx/ui/src/screens/Login/Login.js index dace8f2756..e506eaf9b6 100644 --- a/awx/ui/src/screens/Login/Login.js +++ b/awx/ui/src/screens/Login/Login.js @@ -346,6 +346,20 @@ function AWXLogin({ alt, isAuthenticated }) { ); } + if (authKey === 'oidc') { + return ( + + + + + + ); + } if (authKey.startsWith('saml')) { const samlIDP = authKey.split(':')[1] || null; return ( diff --git a/awx/ui/src/screens/Setting/OIDC/OIDC.js b/awx/ui/src/screens/Setting/OIDC/OIDC.js new file mode 100644 index 0000000000..e43ca521e1 --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDC.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { Link, Redirect, Route, Switch } from 'react-router-dom'; + +import { t } from '@lingui/macro'; +import { PageSection, Card } from '@patternfly/react-core'; +import ContentError from 'components/ContentError'; +import OIDCDetail from './OIDCDetail'; +import OIDCEdit from './OIDCEdit'; + +function OIDC() { + const baseURL = '/settings/oidc'; + return ( + + + + + + + + + + + + + {t`View OIDC settings`} + + + + + + ); +} + +export default OIDC; diff --git a/awx/ui/src/screens/Setting/OIDC/OIDC.test.js b/awx/ui/src/screens/Setting/OIDC/OIDC.test.js new file mode 100644 index 0000000000..bc08c11809 --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDC.test.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { SettingsProvider } from 'contexts/Settings'; +import { SettingsAPI } from 'api'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; +import OIDC from './OIDC'; + +jest.mock('../../../api'); + +describe('', () => { + let wrapper; + + beforeEach(() => { + SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_OIDC_KEY: 'mock key', + SOCIAL_AUTH_OIDC_SECRET: '$encrypted$', + SOCIAL_AUTH_OIDC_OIDC_ENDPOINT: 'https://example.com', + SOCIAL_AUTH_OIDC_VERIFY_SSL: true, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should render OIDC details', async () => { + const history = createMemoryHistory({ + initialEntries: ['/settings/oidc/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + expect(wrapper.find('OIDCDetail').length).toBe(1); + }); + + test('should render OIDC edit', async () => { + const history = createMemoryHistory({ + initialEntries: ['/settings/oidc/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + expect(wrapper.find('OIDCEdit').length).toBe(1); + }); + + test('should show content error when user navigates to erroneous route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/settings/oidc/foo'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.js b/awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.js new file mode 100644 index 0000000000..8fe91342fc --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.js @@ -0,0 +1,98 @@ +import React, { useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; + +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { CardBody, CardActionsRow } from 'components/Card'; +import ContentLoading from 'components/ContentLoading'; +import ContentError from 'components/ContentError'; +import RoutedTabs from 'components/RoutedTabs'; +import { SettingsAPI } from 'api'; +import useRequest from 'hooks/useRequest'; +import { DetailList } from 'components/DetailList'; +import { useConfig } from 'contexts/Config'; +import { useSettings } from 'contexts/Settings'; +import { SettingDetail } from '../../shared'; + +function OIDCDetail() { + const { me } = useConfig(); + const { GET: options } = useSettings(); + + const { + isLoading, + error, + request, + result: OIDC, + } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('oidc'); + return data; + }, []), + null + ); + + useEffect(() => { + request(); + }, [request]); + + const tabsArray = [ + { + name: ( + <> + + {t`Back to Settings`} + + ), + link: `/settings`, + id: 99, + }, + { + name: t`Details`, + link: `/settings/oidc/details`, + id: 0, + }, + ]; + + return ( + <> + + + {isLoading && } + {!isLoading && error && } + {!isLoading && OIDC && ( + + {Object.keys(OIDC).map((key) => { + const record = options?.[key]; + return ( + + ); + })} + + )} + {me?.is_superuser && ( + + + + )} + + + ); +} + +export default OIDCDetail; diff --git a/awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.test.js b/awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.test.js new file mode 100644 index 0000000000..ba5ab57a3c --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDCDetail/OIDCDetail.test.js @@ -0,0 +1,97 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { SettingsProvider } from 'contexts/Settings'; +import { SettingsAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import { + assertDetail, + assertVariableDetail, +} from '../../shared/settingTestUtils'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import OIDCDetail from './OIDCDetail'; + +jest.mock('../../../../api'); + +describe('', () => { + let wrapper; + + beforeEach(() => { + SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_OIDC_KEY: 'mock key', + SOCIAL_AUTH_OIDC_SECRET: '$encrypted$', + SOCIAL_AUTH_OIDC_OIDC_ENDPOINT: 'https://example.com', + SOCIAL_AUTH_OIDC_VERIFY_SSL: true, + }, + }); + }); + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('OIDCDetail').length).toBe(1); + }); + + test('should render expected tabs', () => { + const expectedTabs = ['Back to Settings', 'Details']; + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should render expected details', () => { + assertDetail(wrapper, 'OIDC Key', 'mock key'); + assertDetail(wrapper, 'OIDC Secret', 'Encrypted'); + assertDetail(wrapper, 'OIDC Provider URL', 'https://example.com'); + assertDetail(wrapper, 'Verify OIDC Provider Certificate', 'On'); + }); + + test('should hide edit button from non-superusers', async () => { + const config = { + me: { + is_superuser: false, + }, + }; + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { config }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('Button[aria-label="Edit"]').exists()).toBeFalsy(); + }); + + test('should display content error when api throws error on initial render', async () => { + SettingsAPI.readCategory.mockRejectedValue(new Error()); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Setting/OIDC/OIDCDetail/index.js b/awx/ui/src/screens/Setting/OIDC/OIDCDetail/index.js new file mode 100644 index 0000000000..d385c07011 --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDCDetail/index.js @@ -0,0 +1 @@ +export { default } from './OIDCDetail'; diff --git a/awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.js b/awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.js new file mode 100644 index 0000000000..30e0616a4f --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.js @@ -0,0 +1,147 @@ +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Formik } from 'formik'; +import { Form } from '@patternfly/react-core'; +import { CardBody } from 'components/Card'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import { FormSubmitError } from 'components/FormField'; +import { FormColumnLayout } from 'components/FormLayout'; +import { useSettings } from 'contexts/Settings'; +import useModal from 'hooks/useModal'; +import useRequest from 'hooks/useRequest'; +import { SettingsAPI } from 'api'; +import { RevertAllAlert, RevertFormActionGroup } from '../../shared'; +import { + EncryptedField, + InputField, + BooleanField, +} from '../../shared/SharedFields'; + +function OIDCEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { + isLoading, + error, + request: fetchOIDC, + result: OIDC, + } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('oidc'); + const mergedData = {}; + Object.keys(data).forEach((key) => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchOIDC(); + }, [fetchOIDC]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async (values) => { + await SettingsAPI.updateAll(values); + history.push('/settings/oidc/details'); + }, + [history] + ), + null + ); + + const { error: revertError, request: revertAll } = useRequest( + useCallback(async () => { + await SettingsAPI.revertCategory('oidc'); + }, []), + null + ); + + const handleSubmit = async (form) => { + await submitForm({ + ...form, + }); + }; + + const handleRevertAll = async () => { + await revertAll(); + + closeModal(); + + history.push('/settings/oidc/details'); + }; + + const handleCancel = () => { + history.push('/settings/oidc/details'); + }; + + const initialValues = (fields) => + Object.keys(fields).reduce((acc, key) => { + if (fields[key].type === 'list' || fields[key].type === 'nested object') { + acc[key] = fields[key].value + ? JSON.stringify(fields[key].value, null, 2) + : null; + } else { + acc[key] = fields[key].value ?? ''; + } + return acc; + }, {}); + + return ( + + {isLoading && } + {!isLoading && error && } + {!isLoading && OIDC && ( + + {(formik) => ( +
+ + + + + + {submitError && } + {revertError && } + + + {isModalOpen && ( + + )} + + )} +
+ )} +
+ ); +} + +export default OIDCEdit; diff --git a/awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.test.js b/awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.test.js new file mode 100644 index 0000000000..25aca2b869 --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDCEdit/OIDCEdit.test.js @@ -0,0 +1,161 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { SettingsProvider } from 'contexts/Settings'; +import { SettingsAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../testUtils/enzymeHelpers'; +import mockAllOptions from '../../shared/data.allSettingOptions.json'; +import OIDCEdit from './OIDCEdit'; + +jest.mock('../../../../api'); + +describe('', () => { + let wrapper; + let history; + + beforeEach(() => { + SettingsAPI.revertCategory.mockResolvedValue({}); + SettingsAPI.updateAll.mockResolvedValue({}); + SettingsAPI.readCategory.mockResolvedValue({ + data: { + SOCIAL_AUTH_OIDC_KEY: 'mock key', + SOCIAL_AUTH_OIDC_SECRET: '$encrypted$', + SOCIAL_AUTH_OIDC_OIDC_ENDPOINT: 'https://example.com', + SOCIAL_AUTH_OIDC_VERIFY_SSL: true, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/oidc/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + test('initially renders without crashing', () => { + expect(wrapper.find('OIDCEdit').length).toBe(1); + }); + + test('should display expected form fields', async () => { + expect(wrapper.find('FormGroup[label="OIDC Key"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="OIDC Secret"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="OIDC Provider URL"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="Verify OIDC Provider Certificate"]').length + ).toBe(1); + }); + + test('should successfully send default values to api on form revert all', async () => { + expect(SettingsAPI.revertCategory).toHaveBeenCalledTimes(0); + expect(wrapper.find('RevertAllAlert')).toHaveLength(0); + await act(async () => { + wrapper + .find('button[aria-label="Revert all to default"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('RevertAllAlert')).toHaveLength(1); + await act(async () => { + wrapper + .find('RevertAllAlert button[aria-label="Confirm revert all"]') + .invoke('onClick')(); + }); + wrapper.update(); + expect(SettingsAPI.revertCategory).toHaveBeenCalledTimes(1); + expect(SettingsAPI.revertCategory).toHaveBeenCalledWith('oidc'); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_OIDC_SECRET"] button[aria-label="Revert"]' + ) + .invoke('onClick')(); + wrapper.find('input#SOCIAL_AUTH_OIDC_KEY').simulate('change', { + target: { value: 'new key', name: 'SOCIAL_AUTH_OIDC_KEY' }, + }); + wrapper.find('input#SOCIAL_AUTH_OIDC_OIDC_ENDPOINT').simulate('change', { + target: { + value: 'https://example.com', + name: 'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT', + }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledWith({ + SOCIAL_AUTH_OIDC_KEY: 'new key', + SOCIAL_AUTH_OIDC_SECRET: '', + SOCIAL_AUTH_OIDC_OIDC_ENDPOINT: 'https://example.com', + SOCIAL_AUTH_OIDC_VERIFY_SSL: true, + }); + }); + + test('should navigate to OIDC detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/oidc/details'); + }); + + test('should navigate to OIDC detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/oidc/details'); + }); + + test('should display error message on unsuccessful submission', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error)); + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1); + }); + + test('should display ContentError on throw', async () => { + SettingsAPI.readCategory.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Setting/OIDC/OIDCEdit/index.js b/awx/ui/src/screens/Setting/OIDC/OIDCEdit/index.js new file mode 100644 index 0000000000..b61b4587d3 --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/OIDCEdit/index.js @@ -0,0 +1 @@ +export { default } from './OIDCEdit'; diff --git a/awx/ui/src/screens/Setting/OIDC/index.js b/awx/ui/src/screens/Setting/OIDC/index.js new file mode 100644 index 0000000000..7acf1820be --- /dev/null +++ b/awx/ui/src/screens/Setting/OIDC/index.js @@ -0,0 +1 @@ +export { default } from './OIDC'; diff --git a/awx/ui/src/screens/Setting/SettingList.js b/awx/ui/src/screens/Setting/SettingList.js index 626a5c55e3..5b28d525c3 100644 --- a/awx/ui/src/screens/Setting/SettingList.js +++ b/awx/ui/src/screens/Setting/SettingList.js @@ -81,6 +81,10 @@ function SettingList() { title: t`TACACS+ settings`, path: '/settings/tacacs', }, + { + title: t`Generic OIDC settings`, + path: '/settings/oidc', + }, ], }, { diff --git a/awx/ui/src/screens/Setting/Settings.js b/awx/ui/src/screens/Setting/Settings.js index fd56512ea7..e3d9c5ac17 100644 --- a/awx/ui/src/screens/Setting/Settings.js +++ b/awx/ui/src/screens/Setting/Settings.js @@ -12,6 +12,7 @@ import useRequest from 'hooks/useRequest'; import AzureAD from './AzureAD'; import GitHub from './GitHub'; import GoogleOAuth2 from './GoogleOAuth2'; +import OIDC from './OIDC'; import Jobs from './Jobs'; import LDAP from './LDAP'; import Subscription from './Subscription'; @@ -68,6 +69,9 @@ function Settings() { '/settings/google_oauth2': t`Google OAuth2`, '/settings/google_oauth2/details': t`Details`, '/settings/google_oauth2/edit': t`Edit Details`, + '/settings/oidc': t`Generic OIDC`, + '/settings/oidc/details': t`Details`, + '/settings/oidc/edit': t`Edit Details`, '/settings/jobs': t`Jobs`, '/settings/jobs/details': t`Details`, '/settings/jobs/edit': t`Edit Details`, @@ -153,6 +157,9 @@ function Settings() { + + + diff --git a/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json b/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json index baa343c826..586ac3d7ef 100644 --- a/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json +++ b/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json @@ -840,6 +840,39 @@ "read_only": false } }, + "SOCIAL_AUTH_OIDC_KEY": { + "type": "string", + "label": "OIDC Key", + "help_text": "The OIDC key (Client ID) from your IDP.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": "" + }, + "SOCIAL_AUTH_OIDC_SECRET": { + "type": "string", + "label": "OIDC Secret", + "help_text": "The OIDC secret (Client Secret) from your IDP.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": "" + }, + "SOCIAL_AUTH_OIDC_OIDC_ENDPOINT": { + "type": "string", + "label": "OIDC Provider URL", + "help_text": "The URL for your OIDC provider, e.g.: http(s)://hostname/.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": "" + }, + "SOCIAL_AUTH_OIDC_VERIFY_SSL": { + "type": "boolean", + "required": false, + "label": "Verify OIDC Provider Certificate", + "help_text": "Verify the OIDV provider ssl certificate.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": true + }, "AUTH_LDAP_SERVER_URI": { "type": "string", "required": false, @@ -4485,6 +4518,38 @@ "type": "string" } }, + "SOCIAL_AUTH_OIDC_KEY": { + "type": "string", + "label": "OIDC Key", + "help_text": "The OIDC key (Client ID) from your IDP.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": "" + }, + "SOCIAL_AUTH_OIDC_SECRET": { + "type": "string", + "label": "OIDC Secret", + "help_text": "The OIDC secret (Client Secret) from your IDP.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": "" + }, + "SOCIAL_AUTH_OIDC_OIDC_ENDPOINT": { + "type": "string", + "label": "OIDC Provider URL", + "help_text": "The URL for your OIDC provider, e.g.: http(s)://hostname/.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": "" + }, + "SOCIAL_AUTH_OIDC_VERIFY_SSL": { + "type": "boolean", + "label": "Verify OIDC Provider Certificate", + "help_text": "Verify the OIDV provider ssl certificate.", + "category": "Generic OIDC", + "category_slug": "oidc", + "default": true + }, "AUTH_LDAP_SERVER_URI": { "type": "string", "label": "LDAP Server URI", diff --git a/awx/ui/src/screens/Setting/shared/data.allSettings.json b/awx/ui/src/screens/Setting/shared/data.allSettings.json index c72f5c3c8e..e5136f4b58 100644 --- a/awx/ui/src/screens/Setting/shared/data.allSettings.json +++ b/awx/ui/src/screens/Setting/shared/data.allSettings.json @@ -253,6 +253,10 @@ "SOCIAL_AUTH_SAML_ORGANIZATION_ATTR":{}, "SOCIAL_AUTH_SAML_TEAM_ATTR":{}, "SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR":{}, + "SOCIAL_AUTH_OIDC_KEY":"", + "SOCIAL_AUTH_OIDC_SECRET":"", + "SOCIAL_AUTH_OIDC_OIDC_ENDPOINT":"", + "SOCIAL_AUTH_OIDC_VERIFY_SSL":true, "NAMED_URL_FORMATS":{ "organizations":"", "teams":"++", diff --git a/docs/licenses/ecdsa.txt b/docs/licenses/ecdsa.txt new file mode 100644 index 0000000000..474479a2ce --- /dev/null +++ b/docs/licenses/ecdsa.txt @@ -0,0 +1,24 @@ +"python-ecdsa" Copyright (c) 2010 Brian Warner + +Portions written in 2005 by Peter Pearson and placed in the public domain. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/python-jose.txt b/docs/licenses/python-jose.txt new file mode 100644 index 0000000000..59160df34b --- /dev/null +++ b/docs/licenses/python-jose.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Michael Davis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/requirements/requirements.in b/requirements/requirements.in index ce1bb00a1f..e0f707b8bc 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -47,7 +47,7 @@ python-ldap>=3.4.0 # https://github.com/ansible/awx/security/dependabot/20 pyyaml>=5.4.1 # minimum to fix https://github.com/yaml/pyyaml/issues/478 receptorctl==1.2.3 schedule==0.6.0 -social-auth-core==4.2.0 # see UPGRADE BLOCKERs +social-auth-core[openidconnect]==4.3.0 # see UPGRADE BLOCKERs social-auth-app-django==5.0.0 # see UPGRADE BLOCKERs redis requests diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 79bbe88aca..f05f27b807 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -130,6 +130,8 @@ djangorestframework-yaml==2.0.0 # via -r /awx_devel/requirements/requirements.in docutils==0.16 # via python-daemon +ecdsa==0.18.0 + # via python-jose # via # -r /awx_devel/requirements/requirements_git.txt # django-radius @@ -246,6 +248,7 @@ ptyprocess==0.6.0 pyasn1==0.4.8 # via # pyasn1-modules + # python-jose # python-ldap # rsa # service-identity @@ -283,6 +286,8 @@ python-dateutil==2.8.1 # receptorctl python-dsv-sdk==0.0.1 # via -r /awx_devel/requirements/requirements.in +python-jose==3.3.0 + # via social-auth-core python-ldap==3.4.0 # via # -r /awx_devel/requirements/requirements.in @@ -334,7 +339,9 @@ requests-oauthlib==1.3.1 # msrest # social-auth-core rsa==4.7.2 - # via google-auth + # via + # google-auth + # python-jose schedule==0.6.0 # via -r /awx_devel/requirements/requirements.in semantic-version==2.9.0 @@ -351,6 +358,7 @@ six==1.14.0 # automat # django-extensions # django-pglocks + # ecdsa # google-auth # isodate # jaraco-collections @@ -372,7 +380,7 @@ smmap==3.0.1 # via gitdb social-auth-app-django==5.0.0 # via -r /awx_devel/requirements/requirements.in -social-auth-core==4.2.0 +social-auth-core[openidconnect]==4.3.0 # via # -r /awx_devel/requirements/requirements.in # social-auth-app-django