diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py
index 2df7f8dc69..0be21d6020 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) => (
+
+ )}
+
+ )}
+
+ );
+}
+
+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