diff --git a/shell/assets/images/vendor/openid.svg b/shell/assets/images/vendor/openid.svg
new file mode 100644
index 00000000000..71db9269e5e
--- /dev/null
+++ b/shell/assets/images/vendor/openid.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml
index 4bc98081a56..961c8fcb801 100644
--- a/shell/assets/translations/en-us.yaml
+++ b/shell/assets/translations/en-us.yaml
@@ -580,11 +580,21 @@ authConfig:
label: Endpoints
custom: Specify
standard: Generate
+ url: URL
+ realm: Realm
keycloak:
url: Keycloak URL
realm: Keycloak Realm
issuer: Issuer
authEndpoint: Auth Endpoint
+ jwksUrl: JWKS URL
+ tokenEndpoint: Token Endpoint
+ userInfoEndpoint: User Info Endpoint
+ acrValue: Authorization Context Reference
+ scope:
+ label: Scopes
+ placeholder: openid
+ protip: The openid
scope is required. If you are using custom scopes, ensure that it is included.
cert:
label: Certificate
placeholder: Paste in the certificate, starting with -----BEGIN CERTIFICATE-----
@@ -6511,7 +6521,7 @@ model:
okta: Okta
freeipa: FreeIPA
googleoauth: Google
- oidc: OIDC
+ oidc: Generic OIDC
keycloakoidc: Keycloak
cluster:
diff --git a/shell/components/auth/login/oidc.vue b/shell/components/auth/login/oidc.vue
index 95ada1ae983..9f190f1d2b8 100644
--- a/shell/components/auth/login/oidc.vue
+++ b/shell/components/auth/login/oidc.vue
@@ -4,6 +4,12 @@ import Login from '@shell/mixins/login';
export default {
mixins: [Login],
+ computed: {
+ uniqueDisplayName() {
+ return this.t('model.authConfig.description.oidc');
+ },
+ },
+
methods: {
login() {
this.$store.dispatch('auth/redirectTo', { provider: this.name });
@@ -20,7 +26,7 @@ export default {
style="font-size: 18px;"
@click="login"
>
- {{ t('login.loginWithProvider', {provider: displayName}) }}
+ {{ t('login.loginWithProvider', {provider: uniqueDisplayName}) }}
diff --git a/shell/config/product/auth.js b/shell/config/product/auth.js
index 6c8b03ff550..eb1463548ec 100644
--- a/shell/config/product/auth.js
+++ b/shell/config/product/auth.js
@@ -174,6 +174,7 @@ export function init(store) {
componentForType(`${ MANAGEMENT.AUTH_CONFIG }/googleoauth`, 'auth/googleoauth');
componentForType(`${ MANAGEMENT.AUTH_CONFIG }/azuread`, 'auth/azuread');
componentForType(`${ MANAGEMENT.AUTH_CONFIG }/keycloakoidc`, 'auth/oidc');
+ componentForType(`${ MANAGEMENT.AUTH_CONFIG }/oidc`, 'auth/oidc');
basicType([
'config',
diff --git a/shell/edit/auth/__tests__/oidc.test.ts b/shell/edit/auth/__tests__/oidc.test.ts
new file mode 100644
index 00000000000..6bdad06ce1f
--- /dev/null
+++ b/shell/edit/auth/__tests__/oidc.test.ts
@@ -0,0 +1,137 @@
+/* eslint-disable jest/no-hooks */
+import { mount } from '@vue/test-utils';
+import { _EDIT } from '@shell/config/query-params';
+
+import oidc from '@shell/edit/auth/oidc.vue';
+
+jest.mock('@shell/utils/clipboard', () => {
+ return { copyTextToClipboard: jest.fn(() => Promise.resolve({})) };
+});
+
+const validClientId = 'rancheroidc';
+const validClientSecret = 'TOkUxg0P67m1UXWNkJLHDPkUZFIKOWSq';
+const validUrl = 'https://localhost:8080';
+const validRealm = 'rancherrealm';
+const validRancherUrl = 'https://localhost/verify-auth';
+const validIssuer = 'http://localhost:8080/realms/rancherrealm';
+const validAuthEndpoint = 'http://localhost:8080/realms/rancherrealm/protocol/openid-connect/auth';
+const validScope = 'openid profile email';
+
+const mockModel = {
+ enabled: false,
+ id: 'oidc',
+ rancherUrl: validRancherUrl,
+ issuer: validIssuer,
+ authEndpoint: validAuthEndpoint,
+ scope: validScope,
+ clientId: validClientId,
+ clientSecret: validClientSecret,
+ type: 'oidcConfig',
+};
+
+const mockedAuthConfigMixin = {
+ data() {
+ return {
+ isEnabling: false,
+ editConfig: false,
+ model: { ...mockModel },
+ serverSetting: null,
+ errors: [],
+ originalModel: null,
+ principals: [],
+ authConfigName: 'oidc',
+ };
+ },
+ computed: {},
+ methods: {}
+};
+
+describe('oidc.vue', () => {
+ let wrapper: any;
+ const requiredSetup = () => ({
+ mixins: [mockedAuthConfigMixin],
+ mocks: {
+ $fetchState: { pending: false },
+ $store: {
+ getters: {
+ currentStore: () => 'current_store',
+ 'current_store/schemaFor': jest.fn(),
+ 'current_store/all': jest.fn(),
+ 'i18n/t': (val: string) => val,
+ 'i18n/exists': jest.fn(),
+ },
+ dispatch: jest.fn()
+ },
+ $route: { query: { AS: '' }, params: { id: 'oicd' } },
+ $router: { applyQuery: jest.fn() },
+ },
+ propsData: {
+ value: { applicationSecret: '' },
+ mode: _EDIT,
+ },
+ });
+
+ beforeEach(() => {
+ wrapper = mount(oidc, { ...requiredSetup() });
+ });
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('have "Create" button enabled when provider is enabled and not editing config', async() => {
+ wrapper.setData({ model: { enabled: true }, editConfig: false });
+ await wrapper.vm.$nextTick();
+
+ const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
+
+ expect(saveButton.disabled).toBe(false);
+ });
+
+ it('have "Create" button disabled when provider is disabled and editing config before fields are filled in', async() => {
+ wrapper.setData({ model: {}, editConfig: true });
+ await wrapper.vm.$nextTick();
+
+ const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
+
+ expect(saveButton.disabled).toBe(true);
+ });
+
+ it('have "Create" button disabled when provider is disabled and editing config after required fields and scope is missing openid', async() => {
+ wrapper.setData({ oidcUrls: { url: validUrl, realm: validRealm } });
+ await wrapper.vm.$nextTick();
+
+ const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
+
+ expect(saveButton.disabled).toBe(true);
+ });
+
+ it('have "Create" button enabled when customEndpoint is disabled and required fields are filled in', async() => {
+ wrapper.setData({ oidcUrls: { url: validUrl, realm: validRealm }, oidcScope: validScope.split(' ') });
+ await wrapper.vm.$nextTick();
+
+ const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
+
+ expect(saveButton.disabled).toBe(false);
+ });
+
+ it('have "Create" button enabled when customEndpoint is enabled and required fields are filled in', async() => {
+ wrapper.setData({ customEndpoint: { value: true }, oidcScope: validScope.split(' ') });
+ await wrapper.vm.$nextTick();
+
+ const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
+
+ expect(saveButton.disabled).toBe(false);
+ });
+
+ it('updates issuer endpoint when oidcUrls.url and oidcUrls.realm changes', async() => {
+ wrapper.setData({ oidcUrls: { url: validUrl } });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.model.issuer).toBe(`${ validUrl }/realms/`);
+
+ wrapper.setData({ oidcUrls: { realm: validRealm } });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.model.issuer).toBe(`${ validUrl }/realms/${ validRealm }`);
+ });
+});
diff --git a/shell/edit/auth/oidc.vue b/shell/edit/auth/oidc.vue
index 05539b5858f..3510122ed2d 100644
--- a/shell/edit/auth/oidc.vue
+++ b/shell/edit/auth/oidc.vue
@@ -3,26 +3,31 @@ import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view';
import AuthConfig from '@shell/mixins/auth-config';
import CruResource from '@shell/components/CruResource';
-import { LabeledInput } from '@components/Form/LabeledInput';
import AllowedPrincipals from '@shell/components/auth/AllowedPrincipals';
import FileSelector from '@shell/components/form/FileSelector';
import AuthBanner from '@shell/components/auth/AuthBanner';
-import { RadioGroup } from '@components/Form/Radio';
import AuthProviderWarningBanners from '@shell/edit/auth/AuthProviderWarningBanners';
+import AdvancedSection from '@shell/components/AdvancedSection.vue';
+import ArrayList from '@shell/components/form/ArrayList';
+import { LabeledInput } from '@components/Form/LabeledInput';
+import { RadioGroup } from '@components/Form/Radio';
export default {
components: {
Loading,
CruResource,
- LabeledInput,
AllowedPrincipals,
FileSelector,
AuthBanner,
- RadioGroup,
- AuthProviderWarningBanners
+ AuthProviderWarningBanners,
+ AdvancedSection,
+ ArrayList,
+ LabeledInput,
+ RadioGroup
},
mixins: [CreateEditView, AuthConfig],
+
data() {
return {
customEndpoint: {
@@ -36,10 +41,14 @@ export default {
true
]
},
- keycloakUrls: {
- url: null,
- realm: null
- }
+ oidcUrls: {
+ url: null,
+ realm: null,
+ jwksUrl: null,
+ tokenEndpoint: null,
+ userInfoEndpoint: null,
+ },
+ oidcScope: []
};
},
@@ -59,42 +68,84 @@ export default {
};
},
+ validationPassed() {
+ if ( this.model.enabled && !this.editConfig ) {
+ return true;
+ }
+
+ const { clientId, clientSecret } = this.model;
+ const isValidScope = this.model.id === 'keycloakoidc' || this.oidcScope?.includes('openid');
+
+ if ( !isValidScope ) {
+ return false;
+ }
+
+ if ( !this.customEndpoint.value ) {
+ const { url, realm } = this.oidcUrls;
+
+ return !!(clientId && clientSecret && url && realm);
+ } else {
+ const { rancherUrl, issuer } = this.model;
+
+ return !!(clientId && clientSecret && rancherUrl && issuer);
+ }
+ }
},
+
watch: {
- 'keycloakUrls.url'() {
- this.updateIssuerEndpoint();
+ 'oidcUrls.url'() {
+ this.updateEndpoints();
},
- 'keycloakUrls.realm'() {
- this.updateIssuerEndpoint();
+
+ 'oidcUrls.realm'() {
+ this.updateEndpoints();
},
+
'model.enabled'(neu) {
// Cover case where oidc gets disabled and we return to the edit screen with a reset model
if (!neu) {
- this.keycloakUrls = {
- url: null,
- realm: null
+ this.oidcUrls = {
+ url: null,
+ realm: null,
+ jwksUrl: null,
+ tokenEndpoint: null,
+ userInfoEndpoint: null,
};
this.customEndpoint.value = false;
+ this.oidcScope = this.model?.scope?.split(' ');
+ } else {
+ this.oidcScope = this.model?.scope?.split(' ');
}
},
+
editConfig(neu, old) {
- // Cover use case where user edits existing oidc (keycloakUrls aren't persisted, so if we have issuer & authEndpoint set custom endpoints to true)
+ // Cover use case where user edits existing oidc (oidcUrls aren't persisted, so if we have issuer set custom endpoints to true)
if (!old && neu) {
- this.customEndpoint.value = (!this.keycloakUrls.url && !this.keycloakUrls.authEndpoint) && (!!this.model.issuer && !!this.model.authEndpoint);
+ this.customEndpoint.value = !this.oidcUrls.url && !!this.model.issuer;
}
}
},
methods: {
- updateIssuerEndpoint() {
- if (!this.keycloakUrls.url) {
+ updateEndpoints() {
+ if (!this.oidcUrls.url) {
return;
}
- const url = this.keycloakUrls.url.replaceAll(' ', '');
+ const isKeycloak = this.model.id === 'keycloakoidc';
+
+ const url = this.oidcUrls.url.replaceAll(' ', '');
+ const realmsPath = isKeycloak ? 'auth/realms' : 'realms';
- this.model.issuer = `${ url }/auth/realms/${ this.keycloakUrls.realm || '' }`;
- this.model.authEndpoint = `${ this.model.issuer || '' }/protocol/openid-connect/auth`;
+ this.model.issuer = `${ url }/${ realmsPath }/${ this.oidcUrls.realm || '' }`;
+
+ if ( isKeycloak ) {
+ this.model.authEndpoint = `${ this.model.issuer || '' }/protocol/openid-connect/auth`;
+ }
},
+
+ updateScope() {
+ this.model.scope = this.oidcScope.join(' ');
+ }
}
};
@@ -108,7 +159,7 @@ export default {
:mode="mode"
:resource="model"
:subtypes="[]"
- :validation-passed="true"
+ :validation-passed="validationPassed"
:finish-button-mode="model.enabled ? 'edit' : 'enable'"
:can-yaml="false"
:errors="errors"
@@ -155,6 +206,7 @@ export default {
:label="t(`authConfig.oidc.clientId`)"
:mode="mode"
required
+ data-testid="oidc-client-id"
/>