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" />
@@ -163,6 +215,7 @@ export default { :label="t(`authConfig.oidc.clientSecret`)" :mode="mode" required + data-testid="oidc-client-secret" />
@@ -200,6 +253,19 @@ export default { +
+
+ +
+
+
+