diff --git a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts index 14700b1506..09422aabe4 100644 --- a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts +++ b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts @@ -1,37 +1,9 @@ -import "@goauthentik/admin/applications/wizard/ak-wizard-title"; -import "@goauthentik/admin/common/ak-crypto-certificate-search"; -import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; -import { - makeOAuth2PropertyMappingsSelector, - oauth2PropertyMappingsProvider, -} from "@goauthentik/admin/providers/oauth2/OAuth2PropertyMappings.js"; -import { - clientTypeOptions, - issuerModeOptions, - redirectUriHelp, - subjectModeOptions, -} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; -import { - makeSourceSelector, - oauth2SourcesProvider, -} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js"; +import { renderForm } from "@goauthentik/admin/providers/oauth2/OAuth2ProviderFormForm.js"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; -import "@goauthentik/components/ak-number-input"; -import "@goauthentik/components/ak-radio-input"; -import "@goauthentik/components/ak-switch-input"; -import "@goauthentik/components/ak-text-input"; -import "@goauthentik/components/ak-textarea-input"; -import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; -import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/HorizontalFormElement"; -import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; -import { html, nothing } from "lit"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { ClientTypeEnum, FlowsInstancesListDesignationEnum, SourcesApi } from "@goauthentik/api"; +import { SourcesApi } from "@goauthentik/api"; import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; @@ -59,227 +31,10 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { render() { const provider = this.wizard.provider as OAuth2Provider | undefined; const errors = this.wizard.errors.provider; - - return html`${msg("Configure OAuth2/OpenId Provider")} -
- - - - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
- - - ${msg("Protocol settings")} -
- ) => { - this.showClientSecret = ev.detail.value !== ClientTypeEnum.Public; - }} - .options=${clientTypeOptions} - > - - - - - - - - - - - - - - -

- ${msg("Key used to sign the tokens.")} -

-
-
-
- - - ${msg("Advanced protocol settings")} -
- - ${msg("Configure how long access codes are valid for.")} -

- `} - > -
- - - ${msg("Configure how long access tokens are valid for.")} -

- `} - > -
- - - ${msg("Configure how long refresh tokens are valid for.")} -

- `} - > -
- - - -

- ${msg( - "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", - )} -

-
- - - - - - -
-
- - - ${msg("Machine-to-Machine authentication settings")} -
- - -

- ${msg( - "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", - )} -

-
-
-
-
`; + const showClientSecretCallback = (show: boolean) => { + this.showClientSecret = show; + }; + return renderForm(provider ?? {}, errors, this.showClientSecret, showClientSecretCallback); } } diff --git a/web/src/admin/providers/ProviderWizard.ts b/web/src/admin/providers/ProviderWizard.ts index 51e159c671..2c7ba266b9 100644 --- a/web/src/admin/providers/ProviderWizard.ts +++ b/web/src/admin/providers/ProviderWizard.ts @@ -58,6 +58,7 @@ export class ProviderWizard extends AKElement { }} > html`

${m}

`, -)}`; +import { renderForm } from "./OAuth2ProviderFormForm.js"; /** * Form page for OAuth2 Authentication Method @@ -145,233 +40,11 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { } } - renderForm(): TemplateResult { - const provider = this.instance; - - return html` - - - ${msg("Protocol settings")} -
- ) => { - this.showClientSecret = ev.detail.value !== ClientTypeEnum.Public; - }} - .options=${clientTypeOptions} - > - - - - - - - - - - - -

${msg("Key used to sign the tokens.")}

-
- - - -

- ${msg("Key used to encrypt the tokens.")} -

-
-
-
- - - ${msg("Flow settings")} -
- - -

- ${msg( - "Flow used when a user access this provider and is not authenticated.", - )} -

-
- - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
-
-
- - - ${msg("Advanced protocol settings")} -
- - ${msg("Configure how long access codes are valid for.")} -

- `} - > -
- - ${msg("Configure how long access tokens are valid for.")} -

- `} - > -
- - - ${msg("Configure how long refresh tokens are valid for.")} -

- `} - > -
- - - -

- ${msg( - "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", - )} -

-
- - - - - -
-
- - - ${msg("Machine-to-Machine authentication settings")} -
- - -

- ${msg( - "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", - )} -

-
-
-
`; + renderForm() { + const showClientSecretCallback = (show: boolean) => { + this.showClientSecret = show; + }; + return renderForm(this.instance ?? {}, [], this.showClientSecret, showClientSecretCallback); } } diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts new file mode 100644 index 0000000000..35a2ac4cea --- /dev/null +++ b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts @@ -0,0 +1,354 @@ +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/components/ak-text-input"; +import "@goauthentik/components/ak-textarea-input"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/utils/TimeDeltaHelp"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + ClientTypeEnum, + FlowsInstancesListDesignationEnum, + IssuerModeEnum, + OAuth2Provider, + SubModeEnum, + ValidationError, +} from "@goauthentik/api"; + +import { + makeOAuth2PropertyMappingsSelector, + oauth2PropertyMappingsProvider, +} from "./OAuth2PropertyMappings.js"; +import { makeSourceSelector, oauth2SourcesProvider } from "./OAuth2Sources.js"; + +export const clientTypeOptions = [ + { + label: msg("Confidential"), + value: ClientTypeEnum.Confidential, + default: true, + description: html`${msg( + "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets", + )}`, + }, + { + label: msg("Public"), + value: ClientTypeEnum.Public, + description: html`${msg( + "Public clients are incapable of maintaining the confidentiality and should use methods like PKCE. ", + )}`, + }, +]; + +export const subjectModeOptions = [ + { + label: msg("Based on the User's hashed ID"), + value: SubModeEnum.HashedUserId, + default: true, + }, + { + label: msg("Based on the User's ID"), + value: SubModeEnum.UserId, + }, + { + label: msg("Based on the User's UUID"), + value: SubModeEnum.UserUuid, + }, + { + label: msg("Based on the User's username"), + value: SubModeEnum.UserUsername, + }, + { + label: msg("Based on the User's Email"), + value: SubModeEnum.UserEmail, + description: html`${msg("This is recommended over the UPN mode.")}`, + }, + { + label: msg("Based on the User's UPN"), + value: SubModeEnum.UserUpn, + description: html`${msg( + "Requires the user to have a 'upn' attribute set, and falls back to hashed user ID. Use this mode only if you have different UPN and Mail domains.", + )}`, + }, +]; + +export const issuerModeOptions = [ + { + label: msg("Each provider has a different issuer, based on the application slug"), + value: IssuerModeEnum.PerProvider, + default: true, + }, + { + label: msg("Same identifier is used for all providers"), + value: IssuerModeEnum.Global, + }, +]; + +const redirectUriHelpMessages = [ + msg( + "Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.", + ), + msg( + "If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.", + ), + msg( + 'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.', + ), +]; + +export const redirectUriHelp = html`${redirectUriHelpMessages.map( + (m) => html`

${m}

`, +)}`; + +type ShowClientSecret = (show: boolean) => void; +const defaultShowClientSecret: ShowClientSecret = (_show) => undefined; + +export function renderForm( + provider: Partial, + errors: ValidationError, + showClientSecret = false, + showClientSecretCallback: ShowClientSecret = defaultShowClientSecret, +) { + return html` + + + ${msg("Protocol settings")} +
+ ) => { + showClientSecretCallback(ev.detail.value !== ClientTypeEnum.Public); + }} + .options=${clientTypeOptions} + > + + + + + + + + + + + +

${msg("Key used to sign the tokens.")}

+
+ + + +

${msg("Key used to encrypt the tokens.")}

+
+
+
+ + + ${msg("Flow settings")} +
+ + +

+ ${msg( + "Flow used when a user access this provider and is not authenticated.", + )} +

+
+ + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+
+
+ + + ${msg("Advanced protocol settings")} +
+ + ${msg("Configure how long access codes are valid for.")} +

+ `} + > +
+ + ${msg("Configure how long access tokens are valid for.")} +

+ `} + > +
+ + + ${msg("Configure how long refresh tokens are valid for.")} +

+ `} + > +
+ + + +

+ ${msg( + "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", + )} +

+
+ + + + + +
+
+ + + ${msg("Machine-to-Machine authentication settings")} +
+ + +

+ ${msg( + "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", + )} +

+
+
+
`; +} diff --git a/web/src/elements/forms/FormGroup.ts b/web/src/elements/forms/FormGroup.ts index 285bc5acf0..92986eaecf 100644 --- a/web/src/elements/forms/FormGroup.ts +++ b/web/src/elements/forms/FormGroup.ts @@ -44,37 +44,43 @@ export class FormGroup extends AKElement { } render(): TemplateResult { - return html`
-
-
- + return html`
+
+
+
+ +
-
-
-
-
-
- +
+
+
+
+ +
+
+
+
-
- -
+
-
`; } } diff --git a/web/tests/pageobjects/controls.ts b/web/tests/pageobjects/controls.ts new file mode 100644 index 0000000000..cb14cd17a5 --- /dev/null +++ b/web/tests/pageobjects/controls.ts @@ -0,0 +1,102 @@ +import { browser } from "@wdio/globals"; +import { match } from "ts-pattern"; +import { ChainablePromiseArray, Key } from "webdriverio"; + +browser.addCommand('findByText', async function(items: ChainablePromiseArray, text: string) { + let item: WebdriverIO.Element | undefined = undefined; + for (const i of items) { + const label = await i.getText(); + if (label.indexOf(text) !== -1) { + item = i; + break; + } + } + return item; +}, true); + +export async function setSearchSelect(name: string, value: string) { + const control = await (async () => { + try { + const control = await $(`ak-search-select[name="${name}"]`); + await control.waitForExist({ timeout: 500 }); + return control; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + } catch (_e: any) { + const control = await $(`ak-search-selects-ez[name="${name}"]`); + return control; + } + })(); + + // Find the search select input control and activate it. + const view = await control.$("ak-search-select-view"); + const input = await view.$('input[type="text"]'); + await input.scrollIntoView(); + await input.click(); + + // Weirdly necessary because it's portals! + const searchBlock = await ( + await $(`div[data-managed-for*="${name}"]`).$("ak-list-select") + ).shadow$$("button"); + + // @ts-expect-error "Types break on shadow$$" + for (const button of searchBlock) { + if ((await button.getText()).includes(value)) { + target = button; + break; + } + } + // @ts-expect-error "TSC cannot tell if the `for` loop actually performs the assignment." + if (!target) { + throw new Error(`Expected to find an entry matching the spec ${value}`); + } + await (await target).click(); + await browser.keys(Key.Tab); +} + +export async function setTextInput(name: string, value: string) { + const control = await $(`input[name="${name}"]`); + await control.scrollIntoView(); + await control.setValue(value); +} + +export async function setRadio(name: string, value: string) { + const control = await $(`ak-radio[name="${name}"]`); + await control.scrollIntoView(); + const item = await control.$(`label.*=${value}`).parentElement(); + await item.scrollIntoView(); + await item.click(); +} + +export async function setTypeCreate(name: string, value: string) { + const control = await $(`ak-wizard-page-type-create[name="${name}"]`); + await control.scrollIntoView(); + const cards = ; + const selection = await findByText(await control.$$("div.pf-c-card__title"), value); + await selection.scrollIntoView(); + await selection.click(); +} + +export async function setFormGroup(name: string, setting: "open" | "closed") { + const formGroup = await $(`.//span[contains(., "${name}")]`); + await formGroup.scrollIntoView(); + const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); + await match([toggle.getAttribute("expanded"), setting]) + .with(["false", "open"], async () => await toggle.click()) + .with(["true", "closed"], async () => await toggle.click()) + .otherwise(async () => {}); +} + +export async function clickButton(name: string, ctx?: WebdriverIO.Element) { + const context = ctx ?? browser; + const buttons = await context.$$("button"); + let button: WebdriverIO.Element; + for (const b of buttons) { + const label = await b.getText(); + if (label.indexOf(name) !== -1) { + button = b; + break; + } + } + await button.scrollIntoView(); + await button.click(); +} diff --git a/web/tests/pageobjects/page.ts b/web/tests/pageobjects/page.ts index a5b5f15a02..ae225ce06c 100644 --- a/web/tests/pageobjects/page.ts +++ b/web/tests/pageobjects/page.ts @@ -1,4 +1,5 @@ import { browser } from "@wdio/globals"; +import { match } from "ts-pattern"; import { Key } from "webdriverio"; const CLICK_TIME_DELAY = 250; @@ -7,6 +8,7 @@ const CLICK_TIME_DELAY = 250; * Main page object containing all methods, selectors and functionality that is shared across all * page objects */ + export default class Page { /** * Opens a sub page of the page @@ -31,7 +33,6 @@ export default class Page { * why it would be hard to simplify this further (`flow` vs `tentanted-flow` vs a straight-up * SearchSelect each have different a `searchSelector`). */ - async searchSelect(searchSelector: string, managedSelector: string, buttonSelector: string) { const inputBind = await $(searchSelector); const inputMain = await inputBind.$('input[type="text"]'); @@ -55,6 +56,77 @@ export default class Page { await browser.keys(Key.Tab); } + async setSearchSelect(name: string, value: string) { + const control = await (async () => { + try { + const control = await $(`ak-search-select[name="${name}"]`); + await control.waitForExist({ timeout: 500 }); + return control; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + } catch (_e: any) { + const control = await $(`ak-search-selects-ez[name="${name}"]`); + return control; + } + })(); + + // Find the search select input control and activate it. + const view = await control.$("ak-search-select-view"); + const input = await view.$('input[type="text"]'); + await input.scrollIntoView(); + await input.click(); + + // Weirdly necessary because it's portals! + const searchBlock = await ( + await $(`div[data-managed-for="${name}"]`).$("ak-list-select") + ).shadow$$("button"); + + // @ts-expect-error "Types break on shadow$$" + for (const button of searchBlock) { + if ((await button.getText()).includes(value)) { + target = button; + break; + } + } + // @ts-expect-error "TSC cannot tell if the `for` loop actually performs the assignment." + if (!target) { + throw new Error(`Expected to find an entry matching the spec ${value}`); + } + await (await target).click(); + await browser.keys(Key.Tab); + } + + async setTextInput(name: string, value: string) { + const control = await $(`input[name="${name}"}`); + await control.scrollIntoView(); + await control.setValue(value); + } + + async setRadio(name: string, value: string) { + const control = await $(`ak-radio[name="${name}"]`); + await control.scrollIntoView(); + const item = await control.$(`label.*=${value}`).parentElement(); + await item.scrollIntoView(); + await item.click(); + } + + async setTypeCreate(name: string, value: string) { + const control = await $(`ak-wizard-page-type-create[name="${name}"]`); + await control.scrollIntoView(); + const selection = await $(`.pf-c-card__.*=${value}`); + await selection.scrollIntoView(); + await selection.click(); + } + + async setFormGroup(name: string, setting: "open" | "closed") { + const formGroup = await $(`ak-form-group span[slot="header"].*=${name}`).parentElement(); + await formGroup.scrollIntoView(); + const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); + await match([toggle.getAttribute("expanded"), setting]) + .with(["false", "open"], async () => await toggle.click()) + .with(["true", "closed"], async () => await toggle.click()) + .otherwise(async () => {}); + } + public async logout() { await browser.url("http://localhost:9000/flows/-/default/invalidation/"); return await this.pause(); diff --git a/web/tests/specs/oauth-provider.ts b/web/tests/specs/oauth-provider.ts index 69557e9891..0cb5393986 100644 --- a/web/tests/specs/oauth-provider.ts +++ b/web/tests/specs/oauth-provider.ts @@ -1,4 +1,11 @@ import { expect } from "@wdio/globals"; +import { + clickButton, + setFormGroup, + setSearchSelect, + setTextInput, + setTypeCreate, +} from "pageobjects/controls.js"; import ProviderWizardView from "../pageobjects/provider-wizard.page.js"; import ProvidersListPage from "../pageobjects/providers-list.page.js"; @@ -16,6 +23,14 @@ async function reachTheProvider() { await expect(await ProviderWizardView.wizardTitle).toHaveText("New provider"); } +async function fillOutFields(fields: FieldDesc[]) { + for (const field of fields) { + const thefunc = field[0]; + const args = field.slice(1); + await thefunc.apply($, args); + } +} + describe("Configure Oauth2 Providers", () => { it("Should configure a simple LDAP Application", async () => { const newProviderName = `New OAuth2 Provider - ${randomId()}`; @@ -23,25 +38,19 @@ describe("Configure Oauth2 Providers", () => { await reachTheProvider(); await $("ak-wizard-page-type-create").waitForDisplayed(); - await $('div[data-ouid-component-name="oauth2provider"]').scrollIntoView(); - await $('div[data-ouid-component-name="oauth2provider"]').click(); - await ProviderWizardView.nextButton.click(); + await setTypeCreate("selectProviderType", "OAuth2/OpenID Provider"); + await clickButton("Next"); + + // prettier-ignore + await fillOutFields([ + [setTextInput, "name", newProviderName], + [setFormGroup, "Flow settings", "open"], + [setSearchSelect, "authenticationFlow", "default-authentication-flow"], + [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], + [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], + ]); + await ProviderWizardView.pause(); - - return await $('ak-form-element-horizontal[name="name"]').$("input"); - await ProviderWizardView.oauth.setAuthorizationFlow( - "default-provider-authorization-explicit-consent", - ); await ProviderWizardView.nextButton.click(); - await ProviderWizardView.pause(); - - await ProvidersListPage.searchInput.setValue(newProviderName); - await ProvidersListPage.clickSearchButton(); - await ProvidersListPage.pause(); - - const newProvider = await ProvidersListPage.findProviderRow(); - await newProvider.waitForDisplayed(); - expect(newProvider).toExist(); - expect(await newProvider.getText()).toHaveText(newProviderName); }); });