web: ak-application-wizard-authentication-by-oauth, and many fixes!

1. Fixed `eventEmitter` so that if the detail object is a scalar, it will not attempt to "objectify"
   it.  This was causing a bug where retrofitting the eventEmitter to some older components resulted
   in a detail of "some" being translated into ['s', 'o', 'm', 'e'].  Not what is wanted.
2. Removed the "transitional form" from the existing components; they had a two-step where the web
   component class was just a wrapper around an independent rendering function.  While this worked,
   it was only to make the case that they *were* independent rendering objects and could be
   supported with the right web component framework.  We're halfway there now; the last step will be
   to transform the horizontal-element and various input CSS into componentized CSS, the way
   Patternfly-Elements is currently doing.
3. Fixed the `help` field so that it could take a string or a TemplateResult, and if the latter,
   don't bother wrapping it in the helper text functionality; just let it be its own thing.  This
   supports the multi-line help of redirectURI as well as the `ak-utils-time-delta` capability.
4. Transform Oauth2ProviderForm to use the new components, to the best of our ability.  Also used
   the `provider = this.wizard.provider` and `provider = this.instance` syntax to make the render
   function *completely portable*; it's the exact same text that is dropped into...
5. The complete `ak-application-wizard-authentication-by-oauth` component. They're so similar part
   of me wonders if I could push them both out to a common reference, or a collection of common
   references.  Both components use the PropertyMapping and Sources, and both use the same
   collection of searches (Crypto, Flow).
6. A Storybook for `ak-application-wizard-authentication-by-oauth`, showing the works working.
7. New mocks for `authorizationFlow`, `propertyMappings`, and `hasJWKs`.

This sequence has revealed a bug in the radio control.  (It's always the radio control.)  If the
default doesn't match the current setting, the radio control doesn't behave as expected; it won't
change when you fully expect that it should.  I'll investigate how to harmonize those tomorrow.
This commit is contained in:
Ken Sternberg 2023-08-02 15:36:20 -07:00
parent f4807a3081
commit 0bba3ae97f
12 changed files with 809 additions and 541 deletions

View file

@ -1,5 +1,4 @@
import "@goauthentik/admin/applications/ApplicationForm";
import "@goauthentik/admin/applications/wizard/ApplicationWizard";
import { PFSize } from "@goauthentik/app/elements/Spinner";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
@ -89,11 +88,14 @@ export class ApplicationListPage extends TablePage<Application> {
renderSidebarAfter(): TemplateResult {
// Rendering the wizard with .open here, as if we set the attribute in
// renderObjectCreate() it'll open two wizards, since that function gets called twice
return html`<ak-application-wizard
/* Re-enable the wizard later:
<ak-application-wizard
.open=${getURLParam("createWizard", false)}
.showButton=${false}
></ak-application-wizard>
<div class="pf-c-sidebar__panel pf-m-width-25">
></ak-application-wizard>*/
return html` <div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-markdown .md=${MDApplication}></ak-markdown>

View file

@ -0,0 +1,312 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import {
clientTypeOptions,
issuerModeOptions,
redirectUriHelp,
subjectModeOptions,
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
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/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,
PropertymappingsApi,
SourcesApi,
} from "@goauthentik/api";
import type {
OAuth2Provider,
PaginatedOAuthSourceList,
PaginatedScopeMappingList,
} from "@goauthentik/api";
import ApplicationWizardPageBase from "../ApplicationWizardPageBase";
@customElement("ak-application-wizard-authentication-by-oauth")
export class ApplicationWizardAuthenticationByOauth extends ApplicationWizardPageBase {
@state()
showClientSecret = false;
@state()
propertyMappings?: PaginatedScopeMappingList;
@state()
oauthSources?: PaginatedOAuthSourceList;
constructor() {
super();
new PropertymappingsApi(DEFAULT_CONFIG)
.propertymappingsScopeList({
ordering: "scope_name",
})
.then((propertyMappings: PaginatedScopeMappingList) => {
this.propertyMappings = propertyMappings;
});
new SourcesApi(DEFAULT_CONFIG)
.sourcesOauthList({
ordering: "name",
hasJwks: true,
})
.then((oauthSources: PaginatedOAuthSourceList) => {
this.oauthSources = oauthSources;
});
}
handleChange(ev: InputEvent) {
if (!ev.target) {
console.warn(`Received event with no target: ${ev}`);
return;
}
const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({
provider: {
...this.wizard.provider,
[target.name]: value,
},
});
}
render() {
const provider = this.wizard.provider as OAuth2Provider | undefined;
// prettier-ignore
return html`<form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input
name="name"
label=${msg("Name")}
value=${ifDefined(provider?.name)}
required
></ak-text-input>
<ak-form-element-horizontal
name="authenticationFlow"
label=${msg("Authentication flow")}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authenticationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when a user access this provider and is not authenticated.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")}
?required=${true}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${provider?.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when authorizing this provider.")}
</p>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-radio-input
name="clientType"
label=${msg("Client type")}
.value=${provider?.clientType}
required
@change=${(ev: CustomEvent<ClientTypeEnum>) => {
this.showClientSecret = ev.detail !== ClientTypeEnum.Public;
}}
.options=${clientTypeOptions}
>
</ak-radio-input>
<ak-text-input
name="clientId"
label=${msg("Client ID")}
value="${first(
provider?.clientId,
randomString(40, ascii_letters + digits)
)}"
required
>
</ak-text-input>
<ak-text-input
name="clientSecret"
label=${msg("Client Secret")}
value="${first(
provider?.clientSecret,
randomString(128, ascii_letters + digits)
)}"
?hidden=${!this.showClientSecret}
>
</ak-text-input>
<ak-textarea-input
name="redirectUris"
label=${msg("Redirect URIs/Origins (RegEx)")}
.value=${provider?.redirectUris}
.bighelp=${redirectUriHelp}
>
</ak-textarea-input>
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
<ak-crypto-certificate-search
certificate=${ifDefined(provider?.signingKey ?? nothing)}
name="certificate"
singleton
>
</ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">${msg("Key used to sign the tokens.")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Advanced protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-text-input
name="accessCodeValidity"
label=${msg("Access code validity")}
required
value="${first(provider?.accessCodeValidity, "minutes=1")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Configure how long access codes are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-text-input
name="accessTokenValidity"
label=${msg("Access Token validity")}
value="${first(provider?.accessTokenValidity, "minutes=5")}"
required
.bighelp=${html` <p class="pf-c-form__helper-text">
${msg("Configure how long access tokens are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-text-input
name="refreshTokenValidity"
label=${msg("Refresh Token validity")}
value="${first(provider?.refreshTokenValidity, "days=30")}"
?required=${true}
.bighelp=${html` <p class="pf-c-form__helper-text">
${msg("Configure how long refresh tokens are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-element-horizontal label=${msg("Scopes")} name="propertyMappings">
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((scope) => {
let selected = false;
if (!provider?.propertyMappings) {
selected =
scope.managed?.startsWith(
"goauthentik.io/providers/oauth2/scope-"
) || false;
} else {
selected = Array.from(provider?.propertyMappings).some(
(su) => {
return su == scope.pk;
}
);
}
return html`<option
value=${ifDefined(scope.pk)}
?selected=${selected}
>
${scope.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg(
"Select which scopes can be used by the client. The client still has to specify the scope to access the data."
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-radio-input
name="subMode"
label=${msg("Subject mode")}
required
.options=${subjectModeOptions}
.value=${provider?.subMode}
help=${msg(
"Configure what data should be used as unique User Identifier. For most cases, the default should be fine."
)}
>
</ak-radio-input>
<ak-switch-input name="includeClaimsInIdToken">
label=${msg("Include claims in id_token")}
?checked=${first(provider?.includeClaimsInIdToken, true)}
help=${msg(
"Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint."
)}></ak-switch-input
>
<ak-radio-input
name="issuerMode"
label=${msg("Issuer mode")}
required
.options=${issuerModeOptions}
.value=${provider?.issuerMode}
help=${msg(
"Configure how the issuer field of the ID Token should be filled."
)}
>
</ak-radio-input>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header">${msg("Machine-to-Machine authentication settings")}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Trusted OIDC Sources")}
name="jwksSources"
>
<select class="pf-c-form-control" multiple>
${this.oauthSources?.results.map((source) => {
const selected = (provider?.jwksSources || []).some((su) => {
return su == source.pk;
});
return html`<option value=${source.pk} ?selected=${selected}>
${source.name} (${source.slug})
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg(
"JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider."
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`;
}
}
export default ApplicationWizardAuthenticationByOauth;

View file

@ -7,11 +7,15 @@ import AkApplicationWizardApplicationDetails from "../ak-application-wizard-appl
import "../ak-application-wizard-authentication-method-choice";
import "../ak-application-wizard-context";
import "../ldap/ak-application-wizard-authentication-by-ldap";
import "../oauth/ak-application-wizard-authentication-by-oauth";
import "./ak-application-context-display-for-test";
import {
dummyAuthenticationFlowsSearch,
dummyAuthorizationFlowsSearch,
dummyCoreGroupsSearch,
dummyCryptoCertsSearch,
dummyHasJwks,
dummyPropertyMappings,
dummyProviderTypesList,
} from "./samples";
@ -50,6 +54,26 @@ const metadata: Meta<AkApplicationWizardApplicationDetails> = {
status: 200,
response: dummyAuthenticationFlowsSearch,
},
{
url: "/api/v3/flows/instances/?designation=authorization&ordering=slug",
method: "GET",
status: 200,
response: dummyAuthorizationFlowsSearch,
},
{
url: "/api/v3/propertymappings/scope/?ordering=scope_name",
method: "GET",
status: 200,
response: dummyPropertyMappings,
},
{
url: "/api/v3/sources/oauth/?has_jwks=true&ordering=name",
method: "GET",
status: 200,
response: dummyHasJwks,
},
],
},
};
@ -60,10 +84,9 @@ function injectTheme() {
if (!document.body.classList.contains(LIGHT)) {
document.body.classList.add(LIGHT);
}
})
});
}
export default metadata;
const container = (testItem: TemplateResult) => {
@ -79,7 +102,7 @@ const container = (testItem: TemplateResult) => {
</style>
${testItem}
</div>`;
}
};
export const PageOne = () => {
return container(
@ -110,3 +133,14 @@ export const PageThreeLdap = () => {
</ak-application-wizard-context>`,
);
};
export const PageThreeOauth2 = () => {
return container(
html`<ak-application-wizard-context>
<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>
<hr />
<ak-application-context-display-for-test></ak-application-context-display-for-test>
</ak-application-wizard-context>`,
);
};

View file

@ -82,6 +82,58 @@ export const dummyAuthenticationFlowsSearch = {
],
};
export const dummyAuthorizationFlowsSearch = {
pagination: {
next: 0,
previous: 0,
count: 2,
current: 1,
total_pages: 1,
start_index: 1,
end_index: 2,
},
results: [
{
pk: "9e01f011-8b3f-43d6-bedf-c29be5f3a428",
policybindingmodel_ptr_id: "14179ef8-2726-4027-9e2f-dc99185199bf",
name: "Authorize Application",
slug: "default-provider-authorization-explicit-consent",
title: "Redirecting to %(app)s",
designation: "authorization",
background: "/static/dist/assets/images/flow_background.jpg",
stages: ["ed5f015f-82b9-450f-addf-1e9d21d8dda3"],
policies: [],
cache_count: 0,
policy_engine_mode: "any",
compatibility_mode: false,
export_url:
"/api/v3/flows/instances/default-provider-authorization-explicit-consent/export/",
layout: "stacked",
denied_action: "message_continue",
authentication: "require_authenticated",
},
{
pk: "06f11ee3-cbe3-456d-81df-fae4c0a62951",
policybindingmodel_ptr_id: "686e6539-8b9f-473e-9f54-e05cc207dd2a",
name: "Authorize Application",
slug: "default-provider-authorization-implicit-consent",
title: "Redirecting to %(app)s",
designation: "authorization",
background: "/static/dist/assets/images/flow_background.jpg",
stages: [],
policies: [],
cache_count: 0,
policy_engine_mode: "any",
compatibility_mode: false,
export_url:
"/api/v3/flows/instances/default-provider-authorization-implicit-consent/export/",
layout: "stacked",
denied_action: "message_continue",
authentication: "require_authenticated",
},
],
};
export const dummyCoreGroupsSearch = {
pagination: {
next: 0,
@ -121,6 +173,84 @@ export const dummyCoreGroupsSearch = {
],
};
export const dummyPropertyMappings = {
pagination: {
next: 0,
previous: 0,
count: 4,
current: 1,
total_pages: 1,
start_index: 1,
end_index: 4,
},
results: [
{
pk: "30d87af7-9d9d-4292-873e-a52145ba4bcb",
managed: "goauthentik.io/providers/proxy/scope-proxy",
name: "authentik default OAuth Mapping: Proxy outpost",
expression:
'# This mapping is used by the authentik proxy. It passes extra user attributes,\n# which are used for example for the HTTP-Basic Authentication mapping.\nreturn {\n "ak_proxy": {\n "user_attributes": request.user.group_attributes(request),\n "is_superuser": request.user.is_superuser,\n }\n}',
component: "ak-property-mapping-scope-form",
verbose_name: "Scope Mapping",
verbose_name_plural: "Scope Mappings",
meta_model_name: "authentik_providers_oauth2.scopemapping",
scope_name: "ak_proxy",
description: "authentik Proxy - User information",
},
{
pk: "3e3751ed-a24c-4f47-a051-e2e05b5cd306",
managed: "goauthentik.io/providers/oauth2/scope-email",
name: "authentik default OAuth Mapping: OpenID 'email'",
expression: 'return {\n "email": request.user.email,\n "email_verified": True\n}',
component: "ak-property-mapping-scope-form",
verbose_name: "Scope Mapping",
verbose_name_plural: "Scope Mappings",
meta_model_name: "authentik_providers_oauth2.scopemapping",
scope_name: "email",
description: "Email address",
},
{
pk: "81c5e330-d8a0-45cd-9cad-e6a49a9c428f",
managed: "goauthentik.io/providers/oauth2/scope-openid",
name: "authentik default OAuth Mapping: OpenID 'openid'",
expression:
"# This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {}",
component: "ak-property-mapping-scope-form",
verbose_name: "Scope Mapping",
verbose_name_plural: "Scope Mappings",
meta_model_name: "authentik_providers_oauth2.scopemapping",
scope_name: "openid",
description: "",
},
{
pk: "7ad9cd6f-bcc8-425d-b7c2-c7c4592a1b36",
managed: "goauthentik.io/providers/oauth2/scope-profile",
name: "authentik default OAuth Mapping: OpenID 'profile'",
expression:
'return {\n # Because authentik only saves the user\'s full name, and has no concept of first and last names,\n # the full name is used as given name.\n # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`\n "name": request.user.name,\n "given_name": request.user.name,\n "preferred_username": request.user.username,\n "nickname": request.user.username,\n # groups is not part of the official userinfo schema, but is a quasi-standard\n "groups": [group.name for group in request.user.ak_groups.all()],\n}',
component: "ak-property-mapping-scope-form",
verbose_name: "Scope Mapping",
verbose_name_plural: "Scope Mappings",
meta_model_name: "authentik_providers_oauth2.scopemapping",
scope_name: "profile",
description: "General Profile Information",
},
],
};
export const dummyHasJwks = {
pagination: {
next: 0,
previous: 0,
count: 0,
current: 1,
total_pages: 1,
start_index: 0,
end_index: 0,
},
results: [],
};
// prettier-ignore
export const dummyProviderTypesList = [
["LDAP Provider", "ldapprovider",

View file

@ -1,6 +1,10 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
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/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -9,15 +13,12 @@ import "@goauthentik/elements/forms/SearchSelect";
import "@goauthentik/elements/utils/TimeDeltaHelp";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
CertificateKeyPair,
ClientTypeEnum,
CryptoApi,
CryptoCertificatekeypairsListRequest,
FlowsInstancesListDesignationEnum,
IssuerModeEnum,
OAuth2Provider,
@ -29,6 +30,91 @@ import {
SubModeEnum,
} from "@goauthentik/api";
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`<p class="pf-c-form__helper-text">${m}</p>`,
)}`;
/**
* Form page for OAuth2 Authentication Method
*
* @element ak-provider-oauth2-form
*
*/
@customElement("ak-provider-oauth2-form")
export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
propertyMappings?: PaginatedScopeMappingList;
@ -79,22 +165,23 @@ export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
}
renderForm(): TemplateResult {
const provider = this.instance;
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
<ak-text-input
name="name"
label=${msg("Name")}
value=${ifDefined(provider?.name)}
required
/>
</ak-form-element-horizontal>
></ak-text-input>
<ak-form-element-horizontal
label=${msg("Authentication flow")}
name="authenticationFlow"
label=${msg("Authentication flow")}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${this.instance?.authenticationFlow}
.currentFlow=${provider?.authenticationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
@ -102,13 +189,13 @@ export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")}
?required=${true}
name="authorizationFlow"
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${this.instance?.authorizationFlow}
.currentFlow=${provider?.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
@ -119,129 +206,52 @@ export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Client type")}
?required=${true}
<ak-radio-input
name="clientType"
>
<ak-radio
label=${msg("Client type")}
.value=${provider?.clientType}
required
@change=${(ev: CustomEvent<ClientTypeEnum>) => {
if (ev.detail === ClientTypeEnum.Public) {
this.showClientSecret = false;
} else {
this.showClientSecret = true;
}
this.showClientSecret = ev.detail !== ClientTypeEnum.Public;
}}
.options=${[
{
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. ",
)}`,
},
]}
.value=${this.instance?.clientType}
.options=${clientTypeOptions}
>
</ak-radio>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Client ID")}
?required=${true}
</ak-radio-input>
<ak-text-input
name="clientId"
>
<input
type="text"
label=${msg("Client ID")}
value="${first(
this.instance?.clientId,
provider?.clientId,
randomString(40, ascii_letters + digits),
)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
?hidden=${!this.showClientSecret}
label=${msg("Client Secret")}
name="clientSecret"
>
<input
type="text"
</ak-text-input>
<ak-text-input
name="clientSecret"
label=${msg("Client Secret")}
value="${first(
this.instance?.clientSecret,
provider?.clientSecret,
randomString(128, ascii_letters + digits),
)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Redirect URIs/Origins (RegEx)")}
?hidden=${!this.showClientSecret}
>
</ak-text-input>
<ak-textarea-input
name="redirectUris"
label=${msg("Redirect URIs/Origins (RegEx)")}
.value=${provider?.redirectUris}
.bighelp=${redirectUriHelp}
>
<textarea class="pf-c-form-control">
${this.instance?.redirectUris}</textarea
>
<p class="pf-c-form__helper-text">
${msg(
"Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg(
"If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg(
'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.',
)}
</p>
</ak-form-element-horizontal>
</ak-textarea-input>
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
<ak-search-select
.fetchObjects=${async (
query?: string,
): Promise<CertificateKeyPair[]> => {
const args: CryptoCertificatekeypairsListRequest = {
ordering: "name",
hasKey: true,
includeDetails: false,
};
if (query !== undefined) {
args.search = query;
}
const certificates = await new CryptoApi(
DEFAULT_CONFIG,
).cryptoCertificatekeypairsList(args);
return certificates.results;
}}
.renderElement=${(item: CertificateKeyPair): string => {
return item.name;
}}
.value=${(item: CertificateKeyPair | undefined): string | undefined => {
return item?.pk;
}}
.selected=${(
item: CertificateKeyPair,
items: CertificateKeyPair[],
): boolean => {
let selected = this.instance?.signingKey === item.pk;
if (!this.instance && items.length === 1) {
selected = true;
}
return selected;
}}
?blankable=${true}
<ak-crypto-certificate-search
certificate=${ifDefined(provider?.signingKey ?? nothing)}
name="certificate"
singleton
>
</ak-search-select>
</ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">${msg("Key used to sign the tokens.")}</p>
</ak-form-element-horizontal>
</div>
@ -250,69 +260,53 @@ ${this.instance?.redirectUris}</textarea
<ak-form-group>
<span slot="header"> ${msg("Advanced protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Access code validity")}
?required=${true}
<ak-text-input
name="accessCodeValidity"
>
<input
type="text"
value="${first(this.instance?.accessCodeValidity, "minutes=1")}"
class="pf-c-form-control"
label=${msg("Access code validity")}
required
/>
<p class="pf-c-form__helper-text">
value="${first(provider?.accessCodeValidity, "minutes=1")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Configure how long access codes are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Access Token validity")}
?required=${true}
name="accessTokenValidity"
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
<input
type="text"
value="${first(this.instance?.accessTokenValidity, "minutes=5")}"
class="pf-c-form-control"
</ak-text-input>
<ak-text-input
name="accessTokenValidity"
label=${msg("Access Token validity")}
value="${first(provider?.accessTokenValidity, "minutes=5")}"
required
/>
<p class="pf-c-form__helper-text">
.bighelp=${html` <p class="pf-c-form__helper-text">
${msg("Configure how long access tokens are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Refresh Token validity")}
?required=${true}
name="refreshTokenValidity"
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
<input
type="text"
value="${first(this.instance?.refreshTokenValidity, "days=30")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
</ak-text-input>
<ak-text-input
name="refreshTokenValidity"
label=${msg("Refresh Token validity")}
value="${first(provider?.refreshTokenValidity, "days=30")}"
?required=${true}
.bighelp=${html` <p class="pf-c-form__helper-text">
${msg("Configure how long refresh tokens are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-element-horizontal label=${msg("Scopes")} name="propertyMappings">
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((scope) => {
let selected = false;
if (!this.instance?.propertyMappings) {
if (!provider?.propertyMappings) {
selected =
scope.managed?.startsWith(
"goauthentik.io/providers/oauth2/scope-",
) || false;
} else {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
selected = Array.from(provider?.propertyMappings).some((su) => {
return su == scope.pk;
},
);
});
}
return html`<option
value=${ifDefined(scope.pk)}
@ -331,104 +325,35 @@ ${this.instance?.redirectUris}</textarea
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Subject mode")}
?required=${true}
<ak-radio-input
name="subMode"
>
<ak-radio
.options=${[
{
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.",
)}`,
},
]}
.value=${this.instance?.subMode}
>
</ak-radio>
<p class="pf-c-form__helper-text">
${msg(
label=${msg("Subject mode")}
required
.options=${subjectModeOptions}
.value=${provider?.subMode}
help=${msg(
"Configure what data should be used as unique User Identifier. For most cases, the default should be fine.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="includeClaimsInIdToken">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.includeClaimsInIdToken, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Include claims in id_token")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg(
</ak-radio-input>
<ak-switch-input name="includeClaimsInIdToken">
label=${msg("Include claims in id_token")}
?checked=${first(provider?.includeClaimsInIdToken, true)}
help=${msg(
"Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Issuer mode")}
?required=${true}
)}></ak-switch-input
>
<ak-radio-input
name="issuerMode"
>
<ak-radio
.options=${[
{
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,
},
]}
.value=${this.instance?.issuerMode}
>
</ak-radio>
<p class="pf-c-form__helper-text">
${msg(
label=${msg("Issuer mode")}
required
.options=${issuerModeOptions}
.value=${provider?.issuerMode}
help=${msg(
"Configure how the issuer field of the ID Token should be filled.",
)}
</p>
</ak-form-element-horizontal>
>
</ak-radio-input>
</div>
</ak-form-group>
@ -441,7 +366,7 @@ ${this.instance?.redirectUris}</textarea
>
<select class="pf-c-form-control" multiple>
${this.oauthSources?.results.map((source) => {
const selected = (this.instance?.jwksSources || []).some((su) => {
const selected = (provider?.jwksSources || []).some((su) => {
return su == source.pk;
});
return html`<option value=${source.pk} ?selected=${selected}>

View file

@ -4,43 +4,6 @@ import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
type AkFileArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value?: any;
required: boolean;
// The message to show next to the "current icon".
current: string;
// The help message, shown at the bottom.
help?: string;
};
const akFileDefaults = {
name: "",
required: false,
current: msg("Currently set to:"),
};
export function akFile(args: AkFileArgs) {
const { name, label, required, value, help, current } = {
...akFileDefaults,
...args,
};
const currentMsg =
value && current
? html` <p class="pf-c-form__helper-text">${current} ${value}</p> `
: nothing;
return html`<ak-form-element-horizontal ?required="${required}" label=${label} name=${name}>
<input type="file" value="" class="pf-c-form-control" />
${currentMsg} ${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
@customElement("ak-file-input")
export class AkFileInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
@ -60,6 +23,11 @@ export class AkFileInput extends AKElement {
@property({ type: String })
label = "";
/*
* The message to show next to the "current icon".
*
* @attr
*/
@property({ type: String })
current = msg("Currently set to:");
@ -73,13 +41,19 @@ export class AkFileInput extends AKElement {
help = "";
render() {
return akFile({
name: this.name,
label: this.label,
value: this.value,
current: this.current,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
const currentMsg =
this.value && this.current
? html` <p class="pf-c-form__helper-text">${this.current} ${this.value}</p> `
: nothing;
return html`<ak-form-element-horizontal
?required="${this.required}"
label=${this.label}
name=${this.name}
>
<input type="file" value="" class="pf-c-form-control" />
${currentMsg}
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
}

View file

@ -4,38 +4,6 @@ import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
type AkNumberArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: number;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akNumberDefaults = {
required: false,
};
export function akNumber(args: AkNumberArgs) {
const { name, label, value, required, help } = {
...akNumberDefaults,
...args,
};
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<input
type="number"
value=${ifDefined(value)}
class="pf-c-form-control"
?required=${required}
/>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-number-input")
export class AkNumberInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
@ -55,7 +23,7 @@ export class AkNumberInput extends AKElement {
@property({ type: String })
label = "";
@property({ type: Number })
@property({ type: Number, reflect: true })
value = 0;
@property({ type: Boolean })
@ -65,12 +33,18 @@ export class AkNumberInput extends AKElement {
help = "";
render() {
return akNumber({
name: this.name,
label: this.label,
value: this.value,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
name=${this.name}
>
<input
type="number"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
}

View file

@ -4,35 +4,6 @@ import { RadioOption } from "@goauthentik/elements/forms/Radio";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
type AkRadioArgs<T> = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: T;
required?: boolean;
options: RadioOption<T>[];
// The help message, shown at the bottom.
help?: string;
};
const akRadioDefaults = {
required: false,
options: [],
};
export function akRadioInput<T>(args: AkRadioArgs<T>) {
const { name, label, help, required, options, value } = {
...akRadioDefaults,
...args,
};
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<ak-radio .options=${options} .value=${value}></ak-radio>
${help ? html`<p class="pf-c-form__helper-radio">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-radio-input")
export class AkRadioInput<T> extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
@ -65,14 +36,16 @@ export class AkRadioInput<T> extends AKElement {
options: RadioOption<T>[] = [];
render() {
return akRadioInput({
name: this.name,
label: this.label,
value: this.value,
options: this.options,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
name=${this.name}
>
<ak-radio .options=${this.options} .value=${this.value}></ak-radio>
${this.help.trim()
? html`<p class="pf-c-form__helper-radio">${this.help}</p>`
: nothing}
</ak-form-element-horizontal> `;
}
}

View file

@ -3,44 +3,6 @@ import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
type AkSwitchArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
checked: boolean;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akSwitchDefaults = {
checked: false,
required: false,
};
export function akSwitch(args: AkSwitchArgs) {
const { name, label, checked, required, help } = {
...akSwitchDefaults,
...args,
};
const doCheck = checked ? checked : undefined;
return html` <ak-form-element-horizontal name=${name} ?required=${required}>
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" ?checked=${doCheck} />
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${label}</span>
</label>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
@customElement("ak-switch-input")
export class AkSwitchInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
@ -73,12 +35,21 @@ export class AkSwitchInput extends AKElement {
checkbox!: HTMLInputElement;
render() {
return akSwitch({
name: this.name,
label: this.label,
checked: this.checked,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
const doCheck = this.checked ? this.checked : undefined;
return html` <ak-form-element-horizontal name=${this.name} ?required=${this.required}>
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" ?checked=${doCheck} />
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${this.label}</span>
</label>
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
}
export default AkSwitchInput;

View file

@ -1,41 +1,9 @@
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
type AkTextArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: string;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akTextDefaults = {
required: false,
};
export function akText(args: AkTextArgs) {
const { name, label, value, required, help } = {
...akTextDefaults,
...args,
};
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<input
type="text"
value=${ifDefined(value)}
class="pf-c-form-control"
?required=${required}
/>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-text-input")
export class AkTextInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
@ -55,7 +23,7 @@ export class AkTextInput extends AKElement {
@property({ type: String })
label = "";
@property({ type: String })
@property({ type: String, reflect: true })
value = "";
@property({ type: Boolean })
@ -64,13 +32,33 @@ export class AkTextInput extends AKElement {
@property({ type: String })
help = "";
@property({ type: Boolean })
hidden = false;
@property({ type: Object })
bighelp!: TemplateResult | TemplateResult[];
renderHelp() {
return [
this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing,
this.bighelp ? this.bighelp : nothing,
];
}
render() {
return akText({
name: this.name,
label: this.label,
value: this.value,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
?hidden=${this.hidden}
name=${this.name}
>
<input
type="text"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}

View file

@ -1,40 +1,8 @@
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
type AkTextareaArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: string;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akTextareaDefaults = {
required: false,
};
export function akTextarea(args: AkTextareaArgs) {
const { name, label, value, required, help } = {
...akTextareaDefaults,
...args,
};
// `<textarea>` is highly sensitive to whitespace. When editing this control, take care that the
// provided value has no whitespace between the `textarea` open and close tags.
//
// prettier-ignore
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<textarea class="pf-c-form-control" ?required=${required}
name=${name}>${value !== undefined ? value : ""}</textarea>
${help ? html`<p class="pf-c-form__helper-textarea">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-textarea-input")
export class AkTextareaInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
@ -63,13 +31,26 @@ export class AkTextareaInput extends AKElement {
@property({ type: String })
help = "";
@property({ type: Object })
bighelp!: TemplateResult | TemplateResult[];
renderHelp() {
return [
this.help ? html`<p class="pf-c-form__helper-textarea">${this.help}</p>` : nothing,
this.bighelp ? this.bighelp : nothing,
];
}
render() {
return akTextarea({
name: this.name,
label: this.label,
value: this.value,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
name=${this.name}
>
<textarea class="pf-c-form-control" ?required=${this.required} name=${this.name}>
${this.value !== undefined ? this.value : ""}</textarea
>
${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}

View file

@ -11,15 +11,19 @@ export function CustomEmitterElement<T extends Constructor<LitElement>>(supercla
return class EmmiterElementHandler extends superclass {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) {
const fullDetail =
typeof detail === "object"
? {
target: this,
...detail,
}
: detail;
this.dispatchEvent(
new CustomEvent(eventName, {
composed: true,
bubbles: true,
...options,
detail: {
target: this,
...detail,
},
detail: fullDetail,
}),
);
}