Merge branch 'main' into web/theme-controller-2

* main:
  sources/oauth: fix oidc well-known parsing (#7248)
  web/admin: improve user email button labels (#7233)
This commit is contained in:
Ken Sternberg 2023-10-20 14:12:57 -07:00
commit 8713a1d120
5 changed files with 123 additions and 118 deletions

View File

@ -71,15 +71,12 @@ class OAuthSourceSerializer(SourceSerializer):
text = exc.response.text if exc.response else str(exc) text = exc.response.text if exc.response else str(exc)
raise ValidationError({"oidc_well_known_url": text}) raise ValidationError({"oidc_well_known_url": text})
config = well_known_config.json() config = well_known_config.json()
try: if "issuer" not in config:
attrs["authorization_url"] = config["authorization_endpoint"] raise ValidationError({"oidc_well_known_url": "Invalid well-known configuration"})
attrs["access_token_url"] = config["token_endpoint"] attrs["authorization_url"] = config.get("authorization_endpoint", "")
attrs["profile_url"] = config["userinfo_endpoint"] attrs["access_token_url"] = config.get("token_endpoint", "")
inferred_oidc_jwks_url = config["jwks_uri"] attrs["profile_url"] = config.get("userinfo_endpoint", "")
except (IndexError, KeyError) as exc: inferred_oidc_jwks_url = config.get("jwks_uri", "")
raise ValidationError(
{"oidc_well_known_url": f"Invalid well-known configuration: {exc}"}
)
# Prefer user-entered URL to inferred URL to default URL # Prefer user-entered URL to inferred URL to default URL
jwks_url = attrs.get("oidc_jwks_url") or inferred_oidc_jwks_url or source_type.oidc_jwks_url jwks_url = attrs.get("oidc_jwks_url") or inferred_oidc_jwks_url or source_type.oidc_jwks_url

View File

@ -38,7 +38,7 @@ def update_well_known_jwks(self: MonitoredTask):
for source_attr, config_key in source_attr_key: for source_attr, config_key in source_attr_key:
# Check if we're actually changing anything to only # Check if we're actually changing anything to only
# save when something has changed # save when something has changed
if getattr(source, source_attr) != config[config_key]: if getattr(source, source_attr, "") != config[config_key]:
dirty = True dirty = True
setattr(source, source_attr, config[config_key]) setattr(source, source_attr, config[config_key])
except (IndexError, KeyError) as exc: except (IndexError, KeyError) as exc:

View File

@ -50,6 +50,7 @@ class TestOAuthSource(TestCase):
def test_api_validate_openid_connect(self): def test_api_validate_openid_connect(self):
"""Test API validation (with OIDC endpoints)""" """Test API validation (with OIDC endpoints)"""
openid_config = { openid_config = {
"issuer": "foo",
"authorization_endpoint": "http://mock/oauth/authorize", "authorization_endpoint": "http://mock/oauth/authorize",
"token_endpoint": "http://mock/oauth/token", "token_endpoint": "http://mock/oauth/token",
"userinfo_endpoint": "http://mock/oauth/userinfo", "userinfo_endpoint": "http://mock/oauth/userinfo",

View File

@ -21,10 +21,11 @@ import { getURLParam, updateURLParams } from "@goauthentik/elements/router/Route
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
@ -40,6 +41,56 @@ import {
UserPath, UserPath,
} from "@goauthentik/api"; } from "@goauthentik/api";
export const requestRecoveryLink = (user: User) =>
new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryRetrieve({
id: user.pk,
})
.then((rec) =>
writeToClipboard(rec.link).then((wroteToClipboard) =>
showMessage({
level: MessageLevel.success,
message: rec.link,
description: wroteToClipboard
? msg("A copy of this recovery link has been placed in your clipboard")
: "",
}),
),
)
.catch((ex: ResponseError) =>
ex.response.json().then(() =>
showMessage({
level: MessageLevel.error,
message: msg(
"The current tenant must have a recovery flow configured to use a recovery link",
),
}),
),
);
export const renderRecoveryEmailRequest = (user: User) =>
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">
<span slot="submit"> ${msg("Send link")} </span>
<span slot="header"> ${msg("Send recovery link to user")} </span>
<ak-user-reset-email-form slot="form" .user=${user}> </ak-user-reset-email-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Email recovery link")}
</button>
</ak-forms-modal>`;
const recoveryButtonStyles = css`
#recovery-request-buttons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
}
#recovery-request-buttons > *,
#update-password-request .pf-c-button {
margin: 0;
}
`;
@customElement("ak-user-list") @customElement("ak-user-list")
export class UserListPage extends TablePage<User> { export class UserListPage extends TablePage<User> {
expandable = true; expandable = true;
@ -74,7 +125,7 @@ export class UserListPage extends TablePage<User> {
me?: SessionUser; me?: SessionUser;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return super.styles.concat(PFDescriptionList, PFCard, PFAlert); return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
} }
constructor() { constructor() {
@ -287,8 +338,14 @@ export class UserListPage extends TablePage<User> {
<span class="pf-c-description-list__text">${msg("Recovery")}</span> <span class="pf-c-description-list__text">${msg("Recovery")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div
<ak-forms-modal size=${PFSize.Medium}> class="pf-c-description-list__text"
id="recovery-request-buttons"
>
<ak-forms-modal
size=${PFSize.Medium}
id="update-password-request"
>
<span slot="submit">${msg("Update password")}</span> <span slot="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span> <span slot="header">${msg("Update password")}</span>
<ak-user-password-form <ak-user-password-form
@ -303,56 +360,12 @@ export class UserListPage extends TablePage<User> {
? html` ? html`
<ak-action-button <ak-action-button
class="pf-m-secondary" class="pf-m-secondary"
.apiRequest=${() => { .apiRequest=${() => requestRecoveryLink(item)}
return new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryRetrieve({
id: item.pk,
})
.then((rec) => {
showMessage({
level: MessageLevel.success,
message: msg(
"Successfully generated recovery link",
),
description: rec.link,
});
})
.catch((ex: ResponseError) => {
ex.response.json().then(() => {
showMessage({
level: MessageLevel.error,
message: msg(
"No recovery flow is configured.",
),
});
});
});
}}
> >
${msg("Copy recovery link")} ${msg("Create recovery link")}
</ak-action-button> </ak-action-button>
${item.email ${item.email
? html`<ak-forms-modal ? renderRecoveryEmailRequest(item)
.closeAfterSuccessfulSubmit=${false}
>
<span slot="submit">
${msg("Send link")}
</span>
<span slot="header">
${msg("Send recovery link to user")}
</span>
<ak-user-reset-email-form
slot="form"
.user=${item}
>
</ak-user-reset-email-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Email recovery link")}
</button>
</ak-forms-modal>`
: html`<span : html`<span
>${msg( >${msg(
"Recovery link cannot be emailed, user has no email address saved.", "Recovery link cannot be emailed, user has no email address saved.",

View File

@ -5,11 +5,14 @@ import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserPasswordForm"; import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/app/admin/users/UserAssignedGlobalPermissionsTable"; import "@goauthentik/app/admin/users/UserAssignedGlobalPermissionsTable";
import "@goauthentik/app/admin/users/UserAssignedObjectPermissionsTable"; import "@goauthentik/app/admin/users/UserAssignedObjectPermissionsTable";
import {
renderRecoveryEmailRequest,
requestRecoveryLink,
} from "@goauthentik/app/admin/users/UserListPage";
import { me } from "@goauthentik/app/common/users"; import { me } from "@goauthentik/app/common/users";
import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import "@goauthentik/app/elements/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import "@goauthentik/components/events/UserEvents"; import "@goauthentik/components/events/UserEvents";
import { AKElement, rootInterface } from "@goauthentik/elements/Base"; import { AKElement, rootInterface } from "@goauthentik/elements/Base";
@ -21,13 +24,12 @@ import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/oauth/UserRefreshList"; import "@goauthentik/elements/oauth/UserRefreshList";
import "@goauthentik/elements/user/SessionList"; import "@goauthentik/elements/user/SessionList";
import "@goauthentik/elements/user/UserConsentList"; import "@goauthentik/elements/user/UserConsentList";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -72,7 +74,7 @@ export class UserViewPage extends AKElement {
@state() @state()
me?: SessionUser; me?: SessionUser;
static get styles(): CSSResult[] { static get styles() {
return [ return [
PFBase, PFBase,
PFPage, PFPage,
@ -84,12 +86,24 @@ export class UserViewPage extends AKElement {
PFDescriptionList, PFDescriptionList,
PFSizing, PFSizing,
css` css`
.pf-c-description-list__description ak-action-button {
margin-right: 6px;
margin-bottom: 6px;
}
.ak-button-collection { .ak-button-collection {
max-width: 12em; display: flex;
flex-direction: column;
gap: 0.375rem;
max-width: 12rem;
}
.ak-button-collection > * {
flex: 1 0 100%;
}
#reset-password-button {
margin-right: 0;
}
#ak-email-recovery-request,
#update-password-request .pf-c-button,
#ak-email-recovery-request .pf-c-button {
margin: 0;
width: 100%;
} }
`, `,
]; ];
@ -103,7 +117,7 @@ export class UserViewPage extends AKElement {
}); });
} }
render(): TemplateResult { render() {
return html`<ak-page-header return html`<ak-page-header
icon="pf-icon pf-icon-user" icon="pf-icon pf-icon-user"
header=${msg(str`User ${this.user?.username || ""}`)} header=${msg(str`User ${this.user?.username || ""}`)}
@ -113,13 +127,17 @@ export class UserViewPage extends AKElement {
${this.renderBody()}`; ${this.renderBody()}`;
} }
renderUserCard(): TemplateResult { renderUserCard() {
if (!this.user) { if (!this.user) {
return html``; return nothing;
} }
const user = this.user;
const canImpersonate = const canImpersonate =
rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) &&
this.user.pk !== this.me?.user.pk; this.user.pk !== this.me?.user.pk;
return html` return html`
<div class="pf-c-card__title">${msg("User Info")}</div> <div class="pf-c-card__title">${msg("User Info")}</div>
<div class="pf-c-card__body"> <div class="pf-c-card__body">
@ -129,7 +147,7 @@ export class UserViewPage extends AKElement {
<span class="pf-c-description-list__text">${msg("Username")}</span> <span class="pf-c-description-list__text">${msg("Username")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${this.user.username}</div> <div class="pf-c-description-list__text">${user.username}</div>
</dd> </dd>
</div> </div>
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
@ -137,7 +155,7 @@ export class UserViewPage extends AKElement {
<span class="pf-c-description-list__text">${msg("Name")}</span> <span class="pf-c-description-list__text">${msg("Name")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${this.user.name}</div> <div class="pf-c-description-list__text">${user.name}</div>
</dd> </dd>
</div> </div>
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
@ -145,7 +163,7 @@ export class UserViewPage extends AKElement {
<span class="pf-c-description-list__text">${msg("Email")}</span> <span class="pf-c-description-list__text">${msg("Email")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${this.user.email || "-"}</div> <div class="pf-c-description-list__text">${user.email || "-"}</div>
</dd> </dd>
</div> </div>
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
@ -154,7 +172,7 @@ export class UserViewPage extends AKElement {
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
${this.user.lastLogin?.toLocaleString()} ${user.lastLogin?.toLocaleString()}
</div> </div>
</dd> </dd>
</div> </div>
@ -165,7 +183,7 @@ export class UserViewPage extends AKElement {
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
<ak-label <ak-label
color=${this.user.isActive ? PFColor.Green : PFColor.Orange} color=${user.isActive ? PFColor.Green : PFColor.Orange}
></ak-label> ></ak-label>
</div> </div>
</dd> </dd>
@ -177,7 +195,7 @@ export class UserViewPage extends AKElement {
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
<ak-label <ak-label
color=${this.user.isSuperuser ? PFColor.Green : PFColor.Orange} color=${user.isSuperuser ? PFColor.Green : PFColor.Orange}
></ak-label> ></ak-label>
</div> </div>
</dd> </dd>
@ -191,7 +209,7 @@ export class UserViewPage extends AKElement {
<ak-forms-modal> <ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span> <span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update User")} </span> <span slot="header"> ${msg("Update User")} </span>
<ak-user-form slot="form" .instancePk=${this.user.pk}> <ak-user-form slot="form" .instancePk=${user.pk}>
</ak-user-form> </ak-user-form>
<button <button
slot="trigger" slot="trigger"
@ -201,13 +219,13 @@ export class UserViewPage extends AKElement {
</button> </button>
</ak-forms-modal> </ak-forms-modal>
<ak-user-active-form <ak-user-active-form
.obj=${this.user} .obj=${user}
objectLabel=${msg("User")} objectLabel=${msg("User")}
.delete=${() => { .delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: this.user?.pk || 0, id: user.pk,
patchedUserRequest: { patchedUserRequest: {
isActive: !this.user?.isActive, isActive: !user.isActive,
}, },
}); });
}} }}
@ -218,15 +236,13 @@ export class UserViewPage extends AKElement {
> >
<pf-tooltip <pf-tooltip
position="top" position="top"
content=${this.user.isActive content=${user.isActive
? msg("Lock the user out of this system") ? msg("Lock the user out of this system")
: msg( : msg(
"Allow the user to log in and use this system", "Allow the user to log in and use this system",
)} )}
> >
${this.user.isActive ${user.isActive ? msg("Deactivate") : msg("Activate")}
? msg("Deactivate")
: msg("Activate")}
</pf-tooltip> </pf-tooltip>
</button> </button>
</ak-user-active-form> </ak-user-active-form>
@ -238,7 +254,7 @@ export class UserViewPage extends AKElement {
.apiRequest=${() => { .apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG) return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({ .coreUsersImpersonateCreate({
id: this.user?.pk || 0, id: user.pk,
}) })
.then(() => { .then(() => {
window.location.href = "/"; window.location.href = "/";
@ -255,7 +271,7 @@ export class UserViewPage extends AKElement {
</pf-tooltip> </pf-tooltip>
</ak-action-button> </ak-action-button>
` `
: html``} : nothing}
</div> </div>
</dd> </dd>
</div> </div>
@ -265,12 +281,12 @@ export class UserViewPage extends AKElement {
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text ak-button-collection"> <div class="pf-c-description-list__text ak-button-collection">
<ak-forms-modal size=${PFSize.Medium}> <ak-forms-modal size=${PFSize.Medium} id="update-password-request">
<span slot="submit">${msg("Update password")}</span> <span slot="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span> <span slot="header">${msg("Update password")}</span>
<ak-user-password-form <ak-user-password-form
slot="form" slot="form"
.instancePk=${this.user?.pk} .instancePk=${user.pk}
></ak-user-password-form> ></ak-user-password-form>
<button <button
slot="trigger" slot="trigger"
@ -287,30 +303,7 @@ export class UserViewPage extends AKElement {
<ak-action-button <ak-action-button
id="reset-password-button" id="reset-password-button"
class="pf-m-secondary pf-m-block" class="pf-m-secondary pf-m-block"
.apiRequest=${() => { .apiRequest=${() => requestRecoveryLink(user)}
return new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryRetrieve({
id: this.user?.pk || 0,
})
.then((rec) => {
showMessage({
level: MessageLevel.success,
message: msg(
"Successfully generated recovery link",
),
description: rec.link,
});
})
.catch(() => {
showMessage({
level: MessageLevel.error,
message: msg(
"To create a recovery link, the current tenant needs to have a recovery flow configured.",
),
description: "",
});
});
}}
> >
<pf-tooltip <pf-tooltip
position="top" position="top"
@ -318,9 +311,10 @@ export class UserViewPage extends AKElement {
"Create a link for this user to reset their password", "Create a link for this user to reset their password",
)} )}
> >
${msg("Reset Password")} ${msg("Create Recovery Link")}
</pf-tooltip> </pf-tooltip>
</ak-action-button> </ak-action-button>
${user.email ? renderRecoveryEmailRequest(user) : nothing}
</div> </div>
</dd> </dd>
</div> </div>
@ -329,9 +323,9 @@ export class UserViewPage extends AKElement {
`; `;
} }
renderBody(): TemplateResult { renderBody() {
if (!this.user) { if (!this.user) {
return html``; return nothing;
} }
return html`<ak-tabs> return html`<ak-tabs>
<section <section