import { t } from "@lingui/macro"; import { CSSResult, LitElement, TemplateResult, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { until } from "lit/directives/until.js"; import AKGlobal from "../authentik.css"; import PFBackgroundImage from "@patternfly/patternfly/components/BackgroundImage/background-image.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; import PFList from "@patternfly/patternfly/components/List/list.css"; import PFLogin from "@patternfly/patternfly/components/Login/login.css"; import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { ChallengeChoices, ChallengeTypes, CurrentTenant, FlowChallengeResponseRequest, FlowsApi, RedirectChallenge, ShellChallenge, } from "@goauthentik/api"; import { DEFAULT_CONFIG, tenant } from "../api/Config"; import { configureSentry } from "../api/Sentry"; import { WebsocketClient } from "../common/ws"; import { EVENT_FLOW_ADVANCE, TITLE_DEFAULT } from "../constants"; import "../elements/LoadingOverlay"; import { first } from "../utils"; import "./stages/RedirectStage"; import "./stages/access_denied/AccessDeniedStage"; import "./stages/autosubmit/AutosubmitStage"; import { StageHost } from "./stages/base"; import "./stages/captcha/CaptchaStage"; import "./stages/identification/IdentificationStage"; import "./stages/password/PasswordStage"; @customElement("ak-flow-executor") export class FlowExecutor extends LitElement implements StageHost { flowSlug?: string; private _challenge?: ChallengeTypes; @property({ attribute: false }) set challenge(value: ChallengeTypes | undefined) { this._challenge = value; // Assign the location as soon as we get the challenge and *not* in the render function // as the render function might be called multiple times, which will navigate multiple // times and can invalidate oauth codes // Also only auto-redirect when the inspector is open, so that a user can inspect the // redirect in the inspector if (value?.type === ChallengeChoices.Redirect && !this.inspectorOpen) { console.debug( "authentik/flows: redirecting to url from server", (value as RedirectChallenge).to, ); window.location.assign((value as RedirectChallenge).to); } tenant().then((tenant) => { if (value?.flowInfo?.title) { document.title = `${value.flowInfo?.title} - ${tenant.brandingTitle}`; } else { document.title = tenant.brandingTitle || TITLE_DEFAULT; } }); this.requestUpdate(); } get challenge(): ChallengeTypes | undefined { return this._challenge; } @property({ type: Boolean }) loading = false; @property({ attribute: false }) tenant!: CurrentTenant; @property({ attribute: false }) inspectorOpen: boolean; ws: WebsocketClient; static get styles(): CSSResult[] { return [PFBase, PFLogin, PFDrawer, PFButton, PFTitle, PFList, PFBackgroundImage, AKGlobal] .concat(css` .ak-hidden { display: none; } :host { position: relative; } .ak-exception { font-family: monospace; overflow-x: scroll; } .pf-c-drawer__content { background-color: transparent; } `); } constructor() { super(); this.ws = new WebsocketClient(); this.flowSlug = window.location.pathname.split("/")[3]; this.inspectorOpen = window.location.search.includes("inspector"); tenant().then((tenant) => (this.tenant = tenant)); } setBackground(url: string): void { this.shadowRoot ?.querySelectorAll(".pf-c-background-image") .forEach((bg) => { bg.style.setProperty("--ak-flow-background", `url('${url}')`); }); } submit(payload?: FlowChallengeResponseRequest): Promise { if (!payload) return Promise.reject(); if (!this.challenge) return Promise.reject(); // @ts-ignore payload.component = this.challenge.component; this.loading = true; return new FlowsApi(DEFAULT_CONFIG) .flowsExecutorSolve({ flowSlug: this.flowSlug || "", query: window.location.search.substring(1), flowChallengeResponseRequest: payload, }) .then((data) => { if (this.inspectorOpen) { window.dispatchEvent( new CustomEvent(EVENT_FLOW_ADVANCE, { bubbles: true, composed: true, }), ); } this.challenge = data; if (this.challenge.responseErrors) { return false; } return true; }) .catch((e: Error | Response) => { this.errorMessage(e); return false; }) .finally(() => { this.loading = false; return false; }); } firstUpdated(): void { configureSentry(); this.loading = true; new FlowsApi(DEFAULT_CONFIG) .flowsExecutorGet({ flowSlug: this.flowSlug || "", query: window.location.search.substring(1), }) .then((challenge) => { if (this.inspectorOpen) { window.dispatchEvent( new CustomEvent(EVENT_FLOW_ADVANCE, { bubbles: true, composed: true, }), ); } this.challenge = challenge; // Only set background on first update, flow won't change throughout execution if (this.challenge?.flowInfo?.background) { this.setBackground(this.challenge.flowInfo.background); } }) .catch((e: Error | Response) => { // Catch JSON or Update errors this.errorMessage(e); }) .finally(() => { this.loading = false; }); } async errorMessage(error: Error | Response): Promise { let body = ""; if (error instanceof Error) { body = error.message; } this.challenge = { type: ChallengeChoices.Shell, body: ` `, } as ChallengeTypes; } async renderChallengeNativeElement(): Promise { switch (this.challenge?.component) { case "ak-stage-access-denied": // Statically imported for performance reasons return html``; case "ak-stage-identification": // Statically imported for performance reasons return html``; case "ak-stage-password": // Statically imported for performance reasons return html``; case "ak-stage-captcha": // Statically imported to prevent browsers blocking urls return html``; case "ak-stage-consent": await import("./stages/consent/ConsentStage"); return html``; case "ak-stage-dummy": await import("./stages/dummy/DummyStage"); return html``; case "ak-stage-email": await import("./stages/email/EmailStage"); return html``; case "ak-stage-autosubmit": // Statically imported for performance reasons return html``; case "ak-stage-prompt": await import("./stages/prompt/PromptStage"); return html``; case "ak-stage-authenticator-totp": await import("./stages/authenticator_totp/AuthenticatorTOTPStage"); return html``; case "ak-stage-authenticator-duo": await import("./stages/authenticator_duo/AuthenticatorDuoStage"); return html``; case "ak-stage-authenticator-static": await import("./stages/authenticator_static/AuthenticatorStaticStage"); return html``; case "ak-stage-authenticator-webauthn": await import("./stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"); return html``; case "ak-stage-authenticator-sms": await import("./stages/authenticator_sms/AuthenticatorSMSStage"); return html``; case "ak-stage-authenticator-validate": await import("./stages/authenticator_validate/AuthenticatorValidateStage"); return html``; case "ak-flow-sources-plex": await import("./sources/plex/PlexLoginInit"); return html``; case "ak-flow-sources-oauth-apple": await import("./sources/apple/AppleLoginInit"); return html``; default: break; } return html`Invalid native challenge element`; } async renderChallenge(): Promise { if (!this.challenge) { return html``; } switch (this.challenge.type) { case ChallengeChoices.Redirect: if (this.inspectorOpen) { return html` `; } return html` `; case ChallengeChoices.Shell: return html`${unsafeHTML((this.challenge as ShellChallenge).body)}`; case ChallengeChoices.Native: return await this.renderChallengeNativeElement(); default: console.debug(`authentik/flows: unexpected data type ${this.challenge.type}`); break; } return html``; } renderChallengeWrapper(): TemplateResult { if (!this.challenge) { return html` `; } return html` ${this.loading ? html`` : html``} ${until(this.renderChallenge())} `; } async renderInspector(): Promise { if (!this.inspectorOpen) { return html``; } await import("./FlowInspector"); return html``; } render(): TemplateResult { return html`
${until(this.renderInspector())}
`; } }