import { DEFAULT_CONFIG, tenant } from "@goauthentik/common/api/config"; import { EVENT_FLOW_ADVANCE, EVENT_FLOW_INSPECTOR_TOGGLE, TITLE_DEFAULT, } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { configureSentry } from "@goauthentik/common/sentry"; import { first } from "@goauthentik/common/utils"; import { WebsocketClient } from "@goauthentik/common/ws"; import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/LoadingOverlay"; import "@goauthentik/flow/stages/FlowErrorStage"; import "@goauthentik/flow/stages/RedirectStage"; import "@goauthentik/flow/stages/access_denied/AccessDeniedStage"; // Import webauthn-related stages to prevent issues on safari // Which is overly sensitive to allowing things only in the context of a // user interaction import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage"; import "@goauthentik/flow/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; import "@goauthentik/flow/stages/autosubmit/AutosubmitStage"; import { StageHost } from "@goauthentik/flow/stages/base"; import "@goauthentik/flow/stages/captcha/CaptchaStage"; import "@goauthentik/flow/stages/identification/IdentificationStage"; import "@goauthentik/flow/stages/password/PasswordStage"; import { t } from "@lingui/macro"; import { CSSResult, TemplateResult, css, html, render } 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 "@goauthentik/common/styles/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 { CapabilitiesEnum, ChallengeChoices, ChallengeTypes, CurrentTenant, FlowChallengeResponseRequest, FlowErrorChallenge, FlowsApi, LayoutEnum, RedirectChallenge, ResponseError, ShellChallenge, } from "@goauthentik/api"; @customElement("ak-flow-executor") export class FlowExecutor extends AKElement 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; } .pf-c-drawer__content { background-color: transparent; } /* layouts */ .pf-c-login__container.content-right { grid-template-areas: "header main" "footer main" ". main"; } .pf-c-login.sidebar_left { justify-content: flex-start; padding-top: 0; padding-bottom: 0; } .pf-c-login.sidebar_left .ak-login-container, .pf-c-login.sidebar_right .ak-login-container { height: 100vh; background-color: var(--pf-c-login__main--BackgroundColor); padding-left: var(--pf-global--spacer--lg); padding-right: var(--pf-global--spacer--lg); } .pf-c-login.sidebar_left .pf-c-list, .pf-c-login.sidebar_right .pf-c-list { color: #000; } @media (prefers-color-scheme: dark) { .pf-c-login.sidebar_left .ak-login-container, .pf-c-login.sidebar_right .ak-login-container { background-color: var(--ak-dark-background); } .pf-c-login.sidebar_left .pf-c-list, .pf-c-login.sidebar_right .pf-c-list { color: var(--ak-dark-foreground); } } .pf-c-login.sidebar_right { justify-content: flex-end; padding-top: 0; padding-bottom: 0; } `); } constructor() { super(); this.ws = new WebsocketClient(); this.flowSlug = window.location.pathname.split("/")[3]; this.inspectorOpen = globalAK()?.config.capabilities.includes(CapabilitiesEnum.Debug) || false; if (window.location.search.includes("inspector")) { this.inspectorOpen = !this.inspectorOpen; } this.addEventListener(EVENT_FLOW_INSPECTOR_TOGGLE, () => { this.inspectorOpen = !this.inspectorOpen; }); tenant().then((tenant) => (this.tenant = tenant)); } setBackground(url: string): void { this.shadowRoot ?.querySelectorAll<HTMLDivElement>(".pf-c-background-image") .forEach((bg) => { bg.style.setProperty("--ak-flow-background", `url('${url}')`); }); } submit(payload?: FlowChallengeResponseRequest): Promise<boolean> { 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 | ResponseError) => { 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 | ResponseError) => { // Catch JSON or Update errors this.errorMessage(e); }) .finally(() => { this.loading = false; }); } async errorMessage(error: Error | ResponseError): Promise<void> { let body = ""; if (error instanceof ResponseError) { body = await error.response.text(); } else if (error instanceof Error) { body = error.message; } const challenge: FlowErrorChallenge = { type: ChallengeChoices.Native, component: "ak-stage-flow-error", error: body, requestId: "", }; this.challenge = challenge as ChallengeTypes; } async renderChallengeNativeElement(): Promise<TemplateResult> { switch (this.challenge?.component) { case "ak-stage-access-denied": // Statically imported for performance reasons return html`<ak-stage-access-denied .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-access-denied>`; case "ak-stage-identification": // Statically imported for performance reasons return html`<ak-stage-identification .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-identification>`; case "ak-stage-password": // Statically imported for performance reasons return html`<ak-stage-password .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-password>`; case "ak-stage-captcha": // Statically imported to prevent browsers blocking urls return html`<ak-stage-captcha .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-captcha>`; case "ak-stage-consent": await import("@goauthentik/flow/stages/consent/ConsentStage"); return html`<ak-stage-consent .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-consent>`; case "ak-stage-dummy": await import("@goauthentik/flow/stages/dummy/DummyStage"); return html`<ak-stage-dummy .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-dummy>`; case "ak-stage-email": await import("@goauthentik/flow/stages/email/EmailStage"); return html`<ak-stage-email .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-email>`; case "ak-stage-autosubmit": // Statically imported for performance reasons return html`<ak-stage-autosubmit .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-autosubmit>`; case "ak-stage-prompt": await import("@goauthentik/flow/stages/prompt/PromptStage"); return html`<ak-stage-prompt .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-prompt>`; case "ak-stage-authenticator-totp": await import("@goauthentik/flow/stages/authenticator_totp/AuthenticatorTOTPStage"); return html`<ak-stage-authenticator-totp .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-authenticator-totp>`; case "ak-stage-authenticator-duo": await import("@goauthentik/flow/stages/authenticator_duo/AuthenticatorDuoStage"); return html`<ak-stage-authenticator-duo .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-authenticator-duo>`; case "ak-stage-authenticator-static": await import( "@goauthentik/flow/stages/authenticator_static/AuthenticatorStaticStage" ); return html`<ak-stage-authenticator-static .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-authenticator-static>`; case "ak-stage-authenticator-webauthn": return html`<ak-stage-authenticator-webauthn .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-authenticator-webauthn>`; case "ak-stage-authenticator-sms": await import("@goauthentik/flow/stages/authenticator_sms/AuthenticatorSMSStage"); return html`<ak-stage-authenticator-sms .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-authenticator-sms>`; case "ak-stage-authenticator-validate": return html`<ak-stage-authenticator-validate .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-authenticator-validate>`; // Sources case "ak-source-plex": await import("@goauthentik/flow/sources/plex/PlexLoginInit"); return html`<ak-flow-source-plex .host=${this as StageHost} .challenge=${this.challenge} ></ak-flow-source-plex>`; case "ak-source-oauth-apple": await import("@goauthentik/flow/sources/apple/AppleLoginInit"); return html`<ak-flow-source-oauth-apple .host=${this as StageHost} .challenge=${this.challenge} ></ak-flow-source-oauth-apple>`; // Providers case "ak-provider-oauth2-device-code": await import("@goauthentik/flow/providers/oauth2/DeviceCode"); return html`<ak-flow-provider-oauth2-code .host=${this as StageHost} .challenge=${this.challenge} ></ak-flow-provider-oauth2-code>`; case "ak-provider-oauth2-device-code-finish": await import("@goauthentik/flow/providers/oauth2/DeviceCodeFinish"); return html`<ak-flow-provider-oauth2-code-finish .host=${this as StageHost} .challenge=${this.challenge} ></ak-flow-provider-oauth2-code-finish>`; // Internal stages case "ak-stage-flow-error": return html`<ak-stage-flow-error .host=${this as StageHost} .challenge=${this.challenge} ></ak-stage-flow-error>`; default: break; } return html`Invalid native challenge element`; } async renderChallenge(): Promise<TemplateResult> { if (!this.challenge) { return html``; } switch (this.challenge.type) { case ChallengeChoices.Redirect: if (this.inspectorOpen) { return html`<ak-stage-redirect .host=${this as StageHost} .challenge=${this.challenge} > </ak-stage-redirect>`; } return html`<ak-empty-state ?loading=${true} header=${t`Loading`}> </ak-empty-state>`; 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`<ak-empty-state ?loading=${true} header=${t`Loading`}> </ak-empty-state>`; } return html` ${this.loading ? html`<ak-loading-overlay></ak-loading-overlay>` : html``} ${until(this.renderChallenge())} `; } async renderInspector(): Promise<TemplateResult> { if (!this.inspectorOpen) { return html``; } await import("@goauthentik/flow/FlowInspector"); return html`<ak-flow-inspector class="pf-c-drawer__panel pf-m-width-33" ></ak-flow-inspector>`; } getLayout(): string { const prefilledFlow = globalAK()?.flow?.layout || LayoutEnum.Stacked; if (this.challenge) { return this.challenge?.flowInfo?.layout || prefilledFlow; } return prefilledFlow; } getLayoutClass(): string { const layout = this.getLayout(); switch (layout) { case LayoutEnum.ContentLeft: return "pf-c-login__container"; case LayoutEnum.ContentRight: return "pf-c-login__container content-right"; case LayoutEnum.Stacked: default: return "ak-login-container"; } } renderBackgroundOverlay(): TemplateResult { const overlaySVG = html`<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0" style="display:none;" > <filter id="image_overlay"> <feColorMatrix in="SourceGraphic" type="matrix" values="1.3 0 0 0 0 0 1.3 0 0 0 0 0 1.3 0 0 0 0 0 1 0" /> <feComponentTransfer color-interpolation-filters="sRGB" result="duotone"> <feFuncR type="table" tableValues="0.086274509803922 0.43921568627451" ></feFuncR> <feFuncG type="table" tableValues="0.086274509803922 0.43921568627451" ></feFuncG> <feFuncB type="table" tableValues="0.086274509803922 0.43921568627451" ></feFuncB> <feFuncA type="table" tableValues="0 1"></feFuncA> </feComponentTransfer> </filter> </svg>`; render(overlaySVG, document.body); return overlaySVG; } render(): TemplateResult { return html`<div class="pf-c-background-image">${this.renderBackgroundOverlay()}</div> <div class="pf-c-page__drawer"> <div class="pf-c-drawer ${this.inspectorOpen ? "pf-m-expanded" : "pf-m-collapsed"}"> <div class="pf-c-drawer__main"> <div class="pf-c-drawer__content"> <div class="pf-c-drawer__body"> <div class="pf-c-login ${this.getLayout()}"> <div class="${this.getLayoutClass()}"> <header class="pf-c-login__header"> <div class="pf-c-brand ak-brand"> <img src="${first(this.tenant?.brandingLogo, "")}" alt="authentik Logo" /> </div> </header> <div class="pf-c-login__main"> ${this.renderChallengeWrapper()} </div> <footer class="pf-c-login__footer"> <p></p> <ul class="pf-c-list pf-m-inline"> ${until( this.tenant?.uiFooterLinks?.map((link) => { return html`<li> <a href="${link.href || ""}" >${link.name}</a > </li>`; }), )} <li> <a href="https://goauthentik.io?utm_source=authentik&utm_medium=flow" >${t`Powered by authentik`}</a > </li> ${this.challenge?.flowInfo?.background?.startsWith( "/static", ) ? html` <li> <a href="https://unsplash.com/@chrishenryphoto" >${t`Background image`}</a > </li> ` : html``} </ul> </footer> </div> </div> </div> </div> ${until(this.renderInspector())} </div> </div> </div>`; } }