diff --git a/authentik/flows/apps.py b/authentik/flows/apps.py index 44f3c05574..31a2e03f68 100644 --- a/authentik/flows/apps.py +++ b/authentik/flows/apps.py @@ -29,6 +29,12 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others visibility = "public" +class ContinuousLogin(Flag[bool], key="flows_continuous_login"): + + default = False + visibility = "public" + + class AuthentikFlowsConfig(ManagedAppConfig): """authentik flows app config""" diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 8088c935c8..28780d278c 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -99,6 +99,7 @@ class IdentificationChallenge(Challenge): password_fields = BooleanField() allow_show_password = BooleanField(default=False) application_pre = CharField(required=False) + application_pre_launch = CharField(required=False) flow_designation = ChoiceField(FlowDesignation.choices) captcha_stage = CaptchaChallenge(required=False, allow_null=True) @@ -348,9 +349,12 @@ class IdentificationStageView(ChallengeStageView): # If the user has been redirected to us whilst trying to access an # application, PLAN_CONTEXT_APPLICATION is set in the flow plan if PLAN_CONTEXT_APPLICATION in self.executor.plan.context: - challenge.initial_data["application_pre"] = self.executor.plan.context.get( + app: Application = self.executor.plan.context.get( PLAN_CONTEXT_APPLICATION, Application() - ).name + ) + challenge.initial_data["application_pre"] = app.name + if launch_url := app.get_launch_url(): + challenge.initial_data["application_pre_launch"] = launch_url if ( PLAN_CONTEXT_DEVICE in self.executor.plan.context and PLAN_CONTEXT_DEVICE_AUTH_TOKEN in self.executor.plan.context diff --git a/schema.yml b/schema.yml index 08c8477aca..c94aaf000e 100644 --- a/schema.yml +++ b/schema.yml @@ -36805,10 +36805,13 @@ components: type: boolean policies_buffered_access_view: type: boolean + flows_continuous_login: + type: boolean flows_refresh_others: type: boolean required: - enterprise_audit_include_expanded_diff + - flows_continuous_login - flows_refresh_others - policies_buffered_access_view readOnly: true @@ -40480,6 +40483,8 @@ components: default: false application_pre: type: string + application_pre_launch: + type: string flow_designation: $ref: '#/components/schemas/FlowDesignationEnum' captcha_stage: @@ -50461,10 +50466,13 @@ components: type: boolean policies_buffered_access_view: type: boolean + flows_continuous_login: + type: boolean flows_refresh_others: type: boolean required: - enterprise_audit_include_expanded_diff + - flows_continuous_login - flows_refresh_others - policies_buffered_access_view PatchedSourceStageRequest: @@ -55160,10 +55168,13 @@ components: type: boolean policies_buffered_access_view: type: boolean + flows_continuous_login: + type: boolean flows_refresh_others: type: boolean required: - enterprise_audit_include_expanded_diff + - flows_continuous_login - flows_refresh_others - policies_buffered_access_view required: @@ -55238,10 +55249,13 @@ components: type: boolean policies_buffered_access_view: type: boolean + flows_continuous_login: + type: boolean flows_refresh_others: type: boolean required: - enterprise_audit_include_expanded_diff + - flows_continuous_login - flows_refresh_others - policies_buffered_access_view required: diff --git a/web/src/admin/admin-settings/AdminSettingsForm.ts b/web/src/admin/admin-settings/AdminSettingsForm.ts index e68e836d66..0de6267c88 100644 --- a/web/src/admin/admin-settings/AdminSettingsForm.ts +++ b/web/src/admin/admin-settings/AdminSettingsForm.ts @@ -293,6 +293,12 @@ export class AdminSettingsForm extends Form { )} > + + `; diff --git a/web/src/common/ui/config.ts b/web/src/common/ui/config.ts index 3cad6cd40c..adc92f9995 100644 --- a/web/src/common/ui/config.ts +++ b/web/src/common/ui/config.ts @@ -19,6 +19,7 @@ export const DefaultBrand = { policiesBufferedAccessView: false, flowsRefreshOthers: false, enterpriseAuditIncludeExpandedDiff: false, + flowsContinuousLogin: false, }, } as const satisfies CurrentBrand; diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index ecab5bbaab..a0482eab77 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -3,6 +3,7 @@ import "#elements/locale/ak-locale-select"; import "#flow/components/ak-brand-footer"; import "#flow/components/ak-flow-card"; import "#flow/inspector/FlowInspectorButton"; +import "#flow/tabs/broadcast"; import Styles from "./FlowExecutor.css" with { type: "bundled-text" }; @@ -25,6 +26,7 @@ import { ThemedImage } from "#elements/utils/images"; import { AKFlowAdvanceEvent } from "#flow/events"; import { StageMapping } from "#flow/FlowExecutorStageFactory"; import { BaseStage } from "#flow/stages/base"; +import { multiTabOrchestrateLeave } from "#flow/tabs/orchestrator"; import type { StageHost, SubmitOptions } from "#flow/types"; import { ConsoleLogger } from "#logger/browser"; @@ -149,6 +151,28 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos this.submit({} as FlowChallengeResponseRequest); } }); + + window.addEventListener("ak-multitab-continue", () => { + document.title = "continued"; + if ( + this.challenge?.component === "ak-stage-identification" && + this.challenge.applicationPreLaunch && + this.challenge.applicationPreLaunch !== "blank://blank" + ) { + multiTabOrchestrateLeave(); + window.location.assign(this.challenge.applicationPreLaunch); + return; + } + const qs = new URLSearchParams(window.location.search); + const next = qs.get("next"); + if (next) { + const url = new URL(next, window.location.origin); + if (url.origin !== window.location.origin) { + multiTabOrchestrateLeave(); + } + window.location.assign(url); + } + }); } /** diff --git a/web/src/flow/stages/RedirectStage.ts b/web/src/flow/stages/RedirectStage.ts index 997d01c600..cd47124652 100644 --- a/web/src/flow/stages/RedirectStage.ts +++ b/web/src/flow/stages/RedirectStage.ts @@ -3,6 +3,7 @@ import "#flow/components/ak-flow-card"; import { SlottedTemplateResult } from "#elements/types"; import { BaseStage } from "#flow/stages/base"; +import { multiTabOrchestrateResume } from "#flow/tabs/orchestrator"; import { FlowChallengeResponseRequest, RedirectChallenge } from "@goauthentik/api"; @@ -63,13 +64,25 @@ export class RedirectStage extends BaseStage(); + exitedTabIds: string[] = []; + + #logger: Logger; + + #onMessage = (ev: MessageEvent) => { + this.#logger.debug("broadcast event", ev.data); + switch (ev.data.type) { + case BroadcastMessageType.discover: + if (ev.data.sender === TabID.shared.current) { + return; + } + this.postMessage({ + type: BroadcastMessageType.discoverReply, + sender: TabID.shared.current, + }); + return; + case BroadcastMessageType.discoverReply: + this.discoveredTabIds.add(ev.data.sender as string); + return; + case BroadcastMessageType.exit: + this.exitedTabIds.push(ev.data.sender); + return; + case BroadcastMessageType.continue: + if (ev.data.target === TabID.shared.current) { + this.#logger.debug("Continuing upon event"); + window.dispatchEvent(new CustomEvent("ak-multitab-continue")); + } + return; + } + }; + + constructor() { + super(BROADCAST_CHANNEL_NAME); + this.addEventListener("message", this.#onMessage); + this.#logger = ConsoleLogger.prefix("mtab/broadcast"); + } + + [Symbol.dispose]() { + this.removeEventListener("message", this.#onMessage); + } + + async akTabDiscover(): Promise> { + this.discoveredTabIds.clear(); + this.postMessage({ + type: BroadcastMessageType.discover, + sender: TabID.shared.current, + }); + await new Promise((r) => { + setTimeout(r, 20); + }); + return this.discoveredTabIds; + } + + akResumeTab(tabId: string) { + this.postMessage({ + type: BroadcastMessageType.continue, + sender: TabID.shared.current, + target: tabId, + }); + } + + akExitTab() { + this.postMessage({ + type: BroadcastMessageType.exit, + sender: TabID.shared.current, + }); + } +} diff --git a/web/src/flow/tabs/orchestrator.ts b/web/src/flow/tabs/orchestrator.ts new file mode 100644 index 0000000000..fad4fae047 --- /dev/null +++ b/web/src/flow/tabs/orchestrator.ts @@ -0,0 +1,54 @@ +import { globalAK } from "#common/global"; + +import { Broadcast } from "#flow/tabs/broadcast"; +import { TabID } from "#flow/tabs/TabID"; + +import { ConsoleLogger } from "#logger/browser"; + +const lockKey = "authentik-tab-locked"; +const logger = ConsoleLogger.prefix("mtab/orchestrate"); + +export function multiTabOrchestrateLeave() { + if (!globalAK().brand.flags.flowsContinuousLogin) { + return; + } + Broadcast.shared.akExitTab(); + TabID.shared.clear(); +} + +export async function multiTabOrchestrateResume() { + if (!globalAK().brand.flags.flowsContinuousLogin) { + return; + } + const lockTabId = localStorage.getItem(lockKey); + const tabs = await Broadcast.shared.akTabDiscover(); + logger.debug("Got list of tabs", tabs); + + if (lockTabId && tabs.has(lockTabId)) { + logger.debug("Tabs locked, leaving."); + multiTabOrchestrateLeave(); + return; + } + logger.debug("Locking tabs"); + localStorage.setItem(lockKey, TabID.shared.current); + + for (const tab of tabs) { + logger.debug("Telling tab to continue", tab); + Broadcast.shared.akResumeTab(tab); + const done = Promise.withResolvers(); + const checker = setInterval(() => { + if (Broadcast.shared.exitedTabIds.includes(tab)) { + logger.debug("tab exited", tab); + setTimeout(() => { + logger.debug("continue exited", tab); + done.resolve(); + }, 1000); + clearInterval(checker); + } + }, 1); + await done.promise; + logger.debug("Tab done, continuing", tab); + } + logger.debug("All tabs done."); + localStorage.removeItem(lockKey); +}