web/flows: continuous login (#19862)

* wip

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	authentik/core/signals.py
#	authentik/stages/identification/stage.py
#	web/src/flow/stages/RedirectStage.ts

# Conflicts:
#	web/src/flow/FlowExecutor.ts

* fix race conditions

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* prevent stale locks

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add to feature flag

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add separate flag

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make it build

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* revisit

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better origin check

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2026-03-04 10:37:53 +00:00
committed by GitHub
parent 59192d94a0
commit 6245809eae
10 changed files with 245 additions and 4 deletions
+6
View File
@@ -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"""
+6 -2
View File
@@ -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
+14
View File
@@ -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:
@@ -293,6 +293,12 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
)}
>
</ak-switch-input>
<ak-switch-input
name="flags.flowsContinuousLogin"
?checked=${settings?.flags.flowsContinuousLogin ?? false}
label=${msg("Continuous Login")}
>
</ak-switch-input>
</div>
</ak-form-group>
`;
+1
View File
@@ -19,6 +19,7 @@ export const DefaultBrand = {
policiesBufferedAccessView: false,
flowsRefreshOthers: false,
enterpriseAuditIncludeExpandedDiff: false,
flowsContinuousLogin: false,
},
} as const satisfies CurrentBrand;
+24
View File
@@ -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);
}
});
}
/**
+15 -2
View File
@@ -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<RedirectChallenge, FlowChallengeRes
this.redirect();
}
redirect() {
isForeignURL() {
try {
const destination = new URL(this.challenge!.to, window.origin);
return destination.origin === window.origin;
} catch {
return true;
}
}
async redirect() {
console.debug(
"authentik/stages/redirect: redirecting to url from server",
this.challenge?.to,
);
window.location.assign(this.challenge?.to || "");
if (this.isForeignURL()) {
await multiTabOrchestrateResume();
}
window.location.assign(this.challenge!.to);
this.startedRedirect = true;
}
+28
View File
@@ -0,0 +1,28 @@
import { ascii_letters, digits, randomString } from "#common/utils";
export const SESSION_STORAGE_TAB_ID = "authentik_tab_id";
export class TabID {
static shared: TabID = new TabID();
#id: string;
constructor() {
const id = sessionStorage.getItem(SESSION_STORAGE_TAB_ID);
const newId = randomString(32, ascii_letters + digits);
if (id) {
this.#id = id;
return;
}
this.#id = newId;
sessionStorage.setItem(SESSION_STORAGE_TAB_ID, this.#id);
}
get current() {
return this.#id;
}
clear() {
sessionStorage.removeItem(SESSION_STORAGE_TAB_ID);
}
}
+91
View File
@@ -0,0 +1,91 @@
import { TabID } from "#flow/tabs/TabID";
import { ConsoleLogger, Logger } from "#logger/browser";
export const BROADCAST_CHANNEL_NAME = "authentik";
enum BroadcastMessageType {
discover = "discover",
continue = "continue",
exit = "exit",
discoverReply = "discoverReply",
}
export interface BroadcastMessage {
type: BroadcastMessageType;
sender: string;
[key: string]: unknown;
}
export class Broadcast extends BroadcastChannel {
static shared = new Broadcast();
private discoveredTabIds = new Set<string>();
exitedTabIds: string[] = [];
#logger: Logger;
#onMessage = (ev: MessageEvent<BroadcastMessage>) => {
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<Set<string>> {
this.discoveredTabIds.clear();
this.postMessage({
type: BroadcastMessageType.discover,
sender: TabID.shared.current,
});
await new Promise<void>((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,
});
}
}
+54
View File
@@ -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<void>();
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);
}