mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web/flows: add continuous flow 2025.12 (#20362)
* web/flows: add continuous flow 2025.12 Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fallthrough for blank launch url Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleanup dev Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove test-migrations-from-stable Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@@ -226,7 +226,6 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- test-migrations
|
||||
- test-migrations-from-stable
|
||||
- test-unittest
|
||||
- test-integration
|
||||
- test-e2e
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -315,9 +316,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
@@ -35760,9 +35760,12 @@ components:
|
||||
properties:
|
||||
policies_buffered_access_view:
|
||||
type: boolean
|
||||
flows_continuous_login:
|
||||
type: boolean
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
required:
|
||||
- flows_continuous_login
|
||||
- flows_refresh_others
|
||||
- policies_buffered_access_view
|
||||
readOnly: true
|
||||
@@ -39330,6 +39333,8 @@ components:
|
||||
default: false
|
||||
application_pre:
|
||||
type: string
|
||||
application_pre_launch:
|
||||
type: string
|
||||
flow_designation:
|
||||
$ref: '#/components/schemas/FlowDesignationEnum'
|
||||
captcha_stage:
|
||||
@@ -48896,9 +48901,12 @@ components:
|
||||
properties:
|
||||
policies_buffered_access_view:
|
||||
type: boolean
|
||||
flows_continuous_login:
|
||||
type: boolean
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
required:
|
||||
- flows_continuous_login
|
||||
- flows_refresh_others
|
||||
- policies_buffered_access_view
|
||||
PatchedSourceStageRequest:
|
||||
@@ -53415,9 +53423,12 @@ components:
|
||||
properties:
|
||||
policies_buffered_access_view:
|
||||
type: boolean
|
||||
flows_continuous_login:
|
||||
type: boolean
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
required:
|
||||
- flows_continuous_login
|
||||
- flows_refresh_others
|
||||
- policies_buffered_access_view
|
||||
required:
|
||||
@@ -53490,9 +53501,12 @@ components:
|
||||
properties:
|
||||
policies_buffered_access_view:
|
||||
type: boolean
|
||||
flows_continuous_login:
|
||||
type: boolean
|
||||
flows_refresh_others:
|
||||
type: boolean
|
||||
required:
|
||||
- flows_continuous_login
|
||||
- flows_refresh_others
|
||||
- policies_buffered_access_view
|
||||
required:
|
||||
|
||||
@@ -286,6 +286,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>
|
||||
`;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const DefaultBrand = {
|
||||
flags: {
|
||||
policiesBufferedAccessView: false,
|
||||
flowsRefreshOthers: false,
|
||||
flowsContinuousLogin: false,
|
||||
},
|
||||
} as const satisfies CurrentBrand;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import "#flow/sources/telegram/TelegramLogin";
|
||||
import "#flow/stages/FlowErrorStage";
|
||||
import "#flow/stages/FlowFrameStage";
|
||||
import "#flow/stages/RedirectStage";
|
||||
import "#flow/tabs/broadcast";
|
||||
|
||||
import Styles from "./FlowExecutor.css" with { type: "bundled-text" };
|
||||
|
||||
@@ -30,6 +31,7 @@ import { ThemedImage } from "#elements/utils/images";
|
||||
|
||||
import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events";
|
||||
import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base";
|
||||
import { multiTabOrchestrateLeave } from "#flow/tabs/orchestrator";
|
||||
|
||||
import {
|
||||
CapabilitiesEnum,
|
||||
@@ -165,6 +167,27 @@ export class FlowExecutor
|
||||
this.submit({} as FlowChallengeResponseRequest);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("ak-multitab-continue", () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//#region Listeners
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -60,13 +61,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user