mirror of
https://github.com/goauthentik/authentik
synced 2026-05-14 10:56:52 +02:00
Compare commits
8 Commits
tests/conf
...
web/flow/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4665146dbb | ||
|
|
80e0e41e55 | ||
|
|
dee3a88646 | ||
|
|
f98e2ee1e5 | ||
|
|
5bf50a92dc | ||
|
|
46240c25a6 | ||
|
|
cb0db4ea70 | ||
|
|
5b50620238 |
99
web/src/elements/directives/light.ts
Normal file
99
web/src/elements/directives/light.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { render, TemplateResult } from "lit";
|
||||
import { AsyncDirective } from "lit/async-directive.js";
|
||||
import { ChildPart, directive, PartInfo, PartType } from "lit/directive.js";
|
||||
import { RootPart } from "lit/html.js";
|
||||
|
||||
export interface LightChildOptions {
|
||||
// Optional alternative target for any `@event`-style handlers passed into the template. NOTE:
|
||||
// this only works if the handlers do not already have a `this` bound to them, so only ordinary
|
||||
// functions and methods will respond to this parameter; arrow function class fields and bound
|
||||
// functions will use the `this` to which they were bound.
|
||||
host?: Element;
|
||||
slotName?: string;
|
||||
}
|
||||
|
||||
class LightChildDirective extends AsyncDirective {
|
||||
#slotName: string | null = null;
|
||||
#slot: HTMLSlotElement | null = null;
|
||||
#rootPart: RootPart | null = null;
|
||||
#sentinel: Comment | null = null;
|
||||
|
||||
constructor(partInfo: PartInfo) {
|
||||
super(partInfo);
|
||||
if (partInfo.type !== PartType.CHILD) {
|
||||
throw new Error("The `light()` directive can only be use in child position");
|
||||
}
|
||||
}
|
||||
|
||||
render(_template?: TemplateResult, options?: LightChildOptions) {
|
||||
this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`;
|
||||
// The lack of `html` here is deliberate. This code only runs in SSR mode. We don't use in in
|
||||
// the `update()` phase.
|
||||
return `<slot name="${this.#slotName}"></slot>`;
|
||||
}
|
||||
|
||||
#withSplicedSlotname(template: TemplateResult): TemplateResult {
|
||||
const raw = template.strings.raw;
|
||||
const strings = [...(template.strings as unknown as string[])];
|
||||
const values = [...template.values];
|
||||
strings.splice(1, 0, " slot=");
|
||||
values.splice(1, 0, this.#slotName);
|
||||
// @ts-expect-error This is esoteric coercion.
|
||||
(strings as TemplateStringsArray).raw = raw;
|
||||
return {
|
||||
...template,
|
||||
strings: Object.freeze(strings as unknown as TemplateStringsArray),
|
||||
values: values,
|
||||
};
|
||||
}
|
||||
|
||||
update(part: ChildPart, [template, options = {}]: [TemplateResult, LightChildOptions]) {
|
||||
if (!/^\s*</.test(template.strings[0])) {
|
||||
throw new Error("The `light()` directive can only take an ElementNode, not a TextNode");
|
||||
}
|
||||
|
||||
this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// This places a comment in the LightDom that belongs to this directive. Comments are not
|
||||
// part of the DOM tree for the purposes of CSS, so it will be possible to style this child
|
||||
// directly without a wrapper.
|
||||
|
||||
if (!this.#sentinel) {
|
||||
const host = (part.options?.host ||
|
||||
(part.parentNode.getRootNode() as ShadowRoot).host) as Element;
|
||||
this.#sentinel = document.createComment("");
|
||||
host.appendChild(this.#sentinel);
|
||||
}
|
||||
|
||||
const slottedTemplate = template.strings.find((s) => /slot=["']$/.test(s))
|
||||
? template
|
||||
: this.#withSplicedSlotname(template);
|
||||
|
||||
if (!this.#sentinel.parentNode) {
|
||||
throw new Error("Could not assign sentinel to element.");
|
||||
}
|
||||
|
||||
const renderOptions = Object.fromEntries(
|
||||
Object.entries(options).filter(([key]) => ["host"].includes(key)),
|
||||
);
|
||||
|
||||
this.#rootPart = render(slottedTemplate, this.#sentinel.parentNode as HTMLElement, {
|
||||
renderBefore: this.#sentinel,
|
||||
...renderOptions,
|
||||
});
|
||||
|
||||
return (this.#slot ??= Object.assign(document.createElement("slot"), {
|
||||
name: this.#slotName,
|
||||
}));
|
||||
}
|
||||
|
||||
disconnected() {
|
||||
this.#rootPart?.setConnected(false);
|
||||
}
|
||||
|
||||
reconnected() {
|
||||
this.#rootPart?.setConnected(true);
|
||||
}
|
||||
}
|
||||
|
||||
export const light = directive(LightChildDirective);
|
||||
@@ -15,6 +15,7 @@ import { AKSessionAuthenticatedEvent } from "#common/ws/events";
|
||||
import { WebsocketClient } from "#common/ws/WebSocketClient";
|
||||
|
||||
import { listen } from "#elements/decorators/listen";
|
||||
import { light } from "#elements/directives/light";
|
||||
import { Interface } from "#elements/Interface";
|
||||
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
|
||||
import { WithBrandConfig } from "#elements/mixins/branding";
|
||||
@@ -22,10 +23,19 @@ import { LitPropertyRecord, SlottedTemplateResult } from "#elements/types";
|
||||
import { exportParts } from "#elements/utils/attributes";
|
||||
import { ThemedImage } from "#elements/utils/images";
|
||||
|
||||
import { AKFlowAdvanceEvent } from "#flow/events";
|
||||
import {
|
||||
AKFlowAdvanceEvent,
|
||||
AKFlowSubmitRequest,
|
||||
AKFlowUpdateChallengeRequest,
|
||||
} from "#flow/events";
|
||||
import { StageMapping } from "#flow/FlowExecutorStageFactory";
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
import type { StageHost, SubmitOptions } from "#flow/types";
|
||||
import type {
|
||||
ExecutorMessage,
|
||||
FlowChallengeResponseRequestBody,
|
||||
StageHost,
|
||||
SubmitOptions,
|
||||
} from "#flow/types";
|
||||
|
||||
import { ConsoleLogger } from "#logger/browser";
|
||||
|
||||
@@ -51,6 +61,7 @@ import { html as staticHTML, unsafeStatic } from "lit/static-html.js";
|
||||
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 PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.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";
|
||||
@@ -86,6 +97,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
PFLogin,
|
||||
PFDrawer,
|
||||
PFButton,
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFList,
|
||||
PFBackgroundImage,
|
||||
@@ -124,6 +136,28 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
return this.challenge?.flowInfo ?? null;
|
||||
}
|
||||
|
||||
//region Live event handlers
|
||||
|
||||
handleExecutorMessage = (event: MessageEvent<ExecutorMessage>) => {
|
||||
const { source, context, message } = event.data;
|
||||
|
||||
if (source !== "goauthentik.io" && context !== "flow-executor" && message === "submit") {
|
||||
this.submit({} as FlowChallengeResponseRequest);
|
||||
}
|
||||
};
|
||||
|
||||
handleChallengeRequest = (event: AKFlowUpdateChallengeRequest) => {
|
||||
this.challenge = event.challenge;
|
||||
};
|
||||
|
||||
handleSubordinateSubmit = (event: AKFlowSubmitRequest) => {
|
||||
// prettier-ignore
|
||||
const { request: { payload, options } } = event;
|
||||
this.submit(payload, options);
|
||||
};
|
||||
|
||||
//endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor() {
|
||||
@@ -135,20 +169,9 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
|
||||
this.#api = new FlowsApi(DEFAULT_CONFIG);
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
const msg: {
|
||||
source?: string;
|
||||
context?: string;
|
||||
message: string;
|
||||
} = event.data;
|
||||
|
||||
if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") {
|
||||
return;
|
||||
}
|
||||
if (msg.message === "submit") {
|
||||
this.submit({} as FlowChallengeResponseRequest);
|
||||
}
|
||||
});
|
||||
window.addEventListener("message", this.handleExecutorMessage);
|
||||
this.addEventListener(AKFlowUpdateChallengeRequest.eventName, this.handleChallengeRequest);
|
||||
this.addEventListener(AKFlowSubmitRequest.eventName, this.handleSubordinateSubmit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,7 +279,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
//#region Public Methods
|
||||
|
||||
public submit = async (
|
||||
payload?: FlowChallengeResponseRequest,
|
||||
payload?: FlowChallengeResponseRequestBody,
|
||||
options?: SubmitOptions,
|
||||
): Promise<boolean> => {
|
||||
if (!payload) throw new Error("No payload provided");
|
||||
@@ -272,7 +295,11 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
throw new Error("No flow slug provided");
|
||||
}
|
||||
|
||||
payload.component = this.challenge.component as FlowChallengeResponseRequest["component"];
|
||||
// This order is deliberate; the executor always specifies the component token.
|
||||
const flowChallengeResponseRequest = {
|
||||
...payload,
|
||||
component: this.challenge.component as FlowChallengeResponseRequest["component"],
|
||||
} as FlowChallengeResponseRequest;
|
||||
|
||||
if (!options?.invisible) {
|
||||
this.loading = true;
|
||||
@@ -282,7 +309,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
.flowsExecutorSolve({
|
||||
flowSlug: this.flowSlug,
|
||||
query: window.location.search.substring(1),
|
||||
flowChallengeResponseRequest: payload,
|
||||
flowChallengeResponseRequest,
|
||||
})
|
||||
.then((challenge) => {
|
||||
window.dispatchEvent(new AKFlowAdvanceEvent());
|
||||
@@ -342,7 +369,7 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
return staticHTML`<${unsafeStatic(tag)} ${props}></${unsafeStatic(tag)}>`;
|
||||
return light(staticHTML`<${unsafeStatic(tag)} ${props}></${unsafeStatic(tag)}>`);
|
||||
}
|
||||
|
||||
protected renderChallengeError(error: unknown): SlottedTemplateResult {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { FlowChallengeResponseRequestBody, SubmitOptions, SubmitRequest } from "#flow/types";
|
||||
|
||||
import { ChallengeTypes } from "@goauthentik/api";
|
||||
|
||||
/**
|
||||
* @file Flow event utilities.
|
||||
*/
|
||||
@@ -33,20 +37,8 @@ export class AKFlowInspectorChangeEvent extends Event {
|
||||
public static dispatchOpen() {
|
||||
window.dispatchEvent(new AKFlowInspectorChangeEvent(true));
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
[AKFlowInspectorChangeEvent.eventName]: AKFlowInspectorChangeEvent;
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Flow Inspector
|
||||
|
||||
/**
|
||||
* Event dispatched when the state of the interface drawers changes.
|
||||
*/
|
||||
@@ -58,10 +50,49 @@ export class AKFlowAdvanceEvent extends Event {
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
[AKFlowAdvanceEvent.eventName]: AKFlowAdvanceEvent;
|
||||
//#endregion
|
||||
|
||||
//#region Executor control
|
||||
|
||||
/**
|
||||
* Event dispatched to request that the challenge be progressed from the client side.
|
||||
*/
|
||||
export class AKFlowUpdateChallengeRequest extends Event {
|
||||
public static readonly eventName = "ak-flow-update-challenge";
|
||||
public challenge: ChallengeTypes;
|
||||
|
||||
constructor(challenge: ChallengeTypes) {
|
||||
super(AKFlowUpdateChallengeRequest.eventName, { bubbles: true, composed: true });
|
||||
this.challenge = challenge;
|
||||
}
|
||||
}
|
||||
|
||||
export class AKFlowSubmitRequest extends Event {
|
||||
public static readonly eventName = "ak-flow-submit-request";
|
||||
public readonly request: SubmitRequest;
|
||||
|
||||
constructor(
|
||||
payload: FlowChallengeResponseRequestBody,
|
||||
options: SubmitOptions = { invisible: false },
|
||||
) {
|
||||
super(AKFlowSubmitRequest.eventName, { bubbles: true, composed: true });
|
||||
this.request = {
|
||||
payload,
|
||||
options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
[AKFlowAdvanceEvent.eventName]: AKFlowAdvanceEvent;
|
||||
[AKFlowInspectorChangeEvent.eventName]: AKFlowInspectorChangeEvent;
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
[AKFlowSubmitRequest.eventName]: AKFlowSubmitRequest;
|
||||
[AKFlowUpdateChallengeRequest.eventName]: AKFlowUpdateChallengeRequest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ import "#flow/components/ak-flow-card";
|
||||
import "#flow/components/ak-flow-password-input";
|
||||
import "#flow/stages/captcha/CaptchaStage";
|
||||
|
||||
import {
|
||||
isConditionalMediationAvailable,
|
||||
transformAssertionForServer,
|
||||
transformCredentialRequestOptions,
|
||||
} from "#common/helpers/webauthn";
|
||||
import { light } from "#elements/directives/light";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
@@ -16,11 +12,13 @@ import { AKLabel } from "#components/ak-label";
|
||||
import { renderSourceIcon } from "#admin/sources/utils";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
import { AkRememberMeController } from "#flow/stages/identification/RememberMeController";
|
||||
import AutoRedirect from "#flow/stages/identification/controllers/AutoRedirectController";
|
||||
import CaptchaController from "#flow/stages/identification/controllers/CaptchaController";
|
||||
import RememberMe from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import WebauthnController from "#flow/stages/identification/controllers/WebauthnController";
|
||||
import Styles from "#flow/stages/identification/styles.css";
|
||||
|
||||
import {
|
||||
CaptchaChallenge,
|
||||
FlowDesignationEnum,
|
||||
IdentificationChallenge,
|
||||
IdentificationChallengeResponseRequest,
|
||||
@@ -33,9 +31,8 @@ import { kebabCase } from "change-case";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { html, nothing, PropertyValues, ReactiveControllerHost } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
@@ -52,6 +49,8 @@ type PasskeyChallenge = Omit<IdentificationChallenge, "passkeyChallenge"> & {
|
||||
|
||||
type IdentificationFooter = Partial<Pick<IdentificationChallenge, "enrollUrl" | "recoveryUrl">>;
|
||||
|
||||
export type IdentificationHost = IdentificationStage & ReactiveControllerHost;
|
||||
|
||||
type EmptyString = string | null | undefined;
|
||||
|
||||
export const PasswordManagerPrefill: {
|
||||
@@ -81,7 +80,7 @@ export class IdentificationStage extends BaseStage<
|
||||
IdentificationChallenge,
|
||||
IdentificationChallengeResponseRequest
|
||||
> {
|
||||
static styles: CSSResult[] = [
|
||||
static styles = [
|
||||
PFAlert,
|
||||
PFInputGroup,
|
||||
PFLogin,
|
||||
@@ -89,7 +88,7 @@ export class IdentificationStage extends BaseStage<
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
...AkRememberMeController.styles,
|
||||
...RememberMe.styles,
|
||||
Styles,
|
||||
];
|
||||
|
||||
@@ -103,140 +102,34 @@ export class IdentificationStage extends BaseStage<
|
||||
|
||||
#form?: HTMLFormElement;
|
||||
|
||||
private rememberMe = new AkRememberMeController(this);
|
||||
|
||||
//#region State
|
||||
|
||||
@state()
|
||||
protected captchaToken = "";
|
||||
|
||||
@state()
|
||||
protected captchaRefreshedAt = new Date();
|
||||
|
||||
@state()
|
||||
protected captchaLoaded = false;
|
||||
|
||||
#captchaInputRef = createRef<HTMLInputElement>();
|
||||
|
||||
#tokenChangeListener = (token: string) => {
|
||||
const input = this.#captchaInputRef.value;
|
||||
|
||||
if (!input) return;
|
||||
|
||||
input.value = token;
|
||||
};
|
||||
|
||||
#captchaLoadListener = () => {
|
||||
this.captchaLoaded = true;
|
||||
};
|
||||
|
||||
// AbortController for conditional WebAuthn request
|
||||
#passkeyAbortController: AbortController | null = null;
|
||||
private rememberMe = new RememberMe(this);
|
||||
private autoRedirect = new AutoRedirect(this);
|
||||
private captcha = new CaptchaController(this);
|
||||
private webauthn = new WebauthnController(this);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("challenge") && this.challenge) {
|
||||
this.#autoRedirect();
|
||||
this.#createHelperForm();
|
||||
this.#startConditionalWebAuthn();
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
// We _define and instantiate_ these fields above, then _read_ them here, and that satisfies
|
||||
// the lint pass that there are no unused private fields.
|
||||
this.addController(this.rememberMe);
|
||||
this.addController(this.autoRedirect);
|
||||
this.addController(this.captcha);
|
||||
this.addController(this.webauthn);
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
// Abort any pending conditional WebAuthn request when component is removed
|
||||
this.#passkeyAbortController?.abort();
|
||||
this.#passkeyAbortController = null;
|
||||
public override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("challenge") && this.challenge) {
|
||||
this.#createHelperForm();
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
#autoRedirect(): void {
|
||||
if (!this.challenge) return;
|
||||
// We only want to auto-redirect to a source if there's only one source.
|
||||
if (this.challenge.sources?.length !== 1) return;
|
||||
|
||||
// And we also only do an auto-redirect if no user fields are select
|
||||
// meaning that without the auto-redirect the user would only have the option
|
||||
// to manually click on the source button
|
||||
if ((this.challenge.userFields || []).length !== 0) return;
|
||||
|
||||
// We also don't want to auto-redirect if there's a passwordless URL configured
|
||||
if (this.challenge.passwordlessUrl) return;
|
||||
|
||||
const source = this.challenge.sources[0];
|
||||
this.host.challenge = source.challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a conditional WebAuthn request for passkey autofill.
|
||||
* This allows users to select a passkey from the browser's autofill dropdown.
|
||||
*/
|
||||
async #startConditionalWebAuthn(): Promise<void> {
|
||||
// Check if passkey challenge is provided
|
||||
// Note: passkeyChallenge is added dynamically and may not be in the generated types yet
|
||||
const passkeyChallenge = (
|
||||
this.challenge as IdentificationChallenge & {
|
||||
passkeyChallenge?: PublicKeyCredentialRequestOptions;
|
||||
}
|
||||
)?.passkeyChallenge;
|
||||
|
||||
if (!passkeyChallenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if browser supports conditional mediation
|
||||
const isAvailable = await isConditionalMediationAvailable();
|
||||
if (!isAvailable) {
|
||||
console.debug("authentik/identification: Conditional mediation not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any existing request
|
||||
this.#passkeyAbortController?.abort();
|
||||
this.#passkeyAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
const publicKeyOptions = transformCredentialRequestOptions(passkeyChallenge);
|
||||
|
||||
// Start the conditional WebAuthn request
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions,
|
||||
mediation: "conditional",
|
||||
signal: this.#passkeyAbortController.signal,
|
||||
})) as PublicKeyCredential | null;
|
||||
|
||||
if (!credential) {
|
||||
console.debug("authentik/identification: No credential returned");
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform and submit the passkey response
|
||||
const transformedCredential = transformAssertionForServer(credential);
|
||||
|
||||
await this.host?.submit(
|
||||
{
|
||||
passkey: transformedCredential,
|
||||
},
|
||||
{
|
||||
invisible: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// Request was aborted, this is expected when navigating away
|
||||
console.debug("authentik/identification: Conditional WebAuthn aborted");
|
||||
return;
|
||||
}
|
||||
console.warn("authentik/identification: Conditional WebAuthn failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
//#region Helper Form
|
||||
|
||||
#createHelperForm(): void {
|
||||
@@ -333,13 +226,7 @@ export class IdentificationStage extends BaseStage<
|
||||
}
|
||||
|
||||
protected override onSubmitFailure(): void {
|
||||
const captchaInput = this.#captchaInputRef.value;
|
||||
|
||||
if (captchaInput) {
|
||||
captchaInput.value = "";
|
||||
}
|
||||
|
||||
this.captchaRefreshedAt = new Date();
|
||||
this.captcha.onFailure();
|
||||
}
|
||||
|
||||
#dispatchChallengeToHost = (challenge: LoginChallengeTypes) => {
|
||||
@@ -360,18 +247,20 @@ export class IdentificationStage extends BaseStage<
|
||||
username: EmptyString,
|
||||
autocomplete: string,
|
||||
) {
|
||||
return html`<input
|
||||
id=${id}
|
||||
type=${type}
|
||||
name="uidField"
|
||||
placeholder=${label}
|
||||
autofocus
|
||||
autocomplete=${autocomplete}
|
||||
spellcheck="false"
|
||||
class="pf-c-form-control"
|
||||
value=${username ?? ""}
|
||||
required
|
||||
/>`;
|
||||
return light(
|
||||
html`<input
|
||||
id=${id}
|
||||
type=${type}
|
||||
name="uidField"
|
||||
placeholder=${label}
|
||||
autofocus
|
||||
autocomplete=${autocomplete}
|
||||
spellcheck="false"
|
||||
class="pf-c-form-control"
|
||||
value=${username ?? ""}
|
||||
required
|
||||
/>`,
|
||||
);
|
||||
}
|
||||
|
||||
protected renderPasswordFields(challenge: IdentificationChallenge) {
|
||||
@@ -388,33 +277,8 @@ export class IdentificationStage extends BaseStage<
|
||||
`;
|
||||
}
|
||||
|
||||
protected renderCaptcha(captchaChallenge: CaptchaChallenge) {
|
||||
return html`
|
||||
<div class="captcha-container">
|
||||
<ak-stage-captcha
|
||||
.challenge=${captchaChallenge}
|
||||
.onTokenChange=${this.#tokenChangeListener}
|
||||
.onLoad=${this.#captchaLoadListener}
|
||||
.refreshedAt=${this.captchaRefreshedAt}
|
||||
embedded
|
||||
>
|
||||
</ak-stage-captcha>
|
||||
<input
|
||||
aria-hidden="true"
|
||||
class="faux-input"
|
||||
${ref(this.#captchaInputRef)}
|
||||
name="captchaToken"
|
||||
type="text"
|
||||
required
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected renderInput(challenge: IdentificationChallenge) {
|
||||
const {
|
||||
captchaStage,
|
||||
flowDesignation,
|
||||
passkeyChallenge,
|
||||
passwordFields,
|
||||
@@ -429,17 +293,17 @@ export class IdentificationStage extends BaseStage<
|
||||
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
|
||||
}
|
||||
|
||||
const { inputID, rememberMe, captchaLoaded } = this;
|
||||
const { inputID, rememberMe } = this;
|
||||
|
||||
const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery;
|
||||
const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text";
|
||||
const label = OR_LIST_FORMATTERS.format(fields.map((f) => UI_FIELDS[f]));
|
||||
const username = rememberMe.username ?? pendingUserIdentifier;
|
||||
const captchaPending = captchaStage && captchaStage.interactive && !captchaLoaded;
|
||||
|
||||
// When passkey is enabled, add "webauthn" to autocomplete to enable passkey autofill
|
||||
const autocomplete: AutoFill = passkeyChallenge ? "username webauthn" : "username";
|
||||
|
||||
// prettier-ignore
|
||||
return html`${offerRecovery ? this.renderRecoveryMessage() : nothing}
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: inputID }, label)}
|
||||
@@ -448,12 +312,11 @@ export class IdentificationStage extends BaseStage<
|
||||
${AKFormErrors({ errors: challenge.responseErrors?.uid_field })}
|
||||
</div>
|
||||
${passwordFields ? this.renderPasswordFields(challenge) : nothing}
|
||||
${this.renderNonFieldErrors()}
|
||||
${captchaStage ? this.renderCaptcha(captchaStage) : nothing}
|
||||
|
||||
<div class="pf-c-form__group ${captchaStage ? "" : "pf-m-action"}">
|
||||
${this.renderNonFieldErrors()}
|
||||
${this.captcha.render()}
|
||||
<div class="pf-c-form__group ${this.captcha.live ? "" : "pf-m-action"}">
|
||||
<button
|
||||
?disabled=${captchaPending}
|
||||
?disabled=${this.captcha.pending}
|
||||
type="submit"
|
||||
class="pf-c-button pf-m-primary pf-m-block"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AKFlowUpdateChallengeRequest } from "#flow/events";
|
||||
import type { IdentificationHost } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { ReactiveController } from "lit";
|
||||
|
||||
export class AutoRedirect implements ReactiveController {
|
||||
constructor(private host: IdentificationHost) {}
|
||||
|
||||
public hostUpdate() {
|
||||
const { challenge } = this.host;
|
||||
if (!challenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { userFields, passwordlessUrl, sources = [] } = challenge;
|
||||
|
||||
// The rules for auto-redirect:
|
||||
const onlyOneSource = sources.length === 1;
|
||||
const noUserAccessibleInputs = (userFields || []).length === 0;
|
||||
const noAlternativeMethods = !passwordlessUrl;
|
||||
|
||||
if (onlyOneSource && noUserAccessibleInputs && noAlternativeMethods) {
|
||||
this.host.dispatchEvent(new AKFlowUpdateChallengeRequest(sources[0].challenge));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoRedirect;
|
||||
@@ -0,0 +1,82 @@
|
||||
import "#flow/stages/captcha/CaptchaStage";
|
||||
|
||||
import type { IdentificationHost } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { CaptchaChallenge } from "@goauthentik/api";
|
||||
|
||||
import { html, nothing, ReactiveController } from "lit";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
export class CaptchaController implements ReactiveController {
|
||||
private challenge: CaptchaChallenge | null = null;
|
||||
|
||||
protected token = "";
|
||||
protected loaded = false;
|
||||
protected refreshedAt = new Date();
|
||||
|
||||
constructor(private host: IdentificationHost) {}
|
||||
|
||||
public hostUpdate() {
|
||||
if (this.challenge !== this.host.challenge?.captchaStage) {
|
||||
this.challenge = this.host.challenge?.captchaStage ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
public get live() {
|
||||
return !!this.challenge;
|
||||
}
|
||||
|
||||
public get pending() {
|
||||
return this.challenge && this.challenge.interactive && !this.loaded;
|
||||
}
|
||||
|
||||
#inputRef = createRef<HTMLInputElement>();
|
||||
|
||||
#loadListener = () => {
|
||||
this.loaded = true;
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
|
||||
#tokenChangeListener = (token: string) => {
|
||||
const input = this.#inputRef.value;
|
||||
if (!input) return;
|
||||
input.value = token;
|
||||
};
|
||||
|
||||
public onFailure() {
|
||||
const captchaInput = this.#inputRef.value;
|
||||
if (captchaInput) {
|
||||
captchaInput.value = "";
|
||||
}
|
||||
this.refreshedAt = new Date();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
#renderCaptchaStage(challenge: CaptchaChallenge) {
|
||||
return html` <div class="captcha-container">
|
||||
<ak-stage-captcha
|
||||
.challenge=${challenge}
|
||||
.onTokenChange=${this.#tokenChangeListener}
|
||||
.onLoad=${this.#loadListener}
|
||||
.refreshedAt=${this.refreshedAt}
|
||||
embedded
|
||||
>
|
||||
</ak-stage-captcha>
|
||||
<input
|
||||
aria-hidden="true"
|
||||
class="faux-input"
|
||||
${ref(this.#inputRef)}
|
||||
name="captchaToken"
|
||||
type="text"
|
||||
required
|
||||
value=""
|
||||
/>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.challenge ? this.#renderCaptchaStage(this.challenge) : nothing;
|
||||
}
|
||||
}
|
||||
|
||||
export default CaptchaController;
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { IdentificationStage } from "./IdentificationStage.js";
|
||||
|
||||
import { getCookie } from "#common/utils";
|
||||
|
||||
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
|
||||
export class AkRememberMeController implements ReactiveController {
|
||||
export class RememberMe implements ReactiveController {
|
||||
static styles = [
|
||||
css`
|
||||
.remember-me-switch {
|
||||
@@ -22,11 +22,33 @@ export class AkRememberMeController implements ReactiveController {
|
||||
|
||||
rememberingUsername: boolean = false;
|
||||
|
||||
constructor(private host: RememberMeHost) {
|
||||
this.trackRememberMe = this.trackRememberMe.bind(this);
|
||||
this.toggleRememberMe = this.toggleRememberMe.bind(this);
|
||||
this.host.addController(this);
|
||||
}
|
||||
trackRememberMe = () => {
|
||||
if (!this.usernameField || this.usernameField.value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.username = this.usernameField.value;
|
||||
localStorage?.setItem("authentik-remember-me-user", this.username);
|
||||
};
|
||||
|
||||
// When active, save current details and record every keystroke to the username.
|
||||
// When inactive, clear all fields and remove keystroke recorder.
|
||||
toggleRememberMe = () => {
|
||||
if (!this.rememberMeToggle || !this.rememberMeToggle.checked) {
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
localStorage?.removeItem("authentik-remember-me-session");
|
||||
this.username = undefined;
|
||||
this.usernameField?.removeEventListener("keyup", this.trackRememberMe);
|
||||
return;
|
||||
}
|
||||
if (!this.usernameField) {
|
||||
return;
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-user", this.usernameField.value);
|
||||
localStorage?.setItem("authentik-remember-me-session", this.localSession);
|
||||
this.usernameField.addEventListener("keyup", this.trackRememberMe);
|
||||
};
|
||||
|
||||
constructor(private host: RememberMeHost) {}
|
||||
|
||||
// Record a stable token that we can use between requests to track if we've
|
||||
// been here before. If we can't, clear out the username.
|
||||
@@ -109,32 +131,6 @@ export class AkRememberMeController implements ReactiveController {
|
||||
}
|
||||
}
|
||||
|
||||
trackRememberMe() {
|
||||
if (!this.usernameField || this.usernameField.value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.username = this.usernameField.value;
|
||||
localStorage?.setItem("authentik-remember-me-user", this.username);
|
||||
}
|
||||
|
||||
// When active, save current details and record every keystroke to the username.
|
||||
// When inactive, clear all fields and remove keystroke recorder.
|
||||
toggleRememberMe() {
|
||||
if (!this.rememberMeToggle || !this.rememberMeToggle.checked) {
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
localStorage?.removeItem("authentik-remember-me-session");
|
||||
this.username = undefined;
|
||||
this.usernameField?.removeEventListener("keyup", this.trackRememberMe);
|
||||
return;
|
||||
}
|
||||
if (!this.usernameField) {
|
||||
return;
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-user", this.usernameField.value);
|
||||
localStorage?.setItem("authentik-remember-me-session", this.localSession);
|
||||
this.usernameField.addEventListener("keyup", this.trackRememberMe);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.isEnabled
|
||||
? html` <label class="pf-c-switch remember-me-switch">
|
||||
@@ -150,3 +146,5 @@ export class AkRememberMeController implements ReactiveController {
|
||||
: nothing;
|
||||
}
|
||||
}
|
||||
|
||||
export default RememberMe;
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
isConditionalMediationAvailable,
|
||||
transformAssertionForServer,
|
||||
transformCredentialRequestOptions,
|
||||
} from "#common/helpers/webauthn";
|
||||
|
||||
import { AKFlowSubmitRequest } from "#flow/events";
|
||||
import type { IdentificationHost } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { IdentificationChallenge } from "@goauthentik/api";
|
||||
|
||||
import { ReactiveController } from "lit";
|
||||
|
||||
type PasskeyChallenge = Omit<IdentificationChallenge, "passkeyChallenge"> & {
|
||||
passkeyChallenge?: PublicKeyCredentialRequestOptions;
|
||||
};
|
||||
|
||||
export class WebauthnController implements ReactiveController {
|
||||
public passkey: PublicKeyCredentialRequestOptions | null = null;
|
||||
|
||||
constructor(private host: IdentificationHost) {}
|
||||
|
||||
#abortController: AbortController | null = null;
|
||||
|
||||
//#endregion
|
||||
|
||||
public hostUpdated() {
|
||||
if (this.passkey !== (this.host.challenge as PasskeyChallenge)?.passkeyChallenge) {
|
||||
this.passkey = (this.host.challenge as PasskeyChallenge)?.passkeyChallenge ?? null;
|
||||
}
|
||||
if (this.passkey) {
|
||||
this.#startConditionalWebAuthn(this.passkey);
|
||||
}
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a conditional WebAuthn request for passkey autofill.
|
||||
* This allows users to select a passkey from the browser's autofill dropdown.
|
||||
*/
|
||||
async #startConditionalWebAuthn(
|
||||
passkeyRequestOptions: PublicKeyCredentialRequestOptions,
|
||||
): Promise<void> {
|
||||
// Check if browser supports conditional mediation
|
||||
const isAvailable = await isConditionalMediationAvailable();
|
||||
if (!isAvailable) {
|
||||
console.debug("authentik/identification: Conditional mediation not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any existing request
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = new AbortController();
|
||||
const { signal } = this.#abortController;
|
||||
|
||||
try {
|
||||
const publicKey = transformCredentialRequestOptions(passkeyRequestOptions);
|
||||
|
||||
// Start the conditional WebAuthn request
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey,
|
||||
mediation: "conditional",
|
||||
signal,
|
||||
})) as PublicKeyCredential | null;
|
||||
|
||||
if (!credential) {
|
||||
console.debug("authentik/identification: No credential returned");
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform and submit the passkey response
|
||||
const passkey = transformAssertionForServer(credential);
|
||||
this.host.dispatchEvent(new AKFlowSubmitRequest({ passkey }, { invisible: true }));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// Request was aborted, this is expected when navigating away
|
||||
console.debug("authentik/identification: Conditional WebAuthn aborted");
|
||||
return;
|
||||
}
|
||||
console.warn("authentik/identification: Conditional WebAuthn failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WebauthnController;
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ChallengeTypes,
|
||||
ConsentChallenge,
|
||||
CurrentBrand,
|
||||
FlowChallengeResponseRequest,
|
||||
PasswordChallenge,
|
||||
SessionEndChallenge,
|
||||
UserLoginChallenge,
|
||||
@@ -58,6 +59,19 @@ export interface SubmitOptions {
|
||||
invisible: boolean;
|
||||
}
|
||||
|
||||
// Make the "component" field optional, since the Executor controls what component type is being
|
||||
// manipulated.
|
||||
type PartialComponent<T> = T extends { component: infer C } & infer Rest
|
||||
? { component?: C } & Omit<Rest, "component">
|
||||
: never;
|
||||
|
||||
export type FlowChallengeResponseRequestBody = PartialComponent<FlowChallengeResponseRequest>;
|
||||
|
||||
export interface SubmitRequest {
|
||||
payload: FlowChallengeResponseRequestBody;
|
||||
options: SubmitOptions;
|
||||
}
|
||||
|
||||
export interface StageHost {
|
||||
challenge?: unknown;
|
||||
flowSlug?: string;
|
||||
@@ -76,6 +90,12 @@ export interface IBaseStage<Tin extends StageChallengeLike, Tout = never>
|
||||
reset?(): void;
|
||||
}
|
||||
|
||||
export interface ExecutorMessage {
|
||||
source?: string;
|
||||
context?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type BaseStageConstructor<
|
||||
Tin extends StageChallengeLike = StageChallengeLike,
|
||||
Tout = never,
|
||||
|
||||
Reference in New Issue
Block a user