Compare commits

...

8 Commits

Author SHA1 Message Date
Ken Sternberg
4665146dbb Initial check-it: light() works!!!! 2026-02-27 14:45:07 -08:00
Ken Sternberg
80e0e41e55 Prettier has 🚽 opinions sometimes. 2026-02-25 17:43:14 -08:00
Ken Sternberg
dee3a88646 Webauthn hooked up. 2026-02-25 17:33:21 -08:00
Ken Sternberg
f98e2ee1e5 Add webauthn; modernize RememberMe 2026-02-25 16:40:35 -08:00
Ken Sternberg
5bf50a92dc Captcha Controller is in. Autoredirect is in and passing. 2026-02-25 16:14:46 -08:00
Ken Sternberg
46240c25a6 Added the Flow Executor event handling; stages can now send events to trigger challenge updates or submissions, rather than Demeter violations. 2026-02-25 15:35:19 -08:00
Ken Sternberg
cb0db4ea70 Reconstruction the separation-of-concerns build using the newest version of 'main', since the merge was getting weird. 2026-02-25 15:06:17 -08:00
Ken Sternberg
5b50620238 Another attempt. 2026-02-25 13:34:49 -08:00
9 changed files with 492 additions and 255 deletions

View 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);

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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"
>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,