Compare commits

...

2 Commits

Author SHA1 Message Date
dependabot[bot]
6bf228794e ci: bump taiki-e/install-action in /.github/actions/setup
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.22 to 2.75.23.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](cf525cb33f...481c34c1cf)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-30 04:29:59 +00:00
Teffen Ellis
d6c0ae21de web: Clear remember me before navigation. (#21647)
* web: Clear remember me before navigation.

* web: fix stray > in "Not you?" link and add Playwright regression for #21571

Move the closing > of the opening <a> tag so the rendered link text no longer
carries a leading > glyph. Add a browser test that seeds the identification
stage with enable_remember_me, walks the identify -> password -> "Not you?"
path, and asserts the link text, the cleared username field, and the cleared
remember-me localStorage key.
Co-Authored-By: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>

* Flesh out remember me lifecycle. Fix edgecases where it doesn't keep up with the e2e suite.

* Fix for submit events, labels.

---------

Co-authored-by: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>
2026-04-29 23:54:42 +02:00
16 changed files with 676 additions and 200 deletions

View File

@@ -64,7 +64,7 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
uses: taiki-e/install-action@481c34c1cf3a84c68b5e46f4eccfc82af798415a # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)

View File

@@ -77,6 +77,8 @@ export class FormFixture extends PageFixture {
/**
* Search for a row containing the given text.
*
* @returns A locator for the row entry matching the query.
*/
public search = async (
query: string,

View File

@@ -1,6 +1,8 @@
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { PageFixture, PageFixtureInit } from "#e2e/fixtures/PageFixture";
import { expect, Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
@@ -11,6 +13,8 @@ export interface LoginInit {
username?: string;
password?: string;
to?: URL | string;
rememberMe?: boolean;
page?: Page;
}
export interface SessionFixtureInit extends PageFixtureInit {
@@ -36,6 +40,10 @@ export class SessionFixture extends PageFixture {
public $passwordStage = this.page.locator("ak-stage-password");
public $passwordField = this.page.getByLabel("Password");
public $rememberMeCheckbox = this.page.getByRole("checkbox", {
name: "Remember me on this device",
});
/**
* The button to submit the the login flow,
* typically redirecting to the authenticated interface.
@@ -66,19 +74,45 @@ export class SessionFixture extends PageFixture {
/**
* Log into the application.
*/
public async login({
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
}: LoginInit = {}) {
public async login(
{
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
rememberMe,
}: LoginInit = {},
page = this.page,
): Promise<void> {
this.logger.info("Logging in...");
const initialURL = new URL(this.page.url());
const initialURL = new URL(page.url());
if (initialURL.pathname === SessionFixture.pathname) {
this.logger.info("Skipping navigation because we're already in a authentication flow");
} else {
await this.page.goto(to.toString());
await page.goto(to.toString());
}
if (typeof rememberMe === "boolean") {
const rememberMeCheckboxVisible = await this.$rememberMeCheckbox.isVisible();
if (rememberMeCheckboxVisible) {
if (rememberMe) {
await this.$rememberMeCheckbox.check();
await expect(
this.$rememberMeCheckbox,
"Remember me checkbox is checked",
).toBeChecked();
} else {
await this.$rememberMeCheckbox.uncheck();
await expect(
this.$rememberMeCheckbox,
"Remember me checkbox is unchecked",
).not.toBeChecked();
}
}
}
await this.$usernameField.fill(username);
@@ -102,7 +136,7 @@ export class SessionFixture extends PageFixture {
//#region Navigation
public async toLoginPage() {
await this.page.goto(SessionFixture.pathname);
public async toLoginPage(page: Page = this.page) {
await page.goto(SessionFixture.pathname);
}
}

View File

@@ -2,20 +2,30 @@ import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/HorizontalFormElement";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PFSize } from "#common/enums";
import { Form } from "#elements/forms/Form";
import { ifPresent } from "#elements/utils/attributes";
import { FocusTarget } from "#elements/utils/focus";
import { AKLabel } from "#components/ak-label";
import { CoreApi, UserPasswordSetRequest } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-user-password-form")
export class UserPasswordForm extends Form<UserPasswordSetRequest> {
public override submitLabel = msg("Set Password");
public static shadowRootOptions: ShadowRootInit = {
...Form.shadowRootOptions,
delegatesFocus: true,
};
public static override verboseName = msg("Password");
public static override verboseNamePlural = msg("Passwords");
public static override submittingVerb = msg("Setting");
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
@@ -23,6 +33,9 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
//#region Properties
public override submitLabel = msg("Set Password");
public override successMessage = msg("Successfully updated password.");
@property({ type: Number })
public instancePk?: number;
@@ -30,13 +43,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
public label = msg("New Password");
@property({ type: String })
public placeholder = msg("New Password");
public placeholder = msg("Type a new password...");
@property({ type: String })
public username?: string;
@property({ type: String, useDefault: true })
public username: string | null = null;
@property({ type: String })
public email?: string;
@property({ type: String, useDefault: true })
public email: string | null = null;
public override size = PFSize.Medium;
/**
* The autocomplete attribute to use for the password field.
@@ -50,17 +65,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
//#endregion
public override getSuccessMessage(): string {
return msg("Successfully updated password.");
}
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener("focus", this.autofocusTarget.toEventListener());
}
public override firstUpdated(): void {
this.focus();
requestAnimationFrame(() => {
this.focus();
});
}
protected override async send(data: UserPasswordSetRequest): Promise<void> {
@@ -94,17 +107,26 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
/>`
: nothing}
<ak-form-element-horizontal label=${this.label} required name="password">
<ak-form-element-horizontal required name="password">
${AKLabel(
{
slot: "label",
className: "pf-c-form__group-label",
htmlFor: "password",
required: true,
},
this.label,
)}
<input
autofocus
${this.autofocusTarget.toRef()}
id="password"
type="password"
value=""
class="pf-c-form-control"
required
placeholder=${ifDefined(this.placeholder || this.label)}
aria-label=${this.label}
autocomplete=${ifDefined(this.autocomplete)}
placeholder=${ifPresent(this.placeholder || this.label)}
autocomplete=${ifPresent(this.autocomplete)}
/>
</ak-form-element-horizontal>`;
}

140
web/src/common/storage.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* @file Storage utilities.
*/
import { ConsoleLogger } from "#logger/browser";
/**
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
*/
export class StorageAccessor {
constructor(
/**
* The key under which the value is stored in the storage backend.
*/
public readonly key: string,
/**
* The storage backend to use, e.g. `window.localStorage` or `window.sessionStorage`.
*/
protected readonly storage: Storage,
protected logger = ConsoleLogger.prefix("storage-accessor"),
) {
if (typeof key !== "string") {
throw new TypeError("Storage key must be a string");
}
if (!key) {
throw new TypeError("Storage key must be a non-empty string");
}
}
/**
* Create a {@link StorageAccessor} for local storage.
*
* @param key The key under which the value is stored in localStorage.
*/
public static local = (key: string) => new StorageAccessor(key, self.localStorage);
/**
* Create a {@link StorageAccessor} for session storage.
*
* @param key The key under which the value is stored in sessionStorage.
*/
public static session = (key: string) => new StorageAccessor(key, self.sessionStorage);
/**
* Read the value from storage.
*
* @param fallback An optional value to return if the key does not exist or an error occurs. Defaults to `null`.
*
* @returns The stored value, or `null` if the key does not exist or an error occurs.
*/
public read<T extends string>(fallback?: T): T | null {
try {
const value = this.storage.getItem(this.key);
return value !== null ? (value as T) : (fallback ?? null);
} catch (_error: unknown) {
return fallback ?? null;
}
}
/**
* Write a value to storage.
*
* @param value The value to store.
*
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
*/
public write(value: string | null): boolean {
if (!value) {
if (this.read()) {
return this.delete();
}
return true;
}
try {
this.storage.setItem(this.key, value);
return true;
} catch (_error: unknown) {
return false;
}
}
/**
* Read the value from storage and parse it as JSON.
*
* @param fallback An optional value to return if the key does not exist, the value is not valid JSON, or an error occurs. Defaults to `null`.
*
* @returns The parsed value, or `null` if the key does not exist, the value is not valid JSON, or an error occurs.
*/
public readJSON<T>(fallback?: T): T | null {
const value = this.read<string>();
if (value === null) {
return fallback ?? null;
}
try {
return JSON.parse(value) as T;
} catch (_error: unknown) {
return fallback ?? null;
}
}
/**
* Write a value to storage after stringifying it as JSON.
*
* @param value The value to store.
*
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
*/
public writeJSON(value: unknown): boolean {
try {
const stringified = JSON.stringify(value);
return this.write(stringified);
} catch (error: unknown) {
this.logger.error("Failed to write JSON value to storage", error);
return false;
}
}
/**
* Delete the value from storage.
*
* @returns `true` if the value was successfully deleted, or `false` if an error occurred.
*/
public delete(): boolean {
this.logger.debug("Deleting value from storage");
try {
this.storage.removeItem(this.key);
return true;
} catch (error: unknown) {
this.logger.error("Failed to delete value from storage", error);
return false;
}
}
}

View File

@@ -207,6 +207,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement))
<a
href="${globalAK().api.base}flows/-/default/invalidation/"
class="pf-c-button pf-m-plain"
aria-label=${msg("Sign out")}
>
<pf-tooltip position="top" content=${msg("Sign out")}>
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>

View File

@@ -414,6 +414,13 @@ export class Form<T = Record<string, unknown>, D = T>
const { submittingVerb, verboseName } = this.constructor as typeof Form;
if (!verboseName) {
return msg(str`${submittingVerb}...`, {
id: "form.submitting.no-entity",
desc: "The message shown while a form is being submitted, when no entity name is provided.",
});
}
return msg(str`${submittingVerb} ${verboseName}...`, {
id: "form.submitting",
desc: "The message shown while a form is being submitted.",
@@ -615,6 +622,7 @@ export class Form<T = Record<string, unknown>, D = T>
protected doSubmit = (event: SubmitEvent): void => {
if (this.submitting) {
this.logger.info("Skipping submit. Already submitting!");
return;
}
this.submitting = true;

View File

@@ -4,6 +4,44 @@
import { createRef, ref, Ref } from "lit/directives/ref.js";
export interface FocusErrorOptions extends ErrorOptions {
target: Element | null;
}
export class FocusAssertionError extends Error {
public override name = "FocusAssertionError";
public readonly target: Element | null;
constructor(message: string, { target, ...options }: FocusErrorOptions) {
super(message, options);
this.target = target;
}
}
export function assertFocusable(target: Element | null | undefined): asserts target is HTMLElement {
if (!target) {
throw new FocusAssertionError("Skipping focus, no target", { target: null });
}
if (!(target instanceof HTMLElement)) {
throw new FocusAssertionError("Skipping focus, target is not an HTMLElement", { target });
}
if (document.activeElement === target) {
throw new FocusAssertionError("Target is already focused", { target });
}
// Despite our type definitions, this method isn't available in all browsers,
// so we fallback to assuming the element is visible.
const visible = target.checkVisibility?.() ?? true;
if (!visible) {
throw new FocusAssertionError("Skipping focus, target is not visible", { target });
}
if (typeof target.focus !== "function") {
throw new FocusAssertionError("Skipping focus, target has no focus method", { target });
}
}
/**
* Recursively check if the target element or any of its children are active (i.e. "focused").
*
@@ -36,35 +74,17 @@ export function isActiveElement(
* @category DOM
*/
export function isFocusable(target: Element | null | undefined): target is HTMLElement {
if (!target) {
console.debug("FocusTarget: Skipping focus, no target", target);
try {
assertFocusable(target);
return true;
} catch (error) {
if (error instanceof FocusAssertionError) {
console.debug(error.message, error.target);
} else {
console.error("Unexpected error during focus assertion", error);
}
return false;
}
if (!(target instanceof HTMLElement)) {
console.debug("FocusTarget: Skipping focus, target is not an HTMLElement", target);
return false;
}
if (document.activeElement === target) {
console.debug("FocusTarget: Target is already focused", target);
return false;
}
// Despite our type definitions, this method isn't available in all browsers,
// so we fallback to assuming the element is visible.
const visible = target.checkVisibility?.() ?? true;
if (!visible) {
console.debug("FocusTarget: Skipping focus, target is not visible", target);
return false;
}
if (typeof target.focus !== "function") {
console.debug("FocusTarget: Skipping focus, target has no focus method", target);
return false;
}
return true;
}
/**

View File

@@ -4,6 +4,7 @@ import { ifPresent } from "#elements/utils/attributes";
import { isDefaultAvatar } from "#elements/utils/images";
import Styles from "#flow/FormStatic.css";
import { RememberMeStorage } from "#flow/stages/identification/controllers/RememberMeController";
import { StageChallengeLike } from "#flow/types";
import { msg, str } from "@lit/localize";
@@ -69,7 +70,9 @@ export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
${flowInfo?.cancelUrl
? html`
<div slot="link">
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
<a href=${flowInfo.cancelUrl} @click=${RememberMeStorage.reset}
>${msg("Not you?")}</a
>
</div>
`
: nothing}

View File

@@ -121,9 +121,10 @@ export class InputPassword extends AKElement {
//#region Refs
inputRef: Ref<HTMLInputElement> = createRef();
@property({ attribute: false, useDefault: true })
public inputRef: Ref<HTMLInputElement> = createRef();
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
public toggleVisibilityRef = createRef<HTMLButtonElement>();
//#endregion

View File

@@ -55,7 +55,7 @@ export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
@intersectionObserver()
public visible = false;
protected autofocusTarget = new FocusTarget();
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
focus = this.autofocusTarget.focus;
#visibilityListener = () => {

View File

@@ -12,7 +12,7 @@ import { AKLabel } from "#components/ak-label";
import { BaseStage } from "#flow/stages/base";
import AutoRedirect from "#flow/stages/identification/controllers/AutoRedirectController";
import CaptchaDisplayController from "#flow/stages/identification/controllers/CaptchaDisplayController";
import RememberMe from "#flow/stages/identification/controllers/RememberMeController";
import RememberMeController from "#flow/stages/identification/controllers/RememberMeController";
import WebauthnController from "#flow/stages/identification/controllers/WebauthnController";
import Styles from "#flow/stages/identification/styles.css";
@@ -30,6 +30,7 @@ import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { html, nothing, PropertyValues, ReactiveControllerHost } from "lit";
import { createRef, ref } from "lit-html/directives/ref.js";
import { customElement, property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
@@ -45,8 +46,6 @@ type IdentificationFooter = Partial<Pick<IdentificationChallenge, "enrollUrl" |
export type IdentificationHost = IdentificationStage & ReactiveControllerHost;
type EmptyString = string | null | undefined;
export const PasswordManagerPrefill: {
password?: string;
totp?: string;
@@ -82,21 +81,26 @@ export class IdentificationStage extends BaseStage<
PFFormControl,
PFTitle,
PFButton,
...RememberMe.styles,
...RememberMeController.styles,
Styles,
];
/**
* The ID of the input field.
* The ID of the identifier input field, used for accessibility and focus management.
*
* @attr
*/
@property({ type: String, attribute: "input-id" })
public inputID = "ak-identifier-input";
protected passwordFieldRef = createRef<HTMLInputElement>();
#form?: HTMLFormElement;
private rememberMe = new RememberMe(this);
public defaultUserIdentification: string | null = null;
protected rememberMeController: RememberMeController | null = null;
#autoRedirect = new AutoRedirect(this);
#captcha = new CaptchaDisplayController(this);
#webauthn = new WebauthnController(this);
@@ -109,15 +113,23 @@ export class IdentificationStage extends BaseStage<
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);
}
#prepareRememberMeFrame = -1;
public override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has("challenge") && this.challenge) {
cancelAnimationFrame(this.#prepareRememberMeFrame);
this.#prepareRememberMeFrame = requestAnimationFrame(() => {
this.prepareRememberMeController();
});
this.#createHelperForm();
}
}
@@ -127,10 +139,46 @@ export class IdentificationStage extends BaseStage<
this.addEventListener("focus", this.autofocusTarget.toEventListener());
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
cancelAnimationFrame(this.#prepareRememberMeFrame);
}
public override firstUpdated(): void {
this.focus();
}
protected prepareRememberMeController(): void {
if (!this.challenge) return;
const { enableRememberMe, pendingUserIdentifier = null } = this.challenge;
if (!enableRememberMe) {
this.defaultUserIdentification = pendingUserIdentifier;
if (this.rememberMeController) {
this.removeController(this.rememberMeController);
this.rememberMeController = null;
}
return;
}
if (!this.rememberMeController) {
this.rememberMeController = new RememberMeController(this, {
identificationFieldID: this.inputID,
identificationFieldRef: this.autofocusTarget.reference,
passwordFieldRef: this.passwordFieldRef,
pendingUserIdentifier,
});
this.addController(this.rememberMeController);
}
this.defaultUserIdentification = this.rememberMeController.defaultUserIdentification;
}
//#endregion
//#region Helper Form
@@ -247,11 +295,11 @@ export class IdentificationStage extends BaseStage<
id: string,
type: string,
label: string,
username: EmptyString,
initialUserIdentification: string | null,
autocomplete: string,
) {
return html`<input
${this.autofocusTarget.toRef()}
${ref(this.autofocusTarget.reference)}
id=${id}
type=${type}
name="uidField"
@@ -260,56 +308,57 @@ export class IdentificationStage extends BaseStage<
autocomplete=${autocomplete}
spellcheck="false"
class="pf-c-form-control"
value=${username ?? ""}
value=${initialUserIdentification ?? ""}
required
/>`;
}
protected renderPasswordFields(challenge: IdentificationChallenge) {
const { allowShowPassword } = challenge;
return html`
<ak-flow-input-password
label=${msg("Password")}
input-id="ak-stage-identification-password"
class="pf-c-form__group"
.errors=${challenge.responseErrors?.password}
?allow-show-password=${allowShowPassword}
prefill=${PasswordManagerPrefill.password ?? ""}
></ak-flow-input-password>
`;
return html`<ak-flow-input-password
.inputRef=${this.passwordFieldRef}
label=${msg("Password")}
input-id="ak-stage-identification-password"
class="pf-c-form__group"
.errors=${challenge.responseErrors?.password}
?allow-show-password=${allowShowPassword}
prefill=${PasswordManagerPrefill.password ?? ""}
></ak-flow-input-password> `;
}
protected renderInput(challenge: IdentificationChallenge) {
const {
flowDesignation,
passwordFields,
passwordlessUrl,
pendingUserIdentifier,
primaryAction,
userFields,
} = challenge;
const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } =
challenge;
const fields = (userFields || []).sort();
if (fields.length === 0) {
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
}
const { inputID, rememberMe } = this;
const {
inputID,
defaultUserIdentification: initialUserIdentification,
rememberMeController,
} = 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;
// When webauthn is enabled, add "webauthn" to autocomplete to enable passkey autofill
const autocomplete: AutoFill = this.#webauthn.live ? "username webauthn" : "username";
console.debug(
"Rendering identification stage with fields:",
fields,
initialUserIdentification,
);
// prettier-ignore
return html`${offerRecovery ? this.renderRecoveryMessage() : nothing}
<div class="pf-c-form__group">
${AKLabel({ required: true, htmlFor: inputID }, label)}
${this.renderUidField(inputID, type, label, username, autocomplete)}
${rememberMe.render()}
${this.renderUidField(inputID, type, label, initialUserIdentification, autocomplete)}
${rememberMeController?.renderToggleInput() ?? null}
${AKFormErrors({ errors: challenge.responseErrors?.uid_field })}
</div>
${passwordFields ? this.renderPasswordFields(challenge) : nothing}

View File

@@ -1,11 +1,35 @@
import { StorageAccessor } from "#common/storage";
import { getCookie } from "#common/utils";
import { ReactiveElementHost } from "#elements/types";
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
import { msg } from "@lit/localize";
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
import { ConsoleLogger } from "#logger/browser";
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
import { msg } from "@lit/localize";
import { css, html, ReactiveController } from "lit";
import { createRef, Ref } from "lit-html/directives/ref.js";
export class RememberMeStorage {
static readonly user = StorageAccessor.local("authentik-remember-me-user");
static readonly session = StorageAccessor.local("authentik-remember-me-session");
static reset = () => {
this.user.delete();
this.session.delete();
};
}
function readSessionID() {
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
}
export interface RememberMeControllerInit {
pendingUserIdentifier: string | null;
identificationFieldRef: Ref<HTMLInputElement>;
passwordFieldRef: Ref<HTMLInputElement> | null;
identificationFieldID: string;
}
/**
* Remember the user's `username` "on this device."
@@ -24,7 +48,7 @@ type RememberMeHost = ReactiveControllerHost & IdentificationStage;
* came back to this view after reaching the identity proof phase, indicating they pressed the "not
* you?" link, at which point it begins again to record the username as it is typed in.
*/
export class RememberMe implements ReactiveController {
export class RememberMeController implements ReactiveController {
static readonly styles = [
css`
.remember-me-switch {
@@ -35,121 +59,178 @@ export class RememberMe implements ReactiveController {
`,
];
public username?: string;
//#region Lifecycle
#trackRememberMe = () => {
if (!this.#usernameField || this.#usernameField.value === undefined) {
return;
}
this.username = this.#usernameField.value;
localStorage?.setItem("authentik-remember-me-user", this.username);
};
public readonly identificationFieldRef: Ref<HTMLInputElement>;
public readonly passwordFieldRef: Ref<HTMLInputElement> | null;
public readonly defaultChecked: boolean;
public readonly defaultUserIdentification: string | null;
public readonly identificationFieldID: string;
// 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);
};
protected logger = ConsoleLogger.prefix("controller/remember-me");
protected autoSubmitAttempts = 0;
protected currentSessionID = readSessionID();
constructor(private host: RememberMeHost) {}
constructor(
protected host: ReactiveElementHost<IdentificationStage>,
{
identificationFieldRef,
passwordFieldRef,
identificationFieldID,
}: RememberMeControllerInit,
) {
this.identificationFieldRef = identificationFieldRef;
this.passwordFieldRef = passwordFieldRef || null;
this.identificationFieldID = identificationFieldID;
// 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.
public hostConnected() {
try {
const sessionId = localStorage.getItem("authentik-remember-me-session");
if (!!this.#localSession && sessionId === this.#localSession) {
this.username = undefined;
localStorage?.removeItem("authentik-remember-me-user");
}
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
}
const persistedSessionID = RememberMeStorage.session.read();
get #localSession() {
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
}
get #usernameField() {
return this.host.renderRoot.querySelector(
'input[name="uidField"]',
) as HTMLInputElement | null;
}
get #rememberMeToggle() {
return this.host.renderRoot.querySelector(
"#authentik-remember-me",
) as HTMLInputElement | null;
}
get #submitButton() {
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
}
get #isEnabled() {
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
}
get #canAutoSubmit() {
return (
!!this.host.challenge &&
!!this.username &&
!!this.#usernameField?.value &&
!this.host.challenge.passwordFields &&
!this.host.challenge.passwordlessUrl
);
}
// Before the page is updated, try to extract the username from localstorage.
public hostUpdate() {
if (!this.#isEnabled) {
return;
if (persistedSessionID && persistedSessionID !== this.currentSessionID) {
this.logger.debug("Session ID mismatch, clearing remembered username");
RememberMeStorage.user.delete();
}
try {
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
const persistedUserIdentifier = RememberMeStorage.user.read();
this.defaultUserIdentification =
persistedUserIdentifier || this.host.challenge?.pendingUserIdentifier || null;
this.defaultChecked = !!persistedUserIdentifier;
}
// After the page is updated, if everything is ready to go, do the autosubmit.
public hostUpdated() {
if (this.#isEnabled && this.#canAutoSubmit) {
this.#submitButton?.click();
if (this.canAutoSubmit() && this.autoSubmitAttempts === 0) {
this.autoSubmitAttempts++;
this.host.submitForm?.();
}
}
public render() {
return this.#isEnabled
? html` <label class="pf-c-switch remember-me-switch">
<input
class="pf-c-switch__input"
id="authentik-remember-me"
@click=${this.#toggleRememberMe}
type="checkbox"
?checked=${!!this.username}
/>
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
</label>`
: nothing;
//#region Event Listeners
#writeFrameID = -1;
public inputListener = (event: InputEvent) => {
cancelAnimationFrame(this.#writeFrameID);
const { value } = event.target as HTMLInputElement;
this.#writeFrameID = requestAnimationFrame(() => {
RememberMeStorage.user.write(value);
});
};
//#endregion
//#region Public API
/**
* Toggle the "remember me" feature on or off.
*
* When toggled on, the current username is saved to localStorage and will be automatically
* submitted on future visits. Additionally, every keystroke in the username field will update
* the stored username.
*
* When toggled off, any stored username is cleared from localStorage, and the keystroke listener
* is removed to stop updating the stored username.
*/
public toggleChangeListener = (event: Event) => {
const checkbox = event.target as HTMLInputElement;
const { usernameField, passwordField } = this;
if (!checkbox.checked) {
this.logger.debug("Disabling remember me");
RememberMeStorage.reset();
if (usernameField) {
usernameField.removeEventListener("input", this.inputListener);
usernameField.focus();
usernameField.select();
}
return;
}
if (!usernameField) {
this.logger.warn("Cannot enable remember me: no username field found");
return;
}
const focusTarget = passwordField && usernameField?.value ? passwordField : usernameField;
if (focusTarget) {
focusTarget.focus();
focusTarget.select();
}
this.logger.debug("Enabling remember me for user");
RememberMeStorage.user.write(usernameField.value);
RememberMeStorage.session.write(this.currentSessionID);
usernameField.addEventListener("input", this.inputListener, {
passive: true,
});
};
/**
* Determines if the "remember me" feature can be automatically submitted, which requires:
*
* - An active challenge.
* - A stored username from a previous session.
* - The identifier input field to be present in the DOM.
* - No password fields or passwordless URL, indicating we can skip directly to the next step.
*/
public canAutoSubmit(): boolean {
const { challenge } = this.host;
if (!challenge) return false;
if (!challenge.enableRememberMe) return false;
if (challenge.passwordFields) return false;
if (challenge.passwordlessUrl) return false;
if (!this.defaultChecked) return false;
return !!this.usernameField?.value;
}
//#endregion
//#region Rendering
protected readonly checkboxRef = createRef<HTMLInputElement>();
protected get usernameField() {
return this.identificationFieldRef.value || null;
}
protected get passwordField() {
return this.passwordFieldRef?.value || null;
}
protected get checkboxToggle() {
return this.checkboxRef.value || null;
}
public renderToggleInput = () => {
return html`<label
class="pf-c-switch remember-me-switch"
for="authentik-remember-me"
aria-description=${msg(
"When enabled, your username will be remembered on this device for future logins.",
)}
>
<input
class="pf-c-switch__input"
type="checkbox"
id="authentik-remember-me"
@change=${this.toggleChangeListener}
?checked=${this.defaultChecked}
/>
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
</label>`;
};
//#endregion
}
export default RememberMe;
export default RememberMeController;

View File

@@ -179,7 +179,7 @@ test.describe("Groups", () => {
});
});
test("Edit group from view page", async ({ navigator, form, pointer, page }, testInfo) => {
test("Edit group from view page", async ({ form, pointer, page }, testInfo) => {
const groupName = groupNames.get(testInfo.testId)!;
const { fill, search } = form;

View File

@@ -17,11 +17,7 @@ test.describe("Provider Wizard", () => {
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/core/providers",
});
});
await test.step("Authenticate", async () => session.login());
await test.step("Navigate to provider wizard", async () => {
await expect(dialog, "Dialog is initially closed").toBeHidden();

View File

@@ -0,0 +1,119 @@
import { expect, test } from "#e2e";
import { FormFixture } from "#e2e/fixtures/FormFixture";
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { GOOD_USERNAME, SessionFixture } from "#e2e/fixtures/SessionFixture";
import type { Page } from "@playwright/test";
const REMEMBER_ME_USER_KEY = "authentik-remember-me-user";
const REMEMBER_ME_SESSION_KEY = "authentik-remember-me-session";
const IDENTIFICATION_STAGE_NAME = "default-authentication-identification";
const readStoredUserIdentifier = (page: Page) =>
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
test.describe("Session Lifecycle", () => {
test.beforeAll(
'Ensure "Enable Remember me on this device" is on for the default identification stage',
async ({ browser }, { title: testName }) => {
if (Date.now()) return;
const context = await browser.newContext();
const page = await context.newPage();
const navigator = new NavigatorFixture(page, testName);
const form = new FormFixture(page, testName);
const session = new SessionFixture({ page, testName, navigator });
await test.step("Authenticate", async () =>
session.login({
to: "/if/admin/#/flow/stages",
page,
}));
const $stage = await test.step("Find stage via search", () =>
form.search(IDENTIFICATION_STAGE_NAME, page));
await $stage.getByRole("button", { name: "Edit Stage" }).click();
const dialog = page.getByRole("dialog", { name: "Edit Identification Stage" });
await expect(dialog, "Edit modal opens after clicking edit").toBeVisible();
await form.setInputCheck(`Enable "Remember me on this device"`, true, dialog);
await dialog.getByRole("button", { name: "Save Changes" }).click();
await expect(dialog, "Edit modal closes after save").toBeHidden();
await context.close();
},
);
test.beforeEach(async ({ session, page }) => {
await session.toLoginPage();
await page.evaluate(
([userKey, sessionKey]) => {
localStorage.removeItem(userKey);
localStorage.removeItem(sessionKey);
},
[REMEMBER_ME_USER_KEY, REMEMBER_ME_SESSION_KEY],
);
await page.reload();
await session.$identificationStage.waitFor({ state: "visible" });
});
test("Remember me persists username", async ({ navigator, session, page }) => {
await test.step("Verify identification stage", async () => {
await expect(
session.$rememberMeCheckbox,
"Remember me checkbox is visible",
).toBeVisible();
await expect(
session.$rememberMeCheckbox,
"Remember me checkbox is not checked by default",
).not.toBeChecked();
});
await test.step("Identify with remember-me enabled", async () => {
await session.login(
{
rememberMe: true,
to: "if/user/#/library",
},
page,
);
const storedUserIdentifier = await readStoredUserIdentifier(page);
expect(
storedUserIdentifier,
"username persists to localStorage when remember-me is checked",
).toBe(GOOD_USERNAME);
});
await test.step("Sign out and verify username is remembered", async () => {
const signOutLink = page.getByRole("link", { name: "Sign out" });
await expect(signOutLink, "Sign out link is visible").toBeVisible();
await signOutLink.click();
await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F");
const notYouLink = page.getByRole("link", { name: "Not you?" });
await expect(notYouLink, "Not you? link is visible after sign out").toBeVisible();
await notYouLink.click();
await expect(
session.$identificationStage,
"Identification stage is visible after clicking not you link",
).toBeVisible();
const storedUserIdentifier = await readStoredUserIdentifier(page);
expect(storedUserIdentifier, "Removed after clicking not you link").toBeNull();
});
});
});