Compare commits

...

2 Commits

Author SHA1 Message Date
Teffen Ellis
5050a94f16 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 <agent@authentik-i21647-current-instant-chili.girlbossru.sh>
2026-04-27 14:10:01 +00:00
Teffen Ellis
f5dd1b62ef web: Clear remember me before navigation. 2026-04-27 11:46:38 +02:00
4 changed files with 269 additions and 47 deletions

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

@@ -0,0 +1,42 @@
/**
* @file Storage utilities.
*/
/**
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
*/
export class StorageAccessor {
constructor(
public readonly key: string,
protected readonly storage: Storage,
) {}
public static local = (key: string) => new StorageAccessor(key, localStorage);
public static session = (key: string) => new StorageAccessor(key, sessionStorage);
public read(): string | null {
try {
return this.storage.getItem(this.key);
} catch (_error: unknown) {
return null;
}
}
public write(value: string): boolean {
try {
this.storage.setItem(this.key, value);
return true;
} catch (_error: unknown) {
return false;
}
}
public delete(): boolean {
try {
this.storage.removeItem(this.key);
return true;
} catch (_error: unknown) {
return false;
}
}
}

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

@@ -1,12 +1,24 @@
import { StorageAccessor } from "#common/storage";
import { getCookie } from "#common/utils";
import { SlottedTemplateResult } 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 { css, html, ReactiveController, ReactiveControllerHost } from "lit";
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
export class RememberMeStorage {
static readonly username = StorageAccessor.local("authentik-remember-me-user");
static readonly session = StorageAccessor.local("authentik-remember-me-session");
static reset = () => {
this.username.delete();
this.session.delete();
};
}
/**
* Remember the user's `username` "on this device."
*
@@ -35,31 +47,32 @@ export class RememberMe implements ReactiveController {
`,
];
public username?: string;
public username: string | null = null;
#trackRememberMe = () => {
if (!this.#usernameField || this.#usernameField.value === undefined) {
return;
}
this.username = this.#usernameField.value;
localStorage?.setItem("authentik-remember-me-user", this.username);
RememberMeStorage.username.write(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;
RememberMeStorage.reset();
this.username = null;
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);
RememberMeStorage.username.write(this.#usernameField.value);
RememberMeStorage.session.write(this.#localSession);
this.#usernameField.addEventListener("keyup", this.#trackRememberMe);
};
@@ -68,17 +81,14 @@ export class RememberMe implements ReactiveController {
// 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 sessionID = RememberMeStorage.session.read();
if (this.#localSession && sessionID === this.#localSession) {
this.username = null;
RememberMeStorage.username.delete();
}
RememberMeStorage.session.write(this.#localSession);
}
get #localSession() {
@@ -101,15 +111,15 @@ export class RememberMe implements ReactiveController {
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
}
get #isEnabled() {
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
get enabled(): boolean {
return !!(this.host.challenge?.enableRememberMe && localStorage);
}
get #canAutoSubmit() {
return (
!!this.host.challenge &&
!!this.username &&
!!this.#usernameField?.value &&
get #canAutoSubmit(): boolean {
return !!(
this.host.challenge &&
this.username &&
this.#usernameField?.value &&
!this.host.challenge.passwordFields &&
!this.host.challenge.passwordlessUrl
);
@@ -117,38 +127,35 @@ export class RememberMe implements ReactiveController {
// Before the page is updated, try to extract the username from localstorage.
public hostUpdate() {
if (!this.#isEnabled) {
if (!this.enabled) {
return;
}
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;
}
this.username = RememberMeStorage.username.read();
}
// After the page is updated, if everything is ready to go, do the autosubmit.
public hostUpdated() {
if (this.#isEnabled && this.#canAutoSubmit) {
if (this.enabled && this.#canAutoSubmit) {
this.#submitButton?.click();
}
}
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;
public render(): SlottedTemplateResult {
if (!this.enabled) {
return null;
}
return 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>`;
}
}

View File

@@ -0,0 +1,168 @@
import { expect, test } from "#e2e";
import {
GOOD_PASSWORD,
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 REMEMBER_ME_ADMIN_TOGGLE =
'ak-stage-identification-form ak-switch-input[name="enableRememberMe"] input[type="checkbox"]';
const readStoredUsername = (page: Page) =>
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
const readStoredSession = (page: Page) =>
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_SESSION_KEY);
test.describe('Remember me — "Not you?" clears stored identity (regression #21571)', () => {
test.beforeAll(
'Ensure "Enable Remember me on this device" is on for the default identification stage',
async ({ browser }) => {
test.setTimeout(120_000);
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(SessionFixture.pathname);
await page.getByLabel("Username").fill(GOOD_USERNAME);
if (!(await page.getByLabel("Password").isVisible())) {
await page.locator('button[type="submit"]').click();
await page.getByLabel("Password").waitFor({ state: "visible" });
}
await page.getByLabel("Password").fill(GOOD_PASSWORD);
await page.locator('button[type="submit"]').click();
await page.waitForURL((url) => !url.pathname.startsWith("/if/flow/"), {
timeout: 30_000,
});
await page.goto("/if/admin/#/flow/stages");
const $search = page.locator('input[name="search"][type="search"]');
await $search.waitFor({ state: "visible", timeout: 15_000 });
await $search.fill(IDENTIFICATION_STAGE_NAME);
await $search.press("Enter");
const $row = page.getByRole("row", {
name: new RegExp(IDENTIFICATION_STAGE_NAME),
});
await expect($row, "Identification stage row visible").toBeVisible({
timeout: 15_000,
});
await $row.getByRole("button", { name: "Edit Stage" }).click();
const $dialog = page.locator("dialog:has(ak-stage-identification-form)");
await expect($dialog, "Edit modal opens").toBeVisible({ timeout: 15_000 });
const $toggle = $dialog.locator(REMEMBER_ME_ADMIN_TOGGLE);
await $toggle.waitFor({ state: "attached", timeout: 10_000 });
await $toggle.scrollIntoViewIfNeeded();
const wasChecked = await $toggle.isChecked();
if (!wasChecked) {
await $toggle.check({ force: true });
await expect($toggle).toBeChecked();
}
await $dialog.getByRole("button", { name: "Save Changes" }).click();
await expect($dialog, "Edit modal closes after save").toBeHidden({
timeout: 15_000,
});
} finally {
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('Clicking "Not you?" clears the remembered username before navigation', async ({
session,
page,
}) => {
const $rememberMe = page.locator("#authentik-remember-me");
const $notYouLink = page.getByRole("link", { name: "Not you?" });
await test.step("Remember me checkbox is offered", async () => {
await expect($rememberMe).toBeVisible();
await expect($rememberMe).not.toBeChecked();
});
await test.step("Identify with remember-me enabled", async () => {
await session.$usernameField.fill(GOOD_USERNAME);
await $rememberMe.check();
await expect($rememberMe).toBeChecked();
expect(
await readStoredUsername(page),
"username persists to localStorage when remember-me is checked",
).toBe(GOOD_USERNAME);
await session.$submitButton.click();
await session.$passwordField.waitFor({ state: "visible" });
});
await test.step('"Not you?" link renders cleanly (no stray ">" character)', async () => {
await expect($notYouLink).toBeVisible();
await expect($notYouLink).toHaveText("Not you?");
});
await test.step('Clicking "Not you?" returns to identification with empty fields', async () => {
await $notYouLink.click();
await session.$identificationStage.waitFor({ state: "visible" });
await session.$usernameField.waitFor({ state: "visible" });
await expect(
session.$usernameField,
"username field must not be auto-populated from prior session",
).toHaveValue("");
await expect(
page.locator("#authentik-remember-me"),
"remember-me toggle must be reset",
).not.toBeChecked();
expect(
await readStoredUsername(page),
"remembered username must be cleared from localStorage",
).toBeNull();
// Session token is rewritten by the controller on hostConnected; assert it does not leak the username.
expect(
await readStoredSession(page),
"session token must not retain remembered identity",
).not.toBe(GOOD_USERNAME);
});
await test.step("Identification does not auto-resubmit", async () => {
await expect(
session.$passwordField,
"password field must not appear without explicit submit",
).toBeHidden();
});
});
});