Compare commits

..

4 Commits

Author SHA1 Message Date
Marcelo Elizeche Landó
264ad31f50 refactor for bandit alerts 2026-04-30 02:39:29 -03:00
Marcelo Elizeche Landó
cced288ddb Add polling to shutdown.wait in lifecycle/worker_process.py 2026-04-30 01:35:06 -03:00
Marcelo Elizeche Landó
4aa323bc20 Add debug-attach to Makefile implementing Python 3.14 remote debugging interface 2026-04-30 00:12:01 -03: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
25 changed files with 891 additions and 663 deletions

View File

@@ -118,6 +118,9 @@ run-worker: ## Run the main authentik worker process
run-worker-watch: ## Run the authentik worker, with auto reloading
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
debug-attach: ## Attach pdb to a running authentik Python worker (PEP 768). PID=<pid> to pick; SUDO=1 on macOS.
$(UV) run python scripts/debug_attach.py
core-i18n-extract:
$(UV) run ak makemessages \
--add-location file \

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import faulthandler
import os
import random
import signal
@@ -76,6 +77,12 @@ def main(worker_id: int, socket_path: str):
signal.signal(signal.SIGINT, immediate_shutdown)
signal.signal(signal.SIGQUIT, immediate_shutdown)
signal.signal(signal.SIGTERM, graceful_shutdown)
# SIGUSR1 dumps every thread's traceback to stderr. Without this, the default
# action is "terminate", which kills the worker (and trips the Rust supervisor).
# Side-benefit: signal delivery wakes the eval loop, so `pdb -p` can attach to
# an otherwise-idle worker parked in a C-level syscall.
faulthandler.enable()
faulthandler.register(signal.SIGUSR1)
random.seed()
@@ -97,7 +104,11 @@ def main(worker_id: int, socket_path: str):
# Notify rust process that we are ready
os.kill(os.getppid(), signal.SIGUSR2)
shutdown.wait()
# Poll instead of waiting indefinitely so the main thread's eval loop ticks
# periodically — PEP 768's debugger pending hook is serviced on the main
# thread, and a permanent Event.wait() never returns to bytecode execution.
while not shutdown.wait(timeout=1.0):
pass
logger.info("Shutting down worker...")

91
scripts/debug_attach.py Normal file
View File

@@ -0,0 +1,91 @@
"""Attach pdb to a running authentik Python worker via PEP 768."""
import os
import shutil
import subprocess # nosec B404 — needed to launch ps and pdb
import sys
PS_BIN = shutil.which("ps") or "/bin/ps"
def list_python_procs() -> list[tuple[int, int, str]]:
# argv is fully controlled, no shell, ps path resolved at startup.
out = subprocess.check_output([PS_BIN, "-eo", "pid,ppid,command"], text=True) # nosec B603
procs: list[tuple[int, int, str]] = []
for line in out.splitlines()[1:]:
try:
pid_s, ppid_s, cmd = line.split(None, 2)
except ValueError:
continue
if not pid_s.isdigit() or not ppid_s.isdigit():
continue
procs.append((int(pid_s), int(ppid_s), cmd))
return procs
def find_targets() -> list[tuple[int, str]]:
# Match any authentik Python process: dev_server / runserver via manage.py,
# gunicorn, dramatiq, or the worker_process supervisor. Go/Rust supervisors
# and the `uv run` / shell wrappers don't match these patterns.
needles = (
"manage.py",
"manage dev_server",
"manage runserver",
"gunicorn",
"dramatiq",
"lifecycle.worker_process",
"lifecycle/worker_process",
)
matches = [(p, pp, c) for p, pp, c in list_python_procs() if any(n in c for n in needles)]
matched_pids = {p for p, _, _ in matches}
parents_of_matches = {pp for _, pp, _ in matches if pp in matched_pids}
# A leaf is a match that isn't itself the parent of another match — this
# picks the dev_server reloader child or the gunicorn worker, and still
# includes single-process workers (which trivially have no child match).
leaves = [(p, c) for p, _, c in matches if p not in parents_of_matches]
return leaves
def attach(pid: int) -> int:
use_sudo = os.environ.get("SUDO") == "1"
cmd = [sys.executable, "-m", "pdb", "-p", str(pid)]
if use_sudo:
cmd = ["sudo", "-E", *cmd]
print(f"attaching pdb to pid {pid} (Ctrl-D or `quit` to detach)", file=sys.stderr)
# cmd is built from sys.executable plus a digits-only PID.
rc = subprocess.call(cmd) # nosec B603
if rc != 0 and not use_sudo and sys.platform == "darwin":
print(
"\nattach failed. On macOS task_for_pid is restricted; "
f"retry with: SUDO=1 make debug-attach PID={pid}",
file=sys.stderr,
)
return rc
def main() -> int:
env_pid = os.environ.get("PID")
if env_pid:
if not env_pid.isdigit():
print(f"PID={env_pid!r} is not numeric", file=sys.stderr)
return 2
return attach(int(env_pid))
targets = find_targets()
if not targets:
print(
"no gunicorn/dramatiq Python workers found — is `make run-server` "
"or `make run-worker` running?",
file=sys.stderr,
)
return 1
if len(targets) > 1:
print("multiple worker candidates — pick one with PID=<pid>:", file=sys.stderr)
for pid, cmd in targets:
print(f" {pid}\t{cmd[:120]}", file=sys.stderr)
return 1
return attach(targets[0][0])
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,303 +1,112 @@
# The authentik WebUI
The authetik WebUI is the default UI for the authentik Single Sign-on (SSO) server. It consists of
three primary applications:
- Flow: The transaction-driven, customizable interface for logging in and all other workflow
activities
- User: The user's library of applications to which they have access, and user settings such as
configuring their MFA and email address, viewing their current sessions, and more
- Admin: The system administration tool for defining applications, providers, policies, and
everything else
Each of these is a [thin client] application around data objects provided by and transactions
available with the authentik SSO server. Business logic and validation is provided by the server.
The authentik SSO server is written in Python and Django.
> - [thin client](https://en.wikipedia.org/wiki/Thin_client): In this case, we mean "a front end to
> show the data where the server does all the heavy lifting."
## Project setup
If you have cloned the authentik repository, the [developer docs] are where you go to perform the
initial set-up and install. This sequence will get you up and running in the usual course of events.
```
$ make install
$ make gen-dev-config
$ make migrate
$ make run-server
$ make run-worker
```
We recommend that you run `run-server` and `run-worker` in different terminals or different sessions
under tmux or screen or a similar terminal multiplexer.
The WebUI runs in this folder (`./web` under the project root). You can put the WebUI into hot
reload by running, from this folder,
```
$ npm run watch
```
## Front-End Architecture
### The Django side
The authentik web-based applications are delivered via a Django server. The server delivers an HTML
template that executes a sequence of startup operations:
- Assigns the language code and `data-theme="light|dark"` settings to the `html` tag. Assigns the
favicon links. Creates a `window.authentik` object and assigns a variety of site-wide
configuration details to it. Probes the a priority decision list of sources of the user's
light/dark preferences and assigns that to the `Document.dataset`
- Begins loading the interface root bundle. The script is of `type="module"`; it will not be
executed until the initial HTML has been completely parsed.
- Loads the site-wide standard CSS.
- Injects any CSS overrides specified in the customer's `brand` settings
- Loads any necessary JavaScript polyfills
- Dispatches any initial messages to the notification handler
- Sets any custom `<meta>` settings specified by the server
- Provides the initial HTML scaffolding to launch an interface.
The interface code is mostly the core web component and its responsibilities. This code will be
hydrated when the interface root bundle in the second step above is executed and the components are
registered with the browser.
### The Flow interface
The Flow interface has three subsystems:
- The Locale Selector: `<ak-locale-select>`
- The Flow Inspector: `<ak-flow-inspector>`
- The Flow Executor: `<ak-flow-executor>`
The Flow interface is a single-page application. The Locale Selector and the Inspector are
independent buttons that persist on the web page for the duration of a Flow. Either can be disabled
and hidden by admin preference. The Locale Selector allows the user to select an alternative locale
in which to display text and labels. The [Flow
Inspector](https://docs.goauthentik.io/add-secure-apps/flows-stages/flow/inspector/), when enabled,
can query the server and then display details about the state of a flow: the accumulated context of
the current flow, existing error messages, and expected next steps; it is present to assist with
debugging.
The Executor is the heart of the system. It executes Flows.
A _Flow_ in authentik is the workflow that accomplishes a specific SSO-oriented task such as logging
in, logging out, or enrolling as a new user, among others.
The Executor starts by examining the current URL for the `flowSlug`, and sends a request for a
_Challenge_ to the server. Upon the response, the Executor loads the corresponding _Stage_: the UI
component responsible for showing the challenge to the user. When the user performs the requested
action the input is sent to the server, which issues a new Challenge. This Challenge may be the same
one with error messages, or the next one in the workflow. This process repeats until the user
reaches the end of the Flow, at which point the task is complete or failed.
The architecture for the Executor is straightforward:
- The HTML Document
- The Locale Selector
- The Inspector
- The Executor
- The current Stage
A Stage may have interior stages or components. The Identification Stage is the most complex of our
stages. It usually shows the Username field, and in some configurations it _can_ show the Password
field; in that case, the password component exists to allow the user to "show password". It may also
host the Captcha and Passkey stages within, to complete the initial task of determining and
validating a user's identity.
### User and Admin Interfaces
The architecture of these interfaces is more complex. In both cases, the user is assumed to have
logged in and so is said to have a _Session_. The architecture is structured:
- The HTML Document
- The Interface
- License: a context handler for the site's enterprise license status
- Session: a context handler for the user's current session. This mostly the `user` identity
- Version: a context handler for the current version of authentik
- Notifications: a context handler for outstanding messages sent from the server to the user
- Capabilities: a list of features that the current user may use. List includes "can save
reports," "can use debugging feature," "can use enterprise features."
- The Application:
- Header
- Sidebar
- Router
- CRUD interfaces to features of the system:
- Dashboard
- Logs
- Configurations
- Flows, Stages & Policies
- Users & Group
- IDP Sources
- Everything else!
### Miscellaneous interfaces
There are three miscellaneous interfaces:
#### API browser
A single page application that loads our schema and allows the user to experiment with it. Uses the
[RapiDoc](https://rapidocweb.com/) app.
#### Loading
The Django application is wrapped in a proxy server for caching and performance; while it is in
start-up mode, the proxy serves this page, which just says "The application is loading" with a
pretty animation.
#### SFE: Simplified Flow Executor
The SFE is a limited version of the Flow Executor written to use [jQuery](https://jquery.com/). It
supports only login operations, and is meant for places where the login is embedded in an Office365
or MicrosoftTeams settings, as those use Trident (Internet Explorer) for their web-based login.
## Front-end foundations
### CSS
Our current CSS is provided by [Patternfly 4](https://v4-archive.patternfly.org/v4/). There are two
different layers of CSS.
The first is the Global CSS that appears in the `<head>`. This defines the basic look: theme,
start-up, reset, and fonts. It also provides the [CSS Custom
Properties](https://docs.goauthentik.io/brands/custom-css/) that control the look and feel of the
rest of an Interface.
The second is per-component CSS. This is linked into each component using [Adopted
Stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets).
> This recipe has led to some significant awkwardness. Information from the outside does not pierce
> the shadowDOM, so Patternfly-Base is linked into every component just to provide the box model,
> reset, basic functionality, and behavioral modifiers. The interior of our components is cluttered
> with lots of patternfly classes.
### Elements
Elements are custom web components that authentik has written that supply advanced behaviors to
common operations, as well as API-independent complex components such as rich drop-downs, dual-pane
selectors, toggles, switches, and wizards. At least, that's the idea. We are still untangling.
### Components
Components are custom web components that authentik has written that are API-aware and that supply
business logic to perform validation, permissioning, and selective display.
## Adding a new feature (developer's guide)
As a thin client, the primary task will either be adding a new CRUD vertical or extending and
enhancing an existing one. (If the elements, components, API, and so on represent the horizontal
layers of an application, a single CRUD task is the "vertical slice" through these.) Our Django
application presents collections of objects from which the user may pick one to view, update, or
delete.
The web component in `./elements/table` is used to display, well, tables of components. A new
feature begins by inheriting the `Table` class and providing two things: the API call to retrieve
the objects, and a method describing a row for the table. This is the retrieval for our Role-Based
Access Controls (RBAC).
```
async apiEndpoint(): Promise<PaginatedResponse<Role>> {
return new RbacApi(DEFAULT_CONFIG).rbacRolesList({
...(await this.defaultEndpointConfig()),
managedIsnull: this.hideManaged ? true : undefined,
});
}
```
The complete list of APIs available can be found in `node_modules/@goauthentik/api/src/apis`.
A row returns an array of cells:
```
row(item: Role): SlottedTemplateResult[] {
return [
html`<a href="#/identity/roles/${item.pk}">${item.name}</a>`,
html`<div>
<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Role")}</span>
<ak-role-form slot="form" .instancePk=${item.pk}> </ak-role-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
</div>`,
];
}
```
This example shows the use of the `modal` dialogue to show the "update role" form. Deciding to use
a modal or to move to a different page is a matter of taste, but mostly rests on how large the form
is. If it's likely to have internal scrolling, opt for a separate page.
For complex objects that have a lot of detail or subsidiary lists of features (such as Flows),
provide a separate View page for each one. We have a specified display standard encapsulated in our
`DictionaryList` component.
Creation and Updating are handled using the web component parent in `./elements/forms`. Like
tables, a child component inherits and extends the Form class, providing three features: how to
_retrieve_ the object, how to _send_ the object, and what to ask for. (RBAC is small enough, it's
useful as an example):
```
loadInstance(pk: string): Promise<Role> {
return new RbacApi(DEFAULT_CONFIG).rbacRolesRetrieve({
uuid: pk,
});
}
async send(data: Role): Promise<Role> {
if (this.instance?.pk) {
return new RbacApi(DEFAULT_CONFIG).rbacRolesPartialUpdate({
uuid: this.instance.pk,
patchedRoleRequest: data,
});
}
return new RbacApi(DEFAULT_CONFIG).rbacRolesCreate({
roleRequest: data,
});
}
protected override renderForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${msg("Name")} required name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>`;
}
```
The `send` shows two different modes: If the existing instance has an identity, this is an update;
otherwise it's a creation request.
These are _simple_ examples, naturally, and our application can get much more complicated. The
`./admin/flows` vertical is one of the most complex, including:
- A per-flow view page with a [Mermaid](https://mermaid.js.org/) diagram to show a Flow's Stages
- A sub-table of the Flow's Policies, with the ability to edit each Policy or its Bindings
- A sub-table of the Flow's Stages with the ability to edit each Stage or a Stage's Binding directly
- A sub-table of the Flow's Permissions
## Choosing To Use A Custom Component (developer's guide)
Some of our server-side objects come with lists. When editing a list, we suggest:
- If it's a simple list and there's only one choice, use `<select>`
- If it's from the server and it's possible there are more than 100 items, use SearchSelect. It
has features for showing complex list objects and narrowing down search items.
- If the user can select multiple choices, use DualSelect
# authentik WebUI
This is the default UI for the authentik server. The documentation is going to be a little sparse
for awhile, but at least let's get started.
# The Theory of the authentik UI
In Peter Naur's 1985 essay [Programming as Theory
Building](https://pages.cs.wisc.edu/~remzi/Naur.pdf), programming is described as creating a mental
model of how a program _should_ run, then writing the code to test if the program _can_ run that
way.
The mental model for the authentik UI is straightforward. There are five "applications" within the
UI, each with its own base URL, router, and responsibilities, and each application needs as many as
three contexts in which to run.
The three contexts corresponds to objects in the API's `model` section, so let's use those names.
- The root `Config`. The root configuration object of the server, containing mostly caching and
error reporting information. This is misleading, however; the `Config` object contains some user
information, specifically a list of permissions the current user (or "no user") has.
- The root `CurrentTenant`. This describes the `Brand` information UIs should use, such as themes,
logos, favicon, and specific default flows for logging in, logging out, and recovering a user
password.
- The current `SessionUser`, the person logged in: username, display name, and various states.
(Note: the authentik server permits administrators to "impersonate" any other user in order to
debug their authentication experience. If impersonation is active, the `user` field reflects that
user, but it also includes a field, `original`, with the administrator's information.)
(There is a fourth context object, Version, but its use is limited to displaying version information
and checking for upgrades. Just be aware that you will see it, but you will probably never interact
with it.)
There are five applications. Two (`loading` and `api-browser`) are trivial applications whose
insides are provided by third-party libraries (Patternfly and Rapidoc, respectively). The other
three are actual applications. The descriptions below are wholly from the view of the user's
experience:
- `Flow`: From a given URL, displays a form that requests information from the user to accomplish a
task. Some tasks require the user to be logged in, but many (such as logging in itself!)
obviously do not.
- `User`: Provides the user with access to the applications they can access, plus a few user
settings.
- `Admin`: Provides someone with super-user permissions access to the administrative functions of
the authentik server.
**Mental Model**
- Upon initialization, _every_ authentik UI application fetches `Config` and `CurrentTenant`. `User`
and `Admin` will also attempt to load the `SessionUser`; if there is none, the user is kicked out
to the `Flow` for logging into authentik itself.
- `Config`, `CurrentTenant`, and `SessionUser`, are provided by the `@goauthentik/api` application,
not by the codebase under `./web`. (Where you are now).
- `Flow`, `User`, and `Admin` are all called `Interfaces` and are found in
`./web/src/flow/FlowInterface`, `./web/src/user/UserInterface`, `./web/src/admin/AdminInterface`,
respectively.
Inside each of these you will find, in a hierarchal order:
- The context layer described above
- A theme managing layer
- The orchestration layer:
- web socket handler for server-generated events
- The router
- Individual routes for each vertical slice and its relationship to other objects:
Each slice corresponds to an object table on the server, and each slice _usually_ consists of the
following:
- A paginated collection display, usually using the `Table` foundation (found in
`./web/src/elements/Table`)
- The ability to view an individual object from the collection, which you may be able to:
- Edit
- Delete
- A form for creating a new object
- Tabs showing that object's relationship to other objects
- Interactive elements for changing or deleting those relationships, or creating new ones.
- The ability to create new objects with which to have that relationship, if they're not part of
the core objects (such as User->MFA authenticator apps, since the latter is not a "core" object
and has no tab of its own).
We are still a bit "all over the place" with respect to sub-units and common units; there are
folders `common`, `elements`, and `components`, and ideally they would be:
- `common`: non-UI related libraries all of our applications need
- `elements`: UI elements shared among multiple applications that do not need context
- `components`: UI elements shared among multiple that use one or more context
... but at the moment there are some context-sensitive elements, and some UI-related stuff in
`common`.
# Comments
**NOTE:** The comments in this section are for specific changes to this repository that cannot be
reliably documented any other way. For the most part, they contain comments related to custom
settings in JSON files, which do not support comments.
- `tsconfig.json`:
- `compilerOptions.useDefineForClassFields: false` is required to make TSC use the "classic" form
of field definition when compiling class definitions. Storybook does not handle the ESNext
proposed definition mechanism (yet).
- `compilerOptions.plugins.ts-lit-plugin.rules.no-unknown-tag-name: "off"`: required to support
rapidoc, which exports its tag late.
- `compilerOptions.plugins.ts-lit-plugin.rules.no-missing-import: "off"`: lit-analyzer currently
does not support path aliases very well, and cannot find the definition files associated with
imports using them.
- `compilerOptions.plugins.ts-lit-plugin.rules.no-incompatible-type-binding: "warn"`: lit-analyzer
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
too many errors to be supportable.
### License

View File

@@ -1,30 +0,0 @@
# 01 Foundations: Language
Date: 2026-05-01 (May 1st, 2026)
## The web application
The authentik web-based front-end is written in Typescript. We are currently targeting Typescript
7.0, aka "TSGO," for its speed and compatibility. We chose Typescript because our experience has
been that the type system, when used reliably, can prevent a wide class of errors, especially when
negotiating with the authentik API as generated by OpenAPI.
- `mode: strict` is non-negotiable.
- `useDefineForClassFields` is required for Lit decorator compatibility. See the Lit documentation
[Typescript class fields for reactive
properties](https://lit.dev/docs/components/properties/#:~:text=Set%20the%20useDefineForClassFields%20compiler%20option%20to%20false)
for details.
## Tooling
Most of our internal tooling in the `./web/scripts` folder is written in JavaScript, not Typescript,
to avoid the chicken-and-egg problem of needing build scripts to build build scripts. To facilitate
checking, we enable `checkJs` and [jsdoc supported
types](https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#param-and-returns) in
our tooling and script files, and we check them rigorously.
## Guidance:
Whenever tempted to use `any`, use `unknown` and a [type
predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
(sometimes called a "type guard") instead.

View File

@@ -1,27 +0,0 @@
# 02 Foundations: Build Tools
Date: 2026-05-01 (May 1st, 2026)
## Esbuild and TSGo
In 2024, the web UI used Rollup and TSC as its primary build tools. Building the entire UI for
release took as many as three minutes.
ESBuild can both produce running Javascript from Typescript, and perform all of the bundling
required to support the authentik WebUI. Switching to ESBuild reduced build time to 5 _seconds_. TSC
has been relegated to the `no-emit` strategy of type-checking but not code-producing.
One complication in our code is that our web component foundation, Lit, has an awkward
CSS-in-Javascript format incompatible with the build tools intended to support React, and the
ESBuild plug-in to handle it is custom.
As of this writing, Typescript 7.0, aka "TSGo," is currently in beta. When it is released, we expect
to both reassess this strategy and examine alternative build strategies. We prefer to hew as close
to the Typescript standard as possible, and the standard is set by the Typescript team.
## Wireit
We have chosen to use Wireit because it provides a finer degree of control over build order and
provides a caching strategy. This significantly speeds up rebuilding during development versus using
NPM's own builds. Use Wireit _only_ when you need the cache or dependency order to be strict; for
baseline builds, prefer writing directly into the `scripts` section of `package.json`.

View File

@@ -1,20 +0,0 @@
# 01 Foundations: Workspaces
Date: 2026-05-02 (May 2st, 2026)
## Workspaces
In order to promote the use and development of a product by the widest community possible, we
default to using NPM workspaces, since it is the most common too possible.
Provide a separate workspace when:
1. The project is support that applies across multiple other workspaces, rather than being a part of
an application directly. `./packages/core` is the example.
2. The project is a polyfill or library that is needed across all the applications supported by the
front-end. `./packages/formdata-polyfill` is the example.
3. The project is an application that has radically different requirements from the standard set of
applications. `./packages/sfe` exists to support only the Login Flow with the Internet Explorer
11-based rendering engine, which is still embedded in some older Microsoft products we cannot
afford to ignore.

View File

@@ -1,12 +0,0 @@
# 01 Foundations: Import Strategies
Date: 2026-05-02 (May 2st, 2026)
## Import Strategies
`package.json` defines a large number of import paths that reach into the `src` folder. We use
NodeJS subpaths prefixed with `#`, such as `#fonts`, `#elements`, or `#flow` to isolate subsections
of the frontend. This strategy is intended to facilitate directory restructuring without having to
do mass search-and-replace ops, and as a precursor to further mono-repo-ifying the codebase.
We recommend using barrel files only to export the intended API of a defined subsection.

View File

@@ -1,69 +0,0 @@
# 01 Foundations: Code Quality
Date: 2026-05-02 (May 2st, 2026)
## Code Linting
We _like_ our guardrails. We use ESLint with as many plug-ins as we can reasonably stuff into it for
our checks, such as `eslint-plugin-lit` and `eslint-plugin-wc`, plus `lit-analyzer`.
## Code Formatting
We use `prettier` to enforce a coding style and to catch some fundamental syntax errors. The current
`prettier` configuration correctly formats Lit's HTML-in-JS and CSS-it-JS use cases, as well as all
the Typescript we can throw at it.
## Lockfile
We have a custom script in `./scripts/lint-lockfile.sh` that checks to ensure that every packages as
a resolved hash.
## Type Checking
Although we use ESBuild to convert and bundle our Typescript into JavaScript, we use the stock
Typescript compiler, `tsc`, to check our types. We maintain a default configuration with `use
strict`.
## Testing
We do have tests, but they are primitive. We are very much in a move-fast and try hard not to break
things. We strongly recommend that every PR include a description of how a peer would test the
product manually to validate that it does what the PR claims it does.
Adding to the library of end-to-end tests is a critical mission.
## Your eyes, and the eyes of your peers
For all that we do like our guardrails, nothing surpasses peer review.
## AI Review
We have had mixed results using AI tools such as Claude and Copilot to vet our code. Claude,
especially, can be very good at pointing out shortcomings and missed opportunities in a pull
request, but it can also generate a lot of false positives or trivial issues. We recommend reading
AI reviews with caution.
## AI Review Strategy
That said, this is the current template for a code review prompt. Start with the _target_ branch,
then download the patch file into the project root. You can easily download the patch file by
navigating to the Github PR and appending `.patch` to it, for example:
`https://github.com/goauthentik/authentik/pull/21868.patch`
We use this template:
> Keep the tone neutral-professional-skeptical, the voice of an expert. Avoid excessive enthusiasm.
> This is the root folder for the authentik single sign-on server.
>
> Read the patch file `./21868.patch`. This [community-provided] patch [describe the patch here in
> your own words, using only one or two sentences].
>
> Task 1: Provide a high-level summary of the effect of applying `./21868.patch` Point out any
> shortcomings or security considerations.
>
> Task2: If no tests are provided in the patch, describe how these changes could be tested.
Edit the patch number, add or remove "community-provided" as needed, and include your best
understanding of what the patch claims to do in the second paragraph. Having a strong template that
you hand-edit before running seems to work much better than using a generic template in a Claude
skill.

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();
});
});
});

View File

@@ -10,11 +10,7 @@
"allowSyntheticDefaultImports": true,
"emitDeclarationOnly": true,
"experimentalDecorators": true,
// `useDefineForClassFields` is required for Lit decorator compatibility.
// See the Lit documentation
// [Typescript class fields for reactive properties](https://lit.dev/docs/components/properties/#:~:text=Set%20the%20useDefineForClassFields%20compiler%20option%20to%20false)
// for details.
// See https://lit.dev/docs/components/properties/
"useDefineForClassFields": false,
"target": "esnext",
"module": "preserve",