web/flow: reset stale authenticator selection between consecutive validate stages (#20802)

* fix(web): reset stale MFA challenge selection across stages

* Surface API errors in plucked details.

* Clean up error messages, lifecycle, cancel states.

* Address review feedback on base host property and tag resolver

Fix lint and typing for authenticator component resolver

Format authenticator resolver signature

chore: trigger CI rerun after transient npm network failure

* Tidy return value.

---------

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
This commit is contained in:
Oluwatobi Mustapha
2026-03-19 15:49:49 +01:00
committed by GitHub
parent 03b23b87e0
commit a10ec34aec
9 changed files with 306 additions and 139 deletions
+40 -4
View File
@@ -32,6 +32,32 @@ export const HTTPStatusCodeTransformer: Record<number, HTTPErrorJSONTransformer>
//#region Type Predicates
/**
* A function that determines the specific type of error.
*/
export type ErrorPredicate<T> = (error: unknown) => error is T;
/**
* Recursively checks if an error or any of its causes satisfies a given predicate.
*
* This is useful for unwrapping errors that may be wrapped in multiple layers of `Error` objects with causes.
*
* @param error The error to check.
* @param predicate The type predicate to apply to the error and its causes.
* @returns The first error in the chain that satisfies the predicate, or `null` if none do.
*/
export function findCause<T>(error: unknown, predicate: ErrorPredicate<T>): T | null {
if (predicate(error)) {
return error;
}
if (error instanceof Error && error.cause) {
return findCause(error.cause, predicate);
}
return null;
}
/**
* Type predicate to check if a response contains a JSON body.
*
@@ -205,15 +231,25 @@ export function pluckErrorDetail(errorLike: unknown, fallback?: string): string
* Given API error, parses the response body and transforms it into a {@linkcode APIError}.
*/
export async function parseAPIResponseError<T extends APIError = APIError>(
error: unknown,
source: unknown,
): Promise<T> {
if (!isResponseErrorLike(error)) {
const message = error instanceof Error ? error.message : String(error);
const apiError = findCause(source, isResponseErrorLike);
if (!apiError) {
const message = source instanceof Error ? source.message : String(apiError);
return createSyntheticGenericError(message) as T;
}
const { response } = apiError;
let message: string | undefined;
const { response, message } = error;
if (apiError && apiError !== source) {
// The API error is wrapped in another error.
const wrapperMessage = pluckErrorDetail(source);
message = wrapperMessage ? `${wrapperMessage}: ${message}` : message;
} else {
message = apiError.message;
}
if (!isJSONResponse(response)) {
return createSyntheticGenericError(message || response.statusText) as T;
+12 -3
View File
@@ -16,16 +16,25 @@ export function u8arr(input: string): Uint8Array<ArrayBuffer> {
);
}
export function checkWebAuthnSupport() {
if ("credentials" in navigator) {
export function assertWebAuthnSupported(scope = window): void {
if ("credentials" in scope.navigator) {
return;
}
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
if (scope.location.protocol === "http:" && scope.location.hostname !== "localhost") {
throw new Error(msg("WebAuthn requires this page to be accessed via HTTPS."));
}
throw new Error(msg("WebAuthn not supported by browser."));
}
/**
* Predicate to determine if a given error originates from a user cancellation or timeout of a WebAuthn authentication ceremony.
*/
export function isWebAuthnNotAllowedError(error: unknown): error is DOMException {
return error instanceof DOMException && (error.name === "NotAllowedError" || error.code === 0);
}
/**
* Check if the browser supports WebAuthn conditional UI (passkey autofill)
*/
@@ -7,6 +7,10 @@ import Styles from "./AuthenticatorValidateStage.css";
import { DEFAULT_CONFIG } from "#common/api/config";
import { SlottedTemplateResult } from "#elements/types";
import { StrictUnsafe } from "#elements/utils/unsafe";
import { shouldResetSelectedChallenge } from "#flow/stages/authenticator_validate/challenge-selection";
import { BaseStage } from "#flow/stages/base";
import { PasswordManagerPrefill } from "#flow/stages/identification/IdentificationStage";
import type { StageHost, SubmitOptions } from "#flow/types";
@@ -77,6 +81,24 @@ const createDevicePickerPropMap = () =>
},
}) as const satisfies Record<DeviceClassesEnum, DevicePickerProps>;
export function resolveAuthenticatorComponentTag(
deviceClass: DeviceClassesEnum | null | undefined,
) {
switch (deviceClass) {
case DeviceClassesEnum.Static:
case DeviceClassesEnum.Totp:
case DeviceClassesEnum.Email:
case DeviceClassesEnum.Sms:
return "ak-stage-authenticator-validate-code";
case DeviceClassesEnum.Webauthn:
return "ak-stage-authenticator-validate-webauthn";
case DeviceClassesEnum.Duo:
return "ak-stage-authenticator-validate-duo";
default:
return null;
}
}
@customElement("ak-stage-authenticator-validate")
export class AuthenticatorValidateStage
extends BaseStage<
@@ -85,9 +107,19 @@ export class AuthenticatorValidateStage
>
implements StageHost
{
static styles: CSSResult[] = [PFLogin, PFForm, PFFormControl, PFTitle, PFButton, Styles];
static styles: CSSResult[] = [
// ---
PFLogin,
PFForm,
PFFormControl,
PFTitle,
PFButton,
Styles,
];
flowSlug = "";
#api = new FlowsApi(DEFAULT_CONFIG);
public flowSlug = "";
set loading(value: boolean) {
this.host.loading = value;
@@ -102,7 +134,7 @@ export class AuthenticatorValidateStage
}
@state()
_firstInitialized: boolean = false;
protected initialized = false;
#selectedDeviceChallenge: DeviceChallenge | null = null;
@@ -127,7 +159,7 @@ export class AuthenticatorValidateStage
// We don't use this.submit here, as we don't want to advance the flow.
// We just want to notify the backend which challenge has been selected.
new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({
this.#api.flowsExecutorSolve({
flowSlug: this.host?.flowSlug || "",
query: window.location.search.substring(1),
flowChallengeResponseRequest,
@@ -149,12 +181,23 @@ export class AuthenticatorValidateStage
this.selectedDeviceChallenge = null;
}
willUpdate(_changed: PropertyValues<this>) {
if (this._firstInitialized || !this.challenge) {
protected override willUpdate(changed: PropertyValues<this>) {
// When moving between multiple authenticator-validate stages in one flow, the element
// instance is reused. Reset selection if it is no longer valid in the new challenge.
if (changed.has("challenge")) {
const allowedChallenges = this.challenge?.deviceChallenges ?? [];
if (shouldResetSelectedChallenge(this.selectedDeviceChallenge, allowedChallenges)) {
this.selectedDeviceChallenge = null;
this.initialized = false;
}
}
if (this.initialized || !this.challenge) {
return;
}
this._firstInitialized = true;
this.initialized = true;
// If user only has a single device, autoselect that device.
if (this.challenge.deviceChallenges.length === 1) {
@@ -168,10 +211,9 @@ export class AuthenticatorValidateStage
(challenge) => challenge.deviceClass === DeviceClassesEnum.Totp,
);
if (PasswordManagerPrefill.totp && totpChallenge) {
console.debug(
"authentik/stages/authenticator_validate: found prefill totp code, selecting totp challenge",
);
this.logger.debug("Found prefill TOTP code to select");
this.selectedDeviceChallenge = totpChallenge;
return;
}
@@ -185,10 +227,10 @@ export class AuthenticatorValidateStage
}
}
renderDevicePicker() {
protected renderDevicePicker(): SlottedTemplateResult {
const { deviceChallenges } = this.challenge || {};
if (this.selectedDeviceChallenge || !deviceChallenges?.length) {
if (!deviceChallenges?.length) {
return nothing;
}
@@ -231,7 +273,7 @@ export class AuthenticatorValidateStage
</fieldset>`;
}
renderStagePicker() {
protected renderStagePicker(): SlottedTemplateResult {
if (!this.challenge?.configurationStages.length) {
return nothing;
}
@@ -264,40 +306,22 @@ export class AuthenticatorValidateStage
</fieldset>`;
}
renderDeviceChallenge() {
protected renderDeviceChallenge() {
if (!this.selectedDeviceChallenge) {
return nothing;
}
switch (this.selectedDeviceChallenge?.deviceClass) {
case DeviceClassesEnum.Static:
case DeviceClassesEnum.Totp:
case DeviceClassesEnum.Email:
case DeviceClassesEnum.Sms:
return html` <ak-stage-authenticator-validate-code
.host=${this}
.challenge=${this.challenge}
.deviceChallenge=${this.selectedDeviceChallenge}
.showBackButton=${(this.challenge?.deviceChallenges || []).length > 1}
>
</ak-stage-authenticator-validate-code>`;
case DeviceClassesEnum.Webauthn:
return html` <ak-stage-authenticator-validate-webauthn
.host=${this}
.challenge=${this.challenge}
.deviceChallenge=${this.selectedDeviceChallenge}
.showBackButton=${(this.challenge?.deviceChallenges || []).length > 1}
>
</ak-stage-authenticator-validate-webauthn>`;
case DeviceClassesEnum.Duo:
return html` <ak-stage-authenticator-validate-duo
.host=${this}
.challenge=${this.challenge}
.deviceChallenge=${this.selectedDeviceChallenge}
.showBackButton=${(this.challenge?.deviceChallenges || []).length > 1}
>
</ak-stage-authenticator-validate-duo>`;
}
return nothing;
const tag = resolveAuthenticatorComponentTag(this.selectedDeviceChallenge.deviceClass);
if (!tag) return null;
const showBackButton = (this.challenge?.deviceChallenges || []).length > 1;
return StrictUnsafe(tag, {
host: this,
challenge: this.challenge,
deviceChallenge: this.selectedDeviceChallenge,
showBackButton,
});
}
protected renderAuthenticatorSelection(): TemplateResult {
@@ -305,7 +329,8 @@ export class AuthenticatorValidateStage
${this.renderUserInfo()}${this.renderStagePicker()}${this.renderDevicePicker()}
</form>`;
}
render(): TemplateResult {
protected override render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
${this.selectedDeviceChallenge
? this.renderDeviceChallenge()
@@ -1,11 +1,15 @@
import "#elements/EmptyState";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import {
checkWebAuthnSupport,
assertWebAuthnSupported,
isWebAuthnNotAllowedError,
transformAssertionForServer,
transformCredentialRequestOptions,
} from "#common/helpers/webauthn";
import { SlottedTemplateResult } from "#elements/types";
import { BaseDeviceStage } from "#flow/stages/authenticator_validate/base";
import {
@@ -15,7 +19,7 @@ import {
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, nothing, PropertyValues, TemplateResult } from "lit";
import { html, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("ak-stage-authenticator-validate-webauthn")
@@ -24,57 +28,65 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
AuthenticatorValidationChallengeResponseRequest
> {
@property({ attribute: false })
deviceChallenge?: DeviceChallenge;
public deviceChallenge?: DeviceChallenge;
@property()
errorMessage?: string;
@property({ attribute: false })
public errorMessage?: string;
@property({ type: Boolean })
showBackButton = false;
public showBackButton = false;
@state()
authenticating = false;
protected authenticating = false;
transformedCredentialRequestOptions?: PublicKeyCredentialRequestOptions;
async authenticate(): Promise<void> {
protected async authenticate(): Promise<boolean> {
assertWebAuthnSupported();
// request the authenticator to create an assertion signature using the
// credential private key
let assertion;
checkWebAuthnSupport();
try {
assertion = await navigator.credentials.get({
const assertion = await navigator.credentials
.get({
publicKey: this.transformedCredentialRequestOptions,
})
.then((assertion) => {
if (!assertion) {
throw new Error(msg("No assertion was returned by the authenticator"));
}
return assertion as PublicKeyCredential;
})
.catch((cause) => {
if (isWebAuthnNotAllowedError(cause)) {
throw new Error(msg("Authentication was cancelled or timed out"), { cause });
}
throw new Error("Error creating credential", { cause });
});
if (!assertion) {
throw new Error("Assertions is empty");
}
} catch (err) {
throw new Error(`Error when creating credential: ${err}`);
}
// we now have an authentication assertion! encode the byte arrays contained
// We now have an authentication assertion! encode the byte arrays contained
// in the assertion data as strings for posting to the server
const transformedAssertionForServer = transformAssertionForServer(
assertion as PublicKeyCredential,
);
// post the assertion to the server for verification.
try {
await this.host?.submit(
const transformedAssertionForServer = transformAssertionForServer(assertion);
// Post the assertion to the server for verification.
return this.host
?.submit(
{
webauthn: transformedAssertionForServer,
},
{
invisible: true,
},
);
} catch (err) {
throw new Error(`Error when validating assertion on server: ${err}`);
}
)
.catch((cause) => {
throw new Error(`Error when validating assertion on server`, { cause });
});
}
updated(changedProperties: PropertyValues<this>) {
public override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("challenge") && this.challenge) {
@@ -84,30 +96,34 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
?.challenge as PublicKeyCredentialRequestOptions;
this.transformedCredentialRequestOptions =
transformCredentialRequestOptions(credentialRequestOptions);
this.authenticateWrapper();
this.tryAuthenticating();
}
}
async authenticateWrapper(): Promise<void> {
protected tryAuthenticating = async (): Promise<unknown> => {
if (this.authenticating) {
return;
}
this.authenticating = true;
this.authenticate()
.catch((error: unknown) => {
console.warn(
"authentik/flows/authenticator_validate/webauthn: failed to auth",
error,
);
this.errorMessage = msg("Authentication failed. Please try again.");
return this.authenticate()
.catch(async (error: unknown) => {
const reason = msg("Failed to authenticate");
this.logger.warn(reason, error);
const parsedError = await parseAPIResponseError(error);
this.errorMessage = pluckErrorDetail(parsedError, reason);
})
.finally(() => {
this.authenticating = false;
});
}
};
render(): TemplateResult {
return html` <form class="pf-c-form">
protected override render(): SlottedTemplateResult {
return html`<form class="pf-c-form">
${this.renderUserInfo()}
<ak-empty-state ?loading="${this.authenticating}" icon="fa-times">
<span
@@ -120,11 +136,9 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
? html`<fieldset class="pf-c-form__group pf-m-action">
<legend class="sr-only">${msg("Form actions")}</legend>
${!this.authenticating
? html` <button
? html`<button
class="pf-c-button pf-m-primary pf-m-block"
@click=${() => {
this.authenticateWrapper();
}}
@click=${this.tryAuthenticating}
type="button"
>
${msg("Retry authentication")}
@@ -31,7 +31,7 @@ export class BaseDeviceStage<Tin extends StageChallengeLike, Tout> extends BaseS
this.host?.reset?.();
};
renderReturnToDevicePicker() {
protected renderReturnToDevicePicker() {
if (!this.showBackButton) {
return nothing;
}
@@ -0,0 +1,15 @@
import { type DeviceChallenge } from "@goauthentik/api";
export function shouldResetSelectedChallenge(
selectedChallenge: DeviceChallenge | null,
allowedChallenges: DeviceChallenge[],
): boolean {
if (!selectedChallenge) {
return false;
}
return !allowedChallenges.some(
(challenge) =>
challenge.deviceClass === selectedChallenge.deviceClass &&
challenge.deviceUid === selectedChallenge.deviceUid,
);
}
@@ -2,9 +2,11 @@ import "#elements/EmptyState";
import "#flow/components/ak-flow-card";
import "#flow/FormStatic";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import {
Assertion,
checkWebAuthnSupport,
assertWebAuthnSupported,
isWebAuthnNotAllowedError,
transformCredentialCreateOptions,
transformNewAssertionForServer,
} from "#common/helpers/webauthn";
@@ -17,7 +19,7 @@ import {
AuthenticatorWebAuthnChallengeResponseRequest,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -36,69 +38,93 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
AuthenticatorWebAuthnChallenge,
AuthenticatorWebAuthnChallengeResponseRequest
> {
@property({ type: Boolean })
registerRunning = false;
@property()
registerMessage = "";
publicKeyCredentialCreateOptions?: PublicKeyCredentialCreationOptions;
static styles: CSSResult[] = [PFLogin, PFFormControl, PFForm, PFTitle, PFButton];
async register(): Promise<void> {
@property({ type: Boolean })
public registerRunning = false;
@property({ type: String, attribute: false })
public errorMessage: string | null = null;
protected publicKeyCredentialCreateOptions?: PublicKeyCredentialCreationOptions;
protected async register(): Promise<unknown> {
if (!this.challenge) {
return;
}
checkWebAuthnSupport();
// request the authenticator(s) to create a new credential keypair.
let credential;
try {
credential = (await navigator.credentials.create({
publicKey: this.publicKeyCredentialCreateOptions,
})) as PublicKeyCredential;
if (!credential) {
throw new Error("Credential is empty");
}
} catch (err) {
throw new Error(msg(str`Error creating credential: ${err}`));
assertWebAuthnSupported();
if (!this.host) {
this.logger.error("Host is not set, cannot submit registration");
return;
}
// Request the authenticator(s) to create a new credential keypair.
const credential = await navigator.credentials
.create({
publicKey: this.publicKeyCredentialCreateOptions,
})
.then((credential) => {
if (!credential) {
throw new Error("Credential is empty");
}
return credential as PublicKeyCredential;
})
.catch((cause) => {
if (isWebAuthnNotAllowedError(cause)) {
throw new Error(
msg("Registration was cancelled or timed out. Please try again."),
{ cause },
);
}
throw new Error(
msg("An error occurred while creating the credential. Please try again."),
{ cause },
);
});
// we now have a new credential! We now need to encode the byte arrays
// in the credential into strings, for posting to our server.
const newAssertionForServer = transformNewAssertionForServer(credential);
// post the transformed credential data to the server for validation
// and storing the public key
try {
await this.host?.submit(
return this.host
.submit(
{
response: newAssertionForServer,
},
{
invisible: true,
},
);
} catch (err) {
throw new Error(msg(str`Server validation of credential failed: ${err}`));
}
)
.catch((cause: unknown) => {
throw new Error(msg("Server validation of credential failed"), { cause });
});
}
async registerWrapper(): Promise<void> {
protected tryRegister = async (): Promise<unknown> => {
if (this.registerRunning) {
return;
}
this.registerRunning = true;
this.register()
.catch((error: unknown) => {
console.warn("authentik/flows/authenticator_webauthn: failed to register", error);
this.registerMessage = msg("Failed to register. Please try again.");
return this.register()
.catch(async (error: unknown) => {
const reason = msg("Failed to register. Please try again.");
this.logger.warn("Failed to register", error);
const parsedError = await parseAPIResponseError(error);
this.errorMessage = pluckErrorDetail(parsedError, reason);
})
.finally(() => {
this.registerRunning = false;
});
}
};
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("challenge") && this.challenge) {
@@ -108,7 +134,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
this.challenge?.registration as PublicKeyCredentialCreationOptions,
this.challenge?.registration.user.id,
);
this.registerWrapper();
this.tryRegister();
}
}
@@ -121,7 +147,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
<span
>${this.registerRunning
? msg("Registering...")
: this.registerMessage || msg("Failed to register")}
: this.errorMessage || msg("Failed to register")}
</span>
</ak-empty-state>
${this.challenge?.responseErrors
@@ -133,7 +159,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
? html` <button
class="pf-c-button pf-m-primary pf-m-block"
@click=${() => {
this.registerWrapper();
this.tryRegister();
}}
type="button"
>
+1 -2
View File
@@ -46,8 +46,7 @@ export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
protected logger = ConsoleLogger.prefix(`flow:${this.tagName.toLowerCase()}`);
// TODO: Should have a property but this needs some refactoring first.
// @property({ attribute: false })
@property({ type: Object, attribute: false })
public host!: StageHost;
@property({ attribute: false })
@@ -0,0 +1,43 @@
import { shouldResetSelectedChallenge } from "#flow/stages/authenticator_validate/challenge-selection";
import { type DeviceChallenge, DeviceClassesEnum } from "@goauthentik/api";
import { describe, expect, it } from "vitest";
const makeDeviceChallenge = (
deviceClass: DeviceClassesEnum,
deviceUid: string,
): DeviceChallenge => ({
deviceClass,
deviceUid,
challenge: {},
lastUsed: null,
});
describe("shouldResetSelectedChallenge", () => {
it("returns true when the previously selected challenge is no longer allowed", () => {
const selected = makeDeviceChallenge(DeviceClassesEnum.Email, "email-1");
const allowed = [
makeDeviceChallenge(DeviceClassesEnum.Totp, "totp-1"),
makeDeviceChallenge(DeviceClassesEnum.Webauthn, "webauthn-1"),
];
expect(shouldResetSelectedChallenge(selected, allowed)).toBe(true);
});
it("returns false when the previously selected challenge is still allowed", () => {
const selected = makeDeviceChallenge(DeviceClassesEnum.Email, "email-1");
const allowed = [
makeDeviceChallenge(DeviceClassesEnum.Email, "email-1"),
makeDeviceChallenge(DeviceClassesEnum.Sms, "sms-1"),
];
expect(shouldResetSelectedChallenge(selected, allowed)).toBe(false);
});
it("returns false when there was no selected challenge", () => {
const allowed = [makeDeviceChallenge(DeviceClassesEnum.Email, "email-1")];
expect(shouldResetSelectedChallenge(null, allowed)).toBe(false);
});
});