mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
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:
committed by
GitHub
parent
03b23b87e0
commit
a10ec34aec
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user