web: Use ESBuild for bundling. Split out webauthn utils. Prep for

TypeScript monorepo.
This commit is contained in:
Teffen Ellis
2025-04-03 21:07:39 +02:00
parent 220378b3f2
commit 82fadf587b
35 changed files with 1273 additions and 2576 deletions

View File

@ -0,0 +1,191 @@
/**
* @import { AuthenticatorValidationChallenge, DeviceChallenge } from "@goauthentik/api";
* @import { FlowExecutor } from './Stage.js';
*/
import {
isWebAuthnSupported,
transformAssertionForServer,
transformCredentialRequestOptions,
} from "@goauthentik/web/authentication";
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
//@ts-check
/**
* @template {AuthenticatorValidationChallenge} T
* @extends {Stage<T>}
*/
export class AuthenticatorValidateStage extends Stage {
/**
* @param {FlowExecutor} executor - The executor for this stage
* @param {T} challenge - The challenge for this stage
*/
constructor(executor, challenge) {
super(executor, challenge);
/**
* @type {DeviceChallenge | null}
*/
this.deviceChallenge = null;
}
render() {
if (!this.deviceChallenge) {
this.renderChallengePicker();
return;
}
switch (this.deviceChallenge.deviceClass) {
case "static":
case "totp":
this.renderCodeInput();
break;
case "webauthn":
this.renderWebauthn();
break;
default:
break;
}
}
/**
* @private
*/
renderChallengePicker() {
const challenges = this.challenge.deviceChallenges.filter((challenge) =>
challenge.deviceClass === "webauthn" && !isWebAuthnSupported() ? undefined : challenge,
);
this.html(/* html */ `<form id="picker-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
challenges.length > 0
? /* html */ `<p>Select an authentication method.</p>`
: /* html */ `<p>No compatible authentication method available</p>`
}
${challenges
.map((challenge) => {
let label = undefined;
switch (challenge.deviceClass) {
case "static":
label = "Recovery keys";
break;
case "totp":
label = "Traditional authenticator";
break;
case "webauthn":
label = "Security key";
break;
}
if (!label) return "";
return /* html */ `<div class="form-label-group my-3 has-validation">
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
${label}
</button>
</div>`;
})
.join("")}
</form>`);
this.challenge.deviceChallenges.forEach((challenge) => {
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
"click",
() => {
this.deviceChallenge = challenge;
this.render();
},
);
});
}
/**
* @private
*/
renderCodeInput() {
this.html(/* html */ `
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? "is-invalid" : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
${this.renderInputError("code")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#totp-form input").trigger("focus");
$("#totp-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
/**
* @private
*/
renderWebauthn() {
this.html(/* html */ `
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>
`);
const challenge = /** @type {PublicKeyCredentialRequestOptions} */ (
this.deviceChallenge?.challenge
);
navigator.credentials
.get({
publicKey: transformCredentialRequestOptions(challenge),
})
.then((credential) => {
if (!credential) {
throw new Error("No assertion");
}
if (credential.type !== "public-key") {
throw new Error("Invalid assertion type");
}
try {
// 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(
/** @type {PublicKeyCredential} */ (credential),
);
// Post the assertion to the server for verification.
this.executor.submit({
webauthn: transformedAssertionForServer,
});
} catch (err) {
throw new Error(`Error when validating assertion on server: ${err}`);
}
})
.catch((error) => {
console.warn(error);
this.deviceChallenge = null;
this.render();
});
}
}

View File

@ -0,0 +1,35 @@
/**
* @import { AutosubmitChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {AutosubmitChallenge} T
* @extends {Stage<T>}
*/
export class AutosubmitStage extends Stage {
render() {
this.html(/* html */ `
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${Object.entries(this.challenge.attrs).map(([key, value]) => {
return /* html */ `<input
type="hidden"
name="${key}"
value="${value}"
/>`;
})}
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>`);
$("#autosubmit-form").submit();
}
}

View File

@ -0,0 +1,50 @@
/**
* @import { IdentificationChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {IdentificationChallenge} T
* @extends {Stage<T>}
*/
export class IdentificationStage extends Stage {
render() {
this.html(/* html */ `
<form id="ident-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
this.challenge.applicationPre
? /* html */ `<p>
Log in to continue to ${this.challenge.applicationPre}.
</p>`
: ""
}
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
</div>
${
this.challenge.passwordFields
? /* html */ `<div class="form-label-group my-3 has-validation">
<input type="password" class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>`
: ""
}
${this.renderNonFieldErrors()}
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
</form>`);
$("#ident-form input[name=uid_field]").trigger("focus");
$("#ident-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
}

View File

@ -0,0 +1,37 @@
/**
* @import { PasswordChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {PasswordChallenge} T
* @extends {Stage<T>}
*/
export class PasswordStage extends Stage {
render() {
this.html(/* html */ `
<form id="password-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3 has-validation">
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#password-form input").trigger("focus");
$("#password-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
}

View File

@ -0,0 +1,14 @@
/**
* @import { RedirectChallenge } from "@goauthentik/api";
*/
import { Stage } from "./Stage.js";
/**
* @template {RedirectChallenge} T
* @extends {Stage<T>}
*/
export class RedirectStage extends Stage {
render() {
window.location.assign(this.challenge.to);
}
}

View File

@ -0,0 +1,113 @@
/**
* @import { ChallengeTypes } from "@goauthentik/api";
* @import { FlowExecutor } from './Stage.js';
*/
import $ from "jquery";
import { ChallengeTypesFromJSON } from "@goauthentik/api";
import { AuthenticatorValidateStage } from "./AuthenticatorValidateStage.js";
import { AutosubmitStage } from "./AutosubmitStage.js";
import { IdentificationStage } from "./IdentificationStage.js";
import { PasswordStage } from "./PasswordStage.js";
import { RedirectStage } from "./RedirectStage.js";
import { ak } from "./utils.js";
/**
* Simple Flow Executor lifecycle.
*
* @implements {FlowExecutor}
*/
export class SimpleFlowExecutor {
/**
*
* @param {HTMLDivElement} container
*/
constructor(container) {
/**
* @type {ChallengeTypes | null} The current challenge.
*/
this.challenge = null;
/**
* @type {string} The flow slug.
*/
this.flowSlug = window.location.pathname.split("/")[3] || "";
/**
* @type {HTMLDivElement} The container element for the flow executor.
*/
this.container = container;
}
get apiURL() {
return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`;
}
start() {
$.ajax({
type: "GET",
url: this.apiURL,
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
});
}
/**
* Submits the form data.
* @param {Record<string, unknown> | FormData} payload
*/
submit(payload) {
$("button[type=submit]").addClass("disabled")
.html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span role="status">Loading...</span>`);
/**
* @type {Record<string, unknown>}
*/
let finalData;
if (payload instanceof FormData) {
finalData = {};
payload.forEach((value, key) => {
finalData[key] = value;
});
} else {
finalData = payload;
}
$.ajax({
type: "POST",
url: this.apiURL,
data: JSON.stringify(finalData),
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
contentType: "application/json",
dataType: "json",
});
}
/**
* @returns {void}
*/
renderChallenge() {
switch (this.challenge?.component) {
case "ak-stage-identification":
return new IdentificationStage(this, this.challenge).render();
case "ak-stage-password":
return new PasswordStage(this, this.challenge).render();
case "xak-flow-redirect":
return new RedirectStage(this, this.challenge).render();
case "ak-stage-autosubmit":
return new AutosubmitStage(this, this.challenge).render();
case "ak-stage-authenticator-validate":
return new AuthenticatorValidateStage(this, this.challenge).render();
default:
this.container.innerText = `Unsupported stage: ${this.challenge?.component}`;
return;
}
}
}

116
web/sfe/lib/Stage.js Normal file
View File

@ -0,0 +1,116 @@
/**
* @import { ContextualFlowInfo, ErrorDetail } from "@goauthentik/api";
*/
/**
* @typedef {object} FlowInfoChallenge
* @property {ContextualFlowInfo} [flowInfo]
* @property {Record<string, Array<ErrorDetail>>} [responseErrors]
*/
/**
* @abstract
*/
export class FlowExecutor {
constructor() {
/**
* The DOM container element.
*
* @type {HTMLElement}
* @abstract
* @returns {void}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.container;
}
/**
* Submits the form data.
*
* @param {Record<string, unknown> | FormData} data The data to submit.
* @abstract
* @returns {void}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
submit(data) {
throw new Error(`Method 'submit' not implemented in ${this.constructor.name}`);
}
}
/**
* Represents a stage in a flow
* @template {FlowInfoChallenge} T
* @abstract
*/
export class Stage {
/**
* @param {FlowExecutor} executor - The executor for this stage
* @param {T} challenge - The challenge for this stage
*/
constructor(executor, challenge) {
/** @type {FlowExecutor} */
this.executor = executor;
/** @type {T} */
this.challenge = challenge;
}
/**
* @protected
* @param {string} fieldName
*/
error(fieldName) {
if (!this.challenge.responseErrors) {
return [];
}
return this.challenge.responseErrors[fieldName] || [];
}
/**
* @protected
* @param {string} fieldName
* @returns {string}
*/
renderInputError(fieldName) {
return `${this.error(fieldName)
.map((error) => {
return /* html */ `<div class="invalid-feedback">
${error.string}
</div>`;
})
.join("")}`;
}
/**
* @protected
* @returns {string}
*/
renderNonFieldErrors() {
return `${this.error("non_field_errors")
.map((error) => {
return /* html */ `<div class="alert alert-danger" role="alert">
${error.string}
</div>`;
})
.join("")}`;
}
/**
* @protected
* @param {string} innerHTML
* @returns {void}
*/
html(innerHTML) {
this.executor.container.innerHTML = innerHTML;
}
/**
* Renders the stage (must be implemented by subclasses)
*
* @abstract
* @returns {void}
*/
render() {
throw new Error("Abstract method");
}
}

12
web/sfe/lib/index.js Normal file
View File

@ -0,0 +1,12 @@
/**
* @file Simplified Flow Executor (SFE) library module.
*/
export * from "./Stage.js";
export * from "./SimpleFlowExecutor.js";
export * from "./AuthenticatorValidateStage.js";
export * from "./AutosubmitStage.js";
export * from "./IdentificationStage.js";
export * from "./PasswordStage.js";
export * from "./RedirectStage.js";
export * from "./utils.js";

20
web/sfe/lib/utils.js Normal file
View File

@ -0,0 +1,20 @@
/**
* @typedef {object} GlobalAuthentik
* @property {object} brand
* @property {string} brand.branding_logo
* @property {object} api
* @property {string} api.base
*/
/**
* Retrieves the global authentik object from the window.
* @throws {Error} If the object not found
* @returns {GlobalAuthentik}
*/
export function ak() {
if (!("authentik" in window)) {
throw new Error("No authentik object found in window");
}
return /** @type {GlobalAuthentik} */ (window.authentik);
}