web: Use ESBuild for bundling. Split out webauthn utils. Prep for
TypeScript monorepo.
This commit is contained in:
191
web/sfe/lib/AuthenticatorValidateStage.js
Normal file
191
web/sfe/lib/AuthenticatorValidateStage.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
35
web/sfe/lib/AutosubmitStage.js
Normal file
35
web/sfe/lib/AutosubmitStage.js
Normal 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();
|
||||
}
|
||||
}
|
||||
50
web/sfe/lib/IdentificationStage.js
Normal file
50
web/sfe/lib/IdentificationStage.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
37
web/sfe/lib/PasswordStage.js
Normal file
37
web/sfe/lib/PasswordStage.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
14
web/sfe/lib/RedirectStage.js
Normal file
14
web/sfe/lib/RedirectStage.js
Normal 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);
|
||||
}
|
||||
}
|
||||
113
web/sfe/lib/SimpleFlowExecutor.js
Normal file
113
web/sfe/lib/SimpleFlowExecutor.js
Normal 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
116
web/sfe/lib/Stage.js
Normal 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
12
web/sfe/lib/index.js
Normal 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
20
web/sfe/lib/utils.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user