web/flows: Simplified flow executor (#10296)
* initial sfe Signed-off-by: Jens Langhammer <jens@goauthentik.io> * build sfe Signed-off-by: Jens Langhammer <jens@goauthentik.io> * downgrade bootstrap Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix path Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make IE compatible Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix query string missing Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add autosubmit stage Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add background image Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add code support Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add support for combo ident/password Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix logo rendering Signed-off-by: Jens Langhammer <jens@goauthentik.io> * only use for edge 18 and before Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix lint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add docs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add webauthn support Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate to TS for some creature comforts Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix ci Signed-off-by: Jens Langhammer <jens@goauthentik.io> * dedupe dependabot Signed-off-by: Jens Langhammer <jens@goauthentik.io> * use API client...kinda Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add more docs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add more polyfills yay Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> * turn powered by into span prevent issues in restricted browsers where users might not be able to return Signed-off-by: Jens Langhammer <jens@goauthentik.io> * allow non-link footer entries Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tsc errors Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Apply suggestions from code review Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com> Signed-off-by: Jens L. <jens@beryju.org> * auto switch for macos Signed-off-by: Jens Langhammer <jens@goauthentik.io> * reword Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Update website/docs/flow/executors/if-flow.md Signed-off-by: Jens L. <jens@beryju.org> * format Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Jens L. <jens@beryju.org> Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com> Signed-off-by: Jens Langhammer <jens@goauthentik.io> # Conflicts: # .github/workflows/ci-web.yml # Dockerfile # website/developer-docs/api/flow-executor.md
This commit is contained in:
		
							
								
								
									
										529
									
								
								web/sfe/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										529
									
								
								web/sfe/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,529 @@
 | 
			
		||||
import { fromByteArray } from "base64-js";
 | 
			
		||||
import "formdata-polyfill";
 | 
			
		||||
import $ from "jquery";
 | 
			
		||||
import "weakmap-polyfill";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
    type AuthenticatorValidationChallenge,
 | 
			
		||||
    type AutosubmitChallenge,
 | 
			
		||||
    type ChallengeTypes,
 | 
			
		||||
    ChallengeTypesFromJSON,
 | 
			
		||||
    type ContextualFlowInfo,
 | 
			
		||||
    type DeviceChallenge,
 | 
			
		||||
    type ErrorDetail,
 | 
			
		||||
    type IdentificationChallenge,
 | 
			
		||||
    type PasswordChallenge,
 | 
			
		||||
    type RedirectChallenge,
 | 
			
		||||
} from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
interface GlobalAuthentik {
 | 
			
		||||
    brand: {
 | 
			
		||||
        branding_logo: string;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ak(): GlobalAuthentik {
 | 
			
		||||
    return (
 | 
			
		||||
        window as unknown as {
 | 
			
		||||
            authentik: GlobalAuthentik;
 | 
			
		||||
        }
 | 
			
		||||
    ).authentik;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SimpleFlowExecutor {
 | 
			
		||||
    challenge?: ChallengeTypes;
 | 
			
		||||
    flowSlug: string;
 | 
			
		||||
    container: HTMLDivElement;
 | 
			
		||||
 | 
			
		||||
    constructor(container: HTMLDivElement) {
 | 
			
		||||
        this.flowSlug = window.location.pathname.split("/")[3];
 | 
			
		||||
        this.container = container;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get apiURL() {
 | 
			
		||||
        return `/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();
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    submit(data: { [key: string]: unknown } | FormData) {
 | 
			
		||||
        $("button[type=submit]").addClass("disabled")
 | 
			
		||||
            .html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
 | 
			
		||||
                <span role="status">Loading...</span>`);
 | 
			
		||||
        let finalData: { [key: string]: unknown } = {};
 | 
			
		||||
        if (data instanceof FormData) {
 | 
			
		||||
            finalData = {};
 | 
			
		||||
            data.forEach((value, key) => {
 | 
			
		||||
                finalData[key] = value;
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            finalData = data;
 | 
			
		||||
        }
 | 
			
		||||
        $.ajax({
 | 
			
		||||
            type: "POST",
 | 
			
		||||
            url: this.apiURL,
 | 
			
		||||
            data: JSON.stringify(finalData),
 | 
			
		||||
            success: (data) => {
 | 
			
		||||
                this.challenge = ChallengeTypesFromJSON(data);
 | 
			
		||||
                this.renderChallenge();
 | 
			
		||||
            },
 | 
			
		||||
            contentType: "application/json",
 | 
			
		||||
            dataType: "json",
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderChallenge() {
 | 
			
		||||
        switch (this.challenge?.component) {
 | 
			
		||||
            case "ak-stage-identification":
 | 
			
		||||
                new IdentificationStage(this, this.challenge).render();
 | 
			
		||||
                return;
 | 
			
		||||
            case "ak-stage-password":
 | 
			
		||||
                new PasswordStage(this, this.challenge).render();
 | 
			
		||||
                return;
 | 
			
		||||
            case "xak-flow-redirect":
 | 
			
		||||
                new RedirectStage(this, this.challenge).render();
 | 
			
		||||
                return;
 | 
			
		||||
            case "ak-stage-autosubmit":
 | 
			
		||||
                new AutosubmitStage(this, this.challenge).render();
 | 
			
		||||
                return;
 | 
			
		||||
            case "ak-stage-authenticator-validate":
 | 
			
		||||
                new AuthenticatorValidateStage(this, this.challenge).render();
 | 
			
		||||
                return;
 | 
			
		||||
            default:
 | 
			
		||||
                this.container.innerText = "Unsupported stage: " + this.challenge?.component;
 | 
			
		||||
                return;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface FlowInfoChallenge {
 | 
			
		||||
    flowInfo?: ContextualFlowInfo;
 | 
			
		||||
    responseErrors?: {
 | 
			
		||||
        [key: string]: Array<ErrorDetail>;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Stage<T extends FlowInfoChallenge> {
 | 
			
		||||
    constructor(
 | 
			
		||||
        public executor: SimpleFlowExecutor,
 | 
			
		||||
        public challenge: T,
 | 
			
		||||
    ) {}
 | 
			
		||||
 | 
			
		||||
    error(fieldName: string) {
 | 
			
		||||
        if (!this.challenge.responseErrors) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
        return this.challenge.responseErrors[fieldName] || [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderInputError(fieldName: string) {
 | 
			
		||||
        return `${this.error(fieldName)
 | 
			
		||||
            .map((error) => {
 | 
			
		||||
                return `<div class="invalid-feedback">
 | 
			
		||||
                    ${error.string}
 | 
			
		||||
                </div>`;
 | 
			
		||||
            })
 | 
			
		||||
            .join("")}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderNonFieldErrors() {
 | 
			
		||||
        return `${this.error("non_field_errors")
 | 
			
		||||
            .map((error) => {
 | 
			
		||||
                return `<div class="alert alert-danger" role="alert">
 | 
			
		||||
                    ${error.string}
 | 
			
		||||
                </div>`;
 | 
			
		||||
            })
 | 
			
		||||
            .join("")}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    html(html: string) {
 | 
			
		||||
        this.executor.container.innerHTML = html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        throw new Error("Abstract method");
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class IdentificationStage extends Stage<IdentificationChallenge> {
 | 
			
		||||
    render() {
 | 
			
		||||
        this.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
 | 
			
		||||
                        ? `<p>
 | 
			
		||||
                              Login 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
 | 
			
		||||
                        ? `<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 data = new FormData(ev.target as HTMLFormElement);
 | 
			
		||||
            this.executor.submit(data);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class PasswordStage extends Stage<PasswordChallenge> {
 | 
			
		||||
    render() {
 | 
			
		||||
        this.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 data = new FormData(ev.target as HTMLFormElement);
 | 
			
		||||
            this.executor.submit(data);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class RedirectStage extends Stage<RedirectChallenge> {
 | 
			
		||||
    render() {
 | 
			
		||||
        window.location.assign(this.challenge.to);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AutosubmitStage extends Stage<AutosubmitChallenge> {
 | 
			
		||||
    render() {
 | 
			
		||||
        this.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 `<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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Assertion {
 | 
			
		||||
    id: string;
 | 
			
		||||
    rawId: string;
 | 
			
		||||
    type: string;
 | 
			
		||||
    registrationClientExtensions: string;
 | 
			
		||||
    response: {
 | 
			
		||||
        clientDataJSON: string;
 | 
			
		||||
        attestationObject: string;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AuthAssertion {
 | 
			
		||||
    id: string;
 | 
			
		||||
    rawId: string;
 | 
			
		||||
    type: string;
 | 
			
		||||
    assertionClientExtensions: string;
 | 
			
		||||
    response: {
 | 
			
		||||
        clientDataJSON: string;
 | 
			
		||||
        authenticatorData: string;
 | 
			
		||||
        signature: string;
 | 
			
		||||
        userHandle: string | null;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge> {
 | 
			
		||||
    deviceChallenge?: DeviceChallenge;
 | 
			
		||||
 | 
			
		||||
    b64enc(buf: Uint8Array): string {
 | 
			
		||||
        return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    b64RawEnc(buf: Uint8Array): string {
 | 
			
		||||
        return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    u8arr(input: string): Uint8Array {
 | 
			
		||||
        return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
 | 
			
		||||
            c.charCodeAt(0),
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    checkWebAuthnSupport(): boolean {
 | 
			
		||||
        if ("credentials" in navigator) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
 | 
			
		||||
            console.warn("WebAuthn requires this page to be accessed via HTTPS.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        console.warn("WebAuthn not supported by browser.");
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Transforms items in the credentialCreateOptions generated on the server
 | 
			
		||||
     * into byte arrays expected by the navigator.credentials.create() call
 | 
			
		||||
     */
 | 
			
		||||
    transformCredentialCreateOptions(
 | 
			
		||||
        credentialCreateOptions: PublicKeyCredentialCreationOptions,
 | 
			
		||||
        userId: string,
 | 
			
		||||
    ): PublicKeyCredentialCreationOptions {
 | 
			
		||||
        const user = credentialCreateOptions.user;
 | 
			
		||||
        // Because json can't contain raw bytes, the server base64-encodes the User ID
 | 
			
		||||
        // So to get the base64 encoded byte array, we first need to convert it to a regular
 | 
			
		||||
        // string, then a byte array, re-encode it and wrap that in an array.
 | 
			
		||||
        const stringId = decodeURIComponent(window.atob(userId));
 | 
			
		||||
        user.id = this.u8arr(this.b64enc(this.u8arr(stringId)));
 | 
			
		||||
        const challenge = this.u8arr(credentialCreateOptions.challenge.toString());
 | 
			
		||||
 | 
			
		||||
        const transformedCredentialCreateOptions = Object.assign({}, credentialCreateOptions, {
 | 
			
		||||
            challenge,
 | 
			
		||||
            user,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return transformedCredentialCreateOptions;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Transforms the binary data in the credential into base64 strings
 | 
			
		||||
     * for posting to the server.
 | 
			
		||||
     * @param {PublicKeyCredential} newAssertion
 | 
			
		||||
     */
 | 
			
		||||
    transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
 | 
			
		||||
        const attObj = new Uint8Array(
 | 
			
		||||
            (newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
 | 
			
		||||
        );
 | 
			
		||||
        const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
 | 
			
		||||
        const rawId = new Uint8Array(newAssertion.rawId);
 | 
			
		||||
 | 
			
		||||
        const registrationClientExtensions = newAssertion.getClientExtensionResults();
 | 
			
		||||
        return {
 | 
			
		||||
            id: newAssertion.id,
 | 
			
		||||
            rawId: this.b64enc(rawId),
 | 
			
		||||
            type: newAssertion.type,
 | 
			
		||||
            registrationClientExtensions: JSON.stringify(registrationClientExtensions),
 | 
			
		||||
            response: {
 | 
			
		||||
                clientDataJSON: this.b64enc(clientDataJSON),
 | 
			
		||||
                attestationObject: this.b64enc(attObj),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    transformCredentialRequestOptions(
 | 
			
		||||
        credentialRequestOptions: PublicKeyCredentialRequestOptions,
 | 
			
		||||
    ): PublicKeyCredentialRequestOptions {
 | 
			
		||||
        const challenge = this.u8arr(credentialRequestOptions.challenge.toString());
 | 
			
		||||
 | 
			
		||||
        const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
 | 
			
		||||
            (credentialDescriptor) => {
 | 
			
		||||
                const id = this.u8arr(credentialDescriptor.id.toString());
 | 
			
		||||
                return Object.assign({}, credentialDescriptor, { id });
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        const transformedCredentialRequestOptions = Object.assign({}, credentialRequestOptions, {
 | 
			
		||||
            challenge,
 | 
			
		||||
            allowCredentials,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return transformedCredentialRequestOptions;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Encodes the binary data in the assertion into strings for posting to the server.
 | 
			
		||||
     * @param {PublicKeyCredential} newAssertion
 | 
			
		||||
     */
 | 
			
		||||
    transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
 | 
			
		||||
        const response = newAssertion.response as AuthenticatorAssertionResponse;
 | 
			
		||||
        const authData = new Uint8Array(response.authenticatorData);
 | 
			
		||||
        const clientDataJSON = new Uint8Array(response.clientDataJSON);
 | 
			
		||||
        const rawId = new Uint8Array(newAssertion.rawId);
 | 
			
		||||
        const sig = new Uint8Array(response.signature);
 | 
			
		||||
        const assertionClientExtensions = newAssertion.getClientExtensionResults();
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            id: newAssertion.id,
 | 
			
		||||
            rawId: this.b64enc(rawId),
 | 
			
		||||
            type: newAssertion.type,
 | 
			
		||||
            assertionClientExtensions: JSON.stringify(assertionClientExtensions),
 | 
			
		||||
 | 
			
		||||
            response: {
 | 
			
		||||
                clientDataJSON: this.b64RawEnc(clientDataJSON),
 | 
			
		||||
                signature: this.b64RawEnc(sig),
 | 
			
		||||
                authenticatorData: this.b64RawEnc(authData),
 | 
			
		||||
                userHandle: null,
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        if (!this.deviceChallenge) {
 | 
			
		||||
            return this.renderChallengePicker();
 | 
			
		||||
        }
 | 
			
		||||
        switch (this.deviceChallenge.deviceClass) {
 | 
			
		||||
            case "static":
 | 
			
		||||
            case "totp":
 | 
			
		||||
                this.renderCodeInput();
 | 
			
		||||
                break;
 | 
			
		||||
            case "webauthn":
 | 
			
		||||
                this.renderWebauthn();
 | 
			
		||||
                break;
 | 
			
		||||
            default:
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderChallengePicker() {
 | 
			
		||||
        const challenges = this.challenge.deviceChallenges.filter((challenge) => {
 | 
			
		||||
            if (challenge.deviceClass === "webauthn") {
 | 
			
		||||
                if (!this.checkWebAuthnSupport()) {
 | 
			
		||||
                    return undefined;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            return challenge;
 | 
			
		||||
        });
 | 
			
		||||
        this.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
 | 
			
		||||
                        ? "<p>Select an authentication method.</p>"
 | 
			
		||||
                        : `
 | 
			
		||||
                    <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 `<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();
 | 
			
		||||
                },
 | 
			
		||||
            );
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderCodeInput() {
 | 
			
		||||
        this.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 data = new FormData(ev.target as HTMLFormElement);
 | 
			
		||||
            this.executor.submit(data);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderWebauthn() {
 | 
			
		||||
        this.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>
 | 
			
		||||
            `);
 | 
			
		||||
        navigator.credentials
 | 
			
		||||
            .get({
 | 
			
		||||
                publicKey: this.transformCredentialRequestOptions(
 | 
			
		||||
                    this.deviceChallenge?.challenge as PublicKeyCredentialRequestOptions,
 | 
			
		||||
                ),
 | 
			
		||||
            })
 | 
			
		||||
            .then((assertion) => {
 | 
			
		||||
                if (!assertion) {
 | 
			
		||||
                    throw new Error("No assertion");
 | 
			
		||||
                }
 | 
			
		||||
                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 = this.transformAssertionForServer(
 | 
			
		||||
                        assertion as PublicKeyCredential,
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    // 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 = undefined;
 | 
			
		||||
                this.render();
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const sfe = new SimpleFlowExecutor($("#flow-sfe-container")[0] as HTMLDivElement);
 | 
			
		||||
sfe.start();
 | 
			
		||||
							
								
								
									
										3057
									
								
								web/sfe/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3057
									
								
								web/sfe/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										28
									
								
								web/sfe/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								web/sfe/package.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
			
		||||
{
 | 
			
		||||
    "name": "@goauthentik/web-sfe",
 | 
			
		||||
    "version": "0.0.0",
 | 
			
		||||
    "private": true,
 | 
			
		||||
    "license": "MIT",
 | 
			
		||||
    "dependencies": {
 | 
			
		||||
        "@goauthentik/api": "^2024.6.0-1719577139",
 | 
			
		||||
        "base64-js": "^1.5.1",
 | 
			
		||||
        "bootstrap": "^4.6.1",
 | 
			
		||||
        "formdata-polyfill": "^4.0.10",
 | 
			
		||||
        "jquery": "^3.7.1",
 | 
			
		||||
        "weakmap-polyfill": "^2.0.4"
 | 
			
		||||
    },
 | 
			
		||||
    "scripts": {
 | 
			
		||||
        "build": "rollup -c rollup.config.js --bundleConfigAsCjs",
 | 
			
		||||
        "watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs"
 | 
			
		||||
    },
 | 
			
		||||
    "devDependencies": {
 | 
			
		||||
        "@rollup/plugin-commonjs": "^26.0.1",
 | 
			
		||||
        "@rollup/plugin-node-resolve": "^15.2.3",
 | 
			
		||||
        "@rollup/plugin-swc": "^0.3.1",
 | 
			
		||||
        "@swc/cli": "^0.3.14",
 | 
			
		||||
        "@swc/core": "^1.6.6",
 | 
			
		||||
        "@types/jquery": "^3.5.30",
 | 
			
		||||
        "rollup": "^4.18.0",
 | 
			
		||||
        "rollup-plugin-copy": "^3.5.0"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								web/sfe/rollup.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web/sfe/rollup.config.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
import commonjs from "@rollup/plugin-commonjs";
 | 
			
		||||
import resolve from "@rollup/plugin-node-resolve";
 | 
			
		||||
import swc from "@rollup/plugin-swc";
 | 
			
		||||
import copy from "rollup-plugin-copy";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    input: "index.ts",
 | 
			
		||||
    output: {
 | 
			
		||||
        dir: "../dist/sfe",
 | 
			
		||||
        format: "cjs",
 | 
			
		||||
    },
 | 
			
		||||
    context: "window",
 | 
			
		||||
    plugins: [
 | 
			
		||||
        copy({
 | 
			
		||||
            targets: [
 | 
			
		||||
                { src: "node_modules/bootstrap/dist/css/bootstrap.min.css", dest: "../dist/sfe" },
 | 
			
		||||
            ],
 | 
			
		||||
        }),
 | 
			
		||||
        resolve({ browser: true }),
 | 
			
		||||
        commonjs(),
 | 
			
		||||
        swc({
 | 
			
		||||
            swc: {
 | 
			
		||||
                jsc: {
 | 
			
		||||
                    loose: false,
 | 
			
		||||
                    externalHelpers: false,
 | 
			
		||||
                    // Requires v1.2.50 or upper and requires target to be es2016 or upper.
 | 
			
		||||
                    keepClassNames: false,
 | 
			
		||||
                },
 | 
			
		||||
                minify: false,
 | 
			
		||||
                env: {
 | 
			
		||||
                    targets: {
 | 
			
		||||
                        edge: "17",
 | 
			
		||||
                        ie: "11",
 | 
			
		||||
                    },
 | 
			
		||||
                    mode: "entry",
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        }),
 | 
			
		||||
    ],
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										7
									
								
								web/sfe/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								web/sfe/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
    "compilerOptions": {
 | 
			
		||||
        "types": ["jquery"],
 | 
			
		||||
        "esModuleInterop": true,
 | 
			
		||||
        "lib": ["DOM", "ES2015", "ES2017"]
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
@ -503,19 +503,17 @@ export class FlowExecutor extends Interface implements StageHost {
 | 
			
		||||
                                        <footer class="pf-c-login__footer">
 | 
			
		||||
                                            <ul class="pf-c-list pf-m-inline">
 | 
			
		||||
                                                ${this.brand?.uiFooterLinks?.map((link) => {
 | 
			
		||||
                                                    if (link.href) {
 | 
			
		||||
                                                        return html`<li>
 | 
			
		||||
                                                            <a href="${link.href}">${link.name}</a>
 | 
			
		||||
                                                        </li>`;
 | 
			
		||||
                                                    }
 | 
			
		||||
                                                    return html`<li>
 | 
			
		||||
                                                        <a href="${link.href || ""}"
 | 
			
		||||
                                                            >${link.name}</a
 | 
			
		||||
                                                        >
 | 
			
		||||
                                                        <span>${link.name}</span>
 | 
			
		||||
                                                    </li>`;
 | 
			
		||||
                                                })}
 | 
			
		||||
                                                <li>
 | 
			
		||||
                                                    <a
 | 
			
		||||
                                                        href="https://goauthentik.io?utm_source=authentik&utm_medium=flow"
 | 
			
		||||
                                                        target="_blank"
 | 
			
		||||
                                                        rel="noopener noreferrer"
 | 
			
		||||
                                                        >${msg("Powered by authentik")}</a
 | 
			
		||||
                                                    >
 | 
			
		||||
                                                    <span>${msg("Powered by authentik")}</span>
 | 
			
		||||
                                                </li>
 | 
			
		||||
                                            </ul>
 | 
			
		||||
                                        </footer>
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user