Compare commits
	
		
			2 Commits
		
	
	
		
			version/20
			...
			sfe-packag
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a9373d60d0 | |||
| 82fadf587b | 
							
								
								
									
										5
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -36,11 +36,6 @@ jobs:
 | 
			
		||||
        run: |
 | 
			
		||||
          export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
 | 
			
		||||
          npm i @goauthentik/api@$VERSION
 | 
			
		||||
      - name: Upgrade /web/packages/sfe
 | 
			
		||||
        working-directory: web/packages/sfe
 | 
			
		||||
        run: |
 | 
			
		||||
          export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
 | 
			
		||||
          npm i @goauthentik/api@$VERSION
 | 
			
		||||
      - uses: peter-evans/create-pull-request@v7
 | 
			
		||||
        id: cpr
 | 
			
		||||
        with:
 | 
			
		||||
 | 
			
		||||
@ -30,7 +30,6 @@ WORKDIR /work/web
 | 
			
		||||
 | 
			
		||||
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
 | 
			
		||||
    --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
 | 
			
		||||
    --mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
 | 
			
		||||
    --mount=type=bind,target=/work/web/scripts,src=./web/scripts \
 | 
			
		||||
    --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
 | 
			
		||||
    npm ci --include=dev
 | 
			
		||||
 | 
			
		||||
@ -49,6 +49,6 @@
 | 
			
		||||
        </main>
 | 
			
		||||
        <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      <script src="{% static 'dist/sfe/index.js' %}"></script>
 | 
			
		||||
      <script src="{% static 'dist/sfe/main.js' %}"></script>
 | 
			
		||||
    </body>
 | 
			
		||||
</html>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -1,12 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
    "name": "@goauthentik/authentik",
 | 
			
		||||
    "version": "2025.2.1",
 | 
			
		||||
    "version": "2025.2.3",
 | 
			
		||||
    "lockfileVersion": 3,
 | 
			
		||||
    "requires": true,
 | 
			
		||||
    "packages": {
 | 
			
		||||
        "": {
 | 
			
		||||
            "name": "@goauthentik/authentik",
 | 
			
		||||
            "version": "2025.2.1"
 | 
			
		||||
            "version": "2025.2.3"
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										206
									
								
								web/authentication/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								web/authentication/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,206 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file WebAuthn utilities.
 | 
			
		||||
 */
 | 
			
		||||
import { fromByteArray } from "base64-js";
 | 
			
		||||
 | 
			
		||||
//@ts-check
 | 
			
		||||
 | 
			
		||||
//#region Type Definitions
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef {object} Assertion
 | 
			
		||||
 * @property {string} id
 | 
			
		||||
 * @property {string} rawId
 | 
			
		||||
 * @property {string} type
 | 
			
		||||
 * @property {string} registrationClientExtensions
 | 
			
		||||
 * @property {object} response
 | 
			
		||||
 * @property {string} response.clientDataJSON
 | 
			
		||||
 * @property {string} response.attestationObject
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef {object} AuthAssertion
 | 
			
		||||
 * @property {string} id
 | 
			
		||||
 * @property {string} rawId
 | 
			
		||||
 * @property {string} type
 | 
			
		||||
 * @property {string} assertionClientExtensions
 | 
			
		||||
 * @property {object} response
 | 
			
		||||
 * @property {string} response.clientDataJSON
 | 
			
		||||
 * @property {string} response.authenticatorData
 | 
			
		||||
 * @property {string} response.signature
 | 
			
		||||
 * @property {string | null} response.userHandle
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
//#region Encoding/Decoding
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Encodes a byte array into a URL-safe base64 string.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {Uint8Array} buffer
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function encodeBase64(buffer) {
 | 
			
		||||
    return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, "");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Encodes a byte array into a base64 string without URL-safe encoding, i.e., with padding.
 | 
			
		||||
 * @param {Uint8Array} buffer
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function encodeBase64Raw(buffer) {
 | 
			
		||||
    return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Decodes a base64 string into a byte array.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} input
 | 
			
		||||
 * @returns {Uint8Array}
 | 
			
		||||
 */
 | 
			
		||||
export function decodeBase64(input) {
 | 
			
		||||
    return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
 | 
			
		||||
        c.charCodeAt(0),
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
//#region Utility Functions
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Checks if the browser supports WebAuthn.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean}
 | 
			
		||||
 */
 | 
			
		||||
export function isWebAuthnSupported() {
 | 
			
		||||
    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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Asserts that the browser supports WebAuthn and that we're in a secure context.
 | 
			
		||||
 *
 | 
			
		||||
 * @throws {Error} If WebAuthn is not supported.
 | 
			
		||||
 */
 | 
			
		||||
export function assertWebAuthnSupport() {
 | 
			
		||||
    // Is the navigator exposing the credentials API?
 | 
			
		||||
    if ("credentials" in navigator) return;
 | 
			
		||||
 | 
			
		||||
    if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
 | 
			
		||||
        throw new Error("WebAuthn requires this page to be accessed via HTTPS.");
 | 
			
		||||
    }
 | 
			
		||||
    throw new Error("WebAuthn not supported by browser.");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Transforms items in the credentialCreateOptions generated on the server
 | 
			
		||||
 * into byte arrays expected by the navigator.credentials.create() call
 | 
			
		||||
 * @param {PublicKeyCredentialCreationOptions} credentialCreateOptions
 | 
			
		||||
 * @param {string} userID
 | 
			
		||||
 * @returns {PublicKeyCredentialCreationOptions}
 | 
			
		||||
 */
 | 
			
		||||
export function transformCredentialCreateOptions(credentialCreateOptions, userID) {
 | 
			
		||||
    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 = decodeBase64(encodeBase64(decodeBase64(stringId)));
 | 
			
		||||
    const challenge = decodeBase64(credentialCreateOptions.challenge.toString());
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        ...credentialCreateOptions,
 | 
			
		||||
        challenge,
 | 
			
		||||
        user,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Transforms the binary data in the credential into base64 strings
 | 
			
		||||
 * for posting to the server.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {PublicKeyCredential} newAssertion
 | 
			
		||||
 * @returns {Assertion}
 | 
			
		||||
 */
 | 
			
		||||
export function transformNewAssertionForServer(newAssertion) {
 | 
			
		||||
    const response = /** @type {AuthenticatorAttestationResponse} */ (newAssertion.response);
 | 
			
		||||
 | 
			
		||||
    const attObj = new Uint8Array(response.attestationObject);
 | 
			
		||||
    const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
 | 
			
		||||
    const rawId = new Uint8Array(newAssertion.rawId);
 | 
			
		||||
 | 
			
		||||
    const registrationClientExtensions = newAssertion.getClientExtensionResults();
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        id: newAssertion.id,
 | 
			
		||||
        rawId: encodeBase64(rawId),
 | 
			
		||||
        type: newAssertion.type,
 | 
			
		||||
        registrationClientExtensions: JSON.stringify(registrationClientExtensions),
 | 
			
		||||
        response: {
 | 
			
		||||
            clientDataJSON: encodeBase64(clientDataJSON),
 | 
			
		||||
            attestationObject: encodeBase64(attObj),
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 *  Transforms  the items in the credentialRequestOptions generated on the server
 | 
			
		||||
 *
 | 
			
		||||
 * @param {PublicKeyCredentialRequestOptions} credentialRequestOptions
 | 
			
		||||
 * @returns {PublicKeyCredentialRequestOptions}
 | 
			
		||||
 */
 | 
			
		||||
export function transformCredentialRequestOptions(credentialRequestOptions) {
 | 
			
		||||
    const challenge = decodeBase64(credentialRequestOptions.challenge.toString());
 | 
			
		||||
 | 
			
		||||
    const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
 | 
			
		||||
        (credentialDescriptor) => {
 | 
			
		||||
            const id = decodeBase64(credentialDescriptor.id.toString());
 | 
			
		||||
            return Object.assign({}, credentialDescriptor, { id });
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return Object.assign({}, credentialRequestOptions, {
 | 
			
		||||
        challenge,
 | 
			
		||||
        allowCredentials,
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Encodes the binary data in the assertion into strings for posting to the server.
 | 
			
		||||
 * @param {PublicKeyCredential} newAssertion
 | 
			
		||||
 * @returns {AuthAssertion}
 | 
			
		||||
 */
 | 
			
		||||
export function transformAssertionForServer(newAssertion) {
 | 
			
		||||
    const response = /** @type {AuthenticatorAssertionResponse} */ (newAssertion.response);
 | 
			
		||||
 | 
			
		||||
    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: encodeBase64(rawId),
 | 
			
		||||
        type: newAssertion.type,
 | 
			
		||||
        assertionClientExtensions: JSON.stringify(assertionClientExtensions),
 | 
			
		||||
 | 
			
		||||
        response: {
 | 
			
		||||
            clientDataJSON: encodeBase64Raw(clientDataJSON),
 | 
			
		||||
            signature: encodeBase64Raw(sig),
 | 
			
		||||
            authenticatorData: encodeBase64Raw(authData),
 | 
			
		||||
            userHandle: null,
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
@ -48,6 +48,9 @@ export default [
 | 
			
		||||
            "lit/no-template-bind": "error",
 | 
			
		||||
            "no-unused-vars": "off",
 | 
			
		||||
            "no-console": ["error", { allow: ["debug", "warn", "error"] }],
 | 
			
		||||
            // TODO: TypeScript already handles this.
 | 
			
		||||
            // Remove after project-wide ESLint config is properly set up.
 | 
			
		||||
            "no-undef": "off",
 | 
			
		||||
            "@typescript-eslint/ban-ts-comment": "off",
 | 
			
		||||
            "@typescript-eslint/no-unused-vars": [
 | 
			
		||||
                "error",
 | 
			
		||||
@ -71,8 +74,18 @@ export default [
 | 
			
		||||
                ...globals.node,
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
        files: ["scripts/**/*.mjs", "*.ts", "*.mjs"],
 | 
			
		||||
        files: [
 | 
			
		||||
            // TODO:Remove after project-wide ESLint config is properly set up.
 | 
			
		||||
            "scripts/**/*.mjs",
 | 
			
		||||
            "authentication/**/*.js",
 | 
			
		||||
            "sfe/**/*.js",
 | 
			
		||||
            "*.ts",
 | 
			
		||||
            "*.mjs",
 | 
			
		||||
        ],
 | 
			
		||||
        rules: {
 | 
			
		||||
            "no-undef": "off",
 | 
			
		||||
            // TODO: TypeScript already handles this.
 | 
			
		||||
            // Remove after project-wide ESLint config is properly set up.
 | 
			
		||||
            "no-unused-vars": "off",
 | 
			
		||||
            // We WANT our scripts to output to the console!
 | 
			
		||||
            "no-console": "off",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1914
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1914
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -57,9 +57,14 @@
 | 
			
		||||
        "ts-pattern": "^5.4.0",
 | 
			
		||||
        "unist-util-visit": "^5.0.0",
 | 
			
		||||
        "webcomponent-qr-code": "^1.2.0",
 | 
			
		||||
        "yaml": "^2.5.1"
 | 
			
		||||
        "yaml": "^2.5.1",
 | 
			
		||||
        "bootstrap": "^4.6.1",
 | 
			
		||||
        "formdata-polyfill": "^4.0.10",
 | 
			
		||||
        "jquery": "^3.7.1",
 | 
			
		||||
        "weakmap-polyfill": "^2.0.4"
 | 
			
		||||
    },
 | 
			
		||||
    "devDependencies": {
 | 
			
		||||
        "@types/jquery": "^3.5.31",
 | 
			
		||||
        "@eslint/js": "^9.11.1",
 | 
			
		||||
        "@hcaptcha/types": "^1.0.4",
 | 
			
		||||
        "@lit/localize-tools": "^0.8.0",
 | 
			
		||||
@ -90,6 +95,8 @@
 | 
			
		||||
        "@wdio/spec-reporter": "^9.1.2",
 | 
			
		||||
        "chromedriver": "^131.0.1",
 | 
			
		||||
        "esbuild": "^0.25.0",
 | 
			
		||||
        "esbuild-plugin-copy": "^2.1.1",
 | 
			
		||||
        "esbuild-plugin-es5": "^2.1.1",
 | 
			
		||||
        "esbuild-plugin-polyfill-node": "^0.3.0",
 | 
			
		||||
        "esbuild-plugins-node-modules-polyfill": "^1.7.0",
 | 
			
		||||
        "eslint": "^9.11.1",
 | 
			
		||||
@ -161,6 +168,12 @@
 | 
			
		||||
        "watch": "run-s build-locales esbuild:watch"
 | 
			
		||||
    },
 | 
			
		||||
    "type": "module",
 | 
			
		||||
    "exports": {
 | 
			
		||||
        "./package.json": "./package.json",
 | 
			
		||||
        "./paths": "./paths.js",
 | 
			
		||||
        "./authentication": "./authentication/index.js",
 | 
			
		||||
        "./scripts/*": "./scripts/*.mjs"
 | 
			
		||||
    },
 | 
			
		||||
    "wireit": {
 | 
			
		||||
        "build": {
 | 
			
		||||
            "#comment": [
 | 
			
		||||
@ -193,8 +206,7 @@
 | 
			
		||||
                "./dist/patternfly.min.css"
 | 
			
		||||
            ],
 | 
			
		||||
            "dependencies": [
 | 
			
		||||
                "build-locales",
 | 
			
		||||
                "./packages/sfe:build"
 | 
			
		||||
                "build-locales"
 | 
			
		||||
            ],
 | 
			
		||||
            "env": {
 | 
			
		||||
                "NODE_RUNNER": {
 | 
			
		||||
@ -204,12 +216,7 @@
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "build:sfe": {
 | 
			
		||||
            "dependencies": [
 | 
			
		||||
                "./packages/sfe:build"
 | 
			
		||||
            ],
 | 
			
		||||
            "files": [
 | 
			
		||||
                "./packages/sfe/**/*.ts"
 | 
			
		||||
            ]
 | 
			
		||||
            "command": "node scripts/build-sfe.mjs"
 | 
			
		||||
        },
 | 
			
		||||
        "build-proxy": {
 | 
			
		||||
            "command": "node scripts/build-web.mjs --proxy",
 | 
			
		||||
@ -242,11 +249,6 @@
 | 
			
		||||
                "lint:package"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "format:packages": {
 | 
			
		||||
            "dependencies": [
 | 
			
		||||
                "./packages/sfe:prettier"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "lint": {
 | 
			
		||||
            "command": "eslint --max-warnings 0 --fix",
 | 
			
		||||
            "env": {
 | 
			
		||||
@ -274,11 +276,6 @@
 | 
			
		||||
            "shell": true,
 | 
			
		||||
            "command": "sh ./scripts/lint-lockfile.sh package-lock.json"
 | 
			
		||||
        },
 | 
			
		||||
        "lint:lockfiles": {
 | 
			
		||||
            "dependencies": [
 | 
			
		||||
                "./packages/sfe:lint:lockfile"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "lint:package": {
 | 
			
		||||
            "command": "syncpack format -i '    '"
 | 
			
		||||
        },
 | 
			
		||||
@ -314,9 +311,7 @@
 | 
			
		||||
                "lint:spelling",
 | 
			
		||||
                "lint:package",
 | 
			
		||||
                "lint:lockfile",
 | 
			
		||||
                "lint:lockfiles",
 | 
			
		||||
                "lint:precommit",
 | 
			
		||||
                "format:packages"
 | 
			
		||||
                "lint:precommit"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "prettier": {
 | 
			
		||||
 | 
			
		||||
@ -1,23 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
    "arrowParens": "always",
 | 
			
		||||
    "bracketSpacing": true,
 | 
			
		||||
    "embeddedLanguageFormatting": "auto",
 | 
			
		||||
    "htmlWhitespaceSensitivity": "css",
 | 
			
		||||
    "insertPragma": false,
 | 
			
		||||
    "jsxSingleQuote": false,
 | 
			
		||||
    "printWidth": 100,
 | 
			
		||||
    "proseWrap": "preserve",
 | 
			
		||||
    "quoteProps": "consistent",
 | 
			
		||||
    "requirePragma": false,
 | 
			
		||||
    "semi": true,
 | 
			
		||||
    "singleQuote": false,
 | 
			
		||||
    "tabWidth": 4,
 | 
			
		||||
    "trailingComma": "all",
 | 
			
		||||
    "useTabs": false,
 | 
			
		||||
    "vueIndentScriptAndStyle": false,
 | 
			
		||||
    "plugins": ["@trivago/prettier-plugin-sort-imports"],
 | 
			
		||||
    "importOrder": ["^(@?)lit(.*)$", "\\.css$", "^@goauthentik/api$", "^[./]"],
 | 
			
		||||
    "importOrderSeparation": true,
 | 
			
		||||
    "importOrderSortSpecifiers": true,
 | 
			
		||||
    "importOrderParserPlugins": ["typescript", "classProperties", "decorators-legacy"]
 | 
			
		||||
}
 | 
			
		||||
@ -1,18 +0,0 @@
 | 
			
		||||
The MIT License (MIT)
 | 
			
		||||
 | 
			
		||||
Copyright (c) 2024 Authentik Security, Inc.
 | 
			
		||||
 | 
			
		||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
 | 
			
		||||
associated documentation files (the "Software"), to deal in the Software without restriction,
 | 
			
		||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
 | 
			
		||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
furnished to do so, subject to the following conditions:
 | 
			
		||||
 | 
			
		||||
The above copyright notice and this permission notice shall be included in all copies or substantial
 | 
			
		||||
portions of the Software.
 | 
			
		||||
 | 
			
		||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
 | 
			
		||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 | 
			
		||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
 | 
			
		||||
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 | 
			
		||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 | 
			
		||||
@ -1,68 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
    "name": "@goauthentik/web-sfe",
 | 
			
		||||
    "version": "0.0.0",
 | 
			
		||||
    "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"
 | 
			
		||||
    },
 | 
			
		||||
    "devDependencies": {
 | 
			
		||||
        "@rollup/plugin-commonjs": "^28.0.0",
 | 
			
		||||
        "@rollup/plugin-node-resolve": "^15.3.0",
 | 
			
		||||
        "@rollup/plugin-swc": "^0.4.0",
 | 
			
		||||
        "@swc/cli": "^0.4.0",
 | 
			
		||||
        "@swc/core": "^1.7.28",
 | 
			
		||||
        "@trivago/prettier-plugin-sort-imports": "^4.3.0",
 | 
			
		||||
        "@types/jquery": "^3.5.31",
 | 
			
		||||
        "lockfile-lint": "^4.14.0",
 | 
			
		||||
        "prettier": "^3.3.2",
 | 
			
		||||
        "rollup": "^4.23.0",
 | 
			
		||||
        "rollup-plugin-copy": "^3.5.0",
 | 
			
		||||
        "wireit": "^0.14.9"
 | 
			
		||||
    },
 | 
			
		||||
    "license": "MIT",
 | 
			
		||||
    "optionalDependencies": {
 | 
			
		||||
        "@swc/core": "^1.7.28",
 | 
			
		||||
        "@swc/core-darwin-arm64": "^1.6.13",
 | 
			
		||||
        "@swc/core-darwin-x64": "^1.6.13",
 | 
			
		||||
        "@swc/core-linux-arm-gnueabihf": "^1.6.13",
 | 
			
		||||
        "@swc/core-linux-arm64-gnu": "^1.6.13",
 | 
			
		||||
        "@swc/core-linux-arm64-musl": "^1.6.13",
 | 
			
		||||
        "@swc/core-linux-x64-gnu": "^1.6.13",
 | 
			
		||||
        "@swc/core-linux-x64-musl": "^1.6.13",
 | 
			
		||||
        "@swc/core-win32-arm64-msvc": "^1.6.13",
 | 
			
		||||
        "@swc/core-win32-ia32-msvc": "^1.6.13",
 | 
			
		||||
        "@swc/core-win32-x64-msvc": "^1.6.13"
 | 
			
		||||
    },
 | 
			
		||||
    "private": true,
 | 
			
		||||
    "scripts": {
 | 
			
		||||
        "build": "wireit",
 | 
			
		||||
        "lint:lockfile": "wireit",
 | 
			
		||||
        "prettier": "prettier --write ./src ./tsconfig.json ./rollup.config.js ./package.json",
 | 
			
		||||
        "watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs"
 | 
			
		||||
    },
 | 
			
		||||
    "wireit": {
 | 
			
		||||
        "build:sfe": {
 | 
			
		||||
            "command": "rollup -c rollup.config.js --bundleConfigAsCjs",
 | 
			
		||||
            "files": [
 | 
			
		||||
                "../../node_modules/bootstrap/dist/css/bootstrap.min.css",
 | 
			
		||||
                "src/index.ts"
 | 
			
		||||
            ],
 | 
			
		||||
            "output": [
 | 
			
		||||
                "./dist/sfe/*"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "build": {
 | 
			
		||||
            "command": "mkdir -p ../../dist/sfe && cp -r dist/sfe/* ../../dist/sfe",
 | 
			
		||||
            "dependencies": [
 | 
			
		||||
                "build:sfe"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
        "lint:lockfile": {
 | 
			
		||||
            "command": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https"
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -1,43 +0,0 @@
 | 
			
		||||
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: "src/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",
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        }),
 | 
			
		||||
    ],
 | 
			
		||||
};
 | 
			
		||||
@ -1,527 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
    };
 | 
			
		||||
    api: {
 | 
			
		||||
        base: 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 `${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();
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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");
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const IS_INVALID = "is-invalid";
 | 
			
		||||
 | 
			
		||||
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>
 | 
			
		||||
                              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
 | 
			
		||||
                        ? `<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());
 | 
			
		||||
 | 
			
		||||
        return Object.assign({}, credentialCreateOptions, {
 | 
			
		||||
            challenge,
 | 
			
		||||
            user,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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 });
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return Object.assign({}, credentialRequestOptions, {
 | 
			
		||||
            challenge,
 | 
			
		||||
            allowCredentials,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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) =>
 | 
			
		||||
            challenge.deviceClass === "webauthn" && !this.checkWebAuthnSupport()
 | 
			
		||||
                ? undefined
 | 
			
		||||
                : 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();
 | 
			
		||||
@ -1,7 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
    "compilerOptions": {
 | 
			
		||||
        "types": ["jquery"],
 | 
			
		||||
        "esModuleInterop": true,
 | 
			
		||||
        "lib": ["DOM", "ES2015", "ES2017"]
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								web/paths.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								web/paths.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Path constants for the web package.
 | 
			
		||||
 */
 | 
			
		||||
import { dirname, resolve } from "node:path";
 | 
			
		||||
import { fileURLToPath } from "node:url";
 | 
			
		||||
 | 
			
		||||
const __dirname = dirname(fileURLToPath(import.meta.url));
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef {'@goauthentik/web'} WebPackageIdentifier
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The root of the web package.
 | 
			
		||||
 */
 | 
			
		||||
export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(__dirname));
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Path to the web package's distribution directory.
 | 
			
		||||
 *
 | 
			
		||||
 * This is where the built files are located after running the build process.
 | 
			
		||||
 */
 | 
			
		||||
export const DistDirectory = /** @type {`${WebPackageIdentifier}/dist`} */ (
 | 
			
		||||
    resolve(__dirname, "dist")
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										90
									
								
								web/scripts/build-sfe.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								web/scripts/build-sfe.mjs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,90 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Build script for the simplified flow executor (SFE).
 | 
			
		||||
 */
 | 
			
		||||
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
 | 
			
		||||
import esbuild from "esbuild";
 | 
			
		||||
import copy from "esbuild-plugin-copy";
 | 
			
		||||
import { es5Plugin } from "esbuild-plugin-es5";
 | 
			
		||||
import { createRequire } from "node:module";
 | 
			
		||||
import * as path from "node:path";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Builds the Simplified Flow Executor bundle.
 | 
			
		||||
 *
 | 
			
		||||
 * @remarks
 | 
			
		||||
 * The output directory and file names are referenced by the backend.
 | 
			
		||||
 * @see {@link ../../authentik/flows/templates/if/flow-sfe.html}
 | 
			
		||||
 * @returns {Promise<void>}
 | 
			
		||||
 */
 | 
			
		||||
async function buildSFE() {
 | 
			
		||||
    const require = createRequire(import.meta.url);
 | 
			
		||||
 | 
			
		||||
    const sourceDirectory = path.join(PackageRoot, "sfe");
 | 
			
		||||
 | 
			
		||||
    const entryPoint = path.join(sourceDirectory, "main.js");
 | 
			
		||||
    const outDirectory = path.join(DistDirectory, "sfe");
 | 
			
		||||
 | 
			
		||||
    const bootstrapCSSPath = require.resolve(
 | 
			
		||||
        path.join("bootstrap", "dist", "css", "bootstrap.min.css"),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {esbuild.BuildOptions}
 | 
			
		||||
     */
 | 
			
		||||
    const config = {
 | 
			
		||||
        tsconfig: path.join(sourceDirectory, "tsconfig.json"),
 | 
			
		||||
        entryPoints: [entryPoint],
 | 
			
		||||
        minify: false,
 | 
			
		||||
        bundle: true,
 | 
			
		||||
        sourcemap: true,
 | 
			
		||||
        treeShaking: true,
 | 
			
		||||
        legalComments: "external",
 | 
			
		||||
        platform: "browser",
 | 
			
		||||
        format: "iife",
 | 
			
		||||
        alias: {
 | 
			
		||||
            "@swc/helpers": path.dirname(require.resolve("@swc/helpers/package.json")),
 | 
			
		||||
        },
 | 
			
		||||
        banner: {
 | 
			
		||||
            js: [
 | 
			
		||||
                // ---
 | 
			
		||||
                "// Simplified Flow Executor (SFE)",
 | 
			
		||||
                `// Bundled on ${new Date().toISOString()}`,
 | 
			
		||||
                "// @ts-nocheck",
 | 
			
		||||
                "",
 | 
			
		||||
            ].join("\n"),
 | 
			
		||||
        },
 | 
			
		||||
        plugins: [
 | 
			
		||||
            copy({
 | 
			
		||||
                assets: [
 | 
			
		||||
                    {
 | 
			
		||||
                        from: bootstrapCSSPath,
 | 
			
		||||
                        to: outDirectory,
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            }),
 | 
			
		||||
            es5Plugin({
 | 
			
		||||
                swc: {
 | 
			
		||||
                    jsc: {
 | 
			
		||||
                        loose: false,
 | 
			
		||||
                        externalHelpers: false,
 | 
			
		||||
                        keepClassNames: false,
 | 
			
		||||
                    },
 | 
			
		||||
                    minify: false,
 | 
			
		||||
                },
 | 
			
		||||
            }),
 | 
			
		||||
        ],
 | 
			
		||||
        target: ["es5"],
 | 
			
		||||
        outdir: outDirectory,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    esbuild.build(config);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
buildSFE()
 | 
			
		||||
    .then(() => {
 | 
			
		||||
        console.log("Build complete");
 | 
			
		||||
    })
 | 
			
		||||
    .catch((error) => {
 | 
			
		||||
        console.error("Build failed", error);
 | 
			
		||||
        process.exit(1);
 | 
			
		||||
    });
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
 | 
			
		||||
import { execFileSync } from "child_process";
 | 
			
		||||
import { deepmerge } from "deepmerge-ts";
 | 
			
		||||
import esbuild from "esbuild";
 | 
			
		||||
@ -170,7 +171,7 @@ function composeVersionID() {
 | 
			
		||||
 * @throws {Error} on build failure
 | 
			
		||||
 */
 | 
			
		||||
function createEntryPointOptions([source, dest], overrides = {}) {
 | 
			
		||||
    const outdir = path.join(__dirname, "..", "dist", dest);
 | 
			
		||||
    const outdir = path.join(DistDirectory, dest);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {esbuild.BuildOptions}
 | 
			
		||||
@ -233,7 +234,7 @@ async function doWatch() {
 | 
			
		||||
                        buildObserverPlugin({
 | 
			
		||||
                            serverURL,
 | 
			
		||||
                            logPrefix: entryPoint[1],
 | 
			
		||||
                            relativeRoot: path.join(__dirname, ".."),
 | 
			
		||||
                            relativeRoot: PackageRoot,
 | 
			
		||||
                        }),
 | 
			
		||||
                    ],
 | 
			
		||||
                    define: {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								web/sfe/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								web/sfe/main.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Simplified Flow Executor (SFE) entry point.
 | 
			
		||||
 */
 | 
			
		||||
import "formdata-polyfill";
 | 
			
		||||
import $ from "jquery";
 | 
			
		||||
 | 
			
		||||
import { SimpleFlowExecutor } from "./lib/index.js";
 | 
			
		||||
 | 
			
		||||
const flowContainer = /** @type {HTMLDivElement} */ ($("#flow-sfe-container")[0]);
 | 
			
		||||
 | 
			
		||||
if (!flowContainer) {
 | 
			
		||||
    throw new Error("No flow container element found");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const sfe = new SimpleFlowExecutor(flowContainer);
 | 
			
		||||
 | 
			
		||||
sfe.start();
 | 
			
		||||
							
								
								
									
										46
									
								
								web/sfe/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								web/sfe/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
{
 | 
			
		||||
    // TODO: Replace with @goauthentik/tsconfig after project compilation.
 | 
			
		||||
    "$schema": "https://json.schemastore.org/tsconfig",
 | 
			
		||||
 | 
			
		||||
    "compilerOptions": {
 | 
			
		||||
        "paths": {
 | 
			
		||||
            "@goauthentik/web/authentication": ["../authentication/index.js"]
 | 
			
		||||
        },
 | 
			
		||||
        "alwaysStrict": true,
 | 
			
		||||
        "baseUrl": ".",
 | 
			
		||||
        "rootDir": "../",
 | 
			
		||||
        "composite": true,
 | 
			
		||||
        "declaration": true,
 | 
			
		||||
        "allowJs": true,
 | 
			
		||||
        "declarationMap": true,
 | 
			
		||||
        "isolatedModules": true,
 | 
			
		||||
        "incremental": true,
 | 
			
		||||
        "emitDeclarationOnly": true,
 | 
			
		||||
        "esModuleInterop": true,
 | 
			
		||||
        "lib": ["DOM", "ES2015", "ES2017"],
 | 
			
		||||
        "module": "NodeNext",
 | 
			
		||||
        "moduleResolution": "NodeNext",
 | 
			
		||||
        "newLine": "lf",
 | 
			
		||||
        "noFallthroughCasesInSwitch": true,
 | 
			
		||||
        "noImplicitOverride": false,
 | 
			
		||||
        "outDir": "${configDir}/out",
 | 
			
		||||
        "pretty": true,
 | 
			
		||||
        "skipDefaultLibCheck": true,
 | 
			
		||||
        "skipLibCheck": true,
 | 
			
		||||
        "sourceMap": true,
 | 
			
		||||
        "strict": true,
 | 
			
		||||
        "noUncheckedIndexedAccess": true,
 | 
			
		||||
        "target": "ESNext",
 | 
			
		||||
        "useUnknownInCatchVariables": true
 | 
			
		||||
    },
 | 
			
		||||
    "exclude": [
 | 
			
		||||
        // ---
 | 
			
		||||
        "./out/**/*",
 | 
			
		||||
        "./dist/**/*"
 | 
			
		||||
    ],
 | 
			
		||||
    "include": [
 | 
			
		||||
        // ---
 | 
			
		||||
        "./**/*.js",
 | 
			
		||||
        "../authentication/**/*.js"
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
@ -1,145 +0,0 @@
 | 
			
		||||
import * as base64js from "base64-js";
 | 
			
		||||
 | 
			
		||||
import { msg } from "@lit/localize";
 | 
			
		||||
 | 
			
		||||
export function b64enc(buf: Uint8Array): string {
 | 
			
		||||
    return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function b64RawEnc(buf: Uint8Array): string {
 | 
			
		||||
    return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function u8arr(input: string): Uint8Array {
 | 
			
		||||
    return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
 | 
			
		||||
        c.charCodeAt(0),
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function checkWebAuthnSupport() {
 | 
			
		||||
    if ("credentials" in navigator) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    if (window.location.protocol === "http:" && window.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."));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Transforms items in the credentialCreateOptions generated on the server
 | 
			
		||||
 * into byte arrays expected by the navigator.credentials.create() call
 | 
			
		||||
 */
 | 
			
		||||
export function 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 = u8arr(b64enc(u8arr(stringId)));
 | 
			
		||||
    const challenge = u8arr(credentialCreateOptions.challenge.toString());
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        ...credentialCreateOptions,
 | 
			
		||||
        challenge,
 | 
			
		||||
        user,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Assertion {
 | 
			
		||||
    id: string;
 | 
			
		||||
    rawId: string;
 | 
			
		||||
    type: string;
 | 
			
		||||
    registrationClientExtensions: string;
 | 
			
		||||
    response: {
 | 
			
		||||
        clientDataJSON: string;
 | 
			
		||||
        attestationObject: string;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Transforms the binary data in the credential into base64 strings
 | 
			
		||||
 * for posting to the server.
 | 
			
		||||
 * @param {PublicKeyCredential} newAssertion
 | 
			
		||||
 */
 | 
			
		||||
export function 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: b64enc(rawId),
 | 
			
		||||
        type: newAssertion.type,
 | 
			
		||||
        registrationClientExtensions: JSON.stringify(registrationClientExtensions),
 | 
			
		||||
        response: {
 | 
			
		||||
            clientDataJSON: b64enc(clientDataJSON),
 | 
			
		||||
            attestationObject: b64enc(attObj),
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function transformCredentialRequestOptions(
 | 
			
		||||
    credentialRequestOptions: PublicKeyCredentialRequestOptions,
 | 
			
		||||
): PublicKeyCredentialRequestOptions {
 | 
			
		||||
    const challenge = u8arr(credentialRequestOptions.challenge.toString());
 | 
			
		||||
 | 
			
		||||
    const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
 | 
			
		||||
        (credentialDescriptor) => {
 | 
			
		||||
            const id = u8arr(credentialDescriptor.id.toString());
 | 
			
		||||
            return Object.assign({}, credentialDescriptor, { id });
 | 
			
		||||
        },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        ...credentialRequestOptions,
 | 
			
		||||
        challenge,
 | 
			
		||||
        allowCredentials,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AuthAssertion {
 | 
			
		||||
    id: string;
 | 
			
		||||
    rawId: string;
 | 
			
		||||
    type: string;
 | 
			
		||||
    assertionClientExtensions: string;
 | 
			
		||||
    response: {
 | 
			
		||||
        clientDataJSON: string;
 | 
			
		||||
        authenticatorData: string;
 | 
			
		||||
        signature: string;
 | 
			
		||||
        userHandle: string | null;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Encodes the binary data in the assertion into strings for posting to the server.
 | 
			
		||||
 * @param {PublicKeyCredential} newAssertion
 | 
			
		||||
 */
 | 
			
		||||
export function 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: b64enc(rawId),
 | 
			
		||||
        type: newAssertion.type,
 | 
			
		||||
        assertionClientExtensions: JSON.stringify(assertionClientExtensions),
 | 
			
		||||
 | 
			
		||||
        response: {
 | 
			
		||||
            clientDataJSON: b64RawEnc(clientDataJSON),
 | 
			
		||||
            signature: b64RawEnc(sig),
 | 
			
		||||
            authenticatorData: b64RawEnc(authData),
 | 
			
		||||
            userHandle: null,
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
import {
 | 
			
		||||
    checkWebAuthnSupport,
 | 
			
		||||
    transformAssertionForServer,
 | 
			
		||||
    transformCredentialRequestOptions,
 | 
			
		||||
} from "@goauthentik/common/helpers/webauthn";
 | 
			
		||||
import "@goauthentik/elements/EmptyState";
 | 
			
		||||
import { BaseDeviceStage } from "@goauthentik/flow/stages/authenticator_validate/base";
 | 
			
		||||
import { assertWebAuthnSupport } from "@goauthentik/web/authentication";
 | 
			
		||||
import {
 | 
			
		||||
    transformAssertionForServer,
 | 
			
		||||
    transformCredentialRequestOptions,
 | 
			
		||||
} from "@goauthentik/web/authentication";
 | 
			
		||||
 | 
			
		||||
import { msg } from "@lit/localize";
 | 
			
		||||
import { PropertyValues, TemplateResult, html, nothing } from "lit";
 | 
			
		||||
@ -39,7 +39,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
 | 
			
		||||
        // request the authenticator to create an assertion signature using the
 | 
			
		||||
        // credential private key
 | 
			
		||||
        let assertion;
 | 
			
		||||
        checkWebAuthnSupport();
 | 
			
		||||
        assertWebAuthnSupport();
 | 
			
		||||
        try {
 | 
			
		||||
            assertion = await navigator.credentials.get({
 | 
			
		||||
                publicKey: this.transformedCredentialRequestOptions,
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,11 @@
 | 
			
		||||
import {
 | 
			
		||||
    Assertion,
 | 
			
		||||
    checkWebAuthnSupport,
 | 
			
		||||
    transformCredentialCreateOptions,
 | 
			
		||||
    transformNewAssertionForServer,
 | 
			
		||||
} from "@goauthentik/common/helpers/webauthn";
 | 
			
		||||
import "@goauthentik/elements/EmptyState";
 | 
			
		||||
import { BaseStage } from "@goauthentik/flow/stages/base";
 | 
			
		||||
import { assertWebAuthnSupport } from "@goauthentik/web/authentication";
 | 
			
		||||
import {
 | 
			
		||||
    Assertion,
 | 
			
		||||
    transformCredentialCreateOptions,
 | 
			
		||||
    transformNewAssertionForServer,
 | 
			
		||||
} from "@goauthentik/web/authentication";
 | 
			
		||||
 | 
			
		||||
import { msg, str } from "@lit/localize";
 | 
			
		||||
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
 | 
			
		||||
@ -66,7 +66,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
 | 
			
		||||
        if (!this.challenge) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        checkWebAuthnSupport();
 | 
			
		||||
        assertWebAuthnSupport();
 | 
			
		||||
        // request the authenticator(s) to create a new credential keypair.
 | 
			
		||||
        let credential;
 | 
			
		||||
        try {
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,8 @@
 | 
			
		||||
    "compilerOptions": {
 | 
			
		||||
        "strict": true,
 | 
			
		||||
        "baseUrl": ".",
 | 
			
		||||
        "emitDeclarationOnly": true,
 | 
			
		||||
        "declaration": true,
 | 
			
		||||
        "esModuleInterop": true,
 | 
			
		||||
        "paths": {
 | 
			
		||||
            "@goauthentik/docs/*": ["../website/docs/*"]
 | 
			
		||||
@ -14,6 +16,7 @@
 | 
			
		||||
            "grecaptcha"
 | 
			
		||||
        ],
 | 
			
		||||
        "jsx": "react-jsx",
 | 
			
		||||
        "allowJs": true,
 | 
			
		||||
        "skipLibCheck": true,
 | 
			
		||||
        "forceConsistentCasingInFileNames": true,
 | 
			
		||||
        "experimentalDecorators": true,
 | 
			
		||||
@ -67,5 +70,12 @@
 | 
			
		||||
            }
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    "exclude": ["src/**/*.test.ts", "./tests"]
 | 
			
		||||
    "exclude": [
 | 
			
		||||
        // ---
 | 
			
		||||
        "./out/**/*",
 | 
			
		||||
        "./dist/**/*",
 | 
			
		||||
 | 
			
		||||
        "src/**/*.test.ts",
 | 
			
		||||
        "./tests"
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,15 @@
 | 
			
		||||
{
 | 
			
		||||
    "extends": "./tsconfig.base.json",
 | 
			
		||||
    "compilerOptions": {
 | 
			
		||||
        "types": ["@wdio/types", "grecaptcha", "node", "@wdio/mocha-framework", "expect-webdriverio"],
 | 
			
		||||
        "types": [
 | 
			
		||||
            "@wdio/types",
 | 
			
		||||
            "grecaptcha",
 | 
			
		||||
            "node",
 | 
			
		||||
            "@wdio/mocha-framework",
 | 
			
		||||
            "expect-webdriverio"
 | 
			
		||||
        ],
 | 
			
		||||
        "paths": {
 | 
			
		||||
            "@goauthentik/web/authentication": ["./authentication/index.js"],
 | 
			
		||||
            "@goauthentik/admin/*": ["./src/admin/*"],
 | 
			
		||||
            "@goauthentik/common/*": ["./src/common/*"],
 | 
			
		||||
            "@goauthentik/components/*": ["./src/components/*"],
 | 
			
		||||
@ -14,5 +21,5 @@
 | 
			
		||||
            "@goauthentik/standalone/*": ["./src/standalone/*"],
 | 
			
		||||
            "@goauthentik/user/*": ["./src/user/*"]
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -31,5 +31,10 @@
 | 
			
		||||
            "@goauthentik/standalone/*": ["./src/standalone/*"],
 | 
			
		||||
            "@goauthentik/user/*": ["./src/user/*"]
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
    "exclude": [
 | 
			
		||||
        // ---
 | 
			
		||||
        "./out/**/*",
 | 
			
		||||
        "./dist/**/*"
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user