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
	