Compare commits
2 Commits
webdriver-
...
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