Compare commits
2 Commits
remove-ses
...
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: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$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
|
- uses: peter-evans/create-pull-request@v7
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
|
@ -30,7 +30,6 @@ WORKDIR /work/web
|
|||||||
|
|
||||||
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
|
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/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=bind,target=/work/web/scripts,src=./web/scripts \
|
||||||
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
||||||
npm ci --include=dev
|
npm ci --include=dev
|
||||||
|
@ -49,6 +49,6 @@
|
|||||||
</main>
|
</main>
|
||||||
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
||||||
</div>
|
</div>
|
||||||
<script src="{% static 'dist/sfe/index.js' %}"></script>
|
<script src="{% static 'dist/sfe/main.js' %}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@goauthentik/authentik",
|
"name": "@goauthentik/authentik",
|
||||||
"version": "2025.2.1",
|
"version": "2025.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@goauthentik/authentik",
|
"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",
|
"lit/no-template-bind": "error",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"no-console": ["error", { allow: ["debug", "warn", "error"] }],
|
"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/ban-ts-comment": "off",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
@ -71,8 +74,18 @@ export default [
|
|||||||
...globals.node,
|
...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: {
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
// TODO: TypeScript already handles this.
|
||||||
|
// Remove after project-wide ESLint config is properly set up.
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
// We WANT our scripts to output to the console!
|
// We WANT our scripts to output to the console!
|
||||||
"no-console": "off",
|
"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",
|
"ts-pattern": "^5.4.0",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"webcomponent-qr-code": "^1.2.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": {
|
"devDependencies": {
|
||||||
|
"@types/jquery": "^3.5.31",
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
"@hcaptcha/types": "^1.0.4",
|
"@hcaptcha/types": "^1.0.4",
|
||||||
"@lit/localize-tools": "^0.8.0",
|
"@lit/localize-tools": "^0.8.0",
|
||||||
@ -90,6 +95,8 @@
|
|||||||
"@wdio/spec-reporter": "^9.1.2",
|
"@wdio/spec-reporter": "^9.1.2",
|
||||||
"chromedriver": "^131.0.1",
|
"chromedriver": "^131.0.1",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"esbuild-plugin-copy": "^2.1.1",
|
||||||
|
"esbuild-plugin-es5": "^2.1.1",
|
||||||
"esbuild-plugin-polyfill-node": "^0.3.0",
|
"esbuild-plugin-polyfill-node": "^0.3.0",
|
||||||
"esbuild-plugins-node-modules-polyfill": "^1.7.0",
|
"esbuild-plugins-node-modules-polyfill": "^1.7.0",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.11.1",
|
||||||
@ -161,6 +168,12 @@
|
|||||||
"watch": "run-s build-locales esbuild:watch"
|
"watch": "run-s build-locales esbuild:watch"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./paths": "./paths.js",
|
||||||
|
"./authentication": "./authentication/index.js",
|
||||||
|
"./scripts/*": "./scripts/*.mjs"
|
||||||
|
},
|
||||||
"wireit": {
|
"wireit": {
|
||||||
"build": {
|
"build": {
|
||||||
"#comment": [
|
"#comment": [
|
||||||
@ -193,8 +206,7 @@
|
|||||||
"./dist/patternfly.min.css"
|
"./dist/patternfly.min.css"
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"build-locales",
|
"build-locales"
|
||||||
"./packages/sfe:build"
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_RUNNER": {
|
"NODE_RUNNER": {
|
||||||
@ -204,12 +216,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build:sfe": {
|
"build:sfe": {
|
||||||
"dependencies": [
|
"command": "node scripts/build-sfe.mjs"
|
||||||
"./packages/sfe:build"
|
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
"./packages/sfe/**/*.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"build-proxy": {
|
"build-proxy": {
|
||||||
"command": "node scripts/build-web.mjs --proxy",
|
"command": "node scripts/build-web.mjs --proxy",
|
||||||
@ -242,11 +249,6 @@
|
|||||||
"lint:package"
|
"lint:package"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"format:packages": {
|
|
||||||
"dependencies": [
|
|
||||||
"./packages/sfe:prettier"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lint": {
|
"lint": {
|
||||||
"command": "eslint --max-warnings 0 --fix",
|
"command": "eslint --max-warnings 0 --fix",
|
||||||
"env": {
|
"env": {
|
||||||
@ -274,11 +276,6 @@
|
|||||||
"shell": true,
|
"shell": true,
|
||||||
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
|
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
|
||||||
},
|
},
|
||||||
"lint:lockfiles": {
|
|
||||||
"dependencies": [
|
|
||||||
"./packages/sfe:lint:lockfile"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lint:package": {
|
"lint:package": {
|
||||||
"command": "syncpack format -i ' '"
|
"command": "syncpack format -i ' '"
|
||||||
},
|
},
|
||||||
@ -314,9 +311,7 @@
|
|||||||
"lint:spelling",
|
"lint:spelling",
|
||||||
"lint:package",
|
"lint:package",
|
||||||
"lint:lockfile",
|
"lint:lockfile",
|
||||||
"lint:lockfiles",
|
"lint:precommit"
|
||||||
"lint:precommit",
|
|
||||||
"format:packages"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prettier": {
|
"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 { execFileSync } from "child_process";
|
||||||
import { deepmerge } from "deepmerge-ts";
|
import { deepmerge } from "deepmerge-ts";
|
||||||
import esbuild from "esbuild";
|
import esbuild from "esbuild";
|
||||||
@ -170,7 +171,7 @@ function composeVersionID() {
|
|||||||
* @throws {Error} on build failure
|
* @throws {Error} on build failure
|
||||||
*/
|
*/
|
||||||
function createEntryPointOptions([source, dest], overrides = {}) {
|
function createEntryPointOptions([source, dest], overrides = {}) {
|
||||||
const outdir = path.join(__dirname, "..", "dist", dest);
|
const outdir = path.join(DistDirectory, dest);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {esbuild.BuildOptions}
|
* @type {esbuild.BuildOptions}
|
||||||
@ -233,7 +234,7 @@ async function doWatch() {
|
|||||||
buildObserverPlugin({
|
buildObserverPlugin({
|
||||||
serverURL,
|
serverURL,
|
||||||
logPrefix: entryPoint[1],
|
logPrefix: entryPoint[1],
|
||||||
relativeRoot: path.join(__dirname, ".."),
|
relativeRoot: PackageRoot,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
define: {
|
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 "@goauthentik/elements/EmptyState";
|
||||||
import { BaseDeviceStage } from "@goauthentik/flow/stages/authenticator_validate/base";
|
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 { msg } from "@lit/localize";
|
||||||
import { PropertyValues, TemplateResult, html, nothing } from "lit";
|
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
|
// request the authenticator to create an assertion signature using the
|
||||||
// credential private key
|
// credential private key
|
||||||
let assertion;
|
let assertion;
|
||||||
checkWebAuthnSupport();
|
assertWebAuthnSupport();
|
||||||
try {
|
try {
|
||||||
assertion = await navigator.credentials.get({
|
assertion = await navigator.credentials.get({
|
||||||
publicKey: this.transformedCredentialRequestOptions,
|
publicKey: this.transformedCredentialRequestOptions,
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import {
|
|
||||||
Assertion,
|
|
||||||
checkWebAuthnSupport,
|
|
||||||
transformCredentialCreateOptions,
|
|
||||||
transformNewAssertionForServer,
|
|
||||||
} from "@goauthentik/common/helpers/webauthn";
|
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
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 { msg, str } from "@lit/localize";
|
||||||
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
||||||
@ -66,7 +66,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
|
|||||||
if (!this.challenge) {
|
if (!this.challenge) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
checkWebAuthnSupport();
|
assertWebAuthnSupport();
|
||||||
// request the authenticator(s) to create a new credential keypair.
|
// request the authenticator(s) to create a new credential keypair.
|
||||||
let credential;
|
let credential;
|
||||||
try {
|
try {
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"declaration": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@goauthentik/docs/*": ["../website/docs/*"]
|
"@goauthentik/docs/*": ["../website/docs/*"]
|
||||||
@ -14,6 +16,7 @@
|
|||||||
"grecaptcha"
|
"grecaptcha"
|
||||||
],
|
],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"experimentalDecorators": 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",
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["@wdio/types", "grecaptcha", "node", "@wdio/mocha-framework", "expect-webdriverio"],
|
"types": [
|
||||||
|
"@wdio/types",
|
||||||
|
"grecaptcha",
|
||||||
|
"node",
|
||||||
|
"@wdio/mocha-framework",
|
||||||
|
"expect-webdriverio"
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@goauthentik/web/authentication": ["./authentication/index.js"],
|
||||||
"@goauthentik/admin/*": ["./src/admin/*"],
|
"@goauthentik/admin/*": ["./src/admin/*"],
|
||||||
"@goauthentik/common/*": ["./src/common/*"],
|
"@goauthentik/common/*": ["./src/common/*"],
|
||||||
"@goauthentik/components/*": ["./src/components/*"],
|
"@goauthentik/components/*": ["./src/components/*"],
|
||||||
@ -14,5 +21,5 @@
|
|||||||
"@goauthentik/standalone/*": ["./src/standalone/*"],
|
"@goauthentik/standalone/*": ["./src/standalone/*"],
|
||||||
"@goauthentik/user/*": ["./src/user/*"]
|
"@goauthentik/user/*": ["./src/user/*"]
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,5 +31,10 @@
|
|||||||
"@goauthentik/standalone/*": ["./src/standalone/*"],
|
"@goauthentik/standalone/*": ["./src/standalone/*"],
|
||||||
"@goauthentik/user/*": ["./src/user/*"]
|
"@goauthentik/user/*": ["./src/user/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"exclude": [
|
||||||
|
// ---
|
||||||
|
"./out/**/*",
|
||||||
|
"./dist/**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user