
* initial sfe Signed-off-by: Jens Langhammer <jens@goauthentik.io> * build sfe Signed-off-by: Jens Langhammer <jens@goauthentik.io> * downgrade bootstrap Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix path Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make IE compatible Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix query string missing Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add autosubmit stage Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add background image Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add code support Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add support for combo ident/password Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix logo rendering Signed-off-by: Jens Langhammer <jens@goauthentik.io> * only use for edge 18 and before Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix lint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add docs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add webauthn support Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate to TS for some creature comforts Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix ci Signed-off-by: Jens Langhammer <jens@goauthentik.io> * dedupe dependabot Signed-off-by: Jens Langhammer <jens@goauthentik.io> * use API client...kinda Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add more docs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add more polyfills yay Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> * turn powered by into span prevent issues in restricted browsers where users might not be able to return Signed-off-by: Jens Langhammer <jens@goauthentik.io> * allow non-link footer entries Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tsc errors Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Apply suggestions from code review Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com> Signed-off-by: Jens L. <jens@beryju.org> * auto switch for macos Signed-off-by: Jens Langhammer <jens@goauthentik.io> * reword Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Update website/docs/flow/executors/if-flow.md Signed-off-by: Jens L. <jens@beryju.org> * format Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Jens L. <jens@beryju.org> Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com> Signed-off-by: Jens Langhammer <jens@goauthentik.io> # Conflicts: # .github/workflows/ci-web.yml # Dockerfile # website/developer-docs/api/flow-executor.md
530 lines
19 KiB
TypeScript
530 lines
19 KiB
TypeScript
import { fromByteArray } from "base64-js";
|
|
import "formdata-polyfill";
|
|
import $ from "jquery";
|
|
import "weakmap-polyfill";
|
|
|
|
import {
|
|
type AuthenticatorValidationChallenge,
|
|
type AutosubmitChallenge,
|
|
type ChallengeTypes,
|
|
ChallengeTypesFromJSON,
|
|
type ContextualFlowInfo,
|
|
type DeviceChallenge,
|
|
type ErrorDetail,
|
|
type IdentificationChallenge,
|
|
type PasswordChallenge,
|
|
type RedirectChallenge,
|
|
} from "@goauthentik/api";
|
|
|
|
interface GlobalAuthentik {
|
|
brand: {
|
|
branding_logo: string;
|
|
};
|
|
}
|
|
|
|
function ak(): GlobalAuthentik {
|
|
return (
|
|
window as unknown as {
|
|
authentik: GlobalAuthentik;
|
|
}
|
|
).authentik;
|
|
}
|
|
|
|
class SimpleFlowExecutor {
|
|
challenge?: ChallengeTypes;
|
|
flowSlug: string;
|
|
container: HTMLDivElement;
|
|
|
|
constructor(container: HTMLDivElement) {
|
|
this.flowSlug = window.location.pathname.split("/")[3];
|
|
this.container = container;
|
|
}
|
|
|
|
get apiURL() {
|
|
return `/api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`;
|
|
}
|
|
|
|
start() {
|
|
$.ajax({
|
|
type: "GET",
|
|
url: this.apiURL,
|
|
success: (data) => {
|
|
this.challenge = ChallengeTypesFromJSON(data);
|
|
this.renderChallenge();
|
|
},
|
|
});
|
|
}
|
|
|
|
submit(data: { [key: string]: unknown } | FormData) {
|
|
$("button[type=submit]").addClass("disabled")
|
|
.html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
|
<span role="status">Loading...</span>`);
|
|
let finalData: { [key: string]: unknown } = {};
|
|
if (data instanceof FormData) {
|
|
finalData = {};
|
|
data.forEach((value, key) => {
|
|
finalData[key] = value;
|
|
});
|
|
} else {
|
|
finalData = data;
|
|
}
|
|
$.ajax({
|
|
type: "POST",
|
|
url: this.apiURL,
|
|
data: JSON.stringify(finalData),
|
|
success: (data) => {
|
|
this.challenge = ChallengeTypesFromJSON(data);
|
|
this.renderChallenge();
|
|
},
|
|
contentType: "application/json",
|
|
dataType: "json",
|
|
});
|
|
}
|
|
|
|
renderChallenge() {
|
|
switch (this.challenge?.component) {
|
|
case "ak-stage-identification":
|
|
new IdentificationStage(this, this.challenge).render();
|
|
return;
|
|
case "ak-stage-password":
|
|
new PasswordStage(this, this.challenge).render();
|
|
return;
|
|
case "xak-flow-redirect":
|
|
new RedirectStage(this, this.challenge).render();
|
|
return;
|
|
case "ak-stage-autosubmit":
|
|
new AutosubmitStage(this, this.challenge).render();
|
|
return;
|
|
case "ak-stage-authenticator-validate":
|
|
new AuthenticatorValidateStage(this, this.challenge).render();
|
|
return;
|
|
default:
|
|
this.container.innerText = "Unsupported stage: " + this.challenge?.component;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface FlowInfoChallenge {
|
|
flowInfo?: ContextualFlowInfo;
|
|
responseErrors?: {
|
|
[key: string]: Array<ErrorDetail>;
|
|
};
|
|
}
|
|
|
|
class Stage<T extends FlowInfoChallenge> {
|
|
constructor(
|
|
public executor: SimpleFlowExecutor,
|
|
public challenge: T,
|
|
) {}
|
|
|
|
error(fieldName: string) {
|
|
if (!this.challenge.responseErrors) {
|
|
return [];
|
|
}
|
|
return this.challenge.responseErrors[fieldName] || [];
|
|
}
|
|
|
|
renderInputError(fieldName: string) {
|
|
return `${this.error(fieldName)
|
|
.map((error) => {
|
|
return `<div class="invalid-feedback">
|
|
${error.string}
|
|
</div>`;
|
|
})
|
|
.join("")}`;
|
|
}
|
|
|
|
renderNonFieldErrors() {
|
|
return `${this.error("non_field_errors")
|
|
.map((error) => {
|
|
return `<div class="alert alert-danger" role="alert">
|
|
${error.string}
|
|
</div>`;
|
|
})
|
|
.join("")}`;
|
|
}
|
|
|
|
html(html: string) {
|
|
this.executor.container.innerHTML = html;
|
|
}
|
|
|
|
render() {
|
|
throw new Error("Abstract method");
|
|
}
|
|
}
|
|
|
|
class IdentificationStage extends Stage<IdentificationChallenge> {
|
|
render() {
|
|
this.html(`
|
|
<form id="ident-form">
|
|
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
|
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
|
${
|
|
this.challenge.applicationPre
|
|
? `<p>
|
|
Login to continue to ${this.challenge.applicationPre}.
|
|
</p>`
|
|
: ""
|
|
}
|
|
<div class="form-label-group my-3 has-validation">
|
|
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
|
|
</div>
|
|
${
|
|
this.challenge.passwordFields
|
|
? `<div class="form-label-group my-3 has-validation">
|
|
<input type="password" class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
|
|
${this.renderInputError("password")}
|
|
</div>`
|
|
: ""
|
|
}
|
|
${this.renderNonFieldErrors()}
|
|
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
|
|
</form>`);
|
|
$("#ident-form input[name=uid_field]").trigger("focus");
|
|
$("#ident-form").on("submit", (ev) => {
|
|
ev.preventDefault();
|
|
const data = new FormData(ev.target as HTMLFormElement);
|
|
this.executor.submit(data);
|
|
});
|
|
}
|
|
}
|
|
|
|
class PasswordStage extends Stage<PasswordChallenge> {
|
|
render() {
|
|
this.html(`
|
|
<form id="password-form">
|
|
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
|
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
|
<div class="form-label-group my-3 has-validation">
|
|
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
|
|
${this.renderInputError("password")}
|
|
</div>
|
|
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
|
|
</form>`);
|
|
$("#password-form input").trigger("focus");
|
|
$("#password-form").on("submit", (ev) => {
|
|
ev.preventDefault();
|
|
const data = new FormData(ev.target as HTMLFormElement);
|
|
this.executor.submit(data);
|
|
});
|
|
}
|
|
}
|
|
|
|
class RedirectStage extends Stage<RedirectChallenge> {
|
|
render() {
|
|
window.location.assign(this.challenge.to);
|
|
}
|
|
}
|
|
|
|
class AutosubmitStage extends Stage<AutosubmitChallenge> {
|
|
render() {
|
|
this.html(`
|
|
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
|
|
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
|
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
|
${Object.entries(this.challenge.attrs).map(([key, value]) => {
|
|
return `<input
|
|
type="hidden"
|
|
name="${key}"
|
|
value="${value}"
|
|
/>`;
|
|
})}
|
|
<div class="d-flex justify-content-center">
|
|
<div class="spinner-border" role="status">
|
|
<span class="sr-only">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</form>`);
|
|
$("#autosubmit-form").submit();
|
|
}
|
|
}
|
|
|
|
export interface Assertion {
|
|
id: string;
|
|
rawId: string;
|
|
type: string;
|
|
registrationClientExtensions: string;
|
|
response: {
|
|
clientDataJSON: string;
|
|
attestationObject: string;
|
|
};
|
|
}
|
|
|
|
export interface AuthAssertion {
|
|
id: string;
|
|
rawId: string;
|
|
type: string;
|
|
assertionClientExtensions: string;
|
|
response: {
|
|
clientDataJSON: string;
|
|
authenticatorData: string;
|
|
signature: string;
|
|
userHandle: string | null;
|
|
};
|
|
}
|
|
|
|
class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge> {
|
|
deviceChallenge?: DeviceChallenge;
|
|
|
|
b64enc(buf: Uint8Array): string {
|
|
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
}
|
|
|
|
b64RawEnc(buf: Uint8Array): string {
|
|
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
|
|
}
|
|
|
|
u8arr(input: string): Uint8Array {
|
|
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
|
|
c.charCodeAt(0),
|
|
);
|
|
}
|
|
|
|
checkWebAuthnSupport(): boolean {
|
|
if ("credentials" in navigator) {
|
|
return true;
|
|
}
|
|
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
|
|
console.warn("WebAuthn requires this page to be accessed via HTTPS.");
|
|
return false;
|
|
}
|
|
console.warn("WebAuthn not supported by browser.");
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Transforms items in the credentialCreateOptions generated on the server
|
|
* into byte arrays expected by the navigator.credentials.create() call
|
|
*/
|
|
transformCredentialCreateOptions(
|
|
credentialCreateOptions: PublicKeyCredentialCreationOptions,
|
|
userId: string,
|
|
): PublicKeyCredentialCreationOptions {
|
|
const user = credentialCreateOptions.user;
|
|
// Because json can't contain raw bytes, the server base64-encodes the User ID
|
|
// So to get the base64 encoded byte array, we first need to convert it to a regular
|
|
// string, then a byte array, re-encode it and wrap that in an array.
|
|
const stringId = decodeURIComponent(window.atob(userId));
|
|
user.id = this.u8arr(this.b64enc(this.u8arr(stringId)));
|
|
const challenge = this.u8arr(credentialCreateOptions.challenge.toString());
|
|
|
|
const transformedCredentialCreateOptions = Object.assign({}, credentialCreateOptions, {
|
|
challenge,
|
|
user,
|
|
});
|
|
|
|
return transformedCredentialCreateOptions;
|
|
}
|
|
|
|
/**
|
|
* Transforms the binary data in the credential into base64 strings
|
|
* for posting to the server.
|
|
* @param {PublicKeyCredential} newAssertion
|
|
*/
|
|
transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
|
|
const attObj = new Uint8Array(
|
|
(newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
|
|
);
|
|
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
|
|
const rawId = new Uint8Array(newAssertion.rawId);
|
|
|
|
const registrationClientExtensions = newAssertion.getClientExtensionResults();
|
|
return {
|
|
id: newAssertion.id,
|
|
rawId: this.b64enc(rawId),
|
|
type: newAssertion.type,
|
|
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
|
|
response: {
|
|
clientDataJSON: this.b64enc(clientDataJSON),
|
|
attestationObject: this.b64enc(attObj),
|
|
},
|
|
};
|
|
}
|
|
|
|
transformCredentialRequestOptions(
|
|
credentialRequestOptions: PublicKeyCredentialRequestOptions,
|
|
): PublicKeyCredentialRequestOptions {
|
|
const challenge = this.u8arr(credentialRequestOptions.challenge.toString());
|
|
|
|
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
|
|
(credentialDescriptor) => {
|
|
const id = this.u8arr(credentialDescriptor.id.toString());
|
|
return Object.assign({}, credentialDescriptor, { id });
|
|
},
|
|
);
|
|
|
|
const transformedCredentialRequestOptions = Object.assign({}, credentialRequestOptions, {
|
|
challenge,
|
|
allowCredentials,
|
|
});
|
|
|
|
return transformedCredentialRequestOptions;
|
|
}
|
|
|
|
/**
|
|
* Encodes the binary data in the assertion into strings for posting to the server.
|
|
* @param {PublicKeyCredential} newAssertion
|
|
*/
|
|
transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
|
|
const response = newAssertion.response as AuthenticatorAssertionResponse;
|
|
const authData = new Uint8Array(response.authenticatorData);
|
|
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
|
const rawId = new Uint8Array(newAssertion.rawId);
|
|
const sig = new Uint8Array(response.signature);
|
|
const assertionClientExtensions = newAssertion.getClientExtensionResults();
|
|
|
|
return {
|
|
id: newAssertion.id,
|
|
rawId: this.b64enc(rawId),
|
|
type: newAssertion.type,
|
|
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
|
|
|
|
response: {
|
|
clientDataJSON: this.b64RawEnc(clientDataJSON),
|
|
signature: this.b64RawEnc(sig),
|
|
authenticatorData: this.b64RawEnc(authData),
|
|
userHandle: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
render() {
|
|
if (!this.deviceChallenge) {
|
|
return this.renderChallengePicker();
|
|
}
|
|
switch (this.deviceChallenge.deviceClass) {
|
|
case "static":
|
|
case "totp":
|
|
this.renderCodeInput();
|
|
break;
|
|
case "webauthn":
|
|
this.renderWebauthn();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
renderChallengePicker() {
|
|
const challenges = this.challenge.deviceChallenges.filter((challenge) => {
|
|
if (challenge.deviceClass === "webauthn") {
|
|
if (!this.checkWebAuthnSupport()) {
|
|
return undefined;
|
|
}
|
|
}
|
|
return challenge;
|
|
});
|
|
this.html(`<form id="picker-form">
|
|
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
|
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
|
${
|
|
challenges.length > 0
|
|
? "<p>Select an authentication method.</p>"
|
|
: `
|
|
<p>No compatible authentication method available</p>
|
|
`
|
|
}
|
|
${challenges
|
|
.map((challenge) => {
|
|
let label = undefined;
|
|
switch (challenge.deviceClass) {
|
|
case "static":
|
|
label = "Recovery keys";
|
|
break;
|
|
case "totp":
|
|
label = "Traditional authenticator";
|
|
break;
|
|
case "webauthn":
|
|
label = "Security key";
|
|
break;
|
|
}
|
|
if (!label) {
|
|
return "";
|
|
}
|
|
return `<div class="form-label-group my-3 has-validation">
|
|
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
|
|
${label}
|
|
</button>
|
|
</div>`;
|
|
})
|
|
.join("")}
|
|
</form>`);
|
|
this.challenge.deviceChallenges.forEach((challenge) => {
|
|
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
|
|
"click",
|
|
() => {
|
|
this.deviceChallenge = challenge;
|
|
this.render();
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
renderCodeInput() {
|
|
this.html(`
|
|
<form id="totp-form">
|
|
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
|
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
|
<div class="form-label-group my-3 has-validation">
|
|
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? "is-invalid" : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
|
|
${this.renderInputError("code")}
|
|
</div>
|
|
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
|
|
</form>`);
|
|
$("#totp-form input").trigger("focus");
|
|
$("#totp-form").on("submit", (ev) => {
|
|
ev.preventDefault();
|
|
const data = new FormData(ev.target as HTMLFormElement);
|
|
this.executor.submit(data);
|
|
});
|
|
}
|
|
|
|
renderWebauthn() {
|
|
this.html(`
|
|
<form id="totp-form">
|
|
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
|
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
|
<div class="d-flex justify-content-center">
|
|
<div class="spinner-border" role="status">
|
|
<span class="sr-only">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
`);
|
|
navigator.credentials
|
|
.get({
|
|
publicKey: this.transformCredentialRequestOptions(
|
|
this.deviceChallenge?.challenge as PublicKeyCredentialRequestOptions,
|
|
),
|
|
})
|
|
.then((assertion) => {
|
|
if (!assertion) {
|
|
throw new Error("No assertion");
|
|
}
|
|
try {
|
|
// we now have an authentication assertion! encode the byte arrays contained
|
|
// in the assertion data as strings for posting to the server
|
|
const transformedAssertionForServer = this.transformAssertionForServer(
|
|
assertion as PublicKeyCredential,
|
|
);
|
|
|
|
// post the assertion to the server for verification.
|
|
this.executor.submit({
|
|
webauthn: transformedAssertionForServer,
|
|
});
|
|
} catch (err) {
|
|
throw new Error(`Error when validating assertion on server: ${err}`);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.warn(error);
|
|
this.deviceChallenge = undefined;
|
|
this.render();
|
|
});
|
|
}
|
|
}
|
|
|
|
const sfe = new SimpleFlowExecutor($("#flow-sfe-container")[0] as HTMLDivElement);
|
|
sfe.start();
|