web/flow: cleanup WebAuthn helper functions (#14460)

* pass #1

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* pass #2

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add polyfill

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-06-16 02:39:50 +02:00
committed by GitHub
parent 20e07486ee
commit e86c40a00c
8 changed files with 76 additions and 323 deletions

View File

@ -1,21 +1,5 @@
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;
@ -25,121 +9,3 @@ export function checkWebAuthnSupport() {
}
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,
},
};
}

View File

@ -1,8 +1,4 @@
import {
checkWebAuthnSupport,
transformAssertionForServer,
transformCredentialRequestOptions,
} from "@goauthentik/common/helpers/webauthn";
import { checkWebAuthnSupport } from "@goauthentik/common/helpers/webauthn";
import "@goauthentik/elements/EmptyState";
import { BaseDeviceStage } from "@goauthentik/flow/stages/authenticator_validate/base";
@ -38,12 +34,12 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
async authenticate(): Promise<void> {
// request the authenticator to create an assertion signature using the
// credential private key
let assertion;
let assertion: PublicKeyCredential;
checkWebAuthnSupport();
try {
assertion = await navigator.credentials.get({
assertion = (await navigator.credentials.get({
publicKey: this.transformedCredentialRequestOptions,
});
})) as PublicKeyCredential;
if (!assertion) {
throw new Error("Assertions is empty");
}
@ -51,17 +47,11 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
throw new Error(`Error when creating credential: ${err}`);
}
// 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(
assertion as PublicKeyCredential,
);
// post the assertion to the server for verification.
try {
await this.host?.submit(
{
webauthn: transformedAssertionForServer,
webauthn: assertion.toJSON(),
},
{
invisible: true,
@ -74,12 +64,10 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("challenge") && this.challenge !== undefined) {
// convert certain members of the PublicKeyCredentialRequestOptions into
// byte arrays as expected by the spec.
const credentialRequestOptions = this.deviceChallenge
?.challenge as PublicKeyCredentialRequestOptions;
?.challenge as unknown as PublicKeyCredentialRequestOptionsJSON;
this.transformedCredentialRequestOptions =
transformCredentialRequestOptions(credentialRequestOptions);
PublicKeyCredential.parseRequestOptionsFromJSON(credentialRequestOptions);
this.authenticateWrapper();
}
}

View File

@ -1,9 +1,4 @@
import {
Assertion,
checkWebAuthnSupport,
transformCredentialCreateOptions,
transformNewAssertionForServer,
} from "@goauthentik/common/helpers/webauthn";
import { checkWebAuthnSupport } from "@goauthentik/common/helpers/webauthn";
import "@goauthentik/elements/EmptyState";
import { BaseStage } from "@goauthentik/flow/stages/base";
@ -24,10 +19,6 @@ import {
AuthenticatorWebAuthnChallengeResponseRequest,
} from "@goauthentik/api";
export interface WebAuthnAuthenticatorRegisterChallengeResponse {
response: Assertion;
}
@customElement("ak-stage-authenticator-webauthn")
export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
AuthenticatorWebAuthnChallenge,
@ -68,7 +59,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
}
checkWebAuthnSupport();
// request the authenticator(s) to create a new credential keypair.
let credential;
let credential: PublicKeyCredential;
try {
credential = (await navigator.credentials.create({
publicKey: this.publicKeyCredentialCreateOptions,
@ -80,16 +71,12 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
throw new Error(msg(str`Error creating credential: ${err}`));
}
// we now have a new credential! We now need to encode the byte arrays
// in the credential into strings, for posting to our server.
const newAssertionForServer = transformNewAssertionForServer(credential);
// post the transformed credential data to the server for validation
// and storing the public key
try {
await this.host?.submit(
{
response: newAssertionForServer,
response: credential.toJSON(),
},
{
invisible: true,
@ -118,12 +105,10 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("challenge") && this.challenge !== undefined) {
// convert certain members of the PublicKeyCredentialCreateOptions into
// byte arrays as expected by the spec.
this.publicKeyCredentialCreateOptions = transformCredentialCreateOptions(
this.challenge?.registration as PublicKeyCredentialCreationOptions,
this.challenge?.registration.user.id,
);
this.publicKeyCredentialCreateOptions =
PublicKeyCredential.parseCreationOptionsFromJSON(
this.challenge?.registration as PublicKeyCredentialCreationOptionsJSON,
);
this.registerWrapper();
}
}

View File

@ -3,6 +3,7 @@ import "construct-style-sheets-polyfill";
import "@webcomponents/webcomponentsjs";
import "lit/polyfill-support.js";
import "core-js/actual";
import "webauthn-polyfills";
import "@formatjs/intl-listformat/polyfill";
import "@formatjs/intl-listformat/locale-data/en";