diff --git a/README.md b/README.md
index 8708971..bcd35c1 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,17 @@ Keywind is a component-based Keycloak Login Theme built with [Tailwind CSS](http
- Login IDP Link Confirm
- Login OAuth Grant
- Login OTP
+- Login Recovery Authn Code Config
+- Login Recovery Authn Code Input
- Login Reset Password
- Login Update Password
- Login Update Profile
- Logout Confirm
- Register
+- Select Authenticator
+- WebAuthn Authenticate
+- WebAuthn Error
+- WebAuthn Register
### Identity Provider Icons
diff --git a/package.json b/package.json
index 895aceb..b41a90e 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
- "alpinejs": "^3.10.5"
+ "alpinejs": "^3.10.5",
+ "rfc4648": "^1.5.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb9396d..91ac6cd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,12 +6,14 @@ specifiers:
alpinejs: ^3.10.5
autoprefixer: ^10.4.13
postcss: ^8.4.20
+ rfc4648: ^1.5.2
tailwindcss: ^3.2.4
typescript: ^4.9.4
vite: ^4.0.1
dependencies:
alpinejs: 3.10.5
+ rfc4648: 1.5.2
devDependencies:
'@tailwindcss/forms': 0.5.3_tailwindcss@3.2.4
@@ -716,6 +718,10 @@ packages:
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
dev: true
+ /rfc4648/1.5.2:
+ resolution: {integrity: sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg==}
+ dev: false
+
/rollup/3.7.5:
resolution: {integrity: sha512-z0ZbqHBtS/et2EEUKMrAl2CoSdwN7ZPzL17UMiKN9RjjqHShTlv7F9J6ZJZJNREYjBh3TvBrdfjkFDIXFNeuiQ==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
diff --git a/src/data/recoveryCodes.ts b/src/data/recoveryCodes.ts
new file mode 100644
index 0000000..b329381
--- /dev/null
+++ b/src/data/recoveryCodes.ts
@@ -0,0 +1,59 @@
+import Alpine from 'alpinejs';
+
+type DataType = {
+ $refs: RefsType;
+ $store: StoreType;
+};
+
+type RefsType = {
+ codeList: HTMLUListElement;
+};
+
+type StoreType = {
+ recoveryCodes: {
+ downloadFileDate: string;
+ downloadFileDescription: string;
+ downloadFileHeader: string;
+ downloadFileName: string;
+ };
+};
+
+document.addEventListener('alpine:init', () => {
+ Alpine.data('recoveryCodes', function (this: DataType) {
+ const { codeList } = this.$refs;
+ const { downloadFileDate, downloadFileDescription, downloadFileHeader, downloadFileName } =
+ this.$store.recoveryCodes;
+
+ const date = new Date().toLocaleString(navigator.language);
+
+ const codeElements = codeList.getElementsByTagName('li');
+ const codes = Array.from(codeElements)
+ .map((codeElement) => codeElement.innerText)
+ .join('\n');
+
+ return {
+ copy: () => navigator.clipboard.writeText(codes),
+ download: () => {
+ const element = document.createElement('a');
+ const text = `${downloadFileHeader}\n\n${codes}\n\n${downloadFileDescription}\n\n${downloadFileDate} ${date}`;
+
+ element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
+ element.setAttribute('download', `${downloadFileName}.txt`);
+ element.click();
+ },
+ print: () => {
+ const codeListHTML = codeList.innerHTML;
+ const styles = 'div { font-family: monospace; list-style-type: none }';
+ const content = `
${downloadFileName}${downloadFileHeader}
${codeListHTML}
${downloadFileDescription}
${downloadFileDate} ${date}
`;
+
+ const printWindow = window.open();
+
+ if (printWindow) {
+ printWindow.document.write(content);
+ printWindow.print();
+ printWindow.close();
+ }
+ },
+ };
+ });
+});
diff --git a/src/data/webAuthnAuthenticate.ts b/src/data/webAuthnAuthenticate.ts
new file mode 100644
index 0000000..5b95f0a
--- /dev/null
+++ b/src/data/webAuthnAuthenticate.ts
@@ -0,0 +1,145 @@
+import Alpine from 'alpinejs';
+import { base64url } from 'rfc4648';
+
+type DataType = {
+ $refs: RefsType;
+ $store: StoreType;
+};
+
+type RefsType = {
+ authenticatorDataInput: HTMLInputElement;
+ authnSelectForm: HTMLFormElement;
+ clientDataJSONInput: HTMLInputElement;
+ credentialIdInput: HTMLInputElement;
+ errorInput: HTMLInputElement;
+ signatureInput: HTMLInputElement;
+ userHandleInput: HTMLInputElement;
+ webAuthnForm: HTMLFormElement;
+};
+
+type StoreType = {
+ webAuthnAuthenticate: {
+ challenge: string;
+ createTimeout: string;
+ isUserIdentified: string;
+ rpId: string;
+ unsupportedBrowserText: string;
+ userVerification: UserVerificationRequirement | 'not specified';
+ };
+};
+
+document.addEventListener('alpine:init', () => {
+ Alpine.data('webAuthnAuthenticate', function (this: DataType) {
+ const {
+ authenticatorDataInput,
+ authnSelectForm,
+ clientDataJSONInput,
+ credentialIdInput,
+ errorInput,
+ signatureInput,
+ userHandleInput,
+ webAuthnForm,
+ } = this.$refs;
+ const {
+ challenge,
+ createTimeout,
+ isUserIdentified,
+ rpId,
+ unsupportedBrowserText,
+ userVerification,
+ } = this.$store.webAuthnAuthenticate;
+
+ const doAuthenticate = (allowCredentials: PublicKeyCredentialDescriptor[]) => {
+ if (!window.PublicKeyCredential) {
+ errorInput.value = unsupportedBrowserText;
+ webAuthnForm.submit();
+
+ return;
+ }
+
+ const publicKey: PublicKeyCredentialRequestOptions = {
+ challenge: base64url.parse(challenge, { loose: true }),
+ rpId: rpId,
+ };
+
+ if (allowCredentials.length) {
+ publicKey.allowCredentials = allowCredentials;
+ }
+
+ if (parseInt(createTimeout) !== 0) publicKey.timeout = parseInt(createTimeout) * 1000;
+
+ if (userVerification !== 'not specified') publicKey.userVerification = userVerification;
+
+ navigator.credentials
+ .get({ publicKey })
+ .then((result) => {
+ if (
+ result instanceof PublicKeyCredential &&
+ result.response instanceof AuthenticatorAssertionResponse
+ ) {
+ window.result = result;
+
+ authenticatorDataInput.value = base64url.stringify(
+ new Uint8Array(result.response.authenticatorData),
+ { pad: false }
+ );
+
+ clientDataJSONInput.value = base64url.stringify(
+ new Uint8Array(result.response.clientDataJSON),
+ { pad: false }
+ );
+
+ signatureInput.value = base64url.stringify(new Uint8Array(result.response.signature), {
+ pad: false,
+ });
+
+ credentialIdInput.value = result.id;
+
+ if (result.response.userHandle) {
+ userHandleInput.value = base64url.stringify(
+ new Uint8Array(result.response.userHandle),
+ { pad: false }
+ );
+ }
+
+ webAuthnForm.submit();
+ }
+ })
+ .catch((error) => {
+ errorInput.value = error;
+ webAuthnForm.submit();
+ });
+ };
+
+ const checkAllowCredentials = () => {
+ const allowCredentials: PublicKeyCredentialDescriptor[] = [];
+
+ const authnSelectFormElements = Array.from(authnSelectForm.elements);
+
+ if (authnSelectFormElements.length) {
+ authnSelectFormElements.forEach((element) => {
+ if (element instanceof HTMLInputElement) {
+ allowCredentials.push({
+ id: base64url.parse(element.value, { loose: true }),
+ type: 'public-key',
+ });
+ }
+ });
+ }
+
+ doAuthenticate(allowCredentials);
+ };
+
+ return {
+ webAuthnAuthenticate: () => {
+ if (!isUserIdentified) {
+ doAuthenticate([]);
+
+ return;
+ }
+
+ checkAllowCredentials();
+ },
+ };
+ });
+});
diff --git a/src/data/webAuthnRegister.ts b/src/data/webAuthnRegister.ts
new file mode 100644
index 0000000..23744df
--- /dev/null
+++ b/src/data/webAuthnRegister.ts
@@ -0,0 +1,225 @@
+import Alpine from 'alpinejs';
+import { base64url } from 'rfc4648';
+
+type DataType = {
+ $refs: RefsType;
+ $store: StoreType;
+};
+
+type RefsType = {
+ attestationObjectInput: HTMLInputElement;
+ authenticatorLabelInput: HTMLInputElement;
+ clientDataJSONInput: HTMLInputElement;
+ errorInput: HTMLInputElement;
+ publicKeyCredentialIdInput: HTMLInputElement;
+ registerForm: HTMLFormElement;
+ transportsInput: HTMLInputElement;
+};
+
+type StoreType = {
+ webAuthnRegister: {
+ attestationConveyancePreference: AttestationConveyancePreference | 'not specified';
+ authenticatorAttachment: AuthenticatorAttachment | 'not specified';
+ challenge: string;
+ createTimeout: string;
+ excludeCredentialIds: string;
+ requireResidentKey: string;
+ rpEntityName: string;
+ rpId: string;
+ signatureAlgorithms: string;
+ unsupportedBrowserText: string;
+ userId: string;
+ userVerificationRequirement: UserVerificationRequirement | 'not specified';
+ username: string;
+ };
+};
+
+document.addEventListener('alpine:init', () => {
+ Alpine.data('webAuthnRegister', function (this: DataType) {
+ const {
+ attestationObjectInput,
+ authenticatorLabelInput,
+ clientDataJSONInput,
+ errorInput,
+ publicKeyCredentialIdInput,
+ registerForm,
+ transportsInput,
+ } = this.$refs;
+ const {
+ attestationConveyancePreference,
+ authenticatorAttachment,
+ challenge,
+ createTimeout,
+ excludeCredentialIds,
+ requireResidentKey,
+ rpEntityName,
+ rpId,
+ signatureAlgorithms,
+ unsupportedBrowserText,
+ userId,
+ userVerificationRequirement,
+ username,
+ } = this.$store.webAuthnRegister;
+
+ const getPubKeyCredParams = (signatureAlgorithms: string) => {
+ const pubKeyCredParams: PublicKeyCredentialParameters[] = [];
+
+ if (signatureAlgorithms === '') {
+ pubKeyCredParams.push({ alg: -7, type: 'public-key' });
+
+ return pubKeyCredParams;
+ }
+
+ const signatureAlgorithmsList = signatureAlgorithms.split(',');
+
+ signatureAlgorithmsList.forEach((value) => {
+ pubKeyCredParams.push({
+ alg: parseInt(value),
+ type: 'public-key',
+ });
+ });
+
+ return pubKeyCredParams;
+ };
+
+ const getExcludeCredentials = (excludeCredentialIds: string) => {
+ const excludeCredentials: PublicKeyCredentialDescriptor[] = [];
+
+ if (excludeCredentialIds === '') return excludeCredentials;
+
+ const excludeCredentialIdsList = excludeCredentialIds.split(',');
+
+ excludeCredentialIdsList.forEach((value) => {
+ excludeCredentials.push({
+ id: base64url.parse(value, { loose: true }),
+ type: 'public-key',
+ });
+ });
+
+ return excludeCredentials;
+ };
+
+ const getTransportsAsString = (transportsList: string | string[]) => {
+ if (transportsList === '' || transportsList.constructor !== Array) return '';
+
+ let transportsString = '';
+
+ transportsList.forEach((value) => {
+ transportsString += value + ',';
+ });
+
+ return transportsString.slice(0, -1);
+ };
+
+ return {
+ registerSecurityKey: () => {
+ if (!window.PublicKeyCredential) {
+ errorInput.value = unsupportedBrowserText;
+ registerForm.submit();
+
+ return;
+ }
+
+ const publicKey: PublicKeyCredentialCreationOptions = {
+ challenge: base64url.parse(challenge, { loose: true }),
+ pubKeyCredParams: getPubKeyCredParams(signatureAlgorithms),
+ rp: {
+ id: rpId,
+ name: rpEntityName,
+ },
+ user: {
+ displayName: username,
+ id: base64url.parse(userId, { loose: true }),
+ name: username,
+ },
+ };
+
+ if (attestationConveyancePreference !== 'not specified')
+ publicKey.attestation = attestationConveyancePreference;
+
+ const authenticatorSelection: AuthenticatorSelectionCriteria = {};
+ let isAuthenticatorSelectionSpecified = false;
+
+ if (authenticatorAttachment !== 'not specified') {
+ authenticatorSelection.authenticatorAttachment = authenticatorAttachment;
+ isAuthenticatorSelectionSpecified = true;
+ }
+
+ if (requireResidentKey !== 'not specified') {
+ if (requireResidentKey === 'Yes') authenticatorSelection.requireResidentKey = true;
+ else authenticatorSelection.requireResidentKey = false;
+ isAuthenticatorSelectionSpecified = true;
+ }
+
+ if (userVerificationRequirement !== 'not specified') {
+ authenticatorSelection.userVerification = userVerificationRequirement;
+ isAuthenticatorSelectionSpecified = true;
+ }
+
+ if (isAuthenticatorSelectionSpecified)
+ publicKey.authenticatorSelection = authenticatorSelection;
+
+ const excludeCredentials = getExcludeCredentials(excludeCredentialIds);
+ if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials;
+
+ if (parseInt(createTimeout) !== 0) publicKey.timeout = parseInt(createTimeout) * 1000;
+
+ navigator.credentials
+ .create({ publicKey })
+ .then((result) => {
+ if (
+ result instanceof PublicKeyCredential &&
+ result.response instanceof AuthenticatorAttestationResponse
+ ) {
+ const { getTransports } = result.response;
+
+ window.result = result;
+
+ const publicKeyCredentialId = result.rawId;
+
+ attestationObjectInput.value = base64url.stringify(
+ new Uint8Array(result.response.attestationObject),
+ { pad: false }
+ );
+
+ clientDataJSONInput.value = base64url.stringify(
+ new Uint8Array(result.response.clientDataJSON),
+ { pad: false }
+ );
+
+ publicKeyCredentialIdInput.value = base64url.stringify(
+ new Uint8Array(publicKeyCredentialId),
+ { pad: false }
+ );
+
+ if (typeof getTransports === 'function') {
+ const transports = getTransports();
+
+ if (transports) {
+ transportsInput.value = getTransportsAsString(transports);
+ }
+ } else {
+ console.log(
+ 'Your browser is not able to recognize supported transport media for the authenticator.'
+ );
+ }
+
+ const initLabel = 'WebAuthn Authenticator (Default Label)';
+ let labelResult = window.prompt(
+ "Please input your registered authenticator's label",
+ initLabel
+ );
+ if (labelResult === null) labelResult = initLabel;
+
+ authenticatorLabelInput.value = labelResult;
+ registerForm.submit();
+ }
+ })
+ .catch(function (error) {
+ error.value = error;
+ registerForm.submit();
+ });
+ },
+ };
+ });
+});
diff --git a/src/global.d.ts b/src/global.d.ts
index bbf6664..a858d3e 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -3,5 +3,6 @@ import type { Alpine } from 'alpinejs';
declare global {
interface Window {
Alpine: Alpine;
+ result?: PublicKeyCredential;
}
}
diff --git a/theme/keywind/login/components/molecules/username.ftl b/theme/keywind/login/components/molecules/username.ftl
index 0c373cb..ba63393 100644
--- a/theme/keywind/login/components/molecules/username.ftl
+++ b/theme/keywind/login/components/molecules/username.ftl
@@ -3,7 +3,7 @@
<#macro kw linkHref="" linkTitle="" name="">
-
${name}
+
${name}
<@link.kw
color="primary"
href=linkHref
diff --git a/theme/keywind/login/login-config-totp.ftl b/theme/keywind/login/login-config-totp.ftl
index 1e57ad7..e0b64c6 100644
--- a/theme/keywind/login/login-config-totp.ftl
+++ b/theme/keywind/login/login-config-totp.ftl
@@ -31,7 +31,7 @@
<#if mode?? && mode="manual">
${msg("loginTotpManualStep2")}
- ${totp.totpSecretEncoded}
+ ${totp.totpSecretEncoded}
<@link.kw color="primary" href=totp.qrUrl>
diff --git a/theme/keywind/login/login-recovery-authn-code-config.ftl b/theme/keywind/login/login-recovery-authn-code-config.ftl
new file mode 100644
index 0000000..186d710
--- /dev/null
+++ b/theme/keywind/login/login-recovery-authn-code-config.ftl
@@ -0,0 +1,91 @@
+<#import "template.ftl" as layout>
+<#import "components/atoms/alert.ftl" as alert>
+<#import "components/atoms/button.ftl" as button>
+<#import "components/atoms/button-group.ftl" as buttonGroup>
+<#import "components/atoms/checkbox.ftl" as checkbox>
+<#import "components/atoms/form.ftl" as form>
+
+<@layout.registrationLayout script="dist/recoveryCodes.js"; section>
+ <#if section="header">
+ ${msg("recovery-code-config-header")}
+ <#elseif section="form">
+
+ <@alert.kw color="warning">
+
+
${msg("recovery-code-config-warning-title")}
+
${msg("recovery-code-config-warning-message")}
+
+ @alert.kw>
+
+ <#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code>
+ - ${code[0..3]}-${code[4..7]}-${code[8..]}
+ #list>
+
+
+ <@button.kw @click="print" color="secondary" size="small" type="button">
+ ${msg("recovery-codes-print")}
+ @button.kw>
+ <@button.kw @click="download" color="secondary" size="small" type="button">
+ ${msg("recovery-codes-download")}
+ @button.kw>
+ <@button.kw @click="copy" color="secondary" size="small" type="button">
+ ${msg("recovery-codes-copy")}
+ @button.kw>
+
+ <@form.kw action=url.loginAction method="post">
+
+
+
+ <@checkbox.kw
+ label=msg("recovery-codes-confirmation-message")
+ name="kcRecoveryCodesConfirmationCheck"
+ required="required"
+ x\-ref="confirmationCheck"
+ />
+ <@buttonGroup.kw>
+ <#if isAppInitiatedAction??>
+ <@button.kw color="primary" type="submit">
+ ${msg("recovery-codes-action-complete")}
+ @button.kw>
+ <@button.kw
+ @click="$refs.confirmationCheck.required = false"
+ color="secondary"
+ name="cancel-aia"
+ type="submit"
+ value="true"
+ >
+ ${msg("recovery-codes-action-cancel")}
+ @button.kw>
+ <#else>
+ <@button.kw color="primary" type="submit">
+ ${msg("recovery-codes-action-complete")}
+ @button.kw>
+ #if>
+ @buttonGroup.kw>
+ @form.kw>
+
+ #if>
+@layout.registrationLayout>
+
+
diff --git a/theme/keywind/login/login-recovery-authn-code-input.ftl b/theme/keywind/login/login-recovery-authn-code-input.ftl
new file mode 100644
index 0000000..a46bcfa
--- /dev/null
+++ b/theme/keywind/login/login-recovery-authn-code-input.ftl
@@ -0,0 +1,26 @@
+<#import "template.ftl" as layout>
+<#import "components/atoms/button.ftl" as button>
+<#import "components/atoms/button-group.ftl" as buttonGroup>
+<#import "components/atoms/form.ftl" as form>
+<#import "components/atoms/input.ftl" as input>
+
+<@layout.registrationLayout; section>
+ <#if section="header">
+ ${msg("auth-recovery-code-header")}
+ <#elseif section="form">
+ <@form.kw action=url.loginAction method="post">
+ <@input.kw
+ autocomplete="off"
+ autofocus=true
+ label=msg("auth-recovery-code-prompt", recoveryAuthnCodesInputBean.codeNumber?c)
+ name="recoveryCodeInput"
+ type="text"
+ />
+ <@buttonGroup.kw>
+ <@button.kw color="primary" name="login" type="submit">
+ ${msg("doLogIn")}
+ @button.kw>
+ @buttonGroup.kw>
+ @form.kw>
+ #if>
+@layout.registrationLayout>
diff --git a/theme/keywind/login/resources/dist/assets/module.esm-90313d2c.js b/theme/keywind/login/resources/dist/assets/module.esm-90313d2c.js
new file mode 100644
index 0000000..c9839ef
--- /dev/null
+++ b/theme/keywind/login/resources/dist/assets/module.esm-90313d2c.js
@@ -0,0 +1,5 @@
+var Ae=!1,Oe=!1,$=[];function Ar(e){Or(e)}function Or(e){$.includes(e)||$.push(e),Cr()}function ft(e){let t=$.indexOf(e);t!==-1&&$.splice(t,1)}function Cr(){!Oe&&!Ae&&(Ae=!0,queueMicrotask(Mr))}function Mr(){Ae=!1,Oe=!0;for(let e=0;e<$.length;e++)$[e]();$.length=0,Oe=!1}var k,Y,ae,dt,Ce=!0;function Tr(e){Ce=!1,e(),Ce=!0}function $r(e){k=e.reactive,ae=e.release,Y=t=>e.effect(t,{scheduler:r=>{Ce?Ar(r):r()}}),dt=e.raw}function it(e){Y=e}function Pr(e){let t=()=>{};return[n=>{let i=Y(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),ae(i))},i},()=>{t()}]}var pt=[],_t=[],ht=[];function Ir(e){ht.push(e)}function gt(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,_t.push(t))}function Rr(e){pt.push(e)}function jr(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function vt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}var Be=new MutationObserver(qe),ze=!1;function xt(){Be.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ze=!0}function Lr(){Fr(),Be.disconnect(),ze=!1}var W=[],me=!1;function Fr(){W=W.concat(Be.takeRecords()),W.length&&!me&&(me=!0,queueMicrotask(()=>{Nr(),me=!1}))}function Nr(){qe(W),W.length=0}function x(e){if(!ze)return e();Lr();let t=e();return xt(),t}var He=!1,ie=[];function Kr(){He=!0}function Dr(){He=!1,qe(ie),ie=[]}function qe(e){if(He){ie=ie.concat(e);return}let t=[],r=[],n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.push(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.push(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,u=e[o].oldValue,c=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},l=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&u===null?c():s.hasAttribute(a)?(l(),c()):l()}i.forEach((o,s)=>{vt(s,o)}),n.forEach((o,s)=>{pt.forEach(a=>a(s,o))});for(let o of r)if(!t.includes(o)&&(_t.forEach(s=>s(o)),o._x_cleanups))for(;o._x_cleanups.length;)o._x_cleanups.pop()();t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.includes(o)||o.isConnected&&(delete o._x_ignoreSelf,delete o._x_ignore,ht.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function yt(e){return Q(N(e))}function J(e,t,r){return e._x_dataStack=[t,...N(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function ot(e,t){let r=e._x_dataStack[0];Object.entries(t).forEach(([n,i])=>{r[n]=i})}function N(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?N(e.host):e.parentNode?N(e.parentNode):[]}function Q(e){let t=new Proxy({},{ownKeys:()=>Array.from(new Set(e.flatMap(r=>Object.keys(r)))),has:(r,n)=>e.some(i=>i.hasOwnProperty(n)),get:(r,n)=>(e.find(i=>{if(i.hasOwnProperty(n)){let o=Object.getOwnPropertyDescriptor(i,n);if(o.get&&o.get._x_alreadyBound||o.set&&o.set._x_alreadyBound)return!0;if((o.get||o.set)&&o.enumerable){let s=o.get,a=o.set,u=o;s=s&&s.bind(t),a=a&&a.bind(t),s&&(s._x_alreadyBound=!0),a&&(a._x_alreadyBound=!0),Object.defineProperty(i,n,{...u,get:s,set:a})}return!0}return!1})||{})[n],set:(r,n,i)=>{let o=e.find(s=>s.hasOwnProperty(n));return o?o[n]=i:e[e.length-1][n]=i,!0}});return t}function bt(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let u=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,u,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,u)})};return r(e)}function mt(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>kr(n,i),s=>Me(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let u=n.initialize(o,s,a);return r.initialValue=u,i(o,s,a)}}else r.initialValue=n;return r}}function kr(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function Me(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Me(e[t[0]],t.slice(1),r)}}var wt={};function E(e,t){wt[e]=t}function Te(e,t){return Object.entries(wt).forEach(([r,n])=>{Object.defineProperty(e,`$${r}`,{get(){let[i,o]=Mt(t);return i={interceptor:mt,...i},gt(t,o),n(t,i)},enumerable:!1})}),e}function Br(e,t,r,...n){try{return r(...n)}catch(i){G(i,e,t)}}function G(e,t,r=void 0){Object.assign(e,{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,t),setTimeout(()=>{throw e},0)}var ne=!0;function zr(e){let t=ne;ne=!1,e(),ne=t}function F(e,t,r={}){let n;return b(e,t)(i=>n=i,r),n}function b(...e){return Et(...e)}var Et=St;function Hr(e){Et=e}function St(e,t){let r={};Te(r,e);let n=[r,...N(e)];if(typeof t=="function")return qr(n,t);let i=Ur(n,t,e);return Br.bind(null,e,t,i)}function qr(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(Q([n,...e]),i);oe(r,o)}}var we={};function Wr(e,t){if(we[e])return we[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e)||/^(let|const)\s/.test(e)?`(() => { ${e} })()`:e,o=(()=>{try{return new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`)}catch(s){return G(s,t,e),Promise.resolve()}})();return we[e]=o,o}function Ur(e,t,r){let n=Wr(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=Q([o,...e]);if(typeof n=="function"){let u=n(n,a).catch(c=>G(c,r,t));n.finished?(oe(i,n.result,a,s,r),n.result=void 0):u.then(c=>{oe(i,c,a,s,r)}).catch(c=>G(c,r,t)).finally(()=>n.result=void 0)}}}function oe(e,t,r,n,i){if(ne&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>oe(e,s,r,n)).catch(s=>G(s,i,t)):e(o)}else e(t)}var We="x-";function B(e=""){return We+e}function Vr(e){We=e}var At={};function g(e,t){At[e]=t}function Ue(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,u])=>({name:a,value:u})),s=Ot(o);o=o.map(a=>s.find(u=>u.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(Pt((o,s)=>n[o]=s)).filter(Rt).map(Jr(n,r)).sort(Qr).map(o=>Yr(e,o))}function Ot(e){return Array.from(e).map(Pt()).filter(t=>!Rt(t))}var $e=!1,q=new Map,Ct=Symbol();function Gr(e){$e=!0;let t=Symbol();Ct=t,q.set(t,[]);let r=()=>{for(;q.get(t).length;)q.get(t).shift()();q.delete(t)},n=()=>{$e=!1,r()};e(r),n()}function Mt(e){let t=[],r=a=>t.push(a),[n,i]=Pr(e);return t.push(i),[{Alpine:Z,effect:n,cleanup:r,evaluateLater:b.bind(b,e),evaluate:F.bind(F,e)},()=>t.forEach(a=>a())]}function Yr(e,t){let r=()=>{},n=At[t.type]||r,[i,o]=Mt(e);jr(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),$e?q.get(Ct).push(n):n())};return s.runCleanups=o,s}var Tt=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),$t=e=>e;function Pt(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=It.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var It=[];function Ve(e){It.push(e)}function Rt({name:e}){return jt().test(e)}var jt=()=>new RegExp(`^${We}([^:^.]+)\\b`);function Jr(e,t){return({name:r,value:n})=>{let i=r.match(jt()),o=r.match(/:([a-zA-Z0-9\-:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(u=>u.replace(".","")),expression:n,original:a}}}var Pe="DEFAULT",te=["ignore","ref","data","id","radio","tabs","switch","disclosure","menu","listbox","list","item","combobox","bind","init","for","mask","model","modelable","transition","show","if",Pe,"teleport"];function Qr(e,t){let r=te.indexOf(e.type)===-1?Pe:e.type,n=te.indexOf(t.type)===-1?Pe:t.type;return te.indexOf(r)-te.indexOf(n)}function U(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}var Ie=[],Ge=!1;function Lt(e=()=>{}){return queueMicrotask(()=>{Ge||setTimeout(()=>{Re()})}),new Promise(t=>{Ie.push(()=>{e(),t()})})}function Re(){for(Ge=!1;Ie.length;)Ie.shift()()}function Zr(){Ge=!0}function R(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>R(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)R(n,t),n=n.nextElementSibling}function K(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}function Xr(){document.body||K("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `
diff --git a/theme/keywind/login/webauthn-error.ftl b/theme/keywind/login/webauthn-error.ftl
new file mode 100644
index 0000000..852d1e3
--- /dev/null
+++ b/theme/keywind/login/webauthn-error.ftl
@@ -0,0 +1,34 @@
+<#import "template.ftl" as layout>
+<#import "components/atoms/button.ftl" as button>
+<#import "components/atoms/button-group.ftl" as buttonGroup>
+
+<@layout.registrationLayout displayMessage=true; section>
+ <#if section="header">
+ ${kcSanitize(msg("webauthn-error-title"))?no_esc}
+ <#elseif section="form">
+
+
+ <@buttonGroup.kw>
+ <@button.kw
+ @click="$refs.executionValueInput.value = '${execution}'; $refs.isSetRetryInput.value = 'retry'; $refs.errorCredentialForm.submit()"
+ color="primary"
+ name="try-again"
+ tabindex="4"
+ type="button"
+ >
+ ${kcSanitize(msg("doTryAgain"))?no_esc}
+ @button.kw>
+ <#if isAppInitiatedAction??>
+
+ #if>
+ @buttonGroup.kw>
+
+ #if>
+@layout.registrationLayout>
diff --git a/theme/keywind/login/webauthn-register.ftl b/theme/keywind/login/webauthn-register.ftl
new file mode 100644
index 0000000..57f4dad
--- /dev/null
+++ b/theme/keywind/login/webauthn-register.ftl
@@ -0,0 +1,54 @@
+<#import "template.ftl" as layout>
+<#import "components/atoms/button.ftl" as button>
+<#import "components/atoms/button-group.ftl" as buttonGroup>
+
+<@layout.registrationLayout script="dist/webAuthnRegister.js"; section>
+ <#if section="title">
+ title
+ <#elseif section="header">
+ ${kcSanitize(msg("webauthn-registration-title"))?no_esc}
+ <#elseif section="form">
+
+
+ <@buttonGroup.kw>
+ <@button.kw @click="registerSecurityKey" color="primary" type="submit">
+ ${msg("doRegister")}
+ @button.kw>
+ <#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
+
+ #if>
+ @buttonGroup.kw>
+
+ #if>
+@layout.registrationLayout>
+
+
diff --git a/vite.config.ts b/vite.config.ts
index b7539ae..3a18d54 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,7 +3,12 @@ import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
- input: 'src/index.ts',
+ input: [
+ 'src/index.ts',
+ 'src/data/recoveryCodes.ts',
+ 'src/data/webAuthnAuthenticate.ts',
+ 'src/data/webAuthnRegister.ts',
+ ],
output: {
assetFileNames: '[name][extname]',
dir: 'theme/keywind/login/resources/dist',