From 439cdabc8f07cc8756c15f2b0ee938b21247413f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Constantin=20Wildf=C3=B6rster?= Date: Tue, 8 Nov 2022 14:57:53 +0100 Subject: [PATCH] feat: add recovery authn code and webauthn pages (#23) Co-authored-by: Anthony Lukin --- README.md | 6 + package.json | 3 +- pnpm-lock.yaml | 6 + src/data/recoveryCodes.ts | 59 +++++ src/data/webAuthnAuthenticate.ts | 145 +++++++++++ src/data/webAuthnRegister.ts | 225 ++++++++++++++++++ src/global.d.ts | 1 + .../login/components/molecules/username.ftl | 2 +- theme/keywind/login/login-config-totp.ftl | 2 +- .../login-recovery-authn-code-config.ftl | 91 +++++++ .../login/login-recovery-authn-code-input.ftl | 26 ++ .../dist/assets/module.esm-90313d2c.js | 5 + .../resources/dist/assets/rfc4648-b81320ce.js | 1 + theme/keywind/login/resources/dist/index.css | 2 +- theme/keywind/login/resources/dist/index.js | 6 +- .../login/resources/dist/recoveryCodes.js | 8 + .../resources/dist/webAuthnAuthenticate.js | 1 + .../login/resources/dist/webAuthnRegister.js | 1 + theme/keywind/login/select-authenticator.ftl | 28 +++ theme/keywind/login/webauthn-authenticate.ftl | 71 ++++++ theme/keywind/login/webauthn-error.ftl | 34 +++ theme/keywind/login/webauthn-register.ftl | 54 +++++ vite.config.ts | 7 +- 23 files changed, 774 insertions(+), 10 deletions(-) create mode 100644 src/data/recoveryCodes.ts create mode 100644 src/data/webAuthnAuthenticate.ts create mode 100644 src/data/webAuthnRegister.ts create mode 100644 theme/keywind/login/login-recovery-authn-code-config.ftl create mode 100644 theme/keywind/login/login-recovery-authn-code-input.ftl create mode 100644 theme/keywind/login/resources/dist/assets/module.esm-90313d2c.js create mode 100644 theme/keywind/login/resources/dist/assets/rfc4648-b81320ce.js create mode 100644 theme/keywind/login/resources/dist/recoveryCodes.js create mode 100644 theme/keywind/login/resources/dist/webAuthnAuthenticate.js create mode 100644 theme/keywind/login/resources/dist/webAuthnRegister.js create mode 100644 theme/keywind/login/select-authenticator.ftl create mode 100644 theme/keywind/login/webauthn-authenticate.ftl create mode 100644 theme/keywind/login/webauthn-error.ftl create mode 100644 theme/keywind/login/webauthn-register.ftl 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")}

    +
    + +
      + <#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code> +
    • ${code[0..3]}-${code[4..7]}-${code[8..]}
    • + +
    +
    + <@button.kw @click="print" color="secondary" size="small" type="button"> + ${msg("recovery-codes-print")} + + <@button.kw @click="download" color="secondary" size="small" type="button"> + ${msg("recovery-codes-download")} + + <@button.kw @click="copy" color="secondary" size="small" type="button"> + ${msg("recovery-codes-copy")} + +
    + <@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 + @click="$refs.confirmationCheck.required = false" + color="secondary" + name="cancel-aia" + type="submit" + value="true" + > + ${msg("recovery-codes-action-cancel")} + + <#else> + <@button.kw color="primary" type="submit"> + ${msg("recovery-codes-action-complete")} + + + + +
    + + + + 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")} + + + + + 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} + + <#if isAppInitiatedAction??> +
    + <@button.kw color="secondary" name="cancel-aia" type="submit" value="true"> + ${msg("doCancel")} + +
    + + +
    + + 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")} + + <#if !isSetRetry?has_content && isAppInitiatedAction?has_content> +
    + <@button.kw color="secondary" name="cancel-aia" type="submit" value="true"> + ${msg("doCancel")} + +
    + + +
    + + + + 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',