feat: add recovery authn code and webauthn pages (#23)

Co-authored-by: Anthony Lukin <anthony@lukin.dev>
This commit is contained in:
Constantin Wildförster 2022-11-08 14:57:53 +01:00
parent 3b4e62dc78
commit 439cdabc8f
23 changed files with 774 additions and 10 deletions

View file

@ -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

View file

@ -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",

View file

@ -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'}

59
src/data/recoveryCodes.ts Normal file
View file

@ -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 = `<html><style>${styles}</style><body><title>${downloadFileName}</title><p>${downloadFileHeader}</p><div>${codeListHTML}</div><p>${downloadFileDescription}</p><p>${downloadFileDate} ${date}</p></body></html>`;
const printWindow = window.open();
if (printWindow) {
printWindow.document.write(content);
printWindow.print();
printWindow.close();
}
},
};
});
});

View file

@ -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();
},
};
});
});

View file

@ -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();
});
},
};
});
});

1
src/global.d.ts vendored
View file

@ -3,5 +3,6 @@ import type { Alpine } from 'alpinejs';
declare global {
interface Window {
Alpine: Alpine;
result?: PublicKeyCredential;
}
}

View file

@ -3,7 +3,7 @@
<#macro kw linkHref="" linkTitle="" name="">
<div class="flex items-center justify-center mb-4 space-x-2">
<b>${name}</b>
<span class="font-medium">${name}</span>
<@link.kw
color="primary"
href=linkHref

View file

@ -31,7 +31,7 @@
<#if mode?? && mode="manual">
<li>
<p>${msg("loginTotpManualStep2")}</p>
<p class="font-bold text-xl">${totp.totpSecretEncoded}</p>
<p class="font-medium text-xl">${totp.totpSecretEncoded}</p>
</li>
<li>
<@link.kw color="primary" href=totp.qrUrl>

View file

@ -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">
<div class="space-y-6" x-data="recoveryCodes">
<@alert.kw color="warning">
<div class="space-y-2">
<h4 class="font-medium">${msg("recovery-code-config-warning-title")}</h4>
<p>${msg("recovery-code-config-warning-message")}</p>
</div>
</@alert.kw>
<ul class="columns-2 font-mono text-center" x-ref="codeList">
<#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code>
<li>${code[0..3]}-${code[4..7]}-${code[8..]}</li>
</#list>
</ul>
<div class="flex items-stretch space-x-4 mb-4">
<@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>
</div>
<@form.kw action=url.loginAction method="post">
<input
name="generatedRecoveryAuthnCodes"
type="hidden"
value="${recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}"
/>
<input
name="generatedAt"
type="hidden"
value="${recoveryAuthnCodesConfigBean.generatedAt?c}"
/>
<input
name="userLabel"
type="hidden"
value="${msg('recovery-codes-label-default')}"
/>
<@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>
</div>
</#if>
</@layout.registrationLayout>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('recoveryCodes', {
downloadFileDate: '${msg("recovery-codes-download-file-date")}',
downloadFileDescription: '${msg("recovery-codes-download-file-description")}',
downloadFileHeader: '${msg("recovery-codes-download-file-header")}',
downloadFileName: 'kc-download-recovery-codes',
})
})
</script>

View file

@ -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>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
function w(f,r,a){var l;if(a===void 0&&(a={}),!r.codes){r.codes={};for(var v=0;v<r.chars.length;++v)r.codes[r.chars[v]]=v}if(!a.loose&&f.length*r.bits&7)throw new SyntaxError("Invalid padding");for(var b=f.length;f[b-1]==="=";)if(--b,!a.loose&&!((f.length-b)*r.bits&7))throw new SyntaxError("Invalid padding");for(var i=new((l=a.out)!=null?l:Uint8Array)(b*r.bits/8|0),t=0,s=0,h=0,e=0;e<b;++e){var u=r.codes[f[e]];if(u===void 0)throw new SyntaxError("Invalid character "+f[e]);s=s<<r.bits|u,t+=r.bits,t>=8&&(t-=8,i[h++]=255&s>>t)}if(t>=r.bits||255&s<<8-t)throw new SyntaxError("Unexpected end of data");return i}function x(f,r,a){a===void 0&&(a={});for(var l=a,v=l.pad,b=v===void 0?!0:v,i=(1<<r.bits)-1,t="",s=0,h=0,e=0;e<f.length;++e)for(h=h<<8|255&f[e],s+=8;s>r.bits;)s-=r.bits,t+=r.chars[i&h>>s];if(s&&(t+=r.chars[i&h<<r.bits-s]),b)for(;t.length*r.bits&7;)t+="=";return t}var o={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",bits:6},y={parse:function(r,a){return w(r,o,a)},stringify:function(r,a){return x(r,o,a)}};export{y as b};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
import{m as p}from"./assets/module.esm-90313d2c.js";document.addEventListener("alpine:init",()=>{p.data("recoveryCodes",function(){const{codeList:o}=this.$refs,{downloadFileDate:n,downloadFileDescription:i,downloadFileHeader:d,downloadFileName:s}=this.$store.recoveryCodes,a=new Date().toLocaleString(navigator.language),r=o.getElementsByTagName("li"),l=Array.from(r).map(t=>t.innerText).join(`
`);return{copy:()=>navigator.clipboard.writeText(l),download:()=>{const t=document.createElement("a"),c=`${d}
${l}
${i}
${n} ${a}`;t.setAttribute("href","data:text/plain;charset=utf-8,"+encodeURIComponent(c)),t.setAttribute("download",`${s}.txt`),t.click()},print:()=>{const t=o.innerHTML,m=`<html><style>div { font-family: monospace; list-style-type: none }</style><body><title>${s}</title><p>${d}</p><div>${t}</div><p>${i}</p><p>${n} ${a}</p></body></html>`,e=window.open();e&&(e.document.write(m),e.print(),e.close())}}})});

View file

@ -0,0 +1 @@
import{m as g}from"./assets/module.esm-90313d2c.js";import{b as n}from"./assets/rfc4648-b81320ce.js";document.addEventListener("alpine:init",()=>{g.data("webAuthnAuthenticate",function(){const{authenticatorDataInput:c,authnSelectForm:l,clientDataJSONInput:p,credentialIdInput:f,errorInput:r,signatureInput:d,userHandleInput:h,webAuthnForm:i}=this.$refs,{challenge:m,createTimeout:s,isUserIdentified:A,rpId:w,unsupportedBrowserText:y,userVerification:o}=this.$store.webAuthnAuthenticate,u=a=>{if(!window.PublicKeyCredential){r.value=y,i.submit();return}const t={challenge:n.parse(m,{loose:!0}),rpId:w};a.length&&(t.allowCredentials=a),parseInt(s)!==0&&(t.timeout=parseInt(s)*1e3),o!=="not specified"&&(t.userVerification=o),navigator.credentials.get({publicKey:t}).then(e=>{e instanceof PublicKeyCredential&&e.response instanceof AuthenticatorAssertionResponse&&(window.result=e,c.value=n.stringify(new Uint8Array(e.response.authenticatorData),{pad:!1}),p.value=n.stringify(new Uint8Array(e.response.clientDataJSON),{pad:!1}),d.value=n.stringify(new Uint8Array(e.response.signature),{pad:!1}),f.value=e.id,e.response.userHandle&&(h.value=n.stringify(new Uint8Array(e.response.userHandle),{pad:!1})),i.submit())}).catch(e=>{r.value=e,i.submit()})},b=()=>{const a=[],t=Array.from(l.elements);t.length&&t.forEach(e=>{e instanceof HTMLInputElement&&a.push({id:n.parse(e.value,{loose:!0}),type:"public-key"})}),u(a)};return{webAuthnAuthenticate:()=>{if(!A){u([]);return}b()}}})});

View file

@ -0,0 +1 @@
import{m as T}from"./assets/module.esm-90313d2c.js";import{b as a}from"./assets/rfc4648-b81320ce.js";document.addEventListener("alpine:init",()=>{T.data("webAuthnRegister",function(){const{attestationObjectInput:m,authenticatorLabelInput:g,clientDataJSONInput:A,errorInput:I,publicKeyCredentialIdInput:w,registerForm:s,transportsInput:C}=this.$refs,{attestationConveyancePreference:u,authenticatorAttachment:c,challenge:K,createTimeout:l,excludeCredentialIds:v,requireResidentKey:p,rpEntityName:S,rpId:P,signatureAlgorithms:R,unsupportedBrowserText:x,userId:E,userVerificationRequirement:d,username:f}=this.$store.webAuthnRegister,L=t=>{const e=[];return t===""?(e.push({alg:-7,type:"public-key"}),e):(t.split(",").forEach(r=>{e.push({alg:parseInt(r),type:"public-key"})}),e)},q=t=>{const e=[];return t===""||t.split(",").forEach(r=>{e.push({id:a.parse(r,{loose:!0}),type:"public-key"})}),e},N=t=>{if(t===""||t.constructor!==Array)return"";let e="";return t.forEach(i=>{e+=i+","}),e.slice(0,-1)};return{registerSecurityKey:()=>{if(!window.PublicKeyCredential){I.value=x,s.submit();return}const t={challenge:a.parse(K,{loose:!0}),pubKeyCredParams:L(R),rp:{id:P,name:S},user:{displayName:f,id:a.parse(E,{loose:!0}),name:f}};u!=="not specified"&&(t.attestation=u);const e={};let i=!1;c!=="not specified"&&(e.authenticatorAttachment=c,i=!0),p!=="not specified"&&(p==="Yes"?e.requireResidentKey=!0:e.requireResidentKey=!1,i=!0),d!=="not specified"&&(e.userVerification=d,i=!0),i&&(t.authenticatorSelection=e);const r=q(v);r.length>0&&(t.excludeCredentials=r),parseInt(l)!==0&&(t.timeout=parseInt(l)*1e3),navigator.credentials.create({publicKey:t}).then(n=>{if(n instanceof PublicKeyCredential&&n.response instanceof AuthenticatorAttestationResponse){const{getTransports:h}=n.response;window.result=n;const O=n.rawId;if(m.value=a.stringify(new Uint8Array(n.response.attestationObject),{pad:!1}),A.value=a.stringify(new Uint8Array(n.response.clientDataJSON),{pad:!1}),w.value=a.stringify(new Uint8Array(O),{pad:!1}),typeof h=="function"){const b=h();b&&(C.value=N(b))}else console.log("Your browser is not able to recognize supported transport media for the authenticator.");const y="WebAuthn Authenticator (Default Label)";let o=window.prompt("Please input your registered authenticator's label",y);o===null&&(o=y),g.value=o,s.submit()}}).catch(function(n){n.value=n,s.submit()})}}})});

View file

@ -0,0 +1,28 @@
<#import "template.ftl" as layout>
<#import "components/atoms/form.ftl" as form>
<#import "components/atoms/link.ftl" as link>
<@layout.registrationLayout displayInfo=false; section>
<#if section="header">
${msg("loginChooseAuthenticator")}
<#elseif section="form">
<div x-data>
<@form.kw action=url.loginAction method="post" x\-ref="selectCredentialForm">
<input name="authenticationExecution" type="hidden" x-ref="authExecInput" />
<#list auth.authenticationSelections as authenticationSelection>
<div>
<@link.kw
@click="$refs.authExecInput.value = '${authenticationSelection.authExecId}'; $refs.selectCredentialForm.submit()"
color="primary"
component="button"
type="button"
>
${msg("${authenticationSelection.displayName}")}
</@link.kw>
<div class="text-sm">${msg("${authenticationSelection.helpText}")}</div>
</div>
</#list>
</@form.kw>
</div>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,71 @@
<#import "template.ftl" as layout>
<#import "components/atoms/button.ftl" as button>
<#import "components/atoms/button-group.ftl" as buttonGroup>
<@layout.registrationLayout script="dist/webAuthnAuthenticate.js"; section>
<#if section="title">
title
<#elseif section="header">
${kcSanitize(msg("webauthn-login-title"))?no_esc}
<#elseif section="form">
<div x-data="webAuthnAuthenticate">
<form action="${url.loginAction}" method="post" x-ref="webAuthnForm">
<input name="authenticatorData" type="hidden" x-ref="authenticatorDataInput" />
<input name="clientDataJSON" type="hidden" x-ref="clientDataJSONInput" />
<input name="credentialId" type="hidden" x-ref="credentialIdInput" />
<input name="error" type="hidden" x-ref="errorInput" />
<input name="signature" type="hidden" x-ref="signatureInput" />
<input name="userHandle" type="hidden" x-ref="userHandleInput" />
</form>
<#if authenticators??>
<form x-ref="authnSelectForm">
<#list authenticators.authenticators as authenticator>
<input value="${authenticator.credentialId}" type="hidden" />
</#list>
</form>
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
<#if authenticators.authenticators?size gt 1>
<p>${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}</p>
</#if>
<#list authenticators.authenticators as authenticator>
<div>
<div class="font-medium">${kcSanitize(msg("${authenticator.label}"))?no_esc}</div>
<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
<div>
<#list authenticator.transports.displayNameProperties as nameProperty>
<span>${kcSanitize(msg("${nameProperty!}"))?no_esc}</span>
<#if nameProperty?has_next>
<span>, </span>
</#if>
</#list>
</div>
</#if>
<div class="text-sm">
<span>${kcSanitize(msg("webauthn-createdAt-label"))?no_esc}</span>
<span>${kcSanitize(authenticator.createdAt)?no_esc}</span>
</div>
</div>
</#list>
</#if>
</#if>
<@buttonGroup.kw>
<@button.kw @click="webAuthnAuthenticate" color="primary" type="button">
${kcSanitize(msg("webauthn-doAuthenticate"))}
</@button.kw>
</@buttonGroup.kw>
</div>
</#if>
</@layout.registrationLayout>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('webAuthnAuthenticate', {
challenge: '${challenge}',
createTimeout: '${createTimeout}',
isUserIdentified: '${isUserIdentified}',
rpId: '${rpId}',
unsupportedBrowserText: '${msg("webauthn-unsupported-browser-text")?no_esc}',
userVerification: '${userVerification}',
})
})
</script>

View file

@ -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">
<div x-data>
<form action="${url.loginAction}" method="post" x-ref="errorCredentialForm">
<input name="authenticationExecution" type="hidden" x-ref="executionValueInput" />
<input name="isSetRetry" type="hidden" x-ref="isSetRetryInput" />
</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??>
<form action="${url.loginAction}" method="post">
<@button.kw color="secondary" name="cancel-aia" type="submit" value="true">
${msg("doCancel")}
</@button.kw>
</form>
</#if>
</@buttonGroup.kw>
</div>
</#if>
</@layout.registrationLayout>

View file

@ -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">
<div x-data="webAuthnRegister">
<form action="${url.loginAction}" method="post" x-ref="registerForm">
<input name="attestationObject" type="hidden" x-ref="attestationObjectInput" />
<input name="authenticatorLabel" type="hidden" x-ref="authenticatorLabelInput" />
<input name="clientDataJSON" type="hidden" x-ref="clientDataJSONInput" />
<input name="error" type="hidden" x-ref="errorInput" />
<input name="publicKeyCredentialId" type="hidden" x-ref="publicKeyCredentialIdInput" />
<input name="transports" type="hidden" x-ref="transportsInput" />
</form>
<@buttonGroup.kw>
<@button.kw @click="registerSecurityKey" color="primary" type="submit">
${msg("doRegister")}
</@button.kw>
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
<form action="${url.loginAction}" method="post">
<@button.kw color="secondary" name="cancel-aia" type="submit" value="true">
${msg("doCancel")}
</@button.kw>
</form>
</#if>
</@buttonGroup.kw>
</div>
</#if>
</@layout.registrationLayout>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('webAuthnRegister', {
attestationConveyancePreference: '${attestationConveyancePreference}',
authenticatorAttachment: '${authenticatorAttachment}',
challenge: '${challenge}',
createTimeout: '${createTimeout}',
excludeCredentialIds: '${excludeCredentialIds}',
requireResidentKey: '${requireResidentKey}',
rpEntityName: '${rpEntityName}',
rpId: '${rpId}',
signatureAlgorithms: '${signatureAlgorithms}',
unsupportedBrowserText: '${msg("webauthn-unsupported-browser-text")?no_esc}',
userId: '${userid}',
userVerificationRequirement: '${userVerificationRequirement}',
username: '${username}',
})
})
</script>

View file

@ -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',