Skip to content

Commit

Permalink
feat: add recovery authn code and webauthn pages (#23)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Lukin <anthony@lukin.dev>
  • Loading branch information
cwildfoerster and lukin committed Nov 8, 2022
1 parent 3b4e62d commit 439cdab
Show file tree
Hide file tree
Showing 23 changed files with 774 additions and 10 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions src/data/recoveryCodes.ts
Original file line number Diff line number Diff line change
@@ -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();
}
},
};
});
});
145 changes: 145 additions & 0 deletions src/data/webAuthnAuthenticate.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
});
});
Loading

0 comments on commit 439cdab

Please sign in to comment.