Skip to content

Commit 439cdab

Browse files
cwildfoersterlukin
andcommitted
feat: add recovery authn code and webauthn pages (lukin#23)
Co-authored-by: Anthony Lukin <[email protected]>
1 parent 3b4e62d commit 439cdab

23 files changed

+774
-10
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ Keywind is a component-based Keycloak Login Theme built with [Tailwind CSS](http
1111
- Login IDP Link Confirm
1212
- Login OAuth Grant
1313
- Login OTP
14+
- Login Recovery Authn Code Config
15+
- Login Recovery Authn Code Input
1416
- Login Reset Password
1517
- Login Update Password
1618
- Login Update Profile
1719
- Logout Confirm
1820
- Register
21+
- Select Authenticator
22+
- WebAuthn Authenticate
23+
- WebAuthn Error
24+
- WebAuthn Register
1925

2026
### Identity Provider Icons
2127

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"test": "echo \"Error: no test specified\" && exit 1"
77
},
88
"dependencies": {
9-
"alpinejs": "^3.10.5"
9+
"alpinejs": "^3.10.5",
10+
"rfc4648": "^1.5.2"
1011
},
1112
"devDependencies": {
1213
"@tailwindcss/forms": "^0.5.3",

pnpm-lock.yaml

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/data/recoveryCodes.ts

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Alpine from 'alpinejs';
2+
3+
type DataType = {
4+
$refs: RefsType;
5+
$store: StoreType;
6+
};
7+
8+
type RefsType = {
9+
codeList: HTMLUListElement;
10+
};
11+
12+
type StoreType = {
13+
recoveryCodes: {
14+
downloadFileDate: string;
15+
downloadFileDescription: string;
16+
downloadFileHeader: string;
17+
downloadFileName: string;
18+
};
19+
};
20+
21+
document.addEventListener('alpine:init', () => {
22+
Alpine.data('recoveryCodes', function (this: DataType) {
23+
const { codeList } = this.$refs;
24+
const { downloadFileDate, downloadFileDescription, downloadFileHeader, downloadFileName } =
25+
this.$store.recoveryCodes;
26+
27+
const date = new Date().toLocaleString(navigator.language);
28+
29+
const codeElements = codeList.getElementsByTagName('li');
30+
const codes = Array.from(codeElements)
31+
.map((codeElement) => codeElement.innerText)
32+
.join('\n');
33+
34+
return {
35+
copy: () => navigator.clipboard.writeText(codes),
36+
download: () => {
37+
const element = document.createElement('a');
38+
const text = `${downloadFileHeader}\n\n${codes}\n\n${downloadFileDescription}\n\n${downloadFileDate} ${date}`;
39+
40+
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
41+
element.setAttribute('download', `${downloadFileName}.txt`);
42+
element.click();
43+
},
44+
print: () => {
45+
const codeListHTML = codeList.innerHTML;
46+
const styles = 'div { font-family: monospace; list-style-type: none }';
47+
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>`;
48+
49+
const printWindow = window.open();
50+
51+
if (printWindow) {
52+
printWindow.document.write(content);
53+
printWindow.print();
54+
printWindow.close();
55+
}
56+
},
57+
};
58+
});
59+
});

src/data/webAuthnAuthenticate.ts

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import Alpine from 'alpinejs';
2+
import { base64url } from 'rfc4648';
3+
4+
type DataType = {
5+
$refs: RefsType;
6+
$store: StoreType;
7+
};
8+
9+
type RefsType = {
10+
authenticatorDataInput: HTMLInputElement;
11+
authnSelectForm: HTMLFormElement;
12+
clientDataJSONInput: HTMLInputElement;
13+
credentialIdInput: HTMLInputElement;
14+
errorInput: HTMLInputElement;
15+
signatureInput: HTMLInputElement;
16+
userHandleInput: HTMLInputElement;
17+
webAuthnForm: HTMLFormElement;
18+
};
19+
20+
type StoreType = {
21+
webAuthnAuthenticate: {
22+
challenge: string;
23+
createTimeout: string;
24+
isUserIdentified: string;
25+
rpId: string;
26+
unsupportedBrowserText: string;
27+
userVerification: UserVerificationRequirement | 'not specified';
28+
};
29+
};
30+
31+
document.addEventListener('alpine:init', () => {
32+
Alpine.data('webAuthnAuthenticate', function (this: DataType) {
33+
const {
34+
authenticatorDataInput,
35+
authnSelectForm,
36+
clientDataJSONInput,
37+
credentialIdInput,
38+
errorInput,
39+
signatureInput,
40+
userHandleInput,
41+
webAuthnForm,
42+
} = this.$refs;
43+
const {
44+
challenge,
45+
createTimeout,
46+
isUserIdentified,
47+
rpId,
48+
unsupportedBrowserText,
49+
userVerification,
50+
} = this.$store.webAuthnAuthenticate;
51+
52+
const doAuthenticate = (allowCredentials: PublicKeyCredentialDescriptor[]) => {
53+
if (!window.PublicKeyCredential) {
54+
errorInput.value = unsupportedBrowserText;
55+
webAuthnForm.submit();
56+
57+
return;
58+
}
59+
60+
const publicKey: PublicKeyCredentialRequestOptions = {
61+
challenge: base64url.parse(challenge, { loose: true }),
62+
rpId: rpId,
63+
};
64+
65+
if (allowCredentials.length) {
66+
publicKey.allowCredentials = allowCredentials;
67+
}
68+
69+
if (parseInt(createTimeout) !== 0) publicKey.timeout = parseInt(createTimeout) * 1000;
70+
71+
if (userVerification !== 'not specified') publicKey.userVerification = userVerification;
72+
73+
navigator.credentials
74+
.get({ publicKey })
75+
.then((result) => {
76+
if (
77+
result instanceof PublicKeyCredential &&
78+
result.response instanceof AuthenticatorAssertionResponse
79+
) {
80+
window.result = result;
81+
82+
authenticatorDataInput.value = base64url.stringify(
83+
new Uint8Array(result.response.authenticatorData),
84+
{ pad: false }
85+
);
86+
87+
clientDataJSONInput.value = base64url.stringify(
88+
new Uint8Array(result.response.clientDataJSON),
89+
{ pad: false }
90+
);
91+
92+
signatureInput.value = base64url.stringify(new Uint8Array(result.response.signature), {
93+
pad: false,
94+
});
95+
96+
credentialIdInput.value = result.id;
97+
98+
if (result.response.userHandle) {
99+
userHandleInput.value = base64url.stringify(
100+
new Uint8Array(result.response.userHandle),
101+
{ pad: false }
102+
);
103+
}
104+
105+
webAuthnForm.submit();
106+
}
107+
})
108+
.catch((error) => {
109+
errorInput.value = error;
110+
webAuthnForm.submit();
111+
});
112+
};
113+
114+
const checkAllowCredentials = () => {
115+
const allowCredentials: PublicKeyCredentialDescriptor[] = [];
116+
117+
const authnSelectFormElements = Array.from(authnSelectForm.elements);
118+
119+
if (authnSelectFormElements.length) {
120+
authnSelectFormElements.forEach((element) => {
121+
if (element instanceof HTMLInputElement) {
122+
allowCredentials.push({
123+
id: base64url.parse(element.value, { loose: true }),
124+
type: 'public-key',
125+
});
126+
}
127+
});
128+
}
129+
130+
doAuthenticate(allowCredentials);
131+
};
132+
133+
return {
134+
webAuthnAuthenticate: () => {
135+
if (!isUserIdentified) {
136+
doAuthenticate([]);
137+
138+
return;
139+
}
140+
141+
checkAllowCredentials();
142+
},
143+
};
144+
});
145+
});

0 commit comments

Comments
 (0)