Skip to content

Commit cba3c52

Browse files
committed
Template for passkey login
1 parent 589ff86 commit cba3c52

File tree

13 files changed

+300
-26
lines changed

13 files changed

+300
-26
lines changed

crates/templates/src/context.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,80 @@ impl LoginContext {
535535
}
536536
}
537537

538+
/// Fields of the passkey login form
539+
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
540+
#[serde(rename_all = "snake_case")]
541+
pub enum PasskeyLoginFormField {
542+
/// The id field
543+
Id,
544+
545+
/// The response field
546+
Response,
547+
}
548+
549+
impl FormField for PasskeyLoginFormField {
550+
fn keep(&self) -> bool {
551+
match self {
552+
Self::Id => true,
553+
Self::Response => false,
554+
}
555+
}
556+
}
557+
558+
/// Context used by the `login/passkey.html` template
559+
#[derive(Serialize, Default)]
560+
pub struct PasskeyLoginContext {
561+
form: FormState<PasskeyLoginFormField>,
562+
next: Option<PostAuthContext>,
563+
options: String,
564+
}
565+
566+
impl TemplateContext for PasskeyLoginContext {
567+
fn sample(
568+
_now: chrono::DateTime<Utc>,
569+
_rng: &mut impl Rng,
570+
_locales: &[DataLocale],
571+
) -> Vec<Self>
572+
where
573+
Self: Sized,
574+
{
575+
// TODO: samples with errors
576+
vec![PasskeyLoginContext {
577+
form: FormState::default(),
578+
next: None,
579+
options: String::new(),
580+
}]
581+
}
582+
}
583+
584+
impl PasskeyLoginContext {
585+
/// Set the form state
586+
#[must_use]
587+
pub fn with_form_state(self, form: FormState<PasskeyLoginFormField>) -> Self {
588+
Self { form, ..self }
589+
}
590+
591+
/// Mutably borrow the form state
592+
pub fn form_state_mut(&mut self) -> &mut FormState<PasskeyLoginFormField> {
593+
&mut self.form
594+
}
595+
596+
/// Add a post authentication action to the context
597+
#[must_use]
598+
pub fn with_post_action(self, context: PostAuthContext) -> Self {
599+
Self {
600+
next: Some(context),
601+
..self
602+
}
603+
}
604+
605+
/// Set the webauthn options
606+
#[must_use]
607+
pub fn with_options(self, options: String) -> Self {
608+
Self { options, ..self }
609+
}
610+
}
611+
538612
/// Fields of the registration form
539613
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
540614
#[serde(rename_all = "snake_case")]

crates/templates/src/lib.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ pub use self::{
3838
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext,
3939
EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext,
4040
FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext,
41-
PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner,
42-
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
43-
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
44-
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
41+
PasskeyLoginContext, PasskeyLoginFormField, PasswordRegisterContext,
42+
PolicyViolationContext, PostAuthContext, PostAuthContextInner, RecoveryExpiredContext,
43+
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
44+
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
45+
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
4546
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
4647
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
4748
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
@@ -323,7 +324,10 @@ register_templates! {
323324
pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
324325

325326
/// Render the login page
326-
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
327+
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login/index.html" }
328+
329+
/// Render the passkey login page
330+
pub fn render_passkey_login(WithLanguage<WithCsrf<PasskeyLoginContext>>) { "pages/login/passkey.html" }
327331

328332
/// Render the registration page
329333
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
@@ -439,6 +443,7 @@ impl Templates {
439443
check::render_swagger(self, now, rng)?;
440444
check::render_swagger_callback(self, now, rng)?;
441445
check::render_login(self, now, rng)?;
446+
check::render_passkey_login(self, now, rng)?;
442447
check::render_register(self, now, rng)?;
443448
check::render_password_register(self, now, rng)?;
444449
check::render_register_steps_verify_email(self, now, rng)?;

frontend/knip.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default {
99
entry: [
1010
"src/main.tsx",
1111
"src/swagger.ts",
12+
"src/template_passkey.ts",
1213
"src/routes/*",
1314
"i18next-parser.config.ts",
1415
],

frontend/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"name_field_label": "Name",
7171
"name_invalid_error": "The entered name is invalid",
7272
"never_used_message": "Never used",
73+
"not_supported": "Passkeys are not supported on this browser",
7374
"response_invalid_error": "The response from your passkey was invalid: {{error}}",
7475
"title": "Passkeys"
7576
},

frontend/src/i18n.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ const Backend = {
8181
},
8282
} satisfies BackendModule;
8383

84-
export const setupI18n = () => {
84+
export const setupI18n = () =>
8585
i18n
8686
.use(Backend)
8787
.use(LanguageDetector)
@@ -96,7 +96,6 @@ export const setupI18n = () => {
9696
escapeValue: false, // React has built-in XSS protections
9797
},
9898
} satisfies InitOptions);
99-
};
10099

101100
import.meta.hot?.on("locales-update", () => {
102101
i18n.reloadResources().then(() => {

frontend/src/template_passkey.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
import { setupI18n } from "./i18n";
7+
import { checkSupport, performAuthentication } from "./utils/webauthn";
8+
9+
const t = await setupI18n();
10+
11+
interface IWindow {
12+
WEBAUTHN_OPTIONS?: string;
13+
}
14+
15+
const options =
16+
typeof window !== "undefined" && (window as IWindow).WEBAUTHN_OPTIONS;
17+
18+
const errors = document.getElementById("errors");
19+
const retryButtonContainer = document.getElementById("retry-button-container");
20+
const retryButton = document.getElementById("retry-button");
21+
const form = document.getElementById("passkey-form");
22+
const formResponse = form?.querySelector('[name="response"]');
23+
24+
function setError(text: string) {
25+
const error = document.createElement("div");
26+
error.classList.add("text-critical", "font-medium");
27+
error.innerText = text;
28+
errors?.appendChild(error);
29+
}
30+
31+
async function run() {
32+
if (!options) {
33+
throw new Error("WEBAUTHN_OPTIONS is not defined");
34+
}
35+
36+
if (
37+
!errors ||
38+
!retryButtonContainer ||
39+
!retryButton ||
40+
!form ||
41+
!formResponse
42+
) {
43+
throw new Error("Missing elements in document");
44+
}
45+
46+
errors.innerHTML = "";
47+
48+
if (!checkSupport()) {
49+
setError(t("frontend.account.passkeys.not_supported"));
50+
return;
51+
}
52+
53+
try {
54+
const response = await performAuthentication(options);
55+
(formResponse as HTMLInputElement).value = response;
56+
(form as HTMLFormElement).submit();
57+
} catch (e) {
58+
if (e instanceof Error && e.name !== "NotAllowedError") {
59+
setError(e.toString());
60+
}
61+
retryButtonContainer?.classList.remove("hidden");
62+
return;
63+
}
64+
}
65+
66+
if (!errors?.children.length) {
67+
run();
68+
} else {
69+
retryButtonContainer?.classList.remove("hidden");
70+
}
71+
72+
retryButton?.addEventListener("click", () => {
73+
run();
74+
});

frontend/src/templates.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,13 @@
175175
}
176176
}
177177
}
178+
179+
.fullscreen-noscript {
180+
z-index: 99;
181+
position: absolute;
182+
top: 0;
183+
left: 0;
184+
width: 100%;
185+
height: 100vh;
186+
background: var(--cpd-color-bg-canvas-default);
187+
}

frontend/src/utils/webauthn.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,13 @@ export async function performRegistration(options: string): Promise<string> {
116116

117117
return JSON.stringify(credential);
118118
}
119+
120+
export async function performAuthentication(options: string): Promise<string> {
121+
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
122+
JSON.parse(options),
123+
);
124+
125+
const credential = await navigator.credentials.get({ publicKey });
126+
127+
return JSON.stringify(credential);
128+
}

frontend/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export default defineConfig((env) => ({
5959
resolve(__dirname, "src/shared.css"),
6060
resolve(__dirname, "src/templates.css"),
6161
resolve(__dirname, "src/swagger.ts"),
62+
resolve(__dirname, "src/template_passkey.ts"),
6263
],
6364
},
6465
},

templates/components/button.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
name="",
2828
type="submit",
2929
class="",
30+
id="",
3031
value="",
3132
disabled=False,
3233
kind="primary",
@@ -40,6 +41,7 @@
4041
type="{{ type }}"
4142
{% if disabled %}disabled{% endif %}
4243
class="cpd-button {{ class }}"
44+
id="{{id}}"
4345
data-kind="{{ kind }}"
4446
data-size="{{ size }}"
4547
{% if autocapitalize %}autocapitilize="{{ autocapitilize }}"{% endif %}
@@ -53,6 +55,7 @@
5355
name="",
5456
type="submit",
5557
class="",
58+
id="",
5659
value="",
5760
disabled=False,
5861
autocomplete=False,
@@ -65,6 +68,7 @@
6568
{% if disabled %}disabled{% endif %}
6669
data-kind="primary"
6770
class="cpd-link {{ class }}"
71+
id="{{id}}"
6872
{% if autocapitalize %}autocapitilize="{{ autocapitilize }}"{% endif %}
6973
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
7074
{% if autocorrect %}autocorrect="{{ autocorrect }}"{% endif %}
@@ -76,6 +80,7 @@
7680
name="",
7781
type="submit",
7882
class="",
83+
id="",
7984
value="",
8085
disabled=False,
8186
size="lg",
@@ -87,6 +92,7 @@
8792
value="{{ value }}"
8893
type="{{ type }}"
8994
class="cpd-button {{ class }}"
95+
id="{{id}}"
9096
data-kind="secondary"
9197
data-size="{{ size }}"
9298
{% if disabled %}disabled{% endif %}

0 commit comments

Comments
 (0)