Skip to content

Commit d420f40

Browse files
committed
Template for passkey login
1 parent 4061750 commit d420f40

File tree

12 files changed

+297
-26
lines changed

12 files changed

+297
-26
lines changed

crates/templates/src/context.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,76 @@ impl LoginContext {
507507
}
508508
}
509509

510+
/// Fields of the passkey login form
511+
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
512+
#[serde(rename_all = "snake_case")]
513+
pub enum PasskeyLoginFormField {
514+
/// The id field
515+
Id,
516+
517+
/// The response field
518+
Response,
519+
}
520+
521+
impl FormField for PasskeyLoginFormField {
522+
fn keep(&self) -> bool {
523+
match self {
524+
Self::Id => true,
525+
Self::Response => false,
526+
}
527+
}
528+
}
529+
530+
/// Context used by the `login/passkey.html` template
531+
#[derive(Serialize, Default)]
532+
pub struct PasskeyLoginContext {
533+
form: FormState<PasskeyLoginFormField>,
534+
next: Option<PostAuthContext>,
535+
options: String,
536+
}
537+
538+
impl TemplateContext for PasskeyLoginContext {
539+
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
540+
where
541+
Self: Sized,
542+
{
543+
// TODO: samples with errors
544+
vec![PasskeyLoginContext {
545+
form: FormState::default(),
546+
next: None,
547+
options: String::new(),
548+
}]
549+
}
550+
}
551+
552+
impl PasskeyLoginContext {
553+
/// Set the form state
554+
#[must_use]
555+
pub fn with_form_state(self, form: FormState<PasskeyLoginFormField>) -> Self {
556+
Self { form, ..self }
557+
}
558+
559+
/// Mutably borrow the form state
560+
pub fn form_state_mut(&mut self) -> &mut FormState<PasskeyLoginFormField> {
561+
&mut self.form
562+
}
563+
564+
/// Add a post authentication action to the context
565+
#[must_use]
566+
pub fn with_post_action(self, context: PostAuthContext) -> Self {
567+
Self {
568+
next: Some(context),
569+
..self
570+
}
571+
}
572+
573+
/// Set the webauthn options
574+
#[must_use]
575+
pub fn with_options(self, options: String) -> Self {
576+
Self { options, ..self }
577+
}
578+
}
579+
510580
/// Fields of the registration form
511581
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
512582
#[serde(rename_all = "snake_case")]

crates/templates/src/lib.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@ pub use self::{
3737
AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext,
3838
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext,
3939
EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
40-
LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext,
41-
PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext,
42-
ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
43-
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
44-
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
40+
LoginContext, LoginFormField, NotFoundContext, PasskeyLoginContext, PasskeyLoginFormField,
41+
PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner,
42+
ReauthContext, ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext,
43+
RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext,
44+
RecoveryStartFormField, RegisterContext, RegisterFormField,
45+
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
4546
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
4647
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
4748
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
@@ -325,7 +326,10 @@ register_templates! {
325326
pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
326327

327328
/// Render the login page
328-
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
329+
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login/index.html" }
330+
331+
/// Render the passkey login page
332+
pub fn render_passkey_login(WithLanguage<WithCsrf<PasskeyLoginContext>>) { "pages/login/passkey.html" }
329333

330334
/// Render the registration page
331335
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
@@ -441,6 +445,7 @@ impl Templates {
441445
check::render_swagger(self, now, rng)?;
442446
check::render_swagger_callback(self, now, rng)?;
443447
check::render_login(self, now, rng)?;
448+
check::render_passkey_login(self, now, rng)?;
444449
check::render_register(self, now, rng)?;
445450
check::render_password_register(self, now, rng)?;
446451
check::render_register_steps_verify_email(self, now, rng)?;

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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,15 @@ export async function performRegistration(options: string): Promise<string> {
2020

2121
return JSON.stringify(credential);
2222
}
23+
24+
export async function performAuthentication(options: string): Promise<string> {
25+
const opts: { publicKey: PublicKeyCredentialRequestOptionsJSON } =
26+
JSON.parse(options);
27+
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
28+
opts.publicKey,
29+
);
30+
31+
const credential = await navigator.credentials.get({ publicKey });
32+
33+
return JSON.stringify(credential);
34+
}

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 %}

templates/pages/login.html renamed to templates/pages/login/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
{% from "components/idp_brand.html" import logo %}
1212

13+
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
14+
1315
{% block content %}
1416
<form method="POST" class="flex flex-col gap-10">
1517
<header class="page-heading">
@@ -67,15 +69,14 @@ <h1 class="title">{{ _("mas.login.headline") }}</h1>
6769
{% endif %}
6870

6971
{% if features.passkeys_enabled %}
70-
{{ button.link(text=_("mas.login.with_passkey")) }}
72+
{{ button.link(text=_("mas.login.with_passkey"), href="/login/passkey" ~ params) }}
7173
{% endif %}
7274

7375
{% if (features.password_login or features.passkeys_enabled) and providers %}
7476
{{ field.separator() }}
7577
{% endif %}
7678

7779
{% if providers %}
78-
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
7980
{% for provider in providers %}
8081
{% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %}
8182
<a class="cpd-button {%- if provider.brand_name %} has-icon {%- endif %}" data-kind="secondary" data-size="lg" href="{{ ('/upstream/authorize/' ~ provider.id ~ params) | prefix_url }}">
@@ -92,12 +93,11 @@ <h1 class="title">{{ _("mas.login.headline") }}</h1>
9293
{{ _("mas.login.call_to_register") }}
9394
</p>
9495

95-
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
9696
{{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }}
9797
</div>
9898
{% endif %}
9999

100-
{% if not providers and not features.password_login %}
100+
{% if not providers and not features.password_login and not features.passkeys_enabled %}
101101
<div class="text-center">
102102
{{ _("mas.login.no_login_methods") }}
103103
</div>

0 commit comments

Comments
 (0)