Skip to content

Commit 2517315

Browse files
committed
Template for passkey login
1 parent 32bcd42 commit 2517315

File tree

10 files changed

+173
-29
lines changed

10 files changed

+173
-29
lines changed

crates/templates/src/context.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,13 +394,19 @@ pub enum LoginFormField {
394394

395395
/// The password field
396396
Password,
397+
398+
/// The passkey challenge
399+
PasskeyChallengeId,
400+
401+
/// The passkey response
402+
PasskeyResponse,
397403
}
398404

399405
impl FormField for LoginFormField {
400406
fn keep(&self) -> bool {
401407
match self {
402-
Self::Username => true,
403-
Self::Password => false,
408+
Self::Username | Self::PasskeyChallengeId => true,
409+
Self::Password | Self::PasskeyResponse => false,
404410
}
405411
}
406412
}
@@ -461,6 +467,7 @@ pub struct LoginContext {
461467
form: FormState<LoginFormField>,
462468
next: Option<PostAuthContext>,
463469
providers: Vec<UpstreamOAuthProvider>,
470+
webauthn_options: String,
464471
}
465472

466473
impl TemplateContext for LoginContext {
@@ -478,11 +485,13 @@ impl TemplateContext for LoginContext {
478485
form: FormState::default(),
479486
next: None,
480487
providers: Vec::new(),
488+
webauthn_options: String::new(),
481489
},
482490
LoginContext {
483491
form: FormState::default(),
484492
next: None,
485493
providers: Vec::new(),
494+
webauthn_options: String::new(),
486495
},
487496
LoginContext {
488497
form: FormState::default()
@@ -496,12 +505,14 @@ impl TemplateContext for LoginContext {
496505
),
497506
next: None,
498507
providers: Vec::new(),
508+
webauthn_options: String::new(),
499509
},
500510
LoginContext {
501511
form: FormState::default()
502512
.with_error_on_field(LoginFormField::Username, FieldError::Exists),
503513
next: None,
504514
providers: Vec::new(),
515+
webauthn_options: String::new(),
505516
},
506517
]
507518
}
@@ -533,6 +544,15 @@ impl LoginContext {
533544
..self
534545
}
535546
}
547+
548+
/// Set the webauthn options
549+
#[must_use]
550+
pub fn with_webauthn_options(self, webauthn_options: String) -> Self {
551+
Self {
552+
webauthn_options,
553+
..self
554+
}
555+
}
536556
}
537557

538558
/// Fields of the registration form

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.tsx",
1213
"src/routes/*",
1314
"i18next-parser.config.ts",
1415
],

frontend/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,5 +345,8 @@
345345
"view_messages": "View your existing messages and data",
346346
"view_profile": "See your profile info and contact details"
347347
}
348+
},
349+
"passkeys": {
350+
"login": "Continue with Passkey"
348351
}
349352
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useMutation } from "@tanstack/react-query";
2+
import IconKey from "@vector-im/compound-design-tokens/assets/web/icons/key";
3+
import { Alert, Button } from "@vector-im/compound-web";
4+
import { useTranslation } from "react-i18next";
5+
import { checkSupport, performAuthentication } from "../utils/webauthn";
6+
7+
const PasskeyLoginButton: React.FC<{ options?: string }> = ({ options }) => {
8+
const { t } = useTranslation();
9+
const webauthnCeremony = useMutation({
10+
mutationFn: async (options: string) => {
11+
try {
12+
return { response: await performAuthentication(options) };
13+
} catch (e) {
14+
console.error(e);
15+
return { error: e as Error };
16+
}
17+
},
18+
onSuccess: (data) => {
19+
if (data.response) {
20+
const form = document.querySelector("form") as HTMLFormElement;
21+
const formResponse = form?.querySelector(
22+
'[name="passkey_response"]',
23+
) as HTMLInputElement;
24+
25+
formResponse.value = data.response;
26+
form.submit();
27+
}
28+
},
29+
});
30+
31+
if (!options) return;
32+
33+
const handleClick = async (
34+
e: React.FormEvent<HTMLButtonElement>,
35+
): Promise<void> => {
36+
e.preventDefault();
37+
38+
webauthnCeremony.mutate(options);
39+
};
40+
41+
const support = checkSupport();
42+
43+
return (
44+
<div className="flex flex-col gap-6">
45+
{webauthnCeremony.data?.error &&
46+
webauthnCeremony.data?.error.name !== "NotAllowedError" && (
47+
<Alert
48+
type="critical"
49+
title={webauthnCeremony.data?.error.toString()}
50+
/>
51+
)}
52+
<Button
53+
kind="secondary"
54+
size="lg"
55+
Icon={IconKey}
56+
onClick={handleClick}
57+
disabled={!support}
58+
>
59+
{t("passkeys.login")}
60+
</Button>
61+
</div>
62+
);
63+
};
64+
65+
export default PasskeyLoginButton;

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.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 { QueryClientProvider } from "@tanstack/react-query";
7+
import { StrictMode, Suspense } from "react";
8+
import { createRoot } from "react-dom/client";
9+
import { I18nextProvider } from "react-i18next";
10+
import LoadingSpinner from "./components/LoadingSpinner";
11+
import PasskeyLoginButton from "./components/PasskeyLoginButton";
12+
import { queryClient } from "./graphql";
13+
import i18n, { setupI18n } from "./i18n";
14+
15+
setupI18n();
16+
17+
interface IWindow {
18+
WEBAUTHN_OPTIONS?: string;
19+
}
20+
21+
const options =
22+
(typeof window !== "undefined" && (window as IWindow).WEBAUTHN_OPTIONS) ||
23+
undefined;
24+
25+
createRoot(document.getElementById("passkey-root") as HTMLElement).render(
26+
<StrictMode>
27+
<QueryClientProvider client={queryClient}>
28+
<Suspense fallback={<LoadingSpinner />}>
29+
<I18nextProvider i18n={i18n}>
30+
<PasskeyLoginButton options={options} />
31+
</I18nextProvider>
32+
</Suspense>
33+
</QueryClientProvider>
34+
</StrictMode>,
35+
);

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.tsx"),
6263
],
6364
},
6465
},

templates/pages/login.html

Lines changed: 22 additions & 8 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">
@@ -68,20 +70,26 @@ <h1 class="title">{{ _("mas.login.headline") }}</h1>
6870
{{ button.button(text=_("action.continue")) }}
6971
{% endif %}
7072

71-
{% if features.password_login and features.passkeys_enabled %}
72-
{{ field.separator() }}
73-
{% endif %}
74-
7573
{% if features.passkeys_enabled %}
76-
{{ button.link(text=_("mas.login.with_passkey")) }}
74+
<div id="passkey-root"></div>
75+
76+
<div class="hidden">
77+
{% call(f) field.field(name="passkey_challenge_id", form_state=form) %}
78+
<input {{ field.attributes(f) }} class="cpd-text-control" type="hidden" />
79+
{% endcall %}
80+
81+
{% call(f) field.field(name="passkey_response", form_state=form) %}
82+
<input {{ field.attributes(f) }} class="cpd-text-control" type="hidden" />
83+
{% endcall %}
84+
</div>
85+
7786
{% endif %}
7887

7988
{% if (features.password_login or features.passkeys_enabled) and providers %}
8089
{{ field.separator() }}
8190
{% endif %}
8291

8392
{% if providers %}
84-
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
8593
{% for provider in providers %}
8694
{% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %}
8795
<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 }}">
@@ -98,15 +106,21 @@ <h1 class="title">{{ _("mas.login.headline") }}</h1>
98106
{{ _("mas.login.call_to_register") }}
99107
</p>
100108

101-
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
102109
{{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }}
103110
</div>
104111
{% endif %}
105112

106-
{% if not providers and not features.password_login %}
113+
{% if not providers and not features.password_login and not features.passkeys_enabled %}
107114
<div class="text-center">
108115
{{ _("mas.login.no_login_methods") }}
109116
</div>
110117
{% endif %}
111118
</form>
119+
120+
{% if features.passkeys_enabled %}
121+
<script>
122+
window.WEBAUTHN_OPTIONS = "{{ webauthn_options | add_slashes | safe }}";
123+
</script>
124+
{{ include_asset('src/template_passkey.tsx') | indent(4) | safe }}
125+
{% endif %}
112126
{% endblock content %}

translations/en.json

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
},
1111
"continue": "Continue",
1212
"@continue": {
13-
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
13+
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:70:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
1414
},
1515
"create_account": "Create Account",
1616
"@create_account": {
17-
"context": "pages/login.html:102:33-59, pages/upstream_oauth2/do_register.html:192:26-52"
17+
"context": "pages/login.html:109:33-59, pages/upstream_oauth2/do_register.html:192:26-52"
1818
},
1919
"sign_in": "Sign in",
2020
"@sign_in": {
@@ -91,15 +91,15 @@
9191
},
9292
"password": "Password",
9393
"@password": {
94-
"context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53"
94+
"context": "pages/login.html:58:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53"
9595
},
9696
"password_confirm": "Confirm password",
9797
"@password_confirm": {
9898
"context": "pages/register/password.html:46:33-61"
9999
},
100100
"username": "Username",
101101
"@username": {
102-
"context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59"
102+
"context": "pages/login.html:53:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59"
103103
}
104104
},
105105
"error": {
@@ -419,47 +419,43 @@
419419
"login": {
420420
"call_to_register": "Don't have an account yet?",
421421
"@call_to_register": {
422-
"context": "pages/login.html:98:13-44"
422+
"context": "pages/login.html:106:13-44"
423423
},
424424
"continue_with_provider": "Continue with %(provider)s",
425425
"@continue_with_provider": {
426-
"context": "pages/login.html:89:15-67, pages/register/index.html:53:15-67",
426+
"context": "pages/login.html:97:15-67, pages/register/index.html:53:15-67",
427427
"description": "Button to log in with an upstream provider"
428428
},
429429
"description": "Please sign in to continue:",
430430
"@description": {
431-
"context": "pages/login.html:29:29-55"
431+
"context": "pages/login.html:31:29-55"
432432
},
433433
"forgot_password": "Forgot password?",
434434
"@forgot_password": {
435-
"context": "pages/login.html:61:35-65",
435+
"context": "pages/login.html:63:35-65",
436436
"description": "On the login page, link to the account recovery process"
437437
},
438438
"headline": "Sign in",
439439
"@headline": {
440-
"context": "pages/login.html:28:31-54"
440+
"context": "pages/login.html:30:31-54"
441441
},
442442
"link": {
443443
"description": "Linking your <span class=\"break-keep text-links\">%(provider)s</span> account",
444444
"@description": {
445-
"context": "pages/login.html:24:29-75"
445+
"context": "pages/login.html:26:29-75"
446446
},
447447
"headline": "Sign in to link",
448448
"@headline": {
449-
"context": "pages/login.html:22:31-59"
449+
"context": "pages/login.html:24:31-59"
450450
}
451451
},
452452
"no_login_methods": "No login methods available.",
453453
"@no_login_methods": {
454-
"context": "pages/login.html:108:11-42"
454+
"context": "pages/login.html:115:11-42"
455455
},
456456
"username_or_email": "Username or Email",
457457
"@username_or_email": {
458-
"context": "pages/login.html:47:39-71"
459-
},
460-
"with_passkey": "Sign in with a Passkey",
461-
"@with_passkey": {
462-
"context": "pages/login.html:76:28-55"
458+
"context": "pages/login.html:49:39-71"
463459
}
464460
},
465461
"navbar": {

0 commit comments

Comments
 (0)