Skip to content

Commit d639e7d

Browse files
committed
Passkey login handler
1 parent 599b91d commit d639e7d

File tree

7 files changed

+693
-2
lines changed

7 files changed

+693
-2
lines changed

crates/handlers/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ use opentelemetry::metrics::Meter;
4848
use sqlx::PgPool;
4949
use tower::util::AndThenLayer;
5050
use tower_http::cors::{Any, CorsLayer};
51+
use webauthn::Webauthn;
5152

5253
use self::{graphql::ExtraRouterParameters, passwords::PasswordManager};
5354

@@ -329,6 +330,7 @@ where
329330
Limiter: FromRef<S>,
330331
reqwest::Client: FromRef<S>,
331332
Arc<dyn HomeserverConnection>: FromRef<S>,
333+
Webauthn: FromRef<S>,
332334
BoxClock: FromRequestParts<S>,
333335
BoxRng: FromRequestParts<S>,
334336
Policy: FromRequestParts<S>,
@@ -371,6 +373,10 @@ where
371373
mas_router::Login::route(),
372374
get(self::views::login::get).post(self::views::login::post),
373375
)
376+
.route(
377+
mas_router::PasskeyLogin::route(),
378+
get(self::views::login::passkey::get).post(self::views::login::passkey::post),
379+
)
374380
.route(mas_router::Logout::route(), post(self::views::logout::post))
375381
.route(
376382
mas_router::Reauth::route(),

crates/handlers/src/test_utils.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ pub(crate) struct TestState {
111111
pub rng: Arc<Mutex<ChaChaRng>>,
112112
pub http_client: reqwest::Client,
113113
pub task_tracker: TaskTracker,
114+
pub webauthn: Webauthn,
114115

115116
#[allow(dead_code)] // It is used, as it will cancel the CancellationToken when dropped
116117
cancellation_drop_guard: Arc<DropGuard>,
@@ -253,6 +254,7 @@ impl TestState {
253254
rng,
254255
http_client,
255256
task_tracker,
257+
webauthn,
256258
cancellation_drop_guard: Arc::new(shutdown_token.drop_guard()),
257259
})
258260
}
@@ -548,6 +550,12 @@ impl FromRef<TestState> for reqwest::Client {
548550
}
549551
}
550552

553+
impl FromRef<TestState> for Webauthn {
554+
fn from_ref(input: &TestState) -> Self {
555+
input.webauthn.clone()
556+
}
557+
}
558+
551559
impl FromRequestParts<TestState> for ActivityTracker {
552560
type Rejection = Infallible;
553561

crates/handlers/src/views/login.rs renamed to crates/handlers/src/views/login/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
// SPDX-License-Identifier: AGPL-3.0-only
55
// Please see LICENSE in the repository root for full details.
66

7+
pub mod passkey;
8+
79
use std::sync::Arc;
810

911
use axum::{
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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+
use std::collections::BTreeSet;
7+
8+
use chrono::{DateTime, Duration, Utc};
9+
use mas_axum_utils::cookies::CookieJar;
10+
use mas_data_model::UserPasskeyChallenge;
11+
use mas_storage::Clock;
12+
use serde::{Deserialize, Serialize};
13+
use thiserror::Error;
14+
use ulid::Ulid;
15+
16+
/// Name of the cookie
17+
static COOKIE_NAME: &str = "user-passkey-challenges";
18+
19+
/// Sessions expire after an hour
20+
static SESSION_MAX_TIME: Duration = Duration::hours(1);
21+
22+
/// The content of the cookie, which stores a list of user passkey challenge IDs
23+
#[derive(Serialize, Deserialize, Default, Debug)]
24+
pub struct UserPasskeyChallenges(BTreeSet<Ulid>);
25+
26+
#[derive(Debug, Error, PartialEq, Eq)]
27+
#[error("user passkey challenge not found")]
28+
pub struct UserPasskeyChallengeNotFound;
29+
30+
impl UserPasskeyChallenges {
31+
/// Load the user passkey challenges cookie
32+
pub fn load(cookie_jar: &CookieJar) -> Self {
33+
match cookie_jar.load(COOKIE_NAME) {
34+
Ok(Some(challenges)) => challenges,
35+
Ok(None) => Self::default(),
36+
Err(e) => {
37+
tracing::warn!(
38+
error = &e as &dyn std::error::Error,
39+
"Invalid passkey challenges cookie"
40+
);
41+
Self::default()
42+
}
43+
}
44+
}
45+
46+
/// Returns true if the cookie is empty
47+
pub fn is_empty(&self) -> bool {
48+
self.0.is_empty()
49+
}
50+
51+
/// Save the user passkey challenges to the cookie jar
52+
pub fn save<C>(self, cookie_jar: CookieJar, clock: &C) -> CookieJar
53+
where
54+
C: Clock,
55+
{
56+
let this = self.expire(clock.now());
57+
58+
if this.is_empty() {
59+
cookie_jar.remove(COOKIE_NAME)
60+
} else {
61+
cookie_jar.save(COOKIE_NAME, &this, false)
62+
}
63+
}
64+
65+
fn expire(mut self, now: DateTime<Utc>) -> Self {
66+
self.0.retain(|id| {
67+
let Ok(ts) = id.timestamp_ms().try_into() else {
68+
return false;
69+
};
70+
let Some(when) = DateTime::from_timestamp_millis(ts) else {
71+
return false;
72+
};
73+
now - when < SESSION_MAX_TIME
74+
});
75+
76+
self
77+
}
78+
79+
/// Add a new challenge
80+
pub fn add(mut self, passkey_challenge: &UserPasskeyChallenge) -> Self {
81+
self.0.insert(passkey_challenge.id);
82+
self
83+
}
84+
85+
/// Check if the challenge is in the list
86+
pub fn contains(&self, passkey_challenge: &UserPasskeyChallenge) -> bool {
87+
self.0.contains(&passkey_challenge.id)
88+
}
89+
90+
/// Mark a challenge as consumed to avoid replay
91+
pub fn consume_challenge(
92+
mut self,
93+
passkey_challenge: &UserPasskeyChallenge,
94+
) -> Result<Self, UserPasskeyChallengeNotFound> {
95+
if !self.0.remove(&passkey_challenge.id) {
96+
return Err(UserPasskeyChallengeNotFound);
97+
}
98+
99+
Ok(self)
100+
}
101+
}

0 commit comments

Comments
 (0)