Skip to content

Commit 1865a47

Browse files
committed
Passkey login handler
1 parent 9ec383c commit 1865a47

File tree

5 files changed

+577
-110
lines changed

5 files changed

+577
-110
lines changed

crates/handlers/src/lib.rs

Lines changed: 2 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

@@ -348,6 +349,7 @@ where
348349
Limiter: FromRef<S>,
349350
reqwest::Client: FromRef<S>,
350351
Arc<dyn HomeserverConnection>: FromRef<S>,
352+
Webauthn: FromRef<S>,
351353
BoxClock: FromRequestParts<S>,
352354
BoxRng: FromRequestParts<S>,
353355
Policy: FromRequestParts<S>,

crates/handlers/src/test_utils.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ pub(crate) struct TestState {
113113
pub rng: Arc<Mutex<ChaChaRng>>,
114114
pub http_client: reqwest::Client,
115115
pub task_tracker: TaskTracker,
116+
pub webauthn: Webauthn,
116117
queue_worker: Arc<tokio::sync::Mutex<QueueWorker>>,
117118

118119
#[allow(dead_code)] // It is used, as it will cancel the CancellationToken when dropped
@@ -280,6 +281,7 @@ impl TestState {
280281
rng,
281282
http_client,
282283
task_tracker,
284+
webauthn,
283285
queue_worker,
284286
cancellation_drop_guard: Arc::new(shutdown_token.drop_guard()),
285287
})
@@ -585,6 +587,12 @@ impl FromRef<TestState> for reqwest::Client {
585587
}
586588
}
587589

590+
impl FromRef<TestState> for Webauthn {
591+
fn from_ref(input: &TestState) -> Self {
592+
input.webauthn.clone()
593+
}
594+
}
595+
588596
impl FromRequestParts<TestState> for ActivityTracker {
589597
type Rejection = Infallible;
590598

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

0 commit comments

Comments
 (0)