Skip to content

Commit fb5c2ac

Browse files
committed
Passkey login handler
1 parent d420f40 commit fb5c2ac

File tree

7 files changed

+575
-3
lines changed

7 files changed

+575
-3
lines changed

crates/handlers/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ rand.workspace = true
8585
rand_chacha.workspace = true
8686
headers.workspace = true
8787
ulid.workspace = true
88-
webauthn-rs = { version = "0.5.1", features = ["danger-allow-state-serialisation"]}
88+
webauthn-rs = { version = "0.5.1", features = ["danger-allow-state-serialisation", "conditional-ui"]}
8989
webauthn-rs-proto = "0.5.1"
9090

9191
mas-axum-utils.workspace = true

crates/handlers/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,10 @@ where
371371
mas_router::Login::route(),
372372
get(self::views::login::get).post(self::views::login::post),
373373
)
374+
.route(
375+
mas_router::PasskeyLogin::route(),
376+
get(self::views::login::passkey::get).post(self::views::login::passkey::post),
377+
)
374378
.route(mas_router::Logout::route(), post(self::views::logout::post))
375379
.route(
376380
mas_router::Reauth::route(),

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

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

7-
use std::sync::Arc;
7+
pub mod passkey;
88

99
use axum::{
1010
extract::{Form, Query, State},
@@ -32,6 +32,7 @@ use mas_templates::{
3232
};
3333
use rand::Rng;
3434
use serde::{Deserialize, Serialize};
35+
use std::sync::Arc;
3536
use zeroize::Zeroizing;
3637

3738
use super::shared::OptionalPostAuthAction;
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)