this repo has no description
1use crate::auth::BearerAuth;
2use crate::auth::webauthn::{
3 self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state,
4 get_passkeys_for_user, load_registration_state, save_passkey, save_registration_state,
5 update_passkey_name as db_update_passkey_name,
6};
7use crate::state::AppState;
8use axum::{
9 Json,
10 extract::State,
11 http::StatusCode,
12 response::{IntoResponse, Response},
13};
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use tracing::{error, info, warn};
17use webauthn_rs::prelude::*;
18
19fn get_webauthn() -> Result<WebAuthnConfig, (StatusCode, Json<serde_json::Value>)> {
20 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
21 WebAuthnConfig::new(&hostname).map_err(|e| {
22 error!("Failed to create WebAuthn config: {}", e);
23 (
24 StatusCode::INTERNAL_SERVER_ERROR,
25 Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})),
26 )
27 })
28}
29
30#[derive(Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct StartRegistrationInput {
33 pub friendly_name: Option<String>,
34}
35
36#[derive(Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct StartRegistrationResponse {
39 pub options: serde_json::Value,
40}
41
42pub async fn start_passkey_registration(
43 State(state): State<AppState>,
44 auth: BearerAuth,
45 Json(input): Json<StartRegistrationInput>,
46) -> Response {
47 let webauthn = match get_webauthn() {
48 Ok(w) => w,
49 Err(e) => return e.into_response(),
50 };
51
52 let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did)
53 .fetch_optional(&state.db)
54 .await;
55
56 let handle = match user {
57 Ok(Some(row)) => row.handle,
58 Ok(None) => {
59 return (
60 StatusCode::NOT_FOUND,
61 Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
62 )
63 .into_response();
64 }
65 Err(e) => {
66 error!("DB error fetching user: {:?}", e);
67 return (
68 StatusCode::INTERNAL_SERVER_ERROR,
69 Json(json!({"error": "InternalError"})),
70 )
71 .into_response();
72 }
73 };
74
75 let existing_passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await {
76 Ok(passkeys) => passkeys,
77 Err(e) => {
78 error!("DB error fetching existing passkeys: {:?}", e);
79 return (
80 StatusCode::INTERNAL_SERVER_ERROR,
81 Json(json!({"error": "InternalError"})),
82 )
83 .into_response();
84 }
85 };
86
87 let exclude_credentials: Vec<CredentialID> = existing_passkeys
88 .iter()
89 .map(|p| CredentialID::from(p.credential_id.clone()))
90 .collect();
91
92 let display_name = input.friendly_name.as_deref().unwrap_or(&handle);
93
94 let (ccr, reg_state) = match webauthn.start_registration(
95 &auth.0.did,
96 &handle,
97 display_name,
98 exclude_credentials,
99 ) {
100 Ok(result) => result,
101 Err(e) => {
102 error!("Failed to start passkey registration: {}", e);
103 return (
104 StatusCode::INTERNAL_SERVER_ERROR,
105 Json(json!({"error": "InternalError", "message": "Failed to start registration"})),
106 )
107 .into_response();
108 }
109 };
110
111 if let Err(e) = save_registration_state(&state.db, &auth.0.did, ®_state).await {
112 error!("Failed to save registration state: {:?}", e);
113 return (
114 StatusCode::INTERNAL_SERVER_ERROR,
115 Json(json!({"error": "InternalError"})),
116 )
117 .into_response();
118 }
119
120 let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
121
122 info!(did = %auth.0.did, "Passkey registration started");
123
124 Json(StartRegistrationResponse { options }).into_response()
125}
126
127#[derive(Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct FinishRegistrationInput {
130 pub credential: serde_json::Value,
131 pub friendly_name: Option<String>,
132}
133
134#[derive(Serialize)]
135#[serde(rename_all = "camelCase")]
136pub struct FinishRegistrationResponse {
137 pub id: String,
138 pub credential_id: String,
139}
140
141pub async fn finish_passkey_registration(
142 State(state): State<AppState>,
143 auth: BearerAuth,
144 Json(input): Json<FinishRegistrationInput>,
145) -> Response {
146 let webauthn = match get_webauthn() {
147 Ok(w) => w,
148 Err(e) => return e.into_response(),
149 };
150
151 let reg_state = match load_registration_state(&state.db, &auth.0.did).await {
152 Ok(Some(state)) => state,
153 Ok(None) => {
154 return (
155 StatusCode::BAD_REQUEST,
156 Json(json!({
157 "error": "NoRegistrationInProgress",
158 "message": "No registration in progress. Call startPasskeyRegistration first."
159 })),
160 )
161 .into_response();
162 }
163 Err(e) => {
164 error!("DB error loading registration state: {:?}", e);
165 return (
166 StatusCode::INTERNAL_SERVER_ERROR,
167 Json(json!({"error": "InternalError"})),
168 )
169 .into_response();
170 }
171 };
172
173 let credential: RegisterPublicKeyCredential = match serde_json::from_value(input.credential) {
174 Ok(c) => c,
175 Err(e) => {
176 warn!("Failed to parse credential: {:?}", e);
177 return (
178 StatusCode::BAD_REQUEST,
179 Json(json!({
180 "error": "InvalidCredential",
181 "message": "Failed to parse credential response"
182 })),
183 )
184 .into_response();
185 }
186 };
187
188 let passkey = match webauthn.finish_registration(&credential, ®_state) {
189 Ok(pk) => pk,
190 Err(e) => {
191 warn!("Failed to finish passkey registration: {}", e);
192 return (
193 StatusCode::BAD_REQUEST,
194 Json(json!({
195 "error": "RegistrationFailed",
196 "message": "Failed to verify passkey registration"
197 })),
198 )
199 .into_response();
200 }
201 };
202
203 let passkey_id = match save_passkey(
204 &state.db,
205 &auth.0.did,
206 &passkey,
207 input.friendly_name.as_deref(),
208 )
209 .await
210 {
211 Ok(id) => id,
212 Err(e) => {
213 error!("Failed to save passkey: {:?}", e);
214 return (
215 StatusCode::INTERNAL_SERVER_ERROR,
216 Json(json!({"error": "InternalError"})),
217 )
218 .into_response();
219 }
220 };
221
222 if let Err(e) = delete_registration_state(&state.db, &auth.0.did).await {
223 warn!("Failed to delete registration state: {:?}", e);
224 }
225
226 let credential_id_base64 = base64::Engine::encode(
227 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
228 passkey.cred_id(),
229 );
230
231 info!(did = %auth.0.did, passkey_id = %passkey_id, "Passkey registered");
232
233 Json(FinishRegistrationResponse {
234 id: passkey_id.to_string(),
235 credential_id: credential_id_base64,
236 })
237 .into_response()
238}
239
240#[derive(Serialize)]
241#[serde(rename_all = "camelCase")]
242pub struct PasskeyInfo {
243 pub id: String,
244 pub credential_id: String,
245 pub friendly_name: Option<String>,
246 pub created_at: String,
247 pub last_used: Option<String>,
248}
249
250#[derive(Serialize)]
251#[serde(rename_all = "camelCase")]
252pub struct ListPasskeysResponse {
253 pub passkeys: Vec<PasskeyInfo>,
254}
255
256pub async fn list_passkeys(State(state): State<AppState>, auth: BearerAuth) -> Response {
257 let passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await {
258 Ok(pks) => pks,
259 Err(e) => {
260 error!("DB error fetching passkeys: {:?}", e);
261 return (
262 StatusCode::INTERNAL_SERVER_ERROR,
263 Json(json!({"error": "InternalError"})),
264 )
265 .into_response();
266 }
267 };
268
269 let passkey_infos: Vec<PasskeyInfo> = passkeys
270 .into_iter()
271 .map(|pk| PasskeyInfo {
272 id: pk.id.to_string(),
273 credential_id: pk.credential_id_base64(),
274 friendly_name: pk.friendly_name,
275 created_at: pk.created_at.to_rfc3339(),
276 last_used: pk.last_used.map(|dt| dt.to_rfc3339()),
277 })
278 .collect();
279
280 Json(ListPasskeysResponse {
281 passkeys: passkey_infos,
282 })
283 .into_response()
284}
285
286#[derive(Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct DeletePasskeyInput {
289 pub id: String,
290}
291
292pub async fn delete_passkey(
293 State(state): State<AppState>,
294 auth: BearerAuth,
295 Json(input): Json<DeletePasskeyInput>,
296) -> Response {
297 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
298 return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
299 .await;
300 }
301
302 if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
303 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
304 }
305
306 let id: uuid::Uuid = match input.id.parse() {
307 Ok(id) => id,
308 Err(_) => {
309 return (
310 StatusCode::BAD_REQUEST,
311 Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
312 )
313 .into_response();
314 }
315 };
316
317 match db_delete_passkey(&state.db, id, &auth.0.did).await {
318 Ok(true) => {
319 info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted");
320 (StatusCode::OK, Json(json!({}))).into_response()
321 }
322 Ok(false) => (
323 StatusCode::NOT_FOUND,
324 Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
325 )
326 .into_response(),
327 Err(e) => {
328 error!("DB error deleting passkey: {:?}", e);
329 (
330 StatusCode::INTERNAL_SERVER_ERROR,
331 Json(json!({"error": "InternalError"})),
332 )
333 .into_response()
334 }
335 }
336}
337
338#[derive(Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct UpdatePasskeyInput {
341 pub id: String,
342 pub friendly_name: String,
343}
344
345pub async fn update_passkey(
346 State(state): State<AppState>,
347 auth: BearerAuth,
348 Json(input): Json<UpdatePasskeyInput>,
349) -> Response {
350 let id: uuid::Uuid = match input.id.parse() {
351 Ok(id) => id,
352 Err(_) => {
353 return (
354 StatusCode::BAD_REQUEST,
355 Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
356 )
357 .into_response();
358 }
359 };
360
361 match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await {
362 Ok(true) => {
363 info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed");
364 (StatusCode::OK, Json(json!({}))).into_response()
365 }
366 Ok(false) => (
367 StatusCode::NOT_FOUND,
368 Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
369 )
370 .into_response(),
371 Err(e) => {
372 error!("DB error updating passkey: {:?}", e);
373 (
374 StatusCode::INTERNAL_SERVER_ERROR,
375 Json(json!({"error": "InternalError"})),
376 )
377 .into_response()
378 }
379 }
380}
381
382pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool {
383 has_passkeys_for_user_db(&state.db, did).await
384}
385
386pub async fn has_passkeys_for_user_db(db: &sqlx::PgPool, did: &str) -> bool {
387 webauthn::has_passkeys(db, did).await.unwrap_or(false)
388}