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 let id: uuid::Uuid = match input.id.parse() {
298 Ok(id) => id,
299 Err(_) => {
300 return (
301 StatusCode::BAD_REQUEST,
302 Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
303 )
304 .into_response();
305 }
306 };
307
308 match db_delete_passkey(&state.db, id, &auth.0.did).await {
309 Ok(true) => {
310 info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted");
311 (StatusCode::OK, Json(json!({}))).into_response()
312 }
313 Ok(false) => (
314 StatusCode::NOT_FOUND,
315 Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
316 )
317 .into_response(),
318 Err(e) => {
319 error!("DB error deleting passkey: {:?}", e);
320 (
321 StatusCode::INTERNAL_SERVER_ERROR,
322 Json(json!({"error": "InternalError"})),
323 )
324 .into_response()
325 }
326 }
327}
328
329#[derive(Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct UpdatePasskeyInput {
332 pub id: String,
333 pub friendly_name: String,
334}
335
336pub async fn update_passkey(
337 State(state): State<AppState>,
338 auth: BearerAuth,
339 Json(input): Json<UpdatePasskeyInput>,
340) -> Response {
341 let id: uuid::Uuid = match input.id.parse() {
342 Ok(id) => id,
343 Err(_) => {
344 return (
345 StatusCode::BAD_REQUEST,
346 Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
347 )
348 .into_response();
349 }
350 };
351
352 match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await {
353 Ok(true) => {
354 info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed");
355 (StatusCode::OK, Json(json!({}))).into_response()
356 }
357 Ok(false) => (
358 StatusCode::NOT_FOUND,
359 Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
360 )
361 .into_response(),
362 Err(e) => {
363 error!("DB error updating passkey: {:?}", e);
364 (
365 StatusCode::INTERNAL_SERVER_ERROR,
366 Json(json!({"error": "InternalError"})),
367 )
368 .into_response()
369 }
370 }
371}
372
373pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool {
374 has_passkeys_for_user_db(&state.db, did).await
375}
376
377pub async fn has_passkeys_for_user_db(db: &sqlx::PgPool, did: &str) -> bool {
378 webauthn::has_passkeys(db, did).await.unwrap_or(false)
379}