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