1use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 Json,
7};
8use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
9use serde::{Deserialize, Serialize};
10use webauthn_rs::prelude::*;
11
12use cloudillo_core::extract::IdTag;
13use cloudillo_core::Auth;
14use cloudillo_types::{auth_adapter, types::ApiResponse};
15
16use crate::prelude::*;
17
18use super::handler::return_login;
19
20const CHALLENGE_EXPIRY_SECS: u64 = 120;
22
23#[derive(Debug, Serialize, Deserialize)]
25struct RegChallengeToken {
26 tn_id: u32,
27 id_tag: String,
28 state: String, exp: u64,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
34struct LoginChallengeToken {
35 tn_id: u32,
36 id_tag: String,
37 state: String, exp: u64,
39}
40
41fn build_webauthn(id_tag: &str) -> ClResult<Webauthn> {
43 let rp_id = id_tag.to_string();
44 let rp_origin = Url::parse(&format!("https://{}", id_tag))
45 .map_err(|_| Error::Internal("invalid origin URL".into()))?;
46
47 WebauthnBuilder::new(&rp_id, &rp_origin)
48 .map_err(|e| {
49 warn!("WebAuthn builder error: {:?}", e);
50 Error::Internal("WebAuthn builder error".into())
51 })?
52 .rp_name(id_tag)
53 .build()
54 .map_err(|e| {
55 warn!("WebAuthn build error: {:?}", e);
56 Error::Internal("WebAuthn build error".into())
57 })
58}
59
60fn now_secs() -> u64 {
62 std::time::SystemTime::now()
63 .duration_since(std::time::UNIX_EPOCH)
64 .unwrap_or_default()
65 .as_secs()
66}
67
68fn create_challenge_jwt<T: Serialize>(claims: &T, secret: &str) -> ClResult<String> {
70 jsonwebtoken::encode(
71 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
72 claims,
73 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
74 )
75 .map_err(|e| {
76 warn!("JWT encode error: {:?}", e);
77 Error::Internal("JWT encode error".into())
78 })
79}
80
81fn decode_challenge_jwt<T: for<'de> Deserialize<'de>>(token: &str, secret: &str) -> ClResult<T> {
83 let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
84 let token_data = jsonwebtoken::decode::<T>(
85 token,
86 &jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
87 &validation,
88 )
89 .map_err(|e| {
90 warn!("JWT decode error: {:?}", e);
91 Error::Unauthorized
92 })?;
93
94 Ok(token_data.claims)
95}
96
97fn stored_to_passkey(stored: &auth_adapter::Webauthn) -> ClResult<Passkey> {
101 serde_json::from_str(stored.public_key).map_err(|e| {
102 warn!("Failed to deserialize Passkey: {:?}", e);
103 Error::Internal("Failed to deserialize Passkey".into())
104 })
105}
106
107#[derive(Serialize)]
113#[serde(rename_all = "camelCase")]
114pub struct CredentialInfo {
115 credential_id: String,
116 description: String,
117}
118
119fn parse_user_agent(ua: &str) -> String {
121 let browser = if ua.contains("Firefox") {
123 "Firefox"
124 } else if ua.contains("Edg/") {
125 "Edge"
126 } else if ua.contains("Chrome") {
127 "Chrome"
128 } else if ua.contains("Safari") {
129 "Safari"
130 } else {
131 "Browser"
132 };
133
134 let os = if ua.contains("Windows") {
135 "Windows"
136 } else if ua.contains("Mac OS") || ua.contains("Macintosh") {
137 "macOS"
138 } else if ua.contains("Linux") {
139 "Linux"
140 } else if ua.contains("Android") {
141 "Android"
142 } else if ua.contains("iPhone") || ua.contains("iPad") {
143 "iOS"
144 } else {
145 "Unknown"
146 };
147
148 format!("{} on {}", browser, os)
149}
150
151#[derive(Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct RegChallengeRes {
157 options: serde_json::Value,
158 token: String,
159}
160
161#[derive(Clone, Serialize)]
164#[serde(rename_all = "camelCase")]
165pub struct LoginChallengeRes {
166 options: serde_json::Value,
167 token: String,
168}
169
170#[derive(Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct RegReq {
178 token: String,
179 response: RegisterPublicKeyCredential,
180 #[serde(default)]
181 description: Option<String>,
182}
183
184#[derive(Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct LoginReq {
188 token: String,
189 response: PublicKeyCredential,
190}
191
192pub async fn list_reg(
198 State(app): State<App>,
199 Auth(auth): Auth,
200) -> ClResult<(StatusCode, Json<ApiResponse<Vec<CredentialInfo>>>)> {
201 info!("Listing WebAuthn credentials for {}", auth.id_tag);
202
203 let credentials = app.auth_adapter.list_webauthn_credentials(auth.tn_id).await?;
204
205 let result: Vec<CredentialInfo> = credentials
206 .iter()
207 .map(|c| CredentialInfo {
208 credential_id: c.credential_id.to_string(),
209 description: c.description.map_or_else(|| "Passkey".to_string(), ToString::to_string),
210 })
211 .collect();
212
213 Ok((StatusCode::OK, Json(ApiResponse::new(result))))
214}
215
216pub async fn get_reg_challenge(
218 State(app): State<App>,
219 Auth(auth): Auth,
220) -> ClResult<(StatusCode, Json<ApiResponse<RegChallengeRes>>)> {
221 info!("Getting WebAuthn registration challenge for {}", auth.id_tag);
222
223 let webauthn = build_webauthn(&auth.id_tag)?;
224
225 let existing = app.auth_adapter.list_webauthn_credentials(auth.tn_id).await?;
227 let exclude_credentials: Vec<CredentialID> = existing
228 .iter()
229 .filter_map(|c| URL_SAFE_NO_PAD.decode(c.credential_id).ok())
230 .map(CredentialID::from)
231 .collect();
232
233 let user_id = Uuid::from_u128(u128::from(auth.tn_id.0));
235
236 let (ccr, reg_state) = webauthn
238 .start_passkey_registration(user_id, &auth.id_tag, &auth.id_tag, Some(exclude_credentials))
239 .map_err(|e| {
240 warn!("WebAuthn start_passkey_registration error: {:?}", e);
241 Error::Internal("WebAuthn registration error".into())
242 })?;
243
244 let state_json = serde_json::to_string(®_state)
246 .map_err(|_| Error::Internal("Failed to serialize registration state".into()))?;
247
248 let jwt_secret = app.auth_adapter.read_var(TnId(0), "jwt_secret").await?;
250
251 let claims = RegChallengeToken {
253 tn_id: auth.tn_id.0,
254 id_tag: auth.id_tag.to_string(),
255 state: state_json,
256 exp: now_secs() + CHALLENGE_EXPIRY_SECS,
257 };
258 let token = create_challenge_jwt(&claims, &jwt_secret)?;
259
260 let ccr_json = serde_json::to_value(&ccr)
263 .map_err(|_| Error::Internal("Failed to serialize challenge".into()))?;
264 let options = ccr_json.get("publicKey").cloned().unwrap_or(ccr_json);
265
266 Ok((StatusCode::OK, Json(ApiResponse::new(RegChallengeRes { options, token }))))
267}
268
269pub async fn post_reg(
271 State(app): State<App>,
272 Auth(auth): Auth,
273 headers: axum::http::HeaderMap,
274 Json(req): Json<RegReq>,
275) -> ClResult<(StatusCode, Json<ApiResponse<CredentialInfo>>)> {
276 info!("Registering WebAuthn credential for {}", auth.id_tag);
277
278 let jwt_secret = app.auth_adapter.read_var(TnId(0), "jwt_secret").await?;
280 let claims: RegChallengeToken = decode_challenge_jwt(&req.token, &jwt_secret)?;
281
282 if claims.tn_id != auth.tn_id.0 {
284 warn!("Token tn_id mismatch: {} != {}", claims.tn_id, auth.tn_id.0);
285 return Err(Error::PermissionDenied);
286 }
287
288 if claims.exp < now_secs() {
290 warn!("Challenge token expired");
291 return Err(Error::Unauthorized);
292 }
293
294 let reg_state: PasskeyRegistration = serde_json::from_str(&claims.state).map_err(|e| {
296 warn!("Failed to deserialize registration state: {:?}", e);
297 Error::Internal("Invalid registration state".into())
298 })?;
299
300 let webauthn = build_webauthn(&auth.id_tag)?;
302 let passkey = webauthn.finish_passkey_registration(&req.response, ®_state).map_err(|e| {
303 warn!("WebAuthn finish_passkey_registration error: {:?}", e);
304 Error::PermissionDenied
305 })?;
306
307 let cred_id = URL_SAFE_NO_PAD.encode(passkey.cred_id());
309
310 let passkey_json = serde_json::to_string(&passkey)
313 .map_err(|_| Error::Internal("Failed to serialize passkey".into()))?;
314
315 let description = req.description.clone().unwrap_or_else(|| {
317 let user_agent = headers
318 .get(axum::http::header::USER_AGENT)
319 .and_then(|v| v.to_str().ok())
320 .unwrap_or("Unknown device");
321
322 let device_name = parse_user_agent(user_agent);
324 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
325 format!("{} - {}", device_name, timestamp)
326 });
327
328 let webauthn_data = auth_adapter::Webauthn {
331 credential_id: &cred_id,
332 counter: 0, public_key: &passkey_json,
334 description: Some(&description),
335 };
336 app.auth_adapter.create_webauthn_credential(auth.tn_id, &webauthn_data).await?;
337
338 info!("WebAuthn credential registered: {}", cred_id);
339
340 Ok((
341 StatusCode::CREATED,
342 Json(ApiResponse::new(CredentialInfo { credential_id: cred_id, description })),
343 ))
344}
345
346pub async fn delete_reg(
348 State(app): State<App>,
349 Auth(auth): Auth,
350 Path(key_id): Path<String>,
351) -> ClResult<StatusCode> {
352 info!("Deleting WebAuthn credential {} for {}", key_id, auth.id_tag);
353
354 app.auth_adapter.delete_webauthn_credential(auth.tn_id, &key_id).await?;
355
356 Ok(StatusCode::NO_CONTENT)
357}
358
359pub async fn try_login_challenge(
362 app: &App,
363 id_tag: &IdTag,
364 tn_id: TnId,
365) -> Option<LoginChallengeRes> {
366 let credentials = app.auth_adapter.list_webauthn_credentials(tn_id).await.ok()?;
368 if credentials.is_empty() {
369 return None;
370 }
371
372 let passkeys: Vec<Passkey> =
374 credentials.iter().filter_map(|c| stored_to_passkey(c).ok()).collect();
375
376 if passkeys.is_empty() {
377 warn!("No valid passkeys found for {}", id_tag.0);
378 return None;
379 }
380
381 let webauthn = build_webauthn(&id_tag.0)
383 .map_err(|e| warn!("WebAuthn build_webauthn error: {:?}", e))
384 .ok()?;
385 let (rcr, auth_state) = webauthn
386 .start_passkey_authentication(&passkeys)
387 .map_err(|e| {
388 warn!("WebAuthn start_passkey_authentication error: {:?}", e);
389 })
390 .ok()?;
391
392 let state_json = serde_json::to_string(&auth_state)
394 .map_err(|e| warn!("WebAuthn state serialization error: {:?}", e))
395 .ok()?;
396
397 let jwt_secret = app
399 .auth_adapter
400 .read_var(TnId(0), "jwt_secret")
401 .await
402 .map_err(|e| warn!("WebAuthn jwt_secret read error: {:?}", e))
403 .ok()?;
404
405 let claims = LoginChallengeToken {
407 tn_id: tn_id.0,
408 id_tag: id_tag.0.to_string(),
409 state: state_json,
410 exp: now_secs() + CHALLENGE_EXPIRY_SECS,
411 };
412 let token = create_challenge_jwt(&claims, &jwt_secret)
413 .map_err(|e| warn!("WebAuthn challenge JWT creation error: {:?}", e))
414 .ok()?;
415
416 let rcr_json = serde_json::to_value(&rcr)
418 .map_err(|e| warn!("WebAuthn rcr serialization error: {:?}", e))
419 .ok()?;
420 let options = rcr_json.get("publicKey").cloned().unwrap_or(rcr_json);
421
422 Some(LoginChallengeRes { options, token })
423}
424
425pub async fn get_login_challenge(
427 State(app): State<App>,
428 id_tag: IdTag,
429 tn_id: TnId,
430) -> ClResult<(StatusCode, Json<ApiResponse<LoginChallengeRes>>)> {
431 info!("Getting WebAuthn login challenge for {}", id_tag.0);
432
433 let result = try_login_challenge(&app, &id_tag, tn_id).await.ok_or(Error::NotFound)?;
434
435 Ok((StatusCode::OK, Json(ApiResponse::new(result))))
436}
437
438pub async fn post_login(
440 State(app): State<App>,
441 Json(req): Json<LoginReq>,
442) -> ClResult<(StatusCode, Json<ApiResponse<super::handler::Login>>)> {
443 info!("Processing WebAuthn login");
444
445 let jwt_secret = app.auth_adapter.read_var(TnId(0), "jwt_secret").await?;
447 let claims: LoginChallengeToken = decode_challenge_jwt(&req.token, &jwt_secret)?;
448
449 if claims.exp < now_secs() {
451 warn!("Challenge token expired");
452 return Err(Error::Unauthorized);
453 }
454
455 let auth_state: PasskeyAuthentication = serde_json::from_str(&claims.state).map_err(|e| {
457 warn!("Failed to deserialize authentication state: {:?}", e);
458 Error::Internal("Invalid authentication state".into())
459 })?;
460
461 let webauthn = build_webauthn(&claims.id_tag)?;
463 let auth_result =
464 webauthn
465 .finish_passkey_authentication(&req.response, &auth_state)
466 .map_err(|e| {
467 warn!("WebAuthn finish_passkey_authentication error: {:?}", e);
468 Error::PermissionDenied
469 })?;
470
471 let cred_id = URL_SAFE_NO_PAD.encode(auth_result.cred_id());
473 app.auth_adapter
474 .update_webauthn_credential_counter(TnId(claims.tn_id), &cred_id, auth_result.counter())
475 .await?;
476
477 info!("WebAuthn authentication successful for {}", claims.id_tag);
478
479 let auth_login = app.auth_adapter.create_tenant_login(&claims.id_tag).await?;
481
482 let (status, json) = return_login(&app, auth_login).await?;
484 Ok((status, Json(ApiResponse::new(json.0))))
485}
486
487