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