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