Skip to main content

cloudillo_auth/
webauthn.rs

1//! WebAuthn (Passkey) authentication handlers
2
3use 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
21/// Challenge JWT expiry in seconds (2 minutes)
22const CHALLENGE_EXPIRY_SECS: u64 = 120;
23
24/// Challenge token claims for registration
25#[derive(Debug, Serialize, Deserialize)]
26struct RegChallengeToken {
27	tn_id: u32,
28	id_tag: String,
29	state: String, // Serialized PasskeyRegistration
30	exp: u64,
31}
32
33/// Challenge token claims for authentication
34#[derive(Debug, Serialize, Deserialize)]
35struct LoginChallengeToken {
36	tn_id: u32,
37	id_tag: String,
38	state: String, // Serialized PasskeyAuthentication
39	exp: u64,
40}
41
42/// Build a Webauthn instance for the given tenant
43fn 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
61/// Get current timestamp as seconds since epoch
62fn now_secs() -> u64 {
63	std::time::SystemTime::now()
64		.duration_since(std::time::UNIX_EPOCH)
65		.unwrap_or_default()
66		.as_secs()
67}
68
69/// Create a challenge JWT token
70fn 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
82/// Decode and validate a challenge JWT token
83fn 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
98/// Convert stored credentials to webauthn-rs Passkey format
99///
100/// The public_key field stores the full Passkey JSON serialization
101fn 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// ============================================================================
109// Response Types
110// ============================================================================
111
112/// Credential info for listing (without sensitive data)
113#[derive(Serialize)]
114#[serde(rename_all = "camelCase")]
115pub struct CredentialInfo {
116	credential_id: String,
117	description: String,
118}
119
120/// Parse user-agent string to get a readable device/browser name
121fn parse_user_agent(ua: &str) -> String {
122	// Try to extract browser and OS info from user-agent
123	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/// Registration challenge response
153/// Note: options is serialized as JSON Value to extract just the publicKey contents
154/// which is what @simplewebauthn/browser expects
155#[derive(Serialize)]
156#[serde(rename_all = "camelCase")]
157pub struct RegChallengeRes {
158	options: serde_json::Value,
159	token: String,
160}
161
162/// Login challenge response
163/// Note: options is serialized as JSON Value to extract just the publicKey contents
164#[derive(Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct LoginChallengeRes {
167	options: serde_json::Value,
168	token: String,
169}
170
171// ============================================================================
172// Request Types
173// ============================================================================
174
175/// Registration request body
176#[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/// Login request body
186#[derive(Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct LoginReq {
189	token: String,
190	response: PublicKeyCredential,
191}
192
193// ============================================================================
194// Handlers
195// ============================================================================
196
197/// GET /api/auth/wa/reg - List WebAuthn credentials
198pub 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
220/// GET /api/auth/wa/reg/challenge - Get registration challenge
221pub 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	// Get existing credentials to exclude from registration
230	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	// Create user unique ID from tn_id
238	let user_id = Uuid::from_u128(auth.tn_id.0 as u128);
239
240	// Start passkey registration
241	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	// Serialize registration state
249	let state_json = serde_json::to_string(&reg_state)
250		.map_err(|_| Error::Internal("Failed to serialize registration state".into()))?;
251
252	// Get JWT secret
253	let jwt_secret = app.auth_adapter.read_var(TnId(0), "jwt_secret").await?;
254
255	// Create challenge token
256	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	// Extract publicKey contents for @simplewebauthn/browser compatibility
265	// webauthn-rs serializes as { publicKey: { ... } } but simplewebauthn expects just the inner object
266	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
273/// POST /api/auth/wa/reg - Register a new credential
274pub 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	// Get JWT secret and decode challenge token
283	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	// Verify the token belongs to this user
287	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	// Check expiry
293	if claims.exp < now_secs() {
294		warn!("Challenge token expired");
295		return Err(Error::Unauthorized);
296	}
297
298	// Deserialize registration state
299	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	// Build webauthn and finish registration
305	let webauthn = build_webauthn(&auth.id_tag)?;
306	let passkey = webauthn.finish_passkey_registration(&req.response, &reg_state).map_err(|e| {
307		warn!("WebAuthn finish_passkey_registration error: {:?}", e);
308		Error::PermissionDenied
309	})?;
310
311	// Extract credential ID (base64url encoded)
312	let cred_id = URL_SAFE_NO_PAD.encode(passkey.cred_id());
313
314	// Serialize the full Passkey for storage
315	// This stores the COSE key, counter, and other credential data
316	let passkey_json = serde_json::to_string(&passkey)
317		.map_err(|_| Error::Internal("Failed to serialize passkey".into()))?;
318
319	// Generate description from user-agent + timestamp if not provided
320	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		// Parse user-agent to get a readable device name
327		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	// Store the credential
333	// Note: public_key field stores the full Passkey JSON
334	let webauthn_data = auth_adapter::Webauthn {
335		credential_id: &cred_id,
336		counter: 0, // Initial counter, will be managed by Passkey internally
337		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
350/// DELETE /api/auth/wa/reg/{key_id} - Delete a credential
351pub 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
363/// GET /api/auth/wa/login/challenge - Get login challenge
364pub 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	// Get credentials for this tenant
372	let credentials = app.auth_adapter.list_webauthn_credentials(tn_id).await?;
373	if credentials.is_empty() {
374		return Err(Error::NotFound);
375	}
376
377	// Convert to Passkey format
378	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	// Build webauthn and start authentication
387	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	// Serialize authentication state
394	let state_json = serde_json::to_string(&auth_state)
395		.map_err(|_| Error::Internal("Failed to serialize auth state".into()))?;
396
397	// Get JWT secret
398	let jwt_secret = app.auth_adapter.read_var(TnId(0), "jwt_secret").await?;
399
400	// Create challenge token
401	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	// Extract publicKey contents for @simplewebauthn/browser compatibility
410	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
417/// POST /api/auth/wa/login - Authenticate with WebAuthn
418pub 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	// Get JWT secret and decode challenge token
425	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	// Check expiry
429	if claims.exp < now_secs() {
430		warn!("Challenge token expired");
431		return Err(Error::Unauthorized);
432	}
433
434	// Deserialize authentication state
435	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	// Build webauthn and finish authentication
441	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	// Update the counter in the stored credential
451	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	// Create login session
459	let auth_login = app.auth_adapter.create_tenant_login(&claims.id_tag).await?;
460
461	// Return login response using existing pattern
462	let (status, json) = return_login(&app, auth_login).await?;
463	Ok((status, Json(ApiResponse::new(json.0))))
464}
465
466// vim: ts=4