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 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
20/// Challenge JWT expiry in seconds (2 minutes)
21const CHALLENGE_EXPIRY_SECS: u64 = 120;
22
23/// Challenge token claims for registration
24#[derive(Debug, Serialize, Deserialize)]
25struct RegChallengeToken {
26	tn_id: u32,
27	id_tag: String,
28	state: String, // Serialized PasskeyRegistration
29	exp: u64,
30}
31
32/// Challenge token claims for authentication
33#[derive(Debug, Serialize, Deserialize)]
34struct LoginChallengeToken {
35	tn_id: u32,
36	id_tag: String,
37	state: String, // Serialized PasskeyAuthentication
38	exp: u64,
39}
40
41/// Build a Webauthn instance for the given tenant
42fn 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
60/// Get current timestamp as seconds since epoch
61fn now_secs() -> u64 {
62	std::time::SystemTime::now()
63		.duration_since(std::time::UNIX_EPOCH)
64		.unwrap_or_default()
65		.as_secs()
66}
67
68/// Create a challenge JWT token
69fn 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
81/// Decode and validate a challenge JWT token
82fn 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
97/// Convert stored credentials to webauthn-rs Passkey format
98///
99/// The public_key field stores the full Passkey JSON serialization
100fn 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// ============================================================================
108// Response Types
109// ============================================================================
110
111/// Credential info for listing (without sensitive data)
112#[derive(Serialize)]
113#[serde(rename_all = "camelCase")]
114pub struct CredentialInfo {
115	credential_id: String,
116	description: String,
117}
118
119/// Parse user-agent string to get a readable device/browser name
120fn parse_user_agent(ua: &str) -> String {
121	// Try to extract browser and OS info from user-agent
122	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/// Registration challenge response
152/// Note: options is serialized as JSON Value to extract just the publicKey contents
153/// which is what @simplewebauthn/browser expects
154#[derive(Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct RegChallengeRes {
157	options: serde_json::Value,
158	token: String,
159}
160
161/// Login challenge response
162/// Note: options is serialized as JSON Value to extract just the publicKey contents
163#[derive(Clone, Serialize)]
164#[serde(rename_all = "camelCase")]
165pub struct LoginChallengeRes {
166	options: serde_json::Value,
167	token: String,
168}
169
170// ============================================================================
171// Request Types
172// ============================================================================
173
174/// Registration request body
175#[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/// Login request body
185#[derive(Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct LoginReq {
188	token: String,
189	response: PublicKeyCredential,
190}
191
192// ============================================================================
193// Handlers
194// ============================================================================
195
196/// GET /api/auth/wa/reg - List WebAuthn credentials
197pub 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
216/// GET /api/auth/wa/reg/challenge - Get registration challenge
217pub 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	// Get existing credentials to exclude from registration
226	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	// Create user unique ID from tn_id
234	let user_id = Uuid::from_u128(u128::from(auth.tn_id.0));
235
236	// Start passkey registration
237	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	// Serialize registration state
245	let state_json = serde_json::to_string(&reg_state)
246		.map_err(|_| Error::Internal("Failed to serialize registration state".into()))?;
247
248	// Get JWT secret
249	let jwt_secret = app.auth_adapter.read_var(TnId(0), "jwt_secret").await?;
250
251	// Create challenge token
252	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	// Extract publicKey contents for @simplewebauthn/browser compatibility
261	// webauthn-rs serializes as { publicKey: { ... } } but simplewebauthn expects just the inner object
262	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
269/// POST /api/auth/wa/reg - Register a new credential
270pub 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	// Get JWT secret and decode challenge token
279	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	// Verify the token belongs to this user
283	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	// Check expiry
289	if claims.exp < now_secs() {
290		warn!("Challenge token expired");
291		return Err(Error::Unauthorized);
292	}
293
294	// Deserialize registration state
295	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	// Build webauthn and finish registration
301	let webauthn = build_webauthn(&auth.id_tag)?;
302	let passkey = webauthn.finish_passkey_registration(&req.response, &reg_state).map_err(|e| {
303		warn!("WebAuthn finish_passkey_registration error: {:?}", e);
304		Error::PermissionDenied
305	})?;
306
307	// Extract credential ID (base64url encoded)
308	let cred_id = URL_SAFE_NO_PAD.encode(passkey.cred_id());
309
310	// Serialize the full Passkey for storage
311	// This stores the COSE key, counter, and other credential data
312	let passkey_json = serde_json::to_string(&passkey)
313		.map_err(|_| Error::Internal("Failed to serialize passkey".into()))?;
314
315	// Generate description from user-agent + timestamp if not provided
316	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		// Parse user-agent to get a readable device name
323		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	// Store the credential
329	// Note: public_key field stores the full Passkey JSON
330	let webauthn_data = auth_adapter::Webauthn {
331		credential_id: &cred_id,
332		counter: 0, // Initial counter, will be managed by Passkey internally
333		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
346/// DELETE /api/auth/wa/reg/{key_id} - Delete a credential
347pub 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
359/// Try to create a login challenge, returning `None` instead of an error when no passkeys exist.
360/// Extracted for reuse by `post_login_init`.
361pub async fn try_login_challenge(
362	app: &App,
363	id_tag: &IdTag,
364	tn_id: TnId,
365) -> Option<LoginChallengeRes> {
366	// Get credentials for this tenant
367	let credentials = app.auth_adapter.list_webauthn_credentials(tn_id).await.ok()?;
368	if credentials.is_empty() {
369		return None;
370	}
371
372	// Convert to Passkey format
373	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	// Build webauthn and start authentication
382	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	// Serialize authentication state
393	let state_json = serde_json::to_string(&auth_state)
394		.map_err(|e| warn!("WebAuthn state serialization error: {:?}", e))
395		.ok()?;
396
397	// Get JWT secret
398	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	// Create challenge token
406	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	// Extract publicKey contents for @simplewebauthn/browser compatibility
417	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
425/// GET /api/auth/wa/login/challenge - Get login challenge
426pub 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
438/// POST /api/auth/wa/login - Authenticate with WebAuthn
439pub 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	// Get JWT secret and decode challenge token
446	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	// Check expiry
450	if claims.exp < now_secs() {
451		warn!("Challenge token expired");
452		return Err(Error::Unauthorized);
453	}
454
455	// Deserialize authentication state
456	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	// Build webauthn and finish authentication
462	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	// Update the counter in the stored credential
472	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	// Create login session
480	let auth_login = app.auth_adapter.create_tenant_login(&claims.id_tag).await?;
481
482	// Return login response using existing pattern
483	let (status, json) = return_login(&app, auth_login).await?;
484	Ok((status, Json(ApiResponse::new(json.0))))
485}
486
487// vim: ts=4