Skip to main content

cloudillo_auth/
webauthn.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! WebAuthn (Passkey) authentication handlers
5
6use 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
23/// Challenge JWT expiry in seconds (2 minutes)
24const CHALLENGE_EXPIRY_SECS: u64 = 120;
25
26/// Challenge token claims for registration
27#[derive(Debug, Serialize, Deserialize)]
28struct RegChallengeToken {
29	tn_id: u32,
30	id_tag: String,
31	state: String, // Serialized PasskeyRegistration
32	exp: u64,
33}
34
35/// Challenge token claims for authentication
36#[derive(Debug, Serialize, Deserialize)]
37struct LoginChallengeToken {
38	tn_id: u32,
39	id_tag: String,
40	state: String, // Serialized PasskeyAuthentication
41	exp: u64,
42}
43
44/// Build a Webauthn instance for the given tenant
45fn 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
63/// Get current timestamp as seconds since epoch
64fn now_secs() -> u64 {
65	std::time::SystemTime::now()
66		.duration_since(std::time::UNIX_EPOCH)
67		.unwrap_or_default()
68		.as_secs()
69}
70
71/// Create a challenge JWT token
72fn 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
84/// Decode and validate a challenge JWT token
85fn 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
100/// Convert stored credentials to webauthn-rs Passkey format
101///
102/// The public_key field stores the full Passkey JSON serialization
103fn 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// ============================================================================
111// Response Types
112// ============================================================================
113
114/// Credential info for listing (without sensitive data)
115#[derive(Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct CredentialInfo {
118	credential_id: String,
119	description: String,
120}
121
122/// Parse user-agent string to get a readable device/browser name
123fn parse_user_agent(ua: &str) -> String {
124	// Try to extract browser and OS info from user-agent
125	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/// Registration challenge response
155/// Note: options is serialized as JSON Value to extract just the publicKey contents
156/// which is what @simplewebauthn/browser expects
157#[derive(Serialize)]
158#[serde(rename_all = "camelCase")]
159pub struct RegChallengeRes {
160	options: serde_json::Value,
161	token: String,
162}
163
164/// Login challenge response
165/// Note: options is serialized as JSON Value to extract just the publicKey contents
166#[derive(Clone, Serialize)]
167#[serde(rename_all = "camelCase")]
168pub struct LoginChallengeRes {
169	options: serde_json::Value,
170	token: String,
171}
172
173// ============================================================================
174// Request Types
175// ============================================================================
176
177/// Registration request body
178#[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/// Login request body
188#[derive(Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct LoginReq {
191	token: String,
192	response: PublicKeyCredential,
193}
194
195// ============================================================================
196// Handlers
197// ============================================================================
198
199/// GET /api/auth/wa/reg - List WebAuthn credentials
200pub 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
219/// GET /api/auth/wa/reg/challenge - Get registration challenge
220pub 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	// Get existing credentials to exclude from registration
229	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	// Create user unique ID from tn_id
237	let user_id = Uuid::from_u128(u128::from(auth.tn_id.0));
238
239	// Start passkey registration
240	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	// Serialize registration state
248	let state_json = serde_json::to_string(&reg_state)
249		.map_err(|_| Error::Internal("Failed to serialize registration state".into()))?;
250
251	// Get JWT secret
252	let jwt_secret = app.auth_adapter.read_var(TnId(0), "jwt_secret").await?;
253
254	// Create challenge token
255	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	// Extract publicKey contents for @simplewebauthn/browser compatibility
264	// webauthn-rs serializes as { publicKey: { ... } } but simplewebauthn expects just the inner object
265	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
272/// POST /api/auth/wa/reg - Register a new credential
273pub 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	// Get JWT secret and decode challenge token
282	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	// Verify the token belongs to this user
286	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	// Check expiry
292	if claims.exp < now_secs() {
293		warn!("Challenge token expired");
294		return Err(Error::Unauthorized);
295	}
296
297	// Deserialize registration state
298	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	// Build webauthn and finish registration
304	let webauthn = build_webauthn(&auth.id_tag)?;
305	let passkey = webauthn.finish_passkey_registration(&req.response, &reg_state).map_err(|e| {
306		warn!("WebAuthn finish_passkey_registration error: {:?}", e);
307		Error::PermissionDenied
308	})?;
309
310	// Extract credential ID (base64url encoded)
311	let cred_id = URL_SAFE_NO_PAD.encode(passkey.cred_id());
312
313	// Serialize the full Passkey for storage
314	// This stores the COSE key, counter, and other credential data
315	let passkey_json = serde_json::to_string(&passkey)
316		.map_err(|_| Error::Internal("Failed to serialize passkey".into()))?;
317
318	// Generate description from user-agent + timestamp if not provided
319	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		// Parse user-agent to get a readable device name
326		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	// Store the credential
332	// Note: public_key field stores the full Passkey JSON
333	let webauthn_data = auth_adapter::Webauthn {
334		credential_id: &cred_id,
335		counter: 0, // Initial counter, will be managed by Passkey internally
336		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
349/// DELETE /api/auth/wa/reg/{key_id} - Delete a credential
350pub 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
362/// Try to create a login challenge, returning `None` instead of an error when no passkeys exist.
363/// Extracted for reuse by `post_login_init`.
364pub async fn try_login_challenge(
365	app: &App,
366	id_tag: &IdTag,
367	tn_id: TnId,
368) -> Option<LoginChallengeRes> {
369	// Get credentials for this tenant
370	let credentials = app.auth_adapter.list_webauthn_credentials(tn_id).await.ok()?;
371	if credentials.is_empty() {
372		return None;
373	}
374
375	// Convert to Passkey format
376	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	// Build webauthn and start authentication
385	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	// Serialize authentication state
396	let state_json = serde_json::to_string(&auth_state)
397		.map_err(|e| warn!("WebAuthn state serialization error: {:?}", e))
398		.ok()?;
399
400	// Get JWT secret
401	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	// Create challenge token
409	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	// Extract publicKey contents for @simplewebauthn/browser compatibility
420	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
428/// GET /api/auth/wa/login/challenge - Get login challenge
429pub 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
441/// POST /api/auth/wa/login - Authenticate with WebAuthn
442pub 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	// Get JWT secret and decode challenge token
449	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	// Check expiry
453	if claims.exp < now_secs() {
454		warn!("Challenge token expired");
455		return Err(Error::Unauthorized);
456	}
457
458	// Deserialize authentication state
459	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	// Build webauthn and finish authentication
465	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	// Update the counter in the stored credential
475	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	// Create login session
483	let auth_login = app.auth_adapter.create_tenant_login(&claims.id_tag).await?;
484
485	// Return login response using existing pattern
486	let (status, json) = return_login(&app, auth_login).await?;
487	Ok((status, Json(ApiResponse::new(json.0))))
488}
489
490// vim: ts=4