Skip to main content

rustauth_passkey/
webauthn.rs

1use base64::Engine;
2use rustauth_core::error::RustAuthError;
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use std::collections::BTreeMap;
6use std::str::FromStr;
7use std::time::Duration;
8use url::Url;
9use uuid::Uuid;
10
11use webauthn_rs::prelude::{
12    AttestationFormat, AttestationMetadata, COSEAlgorithm, COSEKey, COSEKeyType,
13    CreationChallengeResponse, Credential, CredentialID, ECDSACurve, EDDSACurve, ParsedAttestation,
14    Passkey, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse,
15};
16use webauthn_rs_core::proto::{
17    AttestationConveyancePreference, AuthenticationState, AuthenticatorTransport,
18    RegisteredExtensions, RegistrationState, RequestAuthenticationExtensions,
19    RequestRegistrationExtensions, UserVerificationPolicy,
20};
21use webauthn_rs_core::WebauthnCore;
22
23use crate::options::{
24    PasskeyRegistrationUser, RegistrationWebAuthnOptions, UserVerificationRequirement,
25};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct WebAuthnConfig {
29    pub rp_id: String,
30    pub rp_name: String,
31    pub origins: Vec<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct PasskeyRegistrationStart {
36    pub options: Value,
37    pub state: Value,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct PasskeyAuthenticationStart {
42    pub options: Value,
43    pub state: Value,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct VerifiedPasskeyCredential {
48    pub credential_id: String,
49    pub public_key: String,
50    pub counter: u32,
51    pub device_type: String,
52    pub backed_up: bool,
53    pub transports: Option<String>,
54    pub aaguid: Option<String>,
55    pub credential: Value,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct VerifiedAuthentication {
60    pub credential: Option<Value>,
61    pub new_counter: u32,
62}
63
64pub trait PasskeyWebAuthnBackend: Send + Sync {
65    fn start_registration(
66        &self,
67        config: WebAuthnConfig,
68        user: &PasskeyRegistrationUser,
69        exclude_credentials: Vec<Value>,
70        options: RegistrationWebAuthnOptions,
71    ) -> Result<PasskeyRegistrationStart, RustAuthError>;
72
73    fn finish_registration(
74        &self,
75        config: WebAuthnConfig,
76        response: Value,
77        state: Value,
78    ) -> Result<VerifiedPasskeyCredential, RustAuthError> {
79        let _ = (config, response, state);
80        Err(RustAuthError::Api(
81            "passkey registration verification is not implemented".to_owned(),
82        ))
83    }
84
85    fn start_authentication(
86        &self,
87        config: WebAuthnConfig,
88        credentials: Vec<Value>,
89        extensions: Option<Value>,
90    ) -> Result<PasskeyAuthenticationStart, RustAuthError>;
91
92    fn finish_authentication(
93        &self,
94        config: WebAuthnConfig,
95        response: Value,
96        state: Value,
97        credential: Option<Value>,
98    ) -> Result<VerifiedAuthentication, RustAuthError> {
99        let _ = (config, response, state, credential);
100        Err(RustAuthError::Api(
101            "passkey authentication verification is not implemented".to_owned(),
102        ))
103    }
104}
105
106#[derive(Debug, Clone, Copy)]
107pub struct RealPasskeyWebAuthnBackend;
108
109impl PasskeyWebAuthnBackend for RealPasskeyWebAuthnBackend {
110    fn start_registration(
111        &self,
112        config: WebAuthnConfig,
113        user: &PasskeyRegistrationUser,
114        exclude_credentials: Vec<Value>,
115        request_options: RegistrationWebAuthnOptions,
116    ) -> Result<PasskeyRegistrationStart, RustAuthError> {
117        let core = core(&config)?;
118        let exclude = exclude_credentials
119            .into_iter()
120            .map(parse_exclude_credential_id)
121            .collect::<Result<Vec<_>, _>>()?;
122        let user_id = Uuid::new_v4();
123        let display_name = user.display_name.as_deref().unwrap_or(&user.name);
124        let policy =
125            user_verification_policy(request_options.authenticator_selection.user_verification);
126        let builder = core
127            .new_challenge_register_builder(user_id.as_bytes(), &user.name, display_name)
128            .map_err(|error| RustAuthError::Api(error.to_string()))?
129            .attestation(AttestationConveyancePreference::None)
130            .credential_algorithms(COSEAlgorithm::secure_algs())
131            .require_resident_key(false)
132            .authenticator_attachment(None)
133            .user_verification_policy(policy)
134            .reject_synchronised_authenticators(false)
135            .exclude_credentials(Some(exclude))
136            .hints(None)
137            .extensions(Some(RequestRegistrationExtensions::default()));
138        let (options, state) = core
139            .generate_challenge_register(builder)
140            .map_err(|error| RustAuthError::Api(error.to_string()))?;
141        let mut options = option_value(options)?;
142        apply_registration_request_options(&mut options, &request_options);
143        Ok(PasskeyRegistrationStart {
144            options,
145            state: serde_json::to_value(state).map_err(json_error)?,
146        })
147    }
148
149    fn finish_registration(
150        &self,
151        config: WebAuthnConfig,
152        response: Value,
153        state: Value,
154    ) -> Result<VerifiedPasskeyCredential, RustAuthError> {
155        let core = core(&config)?;
156        let response = serde_json::from_value::<RegisterPublicKeyCredential>(response)
157            .map_err(|error| RustAuthError::Api(error.to_string()))?;
158        let state = serde_json::from_value::<RegistrationState>(state).map_err(json_error)?;
159        let credential = core
160            .register_credential(&response, &state, None)
161            .map_err(|error| RustAuthError::Api(error.to_string()))?;
162        credential_output(Passkey::from(credential))
163    }
164
165    fn start_authentication(
166        &self,
167        config: WebAuthnConfig,
168        credentials: Vec<Value>,
169        extensions: Option<Value>,
170    ) -> Result<PasskeyAuthenticationStart, RustAuthError> {
171        let core = core(&config)?;
172        if credentials.is_empty() {
173            let builder = core
174                .new_challenge_authenticate_builder(
175                    Vec::new(),
176                    Some(UserVerificationPolicy::Preferred),
177                )
178                .map_err(|error| RustAuthError::Api(error.to_string()))?
179                .extensions(Some(RequestAuthenticationExtensions {
180                    appid: None,
181                    uvm: Some(true),
182                    hmac_get_secret: None,
183                }))
184                .allow_backup_eligible_upgrade(false);
185            let (options, state) = core
186                .generate_challenge_authenticate(builder)
187                .map_err(|error| RustAuthError::Api(error.to_string()))?;
188            let mut options = auth_option_value(options)?;
189            apply_authentication_request_options(&mut options, extensions);
190            return Ok(PasskeyAuthenticationStart {
191                options,
192                state: serde_json::to_value(StoredAuthenticationState::Discoverable(state))
193                    .map_err(json_error)?,
194            });
195        }
196        let creds = credentials
197            .into_iter()
198            .map(|value| credential_value_to_passkey(value).map(Credential::from))
199            .collect::<Result<Vec<_>, _>>()?;
200        let builder = core
201            .new_challenge_authenticate_builder(creds, Some(UserVerificationPolicy::Preferred))
202            .map_err(|error| RustAuthError::Api(error.to_string()))?
203            .allow_backup_eligible_upgrade(true);
204        let (options, state) = core
205            .generate_challenge_authenticate(builder)
206            .map_err(|error| RustAuthError::Api(error.to_string()))?;
207        let mut options = auth_option_value(options)?;
208        apply_authentication_request_options(&mut options, extensions);
209        Ok(PasskeyAuthenticationStart {
210            options,
211            state: serde_json::to_value(StoredAuthenticationState::Passkey(state))
212                .map_err(json_error)?,
213        })
214    }
215
216    fn finish_authentication(
217        &self,
218        config: WebAuthnConfig,
219        response: Value,
220        state: Value,
221        credential: Option<Value>,
222    ) -> Result<VerifiedAuthentication, RustAuthError> {
223        let core = core(&config)?;
224        let response = serde_json::from_value::<PublicKeyCredential>(response)
225            .map_err(|error| RustAuthError::Api(error.to_string()))?;
226        let state =
227            serde_json::from_value::<StoredAuthenticationState>(state).map_err(json_error)?;
228        let credential = credential.map(credential_value_to_passkey).transpose()?;
229        let result = match state {
230            StoredAuthenticationState::Passkey(state) => core
231                .authenticate_credential(&response, &state)
232                .map_err(|error| RustAuthError::Api(error.to_string()))?,
233            StoredAuthenticationState::Discoverable(mut state) => {
234                let Some(passkey) = credential.as_ref() else {
235                    return Err(RustAuthError::Api(
236                        "passkey credential is required".to_owned(),
237                    ));
238                };
239                state.set_allowed_credentials(vec![Credential::from(passkey.clone())]);
240                core.authenticate_credential(&response, &state)
241                    .map_err(|error| RustAuthError::Api(error.to_string()))?
242            }
243        };
244        let updated_credential = credential.and_then(|mut passkey| {
245            passkey
246                .update_credential(&result)
247                .and_then(|changed| changed.then_some(passkey))
248        });
249        Ok(VerifiedAuthentication {
250            credential: updated_credential
251                .map(|passkey| serde_json::to_value(passkey).map_err(json_error))
252                .transpose()?,
253            new_counter: result.counter(),
254        })
255    }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259enum StoredAuthenticationState {
260    Passkey(AuthenticationState),
261    Discoverable(AuthenticationState),
262}
263
264/// Builds the low-level WebAuthn verifier.
265///
266/// `WebauthnCore::new_unsafe_experts_only` is required here because RustAuth must
267/// opt into loopback-only `allow_any_port` origin matching (see `origins_allow_any_port`).
268/// The high-level `WebauthnBuilder` path does not expose that control without the same
269/// expert constructor. Callers must already have resolved `rp_id` and `origins` via
270/// `routes::webauthn_config`, which fails closed instead of defaulting to localhost.
271fn core(config: &WebAuthnConfig) -> Result<WebauthnCore, RustAuthError> {
272    if config.origins.is_empty() {
273        return Err(RustAuthError::InvalidConfig(
274            "passkey origin is required".to_owned(),
275        ));
276    }
277    let mut origins = Vec::with_capacity(config.origins.len());
278    for origin in &config.origins {
279        let url = Url::parse(origin).map_err(|error| RustAuthError::Api(error.to_string()))?;
280        // Preserve the `WebauthnBuilder::new` security check: rp_id must be an
281        // effective domain of every configured origin.
282        let valid = url.domain().is_some_and(|domain| {
283            domain == config.rp_id || domain.ends_with(&format!(".{}", config.rp_id))
284        });
285        if !valid {
286            return Err(RustAuthError::Api(format!(
287                "passkey rp_id `{}` is not an effective domain of origin `{origin}`",
288                config.rp_id
289            )));
290        }
291        origins.push(url);
292    }
293    Ok(WebauthnCore::new_unsafe_experts_only(
294        &config.rp_name,
295        &config.rp_id,
296        origins.clone(),
297        Duration::from_secs(300),
298        Some(false),
299        Some(origins_allow_any_port(&origins)),
300    ))
301}
302
303fn user_verification_policy(value: UserVerificationRequirement) -> UserVerificationPolicy {
304    match value {
305        UserVerificationRequirement::Discouraged => UserVerificationPolicy::Discouraged_DO_NOT_USE,
306        UserVerificationRequirement::Preferred => UserVerificationPolicy::Preferred,
307        UserVerificationRequirement::Required => UserVerificationPolicy::Required,
308    }
309}
310
311/// `WebauthnBuilder::allow_any_port` skips the port check for *every* configured
312/// origin, and ports are part of the browser origin boundary. Enabling it
313/// unconditionally would let a production origin such as `https://auth.example.com`
314/// also accept `https://auth.example.com:8443`, so it is restricted to local
315/// development.
316///
317/// Returns `true` only when every origin is a loopback/localhost host, so a
318/// single non-loopback origin forces exact-port matching for the whole set.
319fn origins_allow_any_port(origins: &[Url]) -> bool {
320    !origins.is_empty() && origins.iter().all(is_loopback_origin)
321}
322
323fn is_loopback_origin(origin: &Url) -> bool {
324    match origin.host() {
325        Some(url::Host::Domain(host)) => host == "localhost" || host.ends_with(".localhost"),
326        Some(url::Host::Ipv4(address)) => address.is_loopback(),
327        Some(url::Host::Ipv6(address)) => address.is_loopback(),
328        None => false,
329    }
330}
331
332fn option_value(options: CreationChallengeResponse) -> Result<Value, RustAuthError> {
333    serde_json::to_value(options)
334        .map(|mut value| value.pointer_mut("/publicKey").cloned().unwrap_or(value))
335        .map_err(json_error)
336}
337
338fn auth_option_value(options: RequestChallengeResponse) -> Result<Value, RustAuthError> {
339    serde_json::to_value(options)
340        .map(|mut value| value.pointer_mut("/publicKey").cloned().unwrap_or(value))
341        .map_err(json_error)
342}
343
344fn apply_registration_request_options(
345    options: &mut Value,
346    request_options: &RegistrationWebAuthnOptions,
347) {
348    options["authenticatorSelection"] = request_options.authenticator_selection.to_json();
349    if let Some(extensions) = &request_options.extensions {
350        options["extensions"] = extensions.clone();
351    }
352}
353
354fn apply_authentication_request_options(options: &mut Value, extensions: Option<Value>) {
355    if let Some(extensions) = extensions {
356        options["extensions"] = extensions;
357    }
358}
359
360fn credential_value_to_passkey(value: Value) -> Result<Passkey, RustAuthError> {
361    serde_json::from_value::<Passkey>(value).map_err(json_error)
362}
363
364fn parse_exclude_credential_id(value: Value) -> Result<CredentialID, RustAuthError> {
365    if let Ok(credential) = serde_json::from_value::<Credential>(value.clone()) {
366        return Ok(credential.cred_id);
367    }
368    let id = value
369        .as_str()
370        .map(str::to_owned)
371        .or_else(|| {
372            value
373                .get("id")
374                .and_then(serde_json::Value::as_str)
375                .map(str::to_owned)
376        })
377        .ok_or_else(|| RustAuthError::Api("invalid passkey exclude credential entry".to_owned()))?;
378    serde_json::from_value(json!(id)).map_err(json_error)
379}
380
381/// Reconstruct `webauthn-rs` credential state from legacy passkey columns.
382///
383/// Rows created before RustAuth stored hidden `webauthn_credential` JSON only
384/// persisted the base64 COSE public key and passkey metadata. Authentication
385/// must rebuild enough state for verification and counter updates.
386pub(crate) fn legacy_passkey_credential_value(
387    credential_id: &str,
388    public_key: &str,
389    counter: i64,
390    device_type: &str,
391    backed_up: bool,
392    transports: Option<&str>,
393) -> Result<Value, RustAuthError> {
394    let cose_bytes = decode_stored_public_key(public_key)?;
395    let cbor = serde_cbor_2::from_slice::<serde_cbor_2::Value>(&cose_bytes)
396        .map_err(|error| RustAuthError::Api(error.to_string()))?;
397    let cose_key =
398        COSEKey::try_from(&cbor).map_err(|error| RustAuthError::Api(error.to_string()))?;
399    let cred_id: CredentialID = serde_json::from_value(json!(credential_id)).map_err(json_error)?;
400    let transports = transports.map(parse_stored_transports).transpose()?;
401    let counter = u32::try_from(counter)
402        .map_err(|_| RustAuthError::Api("passkey counter exceeds u32 range".to_owned()))?;
403    let credential = Credential {
404        cred_id,
405        cred: cose_key,
406        counter,
407        transports,
408        user_verified: false,
409        backup_eligible: device_type == "multiDevice",
410        backup_state: backed_up,
411        registration_policy: UserVerificationPolicy::Preferred,
412        extensions: RegisteredExtensions::none(),
413        attestation: ParsedAttestation::default(),
414        attestation_format: AttestationFormat::None,
415    };
416    serde_json::to_value(Passkey::from(credential)).map_err(json_error)
417}
418
419fn decode_stored_public_key(public_key: &str) -> Result<Vec<u8>, RustAuthError> {
420    use base64::Engine;
421    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(public_key) {
422        return Ok(bytes);
423    }
424    if let Ok(bytes) = base64::engine::general_purpose::URL_SAFE.decode(public_key) {
425        return Ok(bytes);
426    }
427    base64::engine::general_purpose::URL_SAFE_NO_PAD
428        .decode(public_key)
429        .map_err(|error| RustAuthError::Api(error.to_string()))
430}
431
432fn parse_stored_transports(value: &str) -> Result<Vec<AuthenticatorTransport>, RustAuthError> {
433    value
434        .split(',')
435        .map(str::trim)
436        .filter(|part| !part.is_empty())
437        .map(|part| {
438            AuthenticatorTransport::from_str(part)
439                .map_err(|_| RustAuthError::Api(format!("unsupported passkey transport `{part}`")))
440        })
441        .collect()
442}
443
444fn credential_output(passkey: Passkey) -> Result<VerifiedPasskeyCredential, RustAuthError> {
445    let credential = Credential::from(passkey.clone());
446    let aaguid = aaguid_from_attestation_metadata(&credential.attestation.metadata);
447    let credential_id = serde_json::to_value(&credential.cred_id)
448        .and_then(serde_json::from_value::<String>)
449        .unwrap_or_else(|_| format!("{:?}", credential.cred_id));
450    let public_key =
451        base64::engine::general_purpose::STANDARD.encode(cose_public_key_bytes(&credential.cred)?);
452    let transports = credential.transports.as_ref().map(|values| {
453        values
454            .iter()
455            .map(|value| {
456                serde_json::to_value(value)
457                    .ok()
458                    .and_then(|value| serde_json::from_value::<String>(value).ok())
459                    .unwrap_or_else(|| format!("{value:?}").to_ascii_lowercase())
460            })
461            .collect::<Vec<_>>()
462            .join(",")
463    });
464    Ok(VerifiedPasskeyCredential {
465        credential_id,
466        public_key,
467        counter: credential.counter,
468        device_type: if credential.backup_eligible {
469            "multiDevice".to_owned()
470        } else {
471            "singleDevice".to_owned()
472        },
473        backed_up: credential.backup_state,
474        transports,
475        aaguid,
476        credential: serde_json::to_value(passkey).map_err(json_error)?,
477    })
478}
479
480fn aaguid_from_attestation_metadata(metadata: &AttestationMetadata) -> Option<String> {
481    match metadata {
482        AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
483            Some(aaguid.to_string())
484        }
485        _ => None,
486    }
487}
488
489fn cose_public_key_bytes(key: &COSEKey) -> Result<Vec<u8>, RustAuthError> {
490    let mut values = BTreeMap::new();
491    values.insert(
492        serde_cbor_2::Value::Integer(1),
493        serde_cbor_2::Value::Integer(cose_key_type_id(&key.key)),
494    );
495    values.insert(
496        serde_cbor_2::Value::Integer(3),
497        serde_cbor_2::Value::Integer(cose_algorithm_id(key.type_)?),
498    );
499    match &key.key {
500        COSEKeyType::EC_EC2(key) => {
501            values.insert(
502                serde_cbor_2::Value::Integer(-1),
503                serde_cbor_2::Value::Integer(ecdsa_curve_id(&key.curve)),
504            );
505            values.insert(
506                serde_cbor_2::Value::Integer(-2),
507                serde_cbor_2::Value::Bytes(key.x.as_ref().to_vec()),
508            );
509            values.insert(
510                serde_cbor_2::Value::Integer(-3),
511                serde_cbor_2::Value::Bytes(key.y.as_ref().to_vec()),
512            );
513        }
514        COSEKeyType::RSA(key) => {
515            values.insert(
516                serde_cbor_2::Value::Integer(-1),
517                serde_cbor_2::Value::Bytes(key.n.as_ref().to_vec()),
518            );
519            values.insert(
520                serde_cbor_2::Value::Integer(-2),
521                serde_cbor_2::Value::Bytes(key.e.to_vec()),
522            );
523        }
524        COSEKeyType::EC_OKP(key) => {
525            values.insert(
526                serde_cbor_2::Value::Integer(-1),
527                serde_cbor_2::Value::Integer(eddsa_curve_id(&key.curve)),
528            );
529            values.insert(
530                serde_cbor_2::Value::Integer(-2),
531                serde_cbor_2::Value::Bytes(key.x.as_ref().to_vec()),
532            );
533        }
534    }
535    serde_cbor_2::to_vec(&serde_cbor_2::Value::Map(values))
536        .map_err(|error| RustAuthError::Api(error.to_string()))
537}
538
539fn cose_key_type_id(key: &COSEKeyType) -> i128 {
540    match key {
541        COSEKeyType::EC_OKP(_) => 1,
542        COSEKeyType::EC_EC2(_) => 2,
543        COSEKeyType::RSA(_) => 3,
544    }
545}
546
547fn cose_algorithm_id(algorithm: COSEAlgorithm) -> Result<i128, RustAuthError> {
548    match algorithm {
549        COSEAlgorithm::ES256 => Ok(-7),
550        COSEAlgorithm::ES384 => Ok(-35),
551        COSEAlgorithm::ES512 => Ok(-36),
552        COSEAlgorithm::RS256 => Ok(-257),
553        COSEAlgorithm::RS384 => Ok(-258),
554        COSEAlgorithm::RS512 => Ok(-259),
555        COSEAlgorithm::PS256 => Ok(-37),
556        COSEAlgorithm::PS384 => Ok(-38),
557        COSEAlgorithm::PS512 => Ok(-39),
558        COSEAlgorithm::EDDSA => Ok(-8),
559        COSEAlgorithm::INSECURE_RS1 => Ok(-65535),
560        COSEAlgorithm::PinUvProtocol => Err(RustAuthError::Api(
561            "passkey public key uses an unsupported COSE algorithm".to_owned(),
562        )),
563    }
564}
565
566fn ecdsa_curve_id(curve: &ECDSACurve) -> i128 {
567    match curve {
568        ECDSACurve::SECP256R1 => 1,
569        ECDSACurve::SECP384R1 => 2,
570        ECDSACurve::SECP521R1 => 3,
571    }
572}
573
574fn eddsa_curve_id(curve: &EDDSACurve) -> i128 {
575    match curve {
576        EDDSACurve::ED25519 => 6,
577        EDDSACurve::ED448 => 7,
578    }
579}
580
581fn json_error(error: serde_json::Error) -> RustAuthError {
582    RustAuthError::Api(error.to_string())
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use serde_cbor_2::Value as CborValue;
589    use webauthn_rs::prelude::{AttestationMetadata, Credential};
590
591    fn parse_origins(origins: &[&str]) -> Result<Vec<Url>, url::ParseError> {
592        origins.iter().map(|origin| Url::parse(origin)).collect()
593    }
594
595    #[test]
596    fn production_origins_keep_exact_port_matching() -> Result<(), url::ParseError> {
597        // A configured https://example.com must not be treated as valid for
598        // https://example.com:8443, so any-port matching stays disabled.
599        assert!(!origins_allow_any_port(&parse_origins(&[
600            "https://example.com"
601        ])?));
602        assert!(!origins_allow_any_port(&parse_origins(&[
603            "https://auth.example.com:443"
604        ])?));
605        Ok(())
606    }
607
608    #[test]
609    fn loopback_origins_allow_any_port_for_local_dev() -> Result<(), url::ParseError> {
610        // Local development servers run on arbitrary ports, so loopback hosts
611        // keep any-port matching.
612        for origin in [
613            "http://localhost",
614            "http://localhost:3000",
615            "http://app.localhost:9000",
616            "http://127.0.0.1:5173",
617            "http://[::1]:8080",
618        ] {
619            assert!(
620                origins_allow_any_port(&parse_origins(&[origin])?),
621                "{origin} should allow any port"
622            );
623        }
624        Ok(())
625    }
626
627    #[test]
628    fn mixed_origins_preserve_exact_port_checks() -> Result<(), url::ParseError> {
629        // A single non-loopback origin forces exact-port matching for the
630        // whole set, since allow_any_port is global to the verifier.
631        assert!(!origins_allow_any_port(&parse_origins(&[
632            "http://localhost:3000",
633            "https://example.com",
634        ])?));
635        assert!(origins_allow_any_port(&parse_origins(&[
636            "http://localhost:3000",
637            "http://127.0.0.1:5173",
638        ])?));
639        Ok(())
640    }
641
642    #[test]
643    fn webauthn_builds_for_production_and_loopback_configs() -> Result<(), RustAuthError> {
644        let production = WebAuthnConfig {
645            rp_id: "example.com".to_owned(),
646            rp_name: "Example".to_owned(),
647            origins: vec!["https://auth.example.com".to_owned()],
648        };
649        let loopback = WebAuthnConfig {
650            rp_id: "localhost".to_owned(),
651            rp_name: "Example".to_owned(),
652            origins: vec!["http://localhost:3000".to_owned()],
653        };
654        core(&production)?;
655        core(&loopback)?;
656        Ok(())
657    }
658
659    #[test]
660    fn aaguid_from_attestation_metadata_extracts_packed_and_tpm_values() {
661        let packed = Uuid::from_u128(1);
662        let tpm = Uuid::from_u128(2);
663
664        assert_eq!(
665            aaguid_from_attestation_metadata(&AttestationMetadata::Packed { aaguid: packed }),
666            Some(packed.to_string())
667        );
668        assert_eq!(
669            aaguid_from_attestation_metadata(&AttestationMetadata::Tpm {
670                aaguid: tpm,
671                firmware_version: 1,
672            }),
673            Some(tpm.to_string())
674        );
675        assert_eq!(
676            aaguid_from_attestation_metadata(&AttestationMetadata::None),
677            None
678        );
679    }
680
681    #[test]
682    fn credential_output_public_key_is_cose_cbor_base64() -> Result<(), Box<dyn std::error::Error>>
683    {
684        let credential = serde_json::from_value::<Credential>(serde_json::json!({
685            "cred_id": "AQID",
686            "cred": {
687                "type_": "ES256",
688                "key": {
689                    "EC_EC2": {
690                        "curve": "SECP256R1",
691                        "x": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
692                        "y": [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
693                    }
694                }
695            },
696            "counter": 7,
697            "transports": null,
698            "user_verified": false,
699            "backup_eligible": false,
700            "backup_state": false,
701            "registration_policy": "preferred",
702            "extensions": {
703                "cred_protect": "NotRequested",
704                "hmac_create_secret": "NotRequested"
705            },
706            "attestation": {
707                "data": "None",
708                "metadata": "None"
709            },
710            "attestation_format": "none"
711        }))?;
712        let output = credential_output(credential.into())?;
713        let public_key_bytes =
714            base64::engine::general_purpose::STANDARD.decode(output.public_key)?;
715        let public_key = serde_cbor_2::from_slice::<CborValue>(&public_key_bytes)?;
716        let CborValue::Map(values) = public_key else {
717            return Err("COSE public key must be encoded as a CBOR map".into());
718        };
719
720        assert_eq!(
721            values.get(&CborValue::Integer(1)),
722            Some(&CborValue::Integer(2))
723        );
724        assert_eq!(
725            values.get(&CborValue::Integer(3)),
726            Some(&CborValue::Integer(-7))
727        );
728        assert_eq!(
729            values.get(&CborValue::Integer(-1)),
730            Some(&CborValue::Integer(1))
731        );
732        assert_eq!(
733            values.get(&CborValue::Integer(-2)),
734            Some(&CborValue::Bytes(vec![1; 32]))
735        );
736        assert_eq!(
737            values.get(&CborValue::Integer(-3)),
738            Some(&CborValue::Bytes(vec![2; 32]))
739        );
740        Ok(())
741    }
742
743    fn sample_test_credential() -> Result<Credential, Box<dyn std::error::Error>> {
744        Ok(serde_json::from_value(serde_json::json!({
745            "cred_id": "AQID",
746            "cred": {
747                "type_": "ES256",
748                "key": { "EC_EC2": {
749                    "curve": "SECP256R1",
750                    "x": [
751                        101, 237, 165, 161, 37, 119, 194, 186, 232, 41, 67, 127, 227, 56, 112, 26,
752                        16, 170, 163, 117, 225, 187, 91, 93, 225, 8, 222, 67, 156, 8, 85, 29
753                    ],
754                    "y": [
755                        30, 82, 237, 117, 112, 17, 99, 247, 249, 228, 13, 223, 159, 52, 27, 61,
756                        201, 186, 134, 10, 247, 224, 202, 124, 167, 233, 238, 205, 0, 132, 209, 156
757                    ]
758                } }
759            },
760            "counter": 0,
761            "transports": null,
762            "user_verified": false,
763            "backup_eligible": false,
764            "backup_state": false,
765            "registration_policy": "preferred",
766            "extensions": { "cred_protect": "NotRequested", "hmac_create_secret": "NotRequested" },
767            "attestation": { "data": "None", "metadata": "None" },
768            "attestation_format": "none"
769        }))?)
770    }
771
772    #[test]
773    fn legacy_passkey_credential_value_reconstructs_from_stored_public_key(
774    ) -> Result<(), Box<dyn std::error::Error>> {
775        let output = credential_output(sample_test_credential()?.into())?;
776        let reconstructed = legacy_passkey_credential_value(
777            &output.credential_id,
778            &output.public_key,
779            i64::from(output.counter),
780            &output.device_type,
781            output.backed_up,
782            output.transports.as_deref(),
783        )?;
784        credential_value_to_passkey(reconstructed)?;
785        Ok(())
786    }
787
788    #[test]
789    fn legacy_passkey_credential_value_rejects_invalid_public_key() {
790        let result = legacy_passkey_credential_value(
791            "AQID",
792            "not-valid-cose",
793            0,
794            "singleDevice",
795            false,
796            None,
797        );
798        assert!(result.is_err());
799    }
800}