passki 0.3.0

A simple and secure WebAuthn/Passkey authentication library
Documentation
// Copyright 2026 Grzegorz Blach
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Parsing and verification of the client data JSON.
//!
//! The browser assembles this blob itself and the authenticator signs over its
//! hash, which is what ties a signature to one challenge and one origin.

use std::fmt;
use std::str::FromStr;

use crate::types::{PasskiError, Result};

/// The type of WebAuthn operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientDataType {
    /// Registration operation ("webauthn.create").
    Create,
    /// Authentication operation ("webauthn.get").
    Get,
}

impl ClientDataType {
    /// Returns the string representation used in the client data JSON.
    pub fn as_str(&self) -> &'static str {
        match self {
            ClientDataType::Create => "webauthn.create",
            ClientDataType::Get => "webauthn.get",
        }
    }
}

impl FromStr for ClientDataType {
    type Err = PasskiError;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s {
            "webauthn.create" => Ok(ClientDataType::Create),
            "webauthn.get" => Ok(ClientDataType::Get),
            _ => Err(PasskiError::InvalidClientDataType(s.to_string())),
        }
    }
}

impl fmt::Display for ClientDataType {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

/// The client data JSON the browser sent, parsed.
#[derive(Debug)]
pub struct ClientData {
    /// The type of operation (Create for registration, Get for authentication).
    pub type_: ClientDataType,

    /// The challenge the browser echoed back (base64url-encoded).
    pub challenge: String,

    /// The origin of the page that made the request.
    pub origin: String,

    /// Whether the request came from a cross-origin iframe.
    pub cross_origin: bool,
}

impl ClientData {
    /// Parses client data from raw JSON bytes.
    ///
    /// # Errors
    ///
    /// Returns an error if the JSON is invalid or a required field is missing.
    pub fn from_bytes(bytes: &[u8]) -> Result<ClientData> {
        let json: serde_json::Value = serde_json::from_slice(bytes)?;

        let type_str = json["type"]
            .as_str()
            .ok_or_else(|| PasskiError::MissingClientDataField("type".to_string()))?;

        let type_ = type_str.parse::<ClientDataType>()?;

        let challenge = json["challenge"]
            .as_str()
            .ok_or_else(|| PasskiError::MissingClientDataField("challenge".to_string()))?
            .to_string();

        let origin = json["origin"]
            .as_str()
            .ok_or_else(|| PasskiError::MissingClientDataField("origin".to_string()))?
            .to_string();

        let cross_origin = json["crossOrigin"].as_bool().unwrap_or(false);

        Ok(ClientData {
            type_,
            challenge,
            origin,
            cross_origin,
        })
    }

    /// Parses client data straight from the base64url string the client sent.
    ///
    /// Useful before the ceremony is finished: the challenge it exposes can key
    /// the lookup of the pending registration or authentication state.
    ///
    /// # Errors
    ///
    /// Returns an error if the input is not valid base64url, the JSON is
    /// invalid, or a required field is missing.
    ///
    /// # Example
    ///
    /// ```
    /// # use passki::ClientData;
    /// # /*
    /// let client_data = ClientData::from_base64(&credential.client_data_json)?;
    /// let state = pending_states.remove(&client_data.challenge)
    ///     .ok_or("No pending state")?;
    /// # */
    /// ```
    #[inline]
    pub fn from_base64(client_data_json: &str) -> Result<ClientData> {
        let bytes = crate::Passki::base64_decode(client_data_json)?;
        Self::from_bytes(&bytes)
    }

    #[allow(rustdoc::bare_urls)]
    /// Checks the operation type, that the challenge is the one that was issued,
    /// that the origin is accepted, and that no cross-origin iframe was involved.
    ///
    /// # Arguments
    ///
    /// * `expected_type` - The expected type (Create or Get)
    /// * `expected_challenge` - The challenge bytes this ceremony issued
    /// * `expected_origins` - The accepted origins (e.g., "https://example.com")
    ///
    /// # Errors
    ///
    /// Returns an error if any of the values don't match.
    pub fn verify(
        &self,
        expected_type: ClientDataType,
        expected_challenge: &[u8],
        expected_origins: &[impl AsRef<str>],
    ) -> Result<()> {
        if self.type_ != expected_type {
            return Err(PasskiError::ClientDataTypeMismatch {
                expected: expected_type,
                got: self.type_,
            });
        }

        let challenge = crate::Passki::base64_decode(&self.challenge)?;
        if challenge != expected_challenge {
            return Err(PasskiError::ChallengeMismatch);
        }

        if !expected_origins.iter().any(|o| o.as_ref() == self.origin) {
            return Err(PasskiError::OriginMismatch {
                expected: expected_origins
                    .iter()
                    .map(|o| o.as_ref().to_string())
                    .collect(),
                got: self.origin.clone(),
            });
        }

        if self.cross_origin {
            return Err(PasskiError::CrossOriginNotAllowed);
        }

        Ok(())
    }
}