better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use std::time::Duration;

use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE, URL_SAFE_NO_PAD};
use better_auth_core::{AuthConfig, AuthError, AuthRequest, AuthResult};
use rand::seq::SliceRandom;
use serde::Deserialize;
use serde_json::{Value, json};
use url::Url;
use uuid::Uuid;
use webauthn_rs_core::WebauthnCore;
use webauthn_rs_core::proto::{
    CreationChallengeResponse, CredentialID, RegisterPublicKeyCredential, RequestChallengeResponse,
    UserVerificationPolicy,
};

use super::PasskeyConfig;

const OPTIONS_TIMEOUT_MS: u64 = 60_000;
const GENERATED_USER_ID_LENGTH: usize = 32;
const GENERATED_USER_ID_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";

#[derive(Debug)]
pub(super) struct RegisteredPasskeyMetadata {
    pub public_key: String,
    pub aaguid: Option<String>,
    pub fmt: String,
    pub extensions: Option<Value>,
}

pub(super) struct RegistrationUserState {
    pub user: super::PasskeyRegistrationUser,
    pub context: better_auth_core::FieldValue,
}

impl RegistrationUserState {
    pub(super) fn from_native(value: &better_auth_core::FieldValue) -> AuthResult<Self> {
        use better_auth_core::SchemaValue;
        let user = value.model_property("userData")?;
        Ok(Self {
            user: super::PasskeyRegistrationUser {
                id: SchemaValue::from_field(user.model_property("id")?),
                name: SchemaValue::from_field(user.model_property("name")?),
                display_name: SchemaValue::from_field(user.model_property("displayName")?),
            },
            context: value.model_property("context")?,
        })
    }
}

pub(super) fn resolve_origins(config: &PasskeyConfig, req: &AuthRequest) -> Option<Vec<String>> {
    match &config.origin {
        super::PasskeyOrigins::Request => req
            .headers
            .get("origin")
            .filter(|value| !value.is_empty())
            .map(|value| vec![value.clone()]),
        super::PasskeyOrigins::Explicit(origins) => Some(origins.clone()),
    }
}

pub(super) fn get_cookie_value(req: &AuthRequest, name: &str) -> Option<String> {
    let header = req.headers.get("cookie")?;
    header
        .split(';')
        .filter_map(|cookie| {
            let trimmed = cookie.trim();
            let (cookie_name, cookie_value) = trimmed.split_once('=')?;
            (cookie_name == name).then_some(cookie_value.to_string())
        })
        .next()
}

pub(super) fn challenge_cookie_name(auth_config: &AuthConfig, config: &PasskeyConfig) -> String {
    better_auth_core::utils::cookie_utils::related_cookie_name(
        auth_config,
        &config.web_authn_challenge_cookie,
    )
}

pub(super) fn build_webauthn(
    config: &PasskeyConfig,
    auth_config: &AuthConfig,
    origins: &[String],
) -> AuthResult<WebauthnCore> {
    let rp_id = rp_id(config, auth_config)?;
    let origins = origins
        .iter()
        .map(|origin| {
            Url::parse(origin)
                .map_err(|error| AuthError::bad_request(format!("Invalid passkey origin: {error}")))
        })
        .collect::<AuthResult<Vec<_>>>()?;
    let rp_name = if config.rp_name.is_empty() {
        &auth_config.app_name
    } else {
        &config.rp_name
    };
    Ok(WebauthnCore::new_unsafe_experts_only(
        rp_name,
        &rp_id,
        origins,
        Duration::from_millis(OPTIONS_TIMEOUT_MS),
        Some(false),
        Some(false),
    ))
}

pub(super) fn rp_id(config: &PasskeyConfig, auth_config: &AuthConfig) -> AuthResult<String> {
    if config.rp_id.is_empty() {
        Url::parse(auth_config.base_url.as_static().unwrap_or(""))
            .ok()
            .and_then(|url| url.host_str().map(str::to_owned))
            .ok_or_else(|| AuthError::config("Missing passkey RP ID"))
    } else {
        Ok(config.rp_id.clone())
    }
}

// Better Auth treats UV as a UI preference and permits absent UV after verified registration.
pub(super) const VERIFICATION_POLICY: UserVerificationPolicy =
    UserVerificationPolicy::Discouraged_DO_NOT_USE;

pub(super) fn create_challenge_cookie(
    auth_config: &AuthConfig,
    config: &PasskeyConfig,
    token: &str,
) -> AuthResult<String> {
    let signed = better_auth_core::utils::cookie_utils::sign_cookie_value(
        token,
        auth_config.signing_secret(),
    );
    better_auth_core::utils::cookie_utils::create_cookie(
        &challenge_cookie_name(auth_config, config),
        &signed,
        config.challenge_ttl_secs as f64,
        auth_config,
    )
}

pub(super) fn decode_challenge_cookie(
    auth_config: &AuthConfig,
    raw_cookie: &str,
) -> AuthResult<String> {
    better_auth_core::utils::cookie_utils::verify_cookie_value(
        raw_cookie,
        auth_config.signing_secret(),
    )
    .ok_or(AuthError::Unauthenticated)
}

pub(super) fn generate_ts_user_handle() -> String {
    let mut rng = rand::thread_rng();
    let handle: String = (0..GENERATED_USER_ID_LENGTH)
        .map(|_| {
            GENERATED_USER_ID_ALPHABET
                .choose(&mut rng)
                .copied()
                .unwrap_or(b'a') as char
        })
        .collect();
    URL_SAFE_NO_PAD.encode(handle.as_bytes())
}

pub(super) fn registration_options_json(
    options: CreationChallengeResponse,
    generated_user_handle: &str,
    authenticator_attachment: Option<&str>,
    config: &super::AuthenticatorSelection,
    extensions: Option<serde_json::Map<String, Value>>,
) -> AuthResult<better_auth_core::FieldMap> {
    let mut value = serde_json::to_value(options.public_key)?;
    let Some(root) = value.as_object_mut() else {
        return Err(AuthError::internal(
            "Passkey registration options must serialize as an object",
        ));
    };

    let Some(user) = root.get_mut("user").and_then(Value::as_object_mut) else {
        return Err(AuthError::internal(
            "Passkey registration options missing user object",
        ));
    };
    let _ = user.insert(
        "id".to_string(),
        Value::String(generated_user_handle.to_string()),
    );

    if !root.contains_key("excludeCredentials") {
        let _ = root.insert("excludeCredentials".to_string(), Value::Array(Vec::new()));
    }

    let _ = root.insert(
        "pubKeyCredParams".to_string(),
        json!([
            { "alg": -8, "type": "public-key" },
            { "alg": -7, "type": "public-key" },
            { "alg": -257, "type": "public-key" }
        ]),
    );

    let mut selection = serde_json::Map::from_iter([
        ("residentKey".into(), json!("preferred")),
        ("userVerification".into(), json!("preferred")),
    ]);
    if let Value::Object(config) = serde_json::to_value(config)? {
        selection.extend(config);
    }
    let require_resident_key =
        selection.get("residentKey").and_then(Value::as_str) == Some("required");
    let _ = selection.insert("requireResidentKey".into(), require_resident_key.into());
    if let Some(attachment) = authenticator_attachment {
        let _ = selection.insert("authenticatorAttachment".into(), attachment.into());
    }
    let _ = root.insert("authenticatorSelection".into(), selection.into());

    let _ = root.insert("hints".to_string(), Value::Array(Vec::new()));
    let mut extensions = extensions.unwrap_or_default();
    let _ = extensions.insert("credProps".into(), true.into());
    let _ = root.insert("extensions".into(), extensions.into());
    let _ = root.insert(
        "timeout".to_string(),
        Value::Number(OPTIONS_TIMEOUT_MS.into()),
    );
    better_auth_core::FieldMap::from_json(std::mem::take(root))
}

pub(super) fn authentication_options_json(
    options: RequestChallengeResponse,
    extensions: Option<serde_json::Map<String, Value>>,
) -> AuthResult<better_auth_core::FieldMap> {
    let mut value = serde_json::to_value(options.public_key)?;
    let Some(root) = value.as_object_mut() else {
        return Err(AuthError::internal(
            "Passkey authentication options must serialize as an object",
        ));
    };

    if root
        .get("allowCredentials")
        .and_then(Value::as_array)
        .is_some_and(|credentials| credentials.is_empty())
    {
        let _ = root.remove("allowCredentials");
    }

    let _ = root.remove("extensions");
    if let Some(extensions) = extensions {
        let _ = root.insert("extensions".into(), extensions.into());
    }
    let _ = root.insert(
        "timeout".to_string(),
        Value::Number(OPTIONS_TIMEOUT_MS.into()),
    );
    let _ = root.insert(
        "userVerification".to_string(),
        Value::String("preferred".to_string()),
    );
    let _ = root.remove("hints");
    better_auth_core::FieldMap::from_json(std::mem::take(root))
}

pub(super) fn decode_credential_id(credential_id: &str) -> AuthResult<CredentialID> {
    let bytes = URL_SAFE_NO_PAD
        .decode(credential_id)
        .or_else(|_| URL_SAFE.decode(credential_id))
        .or_else(|_| STANDARD.decode(credential_id))
        .map_err(|_| AuthError::bad_request("Invalid passkey credential id"))?;
    Ok(bytes.into())
}

pub(super) fn extract_registration_metadata(
    registration: &RegisterPublicKeyCredential,
) -> AuthResult<RegisteredPasskeyMetadata> {
    let attestation_bytes = registration.response.attestation_object.as_ref();
    let attestation: serde_cbor_2::Value = serde_cbor_2::from_slice(attestation_bytes)
        .map_err(|error| AuthError::internal(format!("Invalid attestation CBOR: {error}")))?;
    let serde_cbor_2::Value::Map(attestation_map) = attestation else {
        return Err(AuthError::internal("Attestation object must be a CBOR map"));
    };
    let auth_data = attestation_map
        .get(&serde_cbor_2::Value::Text("authData".to_string()))
        .and_then(|value| match value {
            serde_cbor_2::Value::Bytes(bytes) => Some(bytes.as_slice()),
            _ => None,
        })
        .ok_or_else(|| AuthError::internal("Attestation object missing authData"))?;

    if auth_data.len() < 55 {
        return Err(AuthError::internal("Attestation authData is too short"));
    }

    let mut offset = 37;
    let aaguid_bytes = auth_data
        .get(offset..offset + 16)
        .ok_or_else(|| AuthError::internal("Attestation authData missing AAGUID"))?;
    offset += 16;

    let credential_id_length = auth_data
        .get(offset..offset + 2)
        .and_then(|slice| slice.try_into().ok())
        .map(u16::from_be_bytes)
        .map(usize::from)
        .ok_or_else(|| AuthError::internal("Attestation authData missing credential length"))?;
    offset += 2;
    offset += credential_id_length;

    let credential_public_key = auth_data
        .get(offset..)
        .ok_or_else(|| AuthError::internal("Attestation authData missing credential public key"))?;
    let mut deserializer = serde_cbor_2::de::Deserializer::from_slice(credential_public_key);
    let _: serde_cbor_2::Value = Deserialize::deserialize(&mut deserializer).map_err(|error| {
        AuthError::internal(format!("Invalid credential public key CBOR: {error}"))
    })?;
    let public_key_length = deserializer.byte_offset();
    let public_key = credential_public_key
        .get(..public_key_length)
        .ok_or_else(|| AuthError::internal("Credential public key length is invalid"))?;

    let extensions = if auth_data.get(32).is_some_and(|flags| flags & 0x80 != 0) {
        Some(
            serde_cbor_2::from_slice(
                credential_public_key
                    .get(public_key_length..)
                    .ok_or_else(|| AuthError::internal("Invalid authenticator extension offset"))?,
            )
            .map_err(|error| {
                AuthError::internal(format!("Invalid authenticator extensions: {error}"))
            })?,
        )
    } else {
        None
    };
    let fmt = attestation_map
        .get(&serde_cbor_2::Value::Text("fmt".into()))
        .and_then(|value| match value {
            serde_cbor_2::Value::Text(value) => Some(value.clone()),
            _ => None,
        })
        .ok_or_else(|| AuthError::internal("Attestation object missing format"))?;
    Ok(RegisteredPasskeyMetadata {
        fmt,
        extensions,
        public_key: STANDARD.encode(public_key),
        aaguid: Uuid::from_slice(aaguid_bytes)
            .ok()
            .map(|uuid| uuid.to_string()),
    })
}

pub(super) fn parse_transports_csv(
    transports: &better_auth_core::SchemaValue<Option<String>>,
) -> AuthResult<Option<Vec<better_auth_core::Utf16String>>> {
    let value = transports.field_value();
    match value {
        better_auth_core::FieldValue::Null | better_auth_core::FieldValue::Undefined => Ok(None),
        better_auth_core::FieldValue::String(value) => Ok(Some(
            value
                .split(',')
                .map(better_auth_core::Utf16String::from)
                .collect(),
        )),
        better_auth_core::FieldValue::Utf16String(value) => Ok(Some(
            value
                .as_utf16()
                .split(|unit| *unit == u16::from(b','))
                .map(|units| better_auth_core::Utf16String::from_units(units.to_vec()))
                .collect(),
        )),
        _ => Err(AuthError::internal("transports.split is not a function")),
    }
}

pub(super) fn client_origin(client_data: &[u8]) -> AuthResult<String> {
    #[derive(Deserialize)]
    struct ClientData {
        origin: String,
    }
    Ok(serde_json::from_slice::<ClientData>(client_data)?.origin)
}

pub(super) fn authentication_extensions(auth_data: &[u8]) -> AuthResult<Option<Value>> {
    if auth_data.get(32).is_some_and(|flags| flags & 0x80 != 0) {
        Ok(Some(
            serde_cbor_2::from_slice(
                auth_data
                    .get(37..)
                    .ok_or_else(|| AuthError::internal("Missing authenticator extensions"))?,
            )
            .map_err(|error| {
                AuthError::internal(format!("Invalid authenticator extensions: {error}"))
            })?,
        ))
    } else {
        Ok(None)
    }
}