Skip to main content

better_auth_core/plugin_runtime/fields/
passkey.rs

1use super::*;
2use crate::Passkey;
3use crate::store::schema::core_fields;
4use crate::user_fields::UserFieldConfig;
5
6fn native_name(name: &str) -> bool {
7    core_fields(EntityRole::Passkey)
8        .iter()
9        .any(|field| field.name == name)
10        || matches!(
11            name,
12            "publicKey"
13                | "userId"
14                | "credentialID"
15                | "credentialId"
16                | "deviceType"
17                | "backedUp"
18                | "createdAt"
19                | "updatedAt"
20        )
21}
22
23pub(super) fn field_order(name: &str) -> u8 {
24    match name {
25        "name" => 0,
26        "aaguid" => 1,
27        _ => 2,
28    }
29}
30
31pub(super) fn validate_fields(fields: &UserConfig) -> AuthResult<()> {
32    for (name, field) in fields.fields() {
33        let storage = resolve_field_name(field.field_name.as_deref(), name);
34        if matches!(name.as_str(), "name" | "aaguid") {
35            if !matches!(
36                field.field_type,
37                UserFieldType::String | UserFieldType::Enum(_) | UserFieldType::Json
38            ) || field.references.is_some()
39                || (native_name(storage) && storage != name)
40            {
41                return Err(AuthError::config(format!(
42                    "Passkey {name} requires a string, enum, or JSON declaration without a reference or a different native field"
43                )));
44            }
45        } else if native_name(name) || native_name(storage) {
46            return Err(AuthError::config(format!(
47                "Passkey additional field {name} cannot replace native field {storage}"
48            )));
49        }
50        for native in ["name", "aaguid"] {
51            let column = storage_name(fields, native);
52            if !matches!(name.as_str(), "name" | "aaguid") && (name == column || storage == column)
53            {
54                return Err(AuthError::config(format!(
55                    "Passkey field {name} conflicts with {native} storage column {column}"
56                )));
57            }
58        }
59    }
60    Ok(())
61}
62
63fn storage_name<'a>(fields: &'a UserConfig, name: &'a str) -> &'a str {
64    resolve_field_name(
65        fields
66            .fields()
67            .get(name)
68            .and_then(|field| field.field_name.as_deref()),
69        name,
70    )
71}
72
73pub(crate) struct PasskeyFieldPatch {
74    pub name: Option<SchemaValue<Option<String>>>,
75    pub aaguid: Option<SchemaValue<Option<String>>>,
76    pub additional_fields: FieldMap,
77}
78
79impl PasskeyFieldPatch {
80    pub(crate) fn apply(self, row: &mut Passkey) {
81        if let Some(name) = self.name {
82            row.name = name;
83        }
84        if let Some(aaguid) = self.aaguid {
85            row.aaguid = aaguid;
86        }
87        row.additional_fields.extend(self.additional_fields);
88    }
89}
90
91impl ModelFields {
92    pub(crate) async fn passkey_fields_for_storage(
93        &self,
94        name: SchemaValue<Option<String>>,
95        aaguid: SchemaValue<Option<String>>,
96        mut extras: FieldMap,
97        create: bool,
98        bind: impl Fn(&UserFieldConfig, Value) -> AuthResult<Value>,
99    ) -> AuthResult<PasskeyFieldPatch> {
100        let mut core = FieldMap::new();
101        for (name, value) in [("name", name), ("aaguid", aaguid)] {
102            let _ = extras.shift_remove(name);
103            if !value.is_undefined() {
104                let _ = core.insert(name.into(), value.into_field_value());
105            }
106        }
107        let config = self.fields(EntityRole::Passkey);
108        let mut fields = config
109            .organization_storage_fields_with_binding(core, extras, create, |_, field, value| {
110                bind(field, value)
111            })
112            .await?;
113        let name_column = storage_name(config, "name");
114        let aaguid_column = storage_name(config, "aaguid");
115        let name = optional_string(&mut fields, name_column);
116        let aaguid = if name_column == aaguid_column {
117            name.clone()
118        } else {
119            optional_string(&mut fields, aaguid_column)
120        };
121        Ok(PasskeyFieldPatch {
122            name,
123            aaguid,
124            additional_fields: fields,
125        })
126    }
127
128    /// Project declared Passkey fields while retaining credential, owner, and counter snapshots.
129    pub async fn project_passkeys(&self, rows: Vec<Passkey>) -> AuthResult<Vec<Passkey>> {
130        let fields = self.fields(EntityRole::Passkey);
131        let records = rows
132            .iter()
133            .map(|row| {
134                let mut storage = row.additional_fields.clone();
135                for (name, value) in [("name", &row.name), ("aaguid", &row.aaguid)] {
136                    if !value.is_undefined() {
137                        let _ =
138                            storage.insert(storage_name(fields, name).into(), value.field_value());
139                    }
140                }
141                AdapterRecord::new(FieldMap::new(), storage)
142            })
143            .collect();
144        self.project_passkey_records(
145            rows,
146            records,
147            crate::user_fields::FieldOutputCapabilities::json_only(true),
148            true,
149        )
150        .await
151    }
152
153    /// Project extracted adapter fields without exposing undeclared model columns.
154    pub async fn project_passkey_records(
155        &self,
156        mut rows: Vec<Passkey>,
157        records: Vec<AdapterRecord>,
158        capabilities: crate::user_fields::FieldOutputCapabilities,
159        supports_native_dates: bool,
160    ) -> AuthResult<Vec<Passkey>> {
161        let fields = self.fields(EntityRole::Passkey);
162        let output = fields
163            .project_adapter_records_with_capabilities(records, capabilities, supports_native_dates)
164            .await?;
165        for (row, mut output) in rows.iter_mut().zip(output) {
166            for (name, value) in [("name", &mut row.name), ("aaguid", &mut row.aaguid)] {
167                if fields.fields().contains_key(name) {
168                    *value = output
169                        .shift_remove(name)
170                        .map(SchemaValue::from_field)
171                        .unwrap_or_default();
172                }
173            }
174            row.additional_fields = output;
175        }
176        Ok(rows)
177    }
178}